本地应用里如果既要保存结构化数据又要做向量相似搜索,通常会想到把向量存入独立数据库,但这对一个桌面工具来说太重了。本文用一个图片检索项目说明如何通过SQLite与Annoy配合实现轻量级向量检索。

为什么选择Annoy而不是独立向量数据库
Annoy是Spotify开源的近似最近邻搜索库,底层使用多棵随机投影树把高维空间划分成不同的区域。构建索引时,Annoy会随机选择两个点确定超平面,递归地把数据点分到叶子节点。查询时沿着树结构快速下降到对应的叶子节点,只对该节点内的少量候选点计算精确距离,避免全量扫描。这种设计让查询时间从线性级别降低到接近对数级别,同时索引文件可以完整保存到磁盘。
和Faiss、Milvus这类方案相比,Faiss虽然性能更高但依赖较多,Milvus需要独立服务进程和额外的运维成本。Annoy的编译和使用都非常简单,Python包可以直接安装,也不需要GPU支持。把元数据放在SQLite里,把向量索引交给Annoy,两者通过记录主键关联,就能在单机环境里跑起来。对于百万级以内的数据规模,这种组合的查询延迟通常在几十毫秒,完全能满足图片检索、文档相似搜索等交互场景。
这类方案的核心思路是让SQLite负责精确过滤和关系管理,Annoy只负责快速召回候选。因为最终需要回到SQLite查询完整字段,所以Annoy索引中的item id必须与SQLite表的主键保持一致,否则关联会出错。
数据准备与SQLite表设计
先从数据层开始。假设我们已经用预训练模型提取了每张图片的embedding向量,维度是512。SQLite表需要存储图片路径、分类、创建时间等元数据,同时为后续关联留下一个整数主键。下面是建表语句。
CREATE TABLE images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_path TEXT NOT NULL,
category TEXT,
create_time TEXT
);
建表之后,我们准备好向量列表和对应的元数据列表。向量可以来自CLIP、ResNet等模型的输出,通常建议先做归一化,这样后续使用angular距离时效果更稳定。插入SQLite时不需要保存向量本身,只保存元数据和自增主键,向量单独交给Annoy处理。由于SQLite的INTEGER PRIMARY KEY会自增,我们可以先插入元数据获取id,再把同一个id分配给向量。
下面这段Python代码演示了如何插入一批图片记录,并将向量与id一一对应。
import sqlite3
import numpy as np
conn = sqlite3.connect('images.db')
cursor = conn.cursor()
cursor.execute('''CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_path TEXT NOT NULL,
category TEXT,
create_time TEXT
)''')
image_paths = ['a.jpg', 'b.jpg', 'c.jpg', 'd.jpg']
categories = ['风景', '人物', '风景', '建筑']
vectors = [np.random.rand(512).astype('float32') for _ in range(4)]
for path, category, vec in zip(image_paths, categories, vectors):
cursor.execute('INSERT INTO images (file_path, category, create_time) VALUES (?, ?, ?)',
(path, category, '2025-01-01'))
image_id = cursor.lastrowid
# 把 image_id 与 vec 对应起来,后面构建 Annoy 索引时使用
print(image_id, vec.shape)
conn.commit()
需要注意的是,上面的向量只是随机示例,真实项目里应该用模型生成。向量归一化可以通过sklearn.preprocessing.normalize或者手动除以L2范数完成。Annoy的angular距离内部会假设向量已经归一化,如果没归一化,建议使用euclidean距离。
构建Annoy索引并关联SQLite记录
接下来构建Annoy索引。Annoy需要指定向量维度和距离度量方式。图像或文本embedding通常使用angular距离,也就是余弦相似度的变体。如果向量没有归一化,也可以选择euclidean。索引对象创建后,通过add_item方法逐个加入,第一个参数是id,第二个参数是向量。
构建索引时还要指定树的数量,也就是n_trees参数。树的数量越多,查询精度越高,但构建时间越长,索引文件也更大。一般从10到50之间取值就能获得不错的召回率。构建完成后调用build方法,并保存到磁盘。
from annoy import AnnoyIndex
dim = 512
index = AnnoyIndex(dim, 'angular')
for image_id, vec in zip(range(1, 5), vectors):
index.add_item(image_id, vec)
index.build(20)
index.save('image_index.ann')
print('索引构建完成')
这里image_id的范围假设是1到4,实际应该使用上面插入SQLite时返回的lastrowid。保存后的image_index.ann文件就是完整的向量索引,不需要再依赖原始向量数据。SQLite数据库文件和Annoy索引文件可以一起打包分发,运行时分别加载即可。
Annoy索引一旦构建完成,不能像SQLite那样随意增删改。如果新增了图片,通常需要重新构建整个索引。对于批量导入场景,可以先收集所有数据再统一构建;对于频繁更新的应用,可以考虑按时间或分类拆分成多个索引,降低重建成本。
查询阶段:SQLite过滤与向量召回
查询流程可以分为两类。第一类是纯向量相似搜索,直接通过Annoy获取最近邻id列表,再回到SQLite查询元数据。第二类是先做结构化过滤,比如只查某个分类下的图片,再在过滤结果中做向量排序。因为Annoy不支持条件过滤,所以过滤必须放在SQLite层完成。
当过滤后的候选数量不大时,可以直接从SQLite读取这些候选对应的向量(如果向量另外存储),用numpy计算精确距离并排序,这样比Annoy的近似搜索更准确。如果候选数量很大,可以再维护一个子索引或者使用Annoy对候选集合进行二次索引。实际项目里,可以先估算过滤后的数据量,决定走精确计算还是近似索引。
下面示例展示了先按category过滤,然后通过Annoy获取与目标向量最相似的5个结果,再到SQLite取出完整信息。
target_vec = np.random.rand(512).astype('float32')
index = AnnoyIndex(512, 'angular')
index.load('image_index.ann')
category = '风景'
cursor.execute('SELECT id, file_path FROM images WHERE category = ?', (category,))
filtered_ids = [row[0] for row in cursor.fetchall()]
nearest_ids = index.get_nns_by_vector(target_vec, 5, search_k=-1, include_distances=False)
final_ids = [i for i in nearest_ids if i in set(filtered_ids)]
for image_id in final_ids:
cursor.execute('SELECT file_path, category, create_time FROM images WHERE id = ?', (image_id,))
print(cursor.fetchone())
这段代码中,search_k参数控制搜索时检查的节点数,-1表示使用默认值。include_distances设为False只返回id。因为Annoy返回的最近邻可能不在过滤后的集合里,所以需要用集合做交集,确保结果同时满足分类条件和向量相似度。如果交集为空,可以放宽过滤条件或增大返回数量。
更新与性能注意事项
Annoy索引的静态特性决定了它的更新成本较高。每次新增、删除或修改向量后,都需要重新构建索引。为了减轻这个负担,可以在业务上采用批量更新策略,例如每天重建一次,或者把数据按月份分片,每个月一个索引文件。SQLite的增删改不受影响,只需保证id稳定且不与旧索引冲突。
性能方面,树的数量n_trees直接权衡精度和速度。n_trees越大,查询时需要访问的叶子节点越多,精度越高,但查询延迟也会增加。对于512维向量,n_trees取20通常能在速度和召回率之间取得不错平衡。此外,加载Annoy索引非常快,几乎不耗时,所以可以在应用启动时加载,常驻内存。
最后要注意索引文件和数据库文件的一致性。如果SQLite数据库被备份或迁移,对应的Annoy索引文件也要一起复制。建议在应用启动时检查两边记录数量是否一致,如果不一致则提示重建索引。这样一套SQLite加Annoy的组合,已经能在很多本地项目中替代重量级向量数据库,既保持轻量又满足检索需求。

