向量数据库的底层秘密:ANN、HNSW、LSH、PQ 到底在解决什么问题?
这两年做 AI 应用,你会发现一个尴尬的事实:模型能力上来了,但你的知识库、记忆体、检索模块还停留在“关键词匹配”的阶段。无论是给大模型接企业知识库,还是做 AI 智能体的长期记忆,最终都绕不开一个问题:怎么在海量向量数据里,快速找到“最相似”的那一批?
向量数据库就是干这个的。它的核心不是存数据,而是用一套高效的索引结构和检索算法,把“暴力遍历所有向量”这件事变得尽可能快。你去看市面上主流的向量数据库,Milvus、Qdrant、Weaviate、Redis 的向量模块,底层说到底都是在对 ANN(Approximate Nearest Neighbor,近似最近邻)算法做工程化封装。
这篇文章,我尽量用大白话加实操视角,把这套东西的核心讲清楚。包括最常用的 HNSW、经典的 LSH、工业界标配的 PQ(乘积量化),以及最容易踩坑的相似度度量问题。看完之后,你应该能搞清楚:为什么向量数据库这么快、HNSW 到底好在哪里、什么场景该用哪个算法,以及当你往项目里引入向量检索时,哪些参数直接决定系统能不能跑起来。
这篇文章适合刚接触向量检索的开发者,也适合已经在用 Milvus 之类产品、但对内部机制一直“知其然不知其所以然”的朋友。
1. 向量数据库到底是个什么东西
先把你对“数据库”的固有印象放一边。
传统关系型数据库,存的是结构化数据,比如用户表、订单表,查找靠的是 B+ 树索引或者哈希索引。传统全文搜索引擎(比如 Elasticsearch),存的是文档,查找靠的是倒排索引。它们的共同点是:匹配逻辑是基于“精确值”或“关键词”的。
向量数据库不一样。它存的是向量——也就是一组浮点数。比如你把一篇文章用 OpenAI 的 embedding 模型转成一个 1536 维的向量,或者用 BGE 模型转成一个 1024 维的向量,这个向量本质上就是这段文本在高维空间里的“坐标”。
问题来了:怎么从这个坐标系统里找到“语义最接近”的另一个坐标?你不能像 SQL 那样写WHERE vector = ?,因为语义相似不是精确匹配,而且向量也不可能完全相等。真正的做法是算距离:两个向量在空间里距离越近,语义上就越接近。
所以向量数据库的检索逻辑,本质上是一个 KNN(K 近邻)问题:给定一个查询向量,在 N 个向量里,找出距离最近的 K 个。最笨的办法是全表扫描一一比较,N 如果是一百万,每次查询都要算一百万次距离,虽然现代 CPU 算距离不慢,但问题是你的业务不可能只承载一次查询,QPS 一上来就崩。
于是有了 ANN,也就是近似最近邻。核心思想很简单:不追求百分之百找到真正的最近邻,而是用“足够接近”的结果,换取数量级上的性能提升。这就像你在一个十万人城市找一个人,不挨家挨户敲门,而是先通过“同姓”“同小区”“同年龄层”这些特征快速缩小范围。ANN 的“缩小范围”策略,就是各个算法百花齐放的地方。
1.1 一次完整检索流程:召回、粗排、精排
实际工程里,向量召回通常不是单独用的。以企业知识库问答为例,典型流程是:
- 用户提一个问题,比如“我们的报销流程是什么?”
- 把问题文本通过 embedding 模型转成查询向量 q。
- 在向量数据库里用 ANN 索引召回 top-100 候选片段(注意,这里已经近似了,不是全量比对)。
- 有可能再结合关键词检索(BM25)做混合召回,把语义和字面两个维度的结果合并。
- 最后有一个重排(rerank)环节,用更精细的模型(比如 cross-encoder)在这 100 条候选里精排,选出 top-5 喂给大模型。
认识到这个流程很重要,因为它决定了你对向量数据库的“要求”。在实际项目中,向量数据库保证的是召回阶段的“查得全”和“速度快”,它会故意召回多一些候选(比如 100 条),把精度问题丢给后面的精排去解决。这也是为什么很多向量数据库的默认 topK 设置都不会太小。
1.2 相似度度量怎么选:内积、余弦、欧氏距离
很多人上手向量数据库,第一个忽略的问题就是距离度量。你以为这不重要,实际上它直接影响你能不能召回正确结果。
- 欧氏距离(L2):计算的是空间中的直线距离,数值越小越相似。它适合向量经过归一化处理的场景,对向量绝对位置敏感。
- 内积(IP):数值越大越相似。适合向量没归一化、长度本身携带信息量的场景,比如某些推荐场景中向量模长代表热度。
- 余弦相似度:衡量的是向量方向的一致性,范围是 -1 到 1,值越大越相似。它只关心方向,不关心模长,是文本语义场景用最多的。
关键坑在于:很多向量数据库的索引结构是为特定的距离度量设计的。比如 HNSW 在实现时,距离函数的选择会影响图的构建过程。你在 Milvus 里如果在创建索引时选了 L2,在查询时又指定用 IP 去算,返回的结果就是错的。我自己就见过同事在 Milvus 里建 collection 时用了 L2,然后查询时用 cosine 相似度去过滤分数小于 0.8 的结果,直接导致召回为空。
文本场景,我给你的建议是:embedding 模型如果没做归一化,优先用余弦;如果你的 embedding 本身已经 normalize 过了,那用内积其实等价于余弦,而且计算更快。欧氏距离在向量值本身有意义、需要区分绝对大小的时候更合适。
2. 三类最主流的 ANN 算法,思路完全不同
2.1 暴力搜索:所有方案的基线
在讲聪明算法之前,先提一下暴力搜索(Flat 索引)。所谓暴力搜索,就是不管索引结构,直接在全部向量上遍历计算距离,然后取最小。它是所有 ANN 算法的精度上限和速度下限。
- 优点:100% 召回率,也就是不丢结果。
- 缺点:数据量稍大就扛不住。
在 Milvus 里,对应的是 FLAT 索引类型。适合数据量很小(几万条以内)、对精度要求严格、或者作为 Baseline 测试的场景。实际项目里,我一般不推荐直接用 FLAT,除非你的数据量真的小到无所谓。
2.2 HNSW:当前综合体验最好的图算法
HNSW(Hierarchical Navigable Small World,分层可导航小世界图)的思想来源,可以追溯到“六度分隔”理论,也就是小世界网络。在实际图结构里,每个节点连接其邻居,只要连接关系足够合理,从任何一个点出发,几步之内就能到达目标附近。
HNSW 的聪明之处在于“多层”。
想象一个只有一层的图网络。如果这个图里每个节点都只连接最近的两个邻居,那么从起点到目标,路径可能会很长,因为路上只能一家一家地“跳”。但如果把图的尺度拉开——上层是很稀疏的“远距离通道”,下层是很密集的“短距离连接”,就可以做到:在高层快速接近目标区域,再到低层精细定位。
图 1(示意):
- 第 0 层:包含所有向量,连接细密,负责精确定位。
- 第 1 层:包含约 1/
M_l的节点,连接更稀疏,负责快速跳过无关区域。 - 第 2 层:更稀疏,属于“高速公路”。
这就是 HNSW 的“分层”含义——它结构上非常像跳表。构建时,每个节点会以指数衰减概率分配一个层数;搜索时,从顶层开始,在当前层的邻居里贪心找“距离查询向量最近的邻居”,然后下沉到下一层重复这个过程,直到第 0 层。
几个关键参数,我直接给经验值:
| 参数 | 含义 | 建议值 |
|---|---|---|
M | 每个节点的最大连接数,控制图的密度 | 16~32,常见 16 |
efConstruction | 构建图时的动态候选集大小,越大图质量越高但构建越慢 | 100~200,常用 200 |
efSearch | 查询时动态候选集大小,越大召回率越高但延迟越高 | 查询时动态调整,常用 64~256 |
我踩过的一个很深坑:efSearch设太小。项目里用了 HNSW,结果召回率一直不达标,折腾半天,最后发现只是查询时 efSearch 设成了 10。其实 HNSW 的设计里,M决定索引构建质量,efSearch决定查询质量。召回率不够,优先调大efSearch,而不是去重建索引。
HNSW 的缺点也很明确:索引全在内存里,内存占用偏高;增量插入性能差一些。这在数据千万级以上时尤其明显,内存翻好几倍。另外如果你的数据流经常有大量实时删除和更新,HNSW 也显得笨重——它删除节点后图结构不会自动优化,需要周期性重建。
2.3 LSH:用哈希把“近邻”分到同一个桶
LSH(Local Sensitive Hashing,局部敏感哈希)的思路和 HNSW 完全不同,它是给向量做“哈希分桶”,但是呢,这个哈希函数是精心设计的,满足一个特性:两个越相似的向量,哈希到同一个桶的概率越大。
这和普通哈希正好相反——普通哈希要求“哪怕只差一位输入,输出也天差地别”,而 LSH 要求“越相似,哈希值越接近”。
以文本向量为例,最朴素的 LSH 实现是随机投影法(Random Projection)。它的哈希函数是随机生成一组超平面,然后判断向量在超平面的哪一侧。多个超平面组合成一个哈希值。相近的向量,大概率在每个平面上的相对位置一致,因此哈希值一致,被分到同一个桶。
查询时,只需要计算查询向量的哈希值,然后去对应的桶里做精确匹配就行。复杂度从 O(N) 降到了 O(桶内数量),大幅提高速度。
但 LSH 的问题也很明显,实际项目里我很少直接用它做主索引:
- 召回质量不稳定。哈希分桶是概率性的,而且桶与桶之间有“边界效应”——两个很相似的向量刚好落在桶边界两侧,就可能被分到不同桶。理论上可以通过多表(多个 LSH 函数并行)缓解,但代价是存储量乘以表数。
- 对高维数据不友好。维度越高,随机投影越难保持局部性,需要更多的哈希表才能维持召回率,内存直接起飞。
现在的向量数据库里,LSH 更多是作为某些特定场景的补充方案,比如均匀分布的低维向量,或者数据分布比较规律的情况。主流产品的默认索引里,很少是 LSH。
2.4 PQ:压缩向量,用查表代替计算
PQ(Product Quantization,乘积量化)的思路和前面两个都不一样,它不做索引结构,而是做“向量压缩 + 距离查表”。它的核心思想是,把高维向量拆成若干个子向量,对每个子向量空间单独做聚类(比如 KMeans),然后用聚类中心的编号来“编码”原始向量。
举个例子。一个 128 维的向量,拆成 8 段,每段 16 维。对每一段做 256 个聚类中心,也就是每一段可以用 8 个 bit 编码(2^8=256)。这样一来,一个 128 维向量(32 个 float,128 字节)可以被压缩成 8 个 byte。查询时的距离计算也不用重建完整向量,而是用“查表”的方式:把查询向量对应段与聚类中心的距离预先算好,存成表格,再根据目标向量的编码,从表里查出近似距离。
实际工程中,直接用 PQ 的情况少,更多是配合倒排索引使用,这就叫 IVF-PQ(Inverted File with Product Quantization)。流程是:
- 先对整个数据集做一次粗聚类(比如 KMeans,聚类数设为 nlist)。
- 每个向量归属一个聚类中心,建立倒排列表。
- 查询时,先找到查询向量最近的几个聚类中心(nprobe 参数控制搜几个簇),只在这几个簇内部用 PQ 做精确重排。
这在 Milvus、Faiss 里是最常见的工业级方案。它最大的优点是内存占用极小,适合亿级以上的大规模场景。缺点则是精度有损,尤其当编码数(m)太小的时候,召回质量会明显下降。
2.5 三种算法横向对比
为了让你更直观地选择,我做了一张表:
| 算法 | 核心思路 | 内存占用 | 查询速度 | 召回精度 | 适用场景 |
|---|---|---|---|---|---|
| HNSW | 多层图,近似最短路径 | 高 | 极快 | 高 | 千万级以下,对召回率要求高的在线场景 |
| LSH | 哈希分桶 | 中 | 快(依赖桶内数据量) | 中低 | 极大规模且对精度要求不苛刻,或低维数据 |
| IVF-PQ | 倒排 + 向量压缩 | 低 | 快(依赖 nprobe) | 中 | 亿级以上,内存有限,允许一定误差 |
这不是绝对的,因为实际工程里还有各种魔改版本,比如 Milvus 的HNSW_SQ、HNSW_PQ等混合索引,目的就是结合 HNSW 的精度和 PQ 的内存优势。但作为入门,先把这三个核心思路吃透,后面看什么都顺。
3. 动手实战:用 hnswlib 搭一个可运行的向量检索 Demo
原理讲再多,不如跑一次代码。
这里我用一个非常轻量级的 Python 库——hnswlib,它是 HNSW 算法的 C++ 封装,接口简单,性能很好。同时用numpy生成模拟的 embedding 数据来演示整个构建和查询过程。按下面的步骤一步步来。
3.1 准备环境与数据
pip install hnswlib numpy生成 10 万条 128 维的模拟向量数据:
import numpy as np import hnswlib dim = 128 num_elements = 100000 # 生成随机向量,模拟 embedding 数据 data = np.random.random((num_elements, dim)).astype(np.float32) # 生成 100 条查询向量 queries = np.random.random((100, dim)).astype(np.float32)3.2 构建索引
# 初始化索引 # space 可选 'l2'(欧氏距离)、'ip'(内积)、'cosine'(余弦) p = hnswlib.Index(space='cosine', dim=dim) # 初始化索引结构 # max_elements 是索引最多容纳的元素数量 # ef_construction 是上面讲过的构建参数 p.init_index(max_elements=num_elements, ef_construction=200, M=16) # 添加数据 p.add_items(data) # 设置查询时的 efSearch 参数 p.set_ef(64)每一步在干什么,说明一下:
space='cosine':告诉索引用余弦距离来计算向量之间的远近。如果换成 L2,同样的 HNSW 图,边的“距离”衡量方式会完全不同。M=16:每个节点最多连 16 个邻居。M 越大,图越密,查询越慢,召回率越高;M 越小,图越稀疏,查询越快但容易丢邻居。ef_construction=200:插入每个节点时,为了找到合适的邻居,会先找 200 个候选,然后从中择优。这个值越大,构建越慢,但图质量越高。
3.3 查询与评估召回率
# 查询前 10 个最近邻 labels, distances = p.knn_query(queries, k=10) # 输出第一条查询的结果 print("第一个查询向量的 top-10 结果索引:", labels[0]) print("对应的距离(余弦距离,越小越相似):", distances[0])要直观感受 HNSW 的“近似”到底损失了多少,可以做一个召回率测试:先构建一个 FLAT 暴力索引,查询同一个 top-10 集合,然后计算 HNSW 的 top-10 与暴力搜索 top-10 的重合度。
# 用暴力搜索作为 ground truth(精确结果) flat_index = hnswlib.Index(space='cosine', dim=dim) flat_index.init_index(max_elements=num_elements, ef_construction=num_elements, M=16) flat_index.add_items(data) # 把 ef 设到最大的等效暴力搜索 flat_index.set_ef(num_elements) flat_labels, _ = flat_index.knn_query(queries, k=10) # 计算召回率 hit = 0 total = 0 for i in range(len(queries)): hit += len(set(labels[i]) & set(flat_labels[i])) total += 10 recall = hit / total print(f"HNSW 召回率:{recall:.2%}")正常跑下来,召回率一般在 95% 以上。如果调低ef或者M,速度上去了,但召回率会掉下来。这就是精度与性能的权衡,这个代码完全可以拿来做实验,直观感受参数对召回率的影响。
3.4 换个方案试试:用 Faiss 实现 IVF-PQ
再看一下工业级方案 Faiss 怎么实现 IVF-PQ,这样你对工程里的主流方案有直观感受:
pip install faiss-cpuimport faiss import numpy as np dim = 128 num_elements = 100000 data = np.random.random((num_elements, dim)).astype(np.float32) # 使用 IVF-PQ:1024 个聚类中心,每个向量编码成 16 字节 nlist = 1024 m = 16 quantizer = faiss.IndexFlatIP(dim) index = faiss.IndexIVFPQ(quantizer, dim, nlist, m, 8) index.train(data) index.add(data) # 查询时搜索最近邻的 8 个聚类中心 index.nprobe = 8 D, I = index.search(data[:5], k=10) print(I)这里的逻辑是:nlist=1024意味着把数据聚成 1024 个簇,m=16意味着把一个 128 维向量切分为 16 段,每段 8 维,每段用 256 个聚类中心编码。如果数据量大,这个索引的内存占用大概只有原始向量的 1/8 到 1/16。
4. 工程落地时绕不开的选型与调优问题
原理通了,代码跑了,下一步就是在真实项目里选型和调优。这一部分我说的都是实战经验,不一定写在官方文档里。
4.1 怎么选:HNSW 还是 IVF-PQ 还是混合
一个核心的决策依据是数据量和内存预算。
如果数据量在百万级以内,内存不紧张,HNSW 是综合最优解,实现简单、召回率高、查询快。
如果数据量到了千万级以上,或者索引需要常驻内存但机器内存有限,IVF-PQ 或 HNSW-PQ 这类“有损压缩”方案就该上了。代价是精度损失和召回率波动,需要接受。
如果你的业务是“导入大量历史数据 + 增量实时写入”混合模式,需要注意:HNSW 的增量插入会导致图结构逐渐劣化,官方推荐定期重建索引。所以这种场景下,可以考虑对热数据用 HNSW,冷数据用 IVF-PQ,分层处理。
4.2 参数调到什么程度算“好”
各参数经验值:
| 参数名 | 适用算法 | 经验区间 | 调参逻辑 |
|---|---|---|---|
efConstruction | HNSW | 100~300 | 建索引时用,越大图质量越高,构建越慢 |
M | HNSW | 8~48 | 空间换时间,M 翻倍,内存约增 20% |
efSearch | HNSW | 64~512 | 查询时调,越大召回越高,延迟越高 |
nlist | IVF | 数据量开根号左右 | 比如 1000 万数据,nlist ≈ 10000 |
nprobe | IVF-PQ | 8~256 | 每增大一倍,速度约为原来的一半,但召回率提升递减 |
m | PQ | 1/8 向量维度 | 编码越小,压缩越大,召回越低 |
调参宗旨是:先保证召回率达标,再看性能要不要优化。很多人上来就把 efSearch 调到 16 追求极致性能,结果召回率掉到 80%,业务直接不可用。
4.3 Milvus、Redis 等产品选型参考
现在的向量数据库产品非常多,但底层索引大多基于 Faiss、HNSWLib 这类组件。区别主要在工程能力上。
| 产品 | 底层索引 | 特点 |
|---|---|---|
| Milvus | Faiss、HNSWLib、DiskANN | 功能全,支持混合检索和标量过滤,适合大规模生产 |
| Qdrant | 自研 HNSW 实现 | 轻量,Rust 编写,RESTful API 友好 |
| Weaviate | HNSW 为主 | 自带模块化插件,和 GraphQL 深度整合 |
| Redis Stack | 模块化实现 HNSW | 适合轻量级、已有 Redis 技术栈的场景 |
| pgvector | IVFFlat、HNSW | 适合 PostgreSQL 用户,不需要额外引入数据库 |
如果你是做 AI 智能体的知识库,团队已有 PostgreSQL,数据量不大,pgvector 是最快上手的方案。如果数据量大、检索要求高、且有独立的向量检索服务需求,Milvus 是更靠谱的选择。
5. 常见问题与排查技巧实录
最后把这几年被问得最多、我自己也踩过的坑整理成一张速查表,建议收藏。
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 召回结果明显不相关 | 距离度量选错(如该用余弦却用了 L2) | 重建索引,统一 space 参数 |
| 召回率一直上不去 | efSearch 太小或 nprobe 太小 | 调大 efSearch/nprobe,观察召回变化 |
| 索引构建极慢 | efConstruction 太大或 M 太大 | 适当降低 efConstruction 到 100,M 到 16 |
| 内存占用爆炸 | HNSW 的 M 偏大 + 数据量超预期 | 用 IVF-PQ 或者加节点扩容 |
| 删除数据后检索变慢 | HNSW 图结构劣化 | 定期重建索引,或者业务层做“软删除” |
| 为什么加了标量过滤后变慢 | 向量索引无法直接做多条件过滤 | Milvus 使用标量倒排索引,或调整过滤比例后选择底层实现 |
| 更新向量后检索结果不对 | 旧向量未被真正删除,图里存在“幽灵”节点 | 检查产品删除语义,必要时触发索引重建 |
还有一个很重要的独门经验是:在生产环境上线前,一定要做一个“召回率回归测试集”。用一批已知正确答案的 query,把向量数据库的召回率作为监控指标。这个测试集可以在改参数、升级版本、换向量模型之后自动跑一遍,防止“怎么变差了都没发现”。
我从去年开始在多个项目里强制要求加这个回归测试。一个真实案例是,有次同事升级了 embedding 模型,旧向量没重新生成,结果线上检索结果质量大幅下降,但系统本身没报错,要不是回归测试根本发现不了。
写到这里,回头看这个领域,你会发现,向量数据库从来不是“新技术”,它更像是把老算法(KNN、聚类、哈希、图论)在大规模数据和新场景下重新做得更极致。HNSW、LSH、PQ 各有各的适用面,没有银弹,只有理解和权衡。
我个人在实际项目中的体感是:如果只是做个 Demo 或者数据量很小,别折腾复杂索引,FLAT 都够用。一旦数据量跨过百万级,优先上一个经过验证的索引方案(HNSW 优先),并从一开始就把召回率监控、参数调优流程搭好。这套东西,越早做,后面越省心。
最后分享一个小技巧:当你不确定某个索引的参数怎么配,先用数据集的 1/10 做一轮小规模测试,观察召回率和延迟的曲线,再决定全量参数配置。磨刀不误砍柴工,这个习惯能帮你省掉大量线上调试的时间。