有了度量方法,就可以逐条算查询向量和所有存储向量的相似度,排序取 Top-K。这个问题的学名叫 KNN(K-Nearest Neighbors,K 最近邻) 。解法分两条路:
- 精确 KNN: 逐条遍历所有向量,逐一算相似度,排序取 Top-K。100% 精确,但复杂度是 O (N×D)——N 条向量、每条 D 维。万级数据还行,百万级开始就扛不住了,更别提千万级。
- 近似 KNN(ANN): 不逐条算,用索引结构快速锁定候选区域,只对少数候选向量做精确计算。牺牲一点召回精度,换数量级的性能提升。主流实现有 HNSW、IVF、PQ、LSH 等。
1. 暴力搜索为什么不行
精确 KNN 就是全量遍历 ——N 条向量,每条 D 维,逐一算相似度。百万级数据,一次查询一百万次 1536 维向量运算,脑子过一下就知道不行。
打个 MySQL 的比方:就像 SELECT ... ORDER BY score LIMIT 10 没建索引 —— 每次查询全表扫描,数据少还能忍,百万行起步就崩了。ANN 则像给向量建了索引,查询走索引快速定位候选行,只对命中的少量行做精确计算,跟 MySQL 走 B+Tree 只扫少数数据页是一个道理。
所以实际都用 ANN 近似路线 —— 用可控的召回损失,换数量级的查询性能提升。这个思路类似布隆过滤器:用极小的误判概率换极致的空间和速度,在工程上是笔很划算的买卖。 HNSW 是 ANN 里最主流的算法,但不是唯一的。除了 HNSW,还有 IVF(K-Means 聚类 + 倒排索引)、PQ(向量压缩)、LSH(局部敏感哈希)等方案。选型简单说就是:百万级以内用 HNSW,千万级用 IVF,亿级考虑 DiskANN 或分片。
HNSW 之所以最流行,是因为它在精度和速度之间取得了最好的平衡,而且实现成熟、生态完善。ES、Faiss、Milvus 都默认支持。
2.HNSW 三个参数:用节点、边和分层理解
先记住这四句话
M = 每个节点最多有几条路
efConstruction = 修路时,勘察多少个邻居再决定连哪几条路
efSearch = 查路时,愿意试探多少个候选节点
Top-K = 最后带走几个结果
1. HNSW 的完整查询过程:节点如何逐层查找
HNSW 是一张分层图:
- 每个节点是一条向量数据。
- 两个节点之间的边表示两个节点在向量空间中较接近。
- 第
0层包含全部节点;越高层节点越少、边越稀疏。
对查询向量 Q,HNSW 从最高层的入口节点开始查找。
第 2 层:节点最少,边最稀疏
第 1 层:节点更多,边更密
第 0 层:包含全部节点,边最密
在第 2 层,假设入口是节点 A。系统计算 A 与 Q 的距离,然后检查 A 在本层通过边连接的邻居节点。
若 B 比 A 更接近 Q:A → B
若 C 比 B 更接近 Q:B → C
只要当前层有邻居比当前节点更接近 Q,就继续沿边移动。直到当前节点的所有本层邻居都不比它更接近 Q,本层停止。
第 2 层停止在 C
系统从**同一节点 C**下降到第 1 层,而不是从任意节点重新开始:
第 2 层:停止在 C
↓
第 1 层:从 C 开始,重复检查邻居、沿边移动的过程
假设第 1 层停止在 E,再从 E 下降到第 0 层。第 0 层不只保留一个当前节点,而是会维护一批候选节点,探索这些节点的邻居,最后从候选中返回距离 Q 最近的 Top-K 节点。
第 2 层:A → B → C,停止
↓
第 1 层:C → D → E,停止
↓
第 0 层:从 E 开始,维护并扩展候选节点集合
↓
返回距离 Q 最近的 Top-K 节点
efSearch 控制的就是第 0 层候选节点集合的搜索规模。
2. efConstruction 在建图过程中是什么,以及它如何挑选邻居
HNSW 是一张分层图:
- 每个节点是一条向量数据。
- 两个节点之间的边表示它们在向量空间中较接近。
- 第
0层包含全部节点;越高层节点越少、边越稀疏。
当新节点 X 加入 HNSW 时,系统会随机为 X 分配一个最高层级。X 会出现在第 0 层,以及从第 1 层到该最高层级的若干层中。
建图时,系统先从现有图的最高入口节点开始,在高于 X 最高层的各层中沿边贪心移动,找到靠近 X 的区域;再在 X 要加入的每一层中,寻找 X 的邻居并建立边。
efConstruction 表示:
建图时,为了给新节点挑选邻居,允许搜索过程保留和考察多少候选节点。
例如:
M = 16
efConstruction = 200
可以理解为:
新节点 X 加入某一层
→ 从当前层的入口节点开始,沿边探索候选节点
→ 保留和比较的候选节点规模最多约为 200
→ 计算 X 到候选节点的距离
→ 从候选节点中挑选合适的少量节点
→ 与它们建立边
efConstruction 小时,X 看到的候选节点少,建图较快,但可能没有找到质量足够好的邻居;图的导航能力可能较差。
efConstruction 大时,X 会在更多候选中选择邻居,建图较慢,但节点之间的边通常更合理,后续查询的召回率通常更好。
挑选邻居时,通常会优先选择距离 X 较近的候选节点;同时还会进行连接多样性筛选,避免所有边都集中指向同一局部节点簇。这样 X 的邻居边既保持接近,也覆盖多个可继续导航的方向。
它只影响建索引阶段。索引建好以后,修改它通常需要重新建图。
3. efConstruction = 200 是否代表可以连 200 条边
不是。
efConstruction = 200
表示“挑选邻居时考察约多少候选节点”,而不是“最终连接多少条边”。
最终每个节点可以保留多少邻居边,主要由 M 决定:
M = 16
efConstruction = 200
对应的过程是:
新节点 X
→ 搜索并比较约 200 个候选节点
→ 从候选中选择合适的节点
→ 最终通常最多保留约 16 条邻居边
实际选择时,不一定只取“距离最近的前 M 个节点”。实现通常还会避免所有边都指向同一小片局部区域,保留一定的连接多样性,以提高图的可导航性。
不同向量库实现会有细节差异:例如第 0 层允许的连接数有时会高于 M,可能是 2 × M。但核心不变:
M = 最终保留多少邻居边
efConstruction = 为选出这些边,建图时考察多少候选节点
4. efSearch 是什么
efSearch 是查询阶段的参数。
用户问题会先变成查询向量 Q。查询过程只涉及查询向量 Q、节点、节点之间的边,以及 HNSW 的不同层。
efSearch 所在的完整查询过程
假设图共有第 2、1、0 层:
第 2 层:节点最少,边最稀疏
第 1 层:节点更多,边更密
第 0 层:包含全部节点,边最密
查询从最高层的入口节点开始。
第 2 层:粗定位
当前节点:入口节点 A
查询向量:Q
系统计算 A 与 Q 的距离,并检查 A 在第 2 层通过边连接的邻居节点。
如果邻居节点 B 比 A 更接近 Q:
A → B
如果 B 的某个邻居 C 又比 B 更接近 Q:
B → C
只要当前层存在比当前位置更接近 Q 的邻居节点,就继续沿边移动。直到当前节点在这一层的所有邻居都不比它更接近 Q:
第 2 层停止在节点 C
这表示:在稀疏的第 2 层中,C 是当前搜索路径上最靠近 Q 的区域入口。
第 1 层:缩小范围
系统不从第 1 层的任意节点重新开始,而是从同一节点 C 的第 1 层位置开始:
第 2 层:停止在 C
↓
第 1 层:从 C 开始
然后重复相同规则:检查 C 的第 1 层邻居;若某个邻居更接近 Q,就沿边移动;直到这一层也没有更接近 Q 的邻居。
C → D → E
第 1 层停止在 E
第 0 层:精细搜索
再从节点 E 下降到第 0 层:
第 1 层:停止在 E
↓
第 0 层:从 E 开始
第 0 层节点和边最多。此时系统不只保留单一当前节点,而会维护一批候选节点:
候选节点集合:E、F、G、H、...
系统不断从候选节点中取出更接近 Q 的节点,检查该节点的邻居,并把值得继续检查的邻居加入候选集合;同时淘汰距离 Q 较远的候选节点。
efSearch 控制的就是第 0 层这批候选节点允许保留和探索的规模。
efSearch 表示:
查询向量
Q在搜索过程中,允许保留和探索多少候选节点。
例如:
Top-K = 5
efSearch = 128
表示:
查询向量 Q 从最高层逐层下降到第 0 层
→ 在第 0 层保留、比较约 128 个候选节点
→ 从候选中选出距离 Q 最近的 5 个节点
→ 返回 Top-5
efSearch 小:
探索节点少
→ 查询更快
→ 更可能错过真正接近 Q 的节点
efSearch 大:
探索节点多
→ 查询更慢
→ 更不容易遗漏接近 Q 的节点,召回率通常更高
它影响查询阶段,通常可以在线调整,不需要重建索引。
四个相关参数的区别
M = 每个节点最多有几条路
efConstruction = 修路时,勘察多少个邻居再决定连哪几条路
efSearch = 查路时,愿意试探多少个候选节点
Top-K = 最后带走几个结果
最常见的关系是:
efConstruction > M
efSearch > Top-K
例如可作为起点:
M = 16
efConstruction = 200
efSearch = 64
Top-K = 5
HNSW 开发中应该怎么调?
对大多数 RAG 项目,建议先从这组开始:
M = 16
efConstruction = 200
efSearch = 64
如果资料很重要、宁可慢一点也不要漏,可以先试:
M = 32
efConstruction = 200 或 400
efSearch = 128
可以把它理解为建城市地图:
| 参数 | 建议起点 | 什么时候调大 |
|---|---|---|
M | 16 | 结果常找不准,且内存够用 |
efConstruction | 200 | 可以接受更长的建库时间,希望索引质量更好 |
efSearch | 64 | 线上查询漏得多,想提高召回率 |
最常见的调参顺序是:
先固定 M=16、efConstruction=200
↓
只调 efSearch:64 → 128 → 256
↓
观察召回率是否提升、接口是否变慢
↓
仍不够,再考虑把 M 提到 32
因为 efSearch 是查询时参数,最适合线上灵活调节;而 M 和 efConstruction 是建索引时参数,改了通常要重新建索引。
例如你的 RAG 需要返回 Top-5 文档:
Top-K = 5
efSearch = 64
意思是:HNSW 最终只返回 5 条,但搜索过程中愿意多考察大约 64 个候选。候选多一些,漏掉正确文档的概率会下降。
不要把 efSearch 设成 5;它通常应该明显大于 Top-K。常见起点:
Top-5 → efSearch 64
Top-10 → efSearch 64 或 128
真正上线前,应该用你自己的真实问题集验证,而不是只相信参数经验:
准备 50~100 个真实问题
↓
人工标注每个问题“正确答案应来自哪些文档”
↓
分别测试 efSearch = 32、64、128、256
↓
记录:召回率、P95 响应时间、内存占用
↓
选一个“召回够高、速度能接受”的点
例如:
| efSearch | 召回率 | 平均查询耗时 |
|---|---|---|
| 32 | 88% | 15 ms |
| 64 | 94% | 24 ms |
| 128 | 97% | 45 ms |
| 256 | 97.5% | 90 ms |
这时很多团队会选 64 或 128:因为从 128 到 256,速度慢了一倍,召回却只多了 0.5%。
一句实战建议:
新手先用
M=16, efConstruction=200, efSearch=64;如果检索结果不稳,优先把efSearch调到128,不要一开始把所有参数拉满。