11.HNSW(Hierarchical Navigable Small World)

0 阅读11分钟

有了度量方法,就可以逐条算查询向量和所有存储向量的相似度,排序取 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 的完整查询过程:节点如何逐层查找

image.png

HNSW 是一张分层图:

  • 每个节点是一条向量数据。
  • 两个节点之间的表示两个节点在向量空间中较接近。
  • 0 层包含全部节点;越高层节点越少、边越稀疏。

对查询向量 Q,HNSW 从最高层的入口节点开始查找。

第 2 层:节点最少,边最稀疏
第 1 层:节点更多,边更密
第 0 层:包含全部节点,边最密

在第 2 层,假设入口是节点 A。系统计算 AQ 的距离,然后检查 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 所在的完整查询过程

假设图共有第 210 层:

第 2 层:节点最少,边最稀疏
第 1 层:节点更多,边更密
第 0 层:包含全部节点,边最密

查询从最高层的入口节点开始。

第 2 层:粗定位
当前节点:入口节点 A
查询向量:Q

系统计算 AQ 的距离,并检查 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 = 200400
efSearch = 128

可以把它理解为建城市地图:

参数建议起点什么时候调大
M16结果常找不准,且内存够用
efConstruction200可以接受更长的建库时间,希望索引质量更好
efSearch64线上查询漏得多,想提高召回率

最常见的调参顺序是:

先固定 M=16、efConstruction=200
↓
只调 efSearch:64 → 128 → 256
↓
观察召回率是否提升、接口是否变慢
↓
仍不够,再考虑把 M 提到 32

因为 efSearch查询时参数,最适合线上灵活调节;而 MefConstruction建索引时参数,改了通常要重新建索引。


例如你的 RAG 需要返回 Top-5 文档:

Top-K = 5
efSearch = 64

意思是:HNSW 最终只返回 5 条,但搜索过程中愿意多考察大约 64 个候选。候选多一些,漏掉正确文档的概率会下降。

不要把 efSearch 设成 5;它通常应该明显大于 Top-K。常见起点:

Top-5  → efSearch 64
Top-10 → efSearch 64128

真正上线前,应该用你自己的真实问题集验证,而不是只相信参数经验:

准备 50~100 个真实问题
↓
人工标注每个问题“正确答案应来自哪些文档”
↓
分别测试 efSearch = 3264128256
↓
记录:召回率、P95 响应时间、内存占用
↓
选一个“召回够高、速度能接受”的点

例如:

efSearch召回率平均查询耗时
3288%15 ms
6494%24 ms
12897%45 ms
25697.5%90 ms

这时很多团队会选 64128:因为从 128 到 256,速度慢了一倍,召回却只多了 0.5%。

一句实战建议:

新手先用 M=16, efConstruction=200, efSearch=64;如果检索结果不稳,优先把 efSearch 调到 128,不要一开始把所有参数拉满。