实现 HNSW 踩过的六个坑:从 0% Recall 到 98%+ 的 Debug 实录
在我的向量数据库项目 vex 中实现 HNSW 算法(AI 辅助生成初版代码,后续自己做 debug 和优化),第一次跑 Recall 测试——全部 0%。不是 50%、不是 30%,是 0。HNSW 返回的结果和 BruteForce 完全不重叠。本文记录了从 0% recall 一路 debug 到 98%+ 的完整过程,以及对照 HNSW 论文逐一修复六个关键 Bug 的技术细节。
一、HNSW 论文核心思想速览
开始 debug 之前,先把 HNSW 论文(Malkov & Yashunin, 2018)的核心结构搞清楚,后面每个 Bug 都能对应到论文的哪一条。
1.1 分层跳表 + 近邻图
HNSW 的核心是一个多层图。每一层都是一个近邻图,上层稀疏、下层稠密。搜索时从最高层入口点开始贪心下降,每层找到当前最近邻后跳到下一层,直到 Layer 0 精确搜索。
| |
直觉:就像地图导航——先在省级高速跳到目标城市附近,再切到市级公路,最后走街道到达目的地。
1.2 论文中的五个关键参数
| 参数 | 含义 | 论文建议 |
|---|---|---|
| $M$ | 每个节点在非 0 层的最大邻居数 | 12-48 |
| $M_0$ | Layer 0 的最大邻居数,$M_0 = 2M$ | 论文 §4 明确要求 |
| $ef_{Construction}$ | 构建时的候选集大小 | 100-400 |
| $ef$ | 搜索时的候选集大小(beam width) | ≥ k,通常 100-800 |
| $m_L$ | 层级分配因子,$m_L = 1/\ln(M)$ | 自动计算 |
1.3 论文中的两个核心堆
论文 Algorithm 2(搜索)明确规定了两个堆的语义:
| |
这一段极其重要,后面会看到,搞反这两个堆的语义就是 0% recall 的直接根因。
1.4 Algorithm 4:SelectNeighbors 的多样性启发式
论文最被忽视的贡献之一。选择邻居时不是简单取最近的 M 个,而是要考虑空间多样性:
| |
直觉:假设你在选择几个通讯基站的位置。最优策略不是全部选在信号最强的市中心,而是要分散到不同方向,保证每个区域都能被覆盖。
二、第一个惊雷:Recall 全线 0%
2.1 测试框架
我先写了一个 Recall@K 测试,思路很简单:同一批向量分别插入 HNSW 和 BruteForce,对相同查询取 top-k,计算两者结果集的重叠率:
$$Recall@k = \frac{|HNSW_{top-k} \cap BruteForce_{top-k}|}{k}$$
| |
测试配置覆盖了 6 个组合:
| 配置 | 向量数 | 维度 | 查询次数 |
|---|---|---|---|
| 1K-128D | 1,000 | 128 | 100 |
| 1K-256D | 1,000 | 256 | 100 |
| 1K-512D | 1,000 | 512 | 100 |
| 10K-128D | 10,000 | 128 | 50 |
| 10K-256D | 10,000 | 256 | 50 |
| 10K-512D | 10,000 | 512 | 50 |
2.2 全军覆没
| |
所有 6 个配置、所有 k 值,recall 全部是 0.0000。
插入一个小型 debug 测试来观察两边到底返回了什么:
| |
两个发现:
- 结果集完全不重叠——不是排序问题,是找到了完全不同的向量
- HNSW 返回的相似度是负数(-0.22),而 BF 返回的是正数(+0.30)
HNSW 在返回最不相似的向量。
三、Bug #1:距离与相似度的致命混淆
3.1 根因
HNSW 的堆比较逻辑假设 distance 越小越好(像欧氏距离),但实际使用的是 DotProduct 相似度(越大越好)。
| |
堆说"我要让最大的浮到顶上方便丢掉",但 distanceBetween 返回的是相似度,最大的恰恰是最好的。结果:每次扩展候选时都从最差的开始,搜索方向完全反了。
3.2 修复
把相似度转为距离——取负:
| |
修复效果(recall@1):
| 配置 | 修复前 | 修复后 |
|---|---|---|
| 1K-128D | 0% | ~20% |
| 1K-256D | 0% | ~15% |
| 10K-128D | 0% | ~2% |
从 0% 到终于有分了——但维度越高、数据越多,recall 越惨。离理想值还差得远。
四、Bug #2:searchLayerWithEf 候选选择条件反转
4.1 根因
修复距离方向后,搜索函数里还有一个条件写反了:
| |
w[0] 是 max-heap 的堆顶(当前结果集中最差的)。新候选的距离应该比最差的更小(更好)才值得加入。写成 > 等于在说"只要比最差的还差就加进来"——完全反了。
4.2 修复效果
| 配置 | 修复前 | 修复后 |
|---|---|---|
| 1K-128D | ~20% | ~30% |
| 1K-256D | ~15% | ~25% |
| 10K-128D | ~2% | ~5% |
1K 数据有改善,10K 仍然惨不忍睹。说明还有更深层的问题。
五、Bug #3-#6:对照论文的系统性审查
到这一步,我停下来做了一次完整的代码审查,对照论文逐条比对。一次性发现了剩下四个问题。
5.1 Bug #3:candidates 堆语义错误(最致命)
论文要求:candidates 是 min-heap(优先扩展最近候选),W 是 max-heap(维护结果集,淘汰最差的)。
实际实现:两个都是 max-heap。这意味着搜索时每次从 candidates 弹出的是最远的候选去扩展邻居——搜索方向完全错误。
| |
这是 0% recall 到 5% 的根本原因。即使修复了距离方向,堆的语义仍然在把搜索引向错误的方向。
修复:candidates 改用 min-heap,Less 函数取反:
| |
5.2 Bug #4:SelectNeighbors 缺乏多样性启发式
原始实现:
| |
这是论文 Algorithm 4 明确反对的做法。只取最近的 M 个,导致所有邻居聚集在同一个方向,图的连通性极差。
对照论文实现 Algorithm 4:
| |
关键判断:distER < distQE 意味着对于候选 e,已选邻居 r 比查询点 q 更近。此时 e 被 r “覆盖"了——从 e 出发不如从 r 出发,没有额外的导航价值。
5.3 Bug #5:Layer 0 未使用 M₀ = 2M
论文 §4 明确指出 Layer 0 的连接数应该是其他层的两倍,因为 Layer 0 包含所有节点,需要更高的连通度来保证搜索质量。
| |
5.4 Bug #6:pruneNeighbors 使用 O(M²) 冒泡排序
| |
单看一次 pruneNeighbors 差别不大,但它在每次 Insert 时都会对每个邻居的邻居列表调用。10K 个节点 × 每个节点 M=32 个邻居 × 每个 O(M²),积累起来就是显著的性能差距。
5.5 其他修复
全局 RNG 竞态:assignLevel() 使用 rand.Float64(),在并发插入时有竞态条件。改为每个索引实例持有独立的 rand.Rand。
Delete 悬垂指针:删除节点时只从 nodes map 中移除,没有清理其他节点邻居列表中的引用。搜索时可能遍历到已删除节点。修复:删除时遍历该节点所有层的邻居,移除反向引用,并加上 deleted 标记做搜索过滤。
六、六个 Bug 修复后的效果
| |
从 全线 0% 到 1K-10K 128D/256D 接近 100%,10K-512D 约 98%。高维大规模数据的最后 2% 受限于维度诅咒和 ef 参数权衡,属于正常范围。
六个 Bug 的修复效果叠加一览(以 1K-128D recall@1 为观测指标):
| Bug | 类型 | recall 变化趋势 | 论文对应 |
|---|---|---|---|
| 距离/相似度混淆 | 语义错误 | 0% -> ~20% | 距离空间约定 |
| searchLayerWithEf 条件反转 | 逻辑错误 | ~20% -> ~30% | Algorithm 2 终止条件 |
| candidates 堆语义 | 数据结构错误 | ~30% -> ~90% | Algorithm 2(C = min-heap) |
| SelectNeighbors 无多样性 | 算法缺失 | ~90% -> ~97% | Algorithm 4 |
| Layer 0 未用 M₀=2M | 参数错误 | ~97% -> ~99% | §4 参数设定 |
| pruneNeighbors 冒泡排序 | 性能问题 | 无 recall 影响 | 工程优化 |
七、高维性能问题:自适应 ef
修完所有 Bug 后,1K-10K 的 128D/256D 都能在合理时间内完成。但 10K-512D 跑了 512 秒还没完。
7.1 问题分析
ef=600 在 512 维下的构建成本:
| |
7.2 解法:自适应 ef
高维空间中向量分布更均匀,较小的 ef 已足够覆盖最近邻区域。受此启发,让 ef 按维度自适应缩放:
$$ef_{adaptive} = \frac{ef_{base}}{\sqrt{dim / dim_{ref}}}$$
| |
效果:
| 维度 | baseEf | adaptiveEf | 构建时间 | Avg Recall |
|---|---|---|---|---|
| 128D | 600 | 600 | ~10s | ~100% |
| 256D | 600 | 424 | ~20s | ~99% |
| 512D | 600 | 300 | 87s (原 512s+) | ~98% |
构建时间减半,Recall 仅微降,在高维场景下是合理的精度-性能权衡。
八、与工业实现的差距
修完这些之后,从原理上已经对齐了论文。但和 Faiss、hnswlib 这些工业级实现相比,仍然有差距。下表列出当前状态(含本次修复后已实现的部分):
| 维度 | 当前实现 | 工业级 (hnswlib/Faiss/Weaviate) |
|---|---|---|
| SelectNeighbors | Algorithm 4 RNG 启发式(已实现) | 同 |
| 底层 M₀ | 2×M(已实现,论文 §4) | 同 |
| 距离计算 | Go ASM + AVX2/AVX512(已实现) | C/C++ SIMD,快 2-4× |
| 并发 | FNV 分片锁(已实现) | 无锁插入 / 更细粒度分段锁 |
| 内存布局 | map[string]*Node 指针跳转 | 连续数组,cache-friendly |
| Delete | 软删除 + 邻居清理(已实现) | Lazy deletion + 图重连线 |
| 量化 | 无 | Product Quantization,内存减少 4-16× |
| 动态 ef | 按维度自适应(已实现) | 按查询 budget 自适应 |
| 持久化 | snapshot 全量 | mmap 直接加载,秒级恢复 |
当前最大的瓶颈在内存布局和量化——Go 的 map + 指针结构对 CPU cache 不友好,高维向量没有压缩导致内存占用大。这两个是后续优化的重点方向。
九、Debug 方法论总结
回顾这次 debug 过程,有几个方法论值得记录:
1. 先写测试框架再写算法
如果不是一开始就有 Recall@K 测试,可能永远不知道搜索结果是错的。HNSW 的 Search 函数不会报错——它总是"成功"返回结果,只是返回的是错误的向量。
2. 纯 0% 比 50% 更容易 debug
0% recall 意味着有方向性的系统错误,而不是随机的精度损失。这让排查更有针对性——一定是某个基本假设搞反了。
3. 对照论文逐条比对
每篇算法论文的伪代码都有严格的语义约定(min-heap vs max-heap、距离 vs 相似度)。实现时差一个符号就是天壤之别。
4. 不要用降低阈值来"通过"测试
我犯过一个错误:当 recall 仅有 15% 时,试图把阈值从 85% 降到 1% 来让测试通过。这是在掩盖问题而不是解决问题。正确的做法是保持理想阈值不变,把实际值作为 Bug 的量化指标。
5. 一次修一个变量
六个 Bug 不是一次性发现的。每修一个,跑一次测试,观察 recall 的变化趋势。这样才能评估每个修复的实际贡献。
项目地址:github.com/uzqw/vex — 一个用 Go 实现的向量数据库,支持 HNSW 索引、BruteForce 索引、持久化快照。
参考论文:Malkov, Y.A. & Yashunin, D.A. (2018). “Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs.” IEEE TPAMI.