目录
- 背景与动机
- 前置知识:小世界网络(Navigable Small World)
- HNSW 核心设计:分层结构
- 搜索算法详解
- 插入算法详解
- 复杂度分析
- 关键参数与调优
- 与其他 ANN 算法的对比
- 实现要点与工程实践
1. 背景与动机
1.1 问题定义
给定一个包含 N 个 d 维向量的数据集 ,以及一个查询向量 ,精确最近邻搜索的目标是:
当 N 达到百万甚至亿级别,且维度 d 较高(如 128~1024)时,暴力搜索需要计算 N 次距离,耗时无法满足在线服务需求(通常要求 < 10ms)。
1.2 为什么需要近似而非精确?
在实际应用中(推荐系统、语义搜索、图像检索),用户对"第 3 名和第 4 名顺序互换"并不敏感,但对"等待时间从 5ms 变成 500ms"非常敏感。因此,允许 1%5% 的精度损失,换取 1001000 倍的速度提升,是合理的工程取舍。
1.3 已有方法的局限
| 方法 | 核心思想 | 主要缺陷 |
|---|---|---|
| KD-Tree | 沿坐标轴递归分割空间 | 维度超过 20 时效率急剧下降(维度灾难) |
| LSH(局部敏感哈希) | 相近向量大概率落入同一哈希桶 | 需要大量哈希表才能保证召回率,内存开销大 |
| Annoy | 多棵随机投影树 | 静态构建,不支持增量插入;召回率依赖树的数量 |
NSW(Navigable Small World) 在低层表现优异,但存在两个核心问题:
- 搜索初期效率低:从随机入口点开始,需要经过大量"长途跳转"才能到达查询点附近区域
- 内存开销大:每个节点需要维护较多邻居连接,总边数较多
HNSW 通过引入分层结构解决了这两个问题。
2. 前置知识:小世界网络(Navigable Small World)
2.1 什么是小世界网络
"小世界"概念来自社会网络:地球上任意两个人之间,平均只需约 6 个中间人即可建立联系(六度分隔理论)。
在图论中,一个可导航的小世界网络具有以下特征:
- 大部分边是短程边(连接空间上相近的节点)
- 少量长程边(连接空间上远离的节点)
- 从任意节点出发,通过贪心策略(每步走向离目标更近的邻居)可以在 步内到达目标
graph LR
A["入口节点"] --> B["节点2"]
B --> C["节点3"]
C --> D["节点4"]
D --> E["节点5"]
E --> F["目标节点"]
style A fill:#1565C0,color:#fff
style F fill:#C62828,color:#fff
style B fill:#E3F2FD,color:#0D47A1
style C fill:#E3F2FD,color:#0D47A1
style D fill:#E3F2FD,color:#0D47A1
style E fill:#E3F2FD,color:#0D47A1
小世界网络中的贪心搜索路径:入口节点(蓝色)出发,每步选择离目标(红色)最近的邻居前进,最终收敛到目标节点。
2.2 Delaunay 图与 NSW
理想的近邻图是 Delaunay 图——每个节点的邻居是其在 Voronoi 单元中相邻的所有节点。Delaunay 图保证贪心搜索一定能找到全局最近邻,但构建复杂度为 ,在高维下完全不可行。
NSW 是 Delaunay 图的近似:通过随机插入节点并连接其近邻,逐步构建一个"足够好"的图,使贪心搜索大概率能找到最近邻。
2.3 NSW 的搜索过程
graph TD
N1["节点1"] --- N2["节点2"]
N2 --- N3["节点3"]
N3 --- N4["节点4"]
N4 --- N5["节点5"]
N3 --- N6["节点6"]
N6 --- N7["节点7"]
N7 --- N8["节点8"]
N6 --- Q["查询点 q"]
style Q fill:#C62828,color:#fff
style N1 fill:#E3F2FD,color:#0D47A1
style N2 fill:#E3F2FD,color:#0D47A1
style N3 fill:#E3F2FD,color:#0D47A1
style N4 fill:#E3F2FD,color:#0D47A1
style N5 fill:#E3F2FD,color:#0D47A1
style N6 fill:#E3F2FD,color:#0D47A1
style N7 fill:#E3F2FD,color:#0D47A1
style N8 fill:#E3F2FD,color:#0D47A1
NSW 单层搜索示意:边代表近邻连接,红色节点为查询点 q,搜索从入口点出发,每步移向离 q 最近的邻居,直到局部最优。
NSW 的问题:入口点可能在图的"边缘",搜索初期需要经过大量节点才能进入"主干",这部分步数占总搜索步数的很大比例。
3. HNSW 核心设计:分层结构
3.1 灵感来源:跳表(Skip List)
HNSW 的分层设计直接借鉴了跳表(Skip List)数据结构。跳表通过在链表上叠加多层"快速通道",使搜索复杂度从 O(N) 降至 O(log N)。
graph LR
subgraph L2["第 2层(顶层)"]
direction LR
S2_1["1"]
S2_9["9"]
S2_1 -.-> S2_9
end
subgraph L1["第 1层"]
direction LR
S1_1["1"]
S1_4["4"]
S1_7["7"]
S1_9["9"]
S1_1 --> S1_4
S1_4 --> S1_7
S1_7 --> S1_9
end
subgraph L0["第 0层(底层)"]
direction LR
S0_1["1"] --- S0_2["2"] --- S0_3["3"] --- S0_4["4"] --- S0_5["5"] --- S0_6["6"] --- S0_7["7"] --- S0_8["8"] --- S0_9["9"]
end
style S0_5 fill:#1B5E20,color:#fff
style S2_1 fill:#1565C0,color:#fff
style S1_1 fill:#1565C0,color:#fff
style S0_1 fill:#E8F5E9,color:#1B5E20
style S0_2 fill:#E8F5E9,color:#1B5E20
style S0_3 fill:#E8F5E9,color:#1B5E20
style S0_4 fill:#E8F5E9,color:#1B5E20
style S0_6 fill:#E8F5E9,color:#1B5E20
style S0_7 fill:#E8F5E9,color:#1B5E20
style S0_8 fill:#E8F5E9,color:#1B5E20
style S0_9 fill:#E8F5E9,color:#1B5E20
style S1_4 fill:#BBDEFB,color:#0D47A1
style S1_7 fill:#BBDEFB,color:#0D47A1
style S1_9 fill:#BBDEFB,color:#0D47A1
style S2_9 fill:#BBDEFB,color:#0D47A1
跳表示例:搜索目标值 5。从顶层逐层下降——第 2 层 1→9(9 超过 5,下降),第 1 层 1→4→7(7 超过 5,下降),第 0 层 4→5(命中目标,绿色节点)。
HNSW 将这个思想从一维有序链表推广到了高维向量空间。
3.2 分层结构
HNSW 构建一个 L+1 层的多层图(从第 0 层到第 L 层):
- 第 0 层:包含所有 N 个节点,边最密集,是"底层公路网"
- 第 1 层:包含约 N/M 个节点,边较稀疏,是"省级公路"
- 第 2 层:包含约 N/M² 个节点,边更稀疏,是"高速公路"
- 第 L 层:通常只包含 1~2 个节点,是"顶层入口"
graph TD
subgraph L3["第 3 层(顶层)— 入口层,通常仅 1 个节点"]
direction LR
H3["入口节点 ★"]
end
subgraph L2["第 2 层 — 稀疏连接,'高速公路'"]
direction LR
A2["节点A"] --- B2["节点B"] --- C2["节点C"]
end
subgraph L1["第 1 层 — 中等密度,'省道'"]
direction LR
A1["节点A"] --- B1["节点B"] --- C1["节点C"] --- D1["节点D"] --- E1["节点E"]
end
subgraph L0["第 0 层 — 最密集,'街道网络'(包含所有 N 个节点)"]
direction LR
N1["●"] --- N2["●"] --- N3["●"] --- N4["●"] --- N5["●"] --- N6["●"] --- N7["●"] --- N8["●"] --- N9["●"] --- N10["●"] --- N11["●"] --- N12["●"]
end
H3 --> A2
H3 --> C2
A2 --> A1
C2 --> E1
B2 --> C1
style H3 fill:#1565C0,color:#fff
style A2 fill:#BBDEFB,color:#0D47A1
style B2 fill:#BBDEFB,color:#0D47A1
style C2 fill:#BBDEFB,color:#0D47A1
style A1 fill:#E3F2FD,color:#0D47A1
style B1 fill:#E3F2FD,color:#0D47A1
style C1 fill:#E3F2FD,color:#0D47A1
style D1 fill:#E3F2FD,color:#0D47A1
style E1 fill:#E3F2FD,color:#0D47A1
style N1 fill:#E8F5E9,color:#1B5E20
style N2 fill:#E8F5E9,color:#1B5E20
style N3 fill:#E8F5E9,color:#1B5E20
style N4 fill:#E8F5E9,color:#1B5E20
style N5 fill:#E8F5E9,color:#1B5E20
style N6 fill:#E8F5E9,color:#1B5E20
style N7 fill:#E8F5E9,color:#1B5E20
style N8 fill:#E8F5E9,color:#1B5E20
style N9 fill:#E8F5E9,color:#1B5E20
style N10 fill:#E8F5E9,color:#1B5E20
style N11 fill:#E8F5E9,color:#1B5E20
style N12 fill:#E8F5E9,color:#1B5E20
HNSW 分层结构侧视:顶层(蓝色)仅 1 个入口节点,越往下节点越多、边越密。第 0 层包含全部数据节点,连接最为密集。
3.3 节点出现在哪一层?
每个节点在插入时,通过以下方式决定它出现在哪些层:
设 ml = 1 / ln(M) (M 为最大连接数,通常取 16~64)
对节点 x:
生成随机数 r ∈ (0, 1)
层数 l = floor(-ln(r) * ml)
其中 ml = 1 / ln(M),因此:
l = floor(-ln(r) / ln(M))
等价于:P(x 出现在第 l 层) ≈ 1/M^l
推导过程:节点出现在第 l 层的概率等于 ,即 。由于 服从指数分布 ,其 CDF 为 ,因此:
当 M=16 时,,各层概率约为:
xychart-beta
title "节点出现在各层的概率(M=16, ml≈0.361)"
x-axis ["第0层", "第1层", "第2层", "第3层", "第4层", "第5层"]
y-axis "概率 (%)" 0 --> 100
bar [100, 6.25, 0.39, 0.024, 0.0015, 0.00009]
注:第 0 层包含所有节点(概率 100%)。第 l 层(l≥1)的节点数约为 ,当 M=16 时,第 1 层约 N/16 个节点,第 2 层约 N/256 个节点。
直觉理解:大多数节点只在第 0 层("街道级"),少数节点上升到高层("高速公路级"),极少数节点到达顶层。高层节点充当"交通枢纽",为搜索提供远距离跳跃能力。
3.4 各层独立搜索,逐层下降
搜索不是同时在所有层进行,而是从顶层开始,逐层向下:
graph TD
subgraph L3["第 3 层(顶层)"]
direction LR
EP["★ 入口点"]
end
subgraph L2["第 2 层"]
direction LR
A2["●"] --- B2["●"] --- C2["●"]
end
subgraph L1["第 1 层"]
direction LR
A1["●"] --- B1["●"] --- C1["●"] --- D1["●"] --- E1["●"]
end
subgraph L0["第 0 层(底层)"]
direction LR
N1["●"] --- N2["●"] --- N3["●"] --- N4["●"] --- N5["●"] --- N6["●"] --- N7["●"] --- N8["●"] --- N9["●"] --- N10["●"]
end
EP --> B2
B2 --> C1
C1 --> N5
style EP fill:#1565C0,color:#fff
style N5 fill:#C62828,color:#fff
style A2 fill:#BBDEFB,color:#0D47A1
style B2 fill:#BBDEFB,color:#0D47A1
style C2 fill:#BBDEFB,color:#0D47A1
style A1 fill:#E3F2FD,color:#0D47A1
style B1 fill:#E3F2FD,color:#0D47A1
style C1 fill:#E3F2FD,color:#0D47A1
style D1 fill:#E3F2FD,color:#0D47A1
style E1 fill:#E3F2FD,color:#0D47A1
style N1 fill:#E8F5E9,color:#1B5E20
style N2 fill:#E8F5E9,color:#1B5E20
style N3 fill:#E8F5E9,color:#1B5E20
style N4 fill:#E8F5E9,color:#1B5E20
style N6 fill:#E8F5E9,color:#1B5E20
style N7 fill:#E8F5E9,color:#1B5E20
style N8 fill:#E8F5E9,color:#1B5E20
style N9 fill:#E8F5E9,color:#1B5E20
style N10 fill:#E8F5E9,color:#1B5E20
搜索从顶层入口点(蓝色)逐层下降,每层缩小搜索范围,最终在底层(红色)定位到最近邻。
4. 搜索算法详解
4.1 算法伪代码
函数 search(q, efSearch):
// q: 查询向量
// efSearch: 搜索时维护的候选列表大小(通常 32~512)
1. 从顶层入口点 ep 开始
2. 对每一层 l,从顶层到第1层:
a. 在当前层执行贪心搜索
b. 维护一个大小为 1 的优先队列(当前最近邻)
c. 直到无法找到更近的节点
d. 将当前层找到的最近邻作为下一层的入口点
3. 在第0层执行贪心搜索:
a. 维护一个大小为 efSearch 的优先队列
b. 返回 efSearch 个最近邻
4.2 单层贪心搜索详细步骤
函数 greedySearch(q, entryPoint, ef):
candidates = 最小堆(按到 q 的距离排序)
visited = 集合
result = 最大堆(按到 q 的距离排序) // 保存当前最近的 ef 个节点
dist = distance(q, entryPoint)
candidates.push(entryPoint, dist)
result.push(entryPoint, dist)
visited.add(entryPoint)
当 candidates 不为空时循环:
c = candidates.pop()
// 终止条件:c 比 result 中最远的节点还远,后续不可能更优
如果 distance(q, c) > distance(q, result.peek()):
跳出循环
对于 c 的每个邻居 e:
如果 e 未被访问过:
标记 e 为已访问
dist_e = distance(q, e)
如果 dist_e < distance(q, result.peek()) 或 result.size < ef:
candidates.push(e, dist_e)
result.push(e, dist_e)
如果 result.size > ef:
result.pop()
返回 result 中的 ef 个节点
4.3 搜索过程图解
graph TD
subgraph 搜索过程
A["A (入口点)"] --- B["B"]
A --- C["C"]
A --- D["D"]
B --- E["E"]
C --- F["F"]
C --- G["G"]
D --- H["H"]
E --- I["★ 近邻1"]
F --- J["★ 近邻2"]
G --- K["★ 近邻3"]
H --- Q["查询点 q"]
end
style A fill:#1565C0,color:#fff
style Q fill:#C62828,color:#fff
style I fill:#1B5E20,color:#fff
style J fill:#1B5E20,color:#fff
style K fill:#1B5E20,color:#fff
style B fill:#E3F2FD,color:#0D47A1
style C fill:#E3F2FD,color:#0D47A1
style D fill:#E3F2FD,color:#0D47A1
style E fill:#E3F2FD,color:#0D47A1
style F fill:#E3F2FD,color:#0D47A1
style G fill:#E3F2FD,color:#0D47A1
style H fill:#E3F2FD,color:#0D47A1
第 0 层搜索过程:从入口点 A(蓝色)出发逐步扩展邻居,最终收敛到查询点 q(红色)附近的 3 个最近邻(绿色)。
4.4 efSearch 的作用
efSearch 控制搜索的"宽度":
- efSearch 小(如 16):搜索快,但可能漏掉真正的最近邻
- efSearch 大(如 256):搜索慢,但召回率高
graph LR
subgraph efSearch1["efSearch=1(纯贪心,路径短但可能陷入局部最优)"]
direction LR
A1["●入口"] --> B1["○"] --> C1["○"] --> D1["○"] --> G1["★目标"]
end
subgraph efSearch3["efSearch=3(探索更多路径,更可能找到全局最优)"]
direction LR
A2["●入口"] --> B2["○"] --> C2["○"] --> D2["○"] --> G2["★目标"]
A2 --> E2["○"] --> F2["○"]
F2 -.-> D2
end
style A1 fill:#1565C0,color:#fff
style A2 fill:#1565C0,color:#fff
style G1 fill:#1B5E20,color:#fff
style G2 fill:#1B5E20,color:#fff
style B1 fill:#FFF3E0,color:#E65100
style C1 fill:#FFF3E0,color:#E65100
style D1 fill:#FFF3E0,color:#E65100
style B2 fill:#FFF3E0,color:#E65100
style C2 fill:#FFF3E0,color:#E65100
style D2 fill:#FFF3E0,color:#E65100
style E2 fill:#E8F5E9,color:#1B5E20
style F2 fill:#E8F5E9,color:#1B5E20
efSearch 控制搜索宽度:efSearch=1 只保留 1 个候选,路径短但可能错过全局最优;efSearch=3 保留 3 个候选,探索更多路径,召回率更高。
5. 插入算法详解
5.1 算法概述
插入新节点时,需要完成两件事:
- 确定节点出现在哪些层
- 在每一层中,找到该节点的最近邻并建立双向连接
5.2 插入过程伪代码
函数 insert(q, M, efConstruction):
// 参数:q 为待插入向量,M 为最大连接数,efConstruction 为候选列表大小
1. 确定 q 的最高层 l(随机采样)
2. 从顶层入口点逐层下降到第 l 层:
在每一层找到离 q 最近的 1 个节点作为下一层入口
3. 从第 l 层到第 0 层逐层插入:
a. 在当前层搜索 q 的 efConstruction 个最近邻
b. 从中选择最近的 M 个作为 q 的邻居
// 第 0 层最多选 M_max0 = 2M 个,第 1 层及以上最多选 M 个
c. 建立双向边(q ↔ 邻居)
d. 邻居度数超过上限时执行修剪
e. 将 q 作为下一层入口
4. 如果 l > 当前最高层,将 q 设为新的顶层入口点
5.3 插入过程图解
graph TD
subgraph Step1["Step 1:从顶层定位到 q 的最高层"]
direction TB
subgraph L3_1["第 3 层"]
direction LR
EP3["★ 入口"]
end
subgraph L2_1["第 2 层"]
direction LR
A3["●"] --- B3["●"] --- C3["●"]
end
subgraph L1_1["第 1 层"]
direction LR
A4["●"] --- B4["●"] --- C4["●"] --- D4["●"] --- E4["●"]
end
EP3 --> B3
B3 --> C4
end
subgraph Step2["Step 2:在第 1 层搜索最近邻并建立连接"]
direction TB
subgraph L1_2["第 1 层(插入前)"]
direction LR
A5["●"] --- B5["●"] --- C5["●"] --- D5["●"] --- E5["●"]
end
subgraph L1_3["第 1 层(插入后,q 用 ★ 表示)"]
direction LR
A6["●"] --- B6["●"] --- Q1["★ q"] --- D6["●"] --- E6["●"]
end
end
subgraph Step3["Step 3:在第 0 层搜索最近邻并建立双向连接"]
direction TB
subgraph L0_1["第 0 层(插入前)"]
direction LR
N1["○"] --- N2["○"] --- N3["○"]
N4["○"] --- N5["○"] --- N6["○"]
N7["○"] --- N8["○"] --- N9["○"]
end
subgraph L0_2["第 0 层(插入后,q 用 ★ 表示)"]
direction LR
M1["○"] --- M2["○"] --- M3["○"]
M4["○"] --- M5["○ --- ★ q --- ○"] --- M6["○"]
M7["○"] --- M8["○"] --- M9["○"]
end
end
style EP3 fill:#1565C0,color:#fff
style Q1 fill:#C62828,color:#fff
style M5 fill:#C62828,color:#fff
style A3 fill:#BBDEFB,color:#0D47A1
style B3 fill:#BBDEFB,color:#0D47A1
style C3 fill:#BBDEFB,color:#0D47A1
style A4 fill:#E3F2FD,color:#0D47A1
style B4 fill:#E3F2FD,color:#0D47A1
style C4 fill:#E3F2FD,color:#0D47A1
style D4 fill:#E3F2FD,color:#0D47A1
style E4 fill:#E3F2FD,color:#0D47A1
style A5 fill:#E3F2FD,color:#0D47A1
style B5 fill:#E3F2FD,color:#0D47A1
style C5 fill:#E3F2FD,color:#0D47A1
style D5 fill:#E3F2FD,color:#0D47A1
style E5 fill:#E3F2FD,color:#0D47A1
style A6 fill:#E3F2FD,color:#0D47A1
style B6 fill:#E3F2FD,color:#0D47A1
style D6 fill:#E3F2FD,color:#0D47A1
style E6 fill:#E3F2FD,color:#0D47A1
style N1 fill:#E8F5E9,color:#1B5E20
style N2 fill:#E8F5E9,color:#1B5E20
style N3 fill:#E8F5E9,color:#1B5E20
style N4 fill:#E8F5E9,color:#1B5E20
style N5 fill:#E8F5E9,color:#1B5E20
style N6 fill:#E8F5E9,color:#1B5E20
style N7 fill:#E8F5E9,color:#1B5E20
style N8 fill:#E8F5E9,color:#1B5E20
style N9 fill:#E8F5E9,color:#1B5E20
style M1 fill:#E8F5E9,color:#1B5E20
style M2 fill:#E8F5E9,color:#1B5E20
style M3 fill:#E8F5E9,color:#1B5E20
style M4 fill:#E8F5E9,color:#1B5E20
style M6 fill:#E8F5E9,color:#1B5E20
style M7 fill:#E8F5E9,color:#1B5E20
style M8 fill:#E8F5E9,color:#1B5E20
style M9 fill:#E8F5E9,color:#1B5E20
插入过程分三步:Step 1 从顶层定位到 q 的最高层(第 1 层),Step 2 在第 1 层搜索最近邻并建立连接,Step 3 在第 0 层搜索最近邻并建立双向连接(红色节点为 q)。
5.4 邻居修剪(Shrink)
当一个节点已有 M 个邻居,此时新邻居要加入,需要决定保留哪些边:
graph TD
subgraph 方案一["方案一:简单截断(保留最近的 M=3 个)"]
direction TB
C1["节点 C"]
C1 --- A1["A (距离=1)"]
C1 --- B1["B (距离=2)"]
C1 --- E1["E (距离=3)"]
C1 -.- D1["D (距离=4, 被移除)"]
end
subgraph 方案二["方案二:启发式修剪(HNSW 采用,保留视野更广的 M=3 个)"]
direction TB
C2["节点 C"]
C2 --- A2["A (距离=1)"]
C2 --- E2["E (距离=3)"]
C2 --- D2["D (距离=4, 但方向不同,保留)"]
C2 -.- B2["B (距离=2, 但被 A 覆盖,移除)"]
end
style C1 fill:#E3F2FD,color:#0D47A1
style C2 fill:#E3F2FD,color:#0D47A1
style A1 fill:#C8E6C9,color:#1B5E20
style B1 fill:#C8E6C9,color:#1B5E20
style E1 fill:#C8E6C9,color:#1B5E20
style A2 fill:#C8E6C9,color:#1B5E20
style E2 fill:#C8E6C9,color:#1B5E20
style D2 fill:#C8E6C9,color:#1B5E20
style D1 fill:#FFCDD2,color:#B71C1C,stroke-dasharray: 5 5
style B2 fill:#FFCDD2,color:#B71C1C,stroke-dasharray: 5 5
邻居修剪策略对比:方案一简单保留距离最近的 M 个邻居;方案二(HNSW 采用)考虑邻居之间的覆盖性——即使某个邻居距离稍远,只要它指向不同方向,仍会被保留,从而让节点的"视野"更广。
HNSW 论文中的启发式修剪算法:
函数 shrink(neighbors, M):
// neighbors 为候选邻居列表(已按距离排序),M 为最大连接数
如果 neighbors.size ≤ M:
返回 neighbors
result = 空列表
对于 neighbors 中的每个节点 e:
如果 result.size ≥ M:
跳出循环
// 覆盖性检查:若 result 中已有比 e 更接近当前节点的邻居,则 e 多余
dominated = false
对于 result 中的每个已有邻居 r:
如果 distance(r, e) < distance(e, currentNode):
dominated = true
跳出循环
如果 not dominated:
result.push(e)
返回 result
5.5 增量构建的特点
HNSW 的一个关键优势是支持增量插入——不需要预先知道所有数据,可以逐条插入新节点。新节点会自动连接到真正的近邻,已有的图结构不会被破坏,只会增加少量新边。相比之下,Annoy 需要一次性构建所有树,IVF-PQ 需要预先聚类,都不支持这种增量模式。
6. 复杂度分析
6.1 搜索复杂度
设数据集大小为 N,最大连接数为 M:
搜索过程:
顶层到第 1 层(共 L 层,每层约 M 步):
L ≈ log_{1/ml}(N) = ln(N) / ln(1/ml) = ln(N) × M
步数 ≈ L × M = O(M² × ln(N))
第 0 层(efSearch 个候选):
步数 ≈ efSearch × M
总计:O(M² × ln(N) + efSearch × M)
实际中 M 和 efSearch 是常数,因此简化为 O(log N)
6.2 插入复杂度
插入过程:
定位层数:O(M × log N) // 从顶层下降到第 l 层
每层搜索:O(efConstruction × M × log N)
共 l+1 层,平均 l ≈ ln(M) - 1 // 节点最高层的期望值
总计:O(efConstruction × M × log N)
6.3 空间复杂度
采用有向图视角(每个节点独立维护自己的邻居列表,q ↔ 邻居 意味着两条有向边):
每个节点在第 0 层最多 M_max0 个出边邻居(通常 M_max0 = 2M)
每个节点在第 l 层(l≥1)最多 M 个出边邻居
第 0 层总边数 ≈ N × M_max0
第 1 层总边数 ≈ (N/M) × M = N (仅约 N/M 个节点出现在第 1 层)
第 2 层总边数 ≈ (N/M²) × M = N/M
...
总边数 ≈ N × M_max0 + N × (1 + 1/M + 1/M² + ...)
≈ N × 2M + N × M/(M-1)
≈ N × (2M + M/(M-1))
当 M=16 时,总边数 ≈ N × (32 + 16/15) ≈ 33.1N
空间占用 = 向量存储 + 图结构(边列表,每条边存一个 int32 邻居 ID)
= N × d × 4 bytes + 33.1N × 4 bytes
≈ (4d + 132) × N bytes
注:hnswlib 等实际实现中,第 0 层每个节点最多 2M 个邻居,第 1 层及以上最多 M 个邻居。这保证了第 0 层有更密集的连接以支持精确搜索。
7. 关键参数与调优
7.1 参数总览
| 参数 | 含义 | 典型取值 | 影响 |
|---|---|---|---|
| M | 每个节点的最大双向连接数 | 8~64 | 越大越准,但内存和构建时间增加 |
| efConstruction | 构建时的候选列表大小 | 100~500 | 越大索引质量越高,但构建越慢 |
| efSearch | 搜索时的候选列表大小 | 16~512 | 越大召回率越高,但搜索越慢 |
| ml | 层级分配因子,通常设为 1/ln(M) | 自动 | 控制节点在各层的分布密度 |
7.2 M 的选择
xychart-beta
title "M 值对性能的影响(定性趋势)"
x-axis ["M=4", "M=8", "M=16", "M=32", "M=64"]
y-axis "相对值" 0 --> 100
bar [25, 45, 70, 90, 100]
| M 值 | 构建速度 | 内存占用 | 召回率 | 推荐场景 |
|---|---|---|---|---|
| 4 | 快 | 小 | 低 | 快速原型验证 |
| 16 | 中等 | 中等 | 高 | 大多数场景的推荐值 |
| 32 | 较慢 | 较大 | 很高 | 高精度需求 |
| 64 | 慢 | 大 | 最高 | 小规模高精度场景 |
经验法则:低维数据(d<32)取 M=8~16;中维数据(32≤d≤256)取 M=16~32;高维数据(d>256)取 M=32~64。
7.3 efSearch 与召回率的关系
xychart-beta
title "efSearch 与召回率的关系(典型曲线)"
x-axis "efSearch" 16 --> 512
y-axis "召回率 (%)" 75 --> 100
line [76, 80, 84, 88, 92, 95, 97, 98.5, 99.2, 99.5, 99.7, 99.8, 99.9, 99.95]
efSearch 与召回率的关系:efSearch 越大召回率越高,但搜索越慢。经验上,efSearch = 4×M 通常达到 95%+ 召回率,efSearch = 8×M 达到 99%+。
7.4 距离度量选择
HNSW 本身不限制距离函数,但实际使用中:
- 内积(Inner Product):适合归一化向量的相似度搜索
- L2 距离(欧氏距离):最通用的选择
- 余弦相似度:等价于归一化后的内积
注意:HNSW 要求距离函数满足三角不等式(是度量空间),否则贪心搜索的理论保证不成立。
8. 与其他 ANN 算法的对比
8.1 综合对比表
| 维度 | HNSW | IVF-PQ | Annoy | LSH | KD-Tree |
|---|---|---|---|---|---|
| 搜索速度 | O(log N) | O(N/nprobe) | O(log N) | O(1)~O(N) | O(log N)~O(N) |
| 召回率 | 95~99% | 85~95% | 85~95% | 80~95% | 100% |
| 内存占用 | 高 | 低 | 低 | 高 | 中 |
| 增量插入 | 支持 | 不支持 | 不支持 | 部分支持 | 不支持 |
| 构建时间 | 中等 | 快(需聚类) | 中等 | 快 | 快 |
| 维度上限 | ~1024 | ~1024 | ~1024 | ~256 | ~20 |
| 距离度量 | 任意度量 | L2/内积 | 任意 | 特定函数族 | L2 |
8.2 选型决策树
graph TD
Q1{"需要增量插入?"}
Q2{"数据规模?"}
Q3{"内存受限?"}
Q4{"维度 > 256?"}
Q5{"维度 < 20?"}
Q1 --"是"--> R_HNSW1["选择 HNSW"]
Q1 --"否"--> Q2
Q2 --"< 100 万"--> R_HNSW2["选择 HNSW\n(最简单,效果最好)"]
Q2 --"100 万 ~ 1 亿"--> Q3
Q2 --"> 1 亿"--> R_HYBRID["选择 IVF-PQ + HNSW 混合\n(IVF 分簇,簇内 HNSW)"]
Q3 --"是"--> R_PQ["选择 IVF-PQ"]
Q3 --"否"--> R_HNSW3["选择 HNSW"]
Q4 --"是"--> R_HNSW4["选择 HNSW\n(KD-Tree 和 LSH 不适合高维)"]
Q4 --"否"--> Q5
Q5 --"是"--> R_KD["KD-Tree 也可考虑"]
Q5 --"否"--> R_HNSW5["选择 HNSW\n(默认最佳选择)"]
style Q1 fill:#FFF3E0,color:#E65100
style Q2 fill:#FFF3E0,color:#E65100
style Q3 fill:#FFF3E0,color:#E65100
style Q4 fill:#FFF3E0,color:#E65100
style Q5 fill:#FFF3E0,color:#E65100
style R_HNSW1 fill:#C8E6C9,color:#1B5E20
style R_HNSW2 fill:#C8E6C9,color:#1B5E20
style R_HNSW3 fill:#C8E6C9,color:#1B5E20
style R_HNSW4 fill:#C8E6C9,color:#1B5E20
style R_HNSW5 fill:#C8E6C9,color:#1B5E20
style R_HYBRID fill:#FFE0B2,color:#E65100
style R_PQ fill:#BBDEFB,color:#0D47A1
style R_KD fill:#E1BEE7,color:#6A1B9A
ANN 算法选型决策树:HNSW 是大多数场景的默认选择;超大规模且内存受限时选 IVF-PQ;极低维场景可考虑 KD-Tree。
9. 实现要点与工程实践
9.1 核心数据结构
# HNSW 索引的核心数据结构(伪代码)
class HNSWIndex:
def __init__(self, dim, M=16, ef_construction=200, ml=None):
self.dim = dim # 向量维度
self.M = M # 第 1 层及以上每个节点的最大连接数
self.M_max0 = M * 2 # 第 0 层每个节点的最大连接数(通常更大)
self.ef_construction = ef_construction
self.ml = ml or 1.0 / math.log(M)
self.vectors = {} # 节点 ID → 向量
self.graphs = {} # 层数 → {节点 ID → [邻居 ID 列表]}
self.entry_point = None # 顶层入口节点 ID
self.max_level = 0
class Node:
def __init__(self, id, vector):
self.id = id
self.vector = vector
self.neighbors = {} # 层数 → [邻居 ID 列表]
self.level = 0 # 节点出现的最高层
9.2 距离计算的优化
距离计算是 HNSW 中最频繁的操作,通常占总时间的 80% 以上:
优化手段:
1. SIMD 向量化
- 使用 AVX2/AVX512 指令集
- 一次计算 8~16 个 float32 的乘加
2. 提前终止(Early Termination)
- 计算 L2 距离时,逐个维度累加
- 当部分和已经超过当前最优值时,提前终止
3. 向量量化
- float32 → float16(精度损失极小,速度提升约 2x)
- 或配合 PQ 进一步压缩
4. 缓存友好性
- 邻居列表连续存储
- 向量数据按访问模式对齐
9.3 并发控制
HNSW 的并发挑战:
读操作(搜索):
- 只读图结构,天然支持并发
- 多个搜索请求可以并行执行
写操作(插入):
- 插入新节点会修改图结构(增加边)
- 需要加写锁或使用读写锁(RWLock)
工程实践:
1. 读写分离:搜索不加锁,插入加写锁
2. 批量插入:累积一批数据后统一插入,减少锁竞争
3. 副本机制:写操作在副本上进行,定期合并到主索引
9.4 主流开源实现
| 实现 | 语言 | 特点 |
|---|---|---|
| hnswlib | C++ / Python | 官方参考实现,性能最优 |
| FAISS | C++ / Python | Meta 出品,支持 GPU 加速,IVF-PQ + HNSW 混合 |
| Milvus | Go + C++ | 完整向量数据库,底层使用 HNSW/IVF-PQ |
| Qdrant | Rust | 内存安全,性能优秀,支持过滤搜索 |
| Weaviate | Go | 内置向量数据库,支持混合搜索 |
| pgvector | C/PostgreSQL | PostgreSQL 扩展,使用 HNSW |
| Elasticsearch | Java | 8.0+ 版本支持原生向量搜索 |
9.5 典型性能基准
数据集:SIFT-1M(100万个 128 维向量)
硬件:Intel Xeon 2.4GHz,64GB 内存
hnswlib 性能(M=16, efConstruction=200):
构建时间:约 45 秒
内存占用:约 1.2 GB
搜索延迟(efSearch=64):约 0.5 ms
召回率(efSearch=64):约 99.2%
对比暴力搜索:
搜索延迟:约 50 ms
召回率:100%
HNSW 加速比:约 100 倍,代价是损失 0.8% 的精确度
附录:HNSW 搜索算法完整流程图
flowchart TD
START(["查询向量 q"]) --> A["从顶层入口点 ep 开始"]
A --> B["确定当前层数 L"]
B --> C["在当前层执行贪心搜索\n维护大小为 1 的候选队列\n直到无法找到更近的节点"]
D{"当前层是否\n是第 0 层?"}
C --> D
D --"否" --> E["将当前层找到的最近邻\n作为下一层的入口点"]
E --> F["下降到下一层"]
F --> B
D --"是" --> G["在第 0 层执行贪心搜索\n维护大小为 efSearch 的候选队列\n返回 efSearch 个最近邻"]
G --> END(["返回结果"])
style START fill:#1565C0,color:#fff
style END fill:#1B5E20,color:#fff
style D fill:#E65100,color:#fff
style G fill:#C62828,color:#fff
style A fill:#E3F2FD,color:#0D47A1
style B fill:#E3F2FD,color:#0D47A1
style C fill:#E3F2FD,color:#0D47A1
style E fill:#E3F2FD,color:#0D47A1
style F fill:#E3F2FD,color:#0D47A1
HNSW 搜索算法完整流程:从顶层入口点逐层下降,顶层到第 1 层每层只保留 1 个最近邻作为下一层入口,到达第 0 层后用 efSearch 维护候选队列并返回结果。