从街道到高速公路-HNSW分层近邻搜索原理与工程实践

0 阅读15分钟

在这里插入图片描述

目录

  1. 背景与动机
  2. 前置知识:小世界网络(Navigable Small World)
  3. HNSW 核心设计:分层结构
  4. 搜索算法详解
  5. 插入算法详解
  6. 复杂度分析
  7. 关键参数与调优
  8. 与其他 ANN 算法的对比
  9. 实现要点与工程实践

1. 背景与动机

1.1 问题定义

给定一个包含 N 个 d 维向量的数据集 D={x1,x2,,xN}D = \{x_1, x_2, \ldots, x_N\},以及一个查询向量 qq精确最近邻搜索的目标是:

x=argminxiDqxix^* = \arg\min_{x_i \in D} \|q - x_i\|

当 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) 在低层表现优异,但存在两个核心问题:

  1. 搜索初期效率低:从随机入口点开始,需要经过大量"长途跳转"才能到达查询点附近区域
  2. 内存开销大:每个节点需要维护较多邻居连接,总边数较多

HNSW 通过引入分层结构解决了这两个问题。


2. 前置知识:小世界网络(Navigable Small World)

2.1 什么是小世界网络

"小世界"概念来自社会网络:地球上任意两个人之间,平均只需约 6 个中间人即可建立联系(六度分隔理论)。

在图论中,一个可导航的小世界网络具有以下特征:

  • 大部分边是短程边(连接空间上相近的节点)
  • 少量长程边(连接空间上远离的节点)
  • 从任意节点出发,通过贪心策略(每步走向离目标更近的邻居)可以在 O(logN)O(\log N) 步内到达目标
graph LR
    A[&#34;入口节点&#34;] --> B[&#34;节点2&#34;]
    B --> C[&#34;节点3&#34;]
    C --> D[&#34;节点4&#34;]
    D --> E[&#34;节点5&#34;]
    E --> F[&#34;目标节点&#34;]

    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 图保证贪心搜索一定能找到全局最近邻,但构建复杂度为 O(Nd/2)O(N^{\lceil d/2 \rceil}),在高维下完全不可行。

NSW 是 Delaunay 图的近似:通过随机插入节点并连接其近邻,逐步构建一个"足够好"的图,使贪心搜索大概率能找到最近邻。

2.3 NSW 的搜索过程

graph TD
    N1[&#34;节点1&#34;] --- N2[&#34;节点2&#34;]
    N2 --- N3[&#34;节点3&#34;]
    N3 --- N4[&#34;节点4&#34;]
    N4 --- N5[&#34;节点5&#34;]
    N3 --- N6[&#34;节点6&#34;]
    N6 --- N7[&#34;节点7&#34;]
    N7 --- N8[&#34;节点8&#34;]
    N6 --- Q[&#34;查询点 q&#34;]

    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[&#34;第 2层(顶层)&#34;]
        direction LR
        S2_1[&#34;1&#34;]
        S2_9[&#34;9&#34;]
        S2_1 -.-> S2_9
    end

    subgraph L1[&#34;第 1层&#34;]
        direction LR
        S1_1[&#34;1&#34;]
        S1_4[&#34;4&#34;]
        S1_7[&#34;7&#34;]
        S1_9[&#34;9&#34;]
        S1_1 --> S1_4
        S1_4 --> S1_7
        S1_7 --> S1_9
    end

    subgraph L0[&#34;第 0层(底层)&#34;]
        direction LR
        S0_1[&#34;1&#34;] --- S0_2[&#34;2&#34;] --- S0_3[&#34;3&#34;] --- S0_4[&#34;4&#34;] --- S0_5[&#34;5&#34;] --- S0_6[&#34;6&#34;] --- S0_7[&#34;7&#34;] --- S0_8[&#34;8&#34;] --- S0_9[&#34;9&#34;]
    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[&#34;第 3 层(顶层)— 入口层,通常仅 1 个节点&#34;]
        direction LR
        H3[&#34;入口节点 ★&#34;]
    end

    subgraph L2[&#34;第 2 层 — 稀疏连接,'高速公路'&#34;]
        direction LR
        A2[&#34;节点A&#34;] --- B2[&#34;节点B&#34;] --- C2[&#34;节点C&#34;]
    end

    subgraph L1[&#34;第 1 层 — 中等密度,'省道'&#34;]
        direction LR
        A1[&#34;节点A&#34;] --- B1[&#34;节点B&#34;] --- C1[&#34;节点C&#34;] --- D1[&#34;节点D&#34;] --- E1[&#34;节点E&#34;]
    end

    subgraph L0[&#34;第 0 层 — 最密集,'街道网络'(包含所有 N 个节点)&#34;]
        direction LR
        N1[&#34;●&#34;] --- N2[&#34;●&#34;] --- N3[&#34;●&#34;] --- N4[&#34;●&#34;] --- N5[&#34;●&#34;] --- N6[&#34;●&#34;] --- N7[&#34;●&#34;] --- N8[&#34;●&#34;] --- N9[&#34;●&#34;] --- N10[&#34;●&#34;] --- N11[&#34;●&#34;] --- N12[&#34;●&#34;]
    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 层的概率等于 P(l1<ln(r)mll)P(l-1 < -\ln(r) \cdot ml \le l),即 P(ln(r)((l1)/ml,l/ml])P(-\ln(r) \in ((l-1)/ml, l/ml])。由于 ln(r)-\ln(r) 服从指数分布 Exp(1)Exp(1),其 CDF 为 1et1 - e^{-t},因此:

P(l)=e(l1)/mlel/ml=e(l1)/ml(1e1/ml)P(l) = e^{-(l-1)/ml} - e^{-l/ml} = e^{-(l-1)/ml}(1 - e^{-1/ml})

当 M=16 时,ml=1/ln(16)0.361ml = 1/\ln(16) \approx 0.361,各层概率约为:

xychart-beta
    title &#34;节点出现在各层的概率(M=16, ml≈0.361)&#34;
    x-axis [&#34;第0层&#34;, &#34;第1层&#34;, &#34;第2层&#34;, &#34;第3层&#34;, &#34;第4层&#34;, &#34;第5层&#34;]
    y-axis &#34;概率 (%)&#34; 0 --> 100
    bar [100, 6.25, 0.39, 0.024, 0.0015, 0.00009]

注:第 0 层包含所有节点(概率 100%)。第 l 层(l≥1)的节点数约为 N/MlN/M^l,当 M=16 时,第 1 层约 N/16 个节点,第 2 层约 N/256 个节点。

直觉理解:大多数节点只在第 0 层("街道级"),少数节点上升到高层("高速公路级"),极少数节点到达顶层。高层节点充当"交通枢纽",为搜索提供远距离跳跃能力。

3.4 各层独立搜索,逐层下降

搜索不是同时在所有层进行,而是从顶层开始,逐层向下

graph TD
    subgraph L3[&#34;第 3 层(顶层)&#34;]
        direction LR
        EP[&#34;★ 入口点&#34;]
    end

    subgraph L2[&#34;第 2 层&#34;]
        direction LR
        A2[&#34;●&#34;] --- B2[&#34;●&#34;] --- C2[&#34;●&#34;]
    end

    subgraph L1[&#34;第 1 层&#34;]
        direction LR
        A1[&#34;●&#34;] --- B1[&#34;●&#34;] --- C1[&#34;●&#34;] --- D1[&#34;●&#34;] --- E1[&#34;●&#34;]
    end

    subgraph L0[&#34;第 0 层(底层)&#34;]
        direction LR
        N1[&#34;●&#34;] --- N2[&#34;●&#34;] --- N3[&#34;●&#34;] --- N4[&#34;●&#34;] --- N5[&#34;●&#34;] --- N6[&#34;●&#34;] --- N7[&#34;●&#34;] --- N8[&#34;●&#34;] --- N9[&#34;●&#34;] --- N10[&#34;●&#34;]
    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[&#34;A (入口点)&#34;] --- B[&#34;B&#34;]
        A --- C[&#34;C&#34;]
        A --- D[&#34;D&#34;]
        B --- E[&#34;E&#34;]
        C --- F[&#34;F&#34;]
        C --- G[&#34;G&#34;]
        D --- H[&#34;H&#34;]
        E --- I[&#34;★ 近邻1&#34;]
        F --- J[&#34;★ 近邻2&#34;]
        G --- K[&#34;★ 近邻3&#34;]
        H --- Q[&#34;查询点 q&#34;]
    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[&#34;efSearch=1(纯贪心,路径短但可能陷入局部最优)&#34;]
        direction LR
        A1[&#34;●入口&#34;] --> B1[&#34;○&#34;] --> C1[&#34;○&#34;] --> D1[&#34;○&#34;] --> G1[&#34;★目标&#34;]
    end

    subgraph efSearch3[&#34;efSearch=3(探索更多路径,更可能找到全局最优)&#34;]
        direction LR
        A2[&#34;●入口&#34;] --> B2[&#34;○&#34;] --> C2[&#34;○&#34;] --> D2[&#34;○&#34;] --> G2[&#34;★目标&#34;]
        A2 --> E2[&#34;○&#34;] --> F2[&#34;○&#34;]
        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 算法概述

插入新节点时,需要完成两件事:

  1. 确定节点出现在哪些层
  2. 在每一层中,找到该节点的最近邻并建立双向连接

5.2 插入过程伪代码

函数 insert(q, M, efConstruction):
  // 参数:q 为待插入向量,M 为最大连接数,efConstruction 为候选列表大小

  1. 确定 q 的最高层 l(随机采样)

  2. 从顶层入口点逐层下降到第 l 层:
     在每一层找到离 q 最近的 1 个节点作为下一层入口

  3. 从第 l 层到第 0 层逐层插入:
     a. 在当前层搜索 qefConstruction 个最近邻
     b. 从中选择最近的 M 个作为 q 的邻居
        // 第 0 层最多选 M_max0 = 2M 个,第 1 层及以上最多选 M 个
     c. 建立双向边(q ↔ 邻居)
     d. 邻居度数超过上限时执行修剪
     e. 将 q 作为下一层入口

  4. 如果 l > 当前最高层,将 q 设为新的顶层入口点

5.3 插入过程图解

graph TD
    subgraph Step1[&#34;Step 1:从顶层定位到 q 的最高层&#34;]
        direction TB
        subgraph L3_1[&#34;第 3 层&#34;]
            direction LR
            EP3[&#34;★ 入口&#34;]
        end
        subgraph L2_1[&#34;第 2 层&#34;]
            direction LR
            A3[&#34;●&#34;] --- B3[&#34;●&#34;] --- C3[&#34;●&#34;]
        end
        subgraph L1_1[&#34;第 1 层&#34;]
            direction LR
            A4[&#34;●&#34;] --- B4[&#34;●&#34;] --- C4[&#34;●&#34;] --- D4[&#34;●&#34;] --- E4[&#34;●&#34;]
        end
        EP3 --> B3
        B3 --> C4
    end

    subgraph Step2[&#34;Step 2:在第 1 层搜索最近邻并建立连接&#34;]
        direction TB
        subgraph L1_2[&#34;第 1 层(插入前)&#34;]
            direction LR
            A5[&#34;●&#34;] --- B5[&#34;●&#34;] --- C5[&#34;●&#34;] --- D5[&#34;●&#34;] --- E5[&#34;●&#34;]
        end
        subgraph L1_3[&#34;第 1 层(插入后,q 用 ★ 表示)&#34;]
            direction LR
            A6[&#34;●&#34;] --- B6[&#34;●&#34;] --- Q1[&#34;★ q&#34;] --- D6[&#34;●&#34;] --- E6[&#34;●&#34;]
        end
    end

    subgraph Step3[&#34;Step 3:在第 0 层搜索最近邻并建立双向连接&#34;]
        direction TB
        subgraph L0_1[&#34;第 0 层(插入前)&#34;]
            direction LR
            N1[&#34;○&#34;] --- N2[&#34;○&#34;] --- N3[&#34;○&#34;]
            N4[&#34;○&#34;] --- N5[&#34;○&#34;] --- N6[&#34;○&#34;]
            N7[&#34;○&#34;] --- N8[&#34;○&#34;] --- N9[&#34;○&#34;]
        end
        subgraph L0_2[&#34;第 0 层(插入后,q 用 ★ 表示)&#34;]
            direction LR
            M1[&#34;○&#34;] --- M2[&#34;○&#34;] --- M3[&#34;○&#34;]
            M4[&#34;○&#34;] --- M5[&#34;○ --- ★ q --- ○&#34;] --- M6[&#34;○&#34;]
            M7[&#34;○&#34;] --- M8[&#34;○&#34;] --- M9[&#34;○&#34;]
        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 方案一[&#34;方案一:简单截断(保留最近的 M=3 个)&#34;]
        direction TB
        C1[&#34;节点 C&#34;]
        C1 --- A1[&#34;A (距离=1)&#34;]
        C1 --- B1[&#34;B (距离=2)&#34;]
        C1 --- E1[&#34;E (距离=3)&#34;]
        C1 -.- D1[&#34;D (距离=4, 被移除)&#34;]
    end

    subgraph 方案二[&#34;方案二:启发式修剪(HNSW 采用,保留视野更广的 M=3 个)&#34;]
        direction TB
        C2[&#34;节点 C&#34;]
        C2 --- A2[&#34;A (距离=1)&#34;]
        C2 --- E2[&#34;E (距离=3)&#34;]
        C2 --- D2[&#34;D (距离=4, 但方向不同,保留)&#34;]
        C2 -.- B2[&#34;B (距离=2, 但被 A 覆盖,移除)&#34;]
    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 &#34;M 值对性能的影响(定性趋势)&#34;
    x-axis [&#34;M=4&#34;, &#34;M=8&#34;, &#34;M=16&#34;, &#34;M=32&#34;, &#34;M=64&#34;]
    y-axis &#34;相对值&#34; 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 &#34;efSearch 与召回率的关系(典型曲线)&#34;
    x-axis &#34;efSearch&#34; 16 --> 512
    y-axis &#34;召回率 (%)&#34; 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 综合对比表

维度HNSWIVF-PQAnnoyLSHKD-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{&#34;需要增量插入?&#34;}
    Q2{&#34;数据规模?&#34;}
    Q3{&#34;内存受限?&#34;}
    Q4{&#34;维度 > 256?&#34;}
    Q5{&#34;维度 < 20?&#34;}

    Q1 --&#34;是&#34;--> R_HNSW1[&#34;选择 HNSW&#34;]
    Q1 --&#34;否&#34;--> Q2

    Q2 --&#34;< 100 万&#34;--> R_HNSW2[&#34;选择 HNSW\n(最简单,效果最好)&#34;]
    Q2 --&#34;100 万 ~ 1 亿&#34;--> Q3
    Q2 --&#34;> 1 亿&#34;--> R_HYBRID[&#34;选择 IVF-PQ + HNSW 混合\n(IVF 分簇,簇内 HNSW)&#34;]

    Q3 --&#34;是&#34;--> R_PQ[&#34;选择 IVF-PQ&#34;]
    Q3 --&#34;否&#34;--> R_HNSW3[&#34;选择 HNSW&#34;]

    Q4 --&#34;是&#34;--> R_HNSW4[&#34;选择 HNSW\n(KD-Tree 和 LSH 不适合高维)&#34;]
    Q4 --&#34;否&#34;--> Q5

    Q5 --&#34;是&#34;--> R_KD[&#34;KD-Tree 也可考虑&#34;]
    Q5 --&#34;否&#34;--> R_HNSW5[&#34;选择 HNSW\n(默认最佳选择)&#34;]

    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 主流开源实现

实现语言特点
hnswlibC++ / Python官方参考实现,性能最优
FAISSC++ / PythonMeta 出品,支持 GPU 加速,IVF-PQ + HNSW 混合
MilvusGo + C++完整向量数据库,底层使用 HNSW/IVF-PQ
QdrantRust内存安全,性能优秀,支持过滤搜索
WeaviateGo内置向量数据库,支持混合搜索
pgvectorC/PostgreSQLPostgreSQL 扩展,使用 HNSW
ElasticsearchJava8.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([&#34;查询向量 q&#34;]) --> A[&#34;从顶层入口点 ep 开始&#34;]
    A --> B[&#34;确定当前层数 L&#34;]
    B --> C[&#34;在当前层执行贪心搜索\n维护大小为 1 的候选队列\n直到无法找到更近的节点&#34;]
    D{&#34;当前层是否\n是第 0 层?&#34;}
    C --> D
    D --&#34;否&#34; --> E[&#34;将当前层找到的最近邻\n作为下一层的入口点&#34;]
    E --> F[&#34;下降到下一层&#34;]
    F --> B
    D --&#34;是&#34; --> G[&#34;在第 0 层执行贪心搜索\n维护大小为 efSearch 的候选队列\n返回 efSearch 个最近邻&#34;]
    G --> END([&#34;返回结果&#34;])

    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 维护候选队列并返回结果。