想象你刚搬进一座陌生城市,想找一家藏在深巷里的老火锅店。如果只有一条街,你只能从街头走到街尾,挨家挨户张望——这就像线性扫描:数据越多,走得越久。可如果这座城市修好了“高架—主干道—小巷”三层路网,你可以先上高架一口气飞过几个街区,再拐进主干道缩小范围,最后钻进小巷精准停到店门口。HNSW(Hierarchical Navigable Small World,分层导航小世界图)就是给海量向量修建的这样一套城市路网。它把每个向量当作一个节点,上层是稀疏的“高速入口”,底层是容纳全体数据的“小巷”。查询时,从最高层出发,一格格跳到目标附近再逐层下探;建图时,每个新节点像新开店一样,随机决定自己能上到第几层,在每层和附近节点接通道路。因为高层跳得远、底层找得准,搜索代价大致随数据量对数增长,这就是亿级向量检索也能毫秒返回的底气。

岗位关联

掌握 HNSW 的复杂度逻辑,你就能在亿级向量场景下预判建图成本、内存开销和查询延迟,让系统设计不靠拍脑袋。

目录
  1. 可视化演示
  2. 1. 多层图结构
  3. 2. 建图流程
  4. 3. 建图复杂度
  5. 4. 搜索复杂度
  6. 5. 参数调优
  7. 6. 硬件成本
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示HNSW分层可导航小世界图的层级结构、贪心搜索遍历路径与recall-latency参数权衡曲线,支持模式切换。

本节配有交互动效(进入视口后自动加载,约 3KB)。

HNSW分层图:跳表启发的多层导航结构 顶层稀疏、底层稠密,逐层缩小搜索范围 入口点从最高层开始贪心搜索 复杂度 = O(log N) 层级几何分布决定搜索效率

1. 多层图结构

如果把向量检索比作找路,那么 HNSW 不只有一条街,而是修了一座立体的城市:最高层是一条高架,稀疏但视野极广;中间层是几条主干道,能较快穿过城区;最底层才是密密麻麻的小巷,每一户都在这里。每一层都是一张图,越往上节点越少,越往下节点越全。这样设计的目的,是让搜索既能“飞得远”,又能“落得准”。

HNSW 的全称是分层可导航小世界图。其中“小世界”意味着每个节点不需要认识所有人,只要保留少量捷径,就能让任意两点之间的跳数很小;“分层”则让这些捷径有了方向:高层负责粗粒度定位,底层负责精粒度确认。于是查询从最高层出发,先在高架公路上大跨步移动,再逐层下探到小巷,最后停在你真正要找的那家店门口。

新节点到底住在几层?不是人工指定的,而是用一个随机函数决定。这个函数让大多数节点落在底层附近,少数节点才有资格“升到高空”。这就是 HNSW 的随机分层机制:既保证了底层全量覆盖,又保证了上层足够稀疏。

所以,HNSW 的真实面貌就是一座“越往上越少、越往下越密”的立体路网。设计者只需要调节 m_L,就能控制这座城市的“楼高分布”。

level=⌊−ln⁡(U)×mL⌋\displaystyle \text{level} = \lfloor -\ln(U) \times m_L \rfloor
其中 U 是 [0,1] 均匀随机数,m_L 是层距因子。该式表示节点层数越高,出现的概率越低,天然形成“底层多、顶层少”的金字塔结构。
  1. 生成均匀随机数从 0 到 1 之间取一个均匀分布的随机数 U,它决定了这次“抽签”的运气。
    U∼Uniform(0,1)U \sim \text{Uniform}(0, 1)
  2. 取负对数把 U 变成 -ln(U),让小数值被放大,大数值被压缩,从而让层数分布偏向低层。
    −ln⁡(U)-\ln(U)
  3. 乘以层距因子m_L 越大,整体层数越高;m_L 越小,高层节点越稀有。
    −ln⁡(U)×mL-\ln(U) \times m_L
  4. 向下取整得到最终的整数层级 0、1、2……
    ⌊⋅⌋\lfloor \cdot \rfloor

2. 建图流程

新店搬进城市时,不能只盖一间屋子,还要修路。HNSW 建图的过程,就是让每个新入住的数据节点完成三件事:决定自己能上到第几层、从最高层开始找到自己的位置、在每一层和附近节点接通道路。这个过程本身就在使用搜索算法,所以建图不是随随便便摆积木。

从技术上说,每个新节点插入时,先按随机分层公式算出自己的层数。接着,从当前最高层的入口节点开始做贪心搜索:每到一个节点,就检查它的邻居里有没有更接近自己的,有就移过去;在当前层找不到更近的节点时,就顺着这个节点进入下一层继续找。这样一路到底,新节点就掌握了自己在各层的“社交圈”,把自己和每层最近的若干个节点用边连起来。

贪心搜索是建图和查询共用的核心动作。它像你在城市里问路:每次只问当前路口的人“最近的邻居在哪”,然后走向更近的方向,而不是打开全局地图做一次穷举。

每插入一个新节点,就在重复“问路、移动、再问路”的循环。所有节点都这样陆续入住后,城市路网便自动成型。因此 HNSW 天然支持增量更新:不需要为了新增数据重建整个索引,新节点自己会找路、自己会接路。

p←arg⁡min⁡u∈N(p)d(u,q)\displaystyle p \leftarrow \arg\min_{u \in \mathcal{N}(p)} d(u, q)
其中 p 是当前节点,N(p) 是 p 的邻居集合,d(u,q) 是节点 u 到目标 q 的距离。这一步就是在说:从 p 的邻居里挑一个离 q 最近的,作为下一步要跳过去的节点。
  1. 读当前节点 p搜索总有一个“你现在站在哪”的指针。
    pp
  2. 看邻居集合N(p) 是城市地图上当前路口连着的几条路。
    N(p)\mathcal{N}(p)
  3. 选最近的那个邻居用 argmin 在邻居里挑出距离目标最近的节点。
    arg⁡min⁡u∈N(p)\arg\min_{u \in \mathcal{N}(p)}
  4. 计算距离 d(u,q)这里是向量之间的距离,比如欧氏距离或余弦距离。
    d(u,q)d(u, q)

3. 建图复杂度

修路不是免费的。城市越大,路网越密,修路的时间和占用的土地也就越多。HNSW 的建图复杂度,就是这笔“修路账本”:每个新节点入住时要从顶层搜索到自己的层,搜索过程大约访问 O(log N) 个节点;每访问一个节点,又要比较它最多 M 个邻居。因此单个节点的插入成本约为 O(M log N)。当 N 个节点全部插入后,总建图时间复杂度就是 O(N M log N)。

在工程上,M 通常被看作是常数,比如 16、32 或 64。于是大家习惯上把建图总复杂度简写成 O(N log N),意思是:数据量翻一倍,建图时间不会翻倍很久,而是近似线性乘以一个对数因子。内存占用同样和边数挂钩:每个节点平均有 L 层,每层最多 M 条边,所以内存规模大约是 N × L × M。

建图复杂度决定了两件事:一是构建索引要等多久,二是索引要占多大内存。在亿级向量场景下,这两种成本都不能忽视,需要在索引质量和构建资源之间做取舍。

Tbuild=O(N×M×log⁡N),Mem=O(N×Lˉ×M)\displaystyle T_{\text{build}} = O(N \times M \times \log N), \quad \text{Mem} = O(N \times \bar{L} \times M)
其中 N 是数据总量,M 是每层最大邻居数,L 是每层节点数多少?不对,L 是节点平均所在层数,即一个节点平均被多少个楼层收录。
  1. N 个节点要修建 N 栋建筑,每栋都要接入路网。
    NN
  2. M 个邻居每访问一个节点,最多检查 M 条候选边。
    MM
  3. log N 层搜索从顶部搜索到目标层,步骤数随数据量对数增长。
    log⁡N\log N
  4. 平均层数 L节点平均占据的层数越多,需要维护的边越多,内存也越高。
    Lˉ\bar{L}

4. 搜索复杂度

查询时的导航策略非常简单:从最高层入口出发,贪心选择更近的邻居,一直走到当前层找不到更近节点;然后顺着这个节点下降一层,再重复同样的操作。这个“高速飞奔—换路—小巷慢走”的过程,就是 HNSW 搜索的全部秘密。

为什么能快?因为高层节点很少,每一次跳转都跨越大量数据空间,相当于在高架公路上飞过一整个片区;一旦到达目标附近,再下到主干道和小巷精确定位。每一步只比较当前节点的 M 个邻居,而不需要看全量数据。整体搜索步数随数据量对数增长,因此平均搜索复杂度约为 O(M log N)。在 M 固定时,它就是 O(log N)。

不过,这里有一个现实变量:efSearch(查询候选队列长度)。它相当于你导航时允许自己“多绕几个路口”再确认答案。efSearch 越大,搜索时保留的候选就越多,召回率越高,但检查的节点也越多,延迟随之上升。搜索复杂度公式中的常数项会被 efSearch 放大,所以它不是免费的。

Tsearch=O(M×log⁡N)\displaystyle T_{\text{search}} = O(M \times \log N)
搜索复杂度表示查询时检查的“路口数”。数据从一万涨到一亿,log N 只是从 10 涨到 20 左右,这正是 HNSW 能应对亿级数据的核心原因。
  1. 每步比较 M 个邻居在城市路口只看当前能到达的几条路。
    MM
  2. 对数级跳转次数高层的远跳让步骤数与 log N 成正比。
    log⁡N\log N
  3. 总步数相乘每层搜索若干步,层数近似常数,整体仍是对数级。
    M×log⁡NM \times \log N

5. 参数调优

如果把 HNSW 比作城市交通系统,那么参数就是交通工程师手里的旋钮:M 是每条路的宽度,efConstruction 是修路时的质检标准,efSearch 是导航时允许的绕路程度,m_L 是城市的立体程度。调参的本质,就是在路网密度、建造成本和通行效率之间找平衡。

M 越大,每个节点的邻居越多,路网越密,搜索时更容易找到捷径,但内存和单步比较次数都会上升。efConstruction 越大,建图时搜索得更仔细,索引质量更高,召回通常更好,但建图时间显著增加。efSearch 则直接控制查询时的候选规模,对召回率的影响呈边际递减:从 10 加到 100 可能提升明显,从 200 加到 400 可能几乎无感,延迟却接近翻倍。m_L 控制节点层数的分布,m_L 越大,高层节点越多,搜索时可用于“远跳”的入口也越多,但内存可能增加。

交通工程师不会把 efSearch 调到无限大,而是画出“召回率—延迟”曲线,找到拐点。通常做法是先固定 M 和 efConstruction,在验证集上调 efSearch;再逐步增大 M,观察收益是否值得额外内存。

Recall(ef)≈1−e−c×ef\displaystyle \text{Recall}(ef) \approx 1 - e^{-c \times ef}
这是经验上的近似关系:召回率随 efSearch 增大而上升,但上升速度越来越慢,最终趋近饱和。
  1. ef 表示候选队列长度每层保留多少个“有希望的候选点”。
    efef
  2. 指数衰减e 的负指数形式描述了“多找一点,增益变小”的饱和效应。
    e−c×efe^{-c \times ef}
  3. 常数 c由数据集分布、距离度量和 M 决定。
    cc

6. 硬件成本

再好的城市路网也要耗油、占土地、雇人维护。HNSW 的索引必须常驻内存,因为搜索时的随机跳转无法依赖磁盘顺序读。内存占用约等于“节点数 × 平均层数 × 邻居数 × 每条边的字节数”。如果每条边用一个整型表示 4 字节,M=32,平均层数 4,那么一亿个节点大约需要 51.2 亿字节,约 5GB 以上。

硬件配置上,查询延迟主要由内存带宽和 CPU 计算距离的速度决定。向量维度越高,每次距离计算越贵;单机吞吐不够时,可以靠多核并行扛 QPS。如果数据量实在太大,常见做法是先用向量量化压缩维度,再配 HNSW;或者把冷数据放到 SSD,但必须接受随机 IO 带来的延迟抖动。

因此,硬件规划不是拍脑袋决定的。先用公式估算内存下限,再结合 QPS 和召回目标反推 CPU 核数,最后决定是否要用量化、分片或分层存储来降低单机压力。

MemBytes=N×Lˉ×M×b\displaystyle \text{MemBytes} = N \times \bar{L} \times M \times b
其中 b 是存储一条邻居边的字节数。这个公式用来做容量估算:调大 M 或让节点平均层数变高,内存都会成比例上涨。
  1. N 个节点城市里登记在册的建筑总数。
    NN
  2. 平均层数 L每栋建筑平均在几层路网上出现。
    Lˉ\bar{L}
  3. 每条边的邻居数 M每层最多连几条路。
    MM
  4. 单条边字节数 b存储一个邻居编号要占用的内存空间。
    bb

AI 应用场景

电商以图搜图

阶段:向量召回与索引构建

解决的问题:传统以图搜图会把商品图片逐一与用户上传图比较,百万商品就要做百万次距离计算,延迟不可接受。HNSW 先把所有商品图片的 embedding 建成多层路网,查询时从高层快速定位候选区域,再在底层精排,只需访问一小部分节点即可返回 top-k。

具体案例:某电商平台有 5000 万商品图,将图片通过 CLIP 模型转为 512 维向量,使用 HNSW 建索引后,单次搜索在 10ms 内返回 100 张相似图。工程师把 efSearch 从 50 调到 120,召回率提升约 8%,延迟从 8ms 涨到 15ms,最后根据线上 CTR 实验选定 80。

RAG 知识库检索

阶段:大模型外部知识召回

解决的问题:问答系统需要从几十万篇文档中找出与用户问题最相关的片段。朴素做法是全部向量暴力扫描,既慢又贵;用 HNSW 建立索引后,每次检索只需毫秒级,给大模型生成留出充裕时间。

具体案例:一个企业内部知识库有 30 万条文档切片,每条用 embedding 模型转为 768 维向量。部署 HNSW 后,查询 P99 延迟从 300ms 降到 25ms,召回的 top-5 片段能稳定命中正确答案,构建索引仅用 40 分钟。

推荐系统向量召回

阶段:召回阶段候选集生成

解决的问题:推荐系统需要给每个用户从亿级 item 池里捞回几百个候选。HNSW 的高吞吐和可控延迟能力,使线上可以实时根据用户行为向量做近邻检索,替代原先建好的静态倒排候选。

具体案例:短视频平台把用户和视频都映射到向量空间,用 HNSW 做用户向量到视频向量的 ANN 检索。通过调节 M=24、efConstruction=200、efSearch=150,在 1 亿视频量级上单机 QPS 达到 3000,召回率比 IVF 方案提升 5%。

Python 演示

python79 行
import mathimport random  def dist(a, b):    return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)  class Node:    def __init__(self, vec, level):        self.vec = vec        self.level = level        self.neighbors = []  class MiniHNSW:    def __init__(self, m=3, seed=42):        random.seed(seed)        self.m = m        self.nodes = []        self.entry = None     def _random_level(self):        r = random.random()        if r < 0.5:            return 0        if r < 0.8:            return 1        return 2     def add(self, vec):        node = Node(vec, self._random_level())        if self.entry is None:            self.nodes.append(node)            self.entry = node            return        for level in range(node.level + 1):            peers = [n for n in self.nodes if n.level >= level]            if not peers:                continue            peers.sort(key=lambda n: dist(n.vec, vec))            for peer in peers[:self.m]:                node.neighbors.append(peer)                peer.neighbors.append(node)        self.nodes.append(node)        if node.level > self.entry.level:            self.entry = node     def search(self, target, max_steps=200):        current = self.entry        best = dist(current.vec, target)        visited = 0        improved = True        while improved and visited < max_steps:            improved = False            visited += 1            for nb in current.neighbors:                d = dist(nb.vec, target)                if d < best:                    best = d                    current = nb                    improved = True        return current.vec, best, visited  def main():    index = MiniHNSW(m=3)    data = [(random.uniform(0, 1), random.uniform(0, 1)) for _ in range(300)]    for p in data:        index.add(p)    target = (0.52, 0.48)    vec, d, visited = index.search(target)    print("节点总数:", len(data))    print("搜索访问节点数:", visited)    print("最近距离:", round(d, 6))  if __name__ == "__main__":    main()
目的:用 300 个二维点构建一个教学版 MiniHNSW,演示贪心图搜索只访问少量节点就能逼近目标,直观感受 HNSW 的导航式检索。
  1. node = Node(vec, self._random_level())新节点入住时随机决定自己住几层,相当于新店选楼层。
  2. peers = [n for n in self.nodes if n.level >= level]只找同层或更高层的节点修路,保证高层和底层都有邻居。
  3. peers.sort(key=lambda n: dist(n.vec, vec))按距离排序,把最近的 m 个节点设为邻居,模拟每层“修最近的路”。
  4. current = self.entry搜索永远从最高层入口出发,先走高速。
  5. if d < best: current = nb只向更近的邻居跳,这就是贪心导航的“下坡规则”。
  6. len(visited)记录访问节点数,你会看到它远小于 300,这就是对数级搜索的直观证据。

常见误区

误区我总以为最高层路网很稀疏,所以一次跳跃就能覆盖大部分数据空间,不管数据多少,查询时间都应该差不多,甚至觉得它比二分查找还快,接近常数时间。

正解高层确实能“跳得远”,但它不能保证一次就落在目标旁边。搜索仍需从顶层逐层下探,每层做若干次邻域比较。平均访问节点数约等于 O(log N),而不是 O(1)。把“远跳”误解成“精准传送”,是对小世界网络最常见的误读。

为何常见:高层跳转幅度大,给人“瞬间接近目标”的直观感受,容易忽略逐层精化所需的步数。

误区为了让检索更准,我把 M 调到 128,认为每个节点连接的邻居越多,图连通性越好,搜索结果肯定越准。

正解M 增大确实让图更密、单步候选更多,但内存占用和单步比较次数同步上升。当 M 超过数据本身的有效维度或密度需求后,召回率提升非常有限,反而拖慢构建和查询。合适做法是观察“M—召回率”曲线,选收益拐点,而不是一味求大。

为何常见:人直觉上认为“连接多=信息全”,忽略了图索引是每一步都要付出比较成本的。

误区线上召回率不够,我第一反应就是把 efSearch 从 100 调到 500,觉得候选队列越长,越不容易漏掉真正的近邻。

正解efSearch 对召回率的提升是边际递减的,从 10 加到 100 可能效果显著,但从 200 加到 400 可能只提升零点几个百分点,延迟却几乎翻倍。正确的做法是画“召回率—延迟”曲线,选择业务可接受的拐点,而不是盲目堆参。

为何常见:召回率的小幅上升不易察觉,但延迟的明显变长会立刻暴露,容易让人在“再调大一点”和“为什么变慢”之间反复纠结。

误区我只看查询延迟,觉得索引构建慢一点没关系,晚上跑一次任务就好,第二天服务照样起来。

正解亿级数据的 HNSW 构建可能要数小时甚至更久,而且建图复杂度直接受 M 和 efConstruction 影响。如果业务每天或每小时有新数据进来,构建慢会成为上线瓶颈。建图时间、内存占用和更新频率必须一起纳入架构设计。

为何常见:大家平时用现成工具时只感知查询快,忽略了索引构建也是在线系统生命周期的一部分。

面试题

Q1. HNSW 搜索起点通常在哪一层?

单选题 · easy

  • A.最底层的全部节点
  • B.最高层的稀疏节点← 正确答案
  • C.随机的某一个中间层
  • D.数据的中位数所在层

答案:B。HNSW 把搜索起点放在最高层,因为最高层节点最少但“视野”最广。这就像导航先上高架,而不是先钻进小巷。从最高层开始逐层下探,才能先粗后细地逼近目标。

Q2. HNSW 平均搜索复杂度近似为?

单选题 · easy

  • A.O(N) 线性遍历全部节点
  • B.O(log N) 对数级跳转← 正确答案
  • C.O(N log N) 排序式建堆
  • D.O(N^2) 两两距离比较

答案:B。HNSW 依靠多层稀疏索引做“远跳”,搜索步数与 log N 成正比。数据从一万涨到一亿,log N 只从 10 涨到 20 左右,这正是它擅长大规模检索的原因。

Q3. 控制建图时候选队列大小的参数是?

单选题 · mid

  • A.控制查询候选数
  • B.控制建图候选数← 正确答案
  • C.控制层数分布
  • D.控制最大邻居数

答案:B。建图时使用的候选队列参数是 efConstruction,它决定插入节点时每层保留多少个候选用来挑邻居;efSearch 是查询阶段的参数。两者名字很像,但作用阶段不同。

Q4. 增大 efSearch 带来的直接效果是?

单选题 · mid

  • A.召回率上升,查询延迟上升← 正确答案
  • B.召回率下降,查询延迟下降
  • C.建图时间变长,内存翻倍
  • D.精确率提高,召回率不变

答案:A。efSearch 越大,搜索时保留的候选点越多,越不容易漏掉真实近邻,召回率上升;但每步要比较的候选也更多,延迟随之上升。它主要影响查询阶段,对建图时间影响很小。

Q5. 以下哪些操作会增加 HNSW 内存占用?

多选题 · mid

  • A.增大 M 邻居数量← 正确答案
  • B.增大总数据量 N← 正确答案
  • C.增大节点平均层数← 正确答案
  • D.减小 efSearch 候选数

答案:ABC。内存主要来自节点间的边。M 越大,每个节点连的边越多;N 越大,节点总数越多;平均层数越高,节点在多个层都要存邻居。efSearch 是查询时临时候选,不常驻内存。

Q6. 当 M 视为常数时,HNSW 建图总复杂度常用表达是?

单选题 · hard

  • A.O(N) 线性时间建图
  • B.O(N log N) 每点对数搜索← 正确答案
  • C.O(N^2) 全量两两比较
  • D.O(log N) 常数级建图

答案:B。每个节点插入时要搜索 O(log N) 步定位位置,N 个节点合计就是 O(N log N)。这里把每步检查的 M 个邻居视为常数,因此不会出现 O(N^2)。

Q7. 公式 level=floor(-ln(U)×mL) 说明节点层数分布是?

单选题 · hard

  • A.全部节点平均分布在各层
  • B.高层节点指数级减少← 正确答案
  • C.每层节点数量完全相等
  • D.层数只与插入顺序有关

答案:B。对均匀随机数取负对数后,大部分结果集中在 0 附近,只有少数结果很大。因此大多数节点落在底层,高层节点越来越少,形成金字塔分布。

Q8. 内存受限时通常可采用的策略有?

多选题 · hard

  • A.调小 M 减少边数← 正确答案
  • B.限制最大层数← 正确答案
  • C.对向量做量化压缩← 正确答案
  • D.调大 efSearch 提升精度

答案:ABC。减小 M 和限制层数都会减少边数,量化压缩能降低每条向量占用的字节数。efSearch 是查询期参数,不会减少索引驻留内存,反而可能增加临时开销。

Q9. HNSW 相对 IVF 的主要优势是?

单选题 · mid

  • A.无需训练聚类中心← 正确答案
  • B.不需要设置任何参数
  • C.保证返回全局精确最近邻
  • D.内存占用恒等于零

答案:A。IVF 需要先对数据做 KMeans 聚类训练,而 HNSW 通过逐点插入建图,不需要单独训练聚簇中心。HNSW 本质仍是近似检索,不保证全局精确最近邻。

Q10. 为什么 HNSW 能实现对数级搜索复杂度?

单选题 · hard

  • A.靠高层稀疏索引快速逼近,再逐层精化← 正确答案
  • B.靠全图两两直连,一步到达目标
  • C.靠数据按字典序排序后二分查找
  • D.靠随机游走且无需距离计算

答案:A。HNSW 的策略是“高层大步走,底层小步走”。高层节点少,能快速跨越大量区域;到目标附近后再逐层精调。它既不是全连接,也不是传统排序,更不需要随机瞎逛。