想象你刚搬进一座陌生城市,想找一家藏在深巷里的老火锅店。如果只有一条街,你只能从街头走到街尾,挨家挨户张望——这就像线性扫描:数据越多,走得越久。可如果这座城市修好了“高架—主干道—小巷”三层路网,你可以先上高架一口气飞过几个街区,再拐进主干道缩小范围,最后钻进小巷精准停到店门口。HNSW(Hierarchical Navigable Small World,分层导航小世界图)就是给海量向量修建的这样一套城市路网。它把每个向量当作一个节点,上层是稀疏的“高速入口”,底层是容纳全体数据的“小巷”。查询时,从最高层出发,一格格跳到目标附近再逐层下探;建图时,每个新节点像新开店一样,随机决定自己能上到第几层,在每层和附近节点接通道路。因为高层跳得远、底层找得准,搜索代价大致随数据量对数增长,这就是亿级向量检索也能毫秒返回的底气。
掌握 HNSW 的复杂度逻辑,你就能在亿级向量场景下预判建图成本、内存开销和查询延迟,让系统设计不靠拍脑袋。
可视化演示
展示HNSW分层可导航小世界图的层级结构、贪心搜索遍历路径与recall-latency参数权衡曲线,支持模式切换。
本节配有交互动效(进入视口后自动加载,约 3KB)。
HNSW分层图:跳表启发的多层导航结构 顶层稀疏、底层稠密,逐层缩小搜索范围 入口点从最高层开始贪心搜索 复杂度 = O(log N) 层级几何分布决定搜索效率
1. 多层图结构
如果把向量检索比作找路,那么 HNSW 不只有一条街,而是修了一座立体的城市:最高层是一条高架,稀疏但视野极广;中间层是几条主干道,能较快穿过城区;最底层才是密密麻麻的小巷,每一户都在这里。每一层都是一张图,越往上节点越少,越往下节点越全。这样设计的目的,是让搜索既能“飞得远”,又能“落得准”。
HNSW 的全称是分层可导航小世界图。其中“小世界”意味着每个节点不需要认识所有人,只要保留少量捷径,就能让任意两点之间的跳数很小;“分层”则让这些捷径有了方向:高层负责粗粒度定位,底层负责精粒度确认。于是查询从最高层出发,先在高架公路上大跨步移动,再逐层下探到小巷,最后停在你真正要找的那家店门口。
新节点到底住在几层?不是人工指定的,而是用一个随机函数决定。这个函数让大多数节点落在底层附近,少数节点才有资格“升到高空”。这就是 HNSW 的随机分层机制:既保证了底层全量覆盖,又保证了上层足够稀疏。
所以,HNSW 的真实面貌就是一座“越往上越少、越往下越密”的立体路网。设计者只需要调节 m_L,就能控制这座城市的“楼高分布”。
- 生成均匀随机数从 0 到 1 之间取一个均匀分布的随机数 U,它决定了这次“抽签”的运气。
- 取负对数把 U 变成
-ln(U),让小数值被放大,大数值被压缩,从而让层数分布偏向低层。 - 乘以层距因子m_L 越大,整体层数越高;m_L 越小,高层节点越稀有。
- 向下取整得到最终的整数层级 0、1、2……
2. 建图流程
新店搬进城市时,不能只盖一间屋子,还要修路。HNSW 建图的过程,就是让每个新入住的数据节点完成三件事:决定自己能上到第几层、从最高层开始找到自己的位置、在每一层和附近节点接通道路。这个过程本身就在使用搜索算法,所以建图不是随随便便摆积木。
从技术上说,每个新节点插入时,先按随机分层公式算出自己的层数。接着,从当前最高层的入口节点开始做贪心搜索:每到一个节点,就检查它的邻居里有没有更接近自己的,有就移过去;在当前层找不到更近的节点时,就顺着这个节点进入下一层继续找。这样一路到底,新节点就掌握了自己在各层的“社交圈”,把自己和每层最近的若干个节点用边连起来。
贪心搜索是建图和查询共用的核心动作。它像你在城市里问路:每次只问当前路口的人“最近的邻居在哪”,然后走向更近的方向,而不是打开全局地图做一次穷举。
每插入一个新节点,就在重复“问路、移动、再问路”的循环。所有节点都这样陆续入住后,城市路网便自动成型。因此 HNSW 天然支持增量更新:不需要为了新增数据重建整个索引,新节点自己会找路、自己会接路。
- 读当前节点 p搜索总有一个“你现在站在哪”的指针。
- 看邻居集合N(p) 是城市地图上当前路口连着的几条路。
- 选最近的那个邻居用 argmin 在邻居里挑出距离目标最近的节点。
- 计算距离 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。
建图复杂度决定了两件事:一是构建索引要等多久,二是索引要占多大内存。在亿级向量场景下,这两种成本都不能忽视,需要在索引质量和构建资源之间做取舍。
- N 个节点要修建 N 栋建筑,每栋都要接入路网。
- M 个邻居每访问一个节点,最多检查 M 条候选边。
- log N 层搜索从顶部搜索到目标层,步骤数随数据量对数增长。
- 平均层数 L节点平均占据的层数越多,需要维护的边越多,内存也越高。
4. 搜索复杂度
查询时的导航策略非常简单:从最高层入口出发,贪心选择更近的邻居,一直走到当前层找不到更近节点;然后顺着这个节点下降一层,再重复同样的操作。这个“高速飞奔—换路—小巷慢走”的过程,就是 HNSW 搜索的全部秘密。
为什么能快?因为高层节点很少,每一次跳转都跨越大量数据空间,相当于在高架公路上飞过一整个片区;一旦到达目标附近,再下到主干道和小巷精确定位。每一步只比较当前节点的 M 个邻居,而不需要看全量数据。整体搜索步数随数据量对数增长,因此平均搜索复杂度约为 O(M log N)。在 M 固定时,它就是 O(log N)。
不过,这里有一个现实变量:efSearch(查询候选队列长度)。它相当于你导航时允许自己“多绕几个路口”再确认答案。efSearch 越大,搜索时保留的候选就越多,召回率越高,但检查的节点也越多,延迟随之上升。搜索复杂度公式中的常数项会被 efSearch 放大,所以它不是免费的。
- 每步比较 M 个邻居在城市路口只看当前能到达的几条路。
- 对数级跳转次数高层的远跳让步骤数与 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,观察收益是否值得额外内存。
- ef 表示候选队列长度每层保留多少个“有希望的候选点”。
- 指数衰减e 的负指数形式描述了“多找一点,增益变小”的饱和效应。
- 常数 c由数据集分布、距离度量和 M 决定。
6. 硬件成本
再好的城市路网也要耗油、占土地、雇人维护。HNSW 的索引必须常驻内存,因为搜索时的随机跳转无法依赖磁盘顺序读。内存占用约等于“节点数 × 平均层数 × 邻居数 × 每条边的字节数”。如果每条边用一个整型表示 4 字节,M=32,平均层数 4,那么一亿个节点大约需要 51.2 亿字节,约 5GB 以上。
硬件配置上,查询延迟主要由内存带宽和 CPU 计算距离的速度决定。向量维度越高,每次距离计算越贵;单机吞吐不够时,可以靠多核并行扛 QPS。如果数据量实在太大,常见做法是先用向量量化压缩维度,再配 HNSW;或者把冷数据放到 SSD,但必须接受随机 IO 带来的延迟抖动。
因此,硬件规划不是拍脑袋决定的。先用公式估算内存下限,再结合 QPS 和召回目标反推 CPU 核数,最后决定是否要用量化、分片或分层存储来降低单机压力。
- N 个节点城市里登记在册的建筑总数。
- 平均层数 L每栋建筑平均在几层路网上出现。
- 每条边的邻居数 M每层最多连几条路。
- 单条边字节数 b存储一个邻居编号要占用的内存空间。
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 演示
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()
node = Node(vec, self._random_level())新节点入住时随机决定自己住几层,相当于新店选楼层。peers = [n for n in self.nodes if n.level >= level]只找同层或更高层的节点修路,保证高层和底层都有邻居。peers.sort(key=lambda n: dist(n.vec, vec))按距离排序,把最近的 m 个节点设为邻居,模拟每层“修最近的路”。current = self.entry搜索永远从最高层入口出发,先走高速。if d < best: current = nb只向更近的邻居跳,这就是贪心导航的“下坡规则”。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 搜索起点通常在哪一层?
- A.最底层的全部节点
- B.最高层的稀疏节点← 正确答案
- C.随机的某一个中间层
- D.数据的中位数所在层
答案:B。HNSW 把搜索起点放在最高层,因为最高层节点最少但“视野”最广。这就像导航先上高架,而不是先钻进小巷。从最高层开始逐层下探,才能先粗后细地逼近目标。
Q2. HNSW 平均搜索复杂度近似为?
- 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. 控制建图时候选队列大小的参数是?
- A.控制查询候选数
- B.控制建图候选数← 正确答案
- C.控制层数分布
- D.控制最大邻居数
答案:B。建图时使用的候选队列参数是 efConstruction,它决定插入节点时每层保留多少个候选用来挑邻居;efSearch 是查询阶段的参数。两者名字很像,但作用阶段不同。
Q4. 增大 efSearch 带来的直接效果是?
- A.召回率上升,查询延迟上升← 正确答案
- B.召回率下降,查询延迟下降
- C.建图时间变长,内存翻倍
- D.精确率提高,召回率不变
答案:A。efSearch 越大,搜索时保留的候选点越多,越不容易漏掉真实近邻,召回率上升;但每步要比较的候选也更多,延迟随之上升。它主要影响查询阶段,对建图时间影响很小。
Q5. 以下哪些操作会增加 HNSW 内存占用?
- A.增大 M 邻居数量← 正确答案
- B.增大总数据量 N← 正确答案
- C.增大节点平均层数← 正确答案
- D.减小 efSearch 候选数
答案:ABC。内存主要来自节点间的边。M 越大,每个节点连的边越多;N 越大,节点总数越多;平均层数越高,节点在多个层都要存邻居。efSearch 是查询时临时候选,不常驻内存。
Q6. 当 M 视为常数时,HNSW 建图总复杂度常用表达是?
- 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) 说明节点层数分布是?
- A.全部节点平均分布在各层
- B.高层节点指数级减少← 正确答案
- C.每层节点数量完全相等
- D.层数只与插入顺序有关
答案:B。对均匀随机数取负对数后,大部分结果集中在 0 附近,只有少数结果很大。因此大多数节点落在底层,高层节点越来越少,形成金字塔分布。
Q8. 内存受限时通常可采用的策略有?
- A.调小 M 减少边数← 正确答案
- B.限制最大层数← 正确答案
- C.对向量做量化压缩← 正确答案
- D.调大 efSearch 提升精度
答案:ABC。减小 M 和限制层数都会减少边数,量化压缩能降低每条向量占用的字节数。efSearch 是查询期参数,不会减少索引驻留内存,反而可能增加临时开销。
Q9. HNSW 相对 IVF 的主要优势是?
- A.无需训练聚类中心← 正确答案
- B.不需要设置任何参数
- C.保证返回全局精确最近邻
- D.内存占用恒等于零
答案:A。IVF 需要先对数据做 KMeans 聚类训练,而 HNSW 通过逐点插入建图,不需要单独训练聚簇中心。HNSW 本质仍是近似检索,不保证全局精确最近邻。
Q10. 为什么 HNSW 能实现对数级搜索复杂度?
- A.靠高层稀疏索引快速逼近,再逐层精化← 正确答案
- B.靠全图两两直连,一步到达目标
- C.靠数据按字典序排序后二分查找
- D.靠随机游走且无需距离计算
答案:A。HNSW 的策略是“高层大步走,底层小步走”。高层节点少,能快速跨越大量区域;到目标附近后再逐层精调。它既不是全连接,也不是传统排序,更不需要随机瞎逛。