HNSW 算法详解

Hierarchical Navigable Small World

Vector Search

用多层近邻图,在海量向量里快速找到相似项

HNSW 是一种近似最近邻搜索算法。它把每个向量看成图上的节点,用“高层稀疏、底层密集”的多层小世界图,把一次高维搜索变成从粗到细的图遍历。

一句话:HNSW 先在很稀疏的高层图里快速接近目标区域,再逐层下降到更密的图,最后在底层用一个候选队列做精细搜索。

高召回 通过增大 efSearch 可以换取更接近精确搜索的结果。
低延迟 图搜索只访问少量节点,常用于实时语义检索和推荐召回。
占内存 每个节点保存邻居链接,M 越大,图越准也越重。

分层搜索演示

红点是当前节点,橙点是候选节点,绿点是最终近邻。

候选池越大,访问节点越多,召回通常越高。

Intuition

HNSW 的三个关键直觉

它不是把所有向量逐个比较,而是提前建好一张“知道谁和谁相近”的图。查询时,算法沿着越来越近的节点移动。

1

近邻图:相似的向量互相连接

每个向量节点保留若干个邻居。如果当前节点离查询向量还不够近,就沿着更近的邻居继续走。这样能跳过大部分无关节点。

2

小世界:少量长边带来快速跳转

纯本地近邻图容易陷在局部。HNSW 通过分层结构保留一些跨区域连接,让搜索先用长距离跳转接近目标区域。

3

层级结构:高层粗搜,底层精搜

高层节点少、边稀疏,像快速通道;底层包含所有节点,负责最后的细粒度候选扩展和排序。

Search

查询时发生了什么

HNSW 查询可以分成“贪心下楼”和“底层候选扩展”两段。前者快,后者稳。

1

从入口点进入最高层

索引维护一个最高层入口点。查询向量进来后,先和入口点计算距离。

2

高层使用贪心搜索

检查当前节点的邻居,只要有更近的邻居就移动过去。直到这一层没有更近节点。

3

下降到下一层重复

把上一层找到的最近节点作为下一层起点,继续向目标区域靠近。

4

底层用 efSearch 扩展候选池

在第 0 层不只保留一个当前最优,而是维护候选队列,访问更大邻域后返回前 k 个最近节点。

Indexing

插入一个新向量时,索引如何长出来

HNSW 是增量构建的。每来一个新向量,算法随机决定它能进入多高的层,然后在每一层找到合适邻居并连边。

1

随机采样最高层

绝大多数节点只在第 0 层,少数节点会被提升到更高层。层数通常服从指数衰减分布。

2

从入口点搜索到目标区域

和查询类似,先在高层贪心下降,找到每一层接近新节点的位置。

3

用 efConstruction 找候选邻居

在需要插入的层里扩大搜索,得到一批可能连接的邻居。这个候选池越大,建图质量通常越好。

4

选择邻居、建立双向边、裁剪过密连接

每个节点最多保留约 M 条边。裁剪时不只选最近的点,也会保留方向分散的点,减少局部拥挤。

Tuning

几个参数决定速度、召回和内存

实际使用 HNSW 时,调参就是在查询延迟、构建时间、召回率和内存占用之间找平衡。

参数 含义 调大后的影响 经验方向
M 每个节点保留的最大邻居数,控制图的连通密度。 召回更好,搜索更稳,但内存和构建成本上升。 常见范围 16 到 64;数据复杂、召回要求高时增大。
efConstruction 构建索引时的候选池大小。 图质量更好,之后查询召回更高,但建索引更慢。 通常大于 M,比如 100 到 400;离线构建可取更大。
efSearch 查询底层时保留和扩展的候选池大小。 召回提升,访问节点更多,查询延迟增加。 线上常按 SLA 动态调整;它必须不小于返回的 k
k 最终返回的最近邻数量。 需要更大的候选池支撑,否则尾部结果质量不稳定。 Top 10、Top 50、Top 100 的配置通常不同。
距离函数 例如 L2、内积、余弦相似度。 不直接是调大调小问题,但会改变图结构和排序结果。 余弦检索常先做向量归一化;训练和检索口径要一致。

Pseudo Code

把 HNSW 看成两个核心过程

真实实现会处理并发、删除、内存布局和 SIMD 距离计算,但主干逻辑大致如下。

function search(query, k, efSearch):
    entry = graph.entryPoint
    current = entry

    for level from graph.maxLevel down to 1:
        changed = true
        while changed:
            changed = false
            for neighbor in current.neighbors[level]:
                if distance(query, neighbor) < distance(query, current):
                    current = neighbor
                    changed = true

    candidates = maxHeap()
    results = maxHeap()
    visited = set(current)

    push candidates with current
    push results with current

    while candidates is not empty:
        nearest = pop closest from candidates
        worst = farthest item in results

        if distance(query, nearest) > distance(query, worst):
            break

        for neighbor in nearest.neighbors[0]:
            if neighbor in visited:
                continue
            visited.add(neighbor)

            if results.size < efSearch
               or distance(query, neighbor) < distance(query, worst):
                push candidates with neighbor
                push results with neighbor
                keep only efSearch closest in results

    return top k closest from results
function insert(vector):
    newLevel = randomLevel()
    entry = graph.entryPoint
    current = entry

    for level from graph.maxLevel down to newLevel + 1:
        current = greedySearch(vector, current, level)

    for level from min(newLevel, graph.maxLevel) down to 0:
        pool = searchLayer(
            query = vector,
            entry = current,
            ef = efConstruction,
            level = level
        )

        neighbors = selectNeighbors(pool, M)
        connectBidirectional(vector, neighbors, level)

        for neighbor in neighbors:
            if neighbor.degree(level) > M:
                neighbor.neighbors[level] =
                    selectNeighbors(neighbor.neighbors[level], M)

        current = closest item in pool

    if newLevel > graph.maxLevel:
        graph.entryPoint = vector
        graph.maxLevel = newLevel

Practice

使用 HNSW 时最容易踩的点

HNSW 的效果往往很好,但它不是魔法。数据分布、过滤条件和工程约束都会影响最终体验。

近似算法不保证总是精确 Top k

需要用标注集或暴力搜索小样本评估 recall@k,再决定参数。

过滤条件会破坏图搜索路径

先过滤再图搜可能候选太少,边搜边过滤可能召回下降;复杂过滤通常需要额外策略。

删除和更新通常不是完全免费的

很多实现采用标记删除、后台合并或重建索引,频繁更新要提前设计。

距离口径要和 embedding 模型匹配

余弦、内积和 L2 的排序关系不同;归一化策略不一致会让结果看起来“飘”。

内存预算要从链接数量估算

向量本体之外,还要存节点 ID、层级、邻居列表和删除标记。M 对内存很敏感。

参数最好按业务 SLA 分档

低延迟接口可以用较小 efSearch,高质量重排链路可以给更大的候选池。