HNSW 调参实战:M、ef 与召回率的三角关系

《向量数据库选型指南》里我们把 HNSW 描述为「当前向量检索的主流索引」,但选型文章没说的是:同一个 HNSW,参数配置不同,表现可以差出一个量级——召回率从 95% 掉到 70%,延迟从 2ms 涨到 40ms,都可能是三两个参数的事。更麻烦的是,这些参数藏在不同数据库的配置项深处,名字还不统一。这篇把 HNSW 的参数体系一次讲透:每个参数动的是召回、延迟、内存三角里的哪条边,以及拿到一个真实业务该怎么按顺序调。

十秒回顾:HNSW 为什么又快又准

HNSW(Hierarchical Navigable Small World,Malkov 与 Yashunin 2016 年的论文)把所有向量组织成一张多层图:底层包含全部向量,往上每一层都是下一层的稀疏抽样,越往上节点越少、连线越「长」。查询时从顶层入口点出发,在每层做贪心搜索——不断走向当前节点的邻居里离查询最近的那个——到本层无法更近就下降一层,直到底层完成精细搜索。上层负责「高速公路」般的远距离跳跃,把搜索路径从 O(N) 压到近似对数;底层负责局部精修。

它是近似最近邻(ANN)算法:贪心搜索不保证找到真正的 top-k,找到的比例就是召回率(recall@k)。HNSW 的全部调参艺术,本质都是在控制「贪心搜素的视野有多宽」——视野越宽越准,但每一步要看的邻居越多,计算和内存也越多。

三个核心参数各管什么

参数 位置 管什么 调大的收益 代价 典型范围
M 建图 每个节点在每层保留的最大连接数 图更稠密,召回上限抬高 内存近似线性上涨,建图变慢 16~48
efConstruction 建图 建图时候选邻居队列长度 图质量更好,为高召回打地基 只影响建图速度与内存,不影响查询延迟 100~500
efSearch 查询 查询时候选队列长度 召回率直接上升 查询延迟近似线性上涨 ≥ top_k,常用 50~500

M 是内存账,也是召回的天花板。 每个向量在底层要存 M 条边(上层递减,论文里层内连接数约为 M/2),64 维 float32 向量本身 256 字节,M=16 时边表再添一百多字节——内存占用轻松比裸向量翻倍。M 调到 48 以上后召回收益边际递减,内存却继续线性走,所以「M 越大越好」是常见误区:M 决定的是召回上限,上限够用之后,剩下的精度该由 efSearch 买,它不花内存。

efConstruction 只在建图时花钱。 它控制插入每个新点时维护的动态候选列表长度,列表越长,为新点挑选的邻居质量越高,图的结构越好。它不影响线上查询延迟(这也是它最容易被放心调大的原因),只拖慢导入。批量灌库时把 efConstruction 拉到 200~500,用一次性建图时间换一劳永逸的图质量,几乎总是划算的。

efSearch 是线上唯一能随时动的旋钮。 它是查询时维护的候选队列长度:队列越长,贪心搜索「回头看」的余地越大,召回越高,每一步的邻居计算也越多,延迟近似线性上涨。关键约束:efSearch 必须大于等于 top_k——队列都比要返回的结果短,召回率无从谈起。这是新手最常见的翻车点:业务要 top-50,efSearch 还是默认的 16,召回率怎么都上不去,还以为是 embedding 模型的问题。

调参的正确姿势:先建图,再扫 efSearch

拿到业务后不要三个参数一起拧,按这个顺序来:

第一步,定 M。 看两个约束:内存预算和数据规模。千万级以下、内存宽裕,M=32 起步;亿级或内存敏感,M=16。维度很高(1024 维以上)时图更难描述局部结构,M 可以适度上调到 48。这一步基本不需要实验,拍板即可。

第二步,efConstruction 固定在 200~300 建一次图。 除非后面发现 efSearch 扫穿了也到不了目标召回,否则不回头动它——那是图质量本身不够的症状,才需要重图(efConstruction 500 或 M 上调)。

第三步,扫 efSearch,画召回-延迟曲线。 固定一组真实查询(这正是《评测集构建》强调的:没有测试集,调参就是玄学),对每个 efSearch 值测 recall@k 和 P99 延迟,选满足业务召回目标的最小 efSearch:

import hnswlib, numpy as np

dim, num, k = 768, 100_000, 10
data = np.random.rand(num, dim).astype("float32")
queries, truth = data[:1000], None

# 暴力算真值,作召回率分母
d = queries @ data.T
truth = np.argsort(-d, axis=1)[:, :k]

index = hnswlib.Index(space="cosine", dim=dim)
index.init_index(max_elements=num, M=32, ef_construction=256)
index.add_items(data[1000:], np.arange(1000, num))

for ef in (32, 64, 128, 256, 400):
    index.set_ef(ef)                       # 线上随时可调
    hits = 0
    for i, q in enumerate(queries):
        pred = index.knn_query(q, k=k)[0][0]
        hits += len(set(pred) & set(truth[i]))
    print(f"efSearch={ef:4d}  recall@{k}={hits / (len(queries) * k):.3f}")

拿典型曲线举例(具体数字随数据分布、维度变化,仅示意量级):efSearch=64 时召回可能只有 0.90,128 到 0.97,256 到 0.995。如果业务要求 0.98,答案就是 128~200 之间那个最小值——而不是无脑给到 512,让 P99 延迟白白翻倍。

建图与灌库的工程细节

调参数之外,建图环节的几个工程决策同样影响最终质量。批量插入优于逐条:hnswlib 与多数引擎在批量 add 时会共享邻居搜索的中间结果,百万级灌库从数小时压到几十分钟是常见量级;逐条插入还会让图的生长顺序偏向「先来者聚集」,批量乱序插入反而有利于图的均匀性。乱序插入:如果业务数据有天然的簇结构(同一类文档集中入库),插入顺序会固化这种偏斜——随机打乱后再灌,图的连通性更好。容量预留:HNSW 建好后的扩容(超过 max_elements 后的 resize)部分引擎要重建部分结构,灌库前把容量余量按「近期增长预期 × 1.5」预留,避免线上扩容的停顿。持久化与加载:图索引的加载时间是 O(数据量) 的内存映射过程,亿级索引冷启动要按分钟计——服务的高可用方案里要有「多副本预热」这一条,别让一次重启把检索层变成雪崩源。

线上召回监控:别等用户发现

HNSW 的召回率会随数据分布漂移悄悄下滑:新语料的主题与建图时的分布差异越大,贪心搜索的「高速公路」越不认识新路。线上监控召回率做不到全量对比真值(那等于暴力扫描),标准做法是保底子集抽样:留 500 条带真值的标准查询(《评测集构建》的产出一部分),每小时抽 50 条打一遍,记录 recall@k 的滑动平均——跌破基线两个百分点就告警。另外两个监控信号:「搜索到图边界」的比例(查询路径反复触底、候选队列不增长,是分布漂移的前兆)与延迟分布(P99 突涨常意味着内存压力或图退化)。触发告警后的处置通常是重建图(用更贴当前分布的语料、更大的 efConstruction)——重建是这类系统的常规运维动作,不是事故。

各引擎的参数对照

不同数据库对这三个参数的暴露方式不同,动手前先对上号:hnswlib 直接暴露 M / ef_construction / set_ef;Faiss 的 HNSW 索引参数同源(M、efConstruction、efSearch,另外多一层 ef_L 入口点搜索深度);Milvus 的 HNSW 配置在索引参数里给 M / efConstruction,查询参数里给 ef;Qdrant 的 hnsw_config 里是 m / ef_construct,查询时 hnsw_ef;Elasticsearch/OpenSearch 的 HNSW 是 m / ef_construction,查询端 ef_search 是索引级设置。参数名字面差异不小,但语义都回到本篇的三句话:M 管内存与天花板、efConstruction 管地基、efSearch 管线上表现——对上语义,文档怎么改都不怕。

三个高频坑

坑一:top_k 动态变化的服务。 同一个接口有时返回 top-5 有时返回 top-100,efSearch 却是静态配置。解法是查询时按 max(efSearch_base, top_k * 系数) 动态设置,绝大多数客户端库都支持每次查询单独传 ef。

坑二:加了过滤条件后召回骤降。 带元数据过滤的 HNSW 查询,图遍历时会跳过被过滤掉的节点,可到达的「邻居链路」变稀疏,同样的 efSearch 召回率明显下滑(《元数据过滤》一篇专门讨论这个问题与解法:过滤查询要把 efSearch 放大数倍)。

坑三:拿随机向量调参。 真实嵌入向量的分布高度聚簇(同主题文档挤在一起),随机向量的调参结论不能外推。测试查询也一样——务必用真实业务的查询分布,否则曲线漂亮、上线翻车。

一份可执行的调参记录模板

调参最怕「调过就忘」——三个月后召回下滑,没人记得当时的参数组合与依据。建议每次调参留下五行记录:日期与触发原因(新建索引、召回告警或语料大版本更新);数据概况(向量数、维度、嵌入模型版本——这三样决定结论的可迁移性);参数组合(M、efConstruction、efSearch,含改动前后值);评测结果(评测集名称与版本、recall@k 前后对比、P50/P99 前后对比);遗留事项(「efSearch 到 256 时 P99 逼近预算上限,语料增长先考虑 M 上调重建」之类的备忘)。五行模板贴进团队的运行手册,配合前文的监控告警,HNSW 的「玄学抖动」就变成有台账、有基线、有处置路径的常规工程——这五行记录的价值,在第二次调参时就会显现。

维度与数据分布的影响

调参前还有两个「先于参数」的变量要认清。维度:维度越高,图越难描述局部邻域(距离集中现象),同样的召回目标需要更大的 M 与 efSearch——768 维与 1536 维的同参数配置,召回表现可能差出一截,换高维模型时别沿用旧参数直接上线。数据分布:真实嵌入高度聚簇(同主题文档挤在一起),聚簇数据对 HNSW 是友好的(局部导航高效),但精确重复向量是坑——完全相同的向量距离为零且并列,贪心搜索在并列点间反复横跳,浪费预算还可能导致 top-k 被重复内容占满;入库前对内容哈希去重(同一文档的多版本、模板化内容)既省内存又稳检索。这两个变量写进调参记录模板的数据概况里,参数结论才有跨数据集的可比性。

小结

HNSW 调参的完整心法可以压成一句话:M 定天花板(内存账),efConstruction 定地基(一次性建图账),efSearch 定线上表现(延迟账,随时可调)。先按预算拍 M 和 efConstruction,再用真实查询扫 efSearch 选满足召回的最小值,最后记得 top_k 与 efSearch 的硬约束、过滤场景的放大补偿。做到这三步,向量库的「玄学抖动」基本就能归因了。

参考资料

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?