把语义检索从 Demo 推到生产时,两条曲线同时恶化:离线跑 200 条测试问题,前 10 里命中的只有 63 %;P99 查询延迟从 30 ms 涨到 140 ms。团队的分工是"召回归算法、延迟归工程",于是两边各自优化了两个月,汇合时发现它们动的是同一个参数。
向量索引(HNSW 类)不是"给定数据就精确返回"的结构——它是用近似换速度。而"近似到什么程度"由三个参数决定,其中一个可以在线调整,另外两个必须在建索引时定死。搞清楚哪个是哪个,比调数值更重要。
1. 三个参数,两种自由度
| 参数 | 作用 | 何时生效 | 可在线改 |
|---|---|---|---|
m |
每层节点的连接数 | 建索引时 | 否(要重建) |
ef_construction |
建索引时的候选集大小 | 建索引时 | 否(要重建) |
ef_search(或 ef) |
查询时的候选集大小 | 每次查询 | 是 |
第三行是唯一能在事故现场调的那个:调大它,召回上升、延迟上升;调小反之。所以在生产上,正确做法是"m 与 ef_construction 一次定到位,ef_search 留作运行期旋钮"。
// 一次调参的正确顺序(反了会白做)
1 m 与 ef_construction 定死(重建成本高,所以先定)
2 扫 ef_search,找"召回达标的最小值"
3 加上生产过滤条件重测;若塌陷 → 改分区,而不是继续调参
// 先乱调 ef_search 的后果:每次重建都要把这一步重做一遍
// 一次真实的参数扫描(100 万条 768 维向量,单机)
m=16 ef_c=100 ef_s=40 → 召回@10 0.78 P99 22ms
m=16 ef_c=100 ef_s=100 → 召回@10 0.94 P99 48ms
m=32 ef_c=200 ef_s=40 → 召回@10 0.89 P99 31ms ← 建索引时间 ×1.8
m=32 ef_c=200 ef_s=100 → 召回@10 0.97 P99 62ms
// 结论:ef_search 是斜率,m/ef_construction 是截距
2. m 决定的是"内存与召回上限"
m 越大,图越稠密,理论上能找到更好的邻居;代价是每向量多存 2×m 个整数 id(内存直接涨),以及建索引时间显著上升。
m |
内存(每百万向量) | 典型召回上限 | 建索引时间 |
|---|---|---|---|
| 8 | 约 0.6 GB | 0.80 | 快 |
| 16 | 约 1.1 GB | 0.92 | 1× |
| 32 | 约 2.1 GB | 0.96 | 1.8× |
| 48 | 约 3.2 GB | 0.97 | 2.6× |
第二列只算图结构本身,不含原始向量(768 维 float32 是 3 KB/条,百万条约 3 GB)。所以总内存 = 向量本体 + 图,而图这部分的增长是纯开销——它不带来任何新的检索能力,只是让"找邻居"这一步走得更准。(768 维 float32 是 3 KB/条,百万条约 3 GB)。所以总内存 = 向量本体 + 图,而图这部分的增长是纯开销。
3. ef_search 是运行时旋钮,但要小心"召回塌陷"
ef_search 调大能提升召回,但它有个隐蔽的失效模式:当查询带过滤条件时,候选集会被过滤掉大量结果。
| 场景 | 现象 |
|---|---|
| 无过滤 | ef_search=100 召回稳定 0.94 |
| 过滤掉 80 % 数据 | 同样 ef_search=100,实际可用候选只剩 20 个 → 召回掉到 0.6 |
| 过滤掉 99 % | 召回可能比随机还低(越"精确"越差) |
// 带过滤的召回测量:同一批 query、三种过滤比例
$ for f in 0.0 0.5 0.9; do python3 eval.py --ef 100 --filter-ratio $f | grep recall; done
recall@10 0.94 filter 0.0
recall@10 0.81 filter 0.5
recall@10 0.55 filter 0.9 ← 同参数,几乎不可用
// 这条曲线比"召回 0.94"这一个数字有用得多
第三行的机制是:ANN 在图上走的时候按距离扩展,过滤在结果集上做——于是它走到的邻居全被过滤掉了,而真正满足条件的向量从未被访问到。这类问题在"按租户/按时间范围过滤"的场景里极常见。
// 两种处理方式,代价不同
方式一 提高 ef_search(例如 100 → 500):召回恢复到 0.9,延迟 ×4
方式二 分区/预过滤(先按租户分 collection,再在里面搜):召回 0.93,延迟几乎不变
// 判据:过滤比例 > 50% 时,分区优于调参
4. 建索引时的两个时间账
| 账 | 影响 |
|---|---|
| 建索引耗时 | 百万级 768 维约 10–30 分钟(m=16),m=32 约 2 倍 |
| 建索引期间的服务 | 大多数引擎支持"边写边建",但搜索质量在构建期不稳定 |
第二行常被忽略:新数据写入后,索引并不会立刻"变好"。如果业务在批量导入后立刻跑评测,会得到偏低的召回——因为图还没收敛。这也是"离线评测好、上线差"的常见原因之一。
5. 怎么定这三个数:从 SLA 反推
| 步骤 | 动作 | 产出 |
|---|---|---|
| 1 | 定召回目标(例如 @10 ≥ 0.9) | 质量底线 |
| 2 | 固定 m=16、ef_c=200 建一份索引 |
基线 |
| 3 | 扫 ef_search,找"召回达标的最小值" |
延迟预算 |
| 4 | 若延迟仍超,再试 m=32 重建(换内存换速度) |
取舍点 |
| 5 | 把"过滤比例"纳入评测(带真实过滤条件跑) | 真实召回 |
第五步是全篇最实用的一条:评测集必须带上生产里的过滤条件。不带过滤的召回数字,在生产上会系统性偏高。
// 一个可用的评测脚本骨架(带过滤)
for q in eval_set:
approx = index.search(q.vec, k=10, ef=EF, filter=q.filter)
exact = brute_force(q.vec, k=10, filter=q.filter) // 小样本可暴力算真值
recall_at_10 += len(set(approx) & set(exact)) / 10
// 关键:exact 必须用同一过滤条件算,否则召回率无意义
6. 排错:五种"召回与延迟同时差"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 召回 0.6、延迟 100 ms | ef_search 太小 |
先调它 |
调大 ef_search 召回不涨 |
图质量差(m 太小) |
重建,m=32 |
| 带过滤时召回骤降 | ANN 与过滤器交互 | 分区 + 预过滤 |
| 刚导入就评测偏低 | 图未收敛 | 等待或触发优化 |
| 内存超预期 | 只算了向量没算图 | 按第 2 节的表补预算 |
第三行是最容易被误判成"模型不行"的一类:换 embedding 模型前先试分区,成本差两个数量级。
7. 边界:什么情况不该用 HNSW
| 场景 | 为什么 | 替代 |
|---|---|---|
| 数据量 < 1 万 | 暴力搜索足够快 | 直接精确检索 |
| 强过滤(> 90 % 被排除) | 候选集塌陷 | 分区 / 按维度分库 |
| 要求 100 % 召回 | 近似索引做不到 | 精确检索 + 倒排 |
| 写入极频繁、查询很少 | 重建成本高 | 先落库,批量建 |
| 内存受限 | 图本身就要 GB 级 | 量化(PQ)或换磁盘索引 |
判断口径一句话:HNSW 的参数不是"调优",是在"召回上限(m)、构建成本(ef_construction)、运行延迟(ef_search)"三者之间分配预算——所以正确的顺序是"先定 m 与 ef_construction 到能用,再拿 ef_search 换 SLA",而不是出事时把三个一起调。
8. 自测
- 三个参数里哪个可以在线调?另外两个为什么必须建索引时定死?
m增大带来什么收益、什么代价?为什么"总内存 = 向量本体 + 图"?- 为什么带过滤条件时
ef_search调大也可能救不了召回?机制是什么? - 建索引期间的服务质量为什么不稳定?这对"导入后立刻评测"有什么影响?
- 从 SLA 反推参数的五步里,哪一步最容易被跳过、后果最大?
- 什么场景根本不该用 HNSW?判据是什么?
9. 一句话总结
HNSW 的三个参数不是三个独立旋钮,而是一份预算的三个出口:m 定召回上限与内存、ef_construction 定构建成本、ef_search 才是运行期可调的斜率。最隐蔽的失效是带过滤时的候选集塌陷(过滤比例 > 50 % 就该分区而不是调参),而最常被跳过的一步是评测集不带生产过滤条件——那会让召回数字系统性偏高,也会让人误以为是模型不行。