2246 字
约 7 分钟
11
S20-A28 · 向量索引参数:为什么"召回率低"和"查询慢"是同一个旋钮

把语义检索从 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. 自测

  1. 三个参数里哪个可以在线调?另外两个为什么必须建索引时定死?
  2. m 增大带来什么收益、什么代价?为什么"总内存 = 向量本体 + 图"?
  3. 为什么带过滤条件时 ef_search 调大也可能救不了召回?机制是什么?
  4. 建索引期间的服务质量为什么不稳定?这对"导入后立刻评测"有什么影响?
  5. 从 SLA 反推参数的五步里,哪一步最容易被跳过、后果最大?
  6. 什么场景根本不该用 HNSW?判据是什么?

9. 一句话总结

HNSW 的三个参数不是三个独立旋钮,而是一份预算的三个出口:m 定召回上限与内存、ef_construction 定构建成本、ef_search 才是运行期可调的斜率。最隐蔽的失效是带过滤时的候选集塌陷(过滤比例 > 50 % 就该分区而不是调参),而最常被跳过的一步是评测集不带生产过滤条件——那会让召回数字系统性偏高,也会让人误以为是模型不行。

S20-A28 · 向量索引参数:为什么"召回率低"和"查询慢"是同一个旋钮
https://www.clxhxhhr.top/posts/6752/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。