KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 召回率-延迟曲线 — keel 龙骨
这一章是本课证据最厚的一章:三条 nlist × 四个 nprobe 一共 12 组配置、两种 M × 四个 efSearch 一共 8 组配置,全部本机实测。看完你会拿到一个能直接用的判断——IVF 的 nprobe=1 召回只有 2.5%,而想要 100% 召回得把 nprobe 调到等于 nlist,那时它比 Flat 还慢。
这一章是本课证据最厚的一章:三条
nlist× 四个nprobe一共 12 组配置、两种M× 四个efSearch一共 8 组配置,全部本机实测。看完你会拿到一个能直接用的判断——IVF 的nprobe=1召回只有 2.5%,而想要 100% 召回得把nprobe调到等于nlist,那时它比 Flat 还慢。
现场
业务方说「检索要准一点,但也别太慢」。这句话没法直接落地,你得把它变成一组可验收的数字:recall@10 ≥ 多少、p99 ≤ 多少毫秒、QPS ≥ 多少。定完这三个数,调参就有了方向——不是把参数往大调,而是在满足召回的前提下找最便宜的那个点。
这一章先把曲线量出来,再讲怎么在上面画线。
IVF:nlist × nprobe 的完整表
数据:10 万条 128 维随机向量,1000 条查询,recall@10 相对 Flat 的精确结果。训练在全部 10 万条上做(39 × nlist 的护栏都满足)。原始输出(03-ivf-nlist-nprobe.txt):
nlist | nprobe | recall@10 | qps | avg_ms
64 | 1 | 0.0666 | 122147.8 | 0.0082
64 | 4 | 0.2035 | 30487.1 | 0.0328
64 | 16 | 0.5434 | 8296.9 | 0.1205
64 | 64 | 1.0000 | 2011.0 | 0.4973
256 | 1 | 0.0366 | 190759.6 | 0.0052
256 | 4 | 0.1091 | 111390.8 | 0.0090
256 | 16 | 0.2821 | 29974.2 | 0.0334
256 | 64 | 0.6324 | 7496.7 | 0.1334
1024 | 1 | 0.0255 | 196540.9 | 0.0051
1024 | 4 | 0.0666 | 164298.0 | 0.0061
1024 | 16 | 0.1692 | 81504.9 | 0.0123
1024 | 64 | 0.3789 | 26552.2 | 0.0377
这张表要横着读、竖着读各一遍。
横着读(固定 nlist,加大 nprobe):召回单调上升,QPS 单调下降。nlist=256 从 nprobe=1 到 64,召回 0.0366 → 0.6324,QPS 从 190760 掉到 7497。这是预期内的权衡,没什么意外。
竖着读(固定 nprobe,加大 nlist):反直觉的地方来了。nprobe=1 时,nlist 从 64 加到 1024,召回从 0.0666 降到 0.0255。因为 nprobe=1 只探一个列表,nlist 越大每个列表越小、探到的数据越少,漏得越多。nlist 调大并不是「更准」,它只是把空间切得更细,需要你用更大的 nprobe 去配。
那条 1.0000 的行:nlist=64, nprobe=64 召回 1.0——因为 nprobe 等于 nlist 就等于探测所有列表,退化成全扫。注意它的 QPS 2011,比第 00 章 Flat 的 6278 还低。IVF 全扫时不但没省事,还多了粗量化器的开销。这条线画出来是为了提醒你:把 nprobe 调到 nlist 不是「更保险」,是把 IVF 退化成 Flat 的劣化版。
这批数据的特殊性:随机均匀数据没有簇结构,IVF 的假设(相近的点聚在一起)不成立,所以召回普遍偏低——nlist=1024 想拿到 0.9 以上基本不可能。真实 embedding 有语义簇,同一组参数会好很多。但表里的相对关系(nprobe 的作用、nlist 的作用、全扫的代价)是通用的。
曲线为什么是凸的
把 nlist=256 那一列单独抽出来看召回随 nprobe 的变化:1→4 涨 0.0725(0.0366→0.1091),4→16 涨 0.1730(0.1091→0.2821),16→64 涨 0.3503(0.2821→0.6324)。绝对涨幅看着是越来越大,但要同时看 QPS:1→4 掉了 79369 QPS 换来 0.0725 召回,16→64 掉了 22478 QPS 换来 0.3503 召回。越到后面,每单位延迟换来的召回越多——因为前几个 nprobe 主要是在补「几乎全漏」的窟窿,后面才真正开始覆盖查询附近的簇。
把这个观察落到调参上:不要以为「参数要早点调」是对的。对你手上的数据,应该从最小的 nprobe 往上扫,画出你自己的「召回 vs QPS」散点,找到召回开始快速上升、QPS 还没塌的那一段,在那里取值。本机这批数据的那段大约在 nprobe=16 到 64 之间。
nlist 该取多大
nlist 决定了每个倒排列表平均挂多少条向量。本机 10 万条数据下:nlist=64 时每个列表约 1562 条,nlist=256 约 390 条,nlist=1024 约 98 条。列表越大,探一个 nprobe 扫到的数据越多——所以固定 nprobe=16 时,nlist=64(0.5434)的召回反而高于 nlist=256(0.2821),代价是 QPS 从 29974 掉到 8297。
这条关系说明 nlist 不是「越大越细越好」,而是要和你的 nprobe、以及每个列表的目标大小一起定。列表太小会让 nprobe 需要调得很大才能覆盖查询附近的区域;列表太大则单次探测扫的向量太多、延迟高。经验上每个列表留几百条是个不错的起点,但要用第 02 章那套「训练点 ≥ 39 × nlist」的护栏反查训练集够不够。
召回有上限
还有一个容易被忽略的事实:在这批随机数据上,HNSW M=32 把 efSearch 拉到 400 也只有 0.9364,IVF nlist=256 拉到 nprobe=64 只有 0.6324。想逼近 1.0,HNSW 得继续加大 efSearch,IVF 得把 nprobe 加到 nlist(等于全扫)。也就是说,在不做全扫的前提下,ANN 的召回存在一个由数据分布决定的天花板。如果你定验收线时拍了个 0.99,很可能怎么调参都到不了——不是参数没调对,是这批数据和这个索引组合的上限就在那里。这时候要么接受更低的验收线,要么换更强的索引(更大 M、更大 nlist + 更大 nprobe),要么回到第 00 章的判断:这个场景是不是该用精确检索。
训练耗时也在同一份留档里:
nlist need=39*nlist have train_s
64 2496 100000 0.067
256 9984 100000 0.430
1024 39936 100000 1.778
nlist 越大训练越久(64→1024 涨了 26 倍),但都在秒级。真正贵的是第 02 章里 IVF-PQ 的训练(m=32 时 28.2s),别把两者混了。
把曲线读成预算
曲线量出来之后,它要变成两样东西:一份上线配置,和一条告警线。
配置来自「达标的最便宜点」。假设你的验收是「recall@10 ≥ 0.5,QPS ≥ 3000」,用 nlist=64 那一列:nprobe=16 召回 0.5434、QPS 8297,两条都满足;再往下的 nprobe=4 召回 0.2035 不达标。所以上线配置是 nlist=64, nprobe=16,而不是「保险一点用 nprobe=64」——后者 QPS 只有 2011,直接违反第二条验收。
告警线来自曲线的斜率。你要盯的是在固定参数下召回随时间的变化:数据分布漂移会让同一条曲线整体下移,今天 0.5434 的配置过几个月可能只有 0.45。所以线上要定期用抽样查询重算召回,和上线时记录的那条基线比。只监控延迟的团队会发现不了这件事——召回下降时延迟通常还更好了(因为索引更不适应数据,扫到的有效邻居更少)。
还有一件事,给验收线留一点余量。本机这批数据里,参数在临界点附近的 QPS 波动不小(同一配置重跑差个百分之十几很常见)。把验收线定在「刚好达标」的参数上,一次流量波动就可能掉到线下。取达标点的上一个档位、或者把验收阈值降 5 个百分点,比事后救火便宜。
HNSW:efSearch 曲线
同一批数据换 HNSW(IndexHNSWFlat,efConstruction=200)。原始输出(05-hnsw-efsearch.txt):
M | efSearch | recall@10 | qps | avg_ms
16 | 10 | 0.1042 | 406702.5 | 0.0025
16 | 40 | 0.2787 | 130662.6 | 0.0077
16 | 100 | 0.4655 | 68331.7 | 0.0146
16 | 400 | 0.7819 | 14487.5 | 0.0690
32 | 10 | 0.1686 | 147627.6 | 0.0068
32 | 40 | 0.4350 | 57717.1 | 0.0173
32 | 100 | 0.6726 | 24393.6 | 0.0410
32 | 400 | 0.9364 | 7011.6 | 0.1426
efSearch 的角色和 nprobe 一样:候选池越大越准越慢。M 是建索引时的图密度,M=32 在每个 efSearch 下都比 M=16 准(ef=400 时 0.9364 vs 0.7819),代价是内存(第 02 章:784.2 vs 656.3 B/vec)和建图时间(5.4s vs 3.1s)。
IVF 和 HNSW 放在同一张坐标上
把两份数据叠起来看,在「QPS 几千」这一档:
| 配置 | recall@10 | QPS |
|---|---|---|
IVF nlist=256, nprobe=64 |
0.6324 | 7496.7 |
IVF nlist=64, nprobe=16 |
0.5434 | 8296.9 |
HNSW M=32, efSearch=400 |
0.9364 | 7011.6 |
HNSW M=16, efSearch=400 |
0.7819 | 14487.5 |
同样是 ~7000 QPS,HNSW M=32, efSearch=400 的召回是 0.9364,IVF 最好的那条只有 0.6324。在这批无簇结构的数据上,HNSW 明显压过 IVF——因为图索引不依赖「簇」这个假设。这也解释了为什么很多场景默认推荐 HNSW:它用内存换来了对数据分布的鲁棒性。
但这不是「HNSW 一定更好」。IVF 有 HNSW 没有的东西:nlist 可以按数据量切得很细,内存占用低(521 B/vec vs 784 B/vec),而且 IVF-PQ 能在它基础上再压缩。数据量大、内存紧时,IVF-PQ 是 HNSW 给不了的选项。
怎么定验收线
flowchart TD
S["准备真实查询集 + 精确基准 (Flat)"] --> R["算 recall@10 / p99 / QPS 基线"]
R --> T{"业务能接受的<br/>recall@10 下限?"}
T -->|定出来, 例如 0.95| P["网格搜索: nprobe 或 efSearch<br/>从最小往大扫"]
P --> M{"达到 0.95?"}
M -->|是| CHEAP["取刚好达标的最便宜配置<br/>记为候选"]
M -->|否| UP["换索引家族或加大 M/nlist<br/>再回网格搜索"]
UP --> P
CHEAP --> V{"p99 同时满足?"}
V -->|是| SHIP["上线, 并监控线上 recall 漂移"]
V -->|否| UP
style S fill:#e3f2fd,color:#0d3b66
style R fill:#f1f8e9,color:#33691e
style T fill:#fff3e0,color:#8a4b00
style P fill:#e8f5e9,color:#1b5e20
style M fill:#fff3e0,color:#8a4b00
style CHEAP fill:#e8f5e9,color:#1b5e20
style UP fill:#fff3e0,color:#8a4b00
style V fill:#fff3e0,color:#8a4b00
style SHIP fill:#e8f5e9,color:#1b5e20
三个要点:
- 验收线用真实查询集算,不用随机查询。本课的查询是随机生成的,它的「正确答案」只对随机数据有意义;线上要用真实用户查询,最好有标注。
- 从最小参数往大扫,取刚好达标的那个。默认参数往往是「安全的大值」,白付延迟。以
nlist=256为例,若你的目标是 recall ≥ 0.28,nprobe=16(0.2821)就够了,别直接设 64。 - recall 和业务指标不是线性的。从 0.90 调到 0.95 可能要付一倍的延迟,但业务上可能完全没感知。先拿业务数据确认「recall 差 5 个点,下游指标差多少」,再决定值不值得。
主动破坏
把参数推到两个极端:
nprobe=1(或efSearch取最小值):本机 IVFnlist=256, nprobe=1召回 0.0366、nlist=1024, nprobe=1召回 0.0255。这不是「检索」,是抽签——10 条里大约只有 0.25 条是对的。nprobe=nlist:召回回到 1.0,但 QPS 2011 低于 Flat。你已经把 ANN 的好处全退回去了,还多付了粗量化器的成本。
两端都不可取。曲线中间有一段是「花很少的延迟换很多召回」的陡坡(比如 nlist=256 从 nprobe=1 到 16,召回从 0.0366 涨到 0.2821),你要找的就是这段陡坡的拐点。
生产边界
- 教学替身:随机数据 + 随机查询,量的是索引本身的特性。真实数据的 recall 会高很多,但同样的调参方向成立。
- 上线要盯的指标:
线上 recall(用抽样标注或与精确基准比对)、p99 延迟、实际生效的 nprobe/efSearch、索引重建前后的 recall 差。数据分布会漂移,今天达标的参数明天可能就不达标。 - 失败策略:给 recall 设告警而不是只设延迟告警。召回塌了通常不报错,只会安静地让下游质量变差。重建索引后一定要重跑同一套验收。
动手
- 跑
03_ivf_curve.py,补一组nlist=4096,看nprobe=64时召回是多少。解释为什么它比nlist=1024, nprobe=64更低。 - 跑
05_hnsw.py,把efSearch补上1000,找出M=32时召回能不能到 0.99,代价是多少 QPS。 - 用你手上任意一批真实向量,跑一遍「Flat 基线 + HNSW 网格」,画一张属于你自己数据的 recall-QPS 曲线,标出你的验收线。
自测
- 为什么固定
nprobe=1、nlist从 64 加到 1024,召回反而下降?用「每次探到的向量数」解释。 nlist=64, nprobe=64召回 1.0 但 QPS 2011,低于 Flat 的 6278。QPS 差在哪一步?- 同样 ~7000 QPS,HNSW 召回 0.9364、IVF 只有 0.6324。为什么 HNSW 在这批数据上占优?换成有强簇结构的真实数据,这个差距会缩小吗?
- 你的目标是 recall ≥ 0.6,只有
nlist=256这一组数据,最便宜的配置是什么?为什么不是nprobe=1? - 为什么「把 nprobe 调大到等于 nlist」是一个反模式?
↓ 下一步:04 章 · Milvus 架构与一致性 —— 单机索引调完了,看看分布式向量数据库多出了哪些层。