摘要
做 RAG 检索层时,nprobe=16、efSearch=64 这类"经验值"常被直接抄进配置。本文在本机(Windows 10 x64 / 32 逻辑核 / Python 3.11.5 / faiss-cpu 1.14.3)搭了一个可复现的测量台:5 万条 128 维合成向量、500 条查询、k=10,用 IndexFlatIP 精确检索产出 ground truth,对 IVF、HNSW、IVFPQ、SQ8 四类索引各跑 3 轮(seed=1/2/3)。结论是:同一组参数换一种语料分布,recall@10 能差 46.4~51.6 个百分点 ,所以参数只能在自己的数据上测;另外实测到几个反直觉的点------批处理比加核有用一个数量级、SQ8 省 4 倍内存却慢 15~20 倍、top_k 要得更深召回反而掉到 16%。所有数字都来自本机 stdout,跑不出的东西一律不写。
一、先说清楚要测什么
向量检索这条链路上可调的参数分两类,一类影响召回,一类影响成本,混在一起调就会变成玄学:
#mermaid-svg-XeWsmhfDKGuqR0mC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XeWsmhfDKGuqR0mC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XeWsmhfDKGuqR0mC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XeWsmhfDKGuqR0mC .error-icon{fill:#552222;}#mermaid-svg-XeWsmhfDKGuqR0mC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XeWsmhfDKGuqR0mC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XeWsmhfDKGuqR0mC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XeWsmhfDKGuqR0mC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XeWsmhfDKGuqR0mC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XeWsmhfDKGuqR0mC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XeWsmhfDKGuqR0mC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XeWsmhfDKGuqR0mC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XeWsmhfDKGuqR0mC .marker.cross{stroke:#333333;}#mermaid-svg-XeWsmhfDKGuqR0mC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XeWsmhfDKGuqR0mC p{margin:0;}#mermaid-svg-XeWsmhfDKGuqR0mC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-XeWsmhfDKGuqR0mC .cluster-label text{fill:#333;}#mermaid-svg-XeWsmhfDKGuqR0mC .cluster-label span{color:#333;}#mermaid-svg-XeWsmhfDKGuqR0mC .cluster-label span p{background-color:transparent;}#mermaid-svg-XeWsmhfDKGuqR0mC .label text,#mermaid-svg-XeWsmhfDKGuqR0mC span{fill:#333;color:#333;}#mermaid-svg-XeWsmhfDKGuqR0mC .node rect,#mermaid-svg-XeWsmhfDKGuqR0mC .node circle,#mermaid-svg-XeWsmhfDKGuqR0mC .node ellipse,#mermaid-svg-XeWsmhfDKGuqR0mC .node polygon,#mermaid-svg-XeWsmhfDKGuqR0mC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-XeWsmhfDKGuqR0mC .rough-node .label text,#mermaid-svg-XeWsmhfDKGuqR0mC .node .label text,#mermaid-svg-XeWsmhfDKGuqR0mC .image-shape .label,#mermaid-svg-XeWsmhfDKGuqR0mC .icon-shape .label{text-anchor:middle;}#mermaid-svg-XeWsmhfDKGuqR0mC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-XeWsmhfDKGuqR0mC .rough-node .label,#mermaid-svg-XeWsmhfDKGuqR0mC .node .label,#mermaid-svg-XeWsmhfDKGuqR0mC .image-shape .label,#mermaid-svg-XeWsmhfDKGuqR0mC .icon-shape .label{text-align:center;}#mermaid-svg-XeWsmhfDKGuqR0mC .node.clickable{cursor:pointer;}#mermaid-svg-XeWsmhfDKGuqR0mC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-XeWsmhfDKGuqR0mC .arrowheadPath{fill:#333333;}#mermaid-svg-XeWsmhfDKGuqR0mC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-XeWsmhfDKGuqR0mC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-XeWsmhfDKGuqR0mC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XeWsmhfDKGuqR0mC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-XeWsmhfDKGuqR0mC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XeWsmhfDKGuqR0mC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-XeWsmhfDKGuqR0mC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-XeWsmhfDKGuqR0mC .cluster text{fill:#333;}#mermaid-svg-XeWsmhfDKGuqR0mC .cluster span{color:#333;}#mermaid-svg-XeWsmhfDKGuqR0mC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-XeWsmhfDKGuqR0mC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-XeWsmhfDKGuqR0mC rect.text{fill:none;stroke-width:0;}#mermaid-svg-XeWsmhfDKGuqR0mC .icon-shape,#mermaid-svg-XeWsmhfDKGuqR0mC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XeWsmhfDKGuqR0mC .icon-shape p,#mermaid-svg-XeWsmhfDKGuqR0mC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-XeWsmhfDKGuqR0mC .icon-shape .label rect,#mermaid-svg-XeWsmhfDKGuqR0mC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XeWsmhfDKGuqR0mC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-XeWsmhfDKGuqR0mC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-XeWsmhfDKGuqR0mC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} IVF: 扫 nprobe 个桶
HNSW: 探索 efSearch 个节点
约束
用户问题
Embedding 模型
产出 128 维归一化向量
索引层
可调 nprobe / efSearch
候选 top-k
rerank 精排
可调候选宽度
prompt 拼装
大模型生成
内存与建库耗时
nlist / M / PQ 子量化数
要回答的三个问题很具体:
- 召回率和延迟的交换曲线长什么样,拐点在哪;
- 内存该省的时候用 PQ 还是 SQ,代价各是多少;
- 数据涨 4 倍、请求打批、CPU 加核,哪个才真的影响线上延迟。
二、测量台:一个精确基线 + 一把"噪声尺"
语料是合成的,这点必须先说清楚,不然读数会被当成产品指标。生成方式是:取 n_topic 个随机主题中心,每条文档按 softmax 权重混合其中 mixture 个方向,加 noise 幅度高斯噪声后 L2 归一化。查询集与语料同分布但不在语料里。
python
def make_corpus(rng, n, dim, n_topic, mixture, noise):
"""带聚簇结构的归一化向量。真实 embedding 的内在维度远小于环境维度,
所以这里刻意用主题混合来复现"可压缩"这个特性,而不是纯随机向量。"""
centroids = rng.standard_normal((n_topic, dim)).astype("float32")
centroids /= np.linalg.norm(centroids, axis=1, keepdims=True)
idxs = np.argsort(rng.random((n, n_topic)), axis=1)[:, :mixture]
w = np.exp(rng.random((n, mixture)).astype("float32") * 3.0)
w /= w.sum(axis=1, keepdims=True) # 主题权重(softmax)
vecs = np.einsum("ij,ijd->id", w, centroids[idxs]).astype("float32")
vecs += noise * rng.standard_normal((n, dim)).astype("float32")
faiss.normalize_L2(vecs) # 归一化后内积即 cosine
return vecs
我跑了两套分布 :A 强聚簇(20 主题、单主题、噪声 0.05)和 B 弱结构(64 主题、3 主题混合、噪声 0.15)。这不是多余的,第四节会看到它才是全文最重要的变量。
延迟测量刻意做成逐条查询(在线单并发,不带批处理摊薄),并且先量一次噪声底:
python
def per_query(index, queries, gt=None, nprobe=None, efsearch=None, kk=10):
if nprobe is not None:
index.nprobe = nprobe # IVF:扫几个桶,改这个不用重建索引
if efsearch is not None:
index.hnsw.efSearch = efsearch # HNSW:候选队列长度,同样在线可调
lat = np.empty(queries.shape[0])
ids = np.empty((queries.shape[0], kk), dtype="int64")
for i in range(queries.shape[0]):
q = np.ascontiguousarray(queries[i:i + 1]) # 必须连续内存,否则 faiss 走慢路径
t0 = time.perf_counter()
_, I = index.search(q, kk)
lat[i] = (time.perf_counter() - t0) * 1e3
ids[i] = I[0]
return lat, ids, (None if gt is None else recall_at(gt, ids, kk))
噪声底实测:p50 0.003~0.006ms,p99 0.009~0.017ms (拿只装了 1 条向量的索引做同样的 search 调用)。也就是说,Python + faiss 的一次调用本身就要 3~6µs,任何低于 10µs 的延迟差都只是噪声,不值得拿去决策。
三、召回-延迟前沿(语料 B 弱结构,3 轮区间)
| 索引与参数 | recall@10 | p50 (ms) | p99 (ms) | 单并发 QPS | 建库/训练 (s) | 理论内存 (MB) |
|---|---|---|---|---|---|---|
| Flat 精确(基线) | 1.000 | 0.328~0.498 | 0.59~1.678 | 1,842~2,728 | 0.006~0.01 | 24.4 |
| IVF nlist=1024 nprobe=1 | 0.0168~0.0184 | 0.023~0.046 | 0.033~0.075 | 20,521~41,812 | 0.63~0.67 | 24.9 |
| IVF nlist=1024 nprobe=4 | 0.0488~0.0612 | 0.027~0.054 | 0.041~0.090 | 17,834~35,096 | 0.63~0.67 | 24.9 |
| IVF nlist=1024 nprobe=16 | 0.1536~0.1590 | 0.047~0.059 | 0.069~0.110 | 15,348~20,742 | 0.63~0.67 | 24.9 |
| IVF nlist=1024 nprobe=64 | 0.3790~0.3812 | 0.091~0.125 | 0.137~0.273 | 7,677~10,901 | 0.63~0.67 | 24.9 |
| IVF nlist=1024 nprobe=256 | 0.7318~0.7382 | 0.238~0.271 | 0.370~0.500 | 3,493~4,059 | 0.63~0.67 | 24.9 |
| HNSW M=16 efC=200 efS=200 | 0.6688~0.6832 | 0.265~0.301 | 0.405~0.729 | 3,093~3,659 | 0.93~0.98 | --- |
| HNSW M=32 efC=200 efS=16 | 0.2640~0.2802 | 0.073~0.126 | 0.112~0.193 | 7,714~13,290 | 1.79~1.88 | --- |
| HNSW M=32 efC=200 efS=64 | 0.6024~0.6168 | 0.185~0.199 | 0.274~0.692 | 3,763~5,148 | 1.79~1.88 | --- |
| HNSW M=32 efC=200 efS=200 | 0.8924~0.9024 | 0.433~0.509 | 0.696~0.726 | 1,923~2,194 | 1.79~1.88 | --- |
读这张表要注意三件事:
- IVF 的召回随 nprobe 近似线性但远未到线性 :nprobe 从 16 到 64 是 4 倍扫库量,召回 0.154~0.159 → 0.379~0.381(约 2.4 倍)。而 p50 只从 0.047~0.059 涨到 0.091~0.125ms,交换比在低 nprobe 段非常划算,到 nprobe=256 时 p50 已 0.238~0.271ms,召回 0.73 附近,接近精确基线的延迟了------那还不如直接上 Flat。
- HNSW 在同召回下延迟更高,但建库慢得多 :要 0.89 的召回,HNSW M=32 要 efS=200、p50 0.433~0.509ms;IVF 要到 0.73 只需 nprobe=256、p50 0.238~0.271ms。而 M 从 16 翻到 32,建库耗时从 0.93~0.98s 变成 1.79~1.88s(约 1.9 倍),召回同档位只高一大截。
- p99 和 p50 不是一回事:HNSW M=32 efS=64 的 p50 是 0.185~0.199ms,p99 却在 0.274~0.692ms 之间摆动,轮间差到 2.5 倍。写 SLA 要按 p99 排预算。
四、真正决定召回的是语料分布,不是参数
把完全相同的参数放到 A 强聚簇语料上,结果是这样(3 轮区间):
| 参数 | A 强聚簇 recall@10 | B 弱结构 recall@10 | 差值(百分点) |
|---|---|---|---|
| IVF nprobe=4 | 0.0562~0.0798 | 0.0488~0.0612 | -0.5~3.1 |
| IVF nprobe=16 | 0.3400~0.3912 | 0.1536~0.1590 | 18.1~23.8 |
| IVF nprobe=64 | 0.8450~0.8946 | 0.3790~0.3812 | 46.4~51.6 |
| HNSW M=32 efS=16 | 0.2564~0.3200 | 0.2640~0.2802 | -2.4~5.6 |
| HNSW M=32 efS=64 | 0.4960~0.5696 | 0.6024~0.6168 | -12.1~-3.3 |
| HNSW M=32 efS=200 | 0.7586~0.7986 | 0.8924~0.9024 | -14.4~-9.4 |
两个结论值得单独拎出来:
- IVF 在强聚簇数据上收益极大(nprobe=64 时 0.845~0.895 vs 0.379~0.381)。原因是桶就是簇,扫对了桶一次就捞到一整组近邻;数据不聚簇时,真近邻散在许多桶里,多扫 4 倍桶数也补不回来。
- HNSW 的方向反过来------同一档位它在弱结构语料上召回更高,强聚簇反而低 9.4~14.4 个百分点。20 个巨簇、簇内噪声只有 0.05,意味着 top-10 里有大量几乎等距的点,贪心图搜索一旦收敛到某个"枢纽"就漏掉其余等价候选。
所以"我们项目 efSearch=64 够用"这种话,换一批 embedding、换一个领域,甚至只是重新切一次分块,都不再成立。可迁移的做法是:拿精确检索当 ground truth,在自己的语料上把上面那条 recall-latency 曲线画一遍,再按 p99 预算反推参数。
五、要省内存时,PQ 和 SQ 的代价完全不是一种东西
| 方案 | recall@10 | p50 (ms) | 单并发 QPS | 理论内存 (MB) | 训练+建库 (s) |
|---|---|---|---|---|---|
| Flat float32 | 1.000 | 0.328~0.498 | 1,842~2,728 | 24.4 | 0.006~0.01 |
| IVFPQ m=16 8bit nprobe=16 | 0.0956~0.0990 | 0.256~0.276 | 3,826~4,158 | 1.3 | 8.22~8.47 |
| IVFPQ m=16 8bit nprobe=64 | 0.1568~0.1632 | 0.583~0.619 | 1,361~1,447 | 1.3 | 8.23~8.74 |
| SQ8 精确扫描 | 0.9776~0.9814 | 6.778~6.874 | 139~148 | 6.1 | 0.002 |
- IVFPQ 把 24.4MB 压到 1.3MB(约 18.8 倍) ,代价是召回在弱结构语料上只剩 0.0956~0.0990,且训练+建库 8.22~8.74s,是 IVF-Flat 的 0.63~0.67s 的 12~13 倍。库要频繁重建的话,这个成本比内存更贵。
- SQ8 这一行是本次最反直觉的实测 :它仍是精确扫描,量化后内存只从 24.4→6.1MB(4 倍),召回确实几乎不掉(0.9776~0.9814),但 p50 是 6.778~6.874ms,比 float32 的 Flat 慢 15~20 倍,单并发 QPS 只有 139~148。想当然地以为"量化=省内存顺带提速"是错的,至少这条路徑上量化把 SIMD 的规整访存打散了。
- 如果你的目的是省钱而不是省延迟,先确认瓶颈真的是内存。本次 5 万条 ×128 维 float32 只有 24.4MB,百万级也才 488MB 量级,内存很少是向量检索的第一瓶颈。
六、加核几乎没用,打批有用一个数量级
同一份 HNSW 索引(M=32、efSearch=64)、同一批查询,只改 OpenMP 线程数:
| omp 线程 | p50 (ms) | p99 (ms) | 单并发 QPS |
|---|---|---|---|
| 1 | 0.208~0.343 | 0.285~0.415 | 2,888~4,687 |
| 4 | 0.227~0.306 | 0.302~0.385 | 3,672~4,348 |
| 32 | 0.170~0.185 | 0.263~0.320 | 5,154~5,663 |
从 1 核到 32 核,单条查询只快 1.2~1.8 倍(图搜索本身是串行的,多线程只在一次调用内部并行扩边)。而改成批量查询:
| 索引 | batch | ms/条 | 等效 QPS | 相对 batch=1 |
|---|---|---|---|---|
| IVF nprobe=16 | 1 | 0.049~0.053 | 18,949~20,257 | 1x |
| IVF nprobe=16 | 32 | 0.012~0.017 | 57,656~80,273 | 3.0~3.8x |
| IVF nprobe=16 | 500 | 0.007 | 136,162~147,427 | 7.4x |
| HNSW M=32 efS=64 | 1 | 0.230~0.273 | 3,666~4,351 | 1x |
| HNSW M=32 efS=64 | 32 | 0.025~0.029 | 33,924~40,279 | 8.1~9.9x |
| HNSW M=32 efS=64 | 500 | 0.016~0.020 | 50,884~61,438 | 14~17x |
HNSW 从批处理里赚到的比 IVF 多得多(14~17 倍 vs 7.4 倍),这也解释了为什么"逐条在线查询"和"离线批量灌库/高并发摊薄"两种场景的最优索引不是同一个。给线上服务多配 CPU 核,不如在网关侧做 10~30ms 的微批。
七、数据涨 4 倍之后,先掉的是 p99 不是召回
同一张 IVF 表(nlist=1024)从 5 万加到 20 万条,nprobe 不动:
| 指标 | 50k | 200k(nprobe=16 不动) | 200k(nprobe 调到 64) |
|---|---|---|---|
| recall@10 | 0.1536~0.1590 | 0.1446~0.1524 | 0.3498~0.3606 |
| p99 (ms) | 0.069~0.110 | 0.160~0.331 | 0.487~0.617 |
召回只掉了 0.7~1.4 个百分点,p99 却涨了 1.5~3 倍------桶变大了,扫的桶数没变,但每个桶里的暴力计算变多了。等召回掉眼面前,延迟先劣化。
另外测了候选深度(nprobe=64):recall@10 = 0.379~0.381,recall@30 = 0.3415~0.3459,recall@100 = 0.1631~0.1640,p50 从 0.078~0.081ms 只涨到 0.091~0.097ms。要 top-100 给 rerank,ANN 只能给你 16% 的对------"反正精排能吃大候选集"这个假设得先测 recall@100 再说。
顺带一个训练量的对照:faiss 建议训练样本 ≥ 39×nlist,本机用 20,000 条(低于 39,936 会打 WARNING)与 40,960 条对比,同 nprobe 下召回只差 0.14~1.36 个百分点 (nprobe=16)/ 0.84~2.34 个百分点(nprobe=64)。告警要读懂,但不必为了它推倒重建。
避坑指南
- 别在 10µs 以下比延迟。 本机调用开销底就 3~6µs(p99 9~17µs),IVF nprobe=1 的 0.023~0.046ms 里有相当比例是框架开销。
- 别照抄 nprobe / efSearch。 同参数换语料分布能差 46.4~51.6 个百分点,而且 IVF 与 HNSW 的优劣方向会反转。换 embedding 模型、换分块策略之后必须重测。
nlist不是越大越好,且要跟着数据量走。 本次固定 nlist=1024,数据 4 倍后掉的是 p99(1.5~3 倍)。要么同步调大 nlist 重建,要么按数据量把 nprobe 一起提上去。- 别默认"量化就是加速"。 SQ8 精确扫描实测慢 15~20 倍、QPS 只有 139~148;IVFPQ 省 18.8 倍内存但建库慢 12~13 倍、弱结构语料上召回不到 10%。
top_k要深之前先测 recall@k。 实测 k 从 10 到 100,召回 0.379→0.163,候选集越深 ANN 越漏。- 改
nprobe/efSearch不用重建索引,改nlist/M/efConstruction必须重建。 M 16→32 建库耗时 0.930.98s→1.791.88s。在线服务把前者做成可热调参数,后者只能走重建+灰度。 - faiss 的索引对象默认非线程安全。 多线程共享同一 index 时
search可以并发,但改nprobe这类参数会影响所有线程;要按线程各建一个索引句柄,或把参数固定后再并发。 IndexScalarQuantizer不调train()直接add()会在本机段错误 (进程 access violation 崩溃)。我一开始照抄"8bit 量化不需要训练"去硬设sq.is_trained = True,直接崩;正确写法是老老实实调sq.train(x)(QT_8bit 下它是空操作,但必须调)。- 查询向量一定要连续内存。
np.ascontiguousarray之前先确认flags['C_CONTIGUOUS'],否则 faiss 可能走拷贝慢路径,延迟读数直接翻倍。 - 合成语料只能用来定参数、不能用来报指标。 本文所有召回率都只在同分布的合成向量间可比,换到你的 embedding 模型上绝对值一定变,能带走的是方法而不是数字。
结论
按本次实测,选型顺序应该是:① 先在自己的语料上用 Flat 建 ground truth;② 扫 nprobe / efSearch 画 recall-latency 曲线,用 p99 预算反推参数;③ 只有内存真撑不住才碰 PQ,并确认建库耗时可接受;④ 吞吐不足优先做微批(实测 7.4~17 倍)而不是加核(实测 1.2~1.8 倍);⑤ 把 recall@k 随 k 变化一起测了,再决定 rerank 候选宽度。 5 万条 ×128 维这个量级上,Flat 精确检索 p50 只有 0.328~0.498ms、单并发 1,842~2,728 QPS------如果你的库还在十万级以内,最该先考虑的其实是"要不要用 ANN"。
复现代码(demos/ann_index_recall_lab.py,3 轮 seed=1/2/3,输出留档 ann_index_recall_lab.transcript.txt):语料生成、逐条计时、recall_at 计算、各索引构建与 A/B 两套分布对照都在里面,改 N_DOC、DIM、REGIMES 即可换成你自己的数据规模重跑。