5.3 索引参数优化 执行流程与重排就位后,本节处理被问得最多的问题:索引参数到底怎么调。承接第三章的参数语义(倒排的聚类数与探测数、图的连接度与候选队列)与 5.5 将要建立的评估方法,本节给出的是一套流程而不是一张参数表——参数表会过时,流程不会。读完你应当能把"探测宽度怎么选、候选队列设多大"这类问题,变成一条带数据回路的决策过程。 先分清结构参数与运行参数 第三章划过这条线,调参时它决定你的自由度:结构参数(倒排的聚类数、图的连接度、量化码本大小)在构建期定死,改动等于重建,决策依据是内存预算与数据规模,一年调不了几次;运行参数(倒排的探测数、图的候选队列长度、量化重排的候选倍数)每次查询可带,是日常调优的全部空间。
执行流程与重排就位后,本节处理被问得最多的问题:索引参数到底怎么调。承接第三章的参数语义(倒排的聚类数与探测数、图的连接度与候选队列)与 5.5 将要建立的评估方法,本节给出的是一套流程而不是一张参数表——参数表会过时,流程不会。读完你应当能把"探测宽度怎么选、候选队列设多大"这类问题,变成一条带数据回路的决策过程。
第三章划过这条线,调参时它决定你的自由度:结构参数(倒排的聚类数、图的连接度、量化码本大小)在构建期定死,改动等于重建,决策依据是内存预算与数据规模,一年调不了几次;运行参数(倒排的探测数、图的候选队列长度、量化重排的候选倍数)每次查询可带,是日常调优的全部空间。把两者分开的另一个理由是事故域不同:运行参数调错,最坏是延迟毛刺,改回来即恢复;结构参数调错(比如聚类数严重偏离数据规模),可能要全量重建数小时才能恢复。所以调优顺序永远先动运行参数,结构参数只在运行参数穷尽后按重建流程(3.3 的双索引切换)处理。

象限图回答"每个参数的代价是什么",但"调到多少"必须由数据回答。常见业务锚点是:召回率目标九成五到九成九之间(按业务对漏检的容忍度定),尾延迟目标按上游接口预算倒推。两者定下后,调参就变成了找满足双约束的最低成本配置——不追求最高召回,也不追求最低延迟,追求达标线上的最省配置。
流程分四步。第一步备料:固定一批探针查询(从真实查询日志采样数百到数千条),预先用精确检索算好每条的标准答案,这份"答案卷"是所有后续测量的基准,3.1 说过平面基线的价值正在于此。第二步测起点:当前配置下跑一遍探针,记录召回率、平均延迟、尾延迟三个数。第三步单变量扫描:每次只动一个参数,按倍数阶梯取值,记录同三个数。第四步选点:在召回达标的前提下取延迟最低的点,留出一成余量防抖动。整个过程可以全自动:
import numpy as np, time def evaluate(index, probes, truths, ef): index.hnsw.efSearch = ef # 只动这一个旋钮 hits, latency = 0, [] for q, truth in zip(probes, truths): t0 = time.perf_counter() ids = index.search(q, top_k=10) latency.append(time.perf_counter() - t0) hits += len(set(ids) & set(truth)) # 与精确答案求交集 recall = hits / (len(probes) * 10) p99 = float(np.percentile(latency, 99)) return recall, p99 for ef in [16, 32, 64, 128, 256]: r, p = evaluate(idx, probes, answers, ef) print(f"候选队列 {ef:>4} 召回 {r:.3f} 尾延迟 {p*1000:6.1f} ms") # 选召回首过 0.95 的最小档,再留一成余量
这段扫描脚本有两个容易被忽略的讲究:探针查询必须来自真实流量而不是随机向量——随机向量的距离分布与真实业务完全不同,测出的召回率没有意义;延迟要看尾延迟不要只看平均——检索是扇出架构,个别慢请求的比例决定了用户体验的下限,5.5 会把尾延迟讲透。
调参数年的人大致都会踩过同一批坑,提前列出。误区一:在数据集上调、在数据分布变化后不重调。数据量翻倍后最优参数会移动,把参数扫描做成季度例行任务比一次性调优靠谱。误区二:只调索引不问上游。很多时候延迟大头在嵌入服务的编码耗时或网络往返,索引参数扫到天上去也无济于事——先做延迟分解(5.1 的路径分段计时)再动手。误区三:用召回率替代业务指标。召回达标不等于用户满意,最终还要回到点击率、答案采纳率这类业务指标上做闭环,重排参数(5.2)同理。
调参流程需要起点,下表给出主流参数的语义与经验初值(初值只是起点,最终以扫描结果为准):
| 参数 | 层面 | 语义 | 经验初值 |
|---|---|---|---|
| 聚类数 nlist | 结构 | 倒排簇的个数 | 约为条数平方根的四倍附近 |
| 探测数 nprobe | 运行 | 每次查询访问的簇数 | 从十六扫到二百五十六 |
| 连接度 M | 结构 | 图中每点连边数 | 十六到四十八 |
| 候选队列 ef | 运行 | 每层保留的候选宽度 | 从六十四扫到五百一十二 |
| 量化子段数 | 结构 | 码本切分粒度 | 每子段八到十六维 |
| 精修倍数 | 运行 | 精修候选相对 K 的倍数 | 四到十六倍 |
表中值得点破的是运行参数与结构参数的扫描成本差异:运行参数扫描只需要改请求参数跑基准,一轮实验以分钟计;结构参数每档都要重建索引,一轮以小时计。所以扫描永远先穷尽运行参数,把"同样结构下的最优运行点"找到,再用 7.3 的四问评估是否真的需要动结构。
要,且变化有方向可循。数据量翻倍后,同样的探测数或候选队列覆盖的数据比例减半,召回率会缓慢下滑——这是"参数的相对性":它们多数是比例敏感而非绝对值敏感的量。工程上的对策是把参数与规模挂钩:数据每上一个数量级,重跑一轮运行参数扫描;规模在数量级内小幅增长时,用召回代理指标(6.3 的定时探针)盯住下滑趋势,跌破阈值即触发复扫。把"规模变化触发复扫"写进运维手册,参数就不会在无声中过期。
理想与现实之间要找平衡。参数在索引层面是全局的,不同租户的查询分布却可能差异巨大——有的租户全是长文本模糊查询,有的全是型号精确查找,同一组参数对一家最优、对另一家平庸。可行的缓解按投入递增:监控按租户拆分召回代理指标,发现某租户显著掉队再定向处理;对差异极大的头部租户单独建集合(用空间换参数自由);把租户间的分布差异纳入建库前的容量与隔离评审。别追求让所有租户共享一套"平均最优"参数——平均最优往往意味着每家都不最优。
参数的软杠杆用尽了,下一节上硬杠杆:硬件与缓存。