跳至主要内容
向量检索真正难的是参数背后的取舍

向量检索真正难的是参数背后的取舍

有一段时间我一直在盯一条召回率曲线。它没有明显下跌,只是在某个时段抖了两三个点。我最初怀疑是索引参数漂了,翻了 efSearch 的配置,重跑了压测,最后发现问题出在 embedding 模型换了版本,切片策略也跟着动了。

那次之后,我对向量检索的面试题有了点不一样的看法。大部分问题问的是「你会不会用 HNSW」,而实际工作里真正难的是「你知不知道自己在拿什么换什么」。

链路比算法重要

一条 query 从进系统到变成答案,中间通常会经过好几次变形。先被切成块,再被映射成向量,然后进索引做近似搜索,最后还可能被重排一次。任何一环出问题,表现出来的症状都一样:结果不准。

我吃过这个亏。第一次遇到召回变差,我的直觉是去调索引,把 efSearch 从 64 拉到 200,召回确实回来了几个点,但延迟也跟着上去了。后来才发现真正的病灶在切片:固定长度切分把一张表切成了两半,检索到的永远只有前半张。参数只是把噪声盖住了。

所以现在如果有人问我怎么排查召回问题,我的顺序是反的:先看切分和模型,再看过滤条件,最后才看索引参数。索引是最容易调的东西,也因此最容易背锅。

这套顺序在面试里其实挺有用。它说明我知道系统的边界在哪,而不是把 ANN 当成一个能解释一切的词。

链路里还有一个常被略过的决定:元数据过滤放在哪。放在检索前,候选集被提前缩小,速度快,但如果过滤条件太窄,可能把正确答案筛掉,召回直接归零;放在检索后,索引能发挥完整能力,代价是取回一堆再丢掉,浪费算力和带宽。我倾向把范围窄、选择性高的条件前置,把模糊的、可能误伤的条件后置,再看效果决定要不要拆成两次检索。这里没有通用解,只有对数据分布的估计。

重排也是同样的账。加一层交叉编码器,精度通常能涨,但每一条候选都要过一遍模型,延迟是线性增长的。召回一百条去重排,和召回二十条去重排,成本和收益完全不同。我见过把重排层加上去之后指标好看了、P99 却翻倍的方案,最后只能砍候选数量,等于用召回换延迟。

HNSW 的参数是三份账单

HNSW 的结构不难讲:底层放全部节点,上层稀疏,靠分层做长距离跳跃。难的是讲清楚 M、efConstruction、efSearch 这三个数各自在为什么付费。

M 是每个节点的邻居上限。它决定图的密度,也直接决定内存。M 调大,图更连通,孤岛更少,但每条边都要存,内存账单一目了然。

efConstruction 是建图时的候选列表大小。它只影响写入,不影响查询。这个值调高,图的质量更好,代价是构建变慢。

efSearch 是查询时的候选列表,运行时就能改。它是最便宜的旋钮:不用重建索引,改完立刻见效,代价是延迟和 QPS。

我一度把「efConstruction 要设得比 efSearch 大」当成定论背下来。后来在写多读少的场景里发现这句话是有前提的——它假设索引构建是一次性的、以后主要读。如果数据是持续写入的流,单条插入的延迟会直接卡住写入链路,这时候把 efConstruction 拉高就是在给自己找麻烦。

读多写少的场景,我倾向把 M 和 efConstruction 都给足,换取线上一个较低的 efSearch。写多的场景反过来,接受九十五左右的召回,先把写入跑通。这不是哪个更正确,是我愿意在哪一侧认亏。

内存真的紧张的时候,我会离开 HNSW。它的内存膨胀是结构性的,不是调参能解决的。这时候 IVF 加 PQ 更实际,先粗筛再拿原始向量精排,用一次额外的读换回精度。PQ 的压缩比很诱人,但它是把向量切段、各段聚类、用簇心编号替换原始浮点,本质上是有损的。召回出来的顺序会偏,必须靠精排补。补得回来多少,取决于我肯为它多读多少原始向量。

图索引还有一件麻烦事:删除。多数实现只是打个标记,节点还留在图里,边也还在。删得多了,搜索会绕路,内存也不会释放。所以线上要定期做合并或者重建,这件事跟传统数据库的整理碎片是一个道理,只是成本更高,因为要重新算距离、重新连边。

冷启动也在这个账里。一个几 GB 的索引从磁盘加载到内存,期间会有缺页,延迟会抖。生产环境通常要在流量切进来之前预热,否则第一波请求会把抖动放大成故障。这些都是面试里不太会问、上线后一定会遇到的部分。

内存的粗算其实不难,难的是有人愿意在立项时算。一个 float32 的向量,维度乘四字节,这是原始数据;图上每条边再存一个编号,M 越大,这部分越可观。合起来,索引占的内存通常是原始向量的两倍上下,具体倍数取决于维度和参数。这个数字应该在选方案的时候就摆到桌面上,而不是等机器扛不住了再回头看。

归一化之后,距离度量就没那么值得聊了

余弦相似度、内积、欧氏距离,这三个东西在面试里被问得很多,但真正决定结果的往往不是选哪个,而是向量有没有归一化。

归一化到单位长度之后,三者给出的排序是一样的。余弦的分母变成 1,内积就是余弦;L2 和余弦之间是单调关系,距离越小相似度越大。差别只剩计算量:内积只要乘加,最省指令。

所以生产里常见的做法是入库前统一归一化,索引里配置内积。这个选择不神秘,就是拿语义等价换一点 CPU。

真正的坑在没归一化的时候。如果模型的输出模长本身带信息,L2 会把「长度接近」也算成「语义接近」,短文本和高频词的向量容易挤在一起。除非业务确实需要模长(比如它代表置信度),否则我会要求归一化这件事有明确的负责人,而不是默认模型会做。

我在这个问题上不太喜欢那种把三个公式都推一遍的回答。它证明不了判断力,只能证明记性好。

比距离度量更值得花时间的是混合检索。稠密向量擅长语义,遇到型号、错误码、人名、缩写这类精确匹配就弱。用户查一个具体的产品编号,语义模型可能召回一堆「相关品类」,看起来都对,就是没有那一个。这不是模型不好,是它本来就在做另一件事。

所以我在实际系统里基本都会保留一路关键词检索,两路并行,再用排名融合合并。融合不用分数直接相加,因为两边的分数量纲根本不同,一个没有上限,一个在零到一之间。按名次融合更稳,代价是丢掉了分数里的一部分信息——第一名领先很多还是勉强第一,融合后看不出来。这个损失我接受,换来的是不需要为每一路单独调归一化。

召回率是个旋钮,但得先有真值

召回率不是一个固定属性。同一个索引,efSearch 调高,召回上去,延迟和 QPS 下来;nprobe 调大也是同样的交换。所以「你们的召回率是多少」这个问题,如果不带延迟和并发,其实没法回答。

我现在的说法会尽量带上条件:在什么数据规模、什么 P99 延迟下,Recall@K 是多少。这三个数一起出现才有意义。

更难的是真值。近似搜索本身就允许漏,那怎么知道漏了多少。唯一的办法是离线用暴力搜索跑一份基准,拿近似结果去比对。这一步很笨,全量扫描,成本不低,但没有它,后面所有指标都是自说自话。

单机装不下的时候,问题会从算法变成架构。分片解决容量和写入,副本解决并发和可用性,这两件事经常被混为一谈。分片之后,一次查询要发给所有分片再汇总,分片越多,长尾越明显——只要有一个分片慢,整次请求就慢。所以分片数不是越多越好,它有一个由尾延迟决定的上限。副本则是纯加法,用机器换 QPS,代价是成本和一致性上的复杂度。

压测的方式也会决定我看到什么。只测平均延迟,几乎必然乐观;只测单并发,也说明不了线上会怎样。我一般会同时看三组数:低并发下的单次耗时、目标 QPS 下的 P99、以及把 QPS 再往上推一档时指标从哪里开始崩。第三组最有信息量,它标出了这套配置真正的边界在哪。

我在这件事上还在补课。参数表可以抄,工作点却要自己试出来,而且换一批数据、换一个模型,之前的工作点大概率作废。要说有什么稳定的经验,大概是:先想清楚这次愿意牺牲哪一项,再去动参数,而不是先调参再看指标变成了什么样。

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#取舍 #向量检索 #系统设计

评论