精确检索为什么撑不住
文本嵌入的常见维度是 768、1024、1536。在低维空间里,k-d tree 之类的空间划分结构能把最近邻查询压到对数时间;维度一高就失效——划分超平面几乎切不动数据,剪枝条件从不成立,实测退化成全表扫描,比裸暴力还慢(多了树遍历开销)。
更根本的问题是距离集中。设 x、y 各分量独立同分布,欧氏距离平方是 d 项之和,均值正比于 d,标准差正比于 √d,于是相对波动按 1/√d 衰减。维度 128 时最远距离与最近距离的比值已经很接近 1,维度上千时几乎相等。”最近邻”这个概念本身开始失去区分度。
再加上规模:千万级向量 × 1024 维 × 4 字节 ≈ 40 GB,一次暴力查询要算一千万次 1024 维点积。线上服务扛不住。
于是只能是近似最近邻(ANN):接受”偶尔漏掉真正的最近邻”,换取亚线性查询。所有索引结构的差异,本质上是”用什么结构把候选集缩小”。
IVF:聚类 + 倒排,靠 nprobe 换召回
IVF(Inverted File Index)用 k-means 把向量空间划成 nlist 个 Voronoi 单元,每个单元挂一条倒排列表。查询时先算查询向量到全部质心的距离,挑最近的 nprobe 个单元,只扫这些单元的列表。
训练:k-means 得到 nlist 个质心 → 每个向量归入最近质心
查询:query → 找最近 nprobe 个质心 → 合并候选 → 精确排序取 top-k
nprobe 是精度与延迟的主旋钮。nprobe=1 最快,但查询点只要靠近单元边界,真实最近邻就可能落在邻单元,直接漏召回;nprobe 增大,召回单调上升,扫描量线性上升;nprobe=nlist 就退化成暴力扫描。
nlist 的选取经验上取向量总量平方根附近的量级,同时保证训练样本数远大于 nlist 乘聚类中心所需样本数,否则聚类本身过拟合。单元大小不均是个隐患:某些单元塞了几十万条,扫到它时延迟尖刺。
IVF 的优势是结构简单、内存开销小(只多存质心和倒排 id),且天然支持增量写入。缺点是召回对数据分布敏感,聚类质量直接决定上限。
HNSW:分层图上的贪心游走
HNSW(Hierarchical Navigable Small World)不划分空间,而是建图。底层包含全部节点,上面若干层是下层的随机稀疏子集——每个节点按指数衰减的概率被分配到层,层数越高节点越少。
搜索:从顶层某个入口点起,逐层贪心跳到更近的邻居
下降到 layer 0 后,用大小为 ef 的候选堆做 beam search
返回 top-k
顶层相当于”高速公路”,几步跨越很大距离;底层做精细搜索。这就是”可导航小世界”的意思:平均跳数按 log N 增长。
三个关键参数:
M:每个节点维护的邻居数。M 大 → 图更连通、召回高、内存和建索引时间涨。efConstruction:建索引时候选堆大小。只影响索引质量和构建耗时,不影响查询速度,调大几乎白送质量。ef(查询侧):查询时候选堆大小。必须 ≥ k。这是线上唯一需要动态调的旋钮,ef 翻倍,召回和延迟一起涨。
内存是 HNSW 的主要成本。每个连接要存一个 id,M=32 时每节点底层连接就占 32×4 字节量级,加上上层连接和原始向量,总量常常是裸向量的 1.5 倍上下。所以大数据量下几乎必然要配合量化使用。
建索引也比 IVF 慢不少,因为要逐点做贪心插入和邻居裁剪。删除节点更麻烦,主流做法是打墓碑标记,图结构不真正收缩,长期大量删除后需要重建。
PQ:把向量压成一串中心编号
乘积量化(Product Quantization)切段压缩。把 D 维向量均分成 M 段,每段 D/M 维;每段独立跑 k-means 得到 K 个中心(K 常取 256,正好 1 字节存一个 id)。原向量于是被编码成 M 个中心编号:
D=768, M=96, K=256
编码后:96 字节
原始 fp32:768 × 4 = 3072 字节
压缩比:32 倍
查询时不解码。把查询向量同样切 M 段,预计算每段到该段 K 个中心的距离,得到一张 M×K 的距离表;之后对任意候选编码,距离就是 M 次查表加 M-1 次加法。这叫非对称距离计算(ADC),是 PQ 能加速的根本原因。
精度损失来自量化误差。每段只用 256 个中心近似连续子空间,误差有界但不小,单靠 PQ 的召回率通常明显低于原始向量。补救手段有两类:OPQ 先对数据做旋转变换,让各段方差和信息量更均衡;或者用残差量化,逐级量化上一级的残差。
生产里最常见的组合是 IVFPQ:IVF 负责粗粒度裁剪候选,PQ 负责在候选里快速算距离。再进一步可以做两段式——PQ 粗排出一个较大的候选集,对其中 top 少量用原始向量精确重排(refine),用少量额外 I/O 换回大部分精度。
召回率、延迟、内存的三方拉扯
| 配置 | 内存 | 查询延迟 | 召回率 |
|---|---|---|---|
| 暴力(flat) | 最高(原始向量) | 最高 | 100% |
| IVF,nprobe 小 | 低 | 低 | 中低 |
| IVF,nprobe 大 | 低 | 中 | 中高 |
| HNSW,ef 适中 | 高(图连接) | 低 | 高 |
| IVFPQ | 极低 | 低 | 中 |
| IVFPQ + refine | 低(需另存原始) | 中 | 高 |
没有同时占优的方案。内存紧张 → 上量化;延迟紧张 → 提 ef 或 nprobe,用内存换;召回不达标 → 检查是不是索引参数没调,而不是急着换算法。
调参的正确顺序是先固定目标召回(比如 recall@10 ≥ 0.95),再在这个约束下找延迟最低的配置。反着来会得到”延迟 5 ms 但召回 0.6″这种没法用的结果。
带过滤条件的检索是重灾区
真实业务几乎都要带条件:只看某个租户的文档、只要某个时间范围、只要状态为已发布的。这时候有个很容易踩的陷阱。
后过滤(post-filter)是最直觉也最错的写法:先 ANN 取 top-100,再按条件筛。如果条件筛掉 95% 的数据,100 条里平均只剩 5 条,k=10 时直接不够用;更极端的选择率下可能一条都不剩。这就是召回塌陷——ANN 本来就是近似的,再砍一刀,返回的结果质量断崖下跌,而且表现为”有时候有结果有时候没有”,很难定位。
预过滤(pre-filter)是对的:先按条件算出候选 id 集合,再在这个子集上做检索。但如果实现成”子集上暴力扫描”,数据量一大就慢;子集太小时索引结构也白建了。
比较可靠的做法是在 ANN 遍历过程中就应用过滤——HNSW 游走时跳过不满足条件的节点,IVF 扫倒排列表时逐条判断。这样候选堆里始终是有资格的节点。代价是过滤谓词必须能在遍历内快速求值(位图、预排序的倒排、内存列存)。
另一个折中是按高频过滤值建独立索引(分区)。过滤字段基数低(比如几十个类目)时很好用;基数是 user_id 这种百万级时不可行。
落地前务必先统计过滤条件的选择率分布。筛掉 90% 以上和筛掉 10%,需要的是完全不同的方案。
几个排查方向
召回率测不出来是常态。最小做法是抽几百条查询,用暴力检索算出真实 top-k,再和索引结果比对,得到 recall@k。没有这个基线,调参就是盲调。
数据分布漂移会让索引慢慢失效。IVF 的质心是在旧数据上训的,新数据大量涌入后聚类失配;HNSW 的图结构也会因为新节点分布不同而退化。定期重建比持续增量更稳。
还有一点:向量归一化。如果用余弦相似度,务必在建库和查询前都做 L2 归一化,否则内积排序不等价于余弦排序,检索结果会莫名其妙地偏向长向量(模长大的点积自然大)。
评论(2)
已经按这个方案改完了,灰度两天没出问题。
想问问有没有配套的模板或者脚本可以参考。
请 登录 后参与评论。