
069、向量检索的相似度算法与索引一场线上事故后的复盘上周二晚上十一点我正打算收工业务群里突然炸了——搜索推荐的 top20 准确率从 0.87 掉到 0.52。我爬起来查日志第一反应是 embedding 出了问题。对比线上和离线生成的向量逐维比对一模一样。再算相似度离线按余弦相似度排序的结果和线上按点积排序的结果差了十万八千里。当时建索引用的 FAISSmetric 写的是METRIC_INNER_PRODUCT而离线 evaluation 脚本里用的是 cosine。按理说 cosine 不就是归一化之后算点积吗问题出在我们的向量没有提前做 L2 normalize导致模长大的文档在点积排序里占尽便宜。那次之后我把相似度算法和索引选择视为两个独立但又强耦合的决策今天这篇就按我自己的排查思路来写。相似度算法这件事很多人以为“cosine 就是标准答案”但落到工程实现里cosine 往往不是单独被计算的。内积就是x·y Σxi yi它只做一次乘加计算最快结果却受向量模长影响。举个例子query 是(1, 0)候选 a 是(100, 100)候选 b 是(10, 1)。点积得分 a 是 100b 是 10a 完胜。但算 cosinea 的方向是 45 度和 query 的余弦只有 0.707b 的方向更贴近 query余弦是 0.995。不同排序意味着两种完全不同的业务语义。如果你做论文检索希望结果“内容方向更接近”cosine 合理如果你做 CTR 预估召回希望高热度内容有额外加分点积的模长偏置反而是一种特征。没有绝对的对错但上线前必须想清楚。importnumpyasnp qnp.array([1.0,0.0],dtypefloat32)anp.array([100.0,100.0],dtypefloat32)# 模长很大bnp.array([10.0,1.0],dtypefloat32)# 模长小# 点积排序a 明显高于 bprint(q a)# 100.0print(q b)# 10.0# 余弦相似度排序b 反而赢了print(q a/(np.linalg.norm(q)*np.linalg.norm(a)))# 0.707print(q b/(np.linalg.norm(q)*np.linalg.norm(b)))# 0.995余弦相似度本质上就是“归一化之后的内积”。所以很多向量检索库里的IndexFlatIP搭配normalize_L2后得到的就是余弦排序。这里踩过坑如果向量没有归一化IP 就不是 cosineIP 的排序会被模长带着跑。欧氏距离和余弦也有关系对归一化后的向量 x 和 y||x-y||^2 2 - 2*cos(x,y)也就是说归一化前提下用 L2 距离排序和用余弦排序完全等价。但如果不归一化这个等价关系就没了。我见过有人离线用 L2 距离线上却用 cosine阈值完全对不上召回一堆莫名其妙的结果。相似度度量本身不难难的是“离线、索引、线上”三处必须一致。再看索引结构。最直白的实现是暴力扫描把候选向量全部读出来一个个算相似度复杂度 O(N*d)。这个方案不是一无是处十万条以内、128 维单机毫秒级搞定比引入服务、训练量化模型省心多了。Faiss 的IndexFlatIP和IndexFlatL2就是干这个的。别一上来就上 HNSW先问一句你的向量库到底有多大很多场景的“百万级候选”其实是用户个性化的子集真正在一个 partition 里只有几千条暴力扫描反而更快而且它是精确结果。importfaissimportnumpyasnp# 建索引之前先统一归一化。这里踩过坑别嫌麻烦vectorsnp.random.random((100000,128)).astype(float32)faiss.normalize_L2(vectors)# 用 IndexFlatIP 做精确检索归一化后的内积就是余弦indexfaiss.IndexFlatIP(128)index.add(vectors)querynp.random.random((1,128)).astype(float32)faiss.normalize_L2(query)D,Iindex.search(query,10)数据量上来之后暴力扫描不够用IVF 是第一个常见的优化思路。IVF 把向量库用 K-Means 聚成 nlist 个桶add 的时候每个向量进入最近的桶。查询时先算出 query 距离哪些质心近挑出 nprobe 个桶只在这些桶里暴力扫。复杂度变成 O(nprobe * (N/nlist) * d)。nlist 不是越大越好桶太多每个桶太稀疏查询时为了召回率得增大 nprobe最后可能和暴力差不多。我自己的经验是 nlist 取 sqrt(N) 到 4*sqrt(N) 之间nprobe 先给 8 到 16再根据 Recall 慢慢调。# IVF 示例先 train 再 add忘掉 train 会直接报错nlist1000quantizerfaiss.IndexFlatIP(128)index_ivffaiss.IndexIVFFlat(quantizer,128,nlist,faiss.METRIC_INNER_PRODUCT)index_ivf.train(vectors)index_ivf.add(vectors)index_ivf.nprobe16# 查询时扫描的桶数量D,Iindex_ivf.search(query,10)HNSW 是另一个流量担当它用多层图把向量组织起来底层是普通邻居图上层是稀疏的长距离跳板。搜索时从最上层进入每层找到近似最近邻再往下一层继续。参数 M 控制每个节点的最大邻居数M 越大图越稠密准确率越高内存越高。efConstruction 影响建图质量efSearch 影响查询时的搜索宽度。很多人调参只看 M忽略了 efSearch。我见过一个项目 M 拉到 128 反而变慢efSearch 用默认 16 召回率惨不忍睹。实际上 efSearch 从 16 提到 64召回率能涨好几个点延迟只多了几毫秒。这里还要注意HNSW 的插入过程要维护邻居图多线程并发 add 同一个 index 容易出事线上更新要么加锁要么用单线程重建。# HNSW 不需要 train但参数要按数据量调index_hnswfaiss.IndexHNSWFlat(128,32,faiss.METRIC_INNER_PRODUCT)index_hnsw.hnsw.efConstruction100# 建图候选数index_hnsw.hnsw.efSearch64# 查询候选数别用默认值index_hnsw.add(vectors)D,Iindex_hnsw.search(query,10)如果内存实在紧张PQ 产品量化就来了。PQ 把向量切成 m 段对每一段做聚类生成码本然后用码字 id 替换原始向量。128 维 float32 原本占 512 字节切成 16 段、每段用 8 bit 表示压缩后只有 16 字节。省了 32 倍内存代价是距离计算变成查表近似精度有损失。PQ 常用于超大库的存储层或者和 IVF、HNSW 组合成 IVFPQ、HNSWPQ。训练码本时训练集一定要来自真实数据分布别拿 np.random 练码本不然线上全是“未登录码字”召回率崩得妈都不认。LSH 是另一条路线用随机投影让相似向量有更大概率落到同一个桶里适合文本指纹、去重这类二值化场景对 dense embedding 的精度一般不如 HNSW/PQ我很少在业务里单独用 LSH 做向量召回。回到事故本身。那次修复很简单embedding 离线输出之后统一做 L2 normalizeFaiss 里 metric 改成METRIC_INNER_PRODUCT因为 normalized 之后 IP 就是 cosine。上线前又加了一个一致性测试用同一批 query离线评估脚本和线上服务各算一遍 top100相似度和 id 必须完全一致。从那以后我再也没因为这个栽过跟头。我的经验性建议是选择相似度算法之前先确认 embedding 的数学含义。很多预训练模型的 sentence embedding 没有归一化有的开源代码甚至直接拿 pooler_output 做检索这种向量模长和文本长度、语料频次都可能相关不归一化直接用点积等于给热门文档加了一个隐形的 bias。另外索引类型和相似度算法是两回事IndexFlatIP 是“用内积当距离度量的精确索引”IndexIVFFlat 同样可以搭配 inner product。你可以用余弦相似度但底层索引存 normalized 向量 IP也可以用欧氏距离底层直接 L2。别只看索引名里的 Flat、IVF、HNSW还要确认 metric 参数。暴力索引不是耻辱。十万条以内、128 维单机毫秒级搞定比引入服务、训练量化模型省心多了。越高级的索引越需要调参和踩坑。HNSW 的 efSearch 是线上召回率最容易掉的点每周观察一下召回率波动如果发现 topK 忽然少了几个相关结果先去查 efSearch 是不是被某次优化改小了。读论文时经常看到 ANN 的 benchmark比如 Recall10但工程里要的不是纯 Recall而是业务指标。你可以在召回率 0.95 和 0.98 之间做 trade-off还要看 p99 延迟和内存占用。别为了 0.03 的 recall 把内存翻三倍。写这篇的时候我又想起那个深夜的日志同样的向量一个 cosine 一个 inner product线上世界就完全不同。向量检索的算法和索引听起来是数学和数据结构的问题落到工程上全是 alignment 的问题。加上一行 normalize改一个 metric 参数可能就救回一次版本发布。