在架构的最后一站,我们谈检索时到底「比什么」。相似度函数决定了召回的质量上限,是 LEANN 不可外包的一环。
最常用三种度量。余弦看方向不看长度,适合文本语义;内积在向量已归一化时等价于余弦且更快;欧氏距离看绝对位置,对尺度敏感。下面一段把三者放一起比,并给出选型的经验。
import numpy as np def cosine(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) def inner(a, b): return float(np.dot(a, b)) def euclidean(a, b): return float(np.linalg.norm(a - b)) q = np.random.default_rng(1).standard_normal(64) d = np.random.default_rng(2).standard_normal(64) print('余弦', round(cosine(q, d), 3), '内积', round(inner(q, d), 3), '欧氏', round(euclidean(q, d), 3))
经验法则:文本嵌入通常已做归一化,用内积最省;若嵌入未归一化,必须用余弦,否则长度会污染相似度。我们见过团队拿未归一化的向量直接算内积,长文档(向量范数大)被系统性抬高,召回全歪。
检索阶段还有个关键参数 ef_search:它控制近似检索「往深处看多少」。值越大越准越慢。下面给出延迟-召回的权衡扫描。
def search_with_ef(index, q, ef, k=10): # ef 越大召回越好,但遍历节点更多 cand = index.greedy(q, width=ef) # 示意:按宽度取候选 return sorted(cand, key=lambda c: -cosine(q, c))[:k] for ef in [16, 32, 64, 128]: # 用模拟耗时表达权衡:耗时随 ef 近似线性 print(f'ef={ef} 预估耗时(ms)={ef*0.05:.2f}')
案例:长文档召回被抬高
索引实现通常对相似度有偏好:HNSW 常用内积距离,IVF 聚类常用欧氏距离,余弦要求向量先归一化。选错不是「不精确」,而是可能直接报错或结果全乱。工程上最简单的做法:全部向量统一归一化,索引内部一律用内积——归一化后内积等价于余弦,且计算最快。这条规则把「相似度选择」从每次都要想一遍的事,变成一条默认约定。
| 向量状态 | 推荐相似度 | 理由 |
|---|---|---|
| 已归一化 | 内积 | 等价余弦且计算最快 |
| 未归一化 | 余弦 | 长度不污染方向 |
| 尺度敏感 | 欧氏 | 看重绝对位置 |
归一化必须在摄取阶段完成,而不是查询时临场补。因为索引里存的必须与查询时用的是同一规约下的向量。下面给出批量归一化片段,直接放进 ingest 流程。
def normalize_rows(vecs): # 每行除以自身范数,返回与原形状一致的归一化结果 norms = np.linalg.norm(vecs, axis=1, keepdims=True) norms[norms == 0] = 1.0 # 防零向量除零 return vecs / norms
注意零向量处理:真实数据里偶发全零向量(比如空文本),直接除会得到 NaN,污染整个索引。先把零范数兜底为 1,查询阶段再把零向量过滤掉,是摄取管线的常规防御。
ef_search(HNSW 的搜索宽度)与 nprobe(IVF 的探测簇数)功能类似,都是「往深处看多少」。两者的组合要按索引类型定:用 HNSW 只调 ef_search,用 IVF 只调 nprobe,不要同时把两个都拉满。多数情况下,把单一旋钮从默认值往上抬一档,就能换回大部分召回,性价比最高。
第一个坑是 float32 精度:大规模索引里两段几乎相同的文本,余弦差在小数点后第七位,float32 可能把差异抹平,排序不稳。对精度敏感的场景用 float64 计算、float32 存储。第二个坑是未归一化直接算内积,2.4 末尾的案例就是实例:长文档范数大被系统性抬高。两个坑归结为一句话:计算前先确认向量规约,计算中留意精度。
检索返回的相似度分数只有相对意义,没有绝对意义:0.82 不等于「82% 相关」。重排头输出的分数更是网络自行校准的,跨模型不可比。因此做阈值过滤时,不要在分数上拍脑袋定值,而要在验证集上跑出分布再定。这条原则能避免一个常见误操作:上线前随手改相似度阈值,导致召回忽多忽少。
def pick_threshold(scores, labels, target_recall=0.9): # 在验证集上扫描阈值,让召回达到目标再取阈值 best = (0.0, 0.0) for t in np.linspace(scores.min(), scores.max(), 50): hit = ((scores >= t) & labels).sum() / labels.sum() if hit >= target_recall and hit - best[0] > 1e-9: best = (hit, t) return best
边缘端相似度计算也要算账。对 30 个候选算余弦,开销几乎为零;但若在索引检索阶段对百万向量全量算距离,一次查询就是百万次浮点乘加。正确的分层是:索引层用快速近似距离过滤出小候选池,重排层才用精确相似度。别把精确相似度放在索引层,也别把近似距离带进重排层——两层的精度诉求完全不同。
本节可考核点:能讲清余弦、内积、欧氏各自的适用条件,并解释为何「未归一化向量直接内积」会出问题。
