余弦检索实现细节与坑
用 NumPy 实现余弦检索时有几个容易踩的实现细节:归一化要用 float32 以避免精度漂移;接近零的向量(例如空文本编码出的全零 embedding)会让除法产生 NaN,必须在入库前过滤或兜底。NaN 一旦写进向量库,排序会整体异常——比较结果全部为假,返回顺序看起来像随机的,而且极难定位。
基本信息
| 归一步骤 | 逐行计算模长后相除 |
|---|---|
| 精度要求 | 全链路统一 float32 |
| 除零风险 | 全零向量做除法会得到 NaN |
| 兜底写法 | 分母加一个小量 eps,或直接过滤 |
| 入库校验 | 检查 NaN 与 Inf |
| 常见根因 | 空文本或纯符号文本的 embedding |
典型使用场景
- 自实现余弦检索的原型代码
- 离线批量计算相似度
- 校验向量库中已有向量的健康度
- 排查检索结果完全随机的诡异问题
- 数据清洗阶段过滤脏向量
安全归一化
安全归一化
import numpy as np
def safe_l2norm(x, eps=1e-12):
norms = np.linalg.norm(x, axis=1, keepdims=True)
return x / np.maximum(norms, eps) # 全零向量保持全零,不产生 NaN
vecs = safe_l2norm(np.random.rand(100, 384).astype("float32"))
assert not np.isnan(vecs).any() # 入库前统一校验一次异常值读法
| 现象 | 原因 | 处置 |
|---|---|---|
| 结果为 NaN | 全零向量做除法 | 分母加 eps,或直接过滤该向量 |
| 排序完全错乱 | 库中已混入 NaN | 入库前统一校验并清理脏数据 |
| 分数微小不一致 | float32 与 float64 混用 | 全链路统一 dtype |
| 模长异常偏大 | 归一化步骤未执行 | 确认归一化是否在入库与查询两侧都做了 |
对全零向量做除法会得到 NaN;NaN 一旦入库,检索排序整体异常且极难定位,所以校验必须放在写入之前。
全零向量的常见来源是空文本或纯符号文本:切分阶段就应丢弃这类片段,或为它们指定一个兜底向量。
余弦与内积在归一化之后等价,但归一化必须两侧都做:只归一化查询而不归一化文档,分数就失去可比性。
校验脚本值得定期跑一次:抽查全量向量是否存在 NaN 或零模长,把问题挡在检索之前,而不是等用户反馈结果奇怪。