两组人算出不同的相似度分数,常见原因不在模型,而在计算链路上:点积算了什么、范数在哪一步除掉、向量入库时用的是哪种度量。这三件事只要有一处不一致,余弦相似度就不再是同一个数,召回排序自然对不上。建议先把归一化与度量这条链路固定下来,再去调阈值和召回策略,否则后面的调参都建立在不可比的分数上。
排查顺序建议是:先用一对小向量手算点积与范数,确认公式落地正确;再对比“先归一化后点积”和“先点积后相除”,看两者是否给出同一个数、差异出现在什么条件下;然后抽样确认模型输出是否已是单位范数,决定要不要再手动归一化;最后核对向量库建索引时的度量配置。四步都通过配置、日志和命令可验证,任一步不确定就不该对外宣称分数可比。
手算一对向量的点积与各自范数
选两个容易口算的向量,把余弦公式拆成三个量:点积、两个范数。取 a = [3, 4, 0],b = [1, 2, 2],先写出每一步的中间值,再和代码输出对照,这样后面出现分歧时才有基准。
import numpy as np
a = np.array([3.0, 4.0, 0.0])
b = np.array([1.0, 2.0, 2.0])
dot = float(a @ b) # 3*1 + 4*2 + 0*2 = 11
na = float(np.linalg.norm(a)) # sqrt(9+16+0) = 5
nb = float(np.linalg.norm(b)) # sqrt(1+4+4) = 3
cos = dot / (na * nb) # 11 / 15
print(dot, na, nb, cos)
逐步中间值:点积 11;a 的范数 5;b 的范数 3;分母 15;最终相似度 11/15 ≈ 0.7333。这个数就是基准值,任何实现只要在这对向量上给不出 0.7333(允许末位浮点误差),就说明归一化或分母的取法有问题。注意分母是两个范数的乘积,不是其中一个范数;只除以一个范数得到的是“投影长度”,量纲和取值范围都不同,仍拿去和别人的余弦分数比较就会全错位。
对比先归一化再点积与直接点积后相除
同一对向量用两种写法各算一遍,理想情况下应得到同一个数。差异通常只出现在末位,但一旦差异变大,往往不是浮点问题,而是归一化被漏掉、被重复做,或分子分母取自不同的向量。
def cos_normalize_first(a, b):
an = a / np.linalg.norm(a)
bn = b / np.linalg.norm(b)
return float(an @ bn)
def cos_divide_after(a, b):
return float(a @ b) / (float(np.linalg.norm(a)) * float(np.linalg.norm(b)))
print(repr(cos_normalize_first(a, b))) # 0.7333333333333334
print(repr(cos_divide_after(a, b))) # 0.7333333333333333
同批样本上,两种写法在数值上应当一致到浮点精度;用 float32 与 float64 混算时,末位差异会略大一些,但不应影响排序。真正会让结果对不上的条件有几类:一是归一化做了两次或一次没做,先归一化再点积时若又把结果除以范数,分数会被压小;二是批量归一化只对查询侧做了、文档侧没有做,或反过来;三是零向量或接近零的向量参与除法,得到 inf/nan 后被打分逻辑过滤掉,表现为“某些文档永远不召回”;四是库里存的已经是归一化向量,但检索仍按内积配未归一化的查询。差异出现时先定位属于哪一类,再改代码。
抽样检查模型输出是否已是单位范数
不同模型、不同导出格式对“输出是否已归一化”并不统一,不能凭印象假设。落库前抽一批真实输出,直接算每条的 L2 范数看分布,再决定要不要手动补一次归一化。
import numpy as np
# vecs: shape = (n, dim),从一次真实推理结果里取一批
norms = np.linalg.norm(vecs, axis=1)
print(norms[:8]) # 前 8 条的范数
print(norms.min(), norms.max(), norms.mean())
# 已归一化时,直接算相似度就够了:
scores = vecs @ query_vec # 前提是 query_vec 也已归一化
抽样条数不需要很多,抽一两百条覆盖不同长度、不同语言的输入即可,关键是覆盖真实调用路径而不是构造的样例。判定标准建议这样定:若最小范数与最大范数都贴近 1(例如与 1 的偏差都在 1e-4 量级以内),可认为输出已是单位范数;若范数随文本长度明显变化、或散落在 0.2 到 20 这类区间,就说明未归一化,必须在写入向量库前统一做一次归一化,并把查询侧用同一套规则处理。为了稳妥,即便模型侧声称已归一化,也建议在入库前保留一次归一化运算——它对单位向量是幂等操作,成本低,却能挡住上游格式变更。
确认向量库建索引时用的是哪种度量
模型侧按余弦、库侧按内积,是最常见的错位来源。建索引或建集合时的度量配置项决定了库如何比较向量,这个值必须和模型侧的计算方式对齐。常见的配置形态包括:内积(IP / inner product)、余弦(COSINE)、欧氏距离(L2),不同库对是否“自带归一化”的处理也不一样,具体以所用版本的文档和实际行为为准。
# 通用骨架,函数名与参数按实际使用的库替换
# 例:内积索引
index = build_index(dim=DIM, metric="IP")
# 例:余弦索引(部分库会在内部做归一化,部分不会)
index = build_index(dim=DIM, metric="COSINE")
# 查看方式:读索引/集合的度量配置,或打印索引参数
print(index.describe()) # 关注 metric / distance_type 字段
# 也可从已有集合的描述信息里读取,确认是否为建库时的值而非客户端默认值
不一致时的典型现象:查询返回的分数绝对值大于 1,且长文本、长向量明显占优——因为内积同时受方向和模长影响,排序被模长带偏。另一种情况是库侧用 L2、模型侧用余弦,而入库向量已归一化:此时 ||x−y||² = 2 − 2·(x·y),距离与余弦严格单调对应,排序其实一致,但分数含义相反,前端若直接展示距离值会让人误判“越相似分数越大”。还有一种情况是建索引时用了默认度量、客户端查询时又传了不同的度量参数,容易出现“同一个向量两次查询结果不同”。核对方式是在建库脚本和查询脚本里各打印一次度量配置,确认两处是同一个值,而不是只在其中一处显式指定。
把度量与归一化写进一份接入约定
把上面几件事固化成一份短文,附在向量检索模块的说明里,新人接入时按条目逐条核对,比口头传达可靠。约定至少要写清这些字段:
- 模型标识与版本、向量维度、输出 dtype(float32 等);
- 模型输出是否已是单位范数,以及入库前是否仍执行一次归一化(写“是/否”,不留空);
- 归一化使用的范数类型(L2)与零向量的处理策略(跳过、置常量、报错三者择一);
- 向量库的度量配置值(IP / COSINE / L2)、索引类型、该度量下分数字段的含义(越大越相似还是越小越相似);
- 查询侧的归一化规则,必须与文档侧一致;
- 相似度阈值与其对应的度量口径,换度量时必须重定阈值。
新人接入时的校验步骤建议按顺序走:先用手算基准对向量 a=[3,4,0]、b=[1,2,2] 验证实现输出为 0.7333;再对同一批样本分别跑“先归一化后点积”和“先点积后相除”,确认两条路径结果一致;接着抽样检查落库向量的范数分布是否符合约定;然后读一次索引的度量配置,确认与约定字段相同;最后跑一条固定查询,检查返回分数的量纲(是否在 -1 到 1 之间)与排序方向。任何一步对不上,先回到约定文档修改或修正实现,不要靠调阈值把问题盖过去。