KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

01 · 向量、距离与度量 — keel 龙骨

上一章的 Flat 基线能成立,前提是「距离」定义清楚。这一章用同一批数据把 L2、内积、余弦三种度量放一起跑,你会得到一个不太好接受的结果:在这批数据上,L2 和内积的 top-10 一条都不重合,余弦和内积也只重合 21.8%。度量选错,后面所有索引参数都调不到点子上。

上一章的 Flat 基线能成立,前提是「距离」定义清楚。这一章用同一批数据把 L2、内积、余弦三种度量放一起跑,你会得到一个不太好接受的结果:在这批数据上,L2 和内积的 top-10 一条都不重合,余弦和内积也只重合 21.8%。度量选错,后面所有索引参数都调不到点子上。

现场

一个典型的事故:你的 embedding 模型文档写着「用余弦相似度」,你在库里也选了 COSINE,一切正常。换了个模型,它的输出向量模长差异很大,你忘了归一化,但库里依然按余弦算——看起来没问题。直到上线后发现「长文档总是排在前面」,或者更糟:换了套查询代码,把 COSINE 写成了 IP,召回率掉了一半,没人发现,因为没人比对过排序。

度量不是一个可以「差不多就行」的开关。它决定了「谁离谁近」,而索引的所有取舍(簇怎么分、图怎么连、PQ 怎么编码)都建立在这个定义上。

三个度量的关系

三个词经常被混着用,它们的关系其实只有两条恒等式。

欧氏距离(L2):|q - m|。faiss 默认返回的是它的平方 |q - m|²,省掉开方。

内积(IP / dot product):q · m = Σ qᵢmᵢ。越大越相似。

余弦相似度:cos(q, m) = (q · m) / (|q| |m|)。它把两个向量都拉到单位长度再算内积,所以只看方向、不看长度。

把它们连起来的是展开式:

|q - m|² = |q|² + |m|² - 2 (q · m)

从这条式子能直接读出两个结论,它们就是这一章的全部内容:

  1. 如果所有向量的模长都相同(比如都归一化过,|m| = 1),那么 |q - m|² = 2 - 2 cos(q, m)——L2 和余弦单调等价,排序完全一样。
  2. 如果模长不一样,|m|² 这一项就进到距离里,L2 和余弦不再是同一个排序。

内积是这条式子里唯一不减去模长的项,所以它天然偏向模长大的向量。这就是「长文档总是排前面」的根源。

度量选错,排序会差多少:实测

脚本造一批方向随机、模长差异很大的数据(模长 0.25x ~ 4.0x),分别用 L2、未归一化内积、归一化余弦建索引,比 top-10 的平均重合度(1.0 表示每个查询的 10 条完全相同)。

原始输出(lab/evidence/vector-database-engineering/02-metrics.txt):

=== top-10 集合平均重合度(1.0 = 完全相同)===
L2        vs IP(未归一化)      : 0.0000
L2        vs 余弦(归一化)      : 0.2024
IP(未归一) vs 余弦(归一化)     : 0.2181
L2(归一化) vs 余弦(归一化)     : 1.0000
IP(归一化) vs 余弦(归一化)     : 1.0000

四行数据,四个结论:

具体到某一条查询,看内积 top-10 里的向量在余弦榜上排第几:

ip_rank= 1 id= 65639 norm= 44.538 ip= 326.859 cos_rank=3
ip_rank= 2 id= 85872 norm= 45.098 ip= 311.787 cos_rank=9
ip_rank= 6 id= 99418 norm= 52.611 ip= 280.294 cos_rank=278
ip_rank=10 id= 79011 norm= 46.683 ip= 262.127 cos_rank=181

ip_rank=6 的那条(id 99418)内积排第 6,余弦榜上掉到第 278——因为它的模长 52.611 是这批里最大的,内积把它的长度当成了相似度。ip_rank=1 的 65639 模长 44.538,余弦榜第 3,勉强还行。这批数据里模长的差异把内积排序带偏得很厉害。

距离集中:维度越高,越拉不开

还有一件影响索引的事:维度上去以后,任意两点之间的距离会趋同。同一批随机点里,「最近的」和「最远的」之间差距越来越小。实测(同文件末尾):

  dim  mean_dist   min_dist   max_dist  (max-min)/mean
    2     0.4747     0.0089     0.9633          2.0105
    8     1.1484     0.3560     1.8162          1.2715
   32     2.2548     1.4591     2.9823          0.6756
  128     4.6119     3.8218     5.3705          0.3358
  512     9.2131     8.4135     9.9610          0.1680

(max-min)/mean 从 2 维的 2.01 掉到 512 维的 0.17。含义是:在 128 维、512 维里,最近邻和最远邻的区分度非常小,所有点看起来「差不多远」。

这对索引是坏消息。IVF 靠「把相近的点聚成一簇」,但高维下簇内和簇间的距离差异被压缩了;HNSW 靠「图上走几步就能到最近邻」,但邻居彼此区分度低时,图的导航也会失灵。本课所有随机数据实验(第 03、05 章)召回率都不高,一半原因就是这批数据没有真实的簇结构——真实 embedding 有语义结构,比随机均匀数据好做得多,但你不能因此忽略这个效应。

平方距离和阈值

faiss 的 L2 索引返回的是平方距离,省掉一次开方。第 00 章里那批数据的最近邻平方距离是 13.36,对应的欧氏距离大约是 3.66(开方)。这个细节在两种场合会咬你:

一个可操作的检查步骤

拿到一批未知来源的 embedding,按这个顺序确认度量:

  1. 算每个向量的模长,看分布。模长几乎都等于 1 → 已归一化,用余弦或内积或 L2 都行;模长差异明显 → 未归一化,按原样处理。
  2. 查模型文档或训练代码,确认训练时用的相似度定义(余弦 / 内积 / L2)。
  3. 取一批向量,用两种合理候选度量各跑一次 top-k,算重合度。如果重合度接近 1.0,说明这两种度量等价、选谁都行;如果差很多,就必须回到第 2 步确认,不能靠试。

第 3 步就是本章开头那组实验的简化版。它花不了几分钟,但能挡住「度量选错导致召回减半」这类上线后才发现的问题。

度量必须和向量生成方式配套

把上面的结论收成规则:

flowchart TD
    A["确认 embedding 模型怎么训练的"] --> B{"训练时向量做过<br/>L2 归一化吗?"}
    B -->|做过| C["入库和查询都归一化<br/>→ L2 / IP / 余弦 三者等价"]
    B -->|没做过| D["按原向量入库<br/>不要擅自归一化"]
    D --> E{"训练用的相似度定义?"}
    E -->|余弦| F["检查是不是漏了归一化<br/>余弦需要单位向量"]
    E -->|内积| G["用 IP 索引<br/>接受模长参与排序"]
    E -->|L2| H["用 L2 索引<br/>注意返回的是平方距离"]
    C --> I["度量与索引参数<br/>可以自由选库默认值"]

    style A fill:#e3f2fd,color:#0d3b66
    style B fill:#fff3e0,color:#8a4b00
    style C fill:#e8f5e9,color:#1b5e20
    style D fill:#e8f5e9,color:#1b5e20
    style E fill:#fff3e0,color:#8a4b00
    style F fill:#ffebee,color:#b71c1c
    style G fill:#f1f8e9,color:#33691e
    style H fill:#f1f8e9,color:#33691e
    style I fill:#e8f5e9,color:#1b5e20

规则只有一条:度量和向量生成方式是一对,不能拆开看。模型训练时用了归一化 + 余弦,你就得归一化 + 余弦;模型直接输出 L2 空间的距离,你就别归一化再上内积。换度量等于换排序,换排序等于召回评估作废、得重来。

一个具体判断:如果你拿到的是第三方 embedding(比如某个 API 返回的向量),先去查它的文档说没说过归一化、用的什么相似度。查不到就自己验:取一批向量算它们模长的方差,方差很小 → 大概率已归一化;方差很大 → 没归一化,必须按原样处理。

余弦分数不是可信度

第 07 章(记忆系统)里有一条铁律,这里再强调一次:余弦相似度只表示「方向接近」,不表示这条结果可信、有效、有权被读。cos=0.84 的邻居可能是一条过期或越权的记录。不要把相似度阈值当可信度阈值用。它只是一个检索阈值,得用真实查询集标定。

主动破坏

把实验脚本改一行:构造数据时把模长差从 uniform(0.25, 4.0) 拉大到 uniform(0.1, 10.0)(模拟更极端的长度差异),重跑。你会看到 L2 vs IP 的重合度依然是 0.0000,而 IP vs 余弦 会掉得更低——模长差异越大,内积偏离余弦越远。

再破坏一次:数据入库时归一化、查询时忘记归一化,跑内积索引。这时每个查询向量的模长都不同,内积排序会被查询自身的长度带偏,结果没有稳定规律——这正是线上「同样的查询,换个措辞就召回完全不同」的一类根因。

生产边界

动手

  1. 跑 02_metrics.py,把 scale 改成 uniform(0.5, 2.0)(模长差异变小),重看 IP vs 余弦 的重合度——它会明显上升。解释为什么。
  2. 取一批你手上真实的 embedding,算每个向量的模长,画出分布。方差大还是小?据此判断你的模型是否需要归一化。
  3. 用 IndexFlatIP 建两份索引:一份数据归一化、一份不归一化,同一批查询分别检索,输出两者 top-10 的差集大小。

自测

  1. 用恒等式推出:如果所有向量模长都等于 1,为什么 L2 和余弦给出相同排序?如果模长都等于同一个常数 c(c≠1)呢?
  2. L2 vs IP 的重合度是 0.0000,而 L2 vs 余弦 是 0.2024。为什么 L2 和余弦还稍微「沾点边」,和 IP 却完全不沾?
  3. 你的模型文档说用内积训练,但你担心长文档占优,能不能干脆改成余弦?说出这个改动的代价和必须同步做的动作。
  4. 距离集中现象对 IVF 的 nlist 选择有什么影响?(提示:簇内距离差异变小时,粗量化器还能不能把空间切开)
  5. 一个同事说「反正都归一化了,L2 和余弦一样,那 IP 也就一样」。这句话对在哪、错在哪?

↓ 下一步:02 章 · 索引家族 —— 度量定好之后,才是 IVF / PQ / HNSW 各自怎么组织数据。

进入 keel 阅读