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)
从这条式子能直接读出两个结论,它们就是这一章的全部内容:
- 如果所有向量的模长都相同(比如都归一化过,
|m| = 1),那么|q - m|² = 2 - 2 cos(q, m)——L2 和余弦单调等价,排序完全一样。 - 如果模长不一样,
|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
四行数据,四个结论:
L2 vs IP(未归一化) : 0.0000——一条都不重合。这不是「略有差异」,是两套完全不同的排序。L2 里|m|²是个惩罚项(模长大 → 距离大 → 排后面),内积里它是个加分项(模长大 → 内积大 → 排前面),方向正好相反。IP vs 余弦 : 0.2181——把内积换成余弦,top-10 只重合 2.18 条。归一化不是「微调」,它会重排结果。L2(归一化) vs 余弦 : 1.0000、IP(归一化) vs 余弦 : 1.0000——只要两边都归一化,L2、内积、余弦给出完全相同的 top-10。这条恒等式实测成立,意味着:归一化之后,这三种度量可以随便挑,选你库默认支持最好的那个。- 反过来读:只要你没归一化,选哪个度量就是在选一套不同的排序,别指望它们互相印证。
具体到某一条查询,看内积 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(开方)。这个细节在两种场合会咬你:
- 你想设「距离小于某个阈值才算命中」时,阈值单位和索引返回单位必须一致。拿欧氏距离的阈值去比平方距离,命中范围会被放大或缩小。
- 你想把距离换算成「相似度百分比」展示给用户时,平方距离没有上界、不归一化,
1/(1+d)之类的换算只是好看,不代表语义上的相似度。真要展示,用归一化后的余弦,它有明确的[-1, 1]范围。
一个可操作的检查步骤
拿到一批未知来源的 embedding,按这个顺序确认度量:
- 算每个向量的模长,看分布。模长几乎都等于 1 → 已归一化,用余弦或内积或 L2 都行;模长差异明显 → 未归一化,按原样处理。
- 查模型文档或训练代码,确认训练时用的相似度定义(余弦 / 内积 / L2)。
- 取一批向量,用两种合理候选度量各跑一次 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 余弦 会掉得更低——模长差异越大,内积偏离余弦越远。
再破坏一次:数据入库时归一化、查询时忘记归一化,跑内积索引。这时每个查询向量的模长都不同,内积排序会被查询自身的长度带偏,结果没有稳定规律——这正是线上「同样的查询,换个措辞就召回完全不同」的一类根因。
生产边界
- 教学替身:直接对 numpy 数组算距离,没有网络和序列化开销。真实链路里向量要经过序列化、跨进程传输,任何一步改了精度(比如 float64 转 float32)都会轻微改变排序。
- 上线要盯的指标:
top-k 与精确基准的重合度、召回率(需要真实标注或人工抽检)、查询向量模长分布(突变的模长分布通常意味着上游换了模型或预处理)。 - 失败策略:把度量写进 schema 或配置并随索引一起版本化。换 embedding 模型时,连带重建索引(第 06 章),不要让「新向量 + 旧度量」的组合上线。
动手
- 跑
02_metrics.py,把scale改成uniform(0.5, 2.0)(模长差异变小),重看IP vs 余弦的重合度——它会明显上升。解释为什么。 - 取一批你手上真实的 embedding,算每个向量的模长,画出分布。方差大还是小?据此判断你的模型是否需要归一化。
- 用
IndexFlatIP建两份索引:一份数据归一化、一份不归一化,同一批查询分别检索,输出两者 top-10 的差集大小。
自测
- 用恒等式推出:如果所有向量模长都等于 1,为什么 L2 和余弦给出相同排序?如果模长都等于同一个常数 c(c≠1)呢?
L2 vs IP的重合度是 0.0000,而L2 vs 余弦是 0.2024。为什么 L2 和余弦还稍微「沾点边」,和 IP 却完全不沾?- 你的模型文档说用内积训练,但你担心长文档占优,能不能干脆改成余弦?说出这个改动的代价和必须同步做的动作。
- 距离集中现象对 IVF 的
nlist选择有什么影响?(提示:簇内距离差异变小时,粗量化器还能不能把空间切开) - 一个同事说「反正都归一化了,L2 和余弦一样,那 IP 也就一样」。这句话对在哪、错在哪?
↓ 下一步:02 章 · 索引家族 —— 度量定好之后,才是 IVF / PQ / HNSW 各自怎么组织数据。