4、向量相似度与距离度量:余弦相似度、欧氏距离、曼哈顿距离、内积、Jaccard相似度、适用场景对比
聊到向量数据库,有个绕不开的话题——怎么判断两个向量“像不像”?
说白了,这就是相似度度量。我刚开始接触向量检索时,觉得不就是算个距离嘛,有啥难的?结果第一次上线推荐系统,召回结果全是错的。嗯,后来才发现,选错度量方式,整个系统就废了。
这一章,我把工作中最常用的六种度量方法掰开揉碎讲清楚。每种我都会给出代码示例,再聊聊实际场景里踩过的坑。
4.1 余弦相似度(Cosine Similarity)
余弦相似度,算的是两个向量方向上的差异。它只看夹角,不看长度。
公式很简单:
cos(A, B) = (A · B) / (||A|| × ||B||)
取值范围是 [-1, 1]。1 表示方向完全一致,-1 表示方向完全相反。
核心特点:对向量的模长不敏感。哪怕一个向量是另一个的十倍长,只要方向相同,余弦相似度就是 1。
我在做文本检索时特别喜欢用它。为什么?因为文档长度差异太大了。一篇 500 字的新闻和一篇 5000 字的报告,词频向量长度差很多,但主题可能完全一样。余弦相似度正好能忽略长度,抓住语义方向。
import numpy as np
def cosine_similarity(a, b):
dot_product = np.dot(a, b)
norm_a = np.linalg.norm(a)
norm_b = np.linalg.norm(b)
return dot_product / (norm_a * norm_b)
# 示例
vec1 = np.array([1, 2, 3])
vec2 = np.array([2, 4, 6]) # 方向相同,长度翻倍
print(cosine_similarity(vec1, vec2)) # 输出 1.0
个人经验:做文本 embedding 检索时,我默认先用余弦相似度。它和 BERT、Sentence-BERT 这类模型的输出天然匹配。我曾经试过用欧氏距离替代,结果 top-10 召回里混进了大量短文本噪声,后来果断换回余弦。
4.2 欧氏距离(Euclidean Distance)
欧氏距离就是我们最熟悉的“直线距离”。
d(A, B) = sqrt( (a1-b1)² + (a2-b2)² + ... + (an-bn)² )
取值范围是 [0, +∞)。0 表示完全重合。
欧氏距离对向量的每个维度都敏感。换句话说,它既看方向,也看长度。
我记得有一次做图像特征检索,用余弦相似度怎么调都召不回相似图片。后来发现,图像特征向量的模长本身就携带了重要信息——比如亮度、对比度。换成欧氏距离后,效果立竿见影。
def euclidean_distance(a, b):
return np.sqrt(np.sum((a - b) ** 2))
vec1 = np.array([1, 2, 3])
vec2 = np.array([2, 4, 6])
print(euclidean_distance(vec1, vec2)) # 输出约 3.74
避坑指南:我曾经在一个高维稀疏向量场景里直接用欧氏距离,结果所有样本的距离都差不多,完全拉不开区分度。高维空间下,欧氏距离会遭遇“维度灾难”,建议先降维再用。
4.3 曼哈顿距离(Manhattan Distance)
曼哈顿距离也叫 L1 距离。想象你在网格状街道上走路,只能横平竖直地走,不能穿楼。
d(A, B) = |a1-b1| + |a2-b2| + ... + |an-bn|
它比欧氏距离更鲁棒。为什么?因为值运算对异常值不那么敏感。欧氏距离平方后,异常值会被放大,曼哈顿距离不会。
我个人的习惯是:当特征中有明显噪声或离群点时,优先考虑曼哈顿距离。比如用户行为数据,偶尔会有刷单导致的异常点击,用曼哈顿距离能减少这些异常的影响。
def manhattan_distance(a, b):
return np.sum(np.abs(a - b))
vec1 = np.array([1, 2, 3])
vec2 = np.array([2, 4, 6])
print(manhattan_distance(vec1, vec2)) # 输出 6
4.4 内积(Dot Product)
内积就是向量对应位置相乘再求和。
dot(A, B) = a1×b1 + a2×b2 + ... + an×bn
取值范围是 (-∞, +∞)。
内积同时受向量方向和长度影响。两个向量方向越一致、长度越大,内积越大。
你想想看,什么场景下内积最常用?推荐系统!用户向量和物品向量的内积,直接代表用户对物品的偏好程度。长度越大,偏好越强。
注意:内积不是归一化的度量。如果向量长度差异很大,内积结果会被长度主导,而不是语义相似度。所以使用前一定要确认你的业务逻辑是否需要考虑长度。
def dot_product(a, b):
return np.dot(a, b)
vec1 = np.array([1, 2, 3])
vec2 = np.array([2, 4, 6])
print(dot_product(vec1, vec2)) # 输出 28
4.5 Jaccard 相似度
Jaccard 相似度比较特殊。它算的是两个集合的交集与并集之比。
J(A, B) = |A ∩ B| / |A ∪ B|
取值范围是 [0, 1]。0 表示没有共同元素,1 表示完全相同。
它不关心向量中每个维度的具体数值,只关心“有没有”。所以特别适合处理二值向量(0/1 向量)。
我在做标签系统时用过它。比如用户 A 的标签是 {体育, 科技, 音乐},用户 B 的标签是 {科技, 音乐, 美食},Jaccard 相似度就是 2/4 = 0.5。
def jaccard_similarity(set_a, set_b):
intersection = len(set_a & set_b)
union = len(set_a | set_b)
return intersection / union
set1 = {1, 2, 3, 4}
set2 = {3, 4, 5, 6}
print(jaccard_similarity(set1, set2)) # 输出 0.333
个人经验:Jaccard 在稠密向量上表现一般。它最适合稀疏的二值特征,比如用户是否购买过某商品、文档是否包含某关键词。我曾经用它做协同过滤的相似度计算,效果比余弦好。
4.6 适用场景对比
说了这么多,到底什么时候用哪个?我整理了一张表,你直接拿去用。
| 度量方法 | 适用场景 | 对长度敏感 | 对异常值敏感 | 典型应用 |
|---|---|---|---|---|
| 余弦相似度 | 文本、语义检索 | 否 | 中 | BERT embedding、文档相似度 |
| 欧氏距离 | 图像、数值特征 | 是 | 高 | 图像检索、聚类 |
| 曼哈顿距离 | 含噪声数据 | 是 | 低 | 异常检测、用户行为 |
| 内积 | 推荐系统 | 是 | 高 | 矩阵分解、协同过滤 |
| Jaccard 相似度 | 二值特征、标签 | 否 | 低 | 标签匹配、协同过滤 |
选度量方式没有银弹。我一般会先问自己三个问题:
- 向量长度有没有业务含义?有的话用欧氏或内积,没有的话用余弦。
- 数据里有没有异常值?有的话优先曼哈顿或 Jaccard。
- 特征是稠密还是稀疏?稠密用余弦/欧氏,稀疏二值用 Jaccard。
4.7 知识体系总览
下面这张图把六种度量的核心逻辑串起来了。你可以把它当作选型时的快速参考。
4.8 实战中的选择建议
最后,我分享几条实战经验:
- 先归一化再选度量。很多情况下,把向量归一化到单位长度后,余弦相似度和内积就等价了。我习惯先做 L2 归一化,再根据业务选。
- 不要迷信单一度量。我在一个项目里同时用了余弦和欧氏,然后取加权平均,召回率提升了 12%。
- 测试!测试!测试!拿一小批标注数据,跑一遍不同度量的召回结果,用 NDCG 或 MAP 评估。数据不会骗人。
重要提醒:向量数据库(如 Milvus、Qdrant)在创建索引时就需要指定度量类型。一旦建好索引,切换度量的成本很高。所以上线前一定要想清楚,最好用真实数据做对比实验。
嗯,这一章的内容就到这儿。六种度量方法,每种都有它的脾气。选对了,事半功倍;选错了,事倍功半。希望你能从我的经验里少走些弯路。