做相似度计算这些年,我踩过最大的坑就是拿错指标。有次做文本聚类,老板让我比对两个商品描述是不是同一个型号,我顺手用欧氏距离算了一遍,结果跑来跑去全是乱的——明明长度差不多的两个字符串,余弦相似度高达0.95,欧氏距离却差出一大截,聚类结果直接崩了。后来把管线里所有距离计算全部换成基于归一化向量的余弦相似度,问题才解决。今天我想把这两个指标掰开揉碎讲清楚,尤其是它们呈现出的差异到底在衡量什么、为什么在真实业务里经常对不上,以及你该怎么选。
这篇文章适合正在做推荐系统、文本匹配、向量检索、聚类分析或者任何跟“相似度”打交道的新手朋友,也适合被“明明PyTorch里都能算,但结果就是不对”折磨过的工程师。我会从原理讲起,给出手写代码和真实场景的选型思路,最后附上排查清单。
1. 两个指标的定义与直观理解
1.1 余弦相似度:方向比大小更重要
余弦相似度衡量的是两个向量在空间中的夹角有多小。公式是:
cosine_similarity = (A · B) / (||A|| × ||B||)分子是两个向量的点积,分母是各自模长的乘积。结果范围从-1到1,1表示方向完全相同,-1表示方向完全相反,0表示正交(无关)。
生活里类比一下:你在商场问路,别人告诉你“往北走50米再往东走30米”,另一个人告诉你“往东北方向走大约58米”。两个描述本质上指同一个目的地,但如果有人非要用欧氏距离去比较这两句话的差异,就会觉得“50+30怎么跟58差这么多”。余弦相似度更像是直接对比两条路径的方向是否一致,而欧氏距离是实打实地看终点坐标差了多远。
在文本场景里这一点尤其关键。词频向量中,“5米”和“6米”的差距,与“500万”和“600万”的差距,在余弦相似度眼中可能差别不大,因为方向一致、只是长度不同;但在欧氏距离眼中,后者差了100万,完全不可接受。这两种“差异”对业务来说含义完全不同。
1.2 欧氏距离:实实在在的空间直线距离
欧氏距离就是我们初中就学过的两点间距离公式,推广到n维就是:
euclidean_distance = sqrt(sum((A_i - B_i)²))它衡量的是两个向量在特征空间中绝对位置的差。范围是0到正无穷,越小越相似。
继续用指路的类比:欧氏距离是“你现在站的地方和目标点之间隔了多少公里”。哪怕两条路线建议的方向一模一样,只要起点与终点坐标不同,距离就会有差异。
在推荐系统里,用户A看过5部科幻片、2部爱情片,用户B看过50部科幻片、20部爱情片,如果把观看次数直接作为向量,欧氏距离会认为这两个用户差异巨大;但余弦相似度会认为他们的口味方向高度一致——都是八成科幻、两成爱情。这里两种指标给出的“差异”结论完全不同,取决于你到底想衡量什么:是“量级”还是“比例结构”。
1.3 归一化:让两个指标在特定条件下等价
把欧氏距离和余弦相似度联系起来的桥梁是向量的L2归一化。当所有向量都被归一化成单位长度后,欧氏距离的平方正好等于2减去两倍余弦相似度:
||A - B||² = ||A||² + ||B||² - 2AB = 2 - 2·cosine(A, B)也就是说,在归一化后的单位球面上,这两个指标排序等价,选谁都一样。但没做归一化之前,它们衡量的是完全不同的东西。
这一点很多人容易忽略。我见过不少分析报告拿余弦相似度和欧氏距离的结果对比,说“结果差很多”,实际上根本没检查向量是否做了归一化。如果你已经统一做过L2归一化,两个指标不会给你相反的结论;如果你没做,那结论有分歧就是必然的。
2. 两者产生分歧的核心原因
2.1 量纲差异:绝对数值主导了欧氏距离
欧氏距离对数值大小非常敏感。一个向量是[1, 2, 3],另一个是[100, 200, 300],这两个向量的欧氏距离大约是349.7,大得惊人;但它们的余弦相似度是1.0,因为方向完全一致。
这不是巧合,是数学上的必然。当第二个向量是第一个向量的k倍时,欧氏距离会随k增长,余弦相似度却保持不变。很多非结构化数据,比如文本词频、用户行为次数、传感器读数,天然带有“尺度”属性,直接用欧氏距离就会被大数值维度牵着鼻子走。
我在处理用户行为序列时遇到过类似情况。用户A对某个品类点击了3次,用户B点击了300次,如果只看欧氏距离,系统会认为这两个人兴趣差异极大;但实际上两者的点击分布结构几乎一样,都是高度集中在该品类上。对电商场景来说,你要推荐的是“他可能喜欢的品类”,而不是“跟他点击次数绝对值接近的人”,所以用余弦更合理。
2.2 稀疏向量与零值处理:方向更稳健
文本TF-IDF向量、One-Hot编码后的分类特征、多热编码的兴趣标签,天然都是高维稀疏向量,大量维度是0。在这种情况下,欧氏距离的计算会受很多“无关维度”干扰。
举个例子:两份文档的向量都是10000维,各自只有20个非零维度,而且它们有18个维度完全重合,只有2个维度不一样。余弦相似度会给出接近0.9的高分,因为重叠方向很强;但欧氏距离会因为那两个不同维度的差值产生几十甚至上百的距离——具体取决于非零值大小,从而认为“差异很大”。
稀疏场景下,欧氏距离把“交叉项缺失”也当成了一种差异。这有时候是优势,比如做异常检测,想看某个用户的行为是否偏离了大多数人的取值区间,欧氏距离能捕捉到“明明大家都在这个维度有值,你却没有”的情况。但做相似对象匹配时,这种敏感反而帮了倒忙。
2.3 实际案例:不同维度权重下的反转
我用一个三元向量举个例子。A是[4, 0, 1],B是[4, 0, 1]的1.5倍即[6, 0, 1.5],C是[1, 0, 6]。
在余弦相似度下:
- A和B:约1.0(方向相同)
- A和C:约0.26(方向差异很大)
在欧氏距离下:
- A和B:约为2.5(因为模长变了)
- A和C:约为5.1(因为位置差得远)
结论完全不一样。余弦会说“A跟B几乎一样,跟C差很远”,欧氏会说“A跟B还算接近,但跟C差得更远,不过没到不可接受的地步”。
如果你在做K-Means聚类,这两种结论会直接把样本分到完全不同的簇里。我遇到过类似的一次项目,某金融产品用户分群,用欧氏距离分出来的群体按“消费金额绝对值”分层,用余弦分出来的群体按“消费结构类型”分类,最后业务采纳了后者,因为营销策略更关注结构而非总额。
3. 实例代码与计算要点
3.1 纯Python手写两个指标
理解原理最直接的方式是自己写一遍。这里我不调库,只用原生Python实现:
import math def dot_product(a, b): return sum(x * y for x, y in zip(a, b)) def l2_norm(vec): return math.sqrt(sum(x * x for x in vec)) def cosine_similarity(a, b): dot = dot_product(a, b) norm_a = l2_norm(a) norm_b = l2_norm(b) if norm_a == 0 or norm_b == 0: return 0.0 # 零向量处理为无相似度 return dot / (norm_a * norm_b) def euclidean_distance(a, b): return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b))) a = [4, 0, 1] b = [6, 0, 1.5] c = [1, 0, 6] print("cos(a,b):", round(cosine_similarity(a, b), 4)) # ≈1.0 print("cos(a,c):", round(cosine_similarity(a, c), 4)) # ≈0.2604 print("euc(a,b):", round(euclidean_distance(a, b), 4)) # ≈2.5 print("euc(a,c):", round(euclidean_distance(a, c), 4)) # ≈5.0990这里最需要留意的是零向量的边界情况。真实数据里,空行为序列、空文本、全零特征是完全可能出现的,如果不处理,零除会直接报错,更隐蔽的是它会污染后续的排序逻辑。所以我在工程代码里一般规定:零向量与任何向量的相似度记为0,距离则为当前特征空间的最大值。
3.2 用NumPy批量计算
实际业务中很少单条计算,更多是一次性算一批向量两两之间的距离。用NumPy可以写成向量化操作:
import numpy as np # X: (n_samples, n_features) X = np.array([ [4, 0, 1], [6, 0, 1.5], [1, 0, 6], ]) def cosine_sim_matrix(X): norm = np.linalg.norm(X, axis=1, keepdims=True) norm[norm == 0] = 1.0 # 防除零 X_norm = X / norm return X_norm @ X_norm.T def euclidean_dist_matrix(X): # 技巧: ||a-b||² = ||a||² + ||b||² - 2ab sq = np.sum(X ** 2, axis=1, keepdims=True) dist_sq = sq + sq.T - 2 * (X @ X.T) np.fill_diagonal(dist_sq, 0) # 修正浮点误差 return np.sqrt(np.maximum(dist_sq, 0)) print(cosine_sim_matrix(X)) print(euclidean_dist_matrix(X))注意最后一步我用了np.maximum(dist_sq, 0),因为浮点误差偶尔会让理论上是0的值变成负数,直接开方会出现NaN。这是实战中特别容易踩的暗坑。
3.3 样本维度与数据标准化的影响
很多人会在这里混淆“归一化”和“标准化”。L2归一化是把向量的模长变成1,改变的是尺度;标准化(Z-score)是减去均值除以标准差,改变的是分布位置。两者对余弦和欧氏的影响不同:
- 对向量做L2归一化后,余弦相似度不变,但欧氏距离与余弦排序等价。
- 对特征做Z-score标准化后,欧氏距离会变化,但余弦相似度也可能变化,因为每个维度的权重被重新分配了。
我在做图像特征向量检索时,习惯先做L2归一化再建索引,这样既方便用余弦,也方便切欧氏距离做快速检索,后面换距离度量不用重建索引。这种做法在工程上很常见,也省了很多对比时的麻烦。
4. 业务场景里的选型决策
4.1 文本相似度与搜索排序选余弦
文本场景几乎无脑选余弦。原因是词频或者TF-IDF向量天然稀疏,并且文本语义更多取决于词的组合与分布结构,而不是具体词频绝对值。两篇长文档和两个短句子的词频绝对值不可比,但方向可以比。
我在做企业知识库的相似问题匹配时做过一次评测:用余弦相似度排序,用户点选率比用欧氏距离高出23%左右。原因很好理解——用户问“怎么开发票报销”和“发票报销流程是什么”,词频向量方向接近,余弦给的分高,而欧氏距离因为两句长度差异导致整体距离偏大,把本来应该排前面的结果挤到了后面。
搜索排序如果要混合多个信号,余弦作为语义相似度信号,通常还要配合BM25或字面重合率,单纯靠余弦解决不了所有问题。但作为主相似度度量,选余弦基本没错。
4.2 推荐系统里的协同过滤选余弦
协同过滤的本质是找相似用户或相似物品。用户对物品的评分矩阵、隐式反馈矩阵都是稀疏高维的,而且不同用户的打分习惯差异很大——有人喜欢全部打低分,有人喜欢全部打高分,这跟“喜欢程度”无关,只跟个人习惯有关。
余弦相似度能自动去除这种评分尺度差异。但如果用户A平均打2分,用户B平均打4分,余弦会把他们都归一化了,可能误以为A对某物品评2分、B对某物品评4分是同样程度的喜欢。这时更好的做法是先用皮尔逊相关系数——本质上就是中心化后的余弦相似度,或者直接用“调整余弦相似度”。
实际项目里,我通常先对评分矩阵按用户做了均值中心化,再算余弦,效果比直接算好不少。这一点文档里经常不提,但做推荐的朋友应该都深有体会。
4.3 向量数据库里的混合度量策略
现在做向量检索基本绕不开FAISS、Milvus这些工具,它们原生支持多种距离度量。但很多人不知道该选哪个。我的经验是:
- 纯embedding向量的相似度检索,默认选余弦(或者对向量预先L2归一化后用内积)。
- 如果你的向量来自矩阵分解,而且后续还要做K-Means聚类,建议使用欧氏距离。
- 如果同时有数值特征和语义特征,先分别标准化再拼接,这种情况下通常用欧氏距离更稳妥。
有一次我做多模态检索,把图片特征和文本特征拼接到同一向量空间,直接上余弦相似度,结果效果很飘。后来我把两个子特征分别做标准化再拼接,改用欧氏距离,检索精度稳定了不少。原因在于向量各维度没有统一量纲时,余弦会强行只看方向,反而忽略了部分维度的绝对差异信息。
4.4 阈值应该怎么定
指标选定之后,阈值设置也是门学问。余弦相似度0.9以上通常表示强相关,0.5可能就是弱相关了,但这完全取决于向量分布。我见过一个项目里,所有向量都高度集中在某个区域内,0.99的余弦才勉强算“相似”;另一个项目分布很散,0.5的余弦已经非常有用了。
所以务必要对实际数据做分位数统计。我一般的做法是:抽几千条真实样本,算出两两之间的相似度分布,按P50、P90、P99分位来选阈值。不要自己拍脑袋定0.8还是0.9,数据会告诉你答案。
欧氏距离的阈值受向量维度影响更大。维度越高,距离越分散,固定阈值越不可靠。高维场景下,考虑用相对距离排序而不是绝对阈值。
5. 工程实战中的坑与排查记录
5.1 零向量、缺失值和NaN
这是最经典的坑。余弦相似度算出来为NaN,十有八九是输入向量里有零向量或NaN。排查时可以先打印向量的模长,看看有没有0;再打印是否含有NaN。缺失值的处理要前置,不要在计算时去填。
零向量在语义上代表“没有任何信息”,处理成相似度为0通常是安全的,但欧氏距离需要设为该场景的最大值,否则排序时会排到最前面造成“最相似”的假象。我见过线上推荐系统因为这个原因把新用户(行为向量全0)推荐成了所有老用户的最相似用户,尴尬得很。
5.2 高维数据下的反直觉现象
维度越高,所有向量之间的余弦相似度越趋近于0,欧氏距离也越接近一个常数。这是“维度灾难”的典型表现。如果你发现所有相似度都挤在0.6到0.7之间,分不开,不是数据有问题,而是维度过高需要降维。
我处理过一批512维的文本embedding,直接算余弦相似度,正样本平均0.72,负样本平均0.68,区分度非常差。后来用PCA降到128维,正样本平均0.8,负样本平均0.5,区分度立刻出来了。降维虽然丢了信息,但对相似度判别反而有益。
5.3 批量计算的内存爆炸
两两相似度矩阵是O(n²)的空间复杂度。1万条样本就要1亿个浮点数,大约是800MB;10万条样本直接爆内存。工程上要做分块计算,或者用FAISS这类支持GPU并行计算和磁盘索引的工具。
我建议任何超过5万条向量的项目,都不要再自己硬写循环算全量相似度矩阵。用FAISS的IndexFlatIP配合L2归一化向量,代替余弦相似度的暴力计算,速度能提升几个数量级,而且内存可控。
5.4 语义相似度不等于字符串相似度
最后提醒一个容易混淆的点。余弦相似度算的是向量空间的相似度,跟字符串层面的编辑距离是两回事。embedding向量算出来相似度高的两个句子,可能字面完全不同;字面很像的两个句子,embedding可能差很远。这在代码注释、日志聚类、搜索词改写等场景里要格外留意,选错指标等于选错方向。
6. 动手实验建议
如果你想快速建立直觉,推荐一个小实验:找一个语料库,比如新闻标题或商品名称,生成TF-IDF向量,分别用余弦相似度和欧氏距离做K-Means聚类,然后对比每个簇的内容。你大概率会发现,余弦分出来的簇在“主题”上更一致,欧氏分出来的簇在“长度”或“词频规模”上更一致。
再进一步,把向量做L2归一化之后重新聚类,你还会发现结果跟直接用余弦几乎一样。这个实验做完,你对“指标衡量差异”这句话的理解会有质的提升。
我个人倾向上,除非业务明确需要“绝对数值差异”,否则相似度计算优先选余弦。做数据分析报告时,也建议把两个指标都算出来对比,它能帮你看出数据里到底是“方向结构”在起主导,还是“绝对量级”在起主导。理解了这一点,比背任何公式都更管用。