很多人在刚接触机器学习或者做推荐系统、文本匹配的时候,都会遇到两个绕不开的名字:余弦相似度和欧氏距离。这俩确实是衡量"东西像不像"最常用的两个指标,但也是被误解得最深的一对。我见过太多人拿到数据就无脑算一个,结果模型效果差得离谱,还搞不清问题出在哪。这篇文章就把这两个指标掰开揉碎讲清楚,重点落在"指标衡量差异"这件事上——它俩到底差在哪,什么时候该用哪个,以及怎么在工程里用才不容易翻车。
文章比较长,涉及的场景包括向量检索、文本匹配、用户画像相似度这些常见需求。不管你是刚入门的学生,还是已经在做一些数据分析、特征工程相关的开发,读完应该都能对相似度度量有一套清晰的选择逻辑。
1. 两个度量的核心逻辑,先建立直觉
1.1 余弦相似度:看方向,不看距离
余弦相似度的定义其实特别简单,就是两个向量夹角的余弦值。数学公式长这样:
$$cosine_similarity(A, B) = \frac{A \cdot B}{||A|| \cdot ||B||}$$
说白了就是先把两个向量的模长归一化到单位长度,然后看它们之间的夹角。夹角越小,余弦值越接近1,表示方向越一致;夹角90度,余弦值是0,表示正交、无关;夹角越大,余弦值越接近-1,方向越相反。
打个比方。你把两个向量想象成从原点射出去的两支箭,余弦相似度只关心这两支箭是不是朝着差不多的方向射,不关心射出去的远近。一支箭射了1米,另一支射了100米,只要方向一致,余弦相似度就是1,完美相似。
这个特性在文本场景里特别有用。比如两篇文章,一篇写了5000字,一篇是500字浓缩摘要,主题都是"深度学习入门"。词频向量的模长差很多,但分布模式很接近——重点词都集中在"神经网络""梯度下降""反向传播"这些词上。这时候用欧氏距离,长度差异会直接毁掉结果,但用余弦相似度就能干净利落地抓住"主题一致性"。
1.2 欧氏距离:看绝对数值差
欧氏距离就是我们最熟悉的直线距离,n维空间中两个点之间的真实距离:
$$euclidean_distance(A, B) = \sqrt{\sum_{i=1}^{n}(A_i - B_i)^2}$$
它衡量的是两个向量在特征空间里的位置差了多少。余弦相似度忽略长度,欧氏距离则把长度差异直接算进结果里。
还是用射箭的比方。欧氏距离关心的是两支箭的箭尖之间隔了多少米。同样方向的两支箭,一支射了1米,一支射了100米,余弦相似度说它们"完全相似",欧氏距离却会告诉你它们差了99米,根本不接近。
所以欧氏距离在什么时候合理?在特征本身有绝对意义、数值差异代表真实差异的场景。比如两个用户在一个电商平台上的消费金额向量,一个月花了500元,一个月花了10000元,这个数值差异本身就代表消费能力的不同,用欧氏距离就非常自然。这时候你要是只看方向(比例),反而会把这俩"消费能力完全不同"的人当成很相似的人。
1.3 一张表说清核心差异
| 维度 | 余弦相似度 | 欧氏距离 |
|---|---|---|
| 关注点 | 方向一致性 | 数值绝对差异 |
| 取值范围 | [-1, 1],越大越相似 | [0, +∞),越小越相似 |
| 对量纲/尺度敏感度 | 不敏感(归一化后比较) | 很敏感 |
| 对向量模长差异 | 直接忽略 | 完全计入 |
| 典型场景 | 文本、稀疏向量、用户偏好 | 连续数值特征、空间坐标、物理量 |
| 数学本质 | 内积 + 长度归一化 | L2范数 |
很多人在实际项目里遇到的问题是:两种指标算出来的结果趋势经常不一致。用余弦相似度排出来的最相似用户,用欧氏距离排出来的可能是最不相似的。这不是算法错了,而是这两个指标本来就在回答不同的问题。
2. 为什么它们衡量的是"不同的差异"
2.1 标准化视角下的等价性
这里有个特别关键的数学事实:如果先把所有向量做L2归一化(也就是除以模长,变成单位向量),然后再计算,余弦相似度和欧氏距离就变成严格单调相关的了。
推导过程很简短,但能让理解上一个台阶。
对单位向量 $A'$ 和 $B'$,有 $||A'|| = ||B'|| = 1$。那么:
$$||A' - B'||^2 = ||A'||^2 + ||B'||^2 - 2 \cdot A' \cdot B' = 2 - 2 \cdot cosine(A', B')$$
也就是说,欧氏距离的平方等于2减去两倍的余弦相似度。向量A和B越相似(余弦值越大),归一化之后的欧氏距离就越小。
这就是为什么在向量检索领域(像Facebook Faiss、Milvus这类向量数据库),很多场景下你选哪个指标都差不多——因为大家基本都会在入库前做归一化。但关键点是:没做归一化之前,这两个指标根本不是一回事。千万别默认它们等价,这是实战中最大的坑之一。
2.2 量纲敏感性差异的直观案例
举个例子,假设有3个用户在某平台上的两个行为特征:"观看时长(小时)"和"购买金额(元)"。
- 用户A:(10, 100)
- 用户B:(20, 200)
- 用户C:(10, 300)
先看余弦相似度。A和B的特征比例都是1:10,方向完全一致,余弦相似度为1。A和C一个1:10,一个1:30,方向差距大,余弦相似度只有大概0.83左右。所以在余弦视角下,A和B是最相似的。
再看欧氏距离。A和B的距离是 $\sqrt{(10)^2 + (100)^2} \approx 100.5$,A和C的距离是 $\sqrt{(0)^2 + (200)^2} = 200$。欧氏距离的结论同样认为A和B更近,但这纯粹是因为观看时长这个维度数值小、对距离贡献小,而购买金额差异主导了结果。
再改一下案例,把观看时长换成0到1之间的归一化值,把购买金额保持原样。
- 用户A:(0.1, 100)
- 用户B:(0.2, 200)
- 用户C:(0.1, 300)
余弦相似度几乎不变,还是认为A和B最像。但欧氏距离下,A和B的距离是 $\sqrt{0.1^2 + 100^2} \approx 100.0$,A和C的距离是 $\sqrt{0 + 200^2} = 200$,结论依然一致。可如果把购买金额改成万元单位,也就是A:10000,B:20000,C:30000,再看欧氏距离:
- A和B:$\sqrt{0.1^2 + 10000^2} \approx 10000$
- A和C:$\sqrt{0 + 20000^2} = 20000$
结论还是一样。但你换个特征试试,比如把其中一个特征改成"登录天数",数值范围一下变成0~365,特征之间的量纲差距会直接扭曲欧氏距离的结果。事实上,只要两个特征的数值范围差几个量级,欧氏距离基本就等于被大数值的特征主导了,小数值特征全部失效。
2.3 从几何视角理解"度量差异"
在三维空间里,可以更直观地感受这两个度的差异。想象三维空间中的一个点P,欧氏距离衡量的是P到原点O的连线长度,是一个标量;而余弦相似度描述的是OP这条射线相对于坐标轴的角度偏转。同一个点上,这两个值可以一个很大、一个为0,完全描述不同性质。
从信息的视角来说,欧氏距离更"挑剔"——它要求每个维度都足够接近;余弦相似度更"宽容"——它只要求整体模式一致,允许整体缩放。所以在做相似度计算之前,先问自己一个问题:你关心的是"我们俩的数值是不是都差不多",还是"我们俩的趋势/模式是不是一样"?答案直接决定选哪个指标。
3. 实际操作:怎么选型,怎么组合
3.1 文本和稀疏向量场景,默认选余弦
NLP和搜索场景里,文本经过分词、TF-IDF或者词袋编码之后,得到的向量几乎都是稀疏向量,而且文档长度的差异会直接影响词频数值。同一篇长文和它的摘要,欧氏距离会巨大,但余弦相似度能稳定表达语义接近程度。
我自己做文本聚类的时候基本无脑用余弦。比如用TF-IDF把新闻文档转成向量,然后计算两两相似度做聚合,余弦的结果非常稳定。换欧氏距离试过一次,长文档之间因为词频绝对值大,距离全部偏大,短文档之间距离反而偏小,聚类结果基本不可用——因为欧氏距离把"文档长度"这个无关信息当成了主要差异来源。
这里有个补充细节:很多文本向量是稀疏的,里面大量维度是0。这时候用余弦相似度还有额外的好处——0值向量或者大量0值维度对余弦的扰动小。不过在scikit-learn里,cosine_similarity对全零向量的处理是返回NaN,实务里要提前过滤。
3.2 数值型特征和空间数据,优先考虑欧氏距离
如果特征是连续的、每个维度都有明确物理意义的数值——比如温度、价格、GPS坐标、传感器读数——欧氏距离通常更合适。因为这些场景里,数值的绝对大小就是信息本身,不能因为"方向一致"就忽略掉"一个在赤道一个在北极"这个事实。
举例,用K-Means做用户分群,特征有年龄、年收入、月消费额。如果只做标准化不归一化,聚类时欧氏距离就是默认选择,因为它能区分收入100万和10万的人,哪怕这两类人的消费模式比例完全一致。
但要注意,欧氏距离在多元量纲混合的场景有个隐患——特征的数值范围差异会天然产生权重偏差。所以在用欧氏距离之前,几乎必须做标准化(Z-Score或者Min-Max),不然数值大的特征会压制数值小的特征。
3.3 两者的组合使用思路
实际项目里,我越来越多地发现"单选一个指标"是不够的。一个很实用的思路是:先归一化,再同时计算两个指标,然后把它们组合成新的相似度分数。
比如一个推荐系统里,用户A和用户B在余弦相似度上得分0.95(消费结构一致),但欧氏距离非常大(消费总量差距大)。这时候你会怎么判断?如果业务上希望"消费主力人群向消费潜力人群渗透",你可能需要的是消费结构相似但总量有差距的组合,那余弦高分+欧氏距离大反而是好消息。
如果业务希望"找到同层级用户互相推荐",那就要求两个指标都要小。具体做法可以这样:把欧氏距离取倒数或者做高斯核变换变成相似度(比如 $sim = \frac{1}{1 + distance}$),然后跟余弦相似度做加权和。
权重怎么定?没有通用答案,就看业务里哪个更重要。我之前做过一个画像系统,最终公式用的是:
$$score = 0.6 \cdot cosine_similarity + 0.4 \cdot (1 - normalized_euclidean_distance)$$
这个小技巧帮我解决了一个长期困扰的问题:只看余弦会误召回很多"消费结构像但消费能力天差地别"的用户,只看欧氏又过于严格,召回太少。组合之后,线上指标明显改善。
4. 代码实现:从零手写和工程库对比
4.1 用纯Python实现两个指标
先来个最朴素的实现,帮大家把公式落地:
import math def cosine_similarity(vec_a, vec_b): # 向量长度必须一致,否则后续计算会出错 assert len(vec_a) == len(vec_b) # 处理空向量和零向量的边界情况 norm_a = math.sqrt(sum(x * x for x in vec_a)) norm_b = math.sqrt(sum(x * x for x in vec_b)) if norm_a == 0 or norm_b == 0: return float('nan') dot = sum(x * y for x, y in zip(vec_a, vec_b)) return dot / (norm_a * norm_b) def euclidean_distance(vec_a, vec_b): assert len(vec_a) == len(vec_b) return math.sqrt(sum((x - y) ** 2 for x, y in zip(vec_a, vec_b)))这个版本的代码适合理解原理,但工程上不推荐用纯Python算,因为性能太差了。稍微大数据量的场景,建议直接用numpy:
import numpy as np def cosine_similarity_np(vec_a, vec_b): vec_a = np.asarray(vec_a, dtype=np.float32) vec_b = np.asarray(vec_b, dtype=np.float32) norm_a = np.linalg.norm(vec_a) norm_b = np.linalg.norm(vec_b) if norm_a == 0 or norm_b == 0: return np.nan return np.dot(vec_a, vec_b) / (norm_a * norm_b) def euclidean_distance_np(vec_a, vec_b): return np.linalg.norm(np.asarray(vec_a) - np.asarray(vec_b))numpy向量化的计算速度比纯Python快几十倍不止,这也算是工程里的基本操作了。
4.2 scikit-learn里的标准做法
实际项目中,我一般直接用scikit-learn的pairwise_distances或者cosine_similarity,因为接口统一还自带很多优化。举几个常用写法:
from sklearn.metrics.pairwise import cosine_similarity from sklearn.metrics.pairwise import euclidean_distances matrix = [ [1, 0, 0, 0], [1, 1, 0, 0], [0, 0, 1, 1] ] # 返回3x3的相似度矩阵 cos_matrix = cosine_similarity(matrix) # 返回3x3的距离矩阵 euc_matrix = euclidean_distances(matrix)这里有个非常容易犯的错误:cosine_similarity计算的是相似度,越大越好;euclidean_distances计算的是距离,越小越好。两个指标方向相反,在定义业务指标或者设置阈值的时候一定要搞清楚方向,不然排序逻辑全反了。
4.3 归一化的坑:一定要在划分数据之前做
这是我在项目里踩过最深的坑之一。做特征工程时,量纲不统一,我顺手对整个数据集做了Min-Max归一化。当时数据集包含训练集和测试集,我直接一把梭,对全量数据做了归一化。后来模型上线,测试集效果看上去还行,但实际部署时发现新数据的分布和之前归一化用的范围完全对不上。
正确做法是:先用训练集拟合归一化参数(Min-Max的min和max,或者Z-Score的mean和std),然后用同一套参数去transform测试集和新数据。这个道理在相似度计算上也成立——如果你需要用欧氏距离,标准化参数只能从参考集里学,不能从全量数据里学,否则会有信息泄露。
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() # 只对训练数据fit scaler.fit(train_features) train_features_scaled = scaler.transform(train_features) # 用同一套参数transform测试数据 test_features_scaled = scaler.transform(test_features)这个细节很多人不注意,但直接决定了泛化能力。
5. 实战中常见问题与排查思路
5.1 为什么结果"反直觉"
不少人遇到过这种问题:用余弦相似度找到了"最相似"的样本,人工一看,完全是两个不同的东西。反过来用欧氏距离找到的最邻近样本,又觉得好像也没那么接近。这种"反直觉"基本都出在特征选择和预处理上。
一个典型的反例:做图像相似度检索时,直接把原始像素值拉平成一维向量算余弦。两张图,一张是白天拍的猫,一张是夜晚拍的猫,像素值整体偏移很大,余弦相似度反而可能不高;而两张完全不同的图,因为像素均值相近,欧氏距离反而可能很小。纯像素级相似度就是这样的,"语义相似"和"数值相似"完全是两层皮。
对策是:用特征向量替代原始向量。比如图片用卷积网络提取到的embedding,文本用句向量,用户用物品向量的加权和。特征好,相似度指标才有意义。
5.2 维度灾难带来的距离趋同
当特征维度特别高(比如上万维),欧氏距离会出现一个奇怪的现象:所有样本两两之间的距离都差不多,区分度急剧下降。这就是所谓"维度灾难"的典型表现。在这种高维场景下,欧氏距离几乎失灵,余弦相似度虽然也会受影响,但相对更能保留区分度。
我的经验是,特征维度超过几百维之后,优先考虑余弦相似度,或者先用PCA等降维手段把维度压下来再算欧氏距离。尤其是做KNN或者聚类的时候,高维欧氏距离几乎等于随机排序,根本不可用。
5.3 向量中零值和缺失值的处理
现实数据里,大量特征是稀疏的,有大量0值。这在余弦相似度里问题不大,因为0值只会让内积变小,不影响方向判断。但在欧氏距离里,0值的意义是"这个特征缺失"还是"这个特征真实为0"?如果是缺失,直接参与距离计算会产生严重偏差。
假设两个用户,一个没看过任何恐怖片,一个看了3部恐怖片,特征"恐怖片观影数"分别是0和3。这个0是真实的0,参与计算没毛病。但如果用户A没登录过,那个特征是缺失的,你把缺失填成0再算距离,A就成了"没看过恐怖片的人",这和真实情况完全不同。
处理方式:
- 如果是数值型缺失,可以考虑填充为均值/中位数后再算欧氏距离;
- 如果是逻辑上的"不存在",可以考虑把该维度做成0/1布尔特征;
- 如果缺失比例太高,考虑直接删掉该维特征再计算。
5.4 什么时候该切换指标
工程里判断指标是否合适,不能只看离线指标,要回到业务视角。我整理过一个自我检查清单:
- 业务上"相似"的定义更接近"模式一致"还是"数值接近"?模式一致选余弦,数值接近选欧氏。
- 特征是否已经做过标准化?没做标准化就别用欧氏距离,做了再考虑。
- 是否涉及到稀疏高维特征?高维稀疏优先余弦。
- 是否容忍整体缩放?容忍缩放选余弦,不容忍选欧氏。
- 有没有长度差异带来的干扰?文本长度、活跃时长这类天然有量级差异的特征,余弦更稳。
如果按这个清单走完还是拿不准,我的建议是:两个都跑一遍,看它们的差异主要出现在哪些样本上,再针对差异样本人工抽检,看看哪个结果更符合业务直觉。这个方法比我见过任何理论分析都实用,毕竟数据不会说谎。
6. 最后分享一个实用技巧
不管用哪个指标,上线前最好做一次"相似度分布体检"。具体做法很简单:把你所有的样本两两算一遍相似度/距离,画出分布直方图,观察是否集中在一个很窄的区间。
我见过很多项目,阈值设得跟闹着玩似的——比如余弦相似度阈值设0.9,结果全量数据里90%的pair都在0.8~1.0这个区间,这个阈值等于没有。做一次分布体检,你会知道该把阈值设在哪个分位数上,比拍脑袋靠谱得多。
另外,工程实现上我强烈建议用专门的向量检索库来做大规模相似度计算,比如Faiss或者Milvus。自己写双层for循环算pairwise,数据量上到百万级你会怀疑人生。选索引类型的时候,注意看底层支持的距离/相似度类型是否包含你选的指标——有些索引只支持欧氏距离,有些支持内积,别把索引类型和指标搞混了。
这些内容是我在实际项目里反复摔打出来的经验。相似度度量的选择看似基础,却是很多上层算法效果的基石。下次再遇到这种选择,不妨先停下来想一想:我到底想要模型"看见"什么差异?想清楚这个问题比硬调参有用得多。