很多人在刚接触机器学习或者做推荐系统、文本匹配的时候都会遇到两个绕不开的名字余弦相似度和欧氏距离。这俩确实是衡量东西像不像最常用的两个指标但也是被误解得最深的一对。我见过太多人拿到数据就无脑算一个结果模型效果差得离谱还搞不清问题出在哪。这篇文章就把这两个指标掰开揉碎讲清楚重点落在指标衡量差异这件事上——它俩到底差在哪什么时候该用哪个以及怎么在工程里用才不容易翻车。文章比较长涉及的场景包括向量检索、文本匹配、用户画像相似度这些常见需求。不管你是刚入门的学生还是已经在做一些数据分析、特征工程相关的开发读完应该都能对相似度度量有一套清晰的选择逻辑。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_{i1}^{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$结论依然一致。可如果把购买金额改成万元单位也就是A10000B20000C30000再看欧氏距离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算因为性能太差了。稍微大数据量的场景建议直接用numpyimport numpy as np def cosine_similarity_np(vec_a, vec_b): vec_a np.asarray(vec_a, dtypenp.float32) vec_b np.asarray(vec_b, dtypenp.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数据量上到百万级你会怀疑人生。选索引类型的时候注意看底层支持的距离/相似度类型是否包含你选的指标——有些索引只支持欧氏距离有些支持内积别把索引类型和指标搞混了。这些内容是我在实际项目里反复摔打出来的经验。相似度度量的选择看似基础却是很多上层算法效果的基石。下次再遇到这种选择不妨先停下来想一想我到底想要模型看见什么差异想清楚这个问题比硬调参有用得多。