做推荐系统的同学,几乎每天都在跟“相似度”这三个字打交道。召回、粗排、去重、多样性控制,甚至冷启动里的相似品映射,背后全是同一个问题:两个用户或者两个物品,到底“像不像”?像多少?这个问题的答案直接决定你给谁推了什么、候选池里哪些东西被拎出来排序。很多刚入行的朋友看了不少原理文章,知道余弦相似度和皮尔逊相关系数的公式,真到工程里选型的时候却又卡住了——算出来分值总是怪怪的,或者效果跟预期对不上。这篇文章就用实际推荐项目里的场景,把常用相似度计算方法逐个拆开讲清楚,包括公式背后的直觉、代码实现、适用边界,以及我踩过的一些坑。
这篇内容适合正在做推荐、搜索或者做用户画像的算法工程师和数据分析师,也适合刚接触推荐系统、想理解相似度计算底层逻辑的学习者。我尽量不用教科书式的推导堆砌,而是从“什么时候该用哪个、算出来怎么解释、数据不干净怎么处理”这些实际角度来写。看完以后,你可以直接拿文章里的实现思路去处理自己的特征向量、Embedding和用户行为序列。
1. 相似度计算在推荐系统里的位置与选型思路
1.1 为什么所有推荐流程都绕不开相似度
先想一个问题:一个日活百万的产品,物品库几十万甚至上百万,用户来了之后你怎么从这么多东西里挑出他可能感兴趣的几十个?不可能实时把全量物品遍历一遍做排序,成本扛不住。所以工业级推荐系统的通用套路是“召回 + 排序”:先用轻量级方法从全量物品里取回几百上千个候选,再用复杂模型精排。这两个环节里,相似度计算都是地基。
召回阶段最常见的就是协同过滤和向量检索。协同过滤的Item-Based做法,就是预先计算“物品与物品”的相似度矩阵,用户看了某部电影,就把它最像的几个物品拉回来。向量检索则是把用户和物品都映射成Embedding向量,然后用近邻搜索找最相似的TopK。这两个场景,核心都是相似度。排序阶段也没跑掉,粗排模型里经常要拼上“用户最近交互过的物品和当前候选物品的相似度”作为一维特征,甚至做多样性打散时也要用相似度衡量两个物品会不会挤占同一个兴趣点。
可以说,相似度计算的水准决定了推荐结果的“直觉合理性”。算得太糙,明明是同类型的东西分值很高,结果里混入一堆八竿子打不着的玩意;算得太精细,线上性能又扛不住。理解每种相似度的数学含义和工程代价,是选对方案的前提。
1.2 相似度家族分类与适用场景速览
相似度计算方法看着很多,但归一下类就清楚了。从底层逻辑看,主要分四大家族:
- 几何距离类:欧氏距离、曼哈顿距离、汉明距离。核心思想是“向量在空间里离得近就相似”,适合连续稠密特征,或者本身就是坐标性质的向量。
- 夹角类:余弦相似度。只看方向不看幅度,适合文本向量、用户兴趣向量这种“长度没意义、方向才有意义”的数据。
- 相关类:皮尔逊相关系数。把向量先中心化再算夹角,能消除用户打分尺度偏差,适合评分型数据。
- 集合类:Jaccard相似度、Overlap系数。把特征当成集合,看交集比例,适合离散ID、标签、布尔型行为。
先把这个分类记住,后面遇到具体问题就能快速圈定方向。我见过不少人一上来就默认用余弦,结果处理二值化行为序列时效果稀烂,换成Jaccard立刻好很多。选型不是越复杂越好,而是越贴合数据性质越好。
下表是我在实际项目里的经验对照,后面每个方法都会详细展开:
| 方法 | 数据适配类型 | 对数值幅度敏感度 | 典型场景 |
|---|---|---|---|
| 欧氏距离 | 稠密连续向量 | 高 | 图像特征、坐标类特征、降维后的向量 |
| 余弦相似度 | 稠密/稀疏向量 | 低 | 文本向量、用户兴趣向量、Embedding召回 |
| 皮尔逊相关系数 | 评分型稠密向量 | 中(消除后不敏感) | 用户评分、打分偏好,协同过滤 |
| Jaccard相似度 | 离散集合/布尔向量 | 不适用 | 标签匹配、行为交集、冷启动映射 |
| 曼哈顿距离 | 稠密连续向量 | 高 | 鲁棒性要求高的距离场景 |
| 汉明距离 | 二值向量 | 不适用 | 签名向量、近重复检测 |
2. 五种常用相似度的原理拆解与计算实操
2.1 余弦相似度:向量夹角的直觉
余弦相似度应该是推荐系统里出场率最高的方法,没有之一。它的公式是:
cos(A, B) = (A·B) / (|A| × |B|)
也就是两个向量的点积除以两个向量模长的乘积。几何意义是计算两个向量在空间里的夹角余弦值,夹角越小越相似。关键特点是它只关心方向,不关心向量的绝对长度。
为什么推荐场景经常用它?因为用户行为数据的“幅度”经常不可靠。举个例子,A用户特别喜欢点东西,一天点了50个赞,B用户很克制,一周才点5个赞。如果用欧氏距离,这两个用户即使兴趣完全相同,算出来的距离也会很远,因为向量长度差太多。但余弦相似度只看每个维度上的比例结构,点50个赞和点5个赞的人如果喜欢的品类比例一样,算出来的相似度会很高。
实际计算的时候,很多人直接用现成的库函数,但工程里有个常用优化:先把向量做L2归一化,也就是把每个向量除以它的模长,让所有向量长度变成1。归一化后,余弦相似度就变成两个归一化向量的点积。从计算上看少了一次除法,从性能上看,归一化向量只存一份,后面每次计算都省去模长计算。更重要的是,归一化后的向量可以直接对接向量检索库里的内积运算,很多近邻搜索库对内积做过深度优化,速度比余弦快不少。
下面是Python实现,不依赖复杂库,用NumPy就够:
import numpy as np def cosine_similarity(a, b): dot = np.dot(a, b) norm_a = np.linalg.norm(a) norm_b = np.linalg.norm(b) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b) # 归一化后的加速版 def l2_normalize(vec): norm = np.linalg.norm(vec) if norm == 0: return vec return vec / norm def cosine_similarity_normalized(a, b): a_norm = l2_normalize(a) b_norm = l2_normalize(b) return np.dot(a_norm, b_norm)注意归一化版本里有个隐藏问题:如果原始向量是稀疏的,L2归一化后还是稀疏的,但点积计算时如果直接用稠密数组跑,内存会爆掉。实际工程里要么用稀疏矩阵存储,要么用专门的向量检索库(下文第3部分会讲)。
我举一个具体例子来感受数值。假设物品A的类目特征向量是[1, 2, 3, 4, 5],物品B是[2, 3, 4, 5, 6],算出来余弦相似度约等于0.994,几乎是强相似,因为两个向量的方向高度一致。但如果物品C是[5, 4, 3, 2, 1],和A的余弦相似度算出来大概0.64,方向差异开始体现了。这个数值范围也方便设定阈值:一般0.9以上认为强相关,0.7到0.9中等相关,0.5以下基本不相关。但阈值这个东西要看业务,文本向量的相似度分布偏紧,评分向量的分布偏松,不要一套阈值走天下。
2.2 欧氏距离:绝对差值的度量
欧氏距离就是我们在中学学的两点间距离公式的推广:
d(A, B) = sqrt(Σ(ai - bi)^2)
它计算的是两个向量在空间里的直线距离。跟余弦不同,欧氏距离对向量的每一个维度的数值大小都很敏感。哪怕两个向量方向完全一致,但一个长度是另一个的10倍,欧氏距离也会很大。
所以在推荐系统里,欧氏距离最适合那些“幅度本身就有意义”的数据。比如用户画像里的年龄、消费金额、活跃天数这些统计型特征,数值大本身就代表更强的消费力或者更深的活跃度,这时候用欧氏距离是合理的。再比如图像向量,模型输出的特征向量每一维数值经过充分训练,幅度分布相对稳定,用欧氏距离做相似度也很常见。
工程实现同样简单:
def euclidean_distance(a, b): return np.sqrt(np.sum((a - b) ** 2)) # 更高效一点 def euclidean_distance_fast(a, b): diff = a - b return np.linalg.norm(diff)欧氏距离和余弦相似度之间其实有数学上的关联。对L2归一化后的向量,欧氏距离和余弦相似度存在单调对应关系:两个归一化向量夹角越小,欧氏距离就越小。原因很直观,归一化之后所有向量都落在单位球面上,夹角大小直接体现为球面上的弧长,也就对应弦长,弦越短离得越近。所以你在网上经常看到“归一化后用欧氏距离等价于余弦相似度”这个说法,这是对的,但有个前提:所有参与计算的向量都必须做了严格L2归一化。
这个性质有什么用?如果你的向量检索库只支持欧氏距离计算,不支持余弦,那你可以把所有向量L2归一化,然后用欧氏距离检索,效果和余弦基本一致。我用这套方案解决过不少向量库的兼容问题,不用换库就能上线余弦类召回。
注意事项也在这里:如果数据本来就不该归一化,或者归一化会丢失业务含义,就别强行套这个转换。比如你要比较的是“消费金额绝对值”,归一化以后等于把消费能力抹平了,用欧氏距离的意义就丢了。
2.3 皮尔逊相关系数:去均值化的余弦
皮尔逊相关系数表面上看公式比余弦复杂,但只要换一个角度,它就是余弦的一个变种。它的定义是:
r = Σ(ai - μa)(bi - μb) / (sqrt(Σ(ai - μa)^2) * sqrt(Σ(bi - μb)^2))
其中μa和μb分别是向量A和B的均值。换句话说,先对两个向量的每个维度减去自身均值,再做余弦计算。减均值这个操作,把两个向量的“基准线”对齐了,它衡量的是两个向量围绕各自均值波动模式是否一致。
在推荐场景里,这个操作非常实用。因为用户打分的行为习惯差异太大了。有人打分从来只给4分和5分,有人觉得3分已经算高分,还有人偏爱给极端分。如果直接用余弦相似度比较两个用户的原始评分向量,那个“只给4分和5分”的用户和“3分算高分”的用户,哪怕对同一批物品的偏好顺序完全一致,算出来的相似度也不会高,因为两人所有维度的数值都系统性偏移。皮尔逊相关系数通过减均值把这个偏移抹掉了,它看到的是两个用户“相对自己平时标准”的偏好形状是否一致。
看个具体计算。A的评分向量[4, 5, 4, 5, 4],B的评分向量[3, 4, 3, 4, 3]。这两个向量的余弦相似度其实已经很高了,因为方向一致,但皮尔逊值会更高,接近1。如果你加入一个用户C,评分是[1, 2, 1, 2, 1],C和B的余弦相似度也很高,但皮尔逊系数同样是接近1,因为三个人都是相同的波动模式。这就是皮尔逊擅长处理的场景:用户间一致的偏好形状可以被稳定捕捉。
代码实现也不复杂:
def pearson_similarity(a, b): a_centered = a - np.mean(a) b_centered = b - np.mean(b) denom = np.sqrt(np.sum(a_centered ** 2)) * np.sqrt(np.sum(b_centered ** 2)) if denom == 0: return 0.0 return np.dot(a_centered, b_centered) / denom工程上有两个坑提醒一下。第一,当向量长度极短时,皮尔逊系数很不稳定。比如用户只对两个物品打过分,只有两个维度,那么任何两个用户在这两个维度上的点都可以连成一条直线,皮尔逊系数要么是1要么是-1,完全没有区分度。这种情况要设置最小交互数阈值,比如至少有过5个共同评分的用户才进入相似度计算。第二,皮尔逊系数的分母里包含向量的标准差,如果一个向量的所有值都一样,比如所有评分都是4分,那它的标准差为0,算出来会直接除零。代码里要对这种情况做保护,返回0或者其他默认值。
2.4 Jaccard相似度:集合视角的直观度量
Jaccard相似度可能是所有相似度里最好理解的一个。它算的是两个集合的交集大小除以并集大小:
J(A, B) = |A ∩ B| / |A ∪ B|
举个例子,用户A点过的商品是{手机, 耳机, 充电器, 手机壳},用户B点过的商品是{手机, 耳机, 键盘, 鼠标},交集是{手机, 耳机}两个,并集是六个,Jaccard就是2/6约等于0.333。简单、直观、稳定。
这个指标特别适合稀疏和二值化的场景。什么叫二值化?就是特征只有0和1,比如用户是否购买过某个品类,是否点击过某篇文章,是否安装过某个App。对这些数据,你用余弦相似度计算,实际上也是在比较0/1模式,但结果和Jaccard并不完全一样。余弦看重的是“两个向量在哪些维度上同时为1”,但那些“同时为0”的维度也会参与计算且产生权重,这在某些场景下会带来偏差。Jaccard只关心两个集合里共同出现过的元素,完全不考虑那些都没出现的维度,更符合“共同兴趣”的直觉。
推荐系统里的经典用法是做物品的“行为向量相似度”。比如物品X被用户集合U1购买过,物品Y被用户集合U2购买过,U1和U2的交集越大,说明这两个物品经常被同一批人购买,它们之间就越相似。这个相似度做基于物品的协同过滤召回很好用,尤其是数据稀疏的冷启动阶段,效果往往比向量相似度更稳。
代码实现有两种,一种直接用Python集合,一种用NumPy向量化:
def jaccard_similarity_set(set_a, set_b): inter = len(set_a & set_b) union = len(set_a | set_b) if union == 0: return 0.0 return inter / union # 向量版本:输入为0/1数组 def jaccard_similarity_vec(a, b): a_bool = a.astype(bool) b_bool = b.astype(bool) inter = np.sum(a_bool & b_bool) union = np.sum(a_bool | b_bool) if union == 0: return 0.0 return inter / unionJaccard的局限性也明显:它完全丢弃了数值幅度信息。用户A一个月买了10次手机壳,用户B只买过1次,在Jaccard眼里这两个用户对手机壳的偏好程度一样。所以如果你手里的数据有明确的行为次数或评分强度,Jaccard会浪费信息,这时候应该考虑带权重的相似度,或者先做数值分档再转成集合。我常用的办法是:把行为次数归一化后分桶,比如0次、1-2次、3次以上,映射成三个不同的特征ID,再算Jaccard,能在保留部分强度信息的同时维持集合方法的稳定性。
2.5 补充两个常被忽略的距离:曼哈顿与汉明
除了上面几个主力方法,还有两个场景化很强的距离值得掌握。
曼哈顿距离又叫城市街区距离,公式是向量各维度绝对差之和:
d(A, B) = Σ|ai - bi|
和欧氏距离的区别在于,欧氏距离走的是“直线最短路径”,曼哈顿距离走的是“只能横平竖直走”。在推荐场景里,曼哈顿距离的鲁棒性通常比欧氏距离好,因为平方运算会放大离群维度的影响力,而绝对值不会。比如两个向量在大部分维度上一致,只有一个维度差了一倍,欧氏距离会被这个维度主导,曼哈顿距离受的影响就小得多。如果你的特征里有明显的噪声或者异常值,又想保留距离语义,曼哈顿距离值得考虑。
汉明距离则是为二值向量设计的,定义是两个等长向量中不同位的个数。比如10110和11010有两位不同,汉明距离就是2。它在推荐里最常见的应用是做粗去重和签名比对。比如你把物品的标签集合哈希成固定长度的签名向量,用汉明距离快速判断两个物品重不重复,或者做“看过的物品里哪些和当前物品接近”的实时检索。汉明距离在工程上有一个非常有用的性质:可以用位运算极速计算,两个64位整数做异或,再数一下1的个数,几十亿规模的数据也能实时算完。现在很多向量检索库在实现二值签名的近邻查找时,底层走的就是这个思路。
3. 工程实现与踩坑实录
3.1 手写全套代码:从浮点计算到批量矩阵
我一直觉得,相似度计算这种事,第一遍最好是自己手写而不是直接上封装好的库,因为手写一遍你就把边界条件全摸清了。下面是全套代码,包含上面所有方法以及批量计算版本:
import numpy as np class SimilarityCalculator: @staticmethod def cosine(a, b): dot = np.dot(a, b) norm_a = np.linalg.norm(a) norm_b = np.linalg.norm(b) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b) @staticmethod def euclidean(a, b): return np.sqrt(np.sum((a - b) ** 2)) @staticmethod def manhattan(a, b): return np.sum(np.abs(a - b)) @staticmethod def pearson(a, b): a_centered = a - np.mean(a) b_centered = b - np.mean(b) denom = np.sqrt(np.sum(a_centered ** 2)) * np.sqrt(np.sum(b_centered ** 2)) if denom == 0: return 0.0 return np.dot(a_centered, b_centered) / denom @staticmethod def jaccard(a, b): a_bool = a.astype(bool) b_bool = b.astype(bool) inter = np.sum(a_bool & b_bool) union = np.sum(a_bool | b_bool) if union == 0: return 0.0 return inter / union @staticmethod def hamming(a, b): return np.sum(a.astype(bool) != b.astype(bool)) # 批量计算:两个矩阵所有行两两之间的余弦相似度 def batch_cosine(matrix_a, matrix_b): # L2归一化后再用矩阵乘法 norm_a = np.linalg.norm(matrix_a, axis=1, keepdims=True) norm_b = np.linalg.norm(matrix_b, axis=1, keepdims=True) matrix_a_norm = matrix_a / np.maximum(norm_a, 1e-12) matrix_b_norm = matrix_b / np.maximum(norm_b, 1e-12) return np.dot(matrix_a_norm, matrix_b_norm.T)这里有一个很关键的工程技巧:批量算余弦相似度,永远不要用双层循环,一定要转成矩阵运算。上面的batch_cosine函数里我用了L2归一化加矩阵点积的做法,算10000行乘10000列的相似度矩阵也就几秒钟。如果写双层循环,同样是10000乘10000,循环一千万次,跑完可以等一天。关于这里用了np.maximum(norm_a, 1e-12)而不是直接判断等于0,原因是为了防除零,同时保留数值稳定性,这是实际项目里惯用的写法。
批量计算还应考虑内存问题。100万物品的相似度矩阵,就算用float32存储,也需要1000000 × 1000000 × 4字节,约4TB内存,完全不可行。所以工程上要么用稀疏存储,只保留TopK近邻;要么用分块计算+外存合并;要么直接用近邻检索库。不要试图在内存里构建全量稠密相似度矩阵。
3.2 数据预处理:归一化、缺失值、稀疏矩阵存储
进了实际项目,你很快会意识到,相似度算法的代码只是最不重要的一环。数据的形态直接决定算法效果。我在这里踩过两次大坑,一次是归一化时机不对,一次是缺失值没处理。
归一化要分清“为什么要归一化”和“归一化给谁看”。前面讲过,如果算法最终用的是归一化后的向量做点积,那应该在离线把每个物品向量L2归一化后存起来,线上计算时直接用存好的归一化向量做点积。这样省一次在线计算。但如果你用的是欧氏距离,而且特征本身幅度有业务含义,就不能把消费金额这类数值归一化掉,否则等价于把消费能力抹了。我的经验是:在做任何相似度计算之前,先画一画特征分布图,哪些特征方差大、哪些呈长尾分布,然后决定是否要做log变换或标准化,而不是上来就套StandardScaler。
缺失值是最容易被忽视的。用户的评分矩阵天然稀疏,用户没看过某部电影不是表示“不喜欢”,而是“缺失”。但在余弦相似度的计算里,缺失位置如果填0,就会被当成“不喜欢”,这会严重扭曲结果。解决思路有三种:一是只计算共同非缺失维度上的相似度,比如只取两个用户都打过分的物品维度算余弦,但要注意此时余弦公式的分母也要基于共同维度重新计算;二是用均值填充缺失值,对皮尔逊这类方法效果不错;三是直接上协同过滤类算法,把缺失当成未知而不是0,交给模型学习。我实际用过第一种比较多,虽然计算复杂度高一点,但语义最干净。
稀疏矩阵的存储也是一个工程点。同样是100万用户×100万物品的交互矩阵,密度如果只有0.01%,稠密存储完全装不下,但用COO格式或者CSR格式存储,可能只要几百MB。做相似度计算时,尽量使用支持稀疏矩阵运算的库,把稀疏矩阵乘以稀疏矩阵用高效算法。不要习惯性转成NumPy稠密数组,否则内存直接爆掉。
3.3 性能优化:向量化、批量、近邻检索工具
最后聊一聊线上性能。相似度计算在推荐系统里不只离线做,很多场景要线上实时算。用户点了某个物品,需要立即从候选池里找出和这个物品最像的100个。这时候要求单次计算在毫秒级完成。
如果你的候选池不大,几千或者几万个,直接用NumPy矩阵运算就能扛住。上面batch_cosine函数就是干这个的,候选向量预先归一化好,来一个查询向量,做一次矩阵点积,再取TopK,几十毫秒内完成。
候选池上了百万量级,这个办法就不行了。此时应该用专门的近邻检索库,比如Faiss。它对向量索引做了分层和量化,支持百万、千万级的TopK召回,延迟也在毫秒级。使用Faiss时有一个非常关键的细节:索引类型选择。用IndexFlatIP(内积索引)时,必须保证入库和查询的向量都是L2归一化后的,因为内积在归一化向量上等于余弦。用IndexIVFFlat这类倒排索引时,需要先训练聚类中心,训练集的规模和质量会影响召回率。我见过有人把未归一化的向量直接灌进Faiss用内积检索,结果召回结果一片混乱,排查了很长时间才看出是归一化丢了。
还有个小技巧:如果数据是二值化的,可以用带符号的位向量配合汉明距离来做快速的粗筛,先在海量候选里粗筛出几千个,再做精排级的相似度计算。这样既能享受汉明距离的低成本,又不会损失精度。
4. 常见问题排查与避坑速查
4.1 为什么相似度结果全是奇怪的极值
算出来的相似度要么几乎都是1,要么全是-1,这是新手最容易撞见的问题。最常见的原因就是没有处理好维度之间的对齐。向量A和向量B的维度含义必须一一对应,如果A的第3维是“手机类目点击次数”,B的第3维是“笔记本电脑类目点击次数”,那算出来的相似度没有任何参考价值。尤其是从不同数据源拼特征时,经常出现特征拼接顺序不一致的问题。我排查过一次诡异的“所有相似度都异常高”的问题,最后发现是两个Embedding向量在拼接时,一个用了特征名排序,另一个用了原始顺序,维度错位。
另一个原因是数值尺度不一致。比如向量的前10维是0到1的小数特征,后10维是0到10000的计数特征,欧氏距离会被大数值的维度完全主导,余弦相似度也会因为模长计算被大数值维度拉动,小数值维度失去比较意义。处理方式是先做特征级别的标准化或者归一化。我建议对连续特征尤其是长尾分布的特征做log1p变换再加标准化,能显著提升相似度计算的合理性。
4.2 用户行为稀疏导致相似度失真怎么办
推荐系统的数据天然是稀疏的,用户平均交互过几百个物品,而物品库有百万级,两个用户共同交互过的物品数量可能少得可怜。这会导致两个问题:一是交集过小时,Jaccard和皮尔逊的数值方差极大;二是大量用户之间相似度为0,召回候选不足。
应对交集过小的问题,一个常规做法是设置最小交集阈值,比如至少5个共同物品才计算相似度,不满足的直接置0。另一个做法是改用向量化Embedding。把用户的行为序列映射成稠密向量,虽然会损失部分可解释性,但能有效缓解稀疏问题。再就是可以利用“间接相似”:用户A和用户B没有直接交集,但都跟用户C有相似行为,可以通过A-C相似度和C-B相似度估算A-B的相似度。这种做法在冷启动阶段很有用。
稀疏矩阵还有一个隐藏陷阱:你想当然地用稠密矩阵存储,然后一连串计算直接白白消耗大量内存。比如一个10万用户×10万物品的矩阵,即使填充率只有0.5%,稠密存储也要消耗80GB以上,但稀疏存储只有几GB。做批量相似度计算前,先确认数据格式是稀疏的。
4.3 哪到底该选哪个相似度方法:决策清单
每回有人问我“应该用哪个相似度”,我给的答案都是:先看数据形态,再看业务意图。这里整理一个决策清单,遇到具体问题可以对着查:
| 情况 | 推荐方法 | 原因 |
|---|---|---|
| 文本/兴趣Embedding向量,方向比长度重要 | 余弦相似度 | 不受向量长度影响,稳定,适合召回 |
| 特征包含消费金额、活跃度等具有实际幅度的数值 | 欧氏距离/曼哈顿距离 | 保留尺度信息,幅度本身有意义 |
| 用户评分数据,用户打分尺度差异大 | 皮尔逊相关系数 | 去均值化消除打分偏移 |
| 行为是点击/购买/收藏等二值事件,交集语义强 | Jaccard相似度 | 只关心共同交互,自然贴合布尔数据 |
| 需要从海量候选中快速粗筛 | 汉明距离/近邻检索库 | 计算代价低,可做第一层召回 |
| 特征包含离群值,且不希望离群值主导 | 曼哈顿距离 | 绝对值求和鲁棒性优于欧氏距离 |
选型的时候不要只看理论指标,还要考虑工程成本。余弦相似度配合向量检索库是工业界最成熟的组合,能解决八成以上的相似度需求。皮尔逊在评分矩阵上精度好,但需要额外处理均值计算和除零。Jaccard思路简单,上线速度快,适合做冷启动和兜底策略。我的习惯是:主路召回用余弦Embedding,辅助路召回用Jaccard行为相似,排序特征里加皮尔逊评分相似度,三格并行,各有各的用途。
4.4 相似度分数如何转换成业务可用的权重
很多调优场景里,相似度计算完不是终点,你还要把它变成推荐分数的一部分。这里最大的误区是直接把相似度分数当权重用,不做任何变换。相似度分数的分布经常是偏态的:大部分物品对的相似度集中在0.1到0.3,少数同类物品在0.8以上。这种分布直接进去做加权和,会导致少数头部相似度主导最终排序。
我常用的做法是做分数校准。比如把相似度分数做min-max归一化到0到1,或者做分位数映射,把原始分数映射到对应的分位点。还有一种经验做法:对相似度做指数变换,比如用相似度的2次方或3次方作为权重,可以拉大头部和尾部的差距,让真正相似的物品在排序里获得更大优势。注意这个指数不要选太大,否则会过度放大噪声。具体选几次方,要根据线上AB实验效果来定,没有统一最优参数。
5. 最后说一点个人体会
做推荐系统这几年,我越来越意识到,相似度方法本质上是你对“什么是相似”这个问题的一个建模假设。用余弦,你假设用户兴趣是方向性的,幅度不重要;用欧氏,你假设数值本身有绝对意义;用Jaccard,你假设只有共同出现才算数,没发生的都不算数。没有哪种方法是绝对正确或绝对错误的,只能看它跟你的业务场景和数据形态匹不匹配。
如果非要说一个最实用的建议,那就是把代码里的相似度计算方法封装成可替换的模块,输入输出接口保持一致。这样你可以在离线实验里快速比较不同方法的表现,而不是每次换一种方法就要改一遍上游下游代码。我在实际项目中就吃过这个亏,早期把余弦算在业务代码里,后面想换成皮尔逊,牵一发动全身,改了两三天。封装好了以后,所有相似度方法对比实验跑一遍,效果一目了然。
最后分享一个调试技巧:算完相似度之后,不要只看分数,把相似度最高的几十个结果拉出来人工看一眼。很多时候数学指标很漂亮,业务上却完全不相关,这种问题只有靠人工抽检才能发现。相似度的数值再精确,也替代不了对业务本身的理解。