1. 为什么相似度度量值得单独拎出来讲
做数据相关的工作,不管是搜索、推荐、去重还是聚类,绕来绕去总会撞上同一个问题:怎么判断两个东西到底有多像。这个问题听起来简单,实际落地的时候坑特别多。我见过不少项目,模型结构调了半天,最后发现瓶颈居然出在相似度计算这一步选错了方法。
相似度度量本质上就是把两个对象之间的“像的程度”量化成一个数值。这个对象可以是文本、图片、向量、用户行为序列,甚至是一段音频的频谱特征。不同场景下,“像”的定义完全不一样。两段文本用词重叠多算像,还是语义接近算像?两个用户买了同样的商品算像,还是浏览行为模式一致算像?这些问题的答案直接决定了你该选哪种度量方法。
这篇文章我打算把常见的相似度度量方法从头到尾捋一遍,包括它们背后的数学直觉、适用场景、Python实现,以及我在实际项目里踩过的坑。适合有一定Python基础、正在做搜索/推荐/NLP/数据挖掘相关工作的朋友,也适合刚入门想系统了解这块知识的学生。每种方法我都会给出可以直接跑的代码,你复制到Jupyter里就能看到效果。
先说一个核心观点:没有万能的相似度度量方法,只有最适合当前数据特性和业务目标的那个。选错了,后面的模型再强也白搭。
2. 相似度度量的整体分类与选型逻辑
2.1 从数据形态出发的分类框架
相似度度量方法可以从不同角度分类,但我个人觉得最实用的分类方式是按照数据形态来分。因为在实际工作中,你拿到数据的第一反应就是看它长什么样,然后才能决定用什么方法。
大致可以分成这么几类:
- 向量空间中的度量:数据已经被表示成数值向量,比如TF-IDF向量、词嵌入向量、图像特征向量。这类是最常见的,方法也最多。
- 集合型数据的度量:数据表示成集合的形式,比如用户购买的商品集合、文档的关键词集合。这类主要用Jaccard系列。
- 序列型数据的度量:数据有顺序关系,比如时间序列、DNA序列、用户行为序列。这类需要编辑距离、DTW等方法。
- 概率分布型数据的度量:数据表示成概率分布,比如主题分布、直方图。这类用KL散度、JS散度等。
- 字符串型数据的度量:原始文本还没向量化,直接比较字符串。这类用编辑距离、最长公共子序列等。
这个分类不是绝对的,很多方法可以跨类使用。比如余弦相似度既可以用于向量,也可以用于概率分布。但从这个框架出发,你拿到一个新问题的时候,至少知道该往哪个方向去找方法。
2.2 选型时真正要问自己的四个问题
我在实际项目里总结了一个选型清单,每次遇到相似度计算的需求,先问自己这四个问题:
第一个问题:你的数据是什么形态?这个决定了候选方法的范围。如果是稠密向量,余弦和欧氏距离是首选;如果是稀疏向量(比如TF-IDF),余弦通常比欧氏更合适;如果是集合,Jaccard是自然选择。
第二个问题:你关心的是方向还是距离?这个问题特别关键。余弦相似度只看方向,不看模长;欧氏距离既看方向也看模长。举个例子,两篇文档,一篇是另一篇的两倍长但内容主题完全一样,余弦相似度会认为它们非常相似,欧氏距离则会认为它们差很远。你到底想要哪种“像”,取决于业务需求。
第三个问题:数据的尺度是否统一?如果不同维度的量纲差异很大,欧氏距离会被大量纲的维度主导。这时候要么先做标准化,要么改用余弦相似度。我见过一个推荐系统的case,用户年龄范围是18-80,收入范围是几千到几十万,直接用欧氏距离算用户相似度,结果收入维度完全主导了结果,年龄的影响几乎被淹没。
第四个问题:计算效率要求多高?有些方法精度高但计算复杂度也高,比如DTW是O(n²),编辑距离也是O(n²)。如果数据量是百万级,这些方法可能直接不可用。这时候要么降采样,要么用近似算法,要么换更高效的方法。
提示:选型的时候不要一上来就追求最复杂的方法。很多时候,简单的余弦相似度加上好的特征工程,效果比复杂的度量方法加粗糙的特征要好得多。
3. 向量空间中的相似度度量方法与Python实现
3.1 余弦相似度:最常用但不是万能
余弦相似度的公式很简单:
cosine_sim(A, B) = (A · B) / (||A|| * ||B||)它的几何意义是:把两个向量看成从原点出发的箭头,余弦相似度就是这两个箭头之间夹角的余弦值。夹角越小,余弦值越接近1,表示越相似;夹角90度,余弦值为0,表示无关;夹角180度,余弦值为-1,表示完全相反。
为什么余弦相似度这么常用?因为它对向量的模长不敏感。在文本场景下,这意味着长文档和短文档只要主题分布相似,就能得到较高的相似度。这符合大多数文本相似度的直觉。
Python实现有三种常见方式:
import numpy as np from numpy.linalg import norm def cosine_similarity_manual(a, b): """手动实现余弦相似度""" return np.dot(a, b) / (norm(a) * norm(b)) # 示例 a = np.array([1, 2, 3]) b = np.array([2, 4, 6]) print(cosine_similarity_manual(a, b)) # 输出 1.0,因为b是a的2倍,方向相同from sklearn.metrics.pairwise import cosine_similarity import numpy as np # 批量计算,适合矩阵形式 X = np.array([[1, 2, 3], [2, 4, 6], [1, 0, 1]]) sim_matrix = cosine_similarity(X) print(sim_matrix)from scipy.spatial.distance import cosine # scipy的cosine返回的是余弦距离,即1 - 余弦相似度 a = np.array([1, 2, 3]) b = np.array([2, 4, 6]) print(1 - cosine(a, b)) # 转换回相似度实操心得:sklearn的cosine_similarity在数据量大且稀疏的时候效率很高,因为它内部会检测稀疏矩阵并做优化。如果你的数据是scipy的稀疏矩阵格式,直接用sklearn的版本,不要自己写循环。
注意事项:余弦相似度有一个容易被忽略的问题——它不满足三角不等式。也就是说,A和B相似,B和C相似,不能推出A和C相似。这在某些需要传递性的场景下会出问题,比如基于相似度的图聚类。
3.2 欧氏距离:最直观但最容易被量纲坑
欧氏距离就是两点之间的直线距离:
euclidean_dist(A, B) = sqrt(sum((a_i - b_i)^2))它是最符合人类直觉的距离度量。但在高维空间中,欧氏距离会变得不太可靠——这就是所谓的“维度灾难”。当维度很高时,所有点之间的距离都会趋于相近,区分度大幅下降。
Python实现:
import numpy as np def euclidean_distance(a, b): return np.sqrt(np.sum((a - b) ** 2)) # 或者用scipy from scipy.spatial.distance import euclidean print(euclidean([1, 2, 3], [4, 5, 6]))from sklearn.metrics.pairwise import euclidean_distances import numpy as np X = np.array([[1, 2], [3, 4], [5, 6]]) dist_matrix = euclidean_distances(X) print(dist_matrix)实操心得:用欧氏距离之前,一定要检查各维度的量纲是否统一。如果差异大,先做标准化(StandardScaler)或归一化(MinMaxScaler)。我一般会先算一下各维度的标准差,如果最大标准差和最小标准差差了一个数量级以上,就必须先标准化。
注意事项:欧氏距离对异常值很敏感,因为差值会被平方。如果数据中有离群点,考虑用曼哈顿距离替代。
3.3 曼哈顿距离:高维稀疏场景下的稳健选择
曼哈顿距离也叫城市街区距离,就是各维度差值的绝对值之和:
manhattan_dist(A, B) = sum(|a_i - b_i|)它比欧氏距离对异常值更稳健,因为不做平方。在高维稀疏场景下,曼哈顿距离往往比欧氏距离表现更好。
from scipy.spatial.distance import cityblock print(cityblock([1, 2, 3], [4, 5, 6])) # 手动实现 def manhattan_distance(a, b): return np.sum(np.abs(a - b))实操心得:在推荐系统中计算用户相似度时,如果用户特征向量很稀疏(大部分是0),曼哈顿距离通常比欧氏距离更稳定。因为欧氏距离的平方会放大少数几个大差异维度的影响。
3.4 切比雪夫距离:关注最大差异维度
切比雪夫距离是各维度差值绝对值的最大值:
chebyshev_dist(A, B) = max(|a_i - b_i|)它适合那种“只要有一个维度差异太大就不算相似”的场景。比如在工业质检中,只要有一个指标超标就判定为不合格。
from scipy.spatial.distance import chebyshev print(chebyshev([1, 2, 3], [4, 5, 6]))3.5 闵可夫斯基距离:一个公式统一多种距离
闵可夫斯基距离是一个通用框架:
minkowski_dist(A, B) = (sum(|a_i - b_i|^p))^(1/p)当p=1时就是曼哈顿距离,p=2时就是欧氏距离,p→∞时就是切比雪夫距离。这个统一视角很有价值,它说明这些距离本质上是在用不同的方式聚合各维度的差异。
from scipy.spatial.distance import minkowski print(minkowski([1, 2, 3], [4, 5, 6], p=2)) # 欧氏距离 print(minkowski([1, 2, 3], [4, 5, 6], p=1)) # 曼哈顿距离实操心得:p值的选择是一个超参数。p=2是最常用的,但在某些场景下p=1或p=1.5效果更好。我一般会在验证集上试几个p值,选效果最好的。不过要注意,p值不同,距离的尺度也不同,如果后面有阈值判断,需要重新调阈值。
3.6 向量相似度方法对比表
| 方法 | 公式核心 | 对模长敏感 | 对异常值敏感 | 适用场景 |
|---|---|---|---|---|
| 余弦相似度 | 夹角余弦 | 否 | 较低 | 文本、高维稀疏向量 |
| 欧氏距离 | 直线距离 | 是 | 高 | 低维稠密向量、量纲统一 |
| 曼哈顿距离 | 绝对值之和 | 是 | 较低 | 高维稀疏、有异常值 |
| 切比雪夫距离 | 最大差值 | 是 | 高 | 瓶颈约束场景 |
| 闵可夫斯基 | 通用Lp范数 | 是 | 取决于p | 需要调参的场景 |
4. 集合型与字符串型数据的相似度度量
4.1 Jaccard相似度:集合交并比的经典
Jaccard相似度的定义非常直观:
Jaccard(A, B) = |A ∩ B| / |A ∪ B|就是两个集合的交集大小除以并集大小。取值范围是0到1,1表示两个集合完全相同。
def jaccard_similarity(set_a, set_b): intersection = len(set_a & set_b) union = len(set_a | set_b) return intersection / union if union != 0 else 0 # 示例 a = {"苹果", "香蕉", "橙子"} b = {"香蕉", "橙子", "葡萄"} print(jaccard_similarity(a, b)) # 2/4 = 0.5在文本去重场景中,Jaccard非常常用。把每篇文档表示成关键词集合或n-gram集合,然后算Jaccard相似度。一般超过0.8就认为是重复内容。
实操心得:直接用词集合算Jaccard有个问题——高频词(如“的”、“是”)会主导结果。我一般会先去掉停用词,或者用TF-IDF加权后的Jaccard(也叫加权Jaccard)。另外,对于长文档,用shingling(滑动窗口n-gram)比用单个词效果更好。
注意事项:Jaccard相似度对集合大小差异很敏感。一个100个元素的集合和一个10个元素的集合,即使小的完全包含在大的里面,Jaccard也只有10/100=0.1。如果业务上认为“完全包含”就应该算高度相似,那Jaccard就不合适,应该用重叠系数(Overlap Coefficient)。
4.2 重叠系数:解决包含关系的场景
重叠系数的公式是:
Overlap(A, B) = |A ∩ B| / min(|A|, |B|)它衡量的是小集合被大集合覆盖的比例。如果小集合完全被大集合包含,重叠系数就是1。
def overlap_coefficient(set_a, set_b): intersection = len(set_a & set_b) min_size = min(len(set_a), len(set_b)) return intersection / min_size if min_size != 0 else 0适用场景:搜索query和文档的匹配。用户搜“Python教程”,一篇长文档里有一节专门讲Python教程,用Jaccard算相似度会很低(因为文档整体词集很大),但用重叠系数就能捕捉到这种包含关系。
4.3 Dice系数:Jaccard的变体
Dice系数和Jaccard很像,但用的是调和平均的思路:
Dice(A, B) = 2 * |A ∩ B| / (|A| + |B|)它和Jaccard的关系是:Dice = 2 * Jaccard / (1 + Jaccard)。Dice对交集部分的权重更高,在图像分割的评估指标里很常用。
def dice_coefficient(set_a, set_b): intersection = len(set_a & set_b) return 2 * intersection / (len(set_a) + len(set_b))4.4 编辑距离:字符串比较的基石
编辑距离(Levenshtein Distance)是把一个字符串变成另一个字符串所需的最少单字符编辑操作次数。允许的操作包括插入、删除、替换。
def levenshtein_distance(s1, s2): m, n = len(s1), len(s2) # dp[i][j]表示s1[:i]和s2[:j]的编辑距离 dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(m + 1): dp[i][0] = i for j in range(n + 1): dp[0][j] = j for i in range(1, m + 1): for j in range(1, n + 1): if s1[i-1] == s2[j-1]: dp[i][j] = dp[i-1][j-1] else: dp[i][j] = 1 + min(dp[i-1][j], # 删除 dp[i][j-1], # 插入 dp[i-1][j-1]) # 替换 return dp[m][n] print(levenshtein_distance("kitten", "sitting")) # 3编辑距离本身是一个距离,不是相似度。要转成相似度,可以用:
similarity = 1 - distance / max(len(s1), len(s2))实操心得:编辑距离的时间复杂度是O(m*n),对于长字符串会很慢。如果只需要判断相似度是否超过某个阈值,可以用带剪枝的版本,或者用BK树做快速检索。另外,Python的python-Levenshtein库是C实现的,比纯Python快几十倍,生产环境建议直接用。
注意事项:编辑距离对字符顺序敏感,但对语义不敏感。“abc”和“cba”的编辑距离是2,但它们可能表达完全不同的意思。在NLP场景下,编辑距离通常只用于拼写纠错、实体对齐这种表面匹配的任务。
4.5 最长公共子序列与最长公共子串
最长公共子序列(LCS)不要求连续,最长公共子串(LCSubstr)要求连续。它们都可以用来衡量字符串相似度。
def lcs_length(s1, s2): m, n = len(s1), len(s2) dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if s1[i-1] == s2[j-1]: dp[i][j] = dp[i-1][j-1] + 1 else: dp[i][j] = max(dp[i-1][j], dp[i][j-1]) return dp[m][n] def lcsubstr_length(s1, s2): m, n = len(s1), len(s2) dp = [[0] * (n + 1) for _ in range(m + 1)] max_len = 0 for i in range(1, m + 1): for j in range(1, n + 1): if s1[i-1] == s2[j-1]: dp[i][j] = dp[i-1][j-1] + 1 max_len = max(max_len, dp[i][j]) return max_lenLCS适合那种“顺序重要但允许间隔”的场景,比如代码相似度检测。LCSubstr适合“连续匹配”的场景,比如抄袭检测中找连续重复片段。
4.6 集合与字符串方法对比表
| 方法 | 数据类型 | 时间复杂度 | 核心特点 | 典型场景 |
|---|---|---|---|---|
| Jaccard | 集合 | O(n) | 交并比 | 文本去重 |
| 重叠系数 | 集合 | O(n) | 包含关系 | 搜索匹配 |
| Dice | 集合 | O(n) | 交集加权 | 图像分割评估 |
| 编辑距离 | 字符串 | O(m*n) | 编辑操作数 | 拼写纠错 |
| LCS | 字符串 | O(m*n) | 顺序匹配 | 代码相似度 |
| LCSubstr | 字符串 | O(m*n) | 连续匹配 | 抄袭检测 |
5. 概率分布与序列数据的相似度度量
5.1 KL散度与JS散度:分布之间的“距离”
KL散度(Kullback-Leibler Divergence)衡量两个概率分布之间的差异:
KL(P || Q) = sum(P(x) * log(P(x) / Q(x)))注意KL散度不是对称的,KL(P||Q) ≠ KL(Q||P)。所以它严格来说不是“距离”,而是“散度”。在实际使用中,如果需要一个对称的度量,可以用JS散度(Jensen-Shannon Divergence):
JS(P, Q) = 0.5 * KL(P || M) + 0.5 * KL(Q || M) 其中 M = 0.5 * (P + Q)JS散度的取值范围是0到1(如果log以2为底),而且是对称的。
import numpy as np from scipy.spatial.distance import jensenshannon def kl_divergence(p, q): # 避免log(0) p = np.array(p, dtype=float) q = np.array(q, dtype=float) p = p / p.sum() q = q / q.sum() mask = p > 0 return np.sum(p[mask] * np.log(p[mask] / q[mask])) p = [0.1, 0.2, 0.7] q = [0.2, 0.3, 0.5] print(kl_divergence(p, q)) print(jensenshannon(p, q)) # scipy的jensenshannon返回的是距离,不是散度实操心得:KL散度对Q为0而P不为0的情况会无穷大,实际使用中需要做平滑处理。JS散度没有这个问题,因为M是P和Q的平均,不会为0。在主题模型(如LDA)中比较文档主题分布时,JS散度比KL散度更常用。
注意事项:JS散度的值域是[0, 1](log以2为底时),但scipy的jensenshannon返回的是距离的平方根,即sqrt(JS),值域是[0, 1]。用的时候注意看文档。
5.2 皮尔逊相关系数:线性相关性的度量
皮尔逊相关系数衡量两个变量之间的线性相关程度:
pearson(A, B) = cov(A, B) / (std(A) * std(B))取值范围是-1到1,1表示完全正相关,-1表示完全负相关,0表示无线性相关。
from scipy.stats import pearsonr import numpy as np a = np.array([1, 2, 3, 4, 5]) b = np.array([2, 4, 6, 8, 10]) r, p_value = pearsonr(a, b) print(r) # 1.0实操心得:在推荐系统中,皮尔逊相关系数常用来计算用户相似度。它比余弦相似度的优势在于,它考虑了用户的评分偏置。比如一个用户习惯打高分,另一个用户习惯打低分,但他们对于物品的相对偏好可能是一致的,皮尔逊相关系数能捕捉到这种一致性,而余弦相似度不能。
注意事项:皮尔逊相关系数只能捕捉线性关系。如果两个变量是非线性相关的,皮尔逊相关系数可能接近0,但实际上它们高度相关。这时候应该用斯皮尔曼相关系数或互信息。
5.3 斯皮尔曼相关系数:秩相关性的度量
斯皮尔曼相关系数先对数据做秩变换,然后计算秩的皮尔逊相关系数。它能捕捉单调非线性关系。
from scipy.stats import spearmanr a = [1, 2, 3, 4, 5] b = [1, 4, 9, 16, 25] # 非线性但单调 r, p_value = spearmanr(a, b) print(r) # 1.0适用场景:当数据不满足正态分布,或者你关心的是排序一致性而不是具体数值时,用斯皮尔曼。比如评估两个推荐列表的相似度,你关心的是排序是否一致,而不是具体评分。
5.4 DTW:时间序列的弹性匹配
DTW(Dynamic Time Warping)是时间序列相似度度量的经典方法。它允许时间轴上的弹性对齐,适合两个序列长度不同或速度不同的场景。
def dtw_distance(s1, s2): n, m = len(s1), len(s2) # 初始化代价矩阵 dtw_matrix = np.full((n + 1, m + 1), np.inf) dtw_matrix[0][0] = 0 for i in range(1, n + 1): for j in range(1, m + 1): cost = abs(s1[i-1] - s2[j-1]) dtw_matrix[i][j] = cost + min(dtw_matrix[i-1][j], # 插入 dtw_matrix[i][j-1], # 删除 dtw_matrix[i-1][j-1]) # 匹配 return dtw_matrix[n][m] s1 = [1, 2, 3, 4, 5] s2 = [1, 1, 2, 3, 4, 4, 5] print(dtw_distance(s1, s2))实操心得:DTW的时间复杂度是O(n*m),对于长序列会很慢。实际使用中可以用FastDTW做近似,把复杂度降到O(n)。另外,DTW对噪声敏感,可以先做平滑处理。在语音识别、股票走势匹配、传感器数据对齐等场景中,DTW是标配。
注意事项:DTW距离需要归一化,否则长序列的DTW距离天然比短序列大。常用的归一化方式是除以路径长度。
5.5 概率与序列方法对比表
| 方法 | 对称性 | 值域 | 适用场景 | 计算复杂度 |
|---|---|---|---|---|
| KL散度 | 否 | [0, ∞) | 分布差异 | O(n) |
| JS散度 | 是 | [0, 1] | 分布差异 | O(n) |
| 皮尔逊 | 是 | [-1, 1] | 线性相关 | O(n) |
| 斯皮尔曼 | 是 | [-1, 1] | 秩相关 | O(n log n) |
| DTW | 是 | [0, ∞) | 时间序列 | O(n*m) |
6. 实操中的常见问题与排查技巧
6.1 相似度值都差不多怎么办
这是最常见的问题。你算了一堆相似度,发现都在0.7到0.8之间,区分度极低。原因通常有几个:
原因一:高维稀疏导致的维度灾难。当维度很高时,所有点之间的距离趋于相近。解决办法是先做降维(PCA、SVD、UMAP),或者在计算相似度之前做特征选择,只保留有区分度的维度。
原因二:数据本身同质化严重。如果所有样本本来就差不多,那相似度自然区分不开。这时候需要检查特征工程是否有问题,是不是没有提取到有区分度的特征。
原因三:度量方法不合适。比如在稀疏向量上用欧氏距离,大量0维度会稀释差异。换成余弦相似度通常能改善。
我的一般排查流程是:先看相似度分布,如果集中在某个区间,先做降维,再换度量方法,最后检查特征。
6.2 计算速度太慢怎么优化
相似度计算在数据量大时很容易成为瓶颈。优化思路按优先级排列:
用矩阵运算替代循环。这是最有效的优化。比如计算所有样本对的余弦相似度,直接用
sklearn.metrics.pairwise.cosine_similarity(X),内部是矩阵乘法,比Python循环快几个数量级。用稀疏矩阵。如果数据稀疏,用scipy的稀疏矩阵格式,内存和计算都能大幅节省。
用近似算法。如果只需要找最相似的Top-K,不需要精确值,可以用LSH(局部敏感哈希)或HNSW(分层可导航小世界图)。Facebook的Faiss库就是专门做这个的,百万级数据毫秒级检索。
降维。先做PCA降到100维左右,再算相似度,速度会快很多,精度损失通常可接受。
并行化。用多进程或多线程分块计算。不过Python的GIL限制多线程效果,建议用多进程或numpy的并行能力。
6.3 相似度阈值怎么定
阈值定多少算相似,这个问题没有标准答案,必须结合业务。我一般用两种方法:
方法一:看分布。画相似度直方图,找自然的分界点。如果分布是双峰的,谷底就是阈值。
方法二:看标注数据。如果有标注的相似/不相似对,画ROC曲线,找F1最大的阈值。
方法三:业务驱动。比如去重场景,宁可漏杀不可错杀,阈值就设高一点(0.9以上);召回场景,宁可错杀不可漏杀,阈值就设低一点(0.5左右)。
提示:阈值不是一成不变的。数据分布变化后,阈值需要重新调整。我一般会定期监控相似度分布,发现漂移就重新定阈值。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 相似度区分度低 | 维度灾难/特征同质化 | 看相似度分布 | 降维/换度量/改特征 |
| 计算速度慢 | 循环计算/维度高 | profile代码 | 矩阵运算/稀疏矩阵/近似算法 |
| 结果不符合直觉 | 量纲不统一/度量选错 | 检查各维度尺度 | 标准化/换度量方法 |
| 阈值难定 | 分布无自然分界 | 画直方图/ROC | 业务驱动/标注数据 |
| 对异常值敏感 | 用了欧氏距离 | 检查离群点 | 换曼哈顿/切比雪夫 |
| 包含关系识别不了 | 用了Jaccard | 检查集合大小差异 | 换重叠系数 |
7. 几个实战中的经验补充
7.1 特征工程比度量方法更重要
我做过一个文本相似度的项目,一开始在度量方法上折腾了很久,余弦、Jaccard、编辑距离都试了,效果都一般。后来发现真正的问题在特征——用的是原始词袋,没有做任何加权和筛选。换成TF-IDF加权,再去掉高频停用词和低频噪声词,效果直接提升了一大截。度量方法只是最后一步,前面的特征表示才是决定性的。
7.2 多种度量方法融合往往比单一方法好
在实际系统中,我很少只用一种相似度度量。通常是多种方法加权融合。比如搜索场景,可以用余弦相似度算语义相似,用Jaccard算关键词重叠,用编辑距离算表面匹配,然后加权求和。权重的确定可以用简单的网格搜索,也可以用学习排序的方法。融合后的效果通常比任何单一方法都好。
7.3 注意相似度的对称性问题
有些度量是对称的(余弦、欧氏、Jaccard),有些是不对称的(KL散度、包含关系)。在需要对称的场景下,不对称的度量需要做对称化处理。比如KL散度可以对称化成JS散度,或者用KL(P||Q) + KL(Q||P)作为对称距离。
7.4 归一化的坑
很多度量方法的值域不是[0, 1],比如欧氏距离是[0, ∞),编辑距离是[0, max_len]。如果后续要加权融合或者做阈值判断,需要先归一化。归一化的方式有MinMax、Z-score、除以最大值等。不同的归一化方式会影响最终结果,建议在验证集上试。
7.5 别忘了评估
相似度计算的效果最终要用业务指标来评估。如果是去重,看准确率和召回率;如果是推荐,看点击率和转化率;如果是检索,看NDCG和MAP。不要只看相似度数值本身,那只是中间结果。我见过一个项目,相似度算得很漂亮,但业务指标没提升,因为相似度高的物品并不是用户真正想要的。
7.6 一个容易被忽略的细节:IDF加权
在文本相似度中,IDF加权是一个简单但极其有效的技巧。核心思想是:越少见的词,区分度越高,应该给更高的权重。比如“的”这种词几乎每篇文档都有,对区分文档没有帮助,IDF接近0;“量子计算”这种词只在少数文档中出现,IDF很高,应该主导相似度计算。sklearn的TfidfVectorizer默认就会做IDF加权,但很多人用CountVectorizer然后忘了加权,效果差很多。
7.7 关于稀疏向量的存储
如果你的数据是稀疏的(大部分维度为0),千万不要用numpy的稠密数组存储。一个100万维的向量,如果只有100个非零值,稠密存储要占8MB,稀疏存储只要几百字节。scipy的sparse.csr_matrix是标准选择。而且sklearn的相似度计算函数对稀疏矩阵有专门优化,速度比稠密矩阵快很多。
7.8 在线计算vs离线计算
相似度计算可以离线预计算,也可以在线实时算。离线预计算适合数据量不大、更新不频繁的场景,把相似度矩阵存下来,查询时直接查表。在线计算适合数据量大、更新频繁的场景,每次查询时实时算。但在线计算对延迟要求高,通常需要用近似算法或索引结构(如Faiss、Annoy、HNSW)。我的一般策略是:离线预计算候选集,在线做精排。
7.9 关于GPU加速
如果数据量特别大,或者需要频繁计算相似度矩阵,可以考虑用GPU加速。PyTorch和TensorFlow都支持矩阵运算的GPU加速。一个10000x10000的相似度矩阵,CPU可能要几秒,GPU只要几十毫秒。不过GPU加速有数据传输开销,如果数据量不大,可能反而更慢。建议先profile再决定。
7.10 最后一点:保持简单
我见过太多项目,一上来就上最复杂的度量方法,结果调试困难、效果还不一定好。我的建议是:先从最简单的余弦相似度或Jaccard开始,建立一个baseline,然后逐步尝试更复杂的方法,看是否有提升。很多时候,简单方法加上好的特征工程,就能达到很好的效果。复杂方法应该是最后的手段,而不是第一选择。
相似度度量这个领域看起来简单,但真正做好需要结合数据特性、业务目标和计算资源做综合权衡。希望这些经验能帮你少走一些弯路。