余弦相似度本质是dot(a,b)/(norm(a)*norm(b)),推荐用NumPy原生函数组合实现;注意向量shape、零向量防护、批量计算用广播与矩阵乘法,避免scipy/sklearn低效封装。

用 numpy.dot 和 numpy.linalg.norm 直接算最稳
余弦相似度本质就是 dot(a, b) / (norm(a) * norm(b)),NumPy 原生函数组合起来既清晰又高效。别自己写循环或用 scipy.spatial.distance.cosine(它返回的是 1−cosine,还慢一截)。
常见错误是把向量当成了二维数组却没注意 shape:比如 a.shape == (5,) 和 a.shape == (1, 5) 都能点乘,但后者在批量计算时容易出广播问题。
- 确保输入是 1D 向量,或统一为行向量(
a.reshape(1, -1))再做批量处理 -
numpy.linalg.norm默认对全部元素求 L2 范数,不用额外指定axis;若传入二维数组且想按行算范数,才加axis=1 - 如果 a 或 b 是零向量,
norm返回 0,会导致除零警告——实际中建议提前用np.allclose(a, 0)拦一下
示例:
import numpy as np a = np.array([1, 2, 3]) b = np.array([2, 4, 6]) sim = np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) # → 1.0
批量计算两组向量间所有配对相似度:用广播,别嵌套 for
比如你有 100 个 query 向量和 1000 个 doc 向量,要算 100×1000 个相似度,用双层 Python for 循环是灾难。正确做法是利用 NumPy 广播 + einsum 或矩阵乘法。
核心思路:把 query 堆成 (n, d),doc 堆成 (m, d),然后 query @ doc.T 得到 (n, m) 的点积矩阵;再分别算每行 query 的范数((n, 1))和每列 doc 的范数((1, m)),广播相除。
- 点积部分用
query @ doc.T比np.dot(query, doc.T)更直观,也更符合现代 NumPy 习惯 - 范数要 reshape 成列向量和行向量才能正确广播:
np.linalg.norm(query, axis=1, keepdims=True)和np.linalg.norm(doc, axis=1, keepdims=True).T - 如果内存吃紧(比如 n×m 超过几百万),就分块计算,别硬扛——
query[i:i+batch] @ doc.T这样切
为什么不用 sklearn.metrics.pairwise.cosine_similarity?
它确实一行搞定,但代价明显:内部会先把输入转成 float64、强制检查稀疏性、还带一堆验证逻辑。实测在纯 dense float32 向量上,比手写 NumPy 快不了,有时反而慢 20%~30%。
更关键的是行为差异:
- 它默认对每行做 L2 归一化后再算点积,等价于先
normalize(X, norm='l2')再X @ X.T;如果你已经归一化过,它会多做一遍,白费力气 - 返回结果是 dense matrix,哪怕你只想要 top-k,它也全算完——而手写可以配合
np.argpartition提前截断 - 不支持 half precision(
float16),遇到显存紧张的场景直接报错
零向量、NaN、极小值带来的数值不稳定怎么防?
真实数据里常有归一化失败、embedding 截断、梯度爆炸残留等情况,导致向量含 NaN 或范数接近浮点最小正数(如 1e-38),这时除法会崩。
- 检测 NaN:用
np.any(np.isnan(a)),别用a != a(对数组不安全) - 防除零:范数计算后加一个极小偏置,比如
eps = np.finfo(float).tiny,再写成/ (norm_a * norm_b + eps) - 避免下溢:如果原始向量值域极大(如 e100 级别),先减去最大值再 exp(虽然余弦本身 scale-invariant,但中间 norm 计算可能溢出)——不过这种情况更可能是数据预处理漏了
这事看着琐碎,但线上服务里一条 NaN 就能让整个 batch 的相似度全变 nan,排查起来比算法逻辑还花时间。








