向量检索的数学之美:余弦相似度、欧氏距离和内积的使用场景辨析
向量检索的数学之美:余弦相似度、欧氏距离和内积的使用场景辨析
向量检索有三种主流的相似度度量,但你有没有想过:为什么有的选余弦、有的选欧氏距离、有的选内积?这三种数学工具背后的语义差异是什么?
很多 RAG 系统的默认配置就用余弦相似度——因为它是"默认的"。但实际场景中,选错相似度度量会让你的检索质量差 20% 以上。这三个公式不是通用的,它们各自假设了不同的数据分布和语义空间。
一、深度引言与场景痛点
- 余弦相似度:
cos(θ) = (a·b) / (|a| * |b|),只看方向,不看长度。两个向量指向同一方向,相似度为 1;正交为 0;相反为 -1。 - 欧氏距离:
d = sqrt(Σ(ai - bi)²),计算空间中的直线距离。两个向量越近,距离越小。 - 内积:
a·b = Σ(ai * bi) = |a| * |b| * cos(θ),方向相同且长度越大,值越大。
二、底层机制与原理深度剖析
现代 Text Embedding 模型(OpenAI text-embedding-3、BGE、E5)在训练时通常会做 L2 归一化,让所有向量的长度都约等于 1。当|a| = |b| = 1时,余弦相似度等于内积。
但这不是你可以随便替换的理由。如果你自训练的 Embedding 模型没有做归一化,用余弦和用内积的结果就不一样。
余弦相似度适合 Embedding 的三个原因:
- 不受向量长度影响。同一个文档的不同长度 Embedding(不同模型),余弦相似度稳定,内积会波动。
- 语义空间里,"方向"比"大小"更有意义。两篇关于 Python 的文章,Embedding 方向相近但长度不同(一篇长一篇短),余弦相似度仍然高。
- 数学上简洁,范围在 [-1, 1] 之间,容易设定阈值。
三、生产级代码实现
欧氏距离在以下场景优于余弦相似度:
图像检索。图像 Embedding 的长度通常编码了图像的"信息量"(复杂度、细节丰富度)。用余弦会丢失长度信息,用欧氏距离更准确。
向量聚类。K-Means 基于欧氏距离。如果你用余弦相似度做聚类,中心和成员的关系就没那么直观。
数值特征向量。不是 Embedding 模型输出的人工特征(如 [价格, 评分, 销量]),欧氏距离更能反映"综合差距"。
四、边界分析与架构权衡
import numpy as np from enum import Enum from typing import Optional import logging logger = logging.getLogger(__name__) class SimilarityMetric(Enum): COSINE = "cosine" EUCLIDEAN = "euclidean" DOT_PRODUCT = "dot_product" class SimilarityComputer: def __init__(self, metric: SimilarityMetric = SimilarityMetric.COSINE): self.metric = metric def compute(self, a: np.ndarray, b: np.ndarray) -> float: if self.metric == SimilarityMetric.COSINE: return self._cosine(a, b) elif self.metric == SimilarityMetric.EUCLIDEAN: return self._euclidean(a, b) else: return self._dot_product(a, b) @staticmethod def _cosine(a: np.ndarray, b: np.ndarray) -> float: norm_a = np.linalg.norm(a) norm_b = np.linalg.norm(b) if norm_a == 0 or norm_b == 0: logger.warning("Zero vector detected in cosine similarity") return 0.0 sim = np.dot(a, b) / (norm_a * norm_b) return max(-1.0, min(1.0, float(sim))) @staticmethod def _euclidean(a: np.ndarray, b: np.ndarray) -> float: distance = np.linalg.norm(a - b) # 转换为相似度:距离越小,相似度越高 return float(1.0 / (1.0 + distance)) @staticmethod def _dot_product(a: np.ndarray, b: np.ndarray) -> float: return float(np.dot(a, b)) class AutoMetricSelector: """ 自动选择合适的相似度度量: 1. 检查 Embedding 是否归一化 → 余弦和内积等价 → 用余弦 2. 检查向量维度中的特征类型 → 数值特征用欧氏距离 3. 否则默认余弦 """ @staticmethod def select( sample_vectors: list[np.ndarray], is_embedding: bool = True, is_normalized: Optional[bool] = None, ) -> SimilarityMetric: if is_normalized is None and sample_vectors: norms = [np.linalg.norm(v) for v in sample_vectors] avg_norm = np.mean(norms) std_norm = np.std(norms) # 如果所有向量的模长接近 1(方差极小),认为已归一化 is_normalized = ( abs(avg_norm - 1.0) < 0.01 and std_norm < 0.01 ) if is_normalized: return SimilarityMetric.COSINE if not is_embedding: return SimilarityMetric.EUCLIDEAN return SimilarityMetric.COSINE # 使用示例 vectors = [np.array([0.1, 0.3, 0.5]), np.array([0.2, 0.4, 0.6])] metric = AutoMetricSelector.select(vectors) print(f"推荐度量: {metric.value}") computer = SimilarityComputer(metric) sim = computer.compute(vectors[0], vectors[1]) print(f"相似度: {sim:.4f}")五、总结
| 场景 | 推荐度量 | 原因 |
|---|---|---|
| RAG 文本检索 | 余弦相似度 | Embedding 已归一化 |
| 图像相似搜索 | 欧氏距离 | 图像特征长度编码信息量 |
| 推荐系统召回 | 内积 | 协同过滤矩阵分解 |
| 文档聚类 | 余弦相似度 | 归一化后更稳定 |
| 异常检测 | 欧氏距离 | 偏离正常模式的距离 |
| 人脸识别 | 余弦相似度 | 人脸特征向量的方向比长度重要 |
| 商品特征匹配 | 欧氏距离 | 数值特征的绝对差异有意义 |
六、常见错误与纠正
错误一:用欧氏距离不加归一化
如果向量 A 的模长是 10,向量 B 的模长是 1。即使它们方向完全相同,欧氏距离也约等于 9。这在语义上毫无意义。用欧氏距离之前,先做 L2 归一化。
错误二:认为余弦相似度 0.8 就是"好"
余弦相似度的阈值需要根据你的数据分布来定。在某些 Embedding 空间里,0.8 可能非常普遍(所有文档都相似),0.95 才算是"相关"。做任何阈值设定前,先看你的相似度分布直方图。
错误三:混合使用不同度量
同一个系统里,检索用余弦、重排用欧氏、融合用内积,三种度量不可比。统一成一种,或者在融合前归一化。
七、总结
三种相似度度量的选择不是数学偏好,是场景匹配:
- 余弦相似度:比较方向。适用于归一化的 Text Embedding 检索。
- 欧氏距离:比较空间距离。适用于数值特征向量和图像检索。
- 内积:比较投影长度。适用于需要"越大越好"的推荐场景。
RAG 系统 95% 的场景用余弦相似度就够了。但如果你的 Embedding 不是标准模型输出、或者你的数据是人工特征,花十分钟验证一下度量选择,可能让你少走很多弯路。
资料说明
本文中的协议、版本、性能、成本和行业趋势应以可核验的一手资料为准。未标注统计口径的比例、时间表和预测仅作工程讨论,不应视为行业事实。可参考 0730 资料来源索引,并在发布前将具体来源贴到对应断言之后。
