第1832篇:向量相似度函数的选择——余弦、内积、欧氏距离在不同场景的适用性
第1832篇:向量相似度函数的选择——余弦、内积、欧氏距离在不同场景的适用性
这个问题在向量检索里很容易被忽视,但选错了相似度函数,效果可以差很多。
我见过一个案例:一个做语义搜索的项目,用的是 OpenAI 的 text-embedding-ada-002,但向量库里配的是欧氏距离(L2)。系统能跑,结果看起来"还行",但跟换成余弦距离之后的结果一比,明显差了一档。原因很简单:Ada-002 的向量是归一化的,在归一化向量上用欧氏距离和余弦距离等价,但他们的数据在进库之前做了归一化之外的预处理,破坏了这个性质。
这件事告诉我,相似度函数不是随便选的,要结合模型输出的向量特性和业务场景一起考虑。
一、三种距离函数的数学本质
1.1 余弦相似度(Cosine Similarity)
值域在 [-1, 1],越接近1越相似。转成距离:
核心特性:只关注方向,不关注向量的大小(模长)。两个向量 A 和 2A 的余弦相似度是 1(完全相同),但它们的欧氏距离不是0。
1.2 内积(Inner Product / Dot Product)
没有归一化,受向量模长影响。
内积可以分解为:
也就是说,内积 = 模长之积 × 余弦相似度。如果两个向量的模长都为1(归一化),内积退化为余弦相似度。
1.3 欧氏距离(Euclidean / L2 Distance)
核心特性:同时考虑方向和大小,对绝对数值差异敏感。
展开欧氏距离的平方:
如果所有向量都归一化(),则:
结论:归一化向量上的欧氏距离与余弦距离单调等价,排序结果相同。
三种距离的关系总结
二、每种距离的适用场景
2.1 余弦相似度:文本语义检索的默认选择
适用条件:
- 向量代表的是"语义方向",不关心强度
- 文档长短不一,不希望长文档因为信息量多而天然排在前面
典型场景:
- 文本语义检索(BERT、BGE、OpenAI Embedding)
- 问答匹配、FAQ检索
- 跨文档相似推荐
为什么文本用余弦合适?
举个例子:用户问"如何退款",文档A用一句话描述退款流程,文档B用十段话详细讲退款流程。从语义上,两者相关性相近。如果用欧氏距离,文档B因为积累了更多与"退款"相关的语义信息,模长更大,内积更高,但如果文档B的模长是文档A的10倍,欧氏距离的几何位置差别可能比文档C(和"退款"无关但模长接近A)还要大。
余弦相似度通过归一化消除了这个干扰。
2.2 内积(最大内积搜索,MIPS):推荐系统的首选
适用条件:
- 向量的模长本身携带了有意义的信息(比如"流行度"或"置信度")
- 优化目标是最大化得分而非找最相似的方向
典型场景:
- 协同过滤推荐(用户向量×物品向量 = 预测评分)
- 广告点击率预估
- YouTube DNN 双塔模型的召回
具体来说,矩阵分解类推荐算法(如 SVD、ALS)学到的用户嵌入 u 和物品嵌入 v,它们的点积 u·v 预测的就是评分或交互概率。这时候如果用余弦相似度归一化掉模长,反而丢失了模型学到的信息。
一个经典例子:模型学到的物品向量,热门物品的模长往往更大(因为有大量交互数据训练),冷门物品模长更小。用内积搜索,热门物品自然有更高的得分;用余弦相似度,模长信息被归一化掉,热门和冷门的区分度下降。
注意:MIPS(Maximum Inner Product Search)不能直接用标准的最近邻搜索,因为它不是一个度量空间(不满足三角不等式)。需要用支持内积的向量库,如 Faiss 的 IndexFlatIP,或 Milvus 的 IP 度量。
2.3 欧氏距离:图像、音频等感知特征的自然选择
适用条件:
- 特征在绝对数值上有物理意义(如像素值、频谱能量)
- 向量各维度的数量级相近(或已经标准化)
典型场景:
- 图像特征匹配(CNN 最后一层的 feature map)
- 人脸识别(FaceNet 用的就是 L2 距离)
- 音频指纹匹配
- 时序数据相似性
FaceNet 明确说了用 L2 距离:训练目标是 Triplet Loss,让同一人的人脸 embedding 之间 L2 距离最小。这里 L2 距离是有物理意义的——它代表了人脸特征空间里的"真实距离"。
适用场景对照表
| 场景 | 推荐距离函数 | 原因 |
|---|---|---|
| 文本语义检索 | 余弦 | 消除长度影响,关注方向 |
| 推荐系统召回 | 内积 | 保留模型学到的幅度信息 |
| 图像检索 | 欧氏 | 特征数值有物理意义 |
| FAQ问答匹配 | 余弦 | 问题语义方向的相似性 |
| 代码相似度 | 余弦 | 代码功能方向的相似性 |
| 人脸识别 | 欧氏 | Triplet Loss训练目标即L2 |
| 广告CTR预估 | 内积 | 双塔模型的目标是最大化得分 |
| 多模态检索 | 余弦 | 跨模态对齐通常用方向对齐 |
三、归一化的陷阱
理论上说,对归一化的向量,余弦和L2等价。但实践中有几个陷阱。
陷阱1:Embedding模型输出不一定是归一化的
很多工程师默认 Embedding 是归一化的,但实际上不同模型不同:
import ai.onnxruntime.*;
import java.util.Arrays;
public class EmbeddingNormCheck {
/**
* 检查模型输出的向量是否已经归一化
*/
public static void checkNormalization(float[] embedding) {
float norm = 0;
for (float v : embedding) {
norm += v * v;
}
norm = (float) Math.sqrt(norm);
System.out.printf("向量模长: %.6f%n", norm);
if (Math.abs(norm - 1.0f) < 1e-4) {
System.out.println("已归一化 ✓ — 余弦 = 内积,可互换");
} else {
System.out.println("未归一化 ✗ — 余弦和欧氏距离不等价,需明确选择");
}
}
/**
* 手动归一化
*/
public static float[] normalize(float[] embedding) {
float norm = 0;
for (float v : embedding) norm += v * v;
norm = (float) Math.sqrt(norm);
if (norm < 1e-12f) return embedding; // 防止除以0
float[] normalized = new float[embedding.length];
for (int i = 0; i < embedding.length; i++) {
normalized[i] = embedding[i] / norm;
}
return normalized;
}
public static void main(String[] args) {
// 模拟两个不同模型的输出
float[] adaEmbedding = generateNormalizedVector(1536); // OpenAI Ada已归一化
float[] bgeEmbedding = generateRawVector(768); // BGE未归一化
System.out.println("=== Ada-002 ===");
checkNormalization(adaEmbedding);
System.out.println("\n=== BGE-Large (原始输出) ===");
checkNormalization(bgeEmbedding);
System.out.println("\n=== BGE-Large (归一化后) ===");
checkNormalization(normalize(bgeEmbedding));
}
private static float[] generateNormalizedVector(int dim) {
float[] v = generateRawVector(dim);
return normalize(v);
}
private static float[] generateRawVector(int dim) {
float[] v = new float[dim];
java.util.Random rand = new java.util.Random(42);
for (int i = 0; i < dim; i++) v[i] = rand.nextFloat() * 4 - 2;
return v;
}
}常见Embedding模型的归一化情况:
| 模型 | 是否归一化 | 推荐距离 |
|---|---|---|
| OpenAI text-embedding-ada-002 | 是 | 余弦(或内积,等价) |
| OpenAI text-embedding-3 | 是 | 余弦 |
| BGE系列(默认) | 否 | 用前先归一化,再用余弦 |
| sentence-transformers | 否(默认) | normalize_embeddings=True后用余弦 |
| FaceNet | 是 | L2 |
| CLIP | 是 | 余弦 |
陷阱2:部分归一化(partial normalization)
有时候业务上会对向量做特征拼接,比如把文本 Embedding(归一化)和一些数值特征(未归一化)拼在一起,结果整体向量既不是纯文本Embedding,也不满足归一化性质。
这种情况下余弦和L2会有明显差异,需要根据业务含义决定:
/**
* 混合向量的相似度计算
* 前 768 维是文本语义(余弦),后 10 维是数值特征(L2)
*/
public class HybridSimilarity {
private final int textDim = 768;
private final float textWeight = 0.8f;
private final float numericWeight = 0.2f;
public float compute(float[] a, float[] b) {
// 文本部分用余弦相似度
float textSim = cosineSimilarity(a, b, 0, textDim);
// 数值部分用归一化的L2距离转成相似度
float numericDist = l2Distance(a, b, textDim, a.length);
float numericSim = 1.0f / (1.0f + numericDist); // 转成 [0,1]
return textWeight * textSim + numericWeight * numericSim;
}
private float cosineSimilarity(float[] a, float[] b, int start, int end) {
float dot = 0, normA = 0, normB = 0;
for (int i = start; i < end; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA == 0 || normB == 0) return 0;
return dot / (float)(Math.sqrt(normA) * Math.sqrt(normB));
}
private float l2Distance(float[] a, float[] b, int start, int end) {
float sum = 0;
for (int i = start; i < end; i++) {
float diff = a[i] - b[i];
sum += diff * diff;
}
return (float) Math.sqrt(sum);
}
}陷阱3:向量库参数和模型不匹配
这是最容易出问题的地方。用 Milvus 举例:
import io.milvus.v2.client.*;
import io.milvus.v2.service.collection.request.*;
import io.milvus.v2.service.index.request.*;
import io.milvus.v2.common.*;
public class MilvusDistanceConfig {
/**
* 错误示例:BGE模型用了L2,效果会变差
* BGE输出不是归一化的,L2会受模长干扰
*/
public static IndexParam wrongConfig() {
return IndexParam.builder()
.fieldName("embedding")
.indexType(IndexParam.IndexType.HNSW)
.metricType(IndexParam.MetricType.L2) // 错误:对BGE应用L2
.extraParams(Map.of("M", 16, "efConstruction", 200))
.build();
}
/**
* 正确示例1:BGE用余弦距离,入库前先归一化
*/
public static IndexParam correctConfigForBGE() {
return IndexParam.builder()
.fieldName("embedding")
.indexType(IndexParam.IndexType.HNSW)
.metricType(IndexParam.MetricType.COSINE) // 正确
.extraParams(Map.of("M", 16, "efConstruction", 200))
.build();
}
/**
* 正确示例2:推荐系统双塔模型用IP(内积)
*/
public static IndexParam correctConfigForRecommend() {
return IndexParam.builder()
.fieldName("embedding")
.indexType(IndexParam.IndexType.HNSW)
.metricType(IndexParam.MetricType.IP) // 内积,用于推荐系统
.extraParams(Map.of("M", 16, "efConstruction", 200))
.build();
}
}四、边缘场景的处理
4.1 零向量问题
当某个向量全是0(比如embedding模型对特殊输入返回了零向量),余弦相似度会除以0。
public static float safeCosine(float[] a, float[] b) {
float dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
// 如果任一向量为零向量,返回0(视为完全不相似)
if (normA < 1e-12f || normB < 1e-12f) return 0.0f;
return dot / (float)(Math.sqrt(normA) * Math.sqrt(normB));
}4.2 高维向量的数值稳定性
在高维空间(D=1536以上),累加浮点数时可能有精度损失。用 Kahan 求和可以缓解:
public static float kahanDotProduct(float[] a, float[] b) {
float sum = 0;
float compensation = 0;
for (int i = 0; i < a.length; i++) {
float y = a[i] * b[i] - compensation;
float t = sum + y;
compensation = (t - sum) - y;
sum = t;
}
return sum;
}不过坦白说,实际工程里这个精度差异通常可以忽略,排序结果不会有实质变化,除非你在做非常精确的相似度阈值过滤。
4.3 不同相似度函数对阈值的影响
这个问题让我吃过亏。同样的模型,同样的数据,换了距离函数之后,原来"相似度 > 0.85 算相关"的阈值就失效了。
/**
* 相似度阈值不能跨距离函数复用
* 以下是几种距离函数的大致范围参考
*/
public class SimilarityThresholdGuide {
// 余弦相似度:[-1, 1]
// 语义相关的文本:通常 > 0.7
// 语义强相关:> 0.85
// 近乎相同:> 0.95
public static final float COSINE_RELEVANT = 0.70f;
public static final float COSINE_HIGH = 0.85f;
public static final float COSINE_NEAR_DUP = 0.95f;
// 内积(依赖于向量模长,无统一标准,需要根据数据分布校准)
// 通常做法:不用绝对阈值,而是用 Top-K
// 欧氏距离:[0, +∞)
// 需要根据数据集的向量分布来定,没有通用阈值
// 建议:先对数据集做采样,统计平均距离,以此为基准
/**
* 动态阈值校准:基于数据集统计
*/
public static float calibrateThreshold(List<float[]> sampleVectors,
float targetRecall) {
// 随机采样1000对向量,计算距离分布
List<Float> distances = new ArrayList<>();
Random random = new Random();
int sampleSize = Math.min(1000, sampleVectors.size());
for (int i = 0; i < sampleSize; i++) {
float[] a = sampleVectors.get(random.nextInt(sampleVectors.size()));
float[] b = sampleVectors.get(random.nextInt(sampleVectors.size()));
distances.add(safeCosine(a, b));
}
Collections.sort(distances, Collections.reverseOrder());
int thresholdIdx = (int)(distances.size() * (1 - targetRecall));
return distances.get(Math.min(thresholdIdx, distances.size() - 1));
}
}五、实战:用 Faiss 验证三种距离函数的差异
用一个实验来直观感受三种距离的差异。我们用 SBERT 对一批文本做 Embedding,然后分别用三种距离做检索,对比结果。
// 伪代码,展示思路(实际需要对接Faiss Java绑定或通过gRPC调用)
public class DistanceFunctionComparison {
private final EmbeddingService embeddingService;
public void compare(String query, List<String> documents) {
float[] queryVec = embeddingService.embed(query);
List<float[]> docVecs = documents.stream()
.map(embeddingService::embed)
.collect(Collectors.toList());
// 计算三种距离
System.out.println("Query: " + query);
System.out.println("\n--- 余弦相似度 TOP-5 ---");
rankByCosine(queryVec, docVecs, documents).forEach(r ->
System.out.printf(" [%.4f] %s%n", r.score, r.text));
System.out.println("\n--- 内积 TOP-5 ---");
rankByIP(queryVec, docVecs, documents).forEach(r ->
System.out.printf(" [%.4f] %s%n", r.score, r.text));
System.out.println("\n--- 欧氏距离 TOP-5 ---");
rankByL2(queryVec, docVecs, documents).forEach(r ->
System.out.printf(" [%.4f] %s%n", r.score, r.text));
}
private record SearchResult(float score, String text) {}
private List<SearchResult> rankByCosine(float[] query, List<float[]> docs,
List<String> texts) {
List<SearchResult> results = new ArrayList<>();
for (int i = 0; i < docs.size(); i++) {
results.add(new SearchResult(safeCosine(query, docs.get(i)), texts.get(i)));
}
results.sort((a, b) -> Float.compare(b.score(), a.score()));
return results.subList(0, Math.min(5, results.size()));
}
private List<SearchResult> rankByIP(float[] query, List<float[]> docs,
List<String> texts) {
List<SearchResult> results = new ArrayList<>();
for (int i = 0; i < docs.size(); i++) {
float ip = 0;
for (int j = 0; j < query.length; j++) ip += query[j] * docs.get(i)[j];
results.add(new SearchResult(ip, texts.get(i)));
}
results.sort((a, b) -> Float.compare(b.score(), a.score()));
return results.subList(0, Math.min(5, results.size()));
}
private List<SearchResult> rankByL2(float[] query, List<float[]> docs,
List<String> texts) {
List<SearchResult> results = new ArrayList<>();
for (int i = 0; i < docs.size(); i++) {
float sum = 0;
for (int j = 0; j < query.length; j++) {
float diff = query[j] - docs.get(i)[j];
sum += diff * diff;
}
// L2距离越小越相似,取负值方便统一排序
results.add(new SearchResult(-(float)Math.sqrt(sum), texts.get(i)));
}
results.sort((a, b) -> Float.compare(b.score(), a.score()));
return results.subList(0, Math.min(5, results.size()));
}
}真实实验里,对于归一化的 Embedding(如 OpenAI Ada),余弦和L2的排名几乎完全一致;但对于未归一化的 BGE Embedding,三种距离的排名差异明显,余弦的语义相关性通常是最好的。
六、选择指南与决策树
七、总结
- 余弦相似度:文本语义的默认选择,对向量长度不敏感,适合 BERT 类 Embedding
- 内积:推荐系统的首选,保留模型学到的幅度信息,用于 MIPS 场景
- 欧氏距离:感知特征(图像、音频、人脸)的自然选择,Triplet Loss 训练的模型配套使用
最后一句实操建议:每次换向量模型或向量库时,先跑一个对比实验,用真实数据验证三种距离函数的召回率差异,不要只靠直觉。
