Appearance
相似度与距离度量:阈值到底该设多少
1. 本节产出
理解余弦/内积/欧氏三种度量在 Embedding 场景下的差异,能用自己的文档集画出相似度分布直方图,并据此定出 similarityThreshold 的合理取值——而不是拍脑袋写 0.7。
2. 前置依赖
- 02-06 向量库选型
- 02-07 Document 元数据设计
- 已灌入一批真实文档
3. 为什么阈值不能拍脑袋
团队里关于阈值通常有两种说法:
| 说法 | 问题 |
|---|---|
| 「设 0.8,宁缺毋滥」 | 大量正常问题被拒答,用户觉得 AI 什么都不会 |
| 「不设,多召回点让模型自己判断」 | 一堆无关片段塞进上下文,费钱且干扰模型 |
两种都错,因为阈值取决于你的数据分布,不是通用常数。
一个真实案例:某公司设了 0.75,用户问「年假怎么算」被拒答。排查发现:他们用的 Embedding 模型下,正确匹配的相似度只有 0.68。原因不是模型差,而是这个模型的相似度分布整体偏低——它的 0.68 相当于其他模型的 0.85。
结论:阈值必须基于你自己的模型 + 自己的文档实测。
4. 核心原理
4.1 三种度量的差异
| 度量 | 计算 | 取值范围 | 特点 |
|---|---|---|---|
| 余弦相似度 | cos(θ) | [-1, 1],归一化为 [0,1] | 只看方向,不看长度,最常用 |
| 内积(点积) | Σai·bi | 无上界 | 受向量长度影响,归一化后等同余弦 |
| 欧氏距离 | √Σ(ai-bi)² | [0, +∞) | 越小越相似,受维度影响大 |
为什么 Embedding 场景用余弦:文本向量的「长度」通常携带的是「这个词/句的信息量」而非语义,我们关心的是方向是否一致。而且余弦是归一化的,便于设阈值。
提示:多数 Embedding 模型输出的向量已经归一化(长度为 1),此时余弦 = 内积,两者等价。但不要依赖这一点,配置里显式指定 COSINE 更稳妥。
4.2 相似度分布:定阈值的方法
做法:
1. 准备 50~100 个真实问题
2. 对每个问题跑检索,记录 top1 的相似度
3. 人工标注:这个 top1 是否真的相关
4. 画两条分布曲线:相关组 vs 不相关组
5. 取两条曲线的交界处作为阈值频次
│ 不相关组
│ ╭──╮
│ ╱ ╲ 相关组
│ ╱ ╲ ╭───╮
│ ╱ ╲ ╱ ╲
│ ╱ ╲__╱ ╲___
└──┴────────────┴──────────────┴──→ 相似度
0.2 0.55 0.72 0.9
↑
阈值取这里这张图是本节的核心产出。有了它,阈值不再是拍脑袋,而是有据可依。
4.3 阈值之外:Top-K 怎么定
| Top-K | 优点 | 缺点 |
|---|---|---|
| 3 | 上下文短、省钱 | 容易漏掉正确答案 |
| 5 | 平衡点 | — |
| 10+ | 召回全 | 上下文长、费钱、引入噪声 |
推荐做法:先用较大的 K 召回(如 20),再用 Rerank 精排到 5。这样既保证不漏,又控制上下文长度。这正是 02-10 要讲的内容——粗排靠向量,精排靠 Rerank。
4.4 常见误解:相似度不等于相关性
查询:「怎么申请年假」
片段A(相似度 0.82):「请假流程:登录系统 → 选择请假类型 → 提交申请」 ← 相关
片段B(相似度 0.79):「年假申请被驳回的常见原因包括:……」 ← 语义近但不回答「怎么申请」向量相似度衡量的是「语义接近程度」,不是「能否回答这个问题」。这就是为什么需要 Rerank 二阶段排序——Rerank 模型是专门训练来判断「这个文档能否回答这个问题」的。
5. 代码走查
5.1 指定距离度量
java
// PGVector:显式指定余弦距离
PgVectorStore.builder(jdbc, model)
.distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE)
.build();5.2 采样相似度分布
java
// src/main/java/com/example/rag/eval/SimilaritySampler.java
@Service
public class SimilaritySampler {
private final VectorStore store;
private final EmbeddingModel model;
/** 对每个问题取 top1 相似度,输出 CSV 供人工标注 */
public void sample(List<String> questions, Path out) throws IOException {
List<String> lines = new ArrayList<>();
lines.add("question,top1Score,top1Text");
for (String q : questions) {
List<Document> hits = store.similaritySearch(
SearchRequest.builder()
.query(q)
.topK(1)
.similarityThreshold(0.0) // 采样时不设阈值
.build());
double score = hits.isEmpty() ? 0 : scoreOf(hits.get(0));
String text = hits.isEmpty() ? "" : abbreviate(hits.get(0).getText());
lines.add("%s,%.4f,%s".formatted(q, score, text));
}
Files.write(out, lines);
}
}采样时 similarityThreshold 必须设 0,否则你根本看不到低于当前阈值的样本,也就无法判断阈值是否设高了。
5.3 用标注结果算阈值
java
// src/main/java/com/example/rag/eval/ThresholdFinder.java
public final class ThresholdFinder {
/** 在标注数据上找 F1 最大的阈值 */
public static double findBest(List<Labeled> data) {
double best = 0, bestT = 0.5;
for (double t = 0.3; t <= 0.95; t += 0.01) {
double tp = data.stream().filter(d -> d.relevant() && d.score() >= t).count();
double fp = data.stream().filter(d -> !d.relevant() && d.score() >= t).count();
double fn = data.stream().filter(d -> d.relevant() && d.score() < t).count();
double precision = (tp + fp) == 0 ? 0 : tp / (tp + fp);
double recall = (tp + fn) == 0 ? 0 : tp / (tp + fn);
double f1 = (precision + recall) == 0 ? 0
: 2 * precision * recall / (precision + recall);
if (f1 > best) { best = f1; bestT = t; }
}
return bestT;
}
public record Labeled(String question, double score, boolean relevant) {}
}这个循环是「用数据定参数」的具体实现。跑完得到的不只是阈值,还有对应的 F1——你能知道这个阈值下的理论上限。
6. 跑起来
bash
git checkout ch02-08-similarity
# 1. 采样
mvn -q exec:java -Dexec.mainClass=...SimilaritySampler
# 2. 人工标注 CSV 的 relevant 列(这一步不能省)
# 3. 算阈值
mvn -q exec:java -Dexec.mainClass=...ThresholdFinder期望输出:
样本数:87 相关:52 不相关:35
最佳阈值:0.61 (F1 = 0.86, P = 0.84, R = 0.88)
参考对比:
阈值 0.80 → F1 = 0.62(拒答过多)
阈值 0.50 → F1 = 0.79(噪声过多)| 检查项 | 通过标准 |
|---|---|
| 样本量 | ≥ 50 条真实问题 |
| 分布可区分 | 相关组与不相关组有明显分界 |
| 阈值有依据 | 输出 F1 曲线,不是拍脑袋 |
| 落地验证 | 把阈值写进配置后,拒答率明显下降 |
7. 生产避坑
- 阈值是模型相关的,换 Embedding 模型必须重新标定。这是最容易漏的一步:升级模型后沿用旧阈值,检索质量悄悄下降,而且没有报错。做法:把阈值和模型名写在一起,模型变更时校验提示。
- 采样时必须关掉阈值,否则你的样本集天然排除了低分样本,得出的「最佳阈值」会被高估。这个错误很隐蔽,因为采样代码看起来是「正常工作」的。
- 不要只用相似度做唯一判据。相似度高不代表能回答问题(见 4.4)。正确姿势是「低阈值粗召回 + Rerank 精排 + 拒答机制」三段式,单靠阈值调不出好效果。
8. 延伸与锚点
- 思考题:相似度阈值能挡住「完全不相关」的片段,但挡不住「语义相近但答非所问」。怎么解决?(答案在 02-10 Rerank)
- 代码锚点:
git checkout ch02-08-similarity - 下一课时:02-09 混合检索与 RRF 融合
- 对应课件:L02-08 相似度与阈值