Skip to content

相似度与距离度量:阈值到底该设多少 ​

1. 本节产出 ​

理解余弦/内积/欧氏三种度量在 Embedding 场景下的差异,能用自己的文档集画出相似度分布直方图,并据此定出 similarityThreshold 的合理取值——而不是拍脑袋写 0.7。

2. 前置依赖 ​

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. 生产避坑 ​

  1. 阈值是模型相关的,换 Embedding 模型必须重新标定。这是最容易漏的一步:升级模型后沿用旧阈值,检索质量悄悄下降,而且没有报错。做法:把阈值和模型名写在一起,模型变更时校验提示。
  2. 采样时必须关掉阈值,否则你的样本集天然排除了低分样本,得出的「最佳阈值」会被高估。这个错误很隐蔽,因为采样代码看起来是「正常工作」的。
  3. 不要只用相似度做唯一判据。相似度高不代表能回答问题(见 4.4)。正确姿势是「低阈值粗召回 + Rerank 精排 + 拒答机制」三段式,单靠阈值调不出好效果。

8. 延伸与锚点 ​

  • 思考题:相似度阈值能挡住「完全不相关」的片段,但挡不住「语义相近但答非所问」。怎么解决?(答案在 02-10 Rerank)
  • 代码锚点:git checkout ch02-08-similarity
  • 下一课时:02-09 混合检索与 RRF 融合
  • 对应课件:L02-08 相似度与阈值