EmbeddingGemma 2 搭 RAG 检索层 / 相似度阈值要靠标注样本来定

文章导读
相似度阈值卡多少、top-k 取几条,如果没有评估集,调参就只是在两三个数字之间来回试,换个语料又得重来。可行的起点不是找“最优数字”,而是准备一份带标注的问答对,把每条查询的召回分数导出成分布,从分布里读出分界点;再把阈值和 top-k 当作一组参数回测,确认它召回了该召回的内容,又没有把明显无关的片段带进来。
📋 目录
  1. 壹 准备一份带标注的问答对作为阈值评估集
  2. 贰 跑一遍召回并导出每条查询的相似度分数
  3. 叁 按分数区间统计命中与误召比例
  4. 肆 把阈值与 top-k 组合进检索函数并回测
  5. 伍 记录阈值在不同语料上的漂移
A A

相似度阈值卡多少、top-k 取几条,如果没有评估集,调参就只是在两三个数字之间来回试,换个语料又得重来。可行的起点不是找“最优数字”,而是准备一份带标注的问答对,把每条查询的召回分数导出成分布,从分布里读出分界点;再把阈值和 top-k 当作一组参数回测,确认它召回了该召回的内容,又没有把明显无关的片段带进来。

阈值和 top-k 都不是能直接照抄的常数,它们取决于 embedding 模型、语料长度分布和切分粒度。做法是先用一批带标注问答对跑召回,导出每条查询对每个片段的相似度分数,看期望片段与无关片段的分界落在哪个区间,再把阈值与 top-k 组合回测命中与误召。判断边界是:这套数字只对当前模型加当前语料成立,换语料要重跑分布记录,原阈值只能当起点参考。

准备一份带标注的问答对作为阈值评估集

评估集的作用是让阈值有可计算的依据,而不是让标注者凭印象说“感觉差不多”。建议按行存成 JSONL,一条样本一行,字段固定下来,后面导出分数和统计都按同一套 id 串联。

字段含义建议写法
qid样本编号唯一字符串,用于把分数导出和标注对齐
query用户问题原文贴近真实问法,不要提前改写成关键词堆
expected_chunk_ids期望被召回的片段 id 列表可为多个;空列表表示这条不该召回到任何片段
should_recall是否应召回,true / false负例标 false,专门用来测误召
note标注理由边界样本写清为什么算命中,方便复盘

规模不必求大,通常每条语料覆盖几十条起步即可,但三类样本都要有:明显该召回的、边界模糊的(同义改写、答案跨片段)、明确不该召回的(问的是语料外的内容)。如果负例太少,误召根本测不出来,测出来的阈值会偏松。切分策略改变(比如片段长度从 200 字调到 500 字)时,评估集要跟着重跑,因为分数分布会变。

跑一遍召回并导出每条查询的相似度分数

这一步的目标是拿到分布,不是拿一个数字。做法是遍历评估集里的每条 query,对全量片段算相似度,按分数降序取前 N 条写入文件,同时标记每一条是不是期望片段。以下是一个通用骨架,embedding 调用按你实际用的本地推理或服务端接口替换。

EmbeddingGemma 2 搭 RAG 检索层 / 相似度阈值要靠标注样本来定
import json

def embed(texts):
    # 替换为实际调用,返回 list[list[float]],需与索引时同一模型、同一归一化方式
    raise NotImplementedError

def cosine(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    na = sum(x * x for x in a) ** 0.5
    nb = sum(y * y for y in b) ** 0.5
    return dot / (na * nb + 1e-9)

def dump_scores(eval_path, corpus, out_path, export_topk=10):
    rows = []
    corpus_vec = {cid: embed([txt])[0] for cid, txt in corpus.items()}
    for line in open(eval_path, encoding="utf-8"):
        item = json.loads(line)
        qv = embed([item["query"]])[0]
        scored = sorted(
            ((cid, cosine(qv, vec)) for cid, vec in corpus_vec.items()),
            key=lambda x: x[1], reverse=True)[:export_topk]
        expected = set(item["expected_chunk_ids"])
        for rank, (cid, score) in enumerate(scored, 1):
            rows.append({
                "qid": item["qid"],
                "rank": rank,
                "chunk_id": cid,
                "score": round(score, 4),
                "is_expected": cid in expected,
                "should_recall": item["should_recall"],
            })
    with open(out_path, "w", encoding="utf-8") as f:
        for r in rows:
            f.write(json.dumps(r, ensure_ascii=False) + "\n")

输出是 JSONL,每行一个“查询—片段—分数”三元组,字段包含 qid、rank、chunk_id、score、is_expected、should_recall。验证方式很直接:先挑一条明确该召回的 query,看导出文件里 rank=1 附近的片段是不是你标注的那个,确认语料 id 与向量没有串位;再挑一条负例,看前 10 条里有没有出现期望为空却分数很高的片段。

按分数区间统计命中与误召比例

拿到分布后再切区间,不要拍一个阈值。区间划分有两种常见方式:如果 embedding 输出已做归一化,分数分布通常集中在一段窄区间里,先看实际最小值和最大值,再按 0.05 或 0.1 等宽分档;如果分布很散,就按分位数切,保证每档里有足够样本。统计时对每一档数两类条目:is_expected 为 true 的(这一档里的真实命中)和 false 的(这一档里的噪声)。

分数区间期望片段条数非期望片段条数该档占比说明
[0.90, 1.00]填入填入填入通常噪声最少,适合做严格阈值
[0.80, 0.90)填入填入填入常出现命中与噪声混在一起
[0.70, 0.80)填入填入填入多为边界样本
< 0.70填入填入填入一般可直接截断

读表的方式是找“期望片段还不少、非期望片段开始变多”的那一档边界。如果高分区已经混入大量非期望片段,说明分数分辨力有限,此时提高阈值只能减少噪声,同时也会削掉一部分真实命中,需要结合 top-k 一起取舍,而不是单独把阈值拉高。

EmbeddingGemma 2 搭 RAG 检索层 / 相似度阈值要靠标注样本来定

把阈值与 top-k 组合进检索函数并回测

单看分数分布还不够,要把它落到检索函数里跑一遍评估集,才知道这组参数实际返回了什么。检索函数骨架如下,先用 top-k 截断,再用阈值过滤。

def retrieve(query, threshold, topk, corpus_vec):
    qv = embed([query])[0]
    scored = sorted(
        ((cid, cosine(qv, vec)) for cid, vec in corpus_vec.items()),
        key=lambda x: x[1], reverse=True)[:topk]
    return [(cid, s) for cid, s in scored if s >= threshold]

def backtest(eval_items, threshold, topk, corpus_vec):
    hit = miss = false_hit = 0
    total_len = 0
    for item in eval_items:
        got = retrieve(item["query"], threshold, topk, corpus_vec)
        total_len += len(got)
        ids = {cid for cid, _ in got}
        if item["expected_chunk_ids"]:
            if ids & set(item["expected_chunk_ids"]):
                hit += 1
            else:
                miss += 1
        elif ids:
            false_hit += 1
    return {"hit": hit, "miss": miss, "false_hit": false_hit,
            "avg_len": round(total_len / len(eval_items), 2)}

回测口径要事先写死:命中指返回结果与 expected_chunk_ids 有交集;漏召指该召回但没召回到;负例误召指 should_recall 为 false 的样本却返回了非空结果。至少跑两组参数对照,一组偏严格、一组偏宽松,看它们各自牺牲了什么。

EmbeddingGemma 2 搭 RAG 检索层 / 相似度阈值要靠标注样本来定
参数组合阈值top-k命中样本数漏召样本数负例误召数平均返回条数
A(偏严格)填入填入填入填入填入填入
B(偏宽松)填入填入填入填入填入填入

选择依据是业务容忍度:如果下游要把片段塞进提示词,平均返回条数直接影响上下文长度,偏严格更合适;如果下游还有一轮重排,宽松一点先把候选捞全也没问题。这里没有通用最优值,只有在你评估集口径下更匹配的那一组。

记录阈值在不同语料上的漂移

同一套阈值换到另一份语料上,分数区间几乎一定会移动:语料变长、变专业、换语言或换切分粒度,都会让相似度整体上移或下移。做法是保留原评估集和记录表,换语料后按同样流程重跑一遍召回导出,对比区间的变化再决定是否沿用。

语料分数最小值中位数分数最大值原阈值下命中情况备注
语料 A(原)填入填入填入填入阈值标定基准
语料 B(换领域/换语言)填入填入填入填入记录区间是否整体平移

如果换语料后原阈值下的误召明显增多,说明该语料的相似度整体被高估,需要上调阈值或缩小 top-k;反之命中大幅下降则要下调。记录表建议连同模型版本、切分参数、归一化方式一起写进注释——换了 embedding 模型版本而不重跑分布,之前的阈值就失去意义。阈值可以跨查询复用,但不能跨语料照搬。