相似度阈值卡多少、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 调用按你实际用的本地推理或服务端接口替换。
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 一起取舍,而不是单独把阈值拉高。
把阈值与 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 的样本却返回了非空结果。至少跑两组参数对照,一组偏严格、一组偏宽松,看它们各自牺牲了什么。
| 参数组合 | 阈值 | top-k | 命中样本数 | 漏召样本数 | 负例误召数 | 平均返回条数 |
|---|---|---|---|---|---|---|
| A(偏严格) | 填入 | 填入 | 填入 | 填入 | 填入 | 填入 |
| B(偏宽松) | 填入 | 填入 | 填入 | 填入 | 填入 | 填入 |
选择依据是业务容忍度:如果下游要把片段塞进提示词,平均返回条数直接影响上下文长度,偏严格更合适;如果下游还有一轮重排,宽松一点先把候选捞全也没问题。这里没有通用最优值,只有在你评估集口径下更匹配的那一组。
记录阈值在不同语料上的漂移
同一套阈值换到另一份语料上,分数区间几乎一定会移动:语料变长、变专业、换语言或换切分粒度,都会让相似度整体上移或下移。做法是保留原评估集和记录表,换语料后按同样流程重跑一遍召回导出,对比区间的变化再决定是否沿用。
| 语料 | 分数最小值 | 中位数 | 分数最大值 | 原阈值下命中情况 | 备注 |
|---|---|---|---|---|---|
| 语料 A(原) | 填入 | 填入 | 填入 | 填入 | 阈值标定基准 |
| 语料 B(换领域/换语言) | 填入 | 填入 | 填入 | 填入 | 记录区间是否整体平移 |
如果换语料后原阈值下的误召明显增多,说明该语料的相似度整体被高估,需要上调阈值或缩小 top-k;反之命中大幅下降则要下调。记录表建议连同模型版本、切分参数、归一化方式一起写进注释——换了 embedding 模型版本而不重跑分布,之前的阈值就失去意义。阈值可以跨查询复用,但不能跨语料照搬。