"要不要拒答"是 RAG 上线后最贵的决策:答错了比不答更伤信任。但大多数团队的做法是给一个全局阈值,比如"相似度低于 0.7 就不答"——然后发现换个 embedding 模型全线失效,换个库阈值完全不对。
问题不在阈值本身,在于把绝对分数当成置信度。余弦相似度是相对的:它在不同模型、不同语料、不同查询类型下都不可比。置信评分要做的是在单次查询内部构造可比的信号。
1. 可用信号:五个来源
| 信号 | 从哪里取 | 表达什么 | 稳定性 |
|---|---|---|---|
| 向量相似度分布 | top-k 的分数与离散度 | 是否"有且只有一个明显最优" | 中,依赖模型 |
| BM25 分数 | 关键词召回分 | 字面命中强度 | 中,随查询长度变 |
| rerank 分数 | 交叉编码器输出 | 查询与片段的细粒度匹配 | 高(同模型内) |
| 多路召回一致性 | 不同路的 top-k 交集大小 | 两路是否互相印证 | 高 |
| 命中 chunk 数 | 超过阈值的条数 | 证据是否充足 | 高 |
def signals(hits_vec, hits_bm25, rerank_scores):
vec = [h["score"] for h in hits_vec]
return {
"top1": vec[0] if vec else 0.0,
"gap_1_5": (vec[0] - vec[-1]) if len(vec) >= 5 else 0.0,
"bm25_top1": hits_bm25[0]["score"] if hits_bm25 else 0.0,
"rerank_top1": rerank_scores[0] if rerank_scores else 0.0,
"overlap": len({h["id"] for h in hits_vec[:10]} &
{h["id"] for h in hits_bm25[:10]}),
}
这五个信号里,rerank 分数与多路一致性最可靠,因为它们的语义不随查询长度漂移。
2. 绝对阈值为什么不可跨库复用
| 变更 | 对 top1 相似度的影响 | 对固定阈值的后果 |
|---|---|---|
| 换 embedding 模型 | 整体分布平移(可能 ±0.1) | 全部拒答或全部放行 |
| 换相似度度量(cos / ip) | 量纲与范围改变 | 阈值无意义 |
| 语料变化(同质化) | 分数整体抬高 | 噪声也被当成高置信 |
| 查询变短 | 分布变窄,差异变小 | 最优与次优难以区分 |
| 归一化方式变化 | 见上 | 同上 |
所以正确做法是只用相对量:
| 相对量 | 计算 | 含义 |
|---|---|---|
| top1 − top5 差距 | s1 - s5 |
是否有一个明显胜出者 |
| top1 / mean(top-k) | 比值 | 相对突出程度 |
| z-score of top1 | (s1 − μ) / σ |
距本次结果群均值几个标准差 |
| 双路交集 | ` | A∩B |
def relative_conf(scores):
mu = sum(scores) / len(scores)
sd = (sum((s - mu) ** 2 for s in scores) / len(scores)) ** 0.5 or 1e-9
gap = scores[0] - scores[-1]
z = (scores[0] - mu) / sd
return {"gap": round(gap, 4), "z": round(z, 3), "ratio": round(scores[0] / (mu or 1e-9), 3)}
z 是最有用的一个:它回答"第一名比同批结果突出多少",而这个问题在换模型后依然有意义。
3. 用 reranker 分数做判定
交叉编码器同时看查询和片段,输出的是"这个片段是否能回答这个问题"的直接判断,比向量相似度更接近我们要的东西。
| 分数来源 | 语义 | 用来做什么 |
|---|---|---|
| 向量相似度 | 主题相关 | 召回 |
| BM25 | 字面命中 | 召回 |
| rerank | 能否回答 | 判定阈值 |
实践里推荐这样的分层:
def decide(rerank_top1, gap, overlap, cfg):
if rerank_top1 >= cfg["answer"] and (gap >= cfg["gap"] or overlap >= 2):
return "answer" # 直接答
if rerank_top1 >= cfg["hedge"]:
return "answer_with_caveat" # 带提示答
return "refuse" # 拒答 + 转人工
| 动作 | 触发条件 | 输出形态 |
|---|---|---|
| 直接答 | 高分 + 有明显最优 + 双路印证 | 正常回答 + 引用 |
| 带提示答 | 高分但证据单薄 | 回答 + "以下内容依据有限,请核对原文" |
| 拒答 | 分数低于下限 | 说明缺什么 + 转人工入口 |
三档比两档重要:只有"答/不答"会把大量"勉强能答"的请求直接丢弃,用户体验比答错更差。
4. 阈值标定:用标注集画 PR 曲线
阈值不可拍脑袋,只能标定。做法是把标注集上的样本按置信度排序,扫一遍阈值,看精度与覆盖率的取舍。
def pr_curve(samples, ths):
"""samples: [{'conf': float, 'correct': bool}]"""
rows = []
for t in ths:
sel = [s for s in samples if s["conf"] >= t]
if not sel:
continue
prec = sum(s["correct"] for s in sel) / len(sel)
rows.append({"th": t, "coverage": len(sel) / len(samples), "precision": prec})
return rows
典型输出(示意,非基准):
th=0.40 coverage=0.94 precision=0.71
th=0.55 coverage=0.86 precision=0.82
th=0.68 coverage=0.71 precision=0.91
th=0.80 coverage=0.45 precision=0.96
选点原则不是"精度最高",而是在可接受的精度下限上取最大覆盖率。比如业务能容忍 8% 的错答,就取 th=0.55;如果错答代价很高,取 0.68 并接受 71% 的覆盖率。
| 业务约束 | 选点策略 |
|---|---|
| 错答代价高(法务、医疗) | 精度优先,接受低覆盖 + 高转人工 |
| 错答代价低(闲聊、探索) | 覆盖优先 |
| 有转人工兜底 | 可适当降低阈值 |
| 无兜底 | 阈值必须偏严 |
5. 阈值之外:这些情况分数再高也别信
置信度是统计量,它识别不出结构性问题。下面几种情况,分数很高但答案很可能是错的:
| 情况 | 为什么分数高 | 加什么信号 |
|---|---|---|
| 语料里只有旧版本 | 旧文档与查询高度匹配 | 版本/时效字段过滤 |
| 问题是"是否支持"类 | 相关文档多,但没有肯定答复 | 需要"明确表述"判定 |
| 用户问题自带前提(假设已支持 X) | 文档提到 X,但语境相反 | 否定与让步词检测 |
| 多跳问题(A 依赖 B) | 只命中第一跳 | 命中数 + 实体覆盖 |
所以最终的路由函数里,版本与时效是硬过滤,不参与打分:
def hard_filter(hits, now):
return [h for h in hits
if not h.get("effective_to") or h["effective_to"] > now]
def final_action(hits, conf, cfg):
if not hits:
return "refuse" # 硬过滤后为空,与置信度无关
return decide(conf["top1"], conf["gap"], conf["overlap"], cfg)
顺序很重要:先硬过滤,再算置信度。反过来会得到"过滤后为空但置信度高"这种矛盾状态。
6. 线上监控:置信度分布本身要盯
单看拒答率不够,要看分布的形态。分布整体左移说明数据或模型变了,分布变窄说明区分度下降。
| 监控项 | 异常信号 | 可能原因 |
|---|---|---|
| 置信度分布的分位数 | p50 左移 | 语料变化 / 模型切换 |
| 拒答率 | 突增或骤降 | 阈值失效 / 检索坏了 |
| 三档动作占比 | "带提示"占比暴涨 | 系统在退化成"什么都说一点" |
| 双路交集均值 | 下降 | 某一路召回坏了 |
| 人工复核错答率 | 上升 | 阈值需要重标 |
def dist_snapshot(confs):
s = sorted(confs)
n = len(s)
return {f"p{q}": round(s[min(int(n * q / 100), n - 1)], 3) for q in (10, 50, 90, 99)}
把每天的快照存下来,就能画出趋势;只有一张 PR 曲线是发现不了漂移的。
7. 一个完整的实现骨架
CONF_CFG = {"answer": 0.68, "hedge": 0.50, "gap": 0.02,
"z": 1.2, "min_overlap": 2}
def score_and_route(q, vec_hits, bm25_hits, reranker, cfg=CONF_CFG):
sig = signals(vec_hits, bm25_hits, [])
cand = vec_hits[:20]
rerank_scores = reranker.score(q, [c["text"] for c in cand])
order = sorted(range(len(cand)), key=lambda i: -rerank_scores[i])
rerank_scores = [rerank_scores[i] for i in order]
cand = [cand[i] for i in order]
rel = relative_conf(rerank_scores)
sig.update(rel)
sig["rerank_top1"] = rerank_scores[0]
action = decide(rerank_scores[0], rel["gap"], sig["overlap"], cfg)
return {"action": action, "conf": round(rerank_scores[0], 3), "signals": sig, "hits": cand}
返回 signals 是刻意的:置信度低时必须能解释为什么低,否则调阈值只能靠猜。
8. 排错:五种"置信度不管用"
| 现象 | 原因 | 处理 |
|---|---|---|
| 换模型后全部拒答 | 用了绝对相似度阈值 | 改相对量或改用 rerank 分数 |
| 全部放行、错答率高 | 阈值来自别的库 | 用本库标注集重标 |
| 简单问题也被拒答 | gap 要求过严(正确答案分散在多块) |
用"命中数"替代"单一最优" |
| 带提示答占比九成 | hedge 区间过宽 |
收窄,靠 rerank 分数细分 |
| 线上比线下差很多 | 标注集与线上分布不同 | 混入日志样本重标 |
第三条很常见:问题需要三块证据时,"有一个明显最优"本就不成立,此时该看的是命中条数与双路一致性。
9. 边界
| 场景 | 做法 |
|---|---|
| 只有单一召回通路 | 放弃 overlap 信号,靠 rerank + gap |
| 没有 reranker | 用 z-score + 命中数,阈值必须重标 |
| 需要可解释的判定 | 记录全部 signals 到日志并保留 |
| 多语言 | 按语言分别标定,分布差异大 |
| 查询含具体数值 | 加"数值命中"作为额外信号 |
最后一条值得单列:答案里出现用户问的那个数字,比任何相似度都更能说明"找对了",这是最好用的一个硬信号。
10. 自测
- 余弦相似度为什么不适合当绝对阈值用?举两个让它失效的变更。
- z-score 在置信评分里回答的是什么问题?
- 为什么判定阈值更适合用 rerank 分数而不是向量相似度?
- 三档动作相比两档,解决了什么问题?
- PR 曲线选点时,除了精度还该看什么?
- 问题需要多块证据时,
gap信号为什么会误判?