2563 字
约 8 分钟
13
S20-X09 · 置信评分设计:从相似度到三种应答动作

"要不要拒答"是 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. 自测

  1. 余弦相似度为什么不适合当绝对阈值用?举两个让它失效的变更。
  2. z-score 在置信评分里回答的是什么问题?
  3. 为什么判定阈值更适合用 rerank 分数而不是向量相似度?
  4. 三档动作相比两档,解决了什么问题?
  5. PR 曲线选点时,除了精度还该看什么?
  6. 问题需要多块证据时,gap 信号为什么会误判?
S20-X09 · 置信评分设计:从相似度到三种应答动作
https://www.clxhxhhr.top/posts/5684/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。