RAG召回率:分母怎么定

黎 浩然/ 15 10 月, 2026/ 大语言模型/LARGELANGUAGEMODEL/LLM, 机器学习/MACHINELEARNING, 研究生/POSTGRADUATE, 计算机/COMPUTER/ 0 comments

两份RAG评测报告都写Recall@2,一份是44.44%,另一份却是40%,不一定是谁算错了。逐问题先算再平均,与把命中数和相关项数合并后相除,给问题的权重不同。要判断改进,先把分母、标注单位和测量位置说清楚。

文章目录
  1. 先把一个问题的分母固定下来
  2. 同一批结果,先手算两种平均
  3. 相关项比k多时,满分本来就不可达
  4. “命中一条”与“全部覆盖”不是一回事
  5. 一段能运行的评分参考
  6. 空标注里,可能藏着两种不同情况
  7. 测量位置要对齐:召回池不等于最终上下文
  8. 切分变化后,旧分母未必还能直接用
  9. 怎样让下一份报告可比较
  10. 参考资料

同一组数据可以把这些口径拆开。下面的本地评分函数会明确拒绝空标注,并区分“找到一条相关材料”和“找齐已标注材料”。它补充本站的RAG评测与错误定位,不把离线召回率当作回答正确率。Read in English。

先把一个问题的分母固定下来

信息检索教材把召回率定义为已找回相关项占全部相关项的比例。用于前k条结果时,记已审核的相关集合为G,前k个不重复结果的集合为T,则本篇使用:

Recall@k = |T ∩ G| / |G|, |G| > 0

这里的G是当前评测标注集合,而不是凭空知道的“世界上全部相关资料”。如果标注只覆盖有限语料和一部分判断,报告应说明这个范围。测得0.8,只能先解释为当前标注下找回了八成相关项,不能自动推成知识库八成问题都能回答。

k限制的是拿出来检查的排名前缀,分母仍是相关集合大小,不是k。把命中数除以k通常在描述另一类指标,不能因为结果也在0到1之间,就把它叫召回率。

同一批结果,先手算两种平均

下面三个问题都有非空、经确认的相关集合。所有文档标识与顺序均为虚构;每个问题取前2条。A有3个相关项,只命中1个;B有1个相关项并命中;C有1个相关项但未命中。

问题 相关项数 前2条命中数 Recall@2
A 3 1 1/3
B 1 1 1
C 1 0 0

逐问题平均得到(1/3+1+0)/3=4/9,约44.44%。合并统计则是(1+1+0)/(3+1+1)=2/5,即40%。两者使用完全相同的检索输出,差别来自权重。

逐问题:A/B/C
A 33.3%
B 33.3%
C 33.3%
合并统计:A/B/C
A 60.0%
B 20.0%
C 20.0%
原创权重图:逐问题等权与按已标注相关项数量加权;数据仅为教学例子。

逐问题平均让每个问题贡献相同权重;合并统计让相关项多的问题贡献更大。并不存在不看用途就永远更正确的一种。面向典型问题的体验,等权口径容易解释;如果业务确实按相关项覆盖计量,合并口径也有意义,但比较两次实验时必须保持一致。

相关项比k多时,满分本来就不可达

若前k条不重复,而一个问题有|G|个相关项,那么Recall@k的上限为min(k,|G|)/|G|。A有3个相关项,k只有2;即使前两条全都相关,也只能得到2/3,all-hit仍为0。代码增加了这个理想排序边界的断言。

这不是给漏检找理由,而是说明同一k下,不同问题的任务难度可能不同。比较两批问题时,如果相关项数量分布变化,分数也可能变化。应保留这个分布,或在相同问题集上逐项比较,不能仅凭总分下降就认定检索器退步。

同理,合并召回等于按相关项数加权的逐问题召回:每个问题权重为|Gᵢ|/Σ|Gⱼ|。把这个权重写进报告,就能解释为什么A在合并口径中占60%,在等权口径中只占1/3;它并不是另一次检索实验。

“命中一条”与“全部覆盖”不是一回事

仍看三个问题:A和B至少命中一条,所以任意命中的问题占2/3;只有B覆盖了全部已标注相关项,因此全覆盖的问题占1/3。它们又与44.44%和40%不同,因为每个数字回答的问题不同。

本篇将这两项明确记为any-hit与all-hit统计,不假定各个工具对“命中率”这个名字采用相同定义。特别是需要多条证据的问题,找到一条相关段落,不代表已经具备回答所需的全部依据。

反过来,all-hit也不必然等于“回答条件齐全”。标注集合可能同时包含多份可替代资料,找齐每份并非业务所必需。若问题需要A与B同时出现,或者A、B任一即可,应另建证据要求,不能把普通相关集合悄悄当成完整逻辑条件。

一段能运行的评分参考

代码使用稳定标识去重,本例对重复标识直接报错,要求上游先明确去重策略;相关集合为空也报错,避免静默除零或把未知当失败。它只对已审核、非空相关集合评分,不调用检索器或生成模型。

import math

def score_query(ranked, gold, k):
    if type(k) is not int or k <= 0:
        raise ValueError("k must be a positive integer")
    if len(ranked) != len(set(ranked)):
        raise ValueError("deduplicate stable IDs before scoring")
    gold = set(gold)
    if not gold:
        raise ValueError("nonempty reviewed relevance labels required")
    hits = len(set(ranked[:k]) & gold)
    return {
        "hits": hits,
        "relevant": len(gold),
        "recall": hits / len(gold),
        "any_hit": int(hits > 0),
        "all_hit": int(hits == len(gold)),
    }

# All identifiers and rankings are invented teaching data.
cases = [
    (["a", "x", "b", "c"], {"a", "b", "c"}),
    (["x", "d"], {"d"}),
    (["x", "y"], {"e"}),
]
rows = [score_query(ranked, gold, 2) for ranked, gold in cases]
macro = sum(r["recall"] for r in rows) / len(rows)
pooled = sum(r["hits"] for r in rows) / sum(r["relevant"] for r in rows)
any_hit = sum(r["any_hit"] for r in rows) / len(rows)
all_hit = sum(r["all_hit"] for r in rows) / len(rows)
assert math.isclose(macro, 4 / 9)
assert math.isclose(pooled, 2 / 5)
assert math.isclose(any_hit, 2 / 3)
assert math.isclose(all_hit, 1 / 3)
assert score_query([], {"a"}, 2)["recall"] == 0
assert score_query(["a"], {"a", "b"}, 5)["recall"] == 0.5
for ranked, gold, k in [(["a", "a"], {"a"}, 2),
                         (["a"], set(), 2),
                         (["a"], {"a"}, 0)]:
    try:
        score_query(ranked, gold, k)
    except ValueError:
        pass
    else:
        raise AssertionError("invalid evaluation input accepted")
print("macro=%.6f pooled=%.6f any=%.6f all=%.6f" %
      (macro, pooled, any_hit, all_hit))

best_at_two = score_query(["a", "b"], {"a", "b", "c"}, 2)
assert math.isclose(best_at_two["recall"], 2 / 3)
assert best_at_two["all_hit"] == 0
weights = [r["relevant"] / sum(v["relevant"] for v in rows) for r in rows]
assert math.isclose(sum(w * r["recall"] for w, r in zip(weights, rows)), pooled)

输出为macro=0.444444、pooled=0.400000、any=0.666667、all=0.333333。断言还覆盖空返回、返回不足k条、重复标识、空标注与非法k。所有代码已在本地执行;数字是教学数据的精确计算,没有代表任何真实RAG系统的成绩。

如果批次没有可评分的问题,平均值同样没有定义,不应补成0。生产评测器应返回明确状态,同时报告被纳入与被排除的数量和原因;这比让仪表盘始终显示一个数更容易检查。

空标注里,可能藏着两种不同情况

一种情况是问题尚未审核,暂时不知道哪些项相关;另一种是确认固定语料没有可用答案。两者都可能在数据里表现为空集合,但不能据此走同一个统计分支。前者需要补标注,后者可能进入拒答或无答案检测评测。

因此,评测数据最好独立保存标注状态,而不是只保存一个列表。未审核、已审核且有相关项、已审核且无答案,应该能区分。本文函数拒绝空集合,是把这个决定留在调用它的评测流程中,不是宣称所有无答案问题都应删除。

标注不全还可能让新方案吃亏。旧方案常找到的文档被标过,新方案找到的新材料却未审核,若一律当作不相关,分数未必反映真实变化。保留未判断状态,抽查新增结果,并让比较方案共享更新后的标注快照,比单纯争论哪个分数高更有意义。

测量位置要对齐:召回池不等于最终上下文

一条RAG流水线可能先取100条候选,再重排为10条,最后按长度只放入5段上下文。如果一份报告统计候选池,另一份统计最终上下文,即使都叫Recall@k,也可能不在测同一对象。

1
固定语料与标注
2
候选召回
3
重排与过滤
4
最终模型上下文
原创测量位置图:比较分数前,先明确统计的是哪一阶段的结果。

在每个测量点记录实际有序标识,再用同一份标注与相同k计算。若候选池已经缺证据,检查召回与过滤;若候选池有而最终上下文没有,查看排序、去重和长度预算。单看最后一个总分,容易把不同阶段的问题混在一起。

只改变排序而候选集合不变时,全池集合召回不变,但固定较小k的Recall@k可能改变。理解重排本身,可接着看Cross-Encoder重排原理;本篇关注的是如何把测量口径固定住。

切分变化后,旧分母未必还能直接用

把同一篇文档切成更多片段,会改变检索单位。如果旧标注按文档,新报告却按片段计数,命中数和分母都失去了原来的含义。评测前应说明标识代表文档、片段,还是某个具体证据点,并在同一次比较中保持一致。

重叠片段尤其容易制造重复覆盖:三段几乎相同的文本,不应未经定义就算成三个独立证据点。可以按稳定文档标识汇总,也可以维护经过审核的片段级标注,选哪一种取决于你要回答的问题。任何映射都应在评分前完成并留下版本。

调整k也需要边界。对于同一有序结果的嵌套前缀、固定相关集合,增大k不会降低集合召回;但如果每个k都触发不同检索策略,结果并非同一前缀,就不能直接套这个性质。更高召回也不自动抵消延迟、冗余上下文和生成质量的变化。

怎样让下一份报告可比较

至少保留问题集与语料快照、标注状态及单位、测量阶段、k、去重规则、逐问题结果和聚合方式。总分旁边列出可评分问题数、排除数量与原因,让读者知道分母实际由什么组成。

如果新方案只在某类问题上改善,就分组查看,同时保留同一批问题上的逐项对照。不要只展示更好看的聚合方式,也不要在比较时悄悄移除难例。统计不确定性需要另行处理,不能把这三个教学问题的精确算术误当成稳定的总体结论。

召回率的价值在于定位“哪些已知相关材料没有进入指定阶段”。把这个问题说清楚,再结合证据是否足够、回答是否忠实和实际成本,才更容易判断哪项修改值得保留。

参考资料

Introduction to Information Retrieval:集合召回定义;排名结果中的固定截断评估。本文的三问题数据、聚合推导、图形与代码为原创教学示例,未运行线上检索实验。

支持

如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。

微信支持二维码,点击查看大图
微信
支付宝支持二维码,点击查看大图
支付宝
Buy Me a Coffee,点击支持本站
Buy Me a Coffee

二维码可点击放大。更多支持方式见支持页面。

Share this Post

Leave a Comment

您的邮箱地址不会被公开。 必填项已用 * 标注

*
*