从检索排名到 Recall 和 MRR:写一个口径固定的离线评分器

先把相关性判断、候选条数和测试分母固定下来,再比较排名。

1. 排名拿到之后,还缺一份能对照的答案

假设你正在做一个帮助文档助手。第一版检索返回三篇文档,第二版换了排序,又返回三篇。打开结果看一眼,两版似乎都有道理。要判断这次改动有没有帮助,先得说清楚:这条问题需要哪些文档?我们只给后续回答提供前几条?一次请求没返回结果,还算不算一次测试?

这篇教程从已经拿到的文档排名开始,用 Python 标准库写一个小评分器。它只算 Recall@2 和 MRR@2,并把容易让成绩变好看的错误写成测试。跑完你会得到每条 query 的明细、固定分母下的均值,以及被拒绝的非法输入。

实验包含六份原创虚构文档、五条中文问题和三十个二元相关性判断。A、B 都是作者手写的候选顺序。它们用于检验公式和输入协议,没有执行任何检索模型,也不能作为 FTS、RRF、EmbeddingGemma 或某个 RAG 系统的成绩。全文可离线复现,不需要账号、SDK、安装依赖或真实用户数据。

2. 先定信息需求,再把每个判断写下来

qrels 是 query-document relevance judgments 的常见简称,可以把它理解成“每条问题和每份文档之间的相关性表”。这里约定:文档能直接支持问题要求的操作,或明确支持其中一个子需求,就标 1;否则标 0。

例如 q2 问“有哪些导出格式,分别怎样导出”。d3 教 TXT 导出,d4 教 CSV 导出,两份都相关。d1 只讲下载目录,虽然也在操作文件,却不能回答导出问题,所以标 0。只找到 d3,覆盖了这个语料里一半的相关文档。

先写完整信息需求,再判断文档。遇到一句“导出怎么了”这样的模糊查询,要先界定场景或标记争议,不能等看见某个候选结果后,再修改需求使它显得相关。信息检索评估所需的文档集合、信息需求和相关性判断,可参见 Stanford《Introduction to Information Retrieval》8.1 节。

本例的三十个标签由作者为教学固定,没有独立标注员实验,也没有测过标注一致性。所有 0 都是明确判断,不是“没检查到就默认为 0”。这在六份文档的小库里能做到;真实大库往往只有部分文档经过判断,不能直接套用本例的穷尽分母。

图一:五条问题 × 六份文档。q1 留作 dev;q2–q5 是本例 test。每格都有明确的 0 或 1。
图一:五条问题 × 六份文档。q1 留作 dev;q2–q5 是本例 test。每格都有明确的 0 或 1。

3. 两项指标分别回答什么

Recall@k 回答“全部相关文档里,前 k 条找到了多少”。对一条有相关文档的 query,它等于 top-k 中不同相关文档的数量,除以该 query 在固定语料中的全部相关文档数量。分母不是返回条数,也不是 k。Recall 的集合定义见 教材 8.3 节。

RR@k 回答“前 k 条里的首个相关文档出现得早不早”。第一条相关,得 1;第二条才相关,得 1/2;前 k 条都没有相关文档,得 0。MRR@k 是固定 eligible query 集合上的 RR@k 算术平均。本例另把每条 Recall@k 做宏平均,每条 query 权重相同,而非把所有文档命中数合并后算一个比例。

一定保留 @k。第三条才命中,在 RR@2 中就是 0,不能拿未截断 RR 的 1/3 代替。ir-measures 的 RR 定义也明确列出 cutoff 和相关性阈值;本文只参考定义,没有安装该库或做跨实现对照。

只要命中一份相关文档就记 1,衡量的是这条 query 有没有成功命中,不是 Recall。q2 相关集合是 d3、d4,若前两条只有 d3,则 Recall@2=1/2,尽管它已经“命中过”。再放入 d4,Recall 才变成 1;只要 d3 仍在第一位,RR 就仍然是 1。两个指标关注不同问题,没必要为了让分数好看而互换名称。

图二:q2 的三种人工排名。绿色为相关文档,灰色为不相关文档;只改变首个命中位置会影响 RR,补齐第二份相关文档会影响 Recall。
图二:q2 的三种人工排名。绿色为相关文档,灰色为不相关文档;只改变首个命中位置会影响 RR,补齐第二份相关文档会影响 Recall。

4. 先写下缺结果和无答案的处理协议

评分器遍历预先固定的 test query ID,而不是遍历“这次成功返回的 query”。q4 明明有相关文档 d6,A 却缺了整条排名。我们将它视为空列表,两项得分都为 0,而且继续占据宏平均分母。显式提交空列表与完全漏交 query 的分数相同,但 missing_queries 会记录后者,方便区分系统没找到结果和流程漏跑。

q5 则不同:这六份文档根本没有注销账户的说明。其相关文档数为 0,不能做 0/0。本教程事先约定:逐 query 的 Recall 和 RR 都记 null,并从两个宏平均中共同排除。因此 total_queries=4,eligible_queries=3,no_relevant_queries=["q5"]。若所有 query 都没有相关文档,均值也为 null。

这是本文的评价协议,不是所有库的默认行为。q5 返回空列表还是胡乱返回 d1,在这两个宏平均里都不会体现。报告必须保留 q5 的 returned、missing 等明细;想考查“没有答案时能否保持克制”,需要另定适合该任务的评价,不要把这里的 null 当成系统通过了无答案测试。

图三:A 的 q4 缺结果仍记 0,q5 按预先约定排除。误删 q4 会把 Recall 均值从 1/2 抬到 3/4。
图三:A 的 q4 缺结果仍记 0,q5 按预先约定排除。误删 q4 会把 Recall 均值从 1/2 抬到 3/4。

5. 把实验数据保存到空目录

准备 Python 3.10 或以上版本;本次实际复现环境为 Python 3.12.14。建一个空目录,将下面四段 Python 分别保存为标明的文件名。源码只用标准库,同目录运行即可。文末还给出完整预期 JSON,保存为 expected.json 后可以做字节级输出对照。

fixture.py 把语料、查询、split、全部标签和候选排名放在一起,便于逐格审阅。公开的 dev/test 划分只演示“分母由事先指定的 query ID 决定”;所有数据在本文可见,不能声称这是隐藏测试集或证明了没有泄漏。

保存为 fixture.py:

# Invented documents and author-assigned exhaustive judgments, not user data.
CORPUS = {
 'd1': '下载操作指南:在设置的下载目录字段选择保存位置。',
 'd2': '下载故障指南:连接断开后可点击重试,重新下载文件。',
 'd3': '导出文本指南:点击导出并选择 TXT,保存纯文本副本。',
 'd4': '导出表格指南:点击导出并选择 CSV,保存表格副本。',
 'd5': '同步指南:关闭自动同步后可点击立即同步进行手动同步。',
 'd6': '界面指南:在外观设置中选择深色主题。',
}
QUERIES = {
 'q1': {'text': '怎样重新下载失败的文件?', 'split': 'dev'},
 'q2': {'text': '有哪些导出格式,分别怎样导出?', 'split': 'test'},
 'q3': {'text': '关闭自动同步后,怎样手动同步?', 'split': 'test'},
 'q4': {'text': '怎样切换到深色主题?', 'split': 'test'},
 'q5': {'text': '怎样注销账户?', 'split': 'test'},
}
# Each query-doc pair is deliberately judged. A 0 is an explicit judgment.
QRELS = {
 'q1': {'d1': 0, 'd2': 1, 'd3': 0, 'd4': 0, 'd5': 0, 'd6': 0},
 'q2': {'d1': 0, 'd2': 0, 'd3': 1, 'd4': 1, 'd5': 0, 'd6': 0},
 'q3': {'d1': 0, 'd2': 0, 'd3': 0, 'd4': 0, 'd5': 1, 'd6': 0},
 'q4': {'d1': 0, 'd2': 0, 'd3': 0, 'd4': 0, 'd5': 0, 'd6': 1},
 'q5': {'d1': 0, 'd2': 0, 'd3': 0, 'd4': 0, 'd5': 0, 'd6': 0},
}
RUNS = {
 'fixture_A': {'q2': ['d3','d1','d4'], 'q3': ['d2','d5'], 'q5': []},
 'fixture_B': {'q2': ['d1','d3','d4'], 'q3': ['d5','d2'], 'q4': ['d6'], 'q5': []},
 'all_empty': {q: [] for q, m in QUERIES.items() if m['split'] == 'test'},
}

6. 先拒绝含糊输入,再计算分数

evaluate 的第一段检查 k、query 集和 qrels。随后对每条选定的 query 验证整份排名:文档 ID 必须在语料中,同一文档不能重复。即便重复项落在 k 之后,也会拒绝整个 run,而非悄悄切掉它。本例采用严格约束,让调用方明确处理脏数据。

未知 query、混入 dev 的 query、未知文档和没有判断的 query-document 对都会报错。qrels 只接受整数 0、1,Python 的 True、False 虽然能参与整数运算,也不能充当本协议的标签。少一格与明确写 0 是两回事。

如果实际检索返回的是 chunk,先确定评价粒度。要按文档评价,就事先规定 chunk 到文档的映射、同文档如何合并、如何保持首次出现顺序,以及 k 在哪个层级截断。本文拒绝重复 doc ID,不替你临时选择去重规则。

实现使用 Fraction 保存精确分数,避免在小样例里把 5/6 四舍五入后比较。protocol_sha256 覆盖语料、query 文本与 split、qrels、k;它用于记录评价协议版本,不包含 run,也不证明没人见过测试集。接入真实系统时还需记录 run 内容、检索器配置、模型和索引版本、过滤规则等信息。

保存为 metrics.py:

"""Research-only, exhaustive binary qrels, no models or dependencies."""
from fractions import Fraction
import hashlib
import json


def evaluate(corpus, queries, qrels, run, split='test', k=2):
    if type(k) is not int or k < 1:
        raise ValueError('k must be a positive integer')
    if not corpus or not queries or not isinstance(run, dict):
        raise ValueError('nonempty corpus and queries, mapping run required')
    if set(qrels) != set(queries):
        raise ValueError('qrels must cover every query')
    selected = [q for q, meta in queries.items() if meta['split'] == split]
    if not selected:
        raise ValueError('split has no queries')
    if set(run) - set(selected):
        raise ValueError('unknown or wrong-split query')
    for q in queries:
        if set(qrels[q]) != set(corpus):
            raise ValueError('unjudged or unknown documents: exhaustive qrels required')
        if any(type(v) is not int or v not in (0, 1) for v in qrels[q].values()):
            raise ValueError('qrels labels must be integer 0 or 1')
    rows = []
    for q in selected:
        ranking = run.get(q, [])
        if not isinstance(ranking, list) or any(not isinstance(d, str) for d in ranking):
            raise ValueError('ranking must be a list of document IDs')
        if len(set(ranking)) != len(ranking):
            raise ValueError('duplicate document ID; deduplicate upstream explicitly')
        if set(ranking) - set(corpus):
            raise ValueError('unknown document ID')
        relevant = {d for d, rel in qrels[q].items() if rel == 1}
        top = ranking[:k]
        hits = len(set(top) & relevant)
        first = next((i for i, d in enumerate(top, 1) if d in relevant), None)
        rows.append(dict(query=q, missing=q not in run, returned=len(ranking),
                         relevant=len(relevant), hits=hits, first_relevant_rank=first,
                         recall=Fraction(hits, len(relevant)) if relevant else None,
                         rr=Fraction(1, first) if first else (Fraction(0) if relevant else None)))
    eligible = [r for r in rows if r['relevant'] > 0]
    def mean(key):
        return sum((r[key] for r in eligible), Fraction(0)) / len(eligible) if eligible else None
    canonical = json.dumps(dict(corpus=corpus, queries=queries, qrels=qrels, split=split, k=k),
                           sort_keys=True, ensure_ascii=False, separators=(',', ':'))
    return dict(protocol_sha256=hashlib.sha256(canonical.encode()).hexdigest(), split=split, k=k,
                total_queries=len(rows), eligible_queries=len(eligible),
                no_relevant_queries=[r['query'] for r in rows if not r['relevant']],
                missing_queries=[r['query'] for r in rows if r['missing']],
                recall_at_k=mean('recall'), mrr_at_k=mean('rr'), rows=rows)

7. 把错误算法作为负控一起跑

负控的作用是故意展示一个错误做法会怎样歪曲结果,再检查正式实现有没有挡住它。run_experiment.py 算三组人工排名,并保留三种错误计算:删除缺排名的 query,把 hit-rate 冒充 Recall,以及把重复文档按出现次数计算。

对 q2 的 [d3, d3],错误代码把同一份相关文档算两遍,得到 1;严格评分器直接拒绝该 run。这里不是说“重复输入应得几分”,而是明确告诉上游:先按约定清理输入,再来评价。输出中的 contract_rejections 同时记录未知文档、错误 split 和未判断 qrel 的拒绝原因。

保存为 run_experiment.py:

import copy
import json
from fractions import Fraction
from metrics import evaluate
from fixture import CORPUS, QUERIES, QRELS, RUNS

def score(run): return evaluate(CORPUS, QUERIES, QRELS, run)
results = {name: score(run) for name,run in RUNS.items()}
a = results['fixture_A']
present = [r for r in a['rows'] if r['relevant'] and not r['missing']]
wrong_dropped_missing = sum(r['recall'] for r in present)/len(present)
assert wrong_dropped_missing == Fraction(3,4) and a['recall_at_k']==Fraction(1,2)
wrong_hit_rate = Fraction(1) # q2 finds one of two relevant documents
correct_recall = a['rows'][0]['recall']
assert wrong_hit_rate != correct_recall == Fraction(1,2)
wrong_duplicate_recall = sum(QRELS['q2'][d] for d in ['d3','d3'])/2
assert wrong_duplicate_recall == 1
rejections={}
for name,run in {'duplicate':{'q2':['d3','d3']},'unknown_doc':{'q2':['d7']},'wrong_split':{'q1':['d2']}}.items():
    try: score(run)
    except ValueError as e: rejections[name]=str(e)
    else: raise AssertionError(name)
rels=copy.deepcopy(QRELS); del rels['q2']['d1']
try: evaluate(CORPUS,QUERIES,rels,RUNS['fixture_A'])
except ValueError as e: rejections['unjudged']=str(e)
else: raise AssertionError('unjudged')
output=dict(scope='Synthetic fixed rankings; metric implementation evidence, not model performance',
            results=results,
            negative_controls={'dropping_missing_wrong':wrong_dropped_missing,
                               'fixed_denominator_correct':a['recall_at_k'],
                               'hit_rate_mislabeled_as_recall_wrong':wrong_hit_rate,
                               'q2_recall_correct':correct_recall,
                               'duplicate_counting_wrong':wrong_duplicate_recall,
                               'contract_rejections':rejections})
print(json.dumps(output,default=lambda x:str(x) if isinstance(x,Fraction) else x,ensure_ascii=False,indent=2))

8. 用十八项测试把口径固定下来

测试不仅检查 A、B 的总分,也检查缺结果仍留在分母、没有相关文档时的 null、k 的截断、相关文档新增后两指标的差异,以及非法输入必须拒绝。这些断言防的是公式实现和数据契约回归,不验证某个检索系统的语义能力。

保存为 test_metrics.py:

import copy
import unittest
from fractions import Fraction as F
from metrics import evaluate
from fixture import CORPUS, QUERIES, QRELS, RUNS

class MetricsTests(unittest.TestCase):
    def score(self, run=None, **kw):
        return evaluate(CORPUS, QUERIES, QRELS, RUNS['fixture_A'] if run is None else run, **kw)
    def test_exact_A(self):
        r=self.score(); self.assertEqual((r['recall_at_k'],r['mrr_at_k']), (F(1,2),F(1,2)))
    def test_exact_B(self):
        r=self.score(RUNS['fixture_B']); self.assertEqual((r['recall_at_k'],r['mrr_at_k']), (F(5,6),F(5,6)))
    def test_missing_stays_denominator(self):
        r=self.score(); self.assertEqual((r['eligible_queries'],r['missing_queries']), (3,['q4']))
    def test_no_relevant_explicit(self):
        r=self.score(); self.assertEqual((r['total_queries'],r['no_relevant_queries']), (4,['q5'])); self.assertIsNone(r['rows'][-1]['recall'])
    def test_empty(self):
        r=self.score(RUNS['all_empty']); self.assertEqual((r['recall_at_k'],r['mrr_at_k']), (0,0))
    def test_duplicate_rejected(self):
        with self.assertRaisesRegex(ValueError,'duplicate'): self.score({'q2':['d3','d3']})
    def test_unknown_doc(self):
        with self.assertRaisesRegex(ValueError,'unknown document'): self.score({'q2':['d999']})
    def test_unknown_query(self):
        with self.assertRaisesRegex(ValueError,'query'): self.score({'q999':[]})
    def test_wrong_split(self):
        with self.assertRaisesRegex(ValueError,'split'): self.score({'q1':['d2']})
    def test_unjudged_not_nonrelevant(self):
        rel=copy.deepcopy(QRELS); del rel['q2']['d1']
        with self.assertRaisesRegex(ValueError,'unjudged'): evaluate(CORPUS,QUERIES,rel,RUNS['fixture_A'])
    def test_bool_label_rejected(self):
        rel=copy.deepcopy(QRELS); rel['q2']['d1']=False
        with self.assertRaisesRegex(ValueError,'integer'): evaluate(CORPUS,QUERIES,rel,{})
    def test_cutoff(self):
        r=self.score({'q2':['d1','d2','d3']}); self.assertEqual(r['rows'][0]['rr'],0)
    def test_recall_not_hit_rate(self):
        r=self.score({'q2':['d3']}); self.assertEqual(r['rows'][0]['recall'],F(1,2)); self.assertEqual(r['rows'][0]['rr'],1)
    def test_second_relevant_changes_recall_not_rr(self):
        a=self.score({'q2':['d3']})['rows'][0]; b=self.score({'q2':['d3','d4']})['rows'][0]
        self.assertEqual((a['rr'],b['rr']),(1,1)); self.assertEqual((a['recall'],b['recall']),(F(1,2),1))
    def test_k_validation(self):
        for k in [True,0,-1,1.5]:
            with self.assertRaises(ValueError): self.score(k=k)
    def test_empty_eligible_mean_is_null(self):
        queries={'q5':QUERIES['q5']}; r=evaluate(CORPUS,queries,{'q5':QRELS['q5']},{})
        self.assertIsNone(r['mrr_at_k']); self.assertEqual(r['eligible_queries'],0)
    def test_hash_changes_with_qrels(self):
        rel=copy.deepcopy(QRELS); rel['q2']['d1']=1
        self.assertNotEqual(self.score()['protocol_sha256'],evaluate(CORPUS,QUERIES,rel,{})['protocol_sha256'])
    def test_missing_qrels_rejected(self):
        rel=copy.deepcopy(QRELS); del rel['q4']
        with self.assertRaisesRegex(ValueError,'every query'): evaluate(CORPUS,QUERIES,rel,{})

if __name__=='__main__': unittest.main()

在这四个文件所在目录运行:

python -m unittest -v
python run_experiment.py > actual.json
python -c "from pathlib import Path; assert Path('actual.json').read_bytes() == Path('expected.json').read_bytes(); print('output matches')"

第三条命令需要先把下一节 JSON 保存成 expected.json,并保留文件末尾换行。前两条已经足以执行全部断言和生成结果。成功测试的结尾应是 Ran 18 tests 和 OK;出现失败时先看对应输入,不要为了通过测试改掉预期值。

9. 按 query 读结果,再看平均值

固定 k=2,A 的 q2 找到 d3,Recall=1/2、RR=1;q3 在第二位找到 d5,Recall=1、RR=1/2;q4 缺结果,两项都是 0。三个 eligible query 的均值分别是 (1/2+1+0)/3=1/2 和 (1+1/2+0)/3=1/2。

B 的 q2 把 d3 放到第二位,Recall 仍为 1/2,RR 退到 1/2。q3、q4 都在首位命中,所以两个均值都是 5/6。平均值变高,仍然有 q2 的首个命中退步;保留成对的逐 query 对照才能看见它。all_empty 的两个均值均为 0,分母仍然是三个有相关文档的 test query。

这些数字只说明两份手写排名在固定标签和协议下的算术关系。它们没有采样误差分析,没有代表真实流量的查询分布,也没有训练或真实检索过程,不能据此声称某个模型提高了质量。

下面是本次程序完整输出。Fraction 在 JSON 中写成字符串,null 是 JSON 空值,不是字符串 "null":

保存为 expected.json:

{
  "scope": "Synthetic fixed rankings; metric implementation evidence, not model performance",
  "results": {
    "fixture_A": {
      "protocol_sha256": "9dd1a576cc830736a0884e847845f31f2390843c27a250abf3bafaf8fa72cadb",
      "split": "test",
      "k": 2,
      "total_queries": 4,
      "eligible_queries": 3,
      "no_relevant_queries": [
        "q5"
      ],
      "missing_queries": [
        "q4"
      ],
      "recall_at_k": "1/2",
      "mrr_at_k": "1/2",
      "rows": [
        {
          "query": "q2",
          "missing": false,
          "returned": 3,
          "relevant": 2,
          "hits": 1,
          "first_relevant_rank": 1,
          "recall": "1/2",
          "rr": "1"
        },
        {
          "query": "q3",
          "missing": false,
          "returned": 2,
          "relevant": 1,
          "hits": 1,
          "first_relevant_rank": 2,
          "recall": "1",
          "rr": "1/2"
        },
        {
          "query": "q4",
          "missing": true,
          "returned": 0,
          "relevant": 1,
          "hits": 0,
          "first_relevant_rank": null,
          "recall": "0",
          "rr": "0"
        },
        {
          "query": "q5",
          "missing": false,
          "returned": 0,
          "relevant": 0,
          "hits": 0,
          "first_relevant_rank": null,
          "recall": null,
          "rr": null
        }
      ]
    },
    "fixture_B": {
      "protocol_sha256": "9dd1a576cc830736a0884e847845f31f2390843c27a250abf3bafaf8fa72cadb",
      "split": "test",
      "k": 2,
      "total_queries": 4,
      "eligible_queries": 3,
      "no_relevant_queries": [
        "q5"
      ],
      "missing_queries": [],
      "recall_at_k": "5/6",
      "mrr_at_k": "5/6",
      "rows": [
        {
          "query": "q2",
          "missing": false,
          "returned": 3,
          "relevant": 2,
          "hits": 1,
          "first_relevant_rank": 2,
          "recall": "1/2",
          "rr": "1/2"
        },
        {
          "query": "q3",
          "missing": false,
          "returned": 2,
          "relevant": 1,
          "hits": 1,
          "first_relevant_rank": 1,
          "recall": "1",
          "rr": "1"
        },
        {
          "query": "q4",
          "missing": false,
          "returned": 1,
          "relevant": 1,
          "hits": 1,
          "first_relevant_rank": 1,
          "recall": "1",
          "rr": "1"
        },
        {
          "query": "q5",
          "missing": false,
          "returned": 0,
          "relevant": 0,
          "hits": 0,
          "first_relevant_rank": null,
          "recall": null,
          "rr": null
        }
      ]
    },
    "all_empty": {
      "protocol_sha256": "9dd1a576cc830736a0884e847845f31f2390843c27a250abf3bafaf8fa72cadb",
      "split": "test",
      "k": 2,
      "total_queries": 4,
      "eligible_queries": 3,
      "no_relevant_queries": [
        "q5"
      ],
      "missing_queries": [],
      "recall_at_k": "0",
      "mrr_at_k": "0",
      "rows": [
        {
          "query": "q2",
          "missing": false,
          "returned": 0,
          "relevant": 2,
          "hits": 0,
          "first_relevant_rank": null,
          "recall": "0",
          "rr": "0"
        },
        {
          "query": "q3",
          "missing": false,
          "returned": 0,
          "relevant": 1,
          "hits": 0,
          "first_relevant_rank": null,
          "recall": "0",
          "rr": "0"
        },
        {
          "query": "q4",
          "missing": false,
          "returned": 0,
          "relevant": 1,
          "hits": 0,
          "first_relevant_rank": null,
          "recall": "0",
          "rr": "0"
        },
        {
          "query": "q5",
          "missing": false,
          "returned": 0,
          "relevant": 0,
          "hits": 0,
          "first_relevant_rank": null,
          "recall": null,
          "rr": null
        }
      ]
    }
  },
  "negative_controls": {
    "dropping_missing_wrong": "3/4",
    "fixed_denominator_correct": "1/2",
    "hit_rate_mislabeled_as_recall_wrong": "1",
    "q2_recall_correct": "1/2",
    "duplicate_counting_wrong": 1.0,
    "contract_rejections": {
      "duplicate": "duplicate document ID; deduplicate upstream explicitly",
      "unknown_doc": "unknown document ID",
      "wrong_split": "unknown or wrong-split query",
      "unjudged": "unjudged or unknown documents: exhaustive qrels required"
    }
  }
}

10. 换成自己的 qrels 时,先做这几件事

先选一个能认真判断的小语料快照,并给每份文档稳定 ID。收集希望系统支持的查询,写下它们的具体信息需求,包含常见问法、容易混淆的问法和语料没有答案的情况。不要只从某个系统已返回的结果里挑“看起来不错”的 query。

给判断者同时提供需求说明和文档正文,先定相关标准,再逐项记录标签及有争议的原因。小库可穷尽判断;大库做不到时,要明确哪些文档尚未判断,以及评价方法允许哪些推断,不能把未知自动改成 0 来凑齐本评分器。本例只支持穷尽二元 qrels,宁可报错,也不伪造可靠的 Recall 分母。

在看 test 结果之前固定 k、相关阈值、文档粒度、去重规则和无相关样本口径。k 应对应你真正会送给后续步骤的候选规模。调参用 dev,同一意图的近义改写尽量成组划分,以免几乎相同的问题分到两边。若不断看 test 再改系统,这批 query 已经参与开发,应找新的留出查询,而不是继续把它当作未见测试集。

然后把真实检索器输出转换成 run:query ID 映射到按顺序排列的文档 ID 列表。让每个候选系统使用完全相同的语料、标签、query 集和 k。为每次运行保存配置与结果版本,先核对 missing_queries 和每条明细,再比较均值。遇到某类 query 退步,回到需求和文档检查原因;不要仅因为总分更高就抹掉局部损失。

最后把能证明的范围写进报告。本教程已经实际执行评分程序和十八项测试;没有验证真实检索模型、查询分布代表性、标注一致性、统计泛化、答案生成或外部指标 SDK。把这段评分器接到真实系统之后,仍需分别验证这些问题。眼下得到的是一把口径可检查的尺子。

11. 检索返回的是块时,Recall@2 的 2 到底数什么

前面的评分器只收文档 ID,实际检索却可能返回 chunk。接入前先问:要衡量“前两块覆盖了哪些文档”,还是“将已有候选按文档合并后取前两篇”?两种协议都可以成立,候选预算必须写清楚。

沿用 q2 的导出问题,相关文档是 d3 和 d4。固定输入为 [d3a, d3b, d4a],前三个块依次映射到 d3、d3、d4。按首次出现合并为 [d3, d4],文档级 Recall@2=2/2=1。若流水线只提供前两块,合并后只有 d3,Recall@2=1/2。适配器不能补回从未收到的 d4。错误在于隐去预算变化,把两种输入当成同一协议比较。

q2 的三个块依次属于 d3、d3、d4。全部合并后取两篇,Recall 为 1;只接收前两块再合并,Recall 为 1/2。两条路径的候选预算不同。
图四:先说清楚 k 数块还是数文档。右侧是另一种候选预算,不能据此声称左侧检索质量更好。

q3 更能看清排名单位:[d1a, d1b, d5a] 中,只有 d5 相关。它在块排名的第三位,在合并后的文档排名中却是第二位,故 RR@2=1/2;只提供前两块时,RR@2=0。块位置不能直接充当文档位置。这些次序都是手写夹具,没有运行检索器。

12. 按首次出现合并,并保留每一步的来路

chunk_to_doc 由应用从同一语料快照提供,值必须对应 qrels 标注的文档 ID。不要从块名、标题或相同正文猜身份。LangChain 的父文档检索说明明确区分原始整篇与较大的父块;有 parent_id 还不足以证明它对应当前标签。Elasticsearch 的 collapse 文档也提醒,折叠前命中数与分组后的结果数不同。这里仅参考这些概念,没有调用或模拟两者。

适配器先检查整份输入,再生成结果。不同块属于同一文档很正常;同一个块 ID 重复出现则拒收。未知块、未使用但指向语料外的映射、最终截止位之后的重复块,也必须报错。每篇文档保留首块 ID、首块排名和全部贡献排名,缺失的 query 不补成空列表。

函数故意没有 k 参数。它只转换收到的候选,文档级截止位和 qrels 检查仍由前文的 evaluate 负责。adapter_sha256 记录映射及合并规则版本;原来的 protocol_sha256 不包含块映射。映射变了,即使评分器哈希未变,上游协议也已改变。哈希只能帮助发现变化,不能认证映射来源。

保存为 chunk_fixture.py:

"""Author-written rank/identity fixture; no actual chunker or retriever."""
CHUNK_TO_DOC = {
    'd3a': 'd3', 'd3b': 'd3', 'd4a': 'd4',
    'd1a': 'd1', 'd1b': 'd1', 'd5a': 'd5',
    'd6a': 'd6',
}
CHUNK_RUN = {
    'q2': ['d3a', 'd3b', 'd4a'],
    'q3': ['d1a', 'd1b', 'd5a'],
    # q4 is deliberately missing, not silently fabricated as returned.
    'q5': [],
}

保存为 chunk_adapter.py:

"""Synthetic offline adapter: first-occurrence document collapse, no retrieval."""
import hashlib
import json


def adapt_runs(corpus, queries, chunk_to_doc, chunk_runs, split='test'):
    """Validate all supplied ranks, then preserve first parent occurrence.

    `chunk_to_doc` must come from the same trusted corpus snapshot as qrels.
    IDs are exact strings. No prefix parsing, title matching or score sorting.
    This function intentionally has no k: metrics.evaluate applies document k.
    """
    if type(corpus) is not dict or not corpus:
        raise ValueError('nonempty corpus dictionary required')
    if any(type(d) is not str or not d for d in corpus):
        raise ValueError('corpus document IDs must be nonempty strings')
    if type(queries) is not dict or not queries:
        raise ValueError('nonempty query dictionary required')
    if type(split) is not str or not split:
        raise ValueError('split must be a nonempty string')
    for query, meta in queries.items():
        if type(query) is not str or not query or type(meta) is not dict:
            raise ValueError('query IDs and metadata are invalid')
        if type(meta.get('split')) is not str or not meta['split']:
            raise ValueError('query split must be a nonempty string')
    if type(chunk_to_doc) is not dict or type(chunk_runs) is not dict:
        raise ValueError('mapping and runs must be dictionaries')
    for chunk, doc in chunk_to_doc.items():
        if type(chunk) is not str or not chunk or type(doc) is not str or not doc:
            raise ValueError('chunk and document IDs must be strings')
        if doc not in corpus:
            raise ValueError('mapping points outside the corpus')
    selected = {q for q, meta in queries.items() if meta['split'] == split}
    if not selected:
        raise ValueError('split has no queries')
    if set(chunk_runs) - selected:
        raise ValueError('unknown or wrong-split query')
    # Complete validation before creating a consumable adapted run.
    for ranking in chunk_runs.values():
        if type(ranking) is not list:
            raise ValueError('chunk ranking must be a list')
        if any(type(c) is not str or c not in chunk_to_doc for c in ranking):
            raise ValueError('unknown or invalid chunk ID')
        if len(set(ranking)) != len(ranking):
            raise ValueError('duplicate chunk ID')
    doc_runs, evidence = {}, {}
    for query, ranking in chunk_runs.items():
        rows = {}
        for rank, chunk in enumerate(ranking, 1):
            doc = chunk_to_doc[chunk]
            if doc not in rows:
                rows[doc] = {'document_id': doc, 'first_chunk_id': chunk,
                             'first_chunk_rank': rank, 'chunk_ranks': []}
            rows[doc]['chunk_ranks'].append(rank)
        doc_runs[query] = list(rows)
        evidence[query] = {'received_chunks': len(ranking),
                           'distinct_documents': len(rows),
                           'documents': list(rows.values())}
    config = {'adapter': 'first-occurrence-document-v1',
              'chunk_to_doc': chunk_to_doc}
    raw = json.dumps(config, sort_keys=True, ensure_ascii=False,
                     separators=(',', ':')).encode('utf-8')
    return {'adapter_sha256': hashlib.sha256(raw).hexdigest(),
            'document_runs': doc_runs, 'evidence': evidence}

13. 逐条对照,再检查固定分母和完整预期

把本节文件和前文四个 Python 文件、expected.json 放在同一目录,原文件无需修改。q4 仍缺排名,在三个有相关文档的 test query 中记 0;q5 保持显式空列表,因无相关文档而得 null,不参加两项均值。

三块输入合并后,q2 的 Recall、RR 都为 1,q3 为 1、1/2,q4 为 0、0。因此宏平均 Recall@2=(1+1+0)/3=2/3,MRR@2=(1+1/2+0)/3=1/2。两块预算那份输入的均值分别是 (1/2+0+0)/3=1/6、(1+0+0)/3=1/3。它们是不同预算下的固定算术结果。Recall 的分母始终是相关文档数,见 Stanford 教材 8.3 节。

程序还实际演示两种错误:只替换 ID 会留下重复文档,被原评分器拒绝;按出现次数计数会把 d3 的两块误当成两篇相关文档。expected-chunk.json 是事先写定的字面预期,测试不会用当前程序的输出重新生成它。

保存为 probe_chunk_cutoff.py:

"""Print deterministic observations. Expected answers live in another file."""
import json
from fractions import Fraction
from chunk_adapter import adapt_runs
from chunk_fixture import CHUNK_TO_DOC, CHUNK_RUN
from fixture import CORPUS, QUERIES, QRELS
from metrics import evaluate


def summary(run):
    report = evaluate(CORPUS, QUERIES, QRELS, run, k=2)
    return {'recall_at_2': str(report['recall_at_k']),
            'mrr_at_2': str(report['mrr_at_k']),
            'missing_queries': report['missing_queries'],
            'eligible_queries': report['eligible_queries'],
            'rows': {row['query']: {'recall': None if row['recall'] is None else str(row['recall']),
                                     'rr': None if row['rr'] is None else str(row['rr']),
                                     'returned': row['returned']}
                     for row in report['rows']}}


def experiment():
    complete_pool = adapt_runs(CORPUS, QUERIES, CHUNK_TO_DOC, CHUNK_RUN)
    # A legitimate alternative candidate budget; do not claim equal input pools.
    two_chunk_input = {q: ranks[:2] for q, ranks in CHUNK_RUN.items()}
    truncated = adapt_runs(CORPUS, QUERIES, CHUNK_TO_DOC, two_chunk_input)
    # Merely replacing IDs leaves duplicate docs; the original evaluator refuses it.
    uncollapsed = {q: [CHUNK_TO_DOC[c] for c in ranks]
                   for q, ranks in CHUNK_RUN.items()}
    try:
        evaluate(CORPUS, QUERIES, QRELS, uncollapsed, k=2)
    except ValueError as error:
        raw_mapping = {'accepted': False, 'reason': str(error)}
    else:
        raise AssertionError('original evaluator unexpectedly accepted duplicate docs')
    # Deliberately wrong: count occurrences instead of distinct relevant docs.
    repeated_hits = sum(QRELS['q2'][CHUNK_TO_DOC[c]] for c in CHUNK_RUN['q2'][:2])
    relevant_count = sum(QRELS['q2'].values())
    return {'document_runs': complete_pool['document_runs'],
            'provenance': complete_pool['evidence'],
            'collapse_then_document_cutoff': summary(complete_pool['document_runs']),
            'two_chunk_budget_then_collapse': summary(truncated['document_runs']),
            'raw_id_replacement': raw_mapping,
            'naive_q2_duplicate_occurrence_recall': str(Fraction(repeated_hits, relevant_count))}


if __name__ == '__main__':
    print(json.dumps(experiment(), ensure_ascii=False, indent=2))

保存为 expected-chunk.json:

{
  "document_runs": {"q2": ["d3", "d4"], "q3": ["d1", "d5"], "q5": []},
  "provenance": {
    "q2": {"received_chunks": 3, "distinct_documents": 2, "documents": [
      {"document_id": "d3", "first_chunk_id": "d3a", "first_chunk_rank": 1, "chunk_ranks": [1, 2]},
      {"document_id": "d4", "first_chunk_id": "d4a", "first_chunk_rank": 3, "chunk_ranks": [3]}
    ]},
    "q3": {"received_chunks": 3, "distinct_documents": 2, "documents": [
      {"document_id": "d1", "first_chunk_id": "d1a", "first_chunk_rank": 1, "chunk_ranks": [1, 2]},
      {"document_id": "d5", "first_chunk_id": "d5a", "first_chunk_rank": 3, "chunk_ranks": [3]}
    ]},
    "q5": {"received_chunks": 0, "distinct_documents": 0, "documents": []}
  },
  "collapse_then_document_cutoff": {
    "recall_at_2": "2/3", "mrr_at_2": "1/2", "missing_queries": ["q4"], "eligible_queries": 3,
    "rows": {
      "q2": {"recall": "1", "rr": "1", "returned": 2},
      "q3": {"recall": "1", "rr": "1/2", "returned": 2},
      "q4": {"recall": "0", "rr": "0", "returned": 0},
      "q5": {"recall": null, "rr": null, "returned": 0}
    }
  },
  "two_chunk_budget_then_collapse": {
    "recall_at_2": "1/6", "mrr_at_2": "1/3", "missing_queries": ["q4"], "eligible_queries": 3,
    "rows": {
      "q2": {"recall": "1/2", "rr": "1", "returned": 1},
      "q3": {"recall": "0", "rr": "0", "returned": 1},
      "q4": {"recall": "0", "rr": "0", "returned": 0},
      "q5": {"recall": null, "rr": null, "returned": 0}
    }
  },
  "raw_id_replacement": {"accepted": false, "reason": "duplicate document ID; deduplicate upstream explicitly"},
  "naive_q2_duplicate_occurrence_recall": "1"
}

保存为 test_chunk_adapter.py:

import copy
import json
import unittest
from pathlib import Path
from chunk_adapter import adapt_runs
from chunk_fixture import CHUNK_TO_DOC, CHUNK_RUN
from fixture import CORPUS, QUERIES, QRELS
from metrics import evaluate
from probe_chunk_cutoff import experiment


class ChunkAdapterTests(unittest.TestCase):
    def adapt(self, run=None, mapping=None, corpus=None):
        return adapt_runs(CORPUS if corpus is None else corpus, QUERIES,
                          CHUNK_TO_DOC if mapping is None else mapping,
                          CHUNK_RUN if run is None else run)

    def test_exact_fixed_observations(self):
        expected = json.loads(Path(__file__).with_name('expected-chunk.json').read_text())
        self.assertEqual(experiment(), expected)

    def test_nonadjacent_parent_repeats_collapse(self):
        result = self.adapt({'q2': ['d3a', 'd4a', 'd3b']})
        self.assertEqual(result['document_runs']['q2'], ['d3', 'd4'])
        self.assertEqual(result['evidence']['q2']['documents'][0]['chunk_ranks'], [1, 3])

    def test_first_occurrence_not_sorted_document_id(self):
        result = self.adapt({'q2': ['d4a', 'd3a']})
        self.assertEqual(result['document_runs']['q2'], ['d4', 'd3'])

    def test_identical_text_is_not_identical_document(self):
        corpus = dict(CORPUS, d3='same title and text', d4='same title and text')
        self.assertEqual(self.adapt(corpus=corpus)['document_runs']['q2'], ['d3', 'd4'])

    def test_incomplete_candidate_pool_cannot_be_backfilled(self):
        result = self.adapt({'q2': ['d3a', 'd3b']})
        self.assertEqual(result['document_runs'], {'q2': ['d3']})
        row = evaluate(CORPUS, QUERIES, QRELS, result['document_runs'])['rows'][0]
        self.assertEqual(str(row['recall']), '1/2')

    def test_missing_and_empty_stay_different(self):
        result = self.adapt()['document_runs']
        self.assertNotIn('q4', result)
        self.assertEqual(result['q5'], [])

    def test_unknown_chunk_after_document_cutoff_rejected(self):
        with self.assertRaisesRegex(ValueError, 'unknown'):
            self.adapt({'q2': ['d3a', 'd4a', 'missing']})

    def test_duplicate_chunk_after_document_cutoff_rejected(self):
        with self.assertRaisesRegex(ValueError, 'duplicate chunk'):
            self.adapt({'q2': ['d3a', 'd4a', 'd3a']})

    def test_mapping_outside_corpus_rejected_even_if_unused(self):
        with self.assertRaisesRegex(ValueError, 'outside'):
            self.adapt(mapping=dict(CHUNK_TO_DOC, unused='missing-doc'))

    def test_unknown_and_wrong_split_queries_rejected(self):
        for q in ('q1', 'absent'):
            with self.subTest(query=q), self.assertRaisesRegex(ValueError, 'split'):
                self.adapt({q: []})

    def test_strict_rank_and_id_types(self):
        for rank in ('d3a', ('d3a',), [True], [1], [None], ['']):
            with self.subTest(rank=rank), self.assertRaises(ValueError):
                self.adapt({'q2': rank})

        for mapping in ([], None, {'': 'd3'}, {1: 'd3'}, {'x': ''}, {'x': True}):
            with self.subTest(mapping=mapping), self.assertRaises(ValueError):
                adapt_runs(CORPUS, QUERIES, mapping, {})
        for index, invalid in ((0, []), (0, {}), (0, {1: 'text'}),
                               (1, []), (1, {}), (1, {True: {'split': 'test'}}),
                               (1, {'q2': None}), (1, {'q2': {}}),
                               (1, {'q2': {'split': True}}), (3, [])):
            args = [CORPUS, QUERIES, CHUNK_TO_DOC, CHUNK_RUN]
            args[index] = invalid
            with self.subTest(index=index, invalid=invalid), self.assertRaises(ValueError):
                adapt_runs(*args)
        for split in ('absent', '', None, True):
            with self.subTest(split=split), self.assertRaises(ValueError):
                adapt_runs(CORPUS, QUERIES, CHUNK_TO_DOC, {}, split=split)

    def test_input_snapshots_not_mutated(self):
        data = copy.deepcopy((CORPUS, QUERIES, CHUNK_TO_DOC, CHUNK_RUN))
        before = copy.deepcopy(data)
        adapt_runs(*data)
        self.assertEqual(data, before)

    def test_mapping_fingerprint_changes_while_metric_protocol_does_not(self):
        changed = dict(CHUNK_TO_DOC, **{'d3b': 'd4'})
        one, two = self.adapt(), self.adapt(mapping=changed)
        self.assertNotEqual(one['adapter_sha256'], two['adapter_sha256'])
        a = evaluate(CORPUS, QUERIES, QRELS, one['document_runs'])
        b = evaluate(CORPUS, QUERIES, QRELS, two['document_runs'])
        self.assertEqual(a['protocol_sha256'], b['protocol_sha256'])

    def test_dictionary_mapping_order_does_not_change_fingerprint(self):
        backward = dict(reversed(list(CHUNK_TO_DOC.items())))
        self.assertEqual(self.adapt()['adapter_sha256'], self.adapt(mapping=backward)['adapter_sha256'])


if __name__ == '__main__':
    unittest.main()

运行下面三条命令。第一条应显示 32 项测试通过,其中原有 18 项、新增 14 项;第二条生成完整观察记录;第三条再次比较完整 JSON。

python -m unittest -v
python probe_chunk_cutoff.py > observed-chunk.json
python -c "import json; from pathlib import Path; assert json.loads(Path('observed-chunk.json').read_text()) == json.loads(Path('expected-chunk.json').read_text()); print('chunk output matches')"

14. 故意改坏六次,看测试能否指出差错

下面的脚本在临时目录中分别改坏文档排序、完整输入检查、重复块拒绝、未使用映射检查、缺失 query 保留和首块记录,再运行各自的目标测试。六次都应出现指定断言失败;语法错误或导入错误不算抓到了问题。它不会改动当前目录的正确实现。

保存为 run_mutation_checks.py:

"""Run six deliberately broken adapters in isolated temporary directories."""
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile

BASE = Path(__file__).resolve().parent
MUTANTS = [
    ('sort_document_ids', 'doc_runs[query] = list(rows)',
     'doc_runs[query] = sorted(rows)',
     'test_first_occurrence_not_sorted_document_id'),
    ('truncate_input_before_validation',
     '    # Complete validation before creating a consumable adapted run.',
     '    chunk_runs = {q: ranks[:2] for q, ranks in chunk_runs.items()}',
     'test_unknown_chunk_after_document_cutoff_rejected'),
    ('allow_duplicate_chunk_id',
     '        if len(set(ranking)) != len(ranking):',
     '        if False:',
     'test_duplicate_chunk_after_document_cutoff_rejected'),
    ('ignore_unused_bad_mapping', '        if doc not in corpus:',
     '        if False:',
     'test_mapping_outside_corpus_rejected_even_if_unused'),
    ('fabricate_missing_query',
     "    config = {'adapter': 'first-occurrence-document-v1',",
     "    doc_runs.update({q: [] for q in selected if q not in doc_runs})\n"
     "    config = {'adapter': 'first-occurrence-document-v1',",
     'test_missing_and_empty_stay_different'),
    ('overwrite_first_occurrence', '            if doc not in rows:',
     '            if True:',
     'test_nonadjacent_parent_repeats_collapse'),
]


def main():
    original = (BASE / 'chunk_adapter.py').read_text()
    report = []
    for name, old, new, test in MUTANTS:
        if original.count(old) != 1:
            raise RuntimeError('mutation anchor is no longer unique: ' + name)
        with tempfile.TemporaryDirectory(prefix='chunk-mutant-') as tmp:
            work = Path(tmp)
            for source in BASE.iterdir():
                if source.suffix in ('.py', '.json'):
                    shutil.copyfile(source, work / source.name)
            (work / 'chunk_adapter.py').write_text(original.replace(old, new, 1))
            command = [sys.executable, '-m', 'unittest', '-v',
                       'test_chunk_adapter.ChunkAdapterTests.' + test]
            process = subprocess.run(command, cwd=work, capture_output=True, text=True)
            # A syntax/import error is not evidence that the assertion caught a bug.
            killed = (process.returncode == 1 and 'FAIL: ' + test in process.stderr
                      and 'ERROR:' not in process.stderr)
            print(name + '\n' + process.stderr, file=sys.stderr)
            if not killed:
                raise AssertionError('mutant did not fail its intended assertion: ' + name)
            report.append({'mutation': name, 'test': test, 'assertion_failed': True})
    print(json.dumps(report, indent=2))


if __name__ == '__main__':
    main()
python run_mutation_checks.py

脚本成功结束时返回六条 assertion_failed: true。终端中的 FAILED 是故意改坏后的预期结果。新增测试还覆盖非相邻同文档块、相同正文但不同 ID、短候选池、缺失与空结果、严格类型,以及映射哈希对插入顺序不敏感。测试项数不代表覆盖率。

本补充于 2026-10-13 用 Python 3.12.14 执行,仅需标准库。32 项测试与六次故障注入均按预期完成。它证明的是固定数据的转换与计分关系,不验证模型质量、实际切块、分页补取、权限过滤或线上性能。文档相关,也不保证命中的那一块包含答案;要验收引用内容,还需要块级标注与正文复核。