检索变好了吗?recall@k、MRR 与 nDCG

先有一份"查询 + 相关文档"的标注集,再用指标打分,最后按查询配对比较两个版本。换了分词、换了嵌入模型、加了重排,"感觉更准了"不算数。

测试 Agent 要从历史 Bug 报告、需求条款、页面探索记录里检索资料。检索漏了,后面的推理再强也没用;检索评测就是给这一步写断言。

1先建评测集:查询 + 相关性标注

检索评测集长这样:一批查询,每条查询标出语料里哪些文档相关、相关到什么程度。BugHunt-Bench 里的一条:

{"id": "q18", "text": "我收藏的文章列表显示错了",
 "rel": {"BUG-118": 2, "REQ-09": 2, "EXP-05": 1}}   # 没列出的文档都算 0

等级要事先写成规则,否则两个人标出来不一样:2 = 能直接回答查询(同一个 Bug,或明确规定这个行为的需求条款);1 = 相关、有帮助(同模块的相关 Bug、页面探索记录);0 = 无关。

查询从哪来,各有什么偏差

来源好处偏差
真实日志(Agent 运行时发出的检索请求)分布最接近线上只有当前系统已经在处理的问题;如果只标注当时检索到的文档,标注会偏向当前系统,新系统找到的好文档没人标过
人工编写能专门写难例、边界、"一个查询对应多篇文档"写的人往往看着文档写,措辞和原文很像,偏向关键词匹配;数量少,成本高
LLM 读一篇文档生成问题,再人工核对便宜、量大、覆盖每一篇文档问题和源文档用词重叠高,同样偏向关键词匹配;通常只把源文档标为相关,其他相关文档漏标;措辞风格单一
漏标会让好系统吃亏:没标注的文档一律按不相关算。新系统检索到一篇真正相关、但当初没人看过的文档,指标反而下降。TREC 把多个系统的前若干名合并成"候选池"(pooling)再标注,就是为了减少漏标;即便如此,Zobel(1998)估计池子外仍有不少相关文档没被找到;不过他同时发现,在当时的 TREC 集上,这对系统之间的相对比较影响不大。语料更大以后偏差就明显了:Buckley 等(2007,Bias and the limits of pooling for large collections)发现,候选池大小不变时,池子会被含查询标题词的相关文档占满,没参与建池的系统可能被不公平地压低。Buckley 和 Voorhees(2004)也发现,标注明显不完整时常用指标就不稳了;自己建集时,至少把两个待比较系统的前 k 名都送去标注。

三种来源混着用最稳:真实日志定分布,人工补难例,LLM 生成扩量但必须人工核对、补标其他相关文档。查询要按模块和类型分层,别让一个模块占一半。

2四个指标:定义和手算

记 \(R\) 为一条查询的相关文档集合(等级 > 0),检索结果前 \(k\) 名为 \(\text{top}_k\),第 \(i\) 名的等级为 \(rel_i\)。

$$\text{recall@}k = \frac{|R \cap \text{top}_k|}{|R|} \qquad \text{precision@}k = \frac{|R \cap \text{top}_k|}{k}$$ $$\text{RR} = \frac{1}{\text{第一篇相关文档的名次}},\quad \text{MRR} = \text{所有查询的 RR 取平均}$$ $$\text{DCG@}k = \sum_{i=1}^{k} \frac{rel_i}{\log_2(i+1)} \qquad \text{nDCG@}k = \frac{\text{DCG@}k}{\text{IDCG@}k}$$

IDCG 是把全部已标注的相关文档按等级从高到低排好后的 DCG,不是只把检索到的那几篇重排。所以检索结果里漏掉一篇相关文档,nDCG 也会被扣分。

手算:q18 "我收藏的文章列表显示错了"

相关文档 3 篇:BUG-118(2)、REQ-09(2)、EXP-05(1)。两个检索器的前 5 名等级:

第 1~5 名的等级recall@5P@5RRDCG@5nDCG@5
A:TF-IDF0, 2, 0, 0, 22/32/51/22/log₂3 + 2/log₂6 = 2.0360.541
B:BM252, 2, 0, 0, 02/32/512/1 + 2/log₂3 = 3.2620.867

IDCG@5 = 2/1 + 2/log₂3 + 1/log₂4 = 2 + 1.262 + 0.5 = 3.762。两个系统都找到了 2 篇、漏了 EXP-05,recall 和 precision 一模一样;B 把两篇最相关的放在最前面,RR 和 nDCG 才看得出来。

几个容易算错的地方

  1. precision@k 的分母固定是 k:只返回了 3 篇,precision@5 也除以 5。
  2. MRR 要写截断:前 10 名都没有相关文档时 RR 记 0,汇报成 MRR@10。TREC-8 问答评测(Voorhees,1999)用的就是这个规则,只看前 5 个回答。
  3. recall、precision、MRR 要先把等级二值化:这里"等级 > 0 就算相关";阈值换成"等级 = 2",数字会变,要写清楚。
  4. 增益和折扣有不同写法:上面是线性增益 \(rel_i\)、折扣 \(\log_2(i+1)\),和 scikit-learn 的 ndcg_score 一致。另一种常见写法是增益 \(2^{rel_i}-1\)(Burges 等 2005 年的 RankNet 论文就这么定义),更看重高等级文档。DCG 最早见于 Järvelin 和 Kekäläinen 在 SIGIR 2000 的论文,归一化的 nDCG 在两人 2002 年的 TOIS 论文里给出;原文的折扣对排在前 \(b\) 名(\(b\) 是对数的底)的文档不打折,和上面的写法也不一样。不同写法的数字不能混着比较,汇报时写清楚。
  5. 没有相关文档的查询:recall 和 nDCG 的分母是 0,要么从评测集里去掉,要么单独统计"应该什么都不返回"的情况。

3指标之间的取舍

指标回答的问题看不到什么适合的场景
recall@k该找的找全了吗前 k 名内部的顺序;塞了多少噪声前 k 篇整批塞进上下文的 RAG;重排之前的第一阶段
precision@k前 k 名里有多少是有用的漏了多少;顺序上下文预算紧,噪声会占 token、干扰模型
MRR第一篇有用的排第几第一篇之后的所有文档只要一个答案:比如提交 Bug 前查有没有重复报告
nDCG@k按等级、按位置折扣的整体排序质量单看数字说不清是漏了还是排错了分级标注、顺序重要(给人看的列表、重排效果)

上面的 q18 已经说明:只看 recall 会以为 A、B 一样。反过来也有:一个系统把唯一一篇相关文档从第 1 名挤到第 4 名,recall@5 不变,MRR 从 1 掉到 0.25。

指标要先定后看。下面的实验里,BM25 的 recall@5 比 TF-IDF 低 0.017,nDCG@5 高 0.018:看完结果再挑对自己有利的指标,等于没做评测。Agent 场景常见的组合是:第一阶段看 recall@k(大 k),最终喂给模型的那几篇看 nDCG@k 或 precision@k。

4重排:先粗后细

第一阶段用便宜的检索器(BM25、嵌入向量)从全库捞出前 100 篇;第二阶段用更贵的模型对这 100 篇重新打分,取前几名。

怎么打分成本
双编码器(bi-encoder,嵌入检索)查询和文档各自编码成向量,算相似度文档向量可以提前算好建索引,查询时只编码一次
交叉编码器(cross-encoder)把"查询 + 文档"拼在一起送进模型,直接输出相关性分数每个候选都要跑一次模型,没法提前算;成本随候选数线性增长
LLM 重排把查询和一批候选放进 prompt,让模型给出排序(或逐篇打分)最贵、最慢;候选多时要分窗口滑动,输出还可能不合法(漏编号、重复)

几个一手数字。Nogueira 和 Cho(2019)用 BERT 做这种"查询 + 段落"拼接打分的重排,MS MARCO 开发集上 MRR@10 从 BM25 的 16.7 提到 36.5(BERT Large)。Sentence-BERT 论文(Reimers 和 Gurevych,2019)算过交叉编码的代价:在 10,000 个句子里找最相似的一对,要做约 5000 万次推理,V100 上约 65 小时;先各自算嵌入只要约 5 秒。RankGPT(Sun 等,2023)让 LLM 对 BM25 的前 100 篇输出排列,一次放不下,就用窗口 20、步长 10 从后往前滑。BEIR 基准(Thakur 等,2021)的结论是:BM25 是稳健的基线;零样本下,重排模型(BM25 + 交叉编码重排)和后期交互模型(ColBERT)平均效果最好,但计算成本高。

重排救不回第一阶段漏掉的文档。所以评测要分两层:第一阶段看 recall@100(重排的上限),重排后看 nDCG@10 或 precision@5;同时记下每条查询多花的时延和费用。重排有没有用,用的是下面同一套配对比较。

5两个版本对比:按查询配对

两个检索器跑的是同一批查询,这和「新 prompt 从 82% 涨到 86%,是真的变好了吗?」那章的同一批用例是一回事:查询是配对单位。每条查询算一个差值 \(d_q = \text{B 的得分} - \text{A 的得分}\),然后:

  1. 配对 bootstrap:从 \(n\) 条查询里有放回地抽 \(n\) 条(抽中一条,就同时带上 A 和 B 在它上面的得分),算差值均值;重复 10000 次,取第 2.5、97.5 百分位。
  2. 随机化检验(permutation test):原假设"A、B 可以互换",每条查询随机交换 A、B 的得分(差值随机变号),看均值差至少这么大的比例。

Smucker、Allan 和 Carterette(2007)在 TREC 数据上比较过几种检验,结论是随机化检验、bootstrap 和配对 t 检验在实践中差别不大;Wilcoxon 和符号检验检出能力差,还可能误报显著,他们建议不再用来检验均值差。

不能把 A、B 各自独立重抽:查询难度差别很大(有的查询两个系统都满分,有的都很差),不配对会把难度的波动算进噪声。下面的实验里,nDCG@5 的不配对区间约是配对区间的两倍宽。

查询要多少条

配对设计下,用正态近似(双侧 α = 0.05,功效 80%):

$$n \approx \left(\frac{(z_{0.975} + z_{0.8})\,\sigma_d}{\delta}\right)^2 = \left(\frac{2.80\,\sigma_d}{\delta}\right)^2$$

\(\sigma_d\) 是逐查询差值的标准差,从一次小规模试跑里估;\(\delta\) 是你关心的最小差距。实验里 nDCG@5 差值的标准差是 0.095:要检出 0.05 的差约 29 条查询,检出 0.02 的差约 179 条。差距减半,查询数翻四倍。

\(\sigma_d\) 本身是从 30 条查询里估的,也有误差;逐查询差值通常有大量 0(两个系统结果一样),分布也不正态。这个公式给的是数量级,留余量。也不要"不显著就再加几条查询、再检验一次":Webber、Moffat 和 Zobel(2008)指出,这样一轮轮加查询直到显著,会让结果偏向"显著"。查询数事先定好。另外同时比四个指标,至少有一个"显著"的机会变大,事先指定一个主指标。

▶互动演示 1:拖动排序,看四个指标怎么变

初始是系统 A(TF-IDF)对 q18 "我收藏的文章列表显示错了" 的前 10 名。拖动行(或点 ↑ ↓)改顺序,点等级按钮在 0 / 1 / 2 之间切换。虚线以下是第 k 名之后,不计入 @k 指标;MRR 看前 10 名。

载入
增益
等级 2 0 等级 1 1
recall@5
precision@5
RR(MRR@10,单条查询)
nDCG@5
DCG@5
IDCG@5
    DCG 逐项贡献(前 k 名):贡献 = 增益 × 折扣,折扣 = 1/log₂(i+1)

    ▶互动演示 2:两个检索器,按查询配对比较

    数据是下面实验的真实结果:30 条查询,A = TF-IDF,B = BM25。上图是逐查询差值(B − A,从小到大排),点一根柱子看这条查询两个系统的前 5 名;下图是配对 bootstrap 的 10000 个均值差,竖线是 95% 区间。浏览器里的随机数和 Python 不同,区间端点可能和实验输出差零点几个百分点。

    指标
    A:TF-IDF 平均
    B:BM25 平均
    B − A
    B 更好 / A 更好 / 打平
    配对 bootstrap 95% 区间
    区间宽度
    bootstrap 均值差的分布;灰色虚线是 0

    ✎练习

    题 1(算 nDCG):一条查询共有 3 篇相关文档,等级分别是 2、1、1。检索结果前 5 名的等级依次是 1, 0, 2, 0, 0(有一篇等级 1 的没检索到)。用线性增益、log₂ 折扣,nDCG@5 是多少?
    DCG = 1/log₂2 + 2/log₂4 = 1 + 1 = 2。IDCG 用全部 3 篇相关文档理想排序:2/1 + 1/log₂3 + 1/log₂4 = 2 + 0.631 + 0.5 = 3.131。nDCG = 2 / 3.131 ≈ 0.639。如果 IDCG 只用检索到的两篇(2 + 1/log₂3 = 2.631),会算成 0.760,高估了。
    题 2(选指标):测试 Agent 每一步把检索到的前 5 篇文档全部塞进上下文,模型自己读。你最该先看哪个指标?
    5 篇整批进上下文,最致命的是该看的没进来,所以先看 recall@5。顺序和噪声也有影响(噪声占 token、可能干扰模型),可以再辅以 precision@5 或 nDCG@5,但 MRR 和 precision@1 只看第一名,不对应这个用法。
    题 3(配对):要给"BM25 比 TF-IDF 的 nDCG@5 高多少"算 bootstrap 区间,重抽的单位应该是?
    两个系统跑的是同一批查询,查询就是配对单位。B 扔掉了配对信息,区间偏宽(实验里约宽一倍);C 的单位不对,指标是按查询算的,文档之间也不独立。
    题 4(查询数):试跑发现两个检索器 nDCG@10 的逐查询差值标准差约 0.10。想以 80% 功效(双侧 α = 0.05)检出 0.03 的差,大约需要多少条查询?
    条
    n ≈ ((1.96 + 0.84) × 0.10 / 0.03)² = (9.34)² ≈ 87.2,向上取整 88 条。这是正态近似,σ 也是估出来的,实际留些余量。
    题 5(评测集偏差):用 LLM 给每篇文档生成 1 个问题,只把源文档标为相关。BM25 在这个集上远好于嵌入检索。最先该怀疑什么?
    这是 LLM 生成查询的两个典型偏差。要人工核对、改写成真实用户的说法,并把两个系统的前 k 名都送去补标注,再下结论。bootstrap 次数只影响区间端点的蒙特卡洛误差,不影响偏差。

    6面试要点与代码

    一句话讲清楚

    检索评测先建"查询 + 分级相关性"标注集(真实日志定分布、人工补难例、LLM 扩量后人工核对,至少把待比较系统的前 k 名都标了);recall@k 看找没找全,precision@k 看噪声,MRR 看第一篇有用的排第几,nDCG@k 按等级和位置折扣看整体排序;主指标事先定。两个版本对比按查询配对:逐查询差值做 bootstrap 区间或随机化检验,查询数按差值标准差和想检出的差距估。

    追问准备

    1. nDCG 的 IDCG 怎么算?用全部已标注的相关文档按等级理想排序,取前 k 名的 DCG。只用检索到的文档会高估。
    2. 重排加了,怎么证明值?同一批查询,重排前后做配对比较,主指标看 nDCG@10;同时报第一阶段的 recall@100(重排上限)和每条查询多出的时延、费用。
    3. 标注没标全怎么办?把两个系统的前 k 名合并成候选池送去标注;新系统大量检索到未标注文档时,先补标再比较。
    4. RAG 端到端答对率已经有了,还要检索指标吗?要。答错时要分清是没检索到,还是检索到了没用上;检索指标单独定位第一种。

    常见错误说法

    ❌ "recall 一样,两个检索器就一样好":recall@k 看不到前 k 名内部的顺序。
    ❌ "nDCG 的分母用检索到的文档重排一下就行":IDCG 要用全部相关文档,否则漏召回不扣分。
    ❌ "用 LLM 生成查询就不用人工标注了":用词重叠和漏标两个偏差都要人工兜。
    ❌ "BM25 的 nDCG 高了 0.018,所以更好":30 条查询上配对区间 [−0.012, +0.055] 包含 0,证据不够。
    ❌ "加了重排,召回率就上去了":重排只改顺序,第一阶段没捞到的文档它救不回来。

    最小实现(Python,只依赖 numpy)

    """检索评测:recall@k、precision@k、MRR、nDCG@k,以及两个检索器的按查询配对 bootstrap。
    
    运行:python3 retrieval_eval.py(依赖 numpy;有 scikit-learn 时顺带用 ndcg_score 校验 nDCG)
    数据:同目录 data.json,BugHunt-Bench 场景的小型自制标注集(38 篇文档、30 条查询、0/1/2 分级相关性)。
    """
    import json
    import math
    import re
    from pathlib import Path
    
    import numpy as np
    
    K = 5            # 汇报 @5:Agent 一次只往上下文里塞前 5 篇
    DEPTH = 10       # MRR 只看前 10 名(MRR@10),10 名以外没找到记 0
    N_BOOT = 10_000
    
    
    # ---------- 指标:ranking 是检索结果的文档 id 列表,rel 是 {文档 id: 相关性等级} ----------
    
    def recall_at_k(ranking, rel, k):
        relevant = {d for d, g in rel.items() if g > 0}
        return len(relevant & set(ranking[:k])) / len(relevant)
    
    
    def precision_at_k(ranking, rel, k):
        return sum(rel.get(d, 0) > 0 for d in ranking[:k]) / k   # 分母固定是 k,结果不足 k 篇也一样
    
    
    def reciprocal_rank(ranking, rel, depth=DEPTH):
        for i, d in enumerate(ranking[:depth], start=1):
            if rel.get(d, 0) > 0:
                return 1 / i
        return 0.0
    
    
    def dcg(gains):
        return sum(g / math.log2(i + 1) for i, g in enumerate(gains, start=1))
    
    
    def ndcg_at_k(ranking, rel, k):
        ideal = dcg(sorted(rel.values(), reverse=True)[:k])     # 理想排序来自全部标注,不只是检索到的
        return dcg([rel.get(d, 0) for d in ranking[:k]]) / ideal
    
    
    # ---------- 两个检索器:TF-IDF 余弦 与 BM25,中文按字的一元 + 二元切分 ----------
    
    def tokenize(text):
        tokens = []
        for run in re.findall(r"[a-z0-9]+|[一-鿿]+", text.lower()):
            if run.isascii():
                tokens.append(run)
            else:
                tokens += list(run) + [run[i:i + 2] for i in range(len(run) - 1)]
        return tokens
    
    
    class Index:
        def __init__(self, docs):
            self.ids = list(docs)
            self.toks = [tokenize(docs[d]) for d in self.ids]
            self.n = len(self.ids)
            self.df = {}
            for toks in self.toks:
                for t in set(toks):
                    self.df[t] = self.df.get(t, 0) + 1
            self.avgdl = sum(map(len, self.toks)) / self.n
    
        def _rank(self, scores):
            order = sorted(range(self.n), key=lambda i: (-scores[i], self.ids[i]))   # 同分按 id 排,结果可复现
            return [self.ids[i] for i in order]
    
        def tfidf(self, query):
            idf = lambda t: math.log((1 + self.n) / (1 + self.df.get(t, 0))) + 1
            def vec(toks):
                v = {}
                for t in toks:
                    v[t] = v.get(t, 0) + idf(t)
                norm = math.sqrt(sum(x * x for x in v.values())) or 1.0
                return {t: x / norm for t, x in v.items()}
            q = vec(tokenize(query))
            return self._rank([sum(q.get(t, 0) * x for t, x in vec(toks).items()) for toks in self.toks])
    
        def bm25(self, query, k1=1.2, b=0.75):
            qt = tokenize(query)
            scores = []
            for toks in self.toks:
                tf, s = {}, 0.0
                for t in toks:
                    tf[t] = tf.get(t, 0) + 1
                for t in qt:
                    if t in tf:
                        idf = math.log(1 + (self.n - self.df[t] + 0.5) / (self.df[t] + 0.5))
                        s += idf * tf[t] * (k1 + 1) / (tf[t] + k1 * (1 - b + b * len(toks) / self.avgdl))
                scores.append(s)
            return self._rank(scores)
    
    
    # ---------- 按查询配对:bootstrap 区间 + 随机化检验 ----------
    
    def paired_bootstrap(a, b, n_boot=N_BOOT, seed=0):
        """a、b 是两个系统在同一批查询上的逐查询得分。整条查询一起重抽,返回 (b - a) 均值差的 95% 区间。"""
        diff = np.asarray(b) - np.asarray(a)
        idx = np.random.default_rng(seed).integers(0, diff.size, size=(n_boot, diff.size))
        return np.percentile(diff[idx].mean(axis=1), [2.5, 97.5])
    
    
    def randomization_test(a, b, n_perm=N_BOOT, seed=0):
        """原假设:两个系统可以互换。每条查询随机交换 A、B(差值随机变号),看均值差至少这么大的比例(双侧)。"""
        diff = np.asarray(b) - np.asarray(a)
        signs = np.random.default_rng(seed).choice([-1, 1], size=(n_perm, diff.size))
        null = np.abs((signs * diff).mean(axis=1))
        return (np.sum(null >= abs(diff.mean()) - 1e-12) + 1) / (n_perm + 1)
    
    
    def queries_needed(sd, delta, z_alpha=1.959964, z_beta=0.841621):
        """配对设计、双侧 α = 0.05、功效 80% 的正态近似:要检出均值差 delta 需要多少条查询。"""
        return math.ceil(((z_alpha + z_beta) * sd / delta) ** 2)
    
    
    def self_check():
        rel = {"d1": 2, "d2": 1, "d3": 2}
        ranking = ["x", "d1", "y", "d2", "z"]
        assert recall_at_k(ranking, rel, 5) == 2 / 3
        assert precision_at_k(ranking, rel, 5) == 2 / 5
        assert reciprocal_rank(ranking, rel) == 1 / 2
        expected = (2 / math.log2(3) + 1 / math.log2(5)) / (2 + 2 / math.log2(3) + 1 / math.log2(4))
        assert abs(ndcg_at_k(ranking, rel, 5) - expected) < 1e-12
        try:  # 用 scikit-learn 交叉校验:分数给全部文档,理想排序也来自全部文档
            from sklearn.metrics import ndcg_score
            docs = ranking + ["d3"]
            y_true = [[rel.get(d, 0) for d in docs]]
            y_score = [[len(docs) - i for i in range(len(docs))]]
            assert abs(ndcg_score(y_true, y_score, k=5) - expected) < 1e-12
        except ImportError:
            pass
    
    
    def main():
        self_check()
        data = json.loads(Path(__file__).with_name("data.json").read_text(encoding="utf-8"))
        index, queries = Index(data["docs"]), data["queries"]
        metrics = {
            f"recall@{K}": lambda r, rel: recall_at_k(r, rel, K),
            f"precision@{K}": lambda r, rel: precision_at_k(r, rel, K),
            f"MRR@{DEPTH}": reciprocal_rank,
            f"nDCG@{K}": lambda r, rel: ndcg_at_k(r, rel, K),
        }
        runs = {"A: TF-IDF": [index.tfidf(q["text"]) for q in queries],
                "B: BM25": [index.bm25(q["text"]) for q in queries]}
        scores = {(s, m): np.array([f(r, q["rel"]) for r, q in zip(rs, queries)])
                  for s, rs in runs.items() for m, f in metrics.items()}
    
        print(f"{len(data['docs'])} 篇文档,{len(queries)} 条查询,bootstrap / 随机化各 {N_BOOT} 次\n")
        print(f"{'指标':<14}{'A: TF-IDF':>10}{'B: BM25':>10}{'B - A':>9}   {'配对 bootstrap 95% 区间':<24}{'随机化 p':>8}")
        for m in metrics:
            a, b = scores[("A: TF-IDF", m)], scores[("B: BM25", m)]
            lo, hi = paired_bootstrap(a, b)
            print(f"{m:<14}{a.mean():>10.3f}{b.mean():>10.3f}{b.mean() - a.mean():>+9.3f}   "
                  f"[{lo:+.3f}, {hi:+.3f}]{'':<10}{randomization_test(a, b):>8.3f}")
    
        m = f"nDCG@{K}"
        diff = scores[("B: BM25", m)] - scores[("A: TF-IDF", m)]
        print(f"\n{m} 逐查询差值(B - A):B 更好 {int((diff > 1e-9).sum())} 条,"
              f"A 更好 {int((diff < -1e-9).sum())} 条,打平 {int((np.abs(diff) <= 1e-9).sum())} 条")
        for q, d in sorted(zip(queries, diff), key=lambda x: x[1]):
            if abs(d) > 1e-9:
                print(f"  {q['id']} {d:+.3f}  {q['text']}")
        sd = diff.std(ddof=1)
        print(f"差值标准差 {sd:.3f};要以 80% 功效检出 nDCG 差 0.02 / 0.05,约需 "
              f"{queries_needed(sd, 0.02)} / {queries_needed(sd, 0.05)} 条查询")
    
    
    if __name__ == "__main__":
        main()

    输出(Python 3.9 / numpy 1.22 / scikit-learn 1.1):

    38 篇文档,30 条查询,bootstrap / 随机化各 10000 次
    
    指标             A: TF-IDF   B: BM25    B - A   配对 bootstrap 95% 区间        随机化 p
    recall@5           0.844     0.828   -0.017   [-0.042, +0.000]             0.507
    precision@5        0.433     0.420   -0.013   [-0.033, +0.000]             0.507
    MRR@10             0.950     1.000   +0.050   [+0.000, +0.117]             0.246
    nDCG@5             0.871     0.890   +0.018   [-0.012, +0.055]             0.305
    
    nDCG@5 逐查询差值(B - A):B 更好 6 条,A 更好 8 条,打平 16 条
      q24 -0.109  编辑器页面提交过哪些 Bug
      q28 -0.092  接口返回 422 但是前端不显示错误
      q12 -0.080  没有头像的用户显示什么
      q20 -0.070  连点两下发布出现两篇一样的文章
      q25 -0.050  文章列表每页显示多少篇
      q01 -0.018  点了收藏数字没有加一
      q13 -0.012  筛选标签以后翻页是空白页
      q21 -0.012  改了密码以后老密码还能用
      q23 +0.022  设置页面已经测过哪些功能
      q03 +0.037  密码输错了界面上没有任何报错
      q27 +0.140  Your Feed 应该显示谁的文章
      q14 +0.236  修改文章正文后 tag 没了
      q30 +0.236  登录页和注册页测过了吗
      q18 +0.326  我收藏的文章列表显示错了
    差值标准差 0.095;要以 80% 功效检出 nDCG 差 0.02 / 0.05,约需 179 / 29 条查询

    完整脚本(含 TF-IDF、BM25 和数据)在 week04_上下文与记忆/code/retrieval_eval/。用 scikit-learn 的 ndcg_score 交叉校验过 nDCG(把全部文档的分数都传进去,两者一致)。

    公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。