Learning AI Quality 返回 KuthorX Blog II博客首页

第 20 章

第 4 周:检索变好了吗?recall@k、MRR 与 nDCG

检索评测:怎么建查询 + 分级相关性的评测集,recall@k、precision@k、MRR、nDCG@k 的定义与手算,指标之间的取舍,cross-encoder 与 LLM 重排的思路和成本,两个检索器按查询配对 bootstrap,以及查询要多少条。一段讲解视频,一个可以拖动排序、对比两个检索器的互动演示。

换了分词,换了嵌入模型,加了一层重排,“感觉更准了”。可是准了多少,是哪几条查询变好了,有没有哪条变差了,这些靠感觉回答不了。检索评测要做三件事:先建一份"查询 + 相关文档"的标注集,再用指标给每条查询打分,最后按查询配对比较两个版本。这一章接着上一章「Agent 怎么从一堆 Bug 报告里找到相关的那几条?」:测试 Agent 要从历史 Bug 报告、需求条款、页面探索记录里检索资料,检索漏了,后面的推理再强也没用。检索评测就是给这一步写断言。

讲解视频

互动演示

两个演示。上面是一条查询的排序列表:拖动行或点 ↑ ↓ 改顺序,点等级按钮在 0 / 1 / 2 之间切换,调截断 k 和"列表外还没检索到的相关文档",recall@k、precision@k、RR、nDCG@k 和每个名次的 DCG 贡献实时更新。下面是两个检索器在 30 条查询上的真实结果:逐查询差值、配对 bootstrap 的分布和 95% 区间,可以换指标、只用前 n 条查询,也可以切到"A、B 各自独立重抽"的错误做法对比区间宽度。页面底部有自动判分的练习。

互动演示:检索指标与配对比较 在新标签页打开

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

检索评测集长这样:一批查询,每条查询标出语料里哪些文档相关、相关到什么程度。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 生成扩量但必须人工核对、补标其他相关文档。查询要按模块和类型分层,别让一个模块占一半。

四个指标:定义和手算

记 \(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{第一篇相关文档的名次}},\qquad \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,要么从评测集里去掉,要么单独统计"应该什么都不返回"的情况。

指标之间的取舍

指标回答的问题看不到什么适合的场景
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 场景常见的组合是:第一阶段看大 k 的 recall@k,最终喂给模型的那几篇看 nDCG@k 或 precision@k。

重排:先粗后细

第一阶段用便宜的检索器(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;同时记下每条查询多花的时延和费用。重排有没有用,用的是下面同一套配对比较。

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

两个检索器跑的是同一批查询,这和「新 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.04, +0.09],宽度大约是配对区间的两倍。

分歧很少时 bootstrap 不可靠,这一点和 McNemar 那章一样。实验里 MRR@10 只有 3 条查询有差别,而且全部是 BM25 更好,bootstrap 永远抽不出负的差值,区间下界恰好卡在 0。这时看随机化检验:3 条全部同号,双侧 p = 2 × 0.5³ = 0.25(实验的蒙特卡洛结果是 0.246)。

查询要多少条

配对设计下,用正态近似(双侧 α = 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)指出,这样一轮轮加查询直到显著,会让结果偏向"显著"。查询数事先定好。
  • 同时比四个指标,至少有一个"显著"的机会变大。事先指定一个主指标。

动手实验:TF-IDF 对 BM25

语料是 BugHunt-Bench 场景下自制的 38 篇文档(Conduit 应用的 22 条历史 Bug 报告、10 条需求条款、6 条页面探索记录),30 条查询,每条按上面的 0 / 1 / 2 规则手工标注。两个检索器:A 是 TF-IDF 余弦相似度,B 是 BM25(k1 = 1.2,b = 0.75);中文按字切成一元和二元词。本地没有装嵌入模型,所以没有比向量检索,这里的重点是指标和配对比较,不是"哪种检索器更好"。

这份标注集很小,标注也只有一个人,结论只对这份数据成立,不代表 TF-IDF 和 BM25 的一般优劣。

脚本只依赖 numpy;装了 scikit-learn 时,会顺带用 ndcg_score 校验 nDCG(把全部文档的分数都传进去,两者一致)。

"""检索评测: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 条查询

几点说明:

  • ndcg_at_k 里的理想排序取自 rel.values(),也就是全部已标注的相关文档,所以没检索到的 EXP-05 也会拉低 nDCG。
  • paired_bootstrap 先按查询相减再整条重抽,和 McNemar 那章的 paired_bootstrap 是同一个写法。
  • 结果读法:四个指标没有一个的区间明确排除 0。nDCG@5 的 +0.018 在 30 条查询上证据不够;recall、precision、MRR 只有 2~3 条查询有差别,区间一端卡在 0,要看随机化检验的 p(0.25~0.51),同样说明不了什么。逐查询看,BM25 让 MRR 上涨的几条(q18、q14、q30)都是把等级 2 的文档排到了更前面;让 recall 下跌的两条(q24、q28)是把 A 排在第 5 名的一篇等级 1 文档(q24 的 BUG-114、q28 的 REQ-02)挤出了前 5 名,这正是 nDCG 涨、recall 跌的来源。

常见错误说法

  • “recall 一样,两个检索器就一样好”:recall@k 看不到前 k 名内部的顺序。q18 上两个系统 recall 和 precision 完全相同,nDCG@5 一个 0.541、一个 0.867。
  • “nDCG 的分母用检索到的文档重排一下就行”:IDCG 要用全部已标注的相关文档,否则漏召回不扣分。
  • “用 LLM 生成查询就不用人工标注了”:用词重叠和漏标两个偏差,都要人工核对兜住。
  • “BM25 的 nDCG 高了 0.018,所以更好”:30 条查询上配对区间 [−0.012, +0.055] 包含 0,证据不够;要检出 0.02 的差,大约要 179 条查询。
  • “加了重排,召回率就上去了”:重排只改顺序,第一阶段没捞到的文档它救不回来。
  • “A、B 各自 bootstrap,看区间重不重叠”:扔掉了配对信息,区间宽约一倍;按查询配对重抽。