第 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@5 | P@5 | RR | DCG@5 | nDCG@5 | |
|---|---|---|---|---|---|---|
| A:TF-IDF | 0, 2, 0, 0, 2 | 2/3 | 2/5 | 1/2 | 2/log₂3 + 2/log₂6 = 2.036 | 0.541 |
| B:BM25 | 2, 2, 0, 0, 0 | 2/3 | 2/5 | 1 | 2/1 + 2/log₂3 = 3.262 | 0.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 看得出来。
几个容易算错的地方
- precision@k 的分母固定是 k:只返回了 3 篇,precision@5 也除以 5。
- MRR 要写截断:前 10 名都没有相关文档时 RR 记 0,汇报成 MRR@10。TREC-8 问答评测(Voorhees,1999)用的就是这个规则,只看前 5 个回答。
- recall、precision、MRR 要先把等级二值化:这里"等级 > 0 就算相关";阈值换成"等级 = 2",数字会变,要写清楚。
- 增益和折扣有不同写法:上面是线性增益 \(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\) 是对数的底)的文档不打折,和上面的写法也不一样。不同写法的数字不能混着比较,汇报时写清楚。 - 没有相关文档的查询: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 的得分}\),然后:
- 配对 bootstrap:从 \(n\) 条查询里有放回地抽 \(n\) 条(抽中一条,就同时带上 A 和 B 在它上面的得分),算差值均值;重复 10000 次,取第 2.5、97.5 百分位。
- 随机化检验(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,看区间重不重叠”:扔掉了配对信息,区间宽约一倍;按查询配对重抽。