06 · 检索评测: 用数字证明变好了
一句话总结
这一章不写任何新功能, 只产出数字: 实现 Recall@K / MRR / nDCG@K 三个指标, 拿第 3 章那 15 道题跑出一张四行的消融表, 然后花一半篇幅讨论这张表不能说明什么 —— 15 题的样本量陷阱、0.0667 的指标粒度、以及一个用实测揭穿的意外发现: 把 fetch_k 从 10 调到 16, MRR 反而掉了。
本章你能学到什么
- Recall@K / MRR / nDCG@K 分别在回答什么问题, 什么时候该看哪个
- 三个指标怎么用二十行代码实现 (不需要任何评测库)
- 消融表 (ablation table) 怎么设计: 为什么必须能单独跑每一路
- 为什么"提升了 3%"这句话在 15 题的评测集上通常是噪声
- 逐题拆解: 掉分的到底是哪一题, 为什么
- 一个反直觉的实测结果: 扩大候选池 (
fetch_k) 会让排名变差, 以及为什么
一、三个指标: 各自在回答什么问题
1.1 Recall@K: 找得到吗
是什么。 前 K 条结果里, 至少有一条是标准答案, 就算这题成功。全部题目的成功率就是 Recall@K。
本项目的实现:
recalls.append(1.0 if any(rels) else 0.0) # 命中率 (每题至少一个 gold)
注意这是"命中率"而不是教科书上的 Recall。 严格的 Recall@K 是"前 K 条里召回的 gold 数 / 全部 gold 数"。本项目每题的 gold_chunks 只有一篇语料, 所以两者等价; 但如果一题标了三篇 gold, 严格 Recall 会算 1/3、2/3、3/3, 而这里只算 0 或 1。
为什么这么简化。 因为下游用途决定了它: 第 7 章的面试官只需要"有没有拿到能出题的资料", 拿到一块够用的就行, 不需要"把所有相关块都拿到"。指标要服务于用途, 不是服务于教科书。 但这个简化必须写在文档里, 否则和别人对比数字时会对不上。
它回答的问题: 检索有没有把答案捞上来。它不关心排在第几名。
1.2 MRR: 排第几名
是什么。 Mean Reciprocal Rank, 第一个正确答案的排名取倒数, 再对所有题求平均。排第 1 得 1.0, 第 2 得 0.5, 第 3 得 0.333, 前 K 名里没有得 0。
rr = 0.0
for i, r in enumerate(rels, start=1):
if r:
rr = 1.0 / i
break
rrs.append(rr)
break 是关键: 只看第一个命中, 后面的不管。
为什么 MRR 比 Recall 更能反映真实体验。 提示词里 max_chars 有上限, 排在后面的块可能被截掉; 而且模型对提示词开头的内容注意力更高 (这是真实存在的位置偏差)。排第 1 和排第 3 对最终出题质量的影响是不一样的, 但 Recall@3 看不出这个差别。
它回答的问题: 答案排得够前吗。
1.3 nDCG@K: 整体排序质量
是什么。 Normalized Discounted Cumulative Gain。DCG 给每个位置一个折扣 (排越后贡献越小), 然后除以"理想排序"的 DCG 做归一化。
def dcg(rels: list[int]) -> float:
return sum(r / math.log2(i + 2) for i, r in enumerate(rels))
ideal = sorted(rels, reverse=True)
ndcgs.append(dcg(rels) / dcg(ideal) if any(ideal) else 0.0)
log2(i + 2) 那个 +2: enumerate 从 0 开始, 所以第 1 名是 i=0, 折扣是 log2(2) = 1 (不打折); 第 2 名 log2(3) = 1.585; 第 3 名 log2(4) = 2。
ideal = sorted(rels, reverse=True) 是把所有命中挪到最前面得到的理想排序。除以它, nDCG 就落在 0~1 之间, 不同题目之间可比。
它和 MRR 的区别: MRR 只看第一个命中, nDCG 看全部命中的位置。一题标了两篇 gold, 命中在第 1 和第 3 名, MRR 是 1.0 (满分), 但 nDCG 会因为第二个 gold 排在第 3 名而不满分。
它回答的问题: 整个前 K 名的排序有多接近最优。
1.4 什么时候看哪个
| 场景 | 看哪个 | 为什么 |
|---|---|---|
| 判断"检索有没有彻底失败" | Recall@K | 它是 0/1 的, 掉了就是真掉了 |
| 判断"排序有没有变好" | MRR | 对第一名的位置最敏感 |
| 多个 gold 的题、需要看整体 | nDCG@K | 唯一考虑全部命中位置的 |
| 只能报一个数给别人看 | Recall@K + 样本量 | 最不容易被误读 |
三个都算的成本几乎为零 (二十行代码), 所以本项目全都算。但汇报时要说清你在看哪个, 因为它们经常给出相反的结论 —— 下面就会看到 fetch_k 那个例子: Recall 不动, MRR 掉了。
1.5 前缀匹配: 第 3 章的设计在这里兑现
def is_gold(chunk_id: str, golds: list[str]) -> bool:
"""chunk_id 形如 'lifecycle-activity-basic#2-3', 按 doc_id 前缀匹配。"""
doc_id = chunk_id.split("#", 1)[0]
return doc_id in golds
三行代码, 但它是第 3 章那个决定的落点。 gold_chunks 标的是 lifecycle-activity-basic (文档级), 而检索返回的是 lifecycle-activity-basic#3-3 (块级)。split("#", 1)[0] 取前缀就对上了。
正因为有这三行, 上一章那张 chunk_size 敏感性表才可能存在 —— 改切分参数不需要重标评测集。
split("#", 1) 的 1 限制只切一次。如果 doc_id 里有 # (不该有, 但防一手), 也只会在第一个 # 处切。
二、评测脚本: eval/eval_retrieval.py
2.1 先卡数据
def load_golden(path: str) -> list[dict]:
data = yaml.safe_load(pathlib.Path(path).read_text(encoding="utf-8"))
for q in data:
if not q.get("gold_chunks"):
raise ValueError(f"{q['id']}: gold_chunks 为空, 无法参与检索评测")
return data
又是一次 fail fast。 gold_chunks 为空的题, 它的 Recall 永远是 0 —— 混在评测集里会无声地拉低所有指标, 而你会去查检索代码。第 3 章的 check_golden.py 已经查过一遍了, 这里再查一次: 评测脚本不应该信任输入数据, 即使那份数据"应该"已经被校验过。
2.2 核心循环
def evaluate(
retriever: Retriever, golden: list[dict], k: int, fetch_k: int, mode: str
) -> dict[str, float]:
recalls, rrs, ndcgs = [], [], []
for q in golden:
hits = retriever.search(q["question"], top_k=k, fetch_k=fetch_k, mode=mode, threshold=None)
rels = [1 if is_gold(h.chunk.chunk_id, q["gold_chunks"]) else 0 for h in hits]
...
n = len(golden)
return {
f"Recall@{k}": sum(recalls) / n,
"MRR": sum(rrs) / n,
f"nDCG@{k}": sum(ndcgs) / n,
}
rels 是一个 0/1 列表, 比如 [0, 1, 0] 表示第 2 名命中。三个指标全部从这个列表算出来 —— 这就是为什么二十行够用。
注意 q["question"] 原样送进检索, 没有做任何改写、扩展、关键词提取。因为第 7 章的面试官也是原样送的 —— 评测必须和生产走同一条路径, 否则评出来的数字和线上无关。这是评测最容易犯的错: 评测脚本里悄悄做了一次 query 改写, 指标很好看, 上线后完全不是那回事。
f"Recall@{k}" 用 f-string 做 key, 所以 k=5 时表头自动变成 Recall@5。表头和参数联动, 避免了"改了 k 忘了改表头"这种误报。
2.3 消融表: 关键是接口设计
rows: list[tuple[str, dict[str, float]]] = []
plain = Retriever(idx, use_rerank=False)
for mode in ("bm25", "vector", "hybrid"):
rows.append((mode, evaluate(plain, golden, args.k, args.fetch_k, mode)))
if args.rerank:
rr = Retriever(idx, use_rerank=True)
rows.append(("hybrid+rerank", evaluate(rr, golden, args.k, args.fetch_k, "hybrid")))
这十行能存在, 完全靠上一章 search(mode=...) 那个参数。 如果检索器把三路硬编码在一起, 想跑消融就只能注释掉代码 —— 而注释掉代码跑出来的数字没人能复现。
这是本章最想教的工程习惯: 把"消融开关"设计进接口。 判断标准很简单 —— 你能不能用一个循环跑出对照表? 不能, 说明接口设计有问题。
use_rerank 要 new 一个新的 Retriever 而不是改属性, 因为精排模型是实例级缓存的。分开建两个实例, 不开精排的那个就永远不会下载模型。
2.4 表格输出和那句免责声明
headers = list(rows[0][1].keys())
print(f"| {'模式':14s} | " + " | ".join(f"{h:9s}" for h in headers) + " |")
print(f"|{'-' * 16}|" + "|".join("-" * 11 for _ in headers) + "|")
for name, m in rows:
print(f"| {name:14s} | " + " | ".join(f"{m[h]:9.4f}" for h in headers) + " |")
n = len(golden)
print()
print(f"注: 本评测集只有 {n} 题, 差异小于 {1 / n:.3f} (1/{n}) 只是一题之差, 没有统计意义。")
print(" 扩到 100 题以上再用这张表做技术选型决策。")
输出 Markdown 表格是为了能直接粘进文档和 PR。 这不是花架子: 指标只有被记录下来才有用, 而"能一键粘贴"直接决定了你会不会真的去记录。
最后那两行 print 是本章最重要的代码。 它把样本量的局限印在每一次输出里, 而不是写在某个 README 的角落。
为什么这么重要: 半年后有人翻到这张表, 看到 hybrid 0.9667 和 vector 0.9556, 会得出"hybrid 更好"的结论。而 0.0111 的差距在 15 题上连六分之一题都不到。免责声明和数字必须在同一屏。 把它写成代码而不是文档, 是因为文档会被遗忘, 而 print 每次都会跑。
{1 / n:.3f} 是自动计算的: 15 题算出 0.067, 换成 100 题会自动变成 0.010。这样扩了评测集之后, 免责声明的口气会自动放松。
三、跑出来
3.1 ✅ Checkpoint 1: 三路对照
uv run python -m eval.eval_retrieval
真实输出:
评测集 15 题, 知识库 25 块, top_k=3 fetch_k=16
| 模式 | Recall@3 | MRR | nDCG@3 |
|----------------|-----------|-----------|-----------|
| bm25 | 0.9333 | 0.9333 | 0.9066 |
| vector | 1.0000 | 0.9556 | 0.9560 |
| hybrid | 1.0000 | 0.9667 | 0.9647 |
注: 本评测集只有 15 题, 差异小于 0.067 (1/15) 只是一题之差, 没有统计意义。
扩到 100 题以上再用这张表做技术选型决策。
这张表能说明什么:
- BM25 的 Recall@3 = 0.9333 = 14/15, 掉了整整一题。这个差距 (0.0667) 正好等于一题, 是唯一超过噪声门槛的结论: 纯关键词检索在这个评测集上确实漏了东西。
- vector 和 hybrid 的 Recall 都是 1.0000, 15 题全命中。
这张表不能说明什么:
- hybrid (0.9667) 比 vector (0.9556) 好。 差 0.0111, 是 1/15 的六分之一 —— 换算成实际含义: 某一题从第 2 名升到第 1 名 (0.5 → 1.0, 除以 15 = 0.033)... 等等, 0.0111 甚至连"一题从第 2 升到第 1"都解释不了。它更可能是某题从第 3 名升到某个位置的零碎差异。这个量级的差距, 唯一诚实的说法是"分辨不出"。
为什么第 1 章还是把 RETRIEVE_MODE 默认设成 hybrid? 因为决策不能只看这一张表。混合检索的理由是机制上的 (上一章 1.1 节那个 23.87 vs 3.48 的例子, 和 hard-002 那个 BM25 完全失败的例子), 而不是"MRR 高了 0.0111"。指标用来否证 (BM25 确实差一题), 机制用来立论。
3.2 ✅ Checkpoint 2: 加上精排
uv run python -m eval.eval_retrieval --rerank
真实输出:
| 模式 | Recall@3 | MRR | nDCG@3 |
|----------------|-----------|-----------|-----------|
| bm25 | 0.9333 | 0.9333 | 0.9066 |
| vector | 1.0000 | 0.9556 | 0.9560 |
| hybrid | 1.0000 | 0.9667 | 0.9647 |
| hybrid+rerank | 1.0000 | 0.9333 | 0.9443 |
首次运行要下载 400MB 的 bge-reranker-base 模型, 慢是正常的。
精排让 MRR 从 0.9667 掉到 0.9333。 按"业界最佳实践", 这里应该写"加了精排 MRR 提升 3%"。实测是掉的, 所以:
USE_RERANK默认false- 这张表照原样贴在文档里
- 但结论必须写准: 0.0334 的差距不到一题, 正确说法是"在这个数据集上没看出收益", 不是"精排没用"
3.3 逐题拆解: 掉的是哪一题
汇总指标只告诉你"掉了", 不告诉你"哪里掉了"。所以要能拆到题。
✅ Checkpoint 3:
uv run python -c "
import yaml, pathlib
from app.rag.index import KnowledgeIndex
from app.rag.retriever import Retriever
from eval.eval_retrieval import is_gold
golden = yaml.safe_load(pathlib.Path('eval/golden.yaml').read_text(encoding='utf-8'))
r = Retriever(KnowledgeIndex())
for q in golden:
row = []
for mode in ('bm25', 'vector', 'hybrid'):
hits = r.search(q['question'], top_k=3, fetch_k=16, mode=mode, threshold=None)
rels = [1 if is_gold(h.chunk.chunk_id, q['gold_chunks']) else 0 for h in hits]
rr = 0.0
for i, x in enumerate(rels, 1):
if x:
rr = 1.0 / i
break
row.append(f'{mode}={rr:.2f}')
print(f\" {q['id']:22s} \" + ' '.join(row))
"
真实输出:
android-lifecycle-001 bm25=1.00 vector=1.00 hybrid=1.00
android-lifecycle-002 bm25=1.00 vector=1.00 hybrid=1.00
android-kotlin-001 bm25=1.00 vector=1.00 hybrid=1.00
android-kotlin-002 bm25=1.00 vector=1.00 hybrid=1.00
android-jetpack-001 bm25=1.00 vector=1.00 hybrid=1.00
android-jetpack-002 bm25=1.00 vector=1.00 hybrid=1.00
android-perf-001 bm25=1.00 vector=1.00 hybrid=1.00
android-perf-002 bm25=1.00 vector=1.00 hybrid=1.00
android-lifecycle-003 bm25=1.00 vector=1.00 hybrid=1.00
android-service-001 bm25=1.00 vector=1.00 hybrid=1.00
android-perf-003 bm25=1.00 vector=1.00 hybrid=1.00
android-hard-001 bm25=1.00 vector=1.00 hybrid=1.00
android-hard-002 bm25=0.00 vector=1.00 hybrid=0.50
android-hard-003 bm25=1.00 vector=0.33 hybrid=1.00
android-hard-004 bm25=1.00 vector=1.00 hybrid=1.00
这十五行比上面那张汇总表信息量大得多。
前 11 道题 (标准问法) 三种模式全部满分。 第 3 章说"前 11 道检验正常情况下系统能不能工作" —— 现在验证了, 而且说明这 11 道题对区分方案毫无贡献。整张消融表的全部差异, 都来自后 4 道改写问法的题。
这直接印证了第 3 章的核心主张: 只有标准问法的评测集会给你虚高的分数。如果没有那 4 道题, 这张表会是三行 1.0000 / 1.0000 / 1.0000, 你会得出"三种模式一样好, 用最简单的 BM25 就行"—— 然后上线后被用户的口语问法打脸。
具体到两道关键题:
- hard-002 ("卡住不动、弹窗说无响应"): BM25 = 0.00 (完全失败), vector = 1.00, hybrid = 0.50。这一题独自造成了 BM25 的 Recall 掉到 0.9333, 也独自造成了 hybrid 的 MRR 不满分。
- hard-003 ("第一个界面什么时候才算彻底停下来"): BM25 = 1.00, vector = 0.33 (正确答案排第 3), hybrid = 1.00。这是向量弱于 BM25 的一题, 而 hybrid 把它救回了第 1 名。
hybrid 的价值就在这两行里: 它在 hard-002 上继承了向量的能力 (从 0 变成 0.5), 在 hard-003 上继承了 BM25 的能力 (从 0.33 变成 1.0)。两路各有各的错, 但错的地方不一样 —— 这句话现在有数据了。
而 hybrid 在 hard-002 上只拿到 0.50 而不是 1.00, 就是它 MRR 不满分的全部原因。下面会挖出这个 0.50 的成因, 结论会有点意外。
3.4 精排掉分掉在哪
uv run python -c "
import yaml, pathlib
from app.rag.index import KnowledgeIndex
from app.rag.retriever import Retriever
from eval.eval_retrieval import is_gold
golden = yaml.safe_load(pathlib.Path('eval/golden.yaml').read_text(encoding='utf-8'))
rr = Retriever(KnowledgeIndex(), use_rerank=True)
for q in golden:
hits = rr.search(q['question'], top_k=3, fetch_k=16, mode='hybrid', threshold=None)
rels = [1 if is_gold(h.chunk.chunk_id, q['gold_chunks']) else 0 for h in hits]
v = 0.0
for i, x in enumerate(rels, 1):
if x:
v = 1.0 / i
break
if v < 1.0:
print(f\" {q['id']:20s} rerank_mrr={v:.2f} {[h.chunk.chunk_id for h in hits]}\")
"
真实输出:
android-hard-001 rerank_mrr=0.50 ['perf-memory-leak#2-3', 'jetpack-viewmodel#0-0', 'lifecycle-service#2-2']
android-hard-003 rerank_mrr=0.50 ['lifecycle-service#0-0', 'lifecycle-activity-basic#3-3', 'lifecycle-activity-basic#0-0']
注意: 精排掉分的是 hard-001 和 hard-003, 而不带精排掉分的是 hard-002。 也就是说精排修好了 hard-002, 又弄坏了另外两题。净效果是 MRR 从 0.9667 掉到 0.9333。
hard-001 ("手机横过来之后我页面上填的东西全没了"): 精排把 perf-memory-leak#2-3 (内存泄漏怎么定位) 排到了第一, 正确答案 jetpack-viewmodel#0-0 挤到第二。cross-encoder 认为"东西全没了"和"内存泄漏"更相关 —— 从字面看不无道理, 但它错了。
这告诉我们一件关于精排的重要事实: 它不是"在原有排序上做微调", 而是完全重新打分。 上一章讲过精排会把 h.score 整个替换掉 —— 所以 RRF 融合的成果被全部丢弃了。修对两题、弄坏两题, 是一个更强但行为不同的模型的正常表现, 而不是 bug。
在 15 题上这就是"没看出收益"。 要判断精排到底行不行, 需要的是 100 题 + 分层看 (标准问法 vs 改写问法分开算), 而不是换个模型再试一次。
四、参数敏感性: 三张实测表
指标脚本最大的价值不是产出那一张表, 而是能让你在半分钟内量任何一个参数。
4.1 top_k 改了会怎样
uv run python -c "
from app.rag.index import KnowledgeIndex
from app.rag.retriever import Retriever
from eval.eval_retrieval import evaluate, load_golden
golden = load_golden('eval/golden.yaml')
r = Retriever(KnowledgeIndex())
for k in (1, 2, 3, 5, 10):
m = evaluate(r, golden, k, 16, 'hybrid')
print(f' top_k={k:>2} ' + ' '.join(f'{kk}={vv:.4f}' for kk, vv in m.items()))
"
真实输出:
top_k= 1 Recall@1=0.9333 MRR=0.9333 nDCG@1=0.9333
top_k= 2 Recall@2=1.0000 MRR=0.9667 nDCG@2=0.9754
top_k= 3 Recall@3=1.0000 MRR=0.9667 nDCG@3=0.9647
top_k= 5 Recall@5=1.0000 MRR=0.9667 nDCG@5=0.9434
top_k=10 Recall@10=1.0000 MRR=0.9667 nDCG@10=0.9353
读出三件事:
- Recall 从 K=2 开始就满了, MRR 从 K=2 开始就不动了。 因为唯一没排在第 1 的是 hard-002 (排第 2) —— K≥2 之后就没有新增命中了。
- nDCG 随 K 增大而下降 (0.9754 → 0.9353)。这不是变差了, 是 nDCG 的定义决定的: K 越大, 分母里的理想 DCG 不变而分子多了一堆 0 贡献的位置。nDCG@K 在不同 K 之间不可比 —— 这是一个很常见的误读。
- 上一章说的"块数必须显著大于 top_k"的诊断法在这里生效了: top_k 从 3 减到 1, Recall 从 1.0 掉到 0.9333。有掉, 说明检索确实在工作, 没有退化成"全都返回"。要是 top_k=1 还满分, 那才该警惕。
本项目 top_k 定 5 而不是 2: 因为面试官出题时多给两块资料能让题目更饱满 (第 7 章), 而不是为了指标。指标满了不等于参数选好了, 下游用途也有发言权。
4.2 fetch_k 改了会怎样: 一个反直觉的结果
uv run python -c "
from app.rag.index import KnowledgeIndex
from app.rag.retriever import Retriever
from eval.eval_retrieval import evaluate, load_golden
golden = load_golden('eval/golden.yaml')
r = Retriever(KnowledgeIndex())
for fk in (3, 6, 10, 16, 25):
m = evaluate(r, golden, 3, fk, 'hybrid')
print(f' fetch_k={fk:>2} ' + ' '.join(f'{kk}={vv:.4f}' for kk, vv in m.items()))
"
真实输出:
fetch_k= 3 Recall@3=1.0000 MRR=1.0000 nDCG@3=0.9839
fetch_k= 6 Recall@3=1.0000 MRR=1.0000 nDCG@3=0.9839
fetch_k=10 Recall@3=1.0000 MRR=1.0000 nDCG@3=0.9893
fetch_k=16 Recall@3=1.0000 MRR=0.9667 nDCG@3=0.9647
fetch_k=25 Recall@3=1.0000 MRR=0.9667 nDCG@3=0.9647
fetch_k 从 10 加到 16, MRR 从 1.0000 掉到 0.9667。 候选池变大, 排名反而变差。
我第一反应是有 bug。追下去发现不是 bug, 是 RRF 的机制。 掉分的还是 hard-002, 看它的两个候选池:
uv run python -c "
from app.rag.index import KnowledgeIndex
from app.rag.retriever import Retriever
r = Retriever(KnowledgeIndex())
q = '用户反馈点一下按钮 App 就卡住不动然后弹窗说无响应, 排查思路是什么?'
print('bm25 pool:', [(c, round(s, 3)) for c, s in r.bm25_search(q, 16)])
for fk in (10, 16):
rank = {c: i for i, (c, _) in enumerate(r.vector_search(q, fk), 1)}
print(f'fetch_k={fk} 错答案的向量排名 =', rank.get('lifecycle-activity-basic#3-3'),
'| 正确答案 =', rank.get('perf-anr#0-0'))
"
真实输出:
bm25 pool: [('lifecycle-activity-basic#3-3', 2.748)]
fetch_k=10 错答案的向量排名 = None | 正确答案 = 1
fetch_k=16 错答案的向量排名 = 14 | 正确答案 = 1
成因清楚了:
- BM25 只召回一条, 而且是错的 (
lifecycle-activity-basic#3-3, BM25 排名 1) - 正确答案
perf-anr#0-0只被向量召回, 排名 1 fetch_k=10时, 错答案的向量排名是 14 —— 超出池子, 没进来。所以它只有 BM25 一路的分:1/61 = 0.0164。正确答案也是一路:1/61 = 0.0164。平手, 但正确答案因为字典序或插入顺序排在前面fetch_k=16时, 错答案的向量排名 14 进池了。于是它拿到两路的分:1/61 + 1/74 = 0.0299, 反超只有一路分的正确答案 (0.0164)
这就是上一章 3.4 节说的"RRF_K=60 让两路都召回胜过单路排第一"的代价。 一个两路都排得很靠后的块 (bm25#1 + vec#14), 会打败一个只有单路但排第一的块 (vec#1)。
这个发现有三层价值:
- 实测推翻了直觉。 "候选池越大越好"听起来天经地义, 实际不是。
- 它定位到了机制, 而不是停在"数字变了"。 从"MRR 掉了"到"RRF 的 K 值权衡"只用了两条命令 —— 靠的是上一章
Hit.explain和vector_search可以单独调用这两个设计。 - 但它不构成"应该把 fetch_k 调到 10"的结论。 因为整个差异只来自一道题。fetch_k=10 在这 15 题上是 1.0000, 那是过拟合到一道题。真要调, 得先有 100 题。
本项目的默认值是 FETCH_K=30 (第 1 章配置), 比这里量的 16 还大。为什么不改? 因为 fetch_k 的作用是给精排留挑选空间, 而这里的最优值是从一道题上得出的。知道一个参数会影响什么、影响多大, 和知道它该设多少, 是两件事。前者靠量, 后者需要足够的样本。
4.3 chunk_size 对检索指标的影响
第 4 章量过 chunk_size 对块数的影响, 现在量它对检索质量的影响 —— 这才是真正关心的那个。
uv run python -c "
from app.rag.index import KnowledgeIndex
from app.rag.retriever import Retriever
from eval.eval_retrieval import evaluate, load_golden
golden = load_golden('eval/golden.yaml')
for cs in (150, 300, 500):
idx = KnowledgeIndex(db_dir=f'/tmp/ck_cs{cs}')
n = idx.rebuild('corpus', chunk_size=cs, overlap=60)
m = evaluate(Retriever(idx), golden, 3, 16, 'hybrid')
print(f' chunk_size={cs:>3} ({n} 块) ' + ' '.join(f'{k}={v:.4f}' for k, v in m.items()))
"
真实输出:
chunk_size=150 (40 块) Recall@3=1.0000 MRR=0.9556 nDCG@3=0.9560
chunk_size=300 (25 块) Recall@3=1.0000 MRR=0.9667 nDCG@3=0.9647
chunk_size=500 (24 块) Recall@3=1.0000 MRR=0.9667 nDCG@3=0.9647
注意这里用了 db_dir=/tmp/ck_cs{cs} —— 每个参数一个独立的库, 不污染 data/chroma。做参数实验时一定要隔离数据目录, 否则跑完实验你的生产库里是最后一次实验的块。
结论: 300 和 500 完全一样, 150 略差 (MRR 0.9556)。 这和第 4 章的块数表一致 (300 和 500 只差一块)。150 略差的原因是块被切碎后, 单块信息量下降, 排名更容易被噪声干扰。
但 0.0111 的差距仍然在噪声范围内。 所以正确的说法是: 在这个评测集上, chunk_size 在 300~500 之间没有可测量的差异, 150 可能略差但也不确定。 这就是第 4 章"chunk_size 在语料规范的项目里几乎是死参数"这句话的完整依据 —— 那一章量的是块数, 这一章量的是效果, 两个都指向同一个结论。
五、这张表能和不能做什么
5.1 三种用法, 只有一种可靠
| 用途 | 15 题够吗 | 为什么 |
|---|---|---|
| 回归测试 ("我改坏了吗") | ✅ 够 | 改坏了检索, 这 15 题里一定有掉的。掉一题 = 0.0667, 一眼能看见 |
| 定位问题 ("哪里坏了") | ✅ 够 | 逐题拆解直接指到 android-hard-002 |
| 技术选型 ("A 比 B 好") | ❌ 不够 | 0.0111 的差距连六分之一题都不到, 是噪声 |
第一种用法是评测集的第一价值, 也是最容易被低估的价值。 第 12 章会把 eval_retrieval 挂到 CI 上, 设一个阈值 (比如 Recall@3 >= 0.93), 谁提的 PR 让它掉下去就不许合并。这时候 15 题完全够用 —— 因为它要检测的是"明显变坏", 不是"细微变好"。
5.2 "提升了 3%" 这句话的问题
假设有人告诉你"我优化了检索, MRR 提升了 3%"。要问三个问题:
- 评测集多少题? 15 题的 3% 是 0.029, 而 1/15 = 0.067。也就是说 3% 连半题都不到 —— 完全可能是一题从第 3 名升到第 2 名的零碎变化。
- 是哪几题变了? 如果是"5 题变好、4 题变坏, 净赚 1 题", 那和"1 题变好、其他不变"是完全不同的结论。逐题拆解能区分。
- 改动是否有机制上的解释? 本章那个 fetch_k 的例子: 数字变了, 而且能追到 RRF 的 K 值权衡 —— 有机制解释的变化才值得相信。找不到机制的指标提升, 大概率是噪声或过拟合。
这三个问题也是面试时最能体现"你真的做过评测"的地方。
5.3 扩到 100 题的成本
第 3 章说"扩到 100 题以上才够做选型, 那时的成本是标注人力"。具体算一下:
- 每题要写: question + gold_chunks + 3 个 rubric 要点 + 权重 + (可选) followups
- 熟练之后一题 10~15 分钟, 主要时间花在从语料里抄要点而不是想题目
- 85 道新题 = 约 18 小时
18 小时能换来什么: 指标粒度从 0.067 降到 0.01, 也就是"3% 的提升"从噪声变成了三题之差, 可以谈了。这是这个项目里性价比最高的一笔投入, 但也是最没人愿意做的。
踩坑与专家提示
- 评测必须和生产走同一条检索路径。 评测脚本里悄悄加一次 query 改写, 指标会很好看, 上线后完全不是那回事。本项目
q["question"]原样送进search。 - 做参数实验一定要隔离数据目录。
KnowledgeIndex(db_dir=f'/tmp/ck_cs{cs}')。不隔离的话跑完实验你的data/chroma里是最后一次实验的块, 而且你不会发现。 - nDCG@K 在不同 K 之间不可比。 K 越大分子多一堆 0 贡献的位置, nDCG 自然下降。看到 nDCG@10 < nDCG@3 不是变差了。
- 免责声明要印在输出里, 不要写在文档角落。 半年后翻到这张表的人不会去读 README。把
1/n算出来 print, 比任何注释都可靠。 - 汇总指标必须能拆到题。 汇总说"掉了", 逐题说"哪题掉了", 只有后者能修。本章两条诊断命令都是二十行的一次性脚本 —— 不需要做成工具。
gold_chunks为空的题会无声拉低所有指标。 第 3 章的check_golden.py查过, 这里load_golden再查一次。评测脚本不要信任输入数据。- 指标满了不代表参数选好了。 top_k 从 2 加到 5 指标完全不动, 但第 7 章需要 5 块资料才能出饱满的题。下游用途也有发言权。
- 不要根据一道题调参。 fetch_k=10 在这 15 题上 MRR 满分, 但那是过拟合到 hard-002 一道题。差异只来自一题时, 唯一诚实的结论是"分辨不出"。
面试视角: 本章高频五问
Q1: Recall / MRR / nDCG 分别在衡量什么? 你主要看哪个?
Recall@K 看"找得到吗" (前 K 里至少一条命中就算成功), MRR 看"排第几名" (第一个命中的排名倒数), nDCG@K 看"整个前 K 名的排序有多接近最优"。日常我主看 Recall (它是 0/1 的, 掉了就是真掉了) 和 MRR (对第一名位置最敏感, 而提示词有长度上限、模型对开头注意力更高, 所以排名真的影响下游)。要注意 nDCG@K 在不同 K 之间不可比 —— K 大了分子多一堆 0 贡献的位置。
Q2: 你的 Recall@3 从 0.9333 提到 1.0000, 这个提升可信吗?
作为"BM25 确实漏了一题"这个结论可信, 因为 0.0667 正好等于 1/15, 而且逐题拆解能指到具体是 android-hard-002 —— 一道用口语描述 ANR 现象、语料里一个词都不重合的题, BM25 完全召回不到。但 hybrid 比 vector 的 MRR 高 0.0111, 这个我不会当结论 —— 连六分之一题都不到, 是噪声。我用指标来否证 (BM25 差一题), 用机制来立论 (专有名词 vs 口语问法各有一路擅长)。
Q3: 只有 15 题的评测集, 有什么用?
三种用法里两种可靠: 回归测试 (改坏了这 15 题一定有掉的, 掉一题就是 0.0667, 一眼能看见) 和定位问题 (逐题拆解直接指到具体哪一题)。第三种 —— 技术选型 —— 不可靠。第 12 章我把它挂到 CI 上设了阈值, 谁的 PR 让 Recall 掉下去就不许合并; 这个用途 15 题完全够, 因为它检测的是"明显变坏"不是"细微变好"。
Q4: 你调参的时候踩过什么反直觉的坑?
把 fetch_k 从 10 加到 16, MRR 从 1.0000 掉到 0.9667 —— 候选池变大排名反而变差。追下去是 RRF 的机制: 那道题 BM25 只召回一条而且是错的 (bm25#1), 正确答案只被向量召回 (vec#1)。fetch_k=10 时错答案的向量排名是 14, 进不了池, 两者都只有一路分; fetch_k=16 时它进池了, 拿到 1/61 + 1/74 = 0.0299, 反超只有 1/61 的正确答案。这就是 RRF_K=60 让"两路都召回"胜过"单路排第一"的代价。但我没据此把 fetch_k 改成 10 —— 整个差异只来自一道题, 那是过拟合。
Q5: 有人说"我优化了检索, 指标提升 3%", 你会问什么?
三个问题。一, 评测集多少题? 15 题的 3% 是 0.029, 而一题就是 0.067 —— 3% 连半题都不到。二, 是哪几题变了? "5 好 4 坏净赚 1 题"和"1 题变好其他不变"是完全不同的结论, 逐题拆解能区分。三, 有机制上的解释吗? 有机制解释的变化才值得相信, 找不到机制的提升大概率是噪声或过拟合。能区分"我知道"、"我以为"和"我量了但样本不够", 比多学一个框架值钱。
划重点
- Recall@K 看找得到吗, MRR 看排第几, nDCG@K 看整体排序质量。三个指标二十行代码就能实现, 不需要评测库。
is_gold的前缀匹配 (三行) 是第 3 章"标文档级"那个决定的落点 —— 有它, 改切分参数才不用重标。- 消融表能存在, 靠的是
search(mode=...)这个接口设计。判断标准: 你能不能用一个循环跑出对照表。 - 免责声明要 print 在输出里, 而且
1/n要自动算。文档会被遗忘, print 每次都会跑。 - 整张消融表的全部差异都来自后 4 道"改写问法"的题。前 11 道标准问法题三种模式全满分 —— 只有标准问法的评测集会给你虚高的分数。
- hybrid 的价值在两行数据里: hard-002 继承向量能力 (0 → 0.5), hard-003 继承 BM25 能力 (0.33 → 1.0)。两路的错不在同一处。
- 精排在这个数据集上修好两题又弄坏两题, 净效果是 MRR 掉 0.0334。结论是"没看出收益", 不是"没用"。
fetch_k变大 MRR 变差, 成因是 RRF 的 K 值权衡 —— 但只来自一道题, 不构成调参依据。- 15 题够做回归测试和问题定位, 不够做技术选型。扩到 100 题约需 18 小时标注, 换来指标粒度从 0.067 降到 0.01。
下一章预告
检索这条线到这里闭环了: 语料 → 切块 → 入库 → 混合检索 → 有数字的评测。下一章开始做面试官本体: 一个六状态的显式状态机 (开场 → 出题 → 判分 → 追问/降难度 → 报告 → 结束)、一个"模型只判要点命中、分数由代码算权重"的判分器、以及难度自适应的选题逻辑。你会看到为什么"让模型自己管流程"在多轮对话里一定会跑偏, 以及一个 Pydantic 模型如何把 LLM 的自由发挥框回可用的结构。