03 · 黄金评测集与判分标准
一句话总结
这一章做全项目最枯燥、也最能决定成败的一件事: 在写任何检索代码之前, 先手写 15 道题的评测集 —— 每道题标好"正确答案应该来自哪篇语料"(给检索评测用) 和"答对要讲到哪几个要点、各占多少权重"(给判分用)。写完这份 YAML, 你才第一次拥有了"变好了"的定义。
本章你能学到什么
- 为什么评测集要写在功能代码之前, 而不是"先做出来再评估"
- rubric (评分细则) 怎么写: 要点怎么拆、权重怎么定、权重和为什么必须是 10
- 一份评测集怎么同时服务三件事: 检索评测、判分器校准、微调种子
- 为什么要故意放"改写问法"的难题, 它专门用来暴露什么问题
- 评测集自己也需要被校验 —— 一个 60 行的自检脚本
一、为什么先写评测集
1.1 "感觉好了一些"是这个项目要消灭的东西
不写评测集的开发流程长这样:
我把 chunk_size 从 300 改成 500, 试了几个问题, 感觉好了一些。
这句话有三个问题: "几个问题"是哪几个 (可能刚好是对新参数友好的)、"好了一些"是多少 (0.1% 还是 10%)、下次改别的参数时怎么和这次比。最要命的是它不可回溯 —— 三周后你想知道"当初为什么定 500", 没有任何记录。
写了评测集之后是这样 (第 6 章的真实输出):
| 模式 | 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 |
同一份数据、同一个脚本, 任何人任何时候都能重跑出这张表。 这就是差别。
1.2 顺序为什么是"先评测集, 后功能"
因为评测集会反过来影响你的设计。
真实例子: 写这份评测集时, 我加了一道题 ——"手机横过来之后我页面上填的东西全没了, 有什么官方组件能解决这个问题?"这道题里一个 API 名字都没有, 语料里也没有"手机横过来"这个说法。写完这道题我立刻意识到: 纯 BM25 关键词检索会完全查不到它, 因为没有一个词能对上。
于是第 5 章的混合检索不是"因为业界都这么做"才加的, 而是因为评测集里有 4 道这样的题。反过来, 如果先做完纯 BM25 检索再写评测集, 你大概会不自觉地只写那些 BM25 能查到的题 —— 这叫指标迎合, 是评估工作最常见的自我欺骗。
1.3 一份数据, 三个用途
这份 eval/golden.yaml 的字段是精心设计的, 因为它要同时服务三件事:
| 字段 | 服务谁 | 在哪一章用 |
|---|---|---|
question + gold_chunks |
检索评测: 拿 question 去检索, 看 gold_chunks 有没有出现在 top-K | 第 6 章 |
question + rubric |
出题与判分: 直接作为题目出给候选人, 拿 rubric 判分 | 第 7 章 |
question + rubric + 真实回答 |
判分器校准: 和人工打分对比算 MAE | 第 9 章 |
question + rubric + 模型输出 |
微调种子: 导出成训练样本 | 第 10 章 |
followups |
追问的题目池 | 第 7 章 |
这是本章设计上最值得学的一点。 很多项目有三套互不相干的标注数据: 检索评测集、判分测试集、微调集。三套各自维护, 各自过时。合并成一套的代价是设计时多想半小时, 收益是永远不会出现"检索评测用的知识点和面试实际考的知识点不一样"这种荒谬情况。
二、评测集的结构
2.1 一道题的完整样子
eval/golden.yaml 开头两行是给未来的自己看的说明:
# 黄金评测集: 一份数据三用 —— 检索评测 / 评分器校准 / 微调种子
# 每题的 gold_chunks 填 doc_id 前缀, 评测时按前缀匹配 chunk_id, 这样切块参数变了标注不用重做。
第二行是一个重要设计, 3.3 节细说。第一道题:
- id: android-lifecycle-001
topic: 四大组件/Activity生命周期
difficulty: 2
question: 从 Activity A 跳到 B, 再按返回键回到 A, 两个 Activity 的生命周期回调顺序分别是什么?
gold_chunks: [lifecycle-activity-basic]
rubric:
- {point: "跳转时 A.onPause 先于 B.onCreate", weight: 3}
- {point: "A.onStop 发生在 B.onResume 之后, 即 B 完全可见后 A 才停止", weight: 3}
- {point: "返回时 B.onPause -> A.onRestart/onStart/onResume -> B.onStop/onDestroy", weight: 4}
followups:
- 如果 B 是 Dialog 主题的半透明 Activity, A 还会走 onStop 吗?
- 为什么系统要设计成 A.onStop 在 B.onResume 之后?
逐个字段说明:
id: 题目唯一标识, 命名android-<板块>-<序号>。第 9 章的人工标注集grader_cases.yaml靠qid引用它。topic: 和语料的topic必须用完全一样的字符串。因为面试报告要按 topic 分组, 错题本按 topic 记, 而选题器要用 topic 去匹配"薄弱知识点"。写成"Activity生命周期"和"四大组件/Activity生命周期"两种就对不上了。difficulty: 1~3。第 7 章的难度自适应按它选题。question: 面试时原样问出去的题目, 不是关键词。gold_chunks: 这道题的答案应该来自哪些语料。检索评测的标准答案。rubric: 评分细则, 本章的核心, 下一节详说。followups: 答得好时追问什么。可以为空 ([])。
2.2 rubric: 怎么把"答对"拆成可计算的东西
这是全项目最重要的一个数据结构, 因为分数完全由它算出来:
分数 = 命中要点的权重和 / 总权重 × 10
上面那道题, 如果候选人只讲清了跳转顺序和 onStop 时机, 没讲返回时的顺序, 那是 (3 + 3) / 10 × 10 = 6.0 分。这个数字可以手算验证, 也可以向候选人解释清楚"你为什么是 6 分"。
要点怎么拆 —— 三条规则:
规则一: 每个要点是一个可判断"讲了没讲"的独立陈述。
反例: {point: "理解 Activity 生命周期", weight: 10} —— 这没法判断, 讲了三分之一算不算讲了?
正例: {point: "在 onStop 之前调用", weight: 4} —— 讲了或没讲, 二元的。
规则二: 2~5 个要点。
少于 2 个, 分数就只有 0 和 10 两档, 区分度太低。多于 5 个, 一是候选人在口述场景下不可能全讲到 (分数普遍偏低, 没有区分度), 二是判分模型要同时判断 6 件事, 准确率下降。本项目 15 道题全部是 3 个要点 (平均 3.00), 这不是巧合, 是刻意保持一致。
规则三: 权重和必须是 10。
不是数学上必须 —— 公式里除了总权重, 权重和是 7 还是 10 算出来的分都在 0~10 之间。但统一成 10 有一个实际好处: 你看到 weight: 4 就立刻知道"这个要点占 40%", 不需要先去把这道题的权重加一遍。标注 15 道题时, 这点心智负担的差别很明显。第四节的自检脚本会强制检查这一条。
权重怎么定 —— 一个简单标准: 漏了这一点, 面试官会不会觉得"你其实没懂"?
看那道 onSaveInstanceState 的题:
rubric:
- {point: "在 onStop 之前调用", weight: 4}
- {point: "只在系统主动销毁时调用, 用户主动按返回键退出不调用", weight: 4}
- {point: "恢复可在 onCreate 的 savedInstanceState 或 onRestoreInstanceState 读", weight: 2}
前两点各 4 分: 不知道调用时机、不知道返回键不触发, 都属于"没懂"。第三点 2 分: 不知道在哪读只是没记住 API 位置, 查一下就行, 不算理解问题。
这份权重是主观的, 而且必须主观。 关键不是"权重定得客观", 而是权重被显式写下来了 —— 谁不同意都可以来改这个 YAML, 而不是去猜模型脑子里的标准。这是"可解释"的真正含义。
2.3 gold_chunks 为什么填 doc_id 而不是 chunk_id
eval/golden.yaml 顶部那句注释:
每题的 gold_chunks 填 doc_id 前缀, 评测时按前缀匹配 chunk_id, 这样切块参数变了标注不用重做。
第 4 章会把 7 篇语料切成 25 个块, 每个块的 id 长这样: lifecycle-activity-basic#2。如果评测集直接标 lifecycle-activity-basic#2, 那么每次改切分参数, 25 个块的编号全变, 15 道题的标注全部作废。
标 lifecycle-activity-basic (文档级), 评测时用前缀匹配判断命中, 切分参数怎么调都不影响标注。
代价说清楚: 粒度变粗了。命中同一篇语料里"不相关的那一节"也算命中, 所以 Recall 会略微偏高。对本项目可以接受, 因为一篇语料只有 400~900 字、3~4 个块, 而且同一篇内的内容主题相近。如果一篇语料有 200 个块, 就必须标到 chunk 级, 那时对应的成本就是"改切分要重标"。
这是一个典型的标注成本 vs 指标精度的权衡, 面试时能把这个权衡讲清楚, 比说"我做了 RAG 评测"有价值得多。
三、15 道题怎么选
3.1 覆盖面: 按知识点分布, 不按题目难度凑数
Jetpack/ViewModel 3 题
Kotlin/协程 3 题
四大组件/Activity生命周期 3 题
四大组件/Fragment生命周期 1 题
四大组件/Service 1 题
性能优化/ANR 2 题
性能优化/内存泄漏 2 题
难度分布 {2: 10, 3: 5} —— 十道中等、五道偏难, 没有难度 1。因为难度 1 的题 (比如"Activity 有几个生命周期回调") 区分不出候选人, 会答的都会答, 放在评测集里只是在浪费 API 调用。
注意每个 topic 至少 1 题: 这是为了保证"报告里的知识点分组"不会有空档。如果某个 topic 一道题都没有, 那这个 topic 的语料等于从来没被验证过。
3.2 前 11 道: 标准问法
前 11 道题用的是语料里出现过的术语, 比如"onSaveInstanceState 在什么时机被调用?"、"startService 和 bindService 启动的 Service 在销毁条件上有什么区别?"。这些题检验的是"正常情况下系统能不能工作"。
3.3 后 4 道: 故意改写问法, 专门逼出检索的短板
评测集文件里有一行分隔注释:
# ---- 改写问法组: 故意不用语料里的原词, 用来把 BM25 和向量检索的差距逼出来 ----
四道题:
- id: android-hard-001
question: 手机横过来之后我页面上填的东西全没了, 有什么官方组件能解决这个问题?
gold_chunks: [jetpack-viewmodel]
- id: android-hard-002
question: 用户反馈点一下按钮 App 就卡住不动然后弹窗说无响应, 排查思路是什么?
gold_chunks: [perf-anr]
- id: android-hard-003
question: 我在第一个界面开了个新界面, 第一个界面什么时候才算彻底停下来?
gold_chunks: [lifecycle-activity-basic]
- id: android-hard-004
question: 为什么说协程比线程省资源? 它省在哪?
gold_chunks: [kotlin-coroutine-basic]
请对比一下词汇:
| 题目里的说法 | 语料里的说法 | BM25 能对上吗 |
|---|---|---|
| 手机横过来 | 配置变更 / 屏幕旋转 | ❌ 一个字都不重合 |
| 卡住不动、弹窗说无响应 | ANR、主线程超时 | ❌ |
| 彻底停下来 | onStop | ❌ |
| 省资源 | 轻量级调度单位 | ❌ |
这四道题是整个评测体系的价值所在。 只有标准问法的评测集会给你一个虚高的分数, 让你误以为纯关键词检索就够了。加上这四道, 第 6 章的对照表里 BM25 单路的 Recall@3 就掉到了 0.9333, 而向量检索是 1.0000 —— 这个差距是这四道题制造出来的, 不是自然出现的。
为什么用户的真实问法更像这四道? 因为知道 onSaveInstanceState 这个词的人根本不需要问。真实场景里, 用户描述的是现象, 而语料写的是术语 —— 这个鸿沟就是语义检索存在的全部理由。
3.4 followups: 追问从哪来
10 道题带了 followups, 5 道是空的。追问题目手写而不是让模型生成, 原因和第 1 章配置不写死一样: 手写的追问有出处、可以 review、不会跑偏。
followups:
- 如果 B 是 Dialog 主题的半透明 Activity, A 还会走 onStop 吗?
- 为什么系统要设计成 A.onStop 在 B.onResume 之后?
注意这两个追问的性质不同: 第一个是边界情况, 第二个是设计意图。这正好对应面试里"深一层"的两个方向 —— 你知道特例吗、你知道为什么吗。第 7 章的状态机会按顺序取用它们。
没有 followups 的题会怎样: 第 7 章的状态机发现追问池空了, 就直接换下一题, 不会崩。允许字段为空但不允许字段缺失 —— followups: [] 是明确的"没有", 而缺这个字段是"忘写了", 两者要能区分。
四、评测集自己也要被校验
4.1 为什么
评测集是手写的 183 行 YAML, 手写就一定会错。会错的地方有四类:
- 权重加起来不是 10 (最常见, 复制粘贴改数字时漏改一个)
gold_chunks里写了一个不存在的doc_id(语料重命名后忘了同步)- 题目
id重复 (复制上一题时忘了改 id) - 缺字段
这四类错误的后果都很隐蔽。第 2 类最坏: 标准答案指向一篇不存在的语料, 这道题永远算作"检索失败", 你的 Recall 会莫名其妙低一截 —— 然后你去查检索代码。
4.2 scripts/check_golden.py
"""评测集自检: 在跑任何检索之前先确认标注本身是合法的。
用法: python -m scripts.check_golden [评测集路径]
"""
from __future__ import annotations
import collections
import pathlib
import sys
import yaml
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from app.rag.loader import load_corpus # noqa: E402
GOLDEN = pathlib.Path(__file__).resolve().parents[1] / "eval" / "golden.yaml"
REQUIRED = ("id", "topic", "difficulty", "question", "gold_chunks", "rubric")
那句 sys.path.insert 和 # noqa: E402: scripts/ 下的脚本要 import 项目自己的 app.rag.loader, 而直接 python scripts/check_golden.py 时项目根目录不在 sys.path 里。所以先手动插入路径, 再 import —— 这就要求 import 语句写在代码后面, 违反了 ruff 的 E402 规则, 于是加 # noqa 单行豁免。第 1 章的 pyproject.toml 里也给 scripts/*.py 整体豁免了 E402, 两处都有是有意的: 文件级豁免防止漏写, 行级注释解释为什么。
主体逻辑:
def main() -> int:
path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else GOLDEN
items = yaml.safe_load(path.read_text(encoding="utf-8"))
docs = {d.doc_id for d in load_corpus("corpus")}
errors: list[str] = []
seen: set[str] = set()
for i, item in enumerate(items):
where = item.get("id", f"第 {i + 1} 题")
for key in REQUIRED:
if key not in item:
errors.append(f"{where}: 缺字段 {key}")
if item.get("id") in seen:
errors.append(f"{where}: id 重复")
seen.add(item.get("id"))
rubric = item.get("rubric") or []
total = sum(p.get("weight", 0) for p in rubric)
if total != 10:
errors.append(f"{where}: rubric 权重和 = {total}, 必须是 10")
if not 2 <= len(rubric) <= 5:
errors.append(f"{where}: rubric 有 {len(rubric)} 个要点, 建议 2~5 个")
for cid in item.get("gold_chunks") or []:
if cid not in docs:
errors.append(f"{where}: gold_chunks 里的 {cid} 在语料里不存在")
为什么收集全部错误再一次报出来, 而不是第一个就 raise: 因为你是在批量修数据。一次跑出全部 8 个问题, 改一轮就完事; 一次报一个, 要跑 8 次。这和第 2 章加载器的 fail fast 不冲突 —— 加载器面对的是"程序不能继续", 校验器面对的是"人要修数据"。报错策略取决于谁来处理这个错误。
然后打印统计并按错误数量决定退出码:
print(f"评测集 {len(items)} 题, 语料 {len(docs)} 篇")
by_topic = collections.Counter(i["topic"] for i in items)
for t, n in sorted(by_topic.items()):
print(f" {t} -> {n} 题")
by_diff = collections.Counter(i["difficulty"] for i in items)
print(f"难度分布: {dict(sorted(by_diff.items()))}")
print(f"平均要点数: {sum(len(i['rubric']) for i in items) / len(items):.2f}")
print(f"带追问的题: {sum(1 for i in items if i.get('followups'))} / {len(items)}")
if errors:
print(f"\n发现 {len(errors)} 个问题:")
for e in errors:
print(f" - {e}")
return 1
print("\n校验通过: 无问题")
return 0
if __name__ == "__main__":
raise SystemExit(main())
return 1 而不是 print("失败"): 退出码是 CI 判断成败的唯一依据。第 12 章会把这个脚本加进 GitHub Actions, 有人提了一份权重不对的评测集就直接卡住合并。raise SystemExit(main()) 是把函数返回值变成进程退出码的标准写法。
✅ Checkpoint 1: 校验评测集。
uv run python -m scripts.check_golden
真实输出:
评测集 15 题, 语料 7 篇
Jetpack/ViewModel -> 3 题
Kotlin/协程 -> 3 题
四大组件/Activity生命周期 -> 3 题
四大组件/Fragment生命周期 -> 1 题
四大组件/Service -> 1 题
性能优化/ANR -> 2 题
性能优化/内存泄漏 -> 2 题
难度分布: {2: 10, 3: 5}
平均要点数: 3.00
带追问的题: 10 / 15
校验通过: 无问题
这段输出本身就是一份体检报告。 每次改完评测集都跑一下, 看到"Fragment生命周期 -> 1 题"你会想起来"这个知识点覆盖太薄了"; 看到平均要点数从 3.00 变成 3.8, 你会想起来"最近几道题的要点是不是拆得太细了"。
✅ Checkpoint 2: 验证它真的能抓出错。造一份坏数据:
cat > /tmp/bad_golden.yaml <<'EOF'
- id: bad-001
topic: 四大组件/Activity生命周期
difficulty: 2
question: 随便问一个
gold_chunks: [lifecycle-activity-basic, no-such-doc]
rubric:
- {point: "要点一", weight: 5}
- {point: "要点二", weight: 4}
EOF
uv run python -m scripts.check_golden /tmp/bad_golden.yaml; echo "exit=$?"
真实输出:
评测集 1 题, 语料 7 篇
四大组件/Activity生命周期 -> 1 题
难度分布: {2: 1}
平均要点数: 2.00
带追问的题: 0 / 1
发现 2 个问题:
- bad-001: rubric 权重和 = 9, 必须是 10
- bad-001: gold_chunks 里的 no-such-doc 在语料里不存在
exit=1
两个问题都抓到了, 而且 exit=1 —— CI 能据此判定失败。
五、顺便看一眼: 第 9 章要用的人工标注集
本章还要提一份姊妹文件 eval/grader_cases.yaml。它不是现在必须写完的 (第 9 章才用), 但标注方法必须现在就说清楚, 否则你到第 9 章会用错的方法标注, 得出一个漂亮但无意义的一致率。
文件开头的注释就是标注规范:
# 判分器一致性标注集。
#
# 怎么标: 自己扮演面试官, 对着 golden.yaml 里的 rubric 给分, 先写 human_score 再跑机器,
# 不要看了机器分再改自己的分 —— 那叫自我实现的预言, 评出来的一致率是假的。
#
# 覆盖面要求 (缺哪一档, 那一档的误差就测不出来):
# 满分档 9~10 / 良好档 6~8 / 及格档 4~5 / 差档 1~3 / 零分档 0
# 还要专门放几条"陷阱样本": 换词表达、正确但啰嗦、术语堆砌但没逻辑、答错方向。
一条标注长这样:
- qid: android-jetpack-001
answer: >
因为 ViewModel 是由 ViewModelStore 持有的, 屏幕旋转属于配置变更, 这时候 ViewModelStore
会被保留下来, 所以数据还在。但它扛不住进程被杀, 进程死了 ViewModel 也就没了。
human_score: 7.0
note: 前两点讲清楚了, 缺"完整方案 SavedStateHandle + Room"这一层
三个字段的意义: qid 指向 golden.yaml 里的题 (所以 rubric 不用重复写)、human_score 是先写下来不许改的人工分、note 是给分理由 —— 半年后回看时, note 是唯一能让你判断"当初这个 7 分给得对不对"的东西。
注意 answer 用了 YAML 的 > 折叠语法: 多行文本会被合并成一行 (换行变空格)。这样标注文件里能排版好看, 而程序拿到的是一个连续字符串 —— 和候选人真实输入的形态一致。
标注的两个纪律:
- 先人后机, 不许回头改。 看了机器分再调自己的分, 算出来的一致率是"我和我自己"的一致率。
- 五个分档都要有样本。 只标满分和零分的话, 你测不出"6 分和 8 分能不能分辨"—— 而这恰恰是判分器最难也最关键的能力。
踩坑与专家提示
- 权重和不是 10 时不要"自动归一化了就算了"。 强制报错的价值在于: 权重和是 9 通常意味着你漏写了一个要点, 而不是"想让它是 9"。归一化会把这个信号吃掉。
topic字符串必须和语料完全一致。 包括斜杠、包括有没有空格。这个字段是跨模块的连接键, 差一个字符, 报告分组和错题本就全错, 而且不报错。- 要点不要写成"知道 XXX"。 "知道 Handler 会泄漏"没法判断; "未执行的 Message 在 MessageQueue 里持有 Handler, Handler 持有 Activity"可以判断。要点应该是可以被复述的陈述句。
- 不要让模型帮你写 rubric。 模型写出来的要点看着很齐整, 但它是从它自己的知识里生成的, 不保证语料里有。于是判分标准和知识库脱节, 系统会因为"语料里没有的要点"给候选人扣分。rubric 必须从语料抄。
- 评测集写完先自己答一遍。 你会发现有的题问得含糊 (三个要点里有两个根本问不出来)、有的题一句话就答完了。能被自己答砸的题目才是好题目。
answer里不要出现 rubric 的原句。 标注时如果直接把要点复制成答案, 你测的是字符串匹配, 不是语义判断。要故意换词 —— 第 9 章 MAE 0.833 这个数字的可信度全靠这一点。- 15 道题不够做技术选型, 够做回归测试。 这句话第 6 章还会重复一遍: 0.0667 (也就是 1/15) 以内的指标差异只是一题之差。评测集的第一价值是"改坏了能立刻发现", 第二价值才是"对比方案"。
面试视角: 本章高频五问
Q1: 你的评测集只有 15 题, 结论可信吗?
作为回归测试完全可信 —— 改坏了检索, 这 15 题里一定有掉的。作为技术选型依据不可信: 15 题的粒度是 1/15 = 0.0667, 所以第 6 章那张表里 hybrid (MRR 0.9667) 和 vector (0.9556) 的差距只是一道题, 不能据此断言 hybrid 更好。我会把这个不确定性明确写在评测报告里, 而不是把 0.9667 当成结论。扩到 100 题以上才够做选型, 那时的成本是标注人力, 不是技术。
Q2: 为什么分数由代码算而不是让模型直接打分?
三个理由。可复现: 同一份答案两次判分结果一致 (模型直接打分会给出 6 分和 9 分)。可解释: 能明确告诉候选人"你漏了权重 4 的那个要点"。可校准: 第 9 章能算出判分器和人工标注的 MAE 是 0.833, 而如果分数是模型现给的, 你连"差在哪"都定位不了。模型只做它做得比代码好的那件事 —— 判断"这句话和那个要点是不是一个意思"。
Q3: rubric 的权重是主观定的, 这不算问题吗?
权重必须主观, 因为"哪个要点更重要"本身就是面试官的判断。关键区别是主观标准被显式写下来了: 任何人都能看到"返回时的回调顺序 weight 4"并提出异议, 然后改这一行 YAML。对比让模型隐式打分, 标准存在模型的权重里, 谁都没法讨论。可讨论的主观 > 不可见的"客观"。
Q4: gold_chunks 为什么标到文档级而不是块级?
因为块 id 依赖切分参数。标到块级的话, 改一次 chunk_size 就要重标 15 道题, 结果就是你再也不敢调切分参数了 —— 评测集反而变成了优化的阻力。标到文档级、按前缀匹配, 代价是 Recall 略微偏高 (命中同篇的无关块也算命中), 收益是切分参数可以自由实验。这个权衡在语料篇幅小的时候明显划算; 一篇语料几百个块时就得标到块级了。
Q5: 那四道"改写问法"的题是怎么想出来的?
从"真实用户不会用术语提问"这个观察出发, 反向构造: 先看语料里的核心术语 (配置变更、ANR、onStop、轻量级调度单位), 然后用完全不含这些词的日常说法描述同一个现象。它们的作用是制造 BM25 一定会失败的情况, 从而让"要不要加向量检索"这个决策有数据支撑, 而不是靠"业界都这么做"。评测集里必须有一部分题是故意为难自己的系统的, 否则它只会给你想听的答案。
划重点
- 先写评测集再写功能。评测集会反过来影响设计 —— 本项目的混合检索是因为评测集里有 4 道改写问法的题才加的。
- 一份
golden.yaml服务四件事: 检索评测、出题、判分校准、微调种子。合并的代价是设计时多想半小时。 - 分数 = 命中要点权重和 / 总权重 × 10。要点必须是可判断"讲了没讲"的独立陈述, 2~5 个, 权重和统一为 10。
- rubric 必须从语料抄, 不能让模型生成 —— 否则判分标准和知识库脱节。
gold_chunks标文档级、按前缀匹配, 让改切分参数不需要重标。这是标注成本和指标精度的权衡。- 故意放"不含语料原词"的题, 专门暴露关键词检索的短板。评测集要有一部分题是为难自己的。
- 评测集本身要有自检脚本, 并用退出码接进 CI。权重和、id 重复、gold_chunks 存在性, 这三项错误都不会自己暴露。
- 人工标注纪律: 先人后机不许改, 五个分档都要有样本,
answer要故意换词。
下一章预告
标注做完了, 下一章开始真正处理数据: 把 7 篇语料切成 25 个块, 灌进 Chroma 向量库。会讲清"为什么按标题切而不是按固定字数切"、"重叠为什么要按整句而不是按字符数"、"元数据怎么跟着块一起落库", 并且第一次跑出 7 篇 -> 25 块 这个数字。切块是 RAG 里最不起眼、又最影响最终效果的一步。