02 · 语料体系: 从零建一个知识库
一句话总结
这一章不写业务代码, 只做一件事: 把 Android 面试知识变成机器能检索、能溯源的结构化文件。我们先定知识点大纲, 再设计一套 Markdown + YAML 元数据规范, 手写 7 篇语料, 最后写一个 60 行的加载器把它们读进来并在格式写错时立刻报错。这一章决定了整个系统的知识天花板 —— 语料里没有的东西, 后面再强的检索和模型都变不出来。
本章你能学到什么
- 为什么"喂 PDF 给大模型"是这类项目最容易失败的起点
- 一份语料的元数据该有哪些字段, 每个字段后面会被谁用到
- 为什么选 Markdown 而不是 PDF / Word / 数据库
- 怎么写"适合被检索"的语料 —— 和写博客的写法完全不同
- 加载器为什么要主动报错, 而不是"跳过有问题的文件"
一、先想清楚: 知识从哪来
1.1 三种来源和它们的问题
做知识库的人通常有三个选择:
选项一: 爬一堆博客。 量大, 但质量不可控。Android 生态里过时博客特别多 —— 讲 AsyncTask 的、讲 startActivityForResult 的 (都已废弃)。检索出来的内容是错的, 比检索不到更糟: 系统会自信地问一道错题, 并且拿错答案给你判分。
选项二: 直接塞官方文档 PDF。 官方文档正确, 但它是写给查阅的, 不是写给出面试题的。一篇 Activity 文档里 80% 是 API 签名和代码示例, 真正能出题的"要点"密度很低。而且 PDF 转文本会带来页眉页脚、断行、目录残留一堆噪声。
选项三 (本项目): 手写少量高密度语料。 7 篇文件, 每篇 400~900 字, 全是"要点句"。
1.2 为什么手写 7 篇比爬 700 篇好
因为这个项目的用途是出面试题并按要点判分, 它需要的不是"信息量", 而是每句话都能当评分要点。
举个对比。官方文档的写法:
onSaveInstanceState(Bundle outState): Called to retrieve per-instance state from an activity before being killed...
本项目语料的写法:
onSaveInstanceState 在 onStop 之前调用, 用于保存临时 UI 状态。 它只在系统主动销毁时调用, 用户主动按返回键退出不会调用。 恢复可以在 onCreate 的 savedInstanceState 参数里读, 也可以在 onRestoreInstanceState 里读。
三句话就是三个评分要点, 可以直接进 rubric。第 3 章的黄金评测集里, 那道 onSaveInstanceState 的题, 三个要点就是从这三句抄下来的。
代价说清楚: 覆盖面窄。7 篇语料只覆盖 7 个知识点, 问到 Compose、问到 Gradle 就答不上。但这是可扩展的窄 —— 加第 8 篇语料的成本是半小时, 而把 700 篇爬来的博客清洗成能用的语料, 成本是无限的。
1.3 知识点大纲
先列大纲再写文件, 否则写到第五篇你会发现三篇都在讲生命周期。Android 面试的六个板块:
| 板块 | 知识点举例 | 本章覆盖 |
|---|---|---|
| 四大组件 | Activity 生命周期、Fragment 生命周期、Service、Broadcast、ContentProvider | ✅ 4 篇 |
| Kotlin 语言 | 协程、Flow、扩展函数、密封类、委托 | ✅ 1 篇 (协程) |
| Jetpack | ViewModel、LiveData、Room、Navigation、Compose | ✅ 1 篇 (ViewModel) |
| 性能优化 | 内存泄漏、ANR、卡顿、启动优化、包体积 | ✅ 2 篇 (泄漏/ANR) |
| 架构与设计 | MVVM/MVI、模块化、依赖注入 | ⬜ 留给你扩展 |
| 系统原理 | Handler/Looper、Binder、View 绘制流程、类加载 | ⬜ 留给你扩展 |
⬜ 的两行是故意留空的。 学完这套文档, 你自己加两篇语料、往黄金集里补两道题, 就完成了一次真实的"知识库迭代"—— 这比多读一章文档有用。
二、语料规范: Markdown + YAML frontmatter
2.1 为什么是 Markdown
是什么。 每篇语料是一个 .md 文件, 开头用 --- 包一段 YAML 写元数据, 后面是正文。
为什么不用别的。
- 不用 PDF/Word: 解析要引入额外库, 而且会带页眉页脚噪声。
- 不用数据库: 语料需要被人写、被 review、被 diff。存数据库你就没法用
git diff看"这次改了哪句话", 也没法在 PR 里评论。 - 不用纯 txt: 因为我们要靠标题层级切块 (第 4 章)。
##是天然的语义边界。
Markdown 的关键优势是它同时对人和机器友好: 人在编辑器里能读, git 能 diff, 而 ## 标题给了我们免费的切块依据。
2.2 元数据字段, 以及每个字段将来被谁用
---
id: lifecycle-activity-basic
topic: 四大组件/Activity生命周期
difficulty: 2
tags: [Activity, 生命周期, onSaveInstanceState]
source: developer.android.com/guide/components/activities/activity-lifecycle
updated: 2026-08-17
---
不要照抄就完事, 每个字段都有明确用途:
| 字段 | 用途 | 谁会用它 |
|---|---|---|
id |
全局唯一标识。检索结果、评测集的标准答案都靠它对齐 | 第 3 章评测集的 expect_doc_ids、第 6 章算 Recall |
topic |
知识点名。面试报告按它分组, 错题本按它记 | 第 7 章的报告、选题器的"薄弱知识点优先" |
difficulty |
1~3。出题时按候选人表现选难度 | 第 7 章的难度自适应 |
tags |
关键词。给 BM25 加权和元数据过滤用 | 第 5 章的混合检索 |
source |
出处。这是"可溯源"的落点 | 面试报告里给学习链接; 半年后复查语料是否过时 |
updated |
最后更新日期 | 判断语料是否需要重新核对 |
id 的命名规则: 板块-知识点-粒度, 全小写连字符。比如 lifecycle-activity-basic、perf-memory-leak。为什么不用自增数字? 因为看到 doc_id=17 你不知道它是什么, 而看到 perf-memory-leak 你立刻知道。检索日志和评测报告里全是这个 id, 可读性直接影响调试效率。
topic 为什么用斜杠分层: 四大组件/Activity生命周期 这种写法, 报告里既能按大板块聚合也能按细知识点聚合, 而只需要一个字符串字段。不需要为此建两列。
2.3 正文怎么写: 三条硬规则
这是本章最重要的一节。语料的写法直接决定检索质量, 而它和写博客的写法几乎相反。
规则一: 用 ## 分节, 每节只讲一件事。
因为第 4 章会按 ## 标题切块, 一个 ## 节就是一个检索单元。如果你在一节里既讲生命周期回调又讲跳转顺序, 检索"跳转顺序"时会连带返回一大段无关内容, 稀释了有效信息。
规则二: 每句话自带主语, 不要用"它"、"这个"、"上面说的"。
反例:
onSaveInstanceState 用于保存状态。它在 onStop 之前调用。用户按返回键时不会触发它。
切块之后, 如果第二句和第一句被分到不同块, 那块内容就变成了"它在 onStop 之前调用" —— 检索出来的这段话谁都看不懂, 模型也没法用它出题。
正例 (本项目的实际写法):
onSaveInstanceState 在 onStop 之前调用, 用于保存临时 UI 状态。 它只在系统主动销毁时调用, 用户主动按返回键退出不会调用。
第二句虽然用了"它", 但它和第一句在同一节、同一块内, 且上一句刚提过主语 —— 这是可以的。判断标准是: 一个块单独拿出来给陌生人看, 他能不能看懂。
规则三: 该出现的 API 名字必须原样写出来。
不要写"保存实例状态的那个回调", 要写 onSaveInstanceState。原因在第 5 章会量给你看: BM25 靠精确词命中, onSaveInstanceState 这种驼峰长词是它最擅长的; 而向量检索对这类专有名词反而不敏感。写全 API 名字等于同时喂饱了两路召回。
2.4 一篇完整的语料
corpus/lifecycle/activity-lifecycle.md:
---
id: lifecycle-activity-basic
topic: 四大组件/Activity生命周期
difficulty: 2
tags: [Activity, 生命周期, onSaveInstanceState]
source: developer.android.com/guide/components/activities/activity-lifecycle
updated: 2026-08-17
---
# Activity 生命周期
## 七个回调
Activity 有七个生命周期回调: onCreate、onStart、onResume、onPause、onStop、onDestroy、onRestart。
onCreate 只在创建时调用一次, 用于 setContentView 和初始化。onStart 表示界面已可见但还不能交互。
onResume 之后界面进入前台且可交互。onPause 表示即将失去焦点, 系统要求它必须快速返回。
onStop 表示界面完全不可见。onDestroy 是销毁前最后一次回调。
## 页面跳转时两个 Activity 的回调顺序
从 A 跳到 B, 顺序是 A.onPause -> B.onCreate -> B.onStart -> B.onResume -> A.onStop。
关键点是 A.onStop 发生在 B.onResume 之后, 也就是 B 完全可见之后 A 才停止。
这个设计是为了让切换过程中屏幕上始终有一个可见界面, 避免黑屏。
所以 onPause 里不能做耗时操作, 否则会拖慢整个跳转。
## 返回时的顺序
从 B 返回 A, 顺序是 B.onPause -> A.onRestart -> A.onStart -> A.onResume -> B.onStop -> B.onDestroy。
如果 B 是 Dialog 主题的半透明 Activity, A 不会走 onStop, 因为 A 一直是部分可见的, 只会停在 onPause。
## onSaveInstanceState 的时机
onSaveInstanceState 在 onStop 之前调用, 用于保存临时 UI 状态。
它只在系统主动销毁时调用, 用户主动按返回键退出不会调用。
恢复可以在 onCreate 的 savedInstanceState 参数里读, 也可以在 onRestoreInstanceState 里读。
请数一下: 四个 ## 节, 每节 3~6 句, 每句都是一个可以拿来判分的陈述。这就是"高密度"的意思。
2.5 另外六篇写什么
完整内容不在这里全贴 (7 篇加起来 4000 多字), 但给出每篇的骨架, 你照着规范写即可 —— 手写这一步不要跳过, 它是你对这套系统知识边界的唯一一次完整认识。
corpus/lifecycle/fragment-lifecycle.md (id: lifecycle-fragment, difficulty 2):
- ## Fragment 的回调比 Activity 多什么 (onAttach / onCreateView / onViewCreated / onDestroyView)
- ## 为什么 View 相关操作要写在 onViewCreated 而不是 onCreateView
- ## Fragment 的 View 可以销毁重建但 Fragment 实例还活着 (这是内存泄漏高发点)
- ## viewLifecycleOwner 和 Fragment 自身 lifecycle 的区别
corpus/lifecycle/service-lifecycle.md (id: lifecycle-service, difficulty 2):
- ## startService 和 bindService 的生命周期差异
- ## onStartCommand 的返回值 (START_STICKY / START_NOT_STICKY / START_REDELIVER_INTENT)
- ## 前台服务和通知的强制绑定关系
- ## Service 默认在主线程, 耗时操作照样 ANR
corpus/kotlin/coroutine-basics.md (id: kotlin-coroutine-basic, difficulty 2):
- ## 协程不是线程 (挂起不阻塞线程, 挂起时线程被释放去干别的)
- ## suspend 关键字做了什么 (CPS 变换、状态机)
- ## 三个常用调度器 (Main / IO 上限 64 / Default 等于核心数)
- ## 结构化并发 (viewModelScope、SupervisorJob)
corpus/jetpack/viewmodel.md (id: jetpack-viewmodel, difficulty 2):
- ## ViewModel 解决什么问题 (配置变更时数据不丢)
- ## 它为什么能活过屏幕旋转 (ViewModelStore 的保留机制)
- ## onCleared 的调用时机
- ## ViewModel 里绝对不能持有 View 或 Context
corpus/memory/memory-leak.md (id: perf-memory-leak, difficulty 3):
- ## 泄漏的本质 (不再需要但仍被 GC Root 引用)
- ## 五个高频场景 (匿名 Handler、静态持 Context、监听器没反注册、单例持 Activity、WebView/Timer)
- ## 怎么定位 (LeakCanary 最短引用链、Profiler 手动 dump)
corpus/memory/anr.md (id: perf-anr, difficulty 3):
- ## 四类 ANR 和各自的超时阈值
- ## ANR 的根因是主线程消息处理超时
- ## 怎么分析 traces.txt
写完的目录:
corpus/
├── jetpack/viewmodel.md
├── kotlin/coroutine-basics.md
├── lifecycle/activity-lifecycle.md
├── lifecycle/fragment-lifecycle.md
├── memory/anr.md
├── memory/memory-leak.md
└── service/service-lifecycle.md
子目录只是给人看的, 加载器用 rglob("*.md") 递归扫, 不关心层级 —— 分类信息全在 topic 字段里。这样你想重新组织目录随时可以, 不会影响任何代码。
三、写加载器: app/rag/loader.py
3.1 它的职责边界
加载器只做两件事: 把文件读成结构化对象, 和在格式不对时立刻报错。它不切块 (第 4 章)、不向量化 (第 4 章)、不检索 (第 5 章)。
一个只有 60 行的模块为什么要单独成文件? 因为它是唯一知道"语料长什么样"的地方。将来语料换成从 CMS 拉取, 只改这一个文件, 后面所有代码都不用动。
3.2 数据结构
"""语料加载: 把 corpus/ 下的 Markdown 读成结构化的 Doc 列表。"""
from __future__ import annotations
import pathlib
from dataclasses import dataclass, field
from typing import Any
import yaml
REQUIRED_KEYS = ("id", "topic", "difficulty")
@dataclass
class Doc:
doc_id: str
topic: str
difficulty: int
tags: list[str] = field(default_factory=list)
source: str = ""
updated: str = ""
body: str = ""
path: str = ""
为什么用 dataclass 而不是 dict: 用 dict 的话, doc["tpoic"] 拼错了要到运行时才报 KeyError, 而且编辑器不会补全。dataclass 有字段名检查和自动补全。这里没用 Pydantic, 因为 Doc 是我们自己造的内部对象, 不需要校验; 第 7 章接收 LLM 输出时才需要 Pydantic (那是外部数据)。
REQUIRED_KEYS 只有三个: id / topic / difficulty。tags / source / updated 是可选的 —— 允许你先把内容写下来, 元数据慢慢补。必填字段要卡得少而准: 卡多了写语料变成填表格, 你就不想写了。
3.3 拆 frontmatter
def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
"""把 '---\\nkey: v\\n---\\n正文' 拆成 (meta, body)。没有 frontmatter 就返回 ({}, 全文)。"""
if not text.startswith("---"):
return {}, text
end = text.find("\n---", 3)
if end == -1:
return {}, text
raw = text[3:end]
body = text[end + 4 :].lstrip("\n")
meta = yaml.safe_load(raw) or {}
if not isinstance(meta, dict):
raise ValueError("frontmatter 必须是键值对")
return meta, body
四个细节:
text.find("\n---", 3)从第 3 个字符开始找: 不然会找到开头那个---自己。yaml.safe_load而不是yaml.load:yaml.load能构造任意 Python 对象, 加载不可信的 YAML 等于让对方执行代码。这是一个真实存在的漏洞类型 (CVE-2017-18342), 只要记住"永远用safe_load"就行。or {}: 空的 frontmatter (---\n---\n) 会让safe_load返回None, 不处理的话下一行k not in meta直接崩。- 检查
isinstance(meta, dict): 有人可能在 frontmatter 里写了一个 YAML 列表, 那也能 parse 成功但不是我们要的形状。
3.4 加载单篇与全量
def load_doc(path: pathlib.Path) -> Doc:
meta, body = _split_frontmatter(path.read_text(encoding="utf-8"))
missing = [k for k in REQUIRED_KEYS if k not in meta]
if missing:
raise ValueError(f"{path}: frontmatter 缺字段 {missing}")
return Doc(
doc_id=str(meta["id"]),
topic=str(meta["topic"]),
difficulty=int(meta["difficulty"]),
tags=[str(t) for t in (meta.get("tags") or [])],
source=str(meta.get("source", "")),
updated=str(meta.get("updated", "")),
body=body,
path=str(path),
)
def load_corpus(root: str | pathlib.Path) -> list[Doc]:
root = pathlib.Path(root)
if not root.exists():
raise FileNotFoundError(f"语料目录不存在: {root}")
docs: list[Doc] = []
seen: dict[str, str] = {}
for p in sorted(root.rglob("*.md")):
doc = load_doc(p)
if doc.doc_id in seen:
raise ValueError(f"id 重复: {doc.doc_id} 同时出现在 {seen[doc.doc_id]} 和 {p}")
seen[doc.doc_id] = str(p)
docs.append(doc)
return docs
这个函数里最重要的两行是 raise, 不是 return。
encoding="utf-8"必须显式写。 不写的话 Python 用系统默认编码, Windows 上是 GBK, 读中文语料直接UnicodeDecodeError。这是跨平台协作最常见的一个坑。sorted(root.rglob(...)):rglob的返回顺序在不同文件系统上不一样。不排序的话, 同一份语料在 Mac 和 Linux 上灌进向量库的顺序不同, 检索结果的排名会有微小差异 —— 然后你会花一小时怀疑是代码有 bug。只要结果会被人对比, 就要排序。id重复必须报错, 不能只警告。 重复 id 的后果特别隐蔽: 第 6 章算 Recall 时, 标准答案写expect_doc_ids: [perf-anr], 而库里有两个perf-anr—— 指标会莫名其妙地不对, 但没有任何报错。str(meta["id"])显式转字符串: YAML 会把id: 2024解析成整数。不转的话, 和评测集里的字符串"2024"比较永远不相等。
为什么不"跳过坏文件继续跑"? 因为语料是人手写的, 写错了就该马上被告知。"跳过并警告"的结果是: 你改了一篇语料、格式写坏了、加载器悄悄跳过、检索时那个知识点就是查不到 —— 而你会去查检索代码。在数据入口处严格报错, 是省调试时间最有效的手段。
3.5 自测
if __name__ == "__main__":
import sys
docs = load_corpus(sys.argv[1] if len(sys.argv) > 1 else "corpus")
print(f"共加载 {len(docs)} 篇语料")
for d in docs:
print(f" [{d.difficulty}] {d.doc_id:24s} {d.topic:22s} {len(d.body):4d}字 tags={d.tags}")
✅ Checkpoint 1: 加载全部语料。
uv run python -m app.rag.loader corpus
真实输出:
共加载 7 篇语料
[2] jetpack-viewmodel Jetpack/ViewModel 623字 tags=['ViewModel', '配置变更', 'StateFlow', 'onCleared']
[2] kotlin-coroutine-basic Kotlin/协程 614字 tags=['协程', 'suspend', 'Dispatchers', '结构化并发']
[2] lifecycle-activity-basic 四大组件/Activity生命周期 852字 tags=['Activity', '生命周期', 'onSaveInstanceState']
[2] lifecycle-fragment 四大组件/Fragment生命周期 636字 tags=['Fragment', '生命周期', 'onViewCreated']
[3] perf-anr 性能优化/ANR 368字 tags=['ANR', '主线程', 'traces.txt', 'Looper']
[3] perf-memory-leak 性能优化/内存泄漏 702字 tags=['内存泄漏', 'Handler', 'LeakCanary', '静态引用']
[2] lifecycle-service 四大组件/Service 480字 tags=['Service', 'onStartCommand', 'bindService', '前台服务']
中文列对不齐是正常的。 {:24s} 按字符数补空格, 而中文在终端占两格宽 —— 这是终端渲染的事, 不是 bug。想对齐得按显示宽度算 (unicodedata.east_asian_width), 为了一个自测函数不值得。
注意最后一行 lifecycle-service 排在 perf-* 后面: 因为按文件路径排序, corpus/service/ 在 corpus/memory/ 之后。排序稳定就达到目的了, 顺序本身无所谓。
✅ Checkpoint 2: 验证它真的会报错。故意造一个缺 id 的文件:
mkdir -p /tmp/badcorpus
printf -- '---\ntopic: 测试\ndifficulty: 2\n---\n\n# 正文\n' > /tmp/badcorpus/bad.md
uv run python -c "
from app.rag.loader import load_corpus
try:
load_corpus('/tmp/badcorpus')
except ValueError as e:
print('预期报错:', e)
"
真实输出:
预期报错: /tmp/badcorpus/bad.md: frontmatter 缺字段 ['id']
这个 Checkpoint 比上一个更重要。 只验证"正常情况能跑"的测试价值有限 —— 报错路径才是你半年后真正会撞上的路径。报错信息里带文件路径和具体缺哪个字段, 是为了让当时的你不用读源码就知道怎么改。
踩坑与专家提示
encoding="utf-8"一定要写。 不写在 Mac 上没问题, 同事在 Windows 上一 clone 就崩。所有read_text/open都要显式指定。yaml.safe_load, 永远不用yaml.load。 前者只构造基本类型, 后者能构造任意 Python 对象。- YAML 里
difficulty: 2是整数,id: 2024也是整数。 需要字符串的字段要显式str()。这个坑在id全是英文时不会出现, 直到有人用纯数字 id。 rglob结果一定要sorted()。 不排序会导致跨机器结果不可复现, 而且极难查。- 不要在语料里放代码块。 第 4 章切块时,
```围栏里的内容会被当成普通文本切开, 一段 Kotlin 代码被劈成两半, 检索出来毫无意义。真要放代码, 写成一句话描述 ("用 removeCallbacksAndMessages(null) 清空消息队列")。 - 一篇语料不要超过 1500 字。 超了就说明它讲了多个知识点, 应该拆成两篇 —— 否则
topic字段是错的, 报告里的知识点归类也就是错的。 updated字段要真的维护。 Android 每年一个大版本, 语料半年不看就可能过时。写个脚本扫一遍"updated超过 180 天的语料"提醒自己复查, 比"我记得那篇还行"可靠。
面试视角: 本章高频五问
Q1: 你的知识库只有 7 篇, 会不会太少?
对这个项目的目标来说是够的, 因为它要证明的是方法链路成立: 语料规范 → 检索 → 评测 → 判分。7 篇能覆盖 15 道黄金题, 足够把 Recall@3 / MRR 这些指标量出来。真要扩到生产, 瓶颈不是语料数量而是标注成本 —— 每篇语料要配 rubric 才有价值, 而 rubric 只能人写。所以正确的扩张路径是"按面试真实出题频率排序, 从高频知识点往下补", 不是"先爬一万篇"。
Q2: 为什么不用向量数据库直接存原文, 还要中间搞一个 Doc 对象?
因为元数据要在入库前就结构化。topic / difficulty / tags 这三个字段是后面选题、难度自适应、元数据过滤的依据, 如果只把原文塞进向量库, 这些信息就丢了。Doc 是"语料的领域模型", 向量库只是它的一个存储后端 —— 换 Chroma 为 Qdrant 时 Doc 一行不改。
Q3: 语料写成什么样才算"适合检索"?
三条: 按 ## 分节且每节单一主题 (给切块提供边界)、每句自带主语 (保证块脱离上下文仍可读)、API 名字原样写出 (喂饱 BM25 的精确匹配)。反过来说, 博客那种"承上启下 + 大段铺垫"的写法对检索是负面的 —— 铺垫句会成为噪声块, 拉低检索精度。
Q4: frontmatter 校验为什么要 fail fast, 不能容错吗?
因为语料是人写的, 而且是低频写、高频读。写错时立刻报错, 成本是改一行 YAML; 悄悄跳过, 成本是几周后"某个知识点怎么老是检索不到"的排查。容错适用于处理外部不可控输入 (比如用户提交的内容), 不适用于自己仓库里的配置和数据。
Q5: 为什么 id 用有含义的字符串而不是 UUID?
因为 id 会大量出现在评测集 (expect_doc_ids)、检索日志和调试输出里, 是给人读的。perf-memory-leak 让人一眼知道命中了什么, a3f9-... 需要再查一次。UUID 适合机器生成的实体, 人手维护的资源用可读 slug —— 代价是重命名时要全局搜替换, 但语料重命名是极低频操作。
划重点
- 手写少量高密度语料 > 爬大量低质语料。这个项目要的是"每句都能当评分要点", 不是信息量。
- 元数据六个字段各有明确下游用途:
id对齐评测、topic分组报告、difficulty难度自适应、tags检索加权、source溯源、updated防过时。 - 正文三条硬规则:
##分节单一主题、每句自带主语、API 名字原样写。判断标准是"一个块单独给陌生人看能否看懂"。 - 加载器只做两件事: 读成结构化对象、格式不对立刻报错。它是唯一知道"语料长什么样"的地方。
encoding="utf-8"、yaml.safe_load、sorted(rglob(...))、str(meta["id"])—— 四行小事, 每一行都对应一类真实故障。id重复必须报错。它导致的指标错误不带任何报错信息, 是最难查的一类问题。- 报错路径的 Checkpoint 比正常路径的更值得写。
下一章预告
有了语料, 下一章做一件反直觉的事: 在写任何检索代码之前, 先写评测集。我们会挑 15 道题, 为每道题手写标准答案要点和权重 (rubric), 并且故意埋两道语料里查不到的"陷阱题"。这一步很枯燥, 但它是后面所有"我优化了一下"能不能被验证的唯一依据 —— 没有评测集, 第 5 章的混合检索、第 6 章的消融表、第 9 章的判分器一致性全都无从谈起。