全栈开发知识库

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 比上一个更重要。 只验证"正常情况能跑"的测试价值有限 —— 报错路径才是你半年后真正会撞上的路径。报错信息里带文件路径和具体缺哪个字段, 是为了让当时的你不用读源码就知道怎么改。


踩坑与专家提示

  1. encoding="utf-8" 一定要写。 不写在 Mac 上没问题, 同事在 Windows 上一 clone 就崩。所有 read_text / open 都要显式指定。
  2. yaml.safe_load, 永远不用 yaml.load。 前者只构造基本类型, 后者能构造任意 Python 对象。
  3. YAML 里 difficulty: 2 是整数, id: 2024 也是整数。 需要字符串的字段要显式 str()。这个坑在 id 全是英文时不会出现, 直到有人用纯数字 id。
  4. rglob 结果一定要 sorted()。 不排序会导致跨机器结果不可复现, 而且极难查。
  5. 不要在语料里放代码块。 第 4 章切块时, ``` 围栏里的内容会被当成普通文本切开, 一段 Kotlin 代码被劈成两半, 检索出来毫无意义。真要放代码, 写成一句话描述 ("用 removeCallbacksAndMessages(null) 清空消息队列")。
  6. 一篇语料不要超过 1500 字。 超了就说明它讲了多个知识点, 应该拆成两篇 —— 否则 topic 字段是错的, 报告里的知识点归类也就是错的。
  7. 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 —— 代价是重命名时要全局搜替换, 但语料重命名是极低频操作。


划重点

  1. 手写少量高密度语料 > 爬大量低质语料。这个项目要的是"每句都能当评分要点", 不是信息量。
  2. 元数据六个字段各有明确下游用途: id 对齐评测、topic 分组报告、difficulty 难度自适应、tags 检索加权、source 溯源、updated 防过时。
  3. 正文三条硬规则: ## 分节单一主题、每句自带主语、API 名字原样写。判断标准是"一个块单独给陌生人看能否看懂"。
  4. 加载器只做两件事: 读成结构化对象、格式不对立刻报错。它是唯一知道"语料长什么样"的地方。
  5. encoding="utf-8"、yaml.safe_load、sorted(rglob(...))、str(meta["id"]) —— 四行小事, 每一行都对应一类真实故障。
  6. id 重复必须报错。它导致的指标错误不带任何报错信息, 是最难查的一类问题。
  7. 报错路径的 Checkpoint 比正常路径的更值得写。

下一章预告

有了语料, 下一章做一件反直觉的事: 在写任何检索代码之前, 先写评测集。我们会挑 15 道题, 为每道题手写标准答案要点和权重 (rubric), 并且故意埋两道语料里查不到的"陷阱题"。这一步很枯燥, 但它是后面所有"我优化了一下"能不能被验证的唯一依据 —— 没有评测集, 第 5 章的混合检索、第 6 章的消融表、第 9 章的判分器一致性全都无从谈起。

本页由 Markdown 自动渲染 · 共 628 篇笔记