《AI Agent 开发入门》10.1 文档加载与切分

检索增强的第一步是把一堆文件变成可检索的块。本节先剥掉 frontmatter,再用带重叠的 chunk(text, size, overlap) 固定长度切块,并拿本仓库 content/posts/python/ 下 6 篇真实文章实测:文件数、总块数、平均/最长/最短块长全部给出。最后对比按字符切与按语义切的取舍,代码在 Python 3.14.6 跑通。

10.1 文档加载与切分

第 9 章让 minagent 能"记住"对话,但记忆是它自己产生的。这一章要让它能查外部的知识——一批现成的文档。检索增强(RAG,Retrieval-Augmented Generation)听起来像个大词,拆开只有三步:把文档切成块、建索引、检索出相关块塞进上下文。本节只做第一步,而且做扎实。

本节把 minagent 推进到 minagent/rag/loader.py:把一堆 .md 文件读成纯文本、剥掉 frontmatter,再用带重叠的固定长度切块,产出一份"块列表"给 10.2 的 search/read 工具用。

10.1.1 加载:从文件到纯文本

检索的第一道工序是加载:把磁盘上散落的文件读成一段一段的字符串。真实系统里格式五花八门——PDF、HTML、Word、Markdown——每种都要专门的解析器。卷一只处理最朴素也最可控的一种:Markdown。

Markdown 文件带 YAML frontmatter,那段 --- 包起来的元数据是给 Hugo 看的,不是给模型看的。如果原样切块,我们会得到一堆 title: ... date: ... 的噪声块。所以加载的第一步是剥掉 frontmatter:

from __future__ import annotations

def strip_frontmatter(text: str) -> str:
    if text.startswith("---"):
        end = text.find("\n---", 3)
        if end != -1:
            return text[end + 4:].lstrip("\n")
    return text

逻辑很短:文件以 --- 开头时,找到下一个 \n--- 作为结束标记,返回它之后的内容。find(..., 3) 从第 3 个字符开始找,是为了跳过开头的那个 --- 本身,避免把结束标记和开头标记认成同一个。lstrip("\n") 去掉紧随其后的空行。

这个函数用 find 而不是正则,是刻意的:它不依赖 YAML 解析器,不需要任何第三方依赖,也永远不会因为 frontmatter 里有一行奇怪的冒号而崩。加载层的首要目标是"不炸",而不是"理解"。

加载还有一个容易忽略的点:要不要保留 Markdown 标记。## 标题、**加粗**、`代码` 这些符号对模型来说其实是有用的结构信号——它知道 ## 后面是章节标题。所以卷一不把 Markdown 渲染成纯文本,而是原样保留。代价是切块时会把标记也切进去,好处是块里仍然带着可读的层级信息。

10.1.2 切分:chunk(text, size, overlap)

有了纯文本,第二道工序是切分(chunking)。为什么不能把整篇文章当一个块?两个原因:一是模型的上下文窗口有限,塞不下几十篇;二是检索的粒度——我们要的是"最相关的那一小段",而不是"包含它的一整篇"。

最朴素的切法是固定长度 + 重叠:

def chunk(text: str, size: int = 400, overlap: int = 80) -> list[str]:
    if overlap >= size:
        raise ValueError("overlap 必须小于 size")
    step = size - overlap
    out, i, n = [], 0, len(text)
    while i < n:
        out.append(text[i:i + size])
        if i + size >= n:
            break
        i += step
    return out

三个参数决定了切块的形状:size 是每块的目标长度,overlap 是相邻块的重叠长度,step = size - overlap 是每次前进的步长。循环从 i = 0 开始,取 text[i:i+size],然后前进 step,直到 i 越过文本末尾。

overlap 是这段代码里唯一需要解释的东西。假设 size=400、overlap=0,那么一块的结尾和下一块的开头之间没有任何衔接——如果一句话、一个代码示例正好横跨边界,它就被硬生生劈成两半,两边的块都不完整。检索时无论命中哪一块,拿到的都是半句话。

overlap 就是为了堵这个缝:让相邻块共享一段尾巴。上面的 size=400, overlap=80 意味着第 0 块是 [0, 400),第 1 块是 [320, 720),两者在 [320, 400) 这 80 个字符上重叠。于是跨越边界的内容,总有一块能完整覆盖它。

代价是什么?块变多、存储变冗余。重叠区在索引里存了两遍。下表是同一批语料在不同 overlap 下的实测:

overlap总块数平均块长
0176392.4
40194394.7
80217396.0
120247396.7

overlap 从 0 涨到 120,块数从 176 涨到 247——多了约 40% 的冗余。这是典型的召回率换存储:重叠越大,边界处的信息越不容易丢,但索引越大、检索时更容易命中重复内容。卷一的默认值取 overlap = size / 5,也就是 400 配 80,是个不激进也不吝啬的折中。

把 size=400, overlap=80 的块区间摊开看,重叠发生在哪里一目了然:

块号起点终点(不含)与上一块重叠
00400—
132072080
2640104080

相邻块总是共享 80 个字符。这 80 个字符的存储代价,换来的是"边界处的内容至少完整出现在某一块里"。

还有一个容易踩的坑:size 按字符数还是 token 数?本节按字符数,因为对中文语料,1 个汉字大约就是 1 个 token,字符数是个不错的近似;而英文大约是 4 个字符 ≈ 1 个 token,按字符数会显著低估。真实工程里更精确的做法是用 tokenizer 按 token 数切,但那样就把分词器版本也拖进了可复现性。卷一的取舍是:先用字符数,把确定性放在第一位,token 口径留到第 15 章核算上下文预算时再对齐。

先用一个 10 字符的小例子把块边界看清楚。chunk("abcdefghij", size=4, overlap=2) 返回:

['abcd', 'cdef', 'efgh', 'ghij']

step = 2,所以每块向右挪 2 格:abcd → cdef → efgh → ghij,相邻两块正好共享 2 个字符(cd、ef、gh)。最后一块 ghij 触到末尾就停,不再前进。

size 选多大?同一批语料按 overlap = size / 5 试三档(实测):

sizeoverlap块数平均块长
20040432199.3
40080217396.0
800160109784.8

size 翻倍,块数大致减半——总字符数固定,这个比例很直观。真正的取舍在"每块装了多少信息":200 字可能只够半段论述,800 字可能把两三个话题混在一起。400 左右对中文技术文档是个舒服的默认:够容纳一段完整论述加一个小标题,又不容易跨太多话题。

10.1.3 在真实语料上实测

切分函数写完了,得拿真实数据跑一遍,否则不知道它在"人写的文章"上长什么样。语料就用本仓库 content/posts/python/ 下 6 篇真实文章——它们有标题、有代码块、有表格,是最典型的工程文档:

python-async-advanced.md
python-concurrency-performance.md
python-built-in-data-structures.md
python-typing-pydantic.md
python-decorators-context-managers.md
python-memory-gc-performance.md

加载、剥 frontmatter、按 size=400, overlap=80 切块,统计结果如下(实测,Python 3.14.6):

文件数: 6
总块数: 217
平均块长: 396.0
最长块: 400
最短块: 156
各文件块数: [34, 27, 40, 41, 47, 28]

几个数字值得读一读。平均块长 396.0 逼近上限 400——因为绝大多数块都是"满"的 400 字符,只有每篇的最后一块是余数,所以均值被拉得离 400 很近。最长块 400 就是 size 本身,符合预期。最短块 156 来自某篇文件末尾的残余:那篇文章正文长度除以步长 320 之后,剩下的尾巴正好 156 个字符。

最短块 156 这个数字提醒我们一个真实问题:尾部残块可能短到几乎没有信息量。一篇文件的最后一段如果只有几十个字,它会作为一个独立块进索引,检索时可能被命中却给不出有用内容。工业界的做法是设一个 min_size,比它短的残块直接并回前一块。卷一先保留原样,让"残块存在"这件事显式可见。

再看一个具体块的形状。python-concurrency-performance 的第 0 块,开头正是这篇文章的引言和第一节标题:

> 本文假设 Python 3.11+。所有代码可直接复制运行。

---

## 1. GIL(全局解释器锁)的真相

### 1.1 什么是 GIL

CPython 的 **GIL(Global Interpreter Lock)** 是一个互斥锁,确保在任意时刻只有一个线……

这个块是"好块":它从一个自然边界(引言)开始,内容连贯。但不是所有块都这么走运。固定长度切分是无视语义的——它只数到 400 个字符就下刀,刀口可能落在句子中间、代码块中间、甚至表格中间。python-async-advanced 的第 1 块就是一个反面教材,它的开头是上一个代码块的尾巴(中间还夹着一行被切剩的代码围栏):

漏

这导致任务生命周期管理困难——后台任务可能悄悄失败、资源无法正确回收、异常被吞没。

第一行只有一个"漏"字——那是上一块里某个循环被切断后剩下的碎片,紧跟着的是一行孤零零的代码围栏(三个反引号)。这就是固定长度切分的真实模样:它便宜、确定、可复现,但它不看内容。

10.1.4 按字符切 vs 按语义切

既然固定长度会切出"漏"这种碎块,为什么还用它?因为另一条路——按语义切——有它自己的成本。两条路的对比:

维度按字符(固定长度+重叠)按语义(标题/段落/句子)
边界位置任意,可能切断句子落在标题、段落、句号处
块长均匀(都接近 size)参差(短段落 vs 长段落)
实现十几行需要 Markdown 解析或句子切分
可复现完全确定依赖分词/解析器版本
对代码块会切断若识别围栏则可整块保留

按语义切最诱人的地方是"块长参差"这一条的反面:它不会把一句话劈开。但代价立刻显现——用 \n\n 按空行切分同一批语料,实测结果是这样的:

段落切分 -> 块数: 868  平均: 76.8  最长: 722  最短: 3

868 个块,平均只有 76.8 个字符——短得离谱。更糟的是最长 722、最短 3:最短块只有 3 个字符(大概是某个孤立的符号或空标题),最长块 722 字符。这种"长度分布极不均"的块,会让后续的向量化、检索、拼上下文都变得棘手:短块没有语义,长块会挤爆上下文预算。

所以工程上的主流不是二选一,而是混合:先按语义(标题层级)做粗切,再对超过 size 的大段做固定长度细切,对小于 min_size 的小段做合并。这样既保留了语义边界,又保证了块长可控。卷一只实现固定长度这一半,因为它是确定性的、可断言的;语义切分留到卷三对照真实 RAG 框架时再展开。

选择哪种切法,最终取决于下游要什么:

下游需求推荐切法
快速原型、可复现测试固定长度 + 重叠
长文档问答(精确到段)按标题层级切
代码库检索按函数/类边界切
表格/结构化数据按行或按记录切

还有一条常被忽略的维度:块是否自包含。无论按字符还是按语义切,块都是"断章"——它不知道自己在哪篇文章、哪个章节。工程上常见的补救是给每个块拼一个"上下文头":把文章标题和当前章节的标题路径(如 python-concurrency-performance > 2. asyncio 并发模型)接到块首。这样即使块本身是一段孤立代码,检索命中后模型也能知道它属于哪里。本节先不做这一步,把最朴素的形式留作基线。

10.1.5 边界处理与常见坑

十几行的 chunk 看着简单,但边界情况不少。下面这张表列出最容易翻车的几种,以及本节的取舍:

情况现象本节处理
文本为空期望返回 []循环条件 i < n 天然跳过
文本短于 size只应有一块i + size >= n 立刻 break
尾部残块过短出现 156 字符的碎块保留,把问题显式暴露
overlap ≥ size步长 ≤ 0,可能死循环开头 raise ValueError
frontmatter 未闭合找不到 \n---find 返回 -1,原样返回

第一条和第四条最关键:空输入返回空列表,让上层不必特判;overlap ≥ size 直接报错,因为步长为 0 或负会让 while 永远转下去。这两条都是"让错误早发生、且发生得清楚"的写法。

加载真实目录时还要多一层:用 Path.glob("**/*.md") 把文件找齐,用 utf-8 显式读(别依赖平台默认编码),并跳过 _index.md 这类索引页——它们是导航,不是知识,混进语料只会污染检索结果。

10.1.6 产物:块列表

切分的最终产物是一个"块列表",每一项是三元组 (doc_id, chunk_id, text)。doc_id 标识文档,chunk_id 是文档内从 0 开始的块序号。把加载和切分串起来,就是 10.2 检索工具的数据源:

from pathlib import Path

def load_corpus(root: str) -> list[tuple[str, int, str]]:
    blocks: list[tuple[str, int, str]] = []
    for path in sorted(Path(root).glob("*.md")):
        if path.name == "_index.md":
            continue
        doc_id = path.stem
        body = strip_frontmatter(path.read_text(encoding="utf-8"))
        for cid, piece in enumerate(chunk(body)):
            blocks.append((doc_id, cid, piece))
    return blocks

doc_id 用 path.stem(文件名去扩展名),简单、稳定、可读——检索结果里回显 python-concurrency-performance#0 比回显一个自增整数有用得多。sorted(...) 保证遍历顺序确定,从而块编号确定:同样的语料跑两次,doc_id#chunk_id 一定一样。这份确定性是 10.3 引用校验能成立的前提——引用一个编号,前提是这个编号可复现。

对 10.1.3 的 6 篇语料跑一遍 load_corpus,返回的就是那 217 个块,再顺手建一个 {(doc_id, chunk_id): text} 的字典,索引就有了。

到这里,10.1 的产物就是一份 list[(doc_id, chunk_id, text)]。它还没有任何"智能"——只是把文件切成了编号的小块。真正的检索能力,是下一节的事。

真实模型下会怎样:本节全程不碰模型,切分是纯确定性代码,所以假模型和真实模型看到的块完全一样。真实系统里多出来的是"切分质量"这一变量——用 tiktoken 按 token 数切(而非字符数)、用 Markdown 解析器保留标题路径、把标题路径拼进块首做"上下文增强",这些都会显著提升检索命中率。但它们都建立在本节这个最小内核之上:加载 → 剥元数据 → 切块。另外,真实语料往往是 PDF/HTML,加载层的复杂度会指数上升,而切分逻辑几乎不变——这也是为什么本节把加载和切分分开讲。

阅读导航:上一节:9.3 记忆即工具 · 下一节:10.2 索引与检索工具 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距