模块 04 · 第 2 课

文档切分

用 httpx 的文档实际比较三种切分方法:按固定长度、按标题、按标题并限制长度。还会遇到一个真实的坑:代码块里的注释被当成了标题。

  • 约 35 分钟
  • 难度:进阶
  • 实测:2026-09-14,纯 Python,不调用 API

RAG 检索的单位不是整篇文档,而是文档切出来的小块(chunk)。一篇讲超时的文档可能有好几节,用户问"怎么关闭超时",你只想取回讲关闭超时的那一小段,而不是整篇。

怎么切,看起来是个很小的技术细节,实际上对 RAG 的效果影响很大。块切得太大,检索时相关的内容被大量无关的内容稀释;切得太小,一个完整的意思被拆散了,取回来的只是半句话。这一课用 httpx 的真实文档,比较几种切法。

办法一:按固定长度切

最简单的切法:不管内容,每 800 个字符切一刀。为了避免刚好把一句话切成两半,让相邻的两块重叠 100 个字符。

def split_fixed(text, size=800, overlap=100):
    """办法一:每 size 个字符切一刀,相邻两块重叠 overlap 个字符。"""
    chunks, start = [], 0
    while start < len(text):
        chunks.append(text[start:start + size])
        start += size - overlap
    return chunks

看看它把讲超时的 timeouts.md 切成了什么样。这是第 2 块:

le.com/api/v1/example", timeout=None)
```

## Setting a default timeout on a client

You can set a timeout on a client instance, which results in the given
`timeout` being used as the default for requests made with this client:

```python
client = httpx.Client()              # Use a default 5s timeout everywhere.
client = httpx.Client(timeout=10.0)  # Use a default 10s timeout everywhere.
client = httpx.Client(timeout=None)  # Disable all timeouts by default.
```

## Fine tuning the configuration

HTTPX also allows you to specify the timeout behavior in more fine grained detail.

There are four different types of timeouts that may occur. These are **connect**,
**read**, **write**, and **pool** timeouts.

* The **connect** timeout specifies the maximum amount of time to wait until
a socket 

开头是一个被切断的网址 le.com/api/v1/example",结尾停在半句话 "until a socket"。中间跨了两个小节,一半讲客户端的默认超时,一半讲四种超时。这一块的意思是混杂的:用户问"四种超时分别是什么",这一块只有一半答案;用户问"怎么给客户端设默认超时",这一块又带了一堆无关的内容。

固定长度切分的好处是简单,对任何文本都能用,每块的长度也很均匀。但它完全不理会文档的结构。

办法二:按标题切

httpx 的文档是 Markdown,本来就按 ##### 标题分好了节。每一节讲一件事,天然就是一个好的切分单位。那就按标题切:遇到一个标题,就开始一个新的块。

最直接的写法是用正则表达式,在每一个以 # 开头的行前面切一刀:

def split_by_heading_naive(text):
    """办法二(有问题的版本):凡是以 # 开头的行都当成标题切开。"""
    parts = re.split(r"(?m)^(?=#{1,3} )", text)
    return [p.strip() for p in parts if p.strip()]

看看它把 timeouts.md 切成了哪几块,每块只显示第一行:

有问题的按标题切法,timeouts.md 的各块开头:
  [ 153 字符] HTTPX is careful to enforce timeouts everywhere by default.
  [  93 字符] ## Setting and disabling timeouts
  [  87 字符] # Using the top-level API:
  [ 186 字符] # Using a client instance:
  [  87 字符] # Using the top-level API:
  [ 127 字符] # Using a client instance:
  [ 424 字符] ## Setting a default timeout on a client
  [1386 字符] ## Fine tuning the configuration
  [ 207 字符] # A client with a 60s timeout for connecting, and a 10s timeout elsewhere.

# Using the top-level API: 不是标题,是代码块里的一行 Python 注释。Python 的注释和 Markdown 的一级标题长得一模一样,都是 # 加空格。于是代码块被从中间切开了,"Setting and disabling timeouts"这一节只剩下 93 个字符的说明文字,代码示例全跑到了别的块里。

这种错误很隐蔽。程序不会报错,切出来的块数看起来也挺合理,只有逐块看过才会发现。整个文档库里,这个有问题的版本切出了 236 块,比正确的版本多了 60 块,多出来的都是这种被切碎的代码。

修正的办法是记住当前是不是在代码块里(遇到 ``` 就切换状态),在代码块里的 # 一律不算标题:

def split_by_heading(text):
    """办法二(修正版):同样按标题切,但跳过代码块里以 # 开头的注释行。"""
    chunks, current, in_code = [], [], False
    for line in text.splitlines():
        if line.startswith("```"):
            in_code = not in_code
        if not in_code and re.match(r"#{1,3} ", line) and current:
            chunks.append("\n".join(current).strip())
            current = []
        current.append(line)
    if current:
        chunks.append("\n".join(current).strip())
    return [c for c in chunks if c]

修正后:

修正后的按标题切法,timeouts.md 的各块开头:
  [ 153 字符] HTTPX is careful to enforce timeouts everywhere by default.
  [ 586 字符] ## Setting and disabling timeouts
  [ 424 字符] ## Setting a default timeout on a client
  [1594 字符] ## Fine tuning the configuration

四块,每块是完整的一节,说明文字和代码示例在一起。

这个坑提醒我们一件事:切分之后,一定要随手打印几块看看。不同格式的文档各有各的坑:HTML 有导航栏和页脚,PDF 有页眉页码和被截断的表格,代码有函数边界。

办法三:按标题切,再限制长度

按标题切也有问题:有的节特别长。整个 httpx 文档库里,按标题切出的最长一块有 5530 个字符。块太长,里面的内容就杂,检索效果会变差;而且第 01 模块第 5 课讲过,bge-small-zh-v1.5 这类嵌入模型最多只读 512 个词元,超出的部分直接丢掉。

所以再加一步:超过上限的块,按空行(也就是段落)继续切开,并在每一小块前面补上它所属的标题,这样每一小块都知道自己在讲什么。

def split_by_heading_capped(text, max_size=1500):
    """办法三:先按标题切;太长的块再按空行(段落)切开,并在每一小块前面补上所属的标题。"""
    chunks = []
    for section in split_by_heading(text):
        if len(section) <= max_size:
            chunks.append(section)
            continue
        title = section.splitlines()[0] if section.startswith("#") else ""
        current = ""
        for para in section.split("\n\n"):
            if current and len(current) + len(para) > max_size:
                chunks.append(current.strip())
                current = title + "\n\n" if title else ""
            current += para + "\n\n"
        if current.strip():
            chunks.append(current.strip())
    return chunks

比一比

四种切法在整个 httpx 文档库上的统计(完整代码在 code/04-rag/chunking.py,不调用 API,你运行应该得到完全一样的数字):

固定长度:179 块,平均 739 字符,最短 12,最长 800
按标题(有问题):236 块,平均 493 字符,最短 8,最长 5530
按标题(修正):176 块,平均 662 字符,最短 8,最长 5530
按标题+限长:196 块,平均 596 字符,最短 8,最长 2193

限长之后最长的块从 5530 降到了 2193。它还是超过了 1500 的上限,因为这一块里有一个很长的代码块,中间没有空行,我的代码只在空行处切,不会把代码块切开。这是故意的取舍:宁可让一块长一点,也不要把代码切成两半。

最短的块只有 8 个字符,是一些只有标题、没有内容的节。它们几乎没有检索价值,实际项目里可以把它们和下一块合并,或者直接丢掉。

后面几课都使用"按标题+限长"这种切法。

块的大小怎么定

没有标准答案,但有几个参考:

  • 不要超过嵌入模型的上限。bge-small-zh 和 multilingual-e5-small 都是 512 个词元。英文大约 1 个词元对应 4 个字符,1500 个字符大约是 400 个词元,在上限以内。
  • 一块最好只讲一件事。按文档本身的结构切,比按长度切更容易做到这一点。
  • 想想检索回来之后怎么用。一次要取回 5 块放进提示词,每块 600 字符,一共 3000 字符,大约 750 个词元,不算多。如果每块 5000 字符,5 块就是一万多个词元了。

最终的块大小要靠第 6 课的评估来定:换几种大小各跑一遍评估集,看哪种检索效果最好。

重叠要不要

固定长度切分时,重叠能防止一句话被切成两半后两边都不完整。按结构切分时,边界本来就落在段落或者标题上,一般不需要重叠。

重叠也有代价:同样的内容出现在两个块里,检索时可能两块一起被取回来,占用了宝贵的位置。

练习

  1. split_fixedsize 改成 300 和 2000,看看 timeouts.md 被切成了什么样。哪种你觉得更合适?
  2. 修改 split_by_heading_capped,把只有标题、没有内容的块(比如短于 50 个字符的)和它后面的块合并。
  3. 找一份你自己的文档(项目的 README、公司的 Wiki 导出、一个 PDF 转成的文本),用这三种方法切一遍,逐块看看有没有被切坏的地方。

自测

1. 按固定长度切分,最大的问题是什么?

它不理会文档的结构,经常在句子、代码、网址的中间切断,一块里还可能混着两个不相关的小节。这样的块意思不完整、不单一,检索和回答的效果都会变差。

2. 按 Markdown 标题切分时,为什么要特别处理代码块?

代码块里的 Python 注释以 # 加空格开头,和 Markdown 标题的写法一模一样。不处理的话,注释会被当成标题,代码块被从中间切开,说明文字和代码示例被分到不同的块里。

3. 为什么限制块的长度时,要在每一小块前面补上所属的标题?

长的一节被切成几小块后,后面的小块可能只有正文,看不出在讲什么。补上标题,每一小块就带上了主题信息,检索时更容易被正确匹配,放进提示词时模型也知道这段内容的上下文。

提问与讨论

这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。

提问 +3 积分,回答别人 +6 积分。内容经审核后公开。

正在加载讨论…