3. 文档切分:切块决定上限
这是什么:文档切分(Chunking) = 把一篇长文切成若干小块,好让检索能精确命中其中一段。
为什么用:上一章的知识库只有三条手写资料,真实场景却是 80 页的《员工手册》、一整套产品文档、几千封邮件。不切成小块,检索回来的是整本书,模型根本用不上。
如何用:本章手写一个递归切分器,讲透 chunk_size 和 overlap 怎么取,再给每块贴上元数据。
3.1 为什么必须切
| 不切的后果 | 说明 |
|---|---|
| 检索不精准 | 整本手册算一个向量,语义被稀释,什么问题都像「有点像」 |
| 塞不进上下文 | 就算模型窗口够大,几十页原文也会淹掉真正相关的那两句 |
| 答案不聚焦 | 模型在海量无关内容里找答案,容易抓错重点 |
经验之谈:RAG 效果差,一半是切分的问题。 检索不到,多半是块切碎了或者切太大。
3.2 两个关键参数
| 参数 | 含义 | 建议起点 | 调大的后果 | 调小的后果 |
|---|---|---|---|---|
chunk_size |
每块的字符数上限 | 300–800 | 块里混进无关内容,检索不精准 | 语义被割断,一句话被劈成两半 |
overlap |
相邻块重叠的字符数 | chunk_size 的 10%–20% |
索引膨胀、重复内容多 | 答案正好落在切口上时丢失 |
重叠(overlap)为什么重要:假设答案写在第 100 个字符,而切口正好在第 98 字符,那么「前半块」和「后半块」都不完整,检索到了也答不全。重叠让边界附近的文字在两块里都出现一次。
3.3 手写一个递归切分器
思路很朴素:优先用语义边界切,实在切不动才用字符硬切。
def split_text(text, chunk_size=400, overlap=60):
"""按语义边界递归切分:段落 → 行 → 句子 → 标点 → 词 → 字"""
seps = ["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
chunks = _split(text, chunk_size, seps)
chunks = _add_overlap(chunks, overlap)
return [c.strip() for c in chunks if c.strip()]
def _split(text, size, seps):
if len(text) <= size:
return [text]
if not seps: # 分隔符用完了,只能硬切
return [text[i:i + size] for i in range(0, len(text), size)]
sep = seps[0]
parts = text.split(sep)
out, buf = [], ""
for part in parts:
piece = part if not buf else buf + sep + part
if len(piece) <= size: # 装得下,继续攒
buf = piece
continue
if buf: # 装不下了,先把攒的收尾
out.append(buf)
buf = ""
if len(part) > size: # 单段自己就超长,交给下一级分隔符
out.extend(_split(part, size, seps[1:]))
else:
buf = part
if buf:
out.append(buf)
return out
def _add_overlap(chunks, overlap):
"""相邻块之间补一段重叠文字"""
if overlap <= 0 or len(chunks) <= 1:
return chunks
out = [chunks[0]]
for c in chunks[1:]:
out.append(out[-1][-overlap:] + c)
return out
用法:
text = open('handbook.md', encoding='utf-8').read()
chunks = split_text(text, chunk_size=400, overlap=60)
print(f'共 {len(chunks)} 块,最长 {max(len(c) for c in chunks)} 字')
3.4 中文与特殊内容的坑
| 坑 | 现象 | 对策 |
|---|---|---|
按 。 切,代码块被劈开 |
代码片段语法不完整 | 切分前先把三反引号包起来的代码块当整体保留 |
| 表格被按行切开 | 检索到一半的表头 | 把表格整块保留,或给每行补上表头 |
| 中英文混排 | 英文单词被劈成两半 | 在分隔符里加空格(上面已加),再不行就调大 chunk_size |
| 标题被切走 | 块里没有章节名,检索失去语境 | 给每块补上它所属的标题(见 3.5) |
中文没有空格分词,但按标点切就够用——语义边界天然存在,比按字数硬切好得多。
3.5 元数据:给每块贴标签
切完的块光有文字还不够,得带上「它来自哪」。这些标签就是元数据(metadata),后面过滤检索全靠它:
def split_file(path, title="", chunk_size=400, overlap=60):
"""切分文件,并给每块附上来源信息"""
text = open(path, encoding='utf-8').read()
blocks = []
for i, chunk in enumerate(split_text(text, chunk_size, overlap)):
blocks.append({
"text": chunk,
"metadata": {
"source": path, # 来自哪个文件
"title": title, # 属于哪一节
"chunk_id": i, # 第几块
},
})
return blocks
blocks = split_file('handbook.md', title='差旅报销')
print(blocks[0]["text"][:40], blocks[0]["metadata"])
带标题还有个额外好处:把标题拼进块的文本里,检索时更容易命中(「差旅报销」的标题会让「住宿上限多少」这个问题匹配上)。
3.6 常见翻车现场
| 报错 / 现象 | 原因 | 修法 |
|---|---|---|
| 块数暴增、索引巨大 | chunk_size 太小 |
调到 300 以上 |
| 检索总是命中标题块 | 标题被重复拼进每块正文 | 标题只放 metadata,或只在块首出现一次 |
| 答案缺最后一句 | 切口正好在答案中间 | 加大 overlap |
| 表格检索出来是乱的 | 表格按行切开 | 表格整体保留,或按行切但每行补表头 |
UnicodeEncodeError |
读文件没指定编码 | open(..., encoding='utf-8') |
3.7 动手改
- 拿一篇长中文文章跑
split_text,打印前 3 块,肉眼检查有没有被劈坏 - 把
chunk_size从 400 改成 100 再 800,对比块数和每块的起止内容 - 给你的切分器加一个功能:按 Markdown 标题(
#)分段,标题单独成块
3.8 小结
- 切分决定 RAG 的上限:块太大不精准,太小丢语义
chunk_size300–800 起调,overlap取其 10%–20%- 递归切分:优先按语义边界,实在不行才按字符硬切
- 每块都要带元数据(来源、标题),标题拼进正文能显著提升命中率
- 中文按标点切就够,不用上分词器
下一章:向量与嵌入,文字是怎么变成数字的 🐸