🐸 青蛙编程网
首页 / AI / 检索增强 RAG / 3. 文档切分

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 动手改

  1. 拿一篇长中文文章跑 split_text,打印前 3 块,肉眼检查有没有被劈坏
  2. 把 chunk_size 从 400 改成 100 再 800,对比块数和每块的起止内容
  3. 给你的切分器加一个功能:按 Markdown 标题(#)分段,标题单独成块

3.8 小结

  • 切分决定 RAG 的上限:块太大不精准,太小丢语义
  • chunk_size 300–800 起调,overlap 取其 10%–20%
  • 递归切分:优先按语义边界,实在不行才按字符硬切
  • 每块都要带元数据(来源、标题),标题拼进正文能显著提升命中率
  • 中文按标点切就够,不用上分词器

下一章:向量与嵌入,文字是怎么变成数字的 🐸

📥 下载本章代码

ZIP 内含本章全部代码文件与说明,解压即用,与教程内容同步生成。