RAG 分块策略实战:文档切块大小、重叠与递归切分的取舍

做 RAG(检索增强生成)时,很多人把精力花在换更好的 Embedding 模型、上更贵的向量库,却忽略了一个更前置、也更致命的环节——RAG 分块策略。文档切块的方式直接决定召回的粒度与答案的完整性:切太大,噪声多、命中不精准;切太小,语义被切碎、上下文丢失。本文用实战对比固定长度、递归字符与语义三种切分,给出 chunk_size 与 overlap 的选型清单,帮你把检索质量的上限在入库前就抬高一截。

一、为什么分块决定 RAG 的上限

RAG 的链路是「切分 → 向量化 → 检索 → 拼进 Prompt」。前面任何一步的上限,都会被后面的步骤继承。切分处在最前面,它的输出就是后续所有环节的原材料:如果一块文本里混进了两个不相关的知识点,检索时就会一起被召回,污染上下文;如果一句话被从中间切断分到两块,模型就可能拿不到完整语义而答错。

换句话说,分块策略不是”预处理的小事”,而是检索质量的地基。很多团队在 Embedding 选型 和向量库(如 pgvectorChroma 向量库)上反复调参,效果却卡在瓶颈,根因往往出在切块阶段。本文就来解决这个最容易被忽视的环节。

二、三种主流切分方式

2.1 固定长度切分

最简单:按字符数或 Token 数从头切到尾,块与块之间留一点重叠。实现成本低、结果稳定,但完全不尊重语义边界,很容易把一个完整的句子或代码块拦腰斩断。

def fixed_split(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
    step = chunk_size - overlap
    return [text[i:i + chunk_size] for i in range(0, len(text), step)]

2.2 递归字符切分(推荐默认)

LangChain 的 RecursiveCharacterTextSplitter 会按一组分隔符优先级依次尝试切分:先按 ## 这种标题,没有就退到 、再退到 、句号、空格。它尽量在”自然边界”断开,是绝大多数场景的通用首选。

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,        # 每块目标字符数
    chunk_overlap=120,     # 相邻块重叠,保留跨块语义
    separators=["
## ", "
### ", "

", "
", "。", " ", ""],
    keep_separator=True,
)
chunks = splitter.split_text(markdown_doc)
print(f"切出 {len(chunks)} 块,平均 {sum(len(c) for c in chunks)//len(chunks)} 字")

2.3 结构感知 / 语义切分

对 Markdown 这类带层级结构的文档,直接用标题切分能保留章节元数据,让每块都”知道自己属于哪一节”。块过大时,可再叠加一层递归切分。

from langchain_text_splitters import MarkdownHeaderTextSplitter

headers = [("#", "h1"), ("##", "h2"), ("###", "h3")]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers)
docs = splitter.split_text(md)
for d in docs:
    print(d.metadata.get("h2"), "->", len(d.page_content), "字")

三、chunk_size 与 overlap 怎么选

没有放之四海皆准的数值,但有一张按文档类型决定的经验表。核心原则:块要小到”一次只讲一件事”,又要大到”保留完整语义”。

文档类型推荐 chunk_sizeoverlap说明
技术文档 / Markdown600–1000 字10–15%按标题结构切,保留章节
法律 / 合同长文1000–1500 字15–20%条款连贯,避免断句
客服 QA / 短文本200–400 字0–10%短问答,粒度细
代码仓库按文件 / 函数用 AST 切,勿断函数

overlap 的作用是让跨块的知识点在相邻块里各出现一次,避免”答案前半在块 A、后半在块 B”时只召回一块而丢失上下文。但 overlap 过大会增加存储与检索成本,一般 10–20% 足够。

四、动手:递归切分 + 代码块保护

真实文档常含代码块,递归切分可能把 ``` 围栏截断。一个实用技巧是切分前先把代码块”压平”成不可断开单元,切完再还原。

import re

def protect_code(text: str) -> str:
    # 把 ```代码块``` 临时压成单行,避免被切分截断
    return re.sub(r"```.*?```", lambda m: m.group(0).replace("
", "⏎"), text, flags=re.S)

doc = protect_code(raw_md)
chunks = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=120).split_text(doc)
chunks = [c.replace("⏎", "
") for c in chunks]  # 还原换行

五、三种方式横向对比

方式优点缺点适用场景
固定长度简单、稳定易截断语义纯文本快速原型
递归字符尊重段落/标点不理解语义通用首选
结构/语义上下文完整实现复杂结构化长文档

六、避坑清单

  • 块太小:语义被切碎,模型拼不出完整答案,召回率虚高但可用率低。
  • 块太大:一块塞进多个主题,检索命中后噪声多,浪费上下文窗口。
  • overlap 过大:存储与检索成本翻倍,且可能让同一句重复命中。
  • 截断代码块:代码被腰斩后向量化失真,回答直接报错,务必先保护围栏。
  • 忽略语言:中文按字符切、英文按 Token 切,混排文档要分别处理。

七、小结与进阶

分块没有银弹,但”递归字符切分打底 + 结构化文档用标题切 + 代码块保护 + 按类型调 chunk_size/overlap”这套组合拳,能覆盖 90% 的 RAG 场景。切分定好之后,再去优化 RAG 召回优化 与重排序,收益才会真正释放。如果想把整套 RAG 跑在本地,Dify+Ollama 是一篇不错的落地参考。

下一步可以进阶到”小块检索、大块喂给模型”的两段式策略,或用语义切分模型按含义边界断开——那是把分块质量再抬一档的方向。

八、一个对比实验:同一份 API 文档三种切分

拿一份约 1.2 万字的接口文档做实验:固定长度 500 字切出 26 块,递归字符 800 字切出 17 块,按标题结构切出 12 块。问”如何刷新 Token”时,固定长度把刷新逻辑和限流说明切进两块,召回后模型漏掉了限流部分;递归字符与结构切分都完整命中。差距不在模型,在切分。

策略块数跨块知识点召回完整度
固定长度 50026
递归字符 80017
标题结构12最少

九、把分块接进你的流水线

分块应发生在”入库”这一步,紧挨着向量化之前:原始文档 → 清洗 → 分块 → Embedding → 写向量库。三个落地建议:第一,分块粒度要和检索时的查询粒度对齐,用户常问”某某怎么配置”就按配置段落切;第二,保留每块的原文档来源与章节路径,便于回答时给出可追溯引用;第三,升级 Embedding 模型或切分策略后要重新切、重新入库,旧块不要和新块混用,否则相似度计算会失真。

当文档持续更新时,给每块打上内容哈希或版本号,只重新切变化的部分,既能降本又能保证召回一致。把分块当成一等公民来对待,RAG 的检索质量才真正可控、可复盘。

上一篇 网络丢包排查实战:从 ping、mtr 到 TCP 重传定位
下一篇 AI 红队实战:系统化攻防测试你的智能体应用