做 RAG(检索增强生成)时,很多人把精力花在换更好的 Embedding 模型、上更贵的向量库,却忽略了一个更前置、也更致命的环节——RAG 分块策略。文档切块的方式直接决定召回的粒度与答案的完整性:切太大,噪声多、命中不精准;切太小,语义被切碎、上下文丢失。本文用实战对比固定长度、递归字符与语义三种切分,给出 chunk_size 与 overlap 的选型清单,帮你把检索质量的上限在入库前就抬高一截。
一、为什么分块决定 RAG 的上限
RAG 的链路是「切分 → 向量化 → 检索 → 拼进 Prompt」。前面任何一步的上限,都会被后面的步骤继承。切分处在最前面,它的输出就是后续所有环节的原材料:如果一块文本里混进了两个不相关的知识点,检索时就会一起被召回,污染上下文;如果一句话被从中间切断分到两块,模型就可能拿不到完整语义而答错。
换句话说,分块策略不是”预处理的小事”,而是检索质量的地基。很多团队在 Embedding 选型 和向量库(如 pgvector、Chroma 向量库)上反复调参,效果却卡在瓶颈,根因往往出在切块阶段。本文就来解决这个最容易被忽视的环节。
二、三种主流切分方式
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_size | overlap | 说明 |
|---|---|---|---|
| 技术文档 / Markdown | 600–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”时,固定长度把刷新逻辑和限流说明切进两块,召回后模型漏掉了限流部分;递归字符与结构切分都完整命中。差距不在模型,在切分。
| 策略 | 块数 | 跨块知识点 | 召回完整度 |
|---|---|---|---|
| 固定长度 500 | 26 | 多 | 中 |
| 递归字符 800 | 17 | 少 | 高 |
| 标题结构 | 12 | 最少 | 高 |
九、把分块接进你的流水线
分块应发生在”入库”这一步,紧挨着向量化之前:原始文档 → 清洗 → 分块 → Embedding → 写向量库。三个落地建议:第一,分块粒度要和检索时的查询粒度对齐,用户常问”某某怎么配置”就按配置段落切;第二,保留每块的原文档来源与章节路径,便于回答时给出可追溯引用;第三,升级 Embedding 模型或切分策略后要重新切、重新入库,旧块不要和新块混用,否则相似度计算会失真。
当文档持续更新时,给每块打上内容哈希或版本号,只重新切变化的部分,既能降本又能保证召回一致。把分块当成一等公民来对待,RAG 的检索质量才真正可控、可复盘。




