为什么「怎么切」和「切什么」一样重要?
前面三篇文章,我们搭好了 RAG Pipeline,也调对了核心参数。但如果你仔细看过召回结果,可能会发现一个奇怪的现象:
明明文档里有答案,Retriever 就是找不到;或者找到了,但答案被拦腰切断,LLM 只能看到前半句。
问题往往出在**分块(Chunking)**这一步。
分块的本质是信息切分策略——你把一本 500 页的书切成多少份、每份多大、在哪里下刀,直接决定了读者(这里是 Retriever)能不能快速找到想要的内容。
本文会用同一份技术文档,分别用 4 种策略处理,让你亲眼看到「怎么切」带来的巨大差异。
📎 配套源码:本文所有实验代码已开源在
llm-in-action/04-chunking-strategies,克隆下来即可复现。
四种分块策略速览
在动手之前,先用一张表建立直觉:
| 策略 | 核心思想 | 优点 | 缺点 |
|---|---|---|---|
| 固定大小分块 | 按固定字符数硬切,像剪刀剪纸条 | 简单、块大小均匀 | 可能切断句子,语义完整性差 |
| 递归字符分块 | 按优先级尝试段落→换行→句子→单词 | 兼顾语义和均匀性 | 对中文支持一般(按英文标点) |
| 语义分块 | 计算相邻句子语义相似度,低相似度处切开 | 块内语义高度一致 | 需要 Embedding API,成本高 |
| 文档结构分块 | 按 Markdown/HTML 标题层级切分 | 保留文档结构,检索结果自带章节上下文 | 仅适用于结构化文档 |
实验设计
测试文档与源码
完整可运行代码见 llm-in-action/04-chunking-strategies,包含:
chunking_compare.py— 4 种策略对比脚本data/sample-tech-doc.md— 测试用的 Markdown 技术文档.env.example— 环境变量模板(SemanticChunker 需要 Embedding API)
测试文档
我们用一份约 5400 字符的 Markdown 技术文档《微服务架构设计指南》,包含 7 个一级章节、多个二级和三级标题,涵盖服务拆分、通信协议、数据一致性、可观测性、安全设计、部署运维等主题。
四种策略的配置
| 策略 | 关键配置 |
|---|---|
| 固定大小分块 | CharacterTextSplitter(chunk_size=512, chunk_overlap=50) |
| 递归字符分块 | RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50, separators=["\n\n", "\n", ". ", " ", ""]) |
| 语义分块 | SemanticChunker(embeddings, breakpoint_threshold_type="percentile", breakpoint_threshold_amount=85, sentence_split_regex=r"(?<=[。!?.?!])\s+", buffer_size=0) |
| 文档结构分块 | MarkdownHeaderTextSplitter(headers_to_split_on=[("#", "Header 1"), ("##", "Header 2"), ("###", "Header 3")]) |
关于
buffer_size=0:SemanticChunker 默认会把相邻句子拼接后计算 Embedding(buffer_size=1表示拼接前后各 1 句)。但 SiliconFlow 的 BGE 模型限制单条输入 < 512 tokens,拼接后容易超限。设为 0 后每个句子独立计算,虽然损失了部分上下文信息,但能稳定运行。
策略一:固定大小分块(Fixed Size)
原理
最粗暴、最直接的方式:不管内容是什么,按固定长度一刀切。
想象你用一把剪刀,每隔 512 个字符剪一刀。简单高效,但可能刚好剪断一句话的中间。
代码
from langchain_text_splitters import CharacterTextSplitter
splitter = CharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
length_function=len,
separator="\n", # 优先按换行切,没有换行就硬切
)
chunks = splitter.split_documents(documents)
实验结果
| 指标 | 数值 |
|---|---|
| 块数 | 12 |
| 平均长度 | 453.5 字符 |
| 最大长度 | 506 字符 |
| 最小长度 | 128 字符 |
前 3 个块的内容:
块 1(489 字符):
# 微服务架构设计指南 本文介绍如何设计和实现一套生产级的微服务架构...
块 2(504 字符):
- **读服务 vs 写服务**:读多写少的场景,读写分离可以独立扩缩容...
块 3(457 字符):
**gRPC** 基于 HTTP/2 和 Protocol Buffers。优点是:...
问题暴露:
注意块 2 的开头:- **读服务 vs 写服务**... 这是一个列表项的中间部分。固定大小分块把上一块末尾的列表硬生生切断了,块 2 从一个不完整的列表项开始。如果用户问"读写分离有什么优势?",Retriever 召回这块时,LLM 看到的是残缺的信息。
策略二:递归字符分块(Recursive Character)
原理
比固定大小「聪明」一点:它有一组分隔符优先级列表,按顺序尝试——先按段落(\n\n)切,如果还太大就按换行(\n)切,再不行按句子(. )切,最后按单词( )切。
像是一个有经验的编辑:优先在段落边界下刀,实在不行再在句子边界下刀,绝不在单词中间切断。
代码
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
length_function=len,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_documents(documents)
实验结果
| 指标 | 数值 |
|---|---|
| 块数 | 13 |
| 平均长度 | 431.5 字符 |
| 最大长度 | 507 字符 |
| 最小长度 | 88 字符 |
前 3 个块的内容:
块 1(441 字符):
# 微服务架构设计指南 本文介绍如何设计和实现一套生产级的微服务架构...
块 2(452 字符):
### 1.2 按技术特性拆分 除了业务边界,也可以按技术特性拆分:...
块 3(457 字符):
微服务之间最常见的同步通信方式是 HTTP REST 和 gRPC。...
对比固定大小的改进:
块 2 现在以 ### 1.2 按技术特性拆分 开头——一个完整的标题。递归字符分块成功地在标题边界处切开了,没有切断列表项。
但注意它的 separators 列表里用的是 . (英文句点+空格),对中文文档来说,它不会按中文句号(。)切分。所以中文文档中它的行为和固定大小很接近,主要靠 \n\n 和 \n 来切分。
策略三:语义分块(Semantic Chunking)
原理
前两种策略都是「按长度切」,而语义分块是「按意思切」。
具体做法:
- 先把文档拆成句子
- 计算每个句子的 Embedding(语义向量)
- 比较相邻句子的语义相似度
- 如果相似度突然下降(低于设定阈值),就在此处切开
想象你在看一部电影,场景从办公室突然切到了海边——这就是语义边界。语义分块能识别这种「场景切换」,确保每个块内部讲的是同一件事。
代码
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(
model="BAAI/bge-large-zh-v1.5",
api_key=os.getenv("EMBEDDING_API_KEY"),
base_url=os.getenv("EMBEDDING_API_BASE", "https://api.siliconflow.cn/v1"),
chunk_size=32, # SiliconFlow 限制 batch_size=32
)
# 关键:自定义中文句子拆分正则,否则 SemanticChunker 默认只按英文标点切
splitter = SemanticChunker(
embeddings=embeddings,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=85,
sentence_split_regex=r"(?<=[。!?.?!])\s+",
buffer_size=0, # 避免组合句子后超过 512 token 限制
)
chunks = splitter.split_documents(documents)
遇到的坑
实现语义分块时,我们踩了三个坑:
坑 1:batch_size 超限
ValueError: input batch size 1000 > maximum allowed batch size 32
→ 解决:OpenAIEmbeddings(chunk_size=32)
坑 2:单条 token 超限
Error code: 413 - input must have less than 512 tokens
→ 解决:设置 buffer_size=0,不让 SemanticChunker 拼接相邻句子
坑 3:空字符串导致 400
Error code: 400 - The parameter is invalid
→ 解决:继承 SemanticChunker 重写 _get_single_sentences_list,过滤空字符串
class FilteredSemanticChunker(SemanticChunker):
def _get_single_sentences_list(self, text: str) -> List[str]:
sentences = re.split(self.sentence_split_regex, text)
return [s for s in sentences if s.strip()]
实验结果
| 指标 | 数值 |
|---|---|
| 块数 | 9(最少) |
| 平均长度 | 590.9 字符 |
| 最大长度 | 2047 字符 |
| 最小长度 | 17 字符 |
关键发现:
语义分块的块数最少(9 块),但块大小差异极大——最小 17 字符,最大 2047 字符。这说明它确实在按「语义边界」聚合内容:语义相近的句子被聚合成大块,语义跳变的地方被切成小块。
比如「服务间通信」这一整章(REST vs gRPC vs 消息队列)被聚合成了一个 1189 字符的大块——因为这些内容都在讲同一件事(服务怎么互相通信)。而章节之间的过渡句被切成了很小的块(如只有 28 字符的决策树片段)。
策略四:文档结构分块(Markdown Header)
原理
前三种策略都是「盲人摸象」——不知道文档结构,纯按文本特征切分。而文档结构分块则「睁着眼切」:它认识 Markdown 的 #、##、### 标题,严格按照标题层级来划分。
每个块的边界就是标题边界:从某个标题开始,到下一个同级或更高级标题之前结束。
代码
from langchain_text_splitters import MarkdownHeaderTextSplitter
splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
],
strip_headers=False, # 保留标题在块内容中
)
chunks = splitter.split_text(text)
实验结果
| 指标 | 数值 |
|---|---|
| 块数 | 20(最多) |
| 平均长度 | 266.5 字符 |
| 最大长度 | 402 字符 |
| 最小长度 | 71 字符 |
关键发现:
文档结构分块产生的块数最多(20 块),但每个块都自带「身份证」——metadata 里记录了它属于哪个标题层级:
chunk.metadata = {
"Header 1": "微服务架构设计指南",
"Header 2": "1. 服务拆分策略",
"Header 3": "1.1 按业务边界拆分(DDD)"
}
这意味着检索时,你不仅拿到了内容,还知道它来自哪个章节。这在后续做引用溯源("答案来自文档第 X 章")时非常有价值。
四种策略横向对比
统计对比总表
| 策略 | 块数 | 平均长度 | 中位数 | 最大 | 最小 |
|---|---|---|---|---|---|
| 固定大小分块 | 12 | 453.5 | 476.5 | 506 | 128 |
| 递归字符分块 | 13 | 431.5 | 457.0 | 507 | 88 |
| 语义分块 | 9 | 590.9 | 422.0 | 2047 | 17 |
| 文档结构分块 | 20 | 266.5 | 259.0 | 402 | 71 |
可视化对比:同一个问题的召回差异
假设用户问:"微服务拆分有哪些反模式?"
| 策略 | 召回的块 | 问题 |
|---|---|---|
| 固定大小 | 块 4(含部分反模式内容,但开头被切断) | 列表项从中间开始,LLM 看不到完整上下文 |
| 递归字符 | 块 5(完整包含"1.3 拆分的常见反模式"小节) | 较好,但如果小节很长会截断 |
| 语义分块 | 块 3(聚合了反模式 + 部分后续内容) | 可能混入无关内容 |
| 文档结构 | 块 6(精确对应"### 1.3 拆分的常见反模式") | 最佳,结构精确匹配 |
策略选择决策表
| 场景 | 推荐策略 | 理由 |
|---|---|---|
| 通用技术文档(PDF/Word) | 递归字符分块 | 最稳妥的 baseline,不需要特殊格式 |
| Markdown / 论文 / 书籍 | 文档结构分块 | 保留章节结构,检索结果可溯源 |
| 专业术语密集的文档(法律/医学) | 语义分块 | 块内语义一致,减少跨主题干扰 |
| 对分块速度要求极高(实时场景) | 固定大小分块 | 零计算开销,纯字符串操作 |
| 代码文档 | 递归字符分块 + 自定义分隔符 | 按函数/类边界切分 |
选型建议
第一步:先用递归字符分块跑通 baseline
↓
第二步:如果文档是 Markdown/HTML,试试文档结构分块
↓
第三步:如果检索质量不满意,再上语义分块(成本最高但效果最好)
小结
本文用同一份文档、四种策略,让你直观感受到「怎么切」对 RAG 质量的影响:
- 固定大小:简单但粗暴,适合快速原型
- 递归字符:最通用的 baseline,80% 场景够用
- 语义分块:效果最好但成本最高,适合精度要求高的场景
- 文档结构:结构化文档的最佳选择,检索结果自带上下文
关键认知: 没有完美的分块策略,只有适合当前文档类型和业务场景的策略。实际项目中,建议用本文的对比脚本,拿自己的文档跑一遍,用数据说话。