文档分块策略-Chunking对RAG效果的决定性影响
**本文适合谁**:已经跑通了基础 RAG(第 01 篇),但发现检索结果不够好——要么返回的内容太冗杂,要么答案被莫名截断。这篇文章讲的就是问题出在哪里,以及怎么解决。
文档分块策略:Chunking 对 RAG 效果的决定性影响
本文适合谁:已经跑通了基础 RAG(第 01 篇),但发现检索结果不够好——要么返回的内容太冗杂,要么答案被莫名截断。这篇文章讲的就是问题出在哪里,以及怎么解决。
为什么说分块是 RAG 最重要的单一因素
很多人搭 RAG 系统遇到问题,第一反应是换个更好的模型,或者调整 Prompt。但实际工程经验告诉我们:检索质量的瓶颈通常不在模型,在分块策略。
以一个极端的例子说明:
一份公司产品手册,把整本手册当成一个 chunk 存入向量数据库。用户问"产品保修期是多久",检索时这个大 chunk 和大多数问题都有一定相似度(因为它包含了所有内容),但对任何具体问题都不够精准。LLM 拿到一大堆噪音,很可能给出模糊的答案。
反过来,把每个句子都切成一个 chunk。"保修期为一年,自购买之日起计算"——这句话单独存在,没有上下文说明这是什么产品、什么条件下的保修。LLM 拿到这个片段,也不知道该怎么用。
好的分块策略,是在"检索精准度"和"上下文完整性"之间找到平衡点。
切蛋糕类比:大小刚好才好吃
把分块想象成切蛋糕:
一整块大蛋糕(整篇文档):没法一口吃完,无从下嘴。
切成太大的块:拿着一大块,只想吃其中一小部分,浪费又不方便。
切成太小的块:一口就吃完,但没有满足感,而且一块蛋糕的多种口味(上下文)都消失了。
切成合适大小的块:每块有完整的味道和口感,吃多少拿多少。
RAG 的目标:每个 chunk 包含一个完整的语义单元,足以独立理解,又不会过于冗杂。
分块为什么有这么大的影响
理解分块影响的关键,是理解 Embedding 的工作原理。
Embedding 模型(把文本转成向量的模型)有个特性:输入文本越短,向量表示越聚焦;输入越长,不同语义的信息被"平均"进一个向量里,表示能力下降。
可以把 Embedding 理解为"语义压缩":把几百个词的含义压缩进 1536 个数字里。信息越纯粹(只讲一件事),压缩后失真越小;信息越混杂(讲了很多不相关的事),压缩后越难区分不同语义。
实际效果:一个包含五种商品退货政策的 chunk,它的向量是五种语义的混合体。和"电子产品退货"这个查询的相似度,会被其他四类商品的信息稀释,排名可能反而不如一个专门讲电子产品退货的小 chunk。
检索精准度 vs 上下文完整性是一对内在矛盾:
- 从检索角度:chunk 越小,语义越聚焦,向量越精准,越容易检索到正确内容
- 从生成角度:chunk 越大,上下文越完整,LLM 越能理解前因后果
五种分块策略,是对这个矛盾的不同解法。
五种主要分块策略
四种主要分块策略对比——固定大小、按句子、滑动窗口、递归字符,各有优缺点适用场景
策略一:固定大小分块
方式:按 token 数硬切,每块 512 token,相邻块重叠 50 token。
优点:实现简单,速度快,适合快速搭建原型。
缺点:完全不考虑语义边界,一句话可能从中间被切断。
适合场景:格式规整、段落较短的文档(FAQ 条目、数据库导出的记录)。
from langchain.text_splitter import CharacterTextSplitter
# 固定大小分块,不考虑语义
splitter = CharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separator="\n" # 最简单的分隔符
)
策略二:递归字符分块(最常用的起点)
方式:按优先级递归切割——先按段落(\n\n)切,太长再按换行(\n)切,还是太长再按句子切,最后才硬切字符。
为什么这是最好的起点:它尽量在自然的语义边界处切割,通用场景下效果不错,代码简单,LangChain 的默认分块器就是这个。
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 推荐的起点配置
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""]
# 中文场景加上 "。" "!" "?" 让它在句子边界切割
)
chunks = splitter.split_documents(documents)
什么时候升级到其他策略:如果发现检索结果"不够聚焦"(返回内容跑题),考虑语义分块;如果发现回答"被截断"(答案横跨两个 chunk),考虑父子分块。
策略三:语义分块
方式:用 Embedding 模型判断切割点——把文档按句子分开,计算相邻句子的向量相似度,当相似度出现明显下降时("话题转变了"),在此处切割。
为什么效果好:切出的 chunk 语义内聚性最强——每个 chunk 只讲一件事,向量表示最精准。
代价:需要对每个句子做 Embedding,速度慢、成本高。100万字的文档可能要花几分钟处理。不适合实时处理大量文档。
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings()
# breakpoint_threshold_type 控制切割灵敏度
# "percentile" = 在相似度变化最大的前 N% 处切割
splitter = SemanticChunker(
embeddings,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=95 # 调小这个值 = 切更细
)
chunks = splitter.split_documents(documents)
适合场景:对检索质量要求高、文档量不大(几十篇到几百篇)、可以承担额外计算成本的情况。
策略四:结构感知分块
方式:利用文档本身的结构信息。Markdown 文档有 #、##、### 标题,HTML 有 <h1> 等标签——这些天然就是语义边界。
特别之处:每个 chunk 会保留它所在章节的标题作为前缀,这样检索出来的内容即使离开上下文也能理解"这是关于什么"的内容。
from langchain.text_splitter import MarkdownHeaderTextSplitter
# 按 Markdown 标题层级切割
headers_to_split_on = [
("#", "章节"),
("##", "小节"),
("###", "子节"),
]
splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False # 保留标题在 chunk 内容里
)
# 分割结果的 metadata 里会记录标题层级
chunks = splitter.split_text(markdown_content)
for chunk in chunks:
print(f"标题:{chunk.metadata}")
print(f"内容:{chunk.page_content[:100]}...")
适合场景:技术文档、产品手册、API 文档这类结构良好的内容。
策略五:父子分块(目前最优雅的解法)
核心思想:检索和生成对 chunk 大小的需求是相反的,那就分别处理——用小 chunk 做检索,用大 chunk 做生成。
具体流程:
- 把文档切成大块("父 chunk",约 1000 token)——这是给 LLM 看的完整上下文
- 把每个父块进一步切成小块("子 chunk",约 200 token)——这是用来检索的精准语义单元
- 每个子块记录自己属于哪个父块
- 检索时用子块做向量匹配(精准),返回时给出对应的父块(完整)
这个设计非常优雅:用小块的精准性解决检索问题,用大块的完整性解决生成问题。
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
embeddings = OpenAIEmbeddings()
# 子 chunk:小,用于精准检索
child_splitter = RecursiveCharacterTextSplitter(
chunk_size=200,
chunk_overlap=20
)
# 父 chunk:大,提供完整上下文
parent_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=100
)
# 向量库存子 chunk,文档库存父 chunk
vectorstore = Chroma(
collection_name="parent_child",
embedding_function=embeddings
)
docstore = InMemoryStore() # 生产环境换成 Redis 或数据库
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=docstore,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# 添加文档(自动处理父子分块逻辑)
retriever.add_documents(documents)
# 检索时返回的是父 chunk(完整上下文)
# 但底层用的是子 chunk 做向量匹配(精准)
results = retriever.invoke("产品退货政策")
for doc in results:
print(f"返回的父 chunk({len(doc.page_content)} 字符):")
print(doc.page_content[:200])
并排对比:不同策略的实际效果
用同一份文档,测试三种策略的检索结果差异:
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
# 准备测试文档
policy_doc = """
公司退货政策
第一部分:电子产品退货规定
1.1 电子产品自购买之日起 7 天内可申请无理由退货
1.2 退货前提:原包装完好,附件齐全,无明显使用痕迹
1.3 7天以上30天以内:仅支持质量问题换货
1.4 超过30天:通过厂商质保渠道处理
第二部分:退货流程
2.1 登录 App,进入"我的订单"
2.2 选择需要退货的订单,点击"申请售后"
2.3 填写退货原因并上传凭证
2.4 等待客服审核(1-2个工作日)
2.5 快递员上门取件
2.6 商品入库验收后,3-5个工作日退款
第三部分:特殊规定
3.1 节假日期间退款可能延迟至7-10个工作日
3.2 大家电类需联系专属客服
"""
from langchain.schema import Document
docs = [Document(page_content=policy_doc, metadata={"source": "policy"})]
embeddings = OpenAIEmbeddings()
query = "电子产品退货需要满足什么条件"
# 策略一:小 chunk(chunk_size=200)
splitter_small = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20)
chunks_small = splitter_small.split_documents(docs)
vs_small = Chroma.from_documents(chunks_small, embeddings, collection_name="small")
results_small = vs_small.similarity_search(query, k=2)
# 策略二:大 chunk(chunk_size=600)
splitter_large = RecursiveCharacterTextSplitter(chunk_size=600, chunk_overlap=60)
chunks_large = splitter_large.split_documents(docs)
vs_large = Chroma.from_documents(chunks_large, embeddings, collection_name="large")
results_large = vs_large.similarity_search(query, k=2)
print(f"小 chunk 切出 {len(chunks_small)} 个,大 chunk 切出 {len(chunks_large)} 个")
print(f"\n=== 小 chunk 检索结果(top-1)===")
print(f"长度:{len(results_small[0].page_content)} 字符")
print(results_small[0].page_content)
print(f"\n=== 大 chunk 检索结果(top-1)===")
print(f"长度:{len(results_large[0].page_content)} 字符")
print(results_large[0].page_content)
典型观察:
- 小 chunk 检索:精准定位到"1.1-1.4"这几条规定,内容高度相关
- 大 chunk 检索:返回整个"第一部分",包含了要找的信息,但也带了一些不直接相关的内容
两者各有优劣,这就是父子分块能同时兼顾的原因。
chunk_size 和 chunk_overlap 怎么选
chunk_size 参考值:
| 场景 | 推荐 chunk_size |
|---|---|
| FAQ、问答对 | 128-256 token |
| 通用文档(合同、政策、手册) | 512-1024 token |
| 技术文档、代码注释 | 1024-2048 token |
| 学术论文、长篇报告 | 512-1024 token |
chunk_overlap 经验值:chunk_size 的 10%-20%。chunk_size=512 时,overlap 设 50-100。
overlap 的作用:假设信息 A 在 chunk 边界处,没有 overlap 会导致 A 被分到两个 chunk,单独看哪个都不完整。有了 overlap,相邻 chunk 会共享边界处的内容,避免信息丢失。
但 overlap 不是越大越好:太大的 overlap 导致相邻 chunk 内容高度重复,浪费存储空间,检索时重复内容还可能互相干扰。
哪种策略适合你的文档
实际建议:
所有项目从 RecursiveCharacterTextSplitter 开始,chunk_size=512,overlap=50。中文文档在 separators 里加上 "。" 和 "!"。这能覆盖 80% 的场景。
上线后根据实际问题迭代:
- 用户反馈"答案不完整"/"被截断" → 试试父子分块
- 检索结果经常跑题、不相关 → 减小 chunk_size 或试试语义分块
- 文档有明确章节结构 → 试试结构感知分块
分块策略不是一次性决策,而是根据真实用户反馈持续调整的工程迭代过程。用 RAGAS(第 09 篇)这类评估工具定期量化效果,数据说话。