课程0基础Agent开发课 / RAG与向量数据库 / 文档分块策略-Chunking对RAG效果的决定性影响
— 15 min read

文档分块策略-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 条目、数据库导出的记录)。

python
from langchain.text_splitter import CharacterTextSplitter

# 固定大小分块,不考虑语义
splitter = CharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=50,
    separator="\n"  # 最简单的分隔符
)

策略二:递归字符分块(最常用的起点)

方式:按优先级递归切割——先按段落(\n\n)切,太长再按换行(\n)切,还是太长再按句子切,最后才硬切字符。

为什么这是最好的起点:它尽量在自然的语义边界处切割,通用场景下效果不错,代码简单,LangChain 的默认分块器就是这个。

python
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万字的文档可能要花几分钟处理。不适合实时处理大量文档。

python
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 会保留它所在章节的标题作为前缀,这样检索出来的内容即使离开上下文也能理解"这是关于什么"的内容。

python
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 做生成。

具体流程:

  1. 把文档切成大块("父 chunk",约 1000 token)——这是给 LLM 看的完整上下文
  2. 把每个父块进一步切成小块("子 chunk",约 200 token)——这是用来检索的精准语义单元
  3. 每个子块记录自己属于哪个父块
  4. 检索时用子块做向量匹配(精准),返回时给出对应的父块(完整)

这个设计非常优雅:用小块的精准性解决检索问题,用大块的完整性解决生成问题

原始文档

父分块器
1000 token/块

父 chunk 列表

子分块器
200 token/块

子 chunk 列表
每个子块记录父块 ID

向量数据库
存储子 chunk 向量

文档存储
存储完整父 chunk

用户提问

向量化

检索子 chunk
精准匹配

获取父块 ID

从文档存储取父 chunk
完整上下文

拼入 Prompt

LLM 生成回答

python
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])

并排对比:不同策略的实际效果

用同一份文档,测试三种策略的检索结果差异:

python
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 内容高度重复,浪费存储空间,检索时重复内容还可能互相干扰。

重叠区 50 token

重叠区 50 token

原始文档(连续长文本)

Chunk 1
Token 0 ~ 512

Chunk 2
Token 462 ~ 974

Chunk 3
Token 924 ~ 1436

哪种策略适合你的文档

Markdown/HTML/有标题层级

无结构或半结构化

普通要求

高要求

小量文档(<1000篇)

大量文档

答案被截断/上下文不完整

检索结果跑题

你的文档类型

有明确结构?

结构感知分块

对检索精度要求?

RecursiveCharacterTextSplitter
512 chunk_size,50 overlap

文档量大?

语义分块

常见问题?

父子分块

减小 chunk_size
+语义分块

实际建议

所有项目从 RecursiveCharacterTextSplitter 开始,chunk_size=512,overlap=50。中文文档在 separators 里加上 "。""!"。这能覆盖 80% 的场景。

上线后根据实际问题迭代:

  • 用户反馈"答案不完整"/"被截断" → 试试父子分块
  • 检索结果经常跑题、不相关 → 减小 chunk_size 或试试语义分块
  • 文档有明确章节结构 → 试试结构感知分块

分块策略不是一次性决策,而是根据真实用户反馈持续调整的工程迭代过程。用 RAGAS(第 09 篇)这类评估工具定期量化效果,数据说话。

本页目录