跳到主要内容

Chunking 策略深度解析

切片看起来是最简单的一步——不就是按字数切吗?但当我用 500 字符的固定切片处理一份 50 页的技术文档时,检索结果惨不忍睹:一句话被切成两半,上下文完全断裂,LLM 拿到残缺的片段根本没法回答。后来我才意识到:切片策略直接决定了 RAG 系统的上限

一、切片为什么重要

RAG 系统的黄金法则:Garbage In, Garbage Out

用户问题:"LangGraph 的状态机是怎么实现的?"

糟糕的切片(刚好把关键信息切断):
片段 1: "...LangGraph 使用 StateGraph 来定义状态机,每个节点是一个"
片段 2: "函数,边定义了状态转移条件..."

好的切片(完整保留语义):
片段: "LangGraph 使用 StateGraph 来定义状态机。每个节点是一个处理函数,
边定义了状态转移条件。通过 add_conditional_edges 实现条件分支..."

好的切片 = 语义完整 + 长度适中 + 重叠合理

二、切片策略全景

2.1 策略对比

策略原理优点缺点适用场景
固定字符分割按字符数硬切实现最简单切断语义快速原型
递归字符分割按分隔符层级递归保留段落结构不理解语义通用场景
语义分割用 Embedding 判断语义边界语义完整速度慢、成本高高质量需求
文档结构分割按标题/章节切保留文档结构依赖文档格式结构化文档
Agentic 切分用 LLM 判断切分点最智能最贵、最慢关键文档
混合策略多种策略组合平衡效果和成本实现复杂生产环境

2.2 可视化对比

原始文档:
┌─────────────────────────────────────────────────┐
│ 第一章 RAG 基础 │
│ 1.1 什么是 RAG │
│ RAG 是检索增强生成的缩写。它通过从外部知识库 │
│ 检索相关文档来增强 LLM 的生成能力。 │
│ 1.2 RAG 的优势 │
│ 相比纯 LLM,RAG 可以减少幻觉、提供引用来源... │
│ 第二章 向量数据库 │
│ 2.1 什么是向量数据库 │
│ 向量数据库专门用于存储和检索高维向量... │
└─────────────────────────────────────────────────┘

固定分割(chunk_size=100):
┌──────┐┌──────┐┌──────┐┌──────┐┌──────┐
│切片1 ││切片2 ││切片3 ││切片4 ││切片5 │
│"第一章 ││"1.1 ││"RAG 是││"检索增││"1.2 │ ← 语义被切断
│ RAG 基││什么 ││检索增 ││强生成 ││RAG 的│
│ 础" ││RAG" ││强生成 ││的缩写 ││优势" │
└──────┘└──────┘└──────┘└──────┘└──────┘

递归字符分割(按标题和段落):
┌─────────────────┐┌─────────────────┐┌─────────────────┐
│ 切片 1 ││ 切片 2 ││ 切片 3 │
│ "第一章 RAG 基础 ││ "1.2 RAG 的优势 ││ "第二章 向量数据库│
│ 1.1 什么是 RAG ││ 相比纯 LLM..." ││ 2.1 什么是..." │
│ RAG 是检索..." ││ ││ │
└─────────────────┘└─────────────────┘└─────────────────┘

语义分割(按语义完整性):
┌───────────────────┐┌───────────────────┐
│ 切片 1 ││ 切片 2 │
│ "什么是 RAG? ││ "RAG 的优势: │
│ RAG 是检索增强生成 ││ 减少幻觉、提供引用 │
│ 的缩写。通过从外部 ││ 来源、知识可更新" │
│ 知识库检索..." ││ │
└───────────────────┘└───────────────────┘

三、LangChain 切片器实战

3.1 固定字符分割

from langchain.text_splitter import CharacterTextSplitter

splitter = CharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separator="\n", # 优先按换行切
)

chunks = splitter.split_text(document)

适用:纯文本、没有明显结构的文档 问题:会在句子中间切断

3.2 递归字符分割(最常用)

from langchain.text_splitter import RecursiveCharacterTextSplitter

# 中文文档推荐配置
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=[
"\n\n", # 段落
"\n", # 换行
"。", # 中文句号
"!", # 中文感叹号
"?", # 中文问号
";", # 中文分号
",", # 中文逗号
" ", # 空格
"", # 最后手段:按字符切
],
length_function=len,
is_separator_regex=False,
)

chunks = splitter.split_text(document)

关键参数

  • chunk_size:建议 300-800 字符(中文)
  • chunk_overlap:建议 chunk_size 的 10%-15%
  • separators:中文一定要把中文标点加进去

3.3 语义分割

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

# 基于 Embedding 相似度的语义分割
splitter = SemanticChunker(
OpenAIEmbeddings(model="text-embedding-3-small"),
breakpoint_threshold_type="percentile", # 用百分位数作为阈值
breakpoint_threshold_amount=85, # 相似度低于 85% 分位数时切分
)

chunks = splitter.split_text(document)

原理:计算相邻句子的 Embedding 相似度,当相似度骤降时认为语义发生了转换,在此处切分。

优点:切片语义最完整 缺点:需要调用 Embedding API,速度慢 10-100 倍,成本高

3.4 文档结构分割

from langchain.text_splitter import MarkdownHeaderTextSplitter

# 按 Markdown 标题层级切分
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]

splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)

# 每个切片会自动带上标题元数据
chunks = splitter.split_text(markdown_doc)
# chunks[0].metadata = {"h1": "第一章", "h2": "1.1 什么是 RAG"}

适用:Markdown、HTML、有明确层级结构的文档 优点:切片自带上下文(标题层级),检索时可以展示完整路径

3.5 Agentic 切分(LLM 判断)

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o", temperature=0)

def agentic_split(text, llm):
prompt = f"""请将以下文档切分成独立的、语义完整的片段。
每个片段应该:
1. 包含一个完整的概念或主题
2. 长度在 200-500 字符之间
3. 可以独立理解,不依赖上下文

文档:
{text}

请用 "---" 分隔每个片段:"""

response = llm.invoke(prompt)
return response.content.split("---")

适用:高价值文档(如法律合同、技术规范),切片质量要求极高 缺点:成本高(每个文档都要调 LLM),速度慢

四、Overlap 策略详解

Overlap(重叠)是防止语义断裂的关键机制:

无重叠:
片段 1: [==================]
片段 2: [==================]
→ 中间的信息可能丢失

有重叠:
片段 1: [==================]
片段 2: [==================]
→ 重叠区域保留了上下文连续性

Overlap 多少合适?

Overlap 比例效果适用场景
0%无重叠,信息可能丢失不推荐
5%轻微重叠,轻微改善简单文档
10-15%推荐,平衡效果和冗余大多数场景
20-30%强重叠,冗余多但上下文好对话记录、长文本
>30%过度重叠,浪费存储和计算不推荐
# 推荐配置
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=75, # 500 * 15% = 75
)

五、Metadata 提取

切片时保留元数据,检索时可以做更精准的过滤:

from langchain_core.documents import Document

def split_with_metadata(docs, splitter):
"""切片时保留元数据"""
all_chunks = []
for doc in docs:
chunks = splitter.split_documents([doc])
for i, chunk in enumerate(chunks):
chunk.metadata.update({
"source": doc.metadata.get("source", "unknown"),
"chunk_index": i,
"total_chunks": len(chunks),
"title": doc.metadata.get("title", ""),
"section": extract_section(chunk.page_content),
})
all_chunks.extend(chunks)
return all_chunks

def extract_section(text):
"""提取第一行作为 section 标识"""
first_line = text.strip().split("\n")[0]
return first_line[:100] if first_line else ""

六、实验对比

我在同一份 10 页的技术文档上测试了不同策略:

策略切片数平均长度检索准确率生成质量
固定 500 字符4550062%中等
递归分割 5003842078%良好
递归分割 3006228075%良好
语义分割2858088%优秀
结构分割2265085%优秀
结构 + 递归3048091%优秀

结论:结构分割 + 递归分割的组合效果最好,性价比最高。

七、踩坑记录

坑 1:chunk_size 太大导致检索噪音

问题:chunk_size 设为 2000,检索回来的文档包含 5-6 个不相关的主题,LLM 被干扰。

解决:chunk_size 控制在 300-800 字符。宁可切小一点(检索更多片段),也不要切太大(引入噪音)。

坑 2:中文文档用英文分隔符

问题:默认的 separators 是 ["\n\n", "\n", ". ", " ", ""],中文句号 和逗号 不在里面,导致中文句子被从中间切断。

解决:自定义 separators,把中文标点加进去:

separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]

坑 3:切片后丢失上下文

问题:技术文档中的"如上所述"、"见第 3.2 节"等引用,在切片后失去了指代对象。

解决

  1. 用 Metadata 保留标题层级(h1 > h2 > h3
  2. 切片时在每个片段开头加上标题路径
  3. 检索时返回片段 + 其所属的章节标题

坑 4:过度切片导致碎片化

问题:chunk_size=100,一篇 1000 字的文章被切成 10 个片段,每个片段只有一两句话,丢失了完整上下文。

解决:chunk_size 不要小于 200 字符。对于中文,300-500 字符是甜区。

坑 5:语义分割对短文档效果差

问题:100 字的短文档用语义分割,只切出 1 个片段,但因为阈值计算问题,这个片段被截断了。

解决:短文档(< chunk_size)直接不切,作为一个整体片段。加一个判断:

if len(text) < chunk_size:
return [text]

九、参考资料