RAG 实战:文本切块(Text Chunking)从入门到精通
文本切块是 RAG 系统中最容易被忽视、却对最终效果影响最大的环节之一。本文从 7 个实战脚本出发,带你深入理解从基础字符分割到 AI 驱动的语义分块的完整技术路径。
目录
- [开篇:为什么切块是 RAG 的"命门"?](#开篇:为什么切块是 RAG 的"命门"?)
- [一、基础字符分割 --- CharacterTextSplitter](#一、基础字符分割 — CharacterTextSplitter)
- [二、递归字符分割 --- RecursiveCharacterTextSplitter](#二、递归字符分割 — RecursiveCharacterTextSplitter)
- 三、分块大小如何影响检索准确性
- [四、代码分块:语言感知 vs 通用分割](#四、代码分块:语言感知 vs 通用分割)
- [五、语义分块 --- 让 AI 自己决定在哪里切割](#五、语义分块 — 让 AI 自己决定在哪里切割)
- [六、辅助工具:PDF 页面精准提取](#六、辅助工具:PDF 页面精准提取)
- 七、进阶切块策略概览
- 总结:切块策略全景图与选型指南
开篇:为什么切块是 RAG 的"命门"?
RAG 流程中切块的位置
在 RAG(检索增强生成)的完整流水线中,文本切块处于数据准备阶段的核心环节,衔接上游的"文档加载"和下游的"向量嵌入":
文档加载 ──→ 文本切块 ──→ 向量嵌入 ──→ 向量存储 ──→ 检索 ──→ 生成回答
(第1步) (第2步) (第3步) (第4步) (第5步) (第6步)
切块质量会向下传播影响每一个后续环节:
| 指标 | 块太大的后果 | 块太小的后果 |
|---|---|---|
| 检索准确性 | 查询命中了块,但块内大部分内容与问题无关 | 关键信息被切断,检索到的块无法提供完整答案 |
| 生成质量 | LLM 拿到大段文本,可能被无关信息干扰 | LLM 拿到碎片信息,无法理解完整语义 |
| 系统效率 | 向量数量少但体积大,检索和嵌入慢 | 向量数量爆炸,存储和检索开销剧增 |
换句话说,切块就是 RAG 的"食材预处理" ------ 食材切得大小不均,再好的厨师也做不出好菜。
切块需要回答的三个核心问题
任何切块策略,本质上都是在回答三个问题:
- 在哪里切? (分割点的选择)------ 优先在语义边界?固定位置?还是 AI 判断?
- 切多大? (chunk_size 的选择)------ 100 字符?500 token?还是动态大小?
- 相邻块要不要重叠? (chunk_overlap 的选择)------ 重叠多少?完全不需要?
本文将通过 7 个实战脚本,逐一回答这三个问题,并带你遍历从最基础的字符分割到最前沿的语义分块的全部策略。
固定字符分割 → 递归字符分割 → 句子分割 → 语言感知分割 → 语义分割
(简单粗暴) (尊重边界) (更精细) (领域特定) (AI 驱动)
一、基础字符分割 --- CharacterTextSplitter
1.1 它是什么?
CharacterTextSplitter 是 LangChain 提供的最基础的文本分割器。它的逻辑非常直接:用一个指定的分隔符把文本切开,然后按固定大小组装成块。
它属于 LangChain 文本分割器的"基类",所有更高级的分割器(递归分割器、代码分割器等)都建立在它的基础之上。
1.2 完整代码
python
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import CharacterTextSplitter
# 第一步:加载文档
loader = TextLoader("90-文档-Data/山西文旅/云冈石窟.txt")
documents = loader.load()
# 第二步:创建分割器
text_splitter = CharacterTextSplitter(
chunk_size=100, # 每个文本块的最大字符数
chunk_overlap=10, # 相邻块之间的重叠字符数
)
# 第三步:执行分割
chunks = text_splitter.split_documents(documents)
# 第四步:查看结果
for i, chunk in enumerate(chunks, 1):
print(f"\n--- 第 {i} 个文档块 ---")
print(f"内容: {chunk.page_content}")
print(f"元数据: {chunk.metadata}")
1.3 内部算法详解
CharacterTextSplitter 的工作流程比表面看起来要复杂一些,核心分为 四步:
原文档: "A\nB\nC\nD\nE\nF\nG"
separator = "\n", chunk_size = 10, chunk_overlap = 3
Step 1: 分割(Split)
用 separator 将文本切为"片段"(splits)
→ splits = ["A", "B", "C", "D", "E", "F", "G"]
Step 2: 合并(Merge)
将 splits 逐个合并,直到总长度接近 chunk_size
→ 当前块 = "A\nB\nC" (长度 ≤ 10)
→ 加入 "D" 后 = "A\nB\nC\nD" (长度 > 10),停止合并
→ 块1 = "A\nB\nC"
Step 3: 重叠处理(Overlap)
下一块从前一块末尾的 overlap 长度处开始
→ 块1 末尾 3 个字符 = "B\nC"
→ 块2 从 "B\nC" 开始,继续合并新的 splits
→ 块2 = "B\nC\nD\nE"
Step 4: 生成块序列
→ 块1: "A\nB\nC"
→ 块2: "B\nC\nD\nE"
→ 块3: "D\nE\nF\nG"
关键细节:
- 分隔符会被消耗 :分隔符
\n在分割时被移除,在合并时被重新插入(作为分隔符使用) - chunk_size 计算包含分隔符 :合并后的总长度(含分隔符)不能超过
chunk_size - overlap 按字符数计算 :从前一块末尾倒取
chunk_overlap个字符作为下一块的开头
1.4 全部参数详解
python
CharacterTextSplitter(
separator="\n", # 分隔符,默认换行符
chunk_size=100, # 每个块的最大长度
chunk_overlap=10, # 相邻块重叠长度
length_function=len, # 长度计算函数(默认按字符数)
is_separator_regex=False, # separator 是否为正则表达式
keep_separator=False, # 是否在分割结果中保留分隔符
add_start_index=False, # 是否在元数据中记录每块在原文的起始位置
strip_whitespace=True, # 是否去除块首尾空白
)
length_function --- 自定义长度计量
默认使用 Python 内置的 len()(按字符数 计量)。但在实际场景中,你可能需要按 token 数计量:
python
import tiktoken
def tiktoken_len(text):
"""使用 tiktoken 计算 OpenAI token 数"""
tokenizer = tiktoken.encoding_for_model("gpt-3.5-turbo")
return len(tokenizer.encode(text))
text_splitter = CharacterTextSplitter(
chunk_size=200, # 现在是 200 个 token,而非 200 个字符
chunk_overlap=20,
length_function=tiktoken_len
)
为什么要按 token 计量? 因为:
- 中文一个汉字通常占 1-2 个 token,而英文一个单词约 1 个 token
- 同样 200 个字符,中文文本可能有 200-400 个 token,英文可能只有 40-50 个 token
- 如果按字符切块后送入 LLM,可能导致某些块超出模型的上下文窗口限制
is_separator_regex --- 正则表达式分隔符
当分隔符不是一个固定字符,而是一类模式时:
python
# 用连续两个及以上换行符作为分隔符(过滤单个换行)
text_splitter = CharacterTextSplitter(
separator=r"\n{2,}",
is_separator_regex=True,
chunk_size=500,
)
keep_separator --- 保留分隔符
默认情况下,分隔符在分割时被移除。如果你需要保留原始格式:
python
text_splitter = CharacterTextSplitter(
separator="\n\n",
keep_separator=True, # 保留分隔符
)
# 结果中每个块末尾会保留 "\n\n"
add_start_index --- 记录原文位置
在元数据中记录每个块在原始文档中的起始字符位置,便于溯源:
python
text_splitter = CharacterTextSplitter(
add_start_index=True,
)
# chunk.metadata 中会多一个 "start_index" 字段
# 例如 {"source": "xxx.txt", "start_index": 256}
1.5 运行效果
假设云冈石窟文档内容为:
云冈石窟位于山西省大同市城西约16公里的武州山南麓。
石窟依山开凿,规模宏大,是中国最大的石窟群之一。
现有主要洞窟45个,大小窟龛252个,石雕造像51000余躯。
使用 chunk_size=100, chunk_overlap=10,可能被切为:
块1: "云冈石窟位于山西省大同市城西约16公里的武州山南麓。\n石窟依山开凿,规模宏大,是中国最大的石窟群之一。\n"
块2: "之一。\n现有主要洞窟45个,大小窟龛252个,石雕造像51000余躯。\n" ← 注意 "之一。" 是重叠部分
1.6 优缺点
- ✅ 最简单:三行代码即可完成
- ✅ 最快:纯字符串操作,无需调用任何模型
- ✅ 完全确定性:相同输入永远产生相同输出,便于调试
- ❌ 一刀切:只认一个分隔符,可能在句子/段落中间硬切
- ❌ 不理解语义:无法区分"话题转换"和"同一句话里的换行"
- ❌ 对分隔符依赖大:如果文档中没有你指定的分隔符,所有内容会变成一个巨大的块
1.7 常见陷阱
陷阱 1:分隔符不存在
python
# 如果文档中没有 "\n\n",整个文档会变成一个块
CharacterTextSplitter(separator="\n\n", chunk_size=500)
# → 输出只有 1 个块(整篇文档)!
陷阱 2:中文标点 vs 英文标点
python
# 这两个是不同的字符!
"." # 英文句号 (U+002E)
"。" # 中文句号 (U+3002)
# 如果中文文档用英文句号做分隔符,等于没有分隔
CharacterTextSplitter(separator=".") # ❌ 对中文无效
CharacterTextSplitter(separator="。") # ✅ 正确
陷阱 3:chunk_overlap 设置不当
python
# overlap 不能大于 chunk_size 的一半,否则会产生大量冗余
CharacterTextSplitter(chunk_size=100, chunk_overlap=80) # ❌ 80% 重叠,冗余严重
CharacterTextSplitter(chunk_size=100, chunk_overlap=10) # ✅ 10% 重叠,合理
1.8 适用场景
快速原型验证、对切块精度要求不高的场景、纯格式化文本(如日志、CSV 导出文本)、格式规范的 Markdown 文档(用
\n\n分段)
二、递归字符分割 --- RecursiveCharacterTextSplitter
2.1 它是什么?
如果说 CharacterTextSplitter 是一把菜刀,那 RecursiveCharacterTextSplitter 就是一套手术刀 ------ 它不满足于用一个分隔符硬切,而是准备了一个分隔符优先级列表,依次尝试,直到找到最合适的切割点。
这是 LangChain 官方推荐用于通用文本的分割器,也是大多数 RAG 项目的默认选择。
2.2 完整代码
python
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 加载文档
loader = TextLoader("90-文档-Data/山西文旅/云冈石窟.txt")
documents = loader.load()
# 定义分隔符优先级列表
separators = ["\n\n", ".", ",", " "] # 段落 → 句号 → 逗号 → 空格
# 创建递归分割器
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=100,
chunk_overlap=10,
separators=separators
)
chunks = text_splitter.split_documents(documents)
2.3 递归算法源码级剖析
RecursiveCharacterTextSplitter 继承自 CharacterTextSplitter,核心是重写了 _split_text 方法。其递归逻辑如下:
python
# 伪代码展示核心递归逻辑
def _split_text(self, text, separators):
"""递归分割文本"""
# 获取当前级别的分隔符
current_separator = separators[0]
# 剩余的分隔符(用于下一轮递归)
next_separators = separators[1:]
# Step 1: 用当前分隔符将文本切成片段
splits = text.split(current_separator)
# Step 2: 将片段合并为不超过 chunk_size 的块
good_splits = [] # 已经合并好的片段列表
final_chunks = [] # 最终输出的块列表
for split in splits:
if length_function(split) < chunk_size:
good_splits.append(split)
else:
# 这个片段太大了!
# 先把之前积攒的好片段合并成块
if good_splits:
merged = merge_splits(good_splits, current_separator)
final_chunks.extend(merged)
good_splits = []
# Step 3: 递归!用下一个分隔符切这个超长片段
if next_separators:
# 还有更细的分隔符可用
recursive_chunks = _split_text(split, next_separators)
final_chunks.extend(recursive_chunks)
else:
# 没有更多分隔符了,只能硬切
final_chunks.append(split)
# 处理剩余的好片段
if good_splits:
merged = merge_splits(good_splits, current_separator)
final_chunks.extend(merged)
return final_chunks
关键洞察 :递归分割器并不是对整篇文档一次性用所有分隔符切割,而是对超长片段逐级降级 ------ 只有当某个片段在当前分隔符下仍然超过 chunk_size 时,才会用下一个更细粒度的分隔符递归处理它。
2.4 递归过程实例
以一段云冈石窟文本为例,完整追踪递归过程:
原文(约 250 字符):
"云冈石窟位于山西省大同市。\n\n石窟依山开凿,规模宏大。现有洞窟45个。
\n\n石雕造像51000余躯,代表了北魏雕刻艺术的最高水平。"
配置:chunk_size=100, separators=["\n\n", "。", ",", " "]
第一轮递归 (separator = "\n\n"):
Step 1: 按 "\n\n" 分割为 3 个片段
片段A: "云冈石窟位于山西省大同市。" (15 字符) ✅ < 100
片段B: "石窟依山开凿,规模宏大。现有洞窟45个。" (18 字符) ✅ < 100
片段C: "石雕造像51000余躯,代表了北魏雕刻艺术的最高水平。" (25 字符) ✅ < 100
结果:所有片段都小于 chunk_size,无需继续递归!
合并为块(考虑 overlap):
块1: "云冈石窟位于山西省大同市。\n\n石窟依山开凿,规模宏大。现有洞窟45个。"
块2: "石窟依山开凿,规模宏大。现有洞窟45个。\n\n石雕造像51000余躯,..."
但如果把 chunk_size 改为 10,就会触发更深层递归:
第一轮(separator = "\n\n"):
片段A (15 字符) > 10 → 递归!
片段B (18 字符) > 10 → 递归!
片段C (25 字符) > 10 → 递归!
第二轮(separator = "。"):
处理片段A: "云冈石窟位于山西省大同市" (10 字符) ✅ < 10 → 合并为块
...
2.5 默认分隔符列表
如果不指定 separators 参数,LangChain 使用以下默认分隔符:
python
# RecursiveCharacterTextSplitter 的默认分隔符
DEFAULT_SEPARATORS = ["\n\n", "\n", " ", ""]
# "\n\n" → 段落分隔(最粗粒度)
# "\n" → 行分隔
# " " → 空格(词级别)
# "" → 按字符切割(最细粒度,兜底)
这些默认值是为英文设计的,对中文文本效果不佳。中文文本需要自定义分隔符列表。
2.6 中文文本的特殊考量与推荐配置
对于中文文本,分隔符列表的设计尤为重要。以下是三种推荐配置:
配置 A:标准中文文档(推荐)
python
separators = [
"\n\n", # 段落分隔(最高优先级)
"。", # 中文句号
"!", # 中文感叹号
"?", # 中文问号
";", # 中文分号
",", # 中文逗号
" ", # 空格
"", # 兜底:按字符切割
]
配置 B:中英混合文档
python
separators = [
"\n\n", # 段落
"。", # 中文句号
".", # 英文句号
"!", # 中文感叹号
"?", # 英文问号
";", # 中文分号
",", # 英文逗号
",", # 中文逗号
" ", # 空格
"",
]
配置 C:技术文档(含 Markdown)
python
separators = [
"\n## ", # 二级标题
"\n### ", # 三级标题
"\n#### ", # 四级标题
"\n\n", # 段落
"\n- ", # 列表项
"。", # 中文句号
".", # 英文句号
",", # 中文逗号
" ", # 空格
"",
]
⚠️ 注意 :中文句号
。和英文句号.是不同的字符。如果处理中文文档时使用英文句号作为分隔符,将无法正确切分句子。
2.7 高级参数
python
RecursiveCharacterTextSplitter(
separators=["\n\n", "\n", " ", ""],
chunk_size=500,
chunk_overlap=50,
length_function=len,
keep_separator=False, # 是否在分割结果中保留分隔符
add_start_index=False,
strip_whitespace=True,
is_separator_regex=False,
)
keep_separator --- 对递归分割器的影响
在递归分割器中,keep_separator 有更微妙的作用:
python
# keep_separator=False(默认)
# 分隔符被移除,合并时用分隔符重新连接
# 块1: "第一段内容\n\n第二段内容"
# keep_separator=True
# 分隔符被保留在分割结果中
# 块1: "第一段内容\n\n第二段内容\n\n"
# 注意末尾多了一个 "\n\n"
建议 :除非你需要精确还原原文格式,否则保持默认 False 即可。
2.8 与基础分割的对比
以同一段云冈石窟文本为例:
| 分割器 | 切割方式 | 结果 |
|---|---|---|
CharacterTextSplitter(separator="\n") |
按 \n 硬切 |
可能在一个长段落内硬切 |
RecursiveCharacterTextSplitter(separators=["\n\n",".",","," "]) |
依次尝试多个分隔符 | 优先保持段落和句子完整 |
2.9 优缺点
- ✅ 更智能:多级分隔符,尊重文本自然结构
- ✅ 高度可定制:分隔符列表完全由用户定义
- ✅ 通用性强:适合大多数文本类型
- ✅ LangChain 官方推荐的通用分割器
- ✅ 仍保持确定性:相同输入相同输出
- ❌ 仍是规则驱动:不理解"这段话在讲另一个话题了"
- ❌ 分隔符设计需要经验:不同语言/文档类型需要不同配置
- ❌ 无法处理"无标点长句":如果一个句子没有标点且超过 chunk_size,只能在空格处切
2.10 适用场景
大多数 RAG 项目的默认选择 。在不确定用哪种策略时,先用
RecursiveCharacterTextSplitter配合合理的分隔符列表,通常就能获得不错的效果。适合:通用文本、新闻文章、技术文档、Wiki 百科、产品说明等。
三、分块大小如何影响检索准确性
3.1 这是一个什么实验?
前两种策略关注的是"在哪里切 ",而这个实验关注的是"切多大 "。chunk_size 是 RAG 系统中最关键的超参数之一 ------ 它直接决定了检索的精度和召回率。
3.2 完整代码
python
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.core import VectorStoreIndex, Settings
from llama_index.readers.file import PDFReader
from llama_index.core.node_parser import SentenceSplitter
from dotenv import load_dotenv
load_dotenv()
# 配置模型
embed_model = OpenAIEmbedding(model="text-embedding-3-small")
llm = OpenAI(model="gpt-3.5-turbo-0125")
Settings.embed_model = embed_model
Settings.llm = llm
# ⭐ 关键参数:chunk_size 决定切块大小
Settings.node_parser = SentenceSplitter(
chunk_size=250, # 🔬 试试改成 50 或 100,对比效果!
chunk_overlap=20
)
# 加载 PDF 并构建索引
loader = PDFReader()
documents = loader.load_data(
file="90-文档-Data/复杂PDF/uber_10q_march_2022_page26.pdf"
)
index = VectorStoreIndex.from_documents(documents)
# 查询
query_engine = index.as_query_engine(similarity_top_k=3, verbose=True)
response = query_engine.query("how much is the Loss from operations for 2022?")
print(response)
# 查看检索到的具体块
for i, source_node in enumerate(response.source_nodes):
print(f"\nChunk {i+1}:")
print(source_node.text)
3.3 SentenceSplitter 内部机制
LlamaIndex 的 SentenceSplitter 与 LangChain 的 CharacterTextSplitter 有一个根本区别:它按句子边界分割,而不是按字符/分隔符分割。
其内部流程为:
原文
│
▼
① 分句(基于正则 / NLTK / spaCy)
→ 将文本拆分为独立句子列表
│
▼
② 按 chunk_size 合并句子
→ 将连续的句子合并,直到接近 chunk_size
→ 保证不会在句子中间截断
│
▼
③ 处理超长句子
→ 如果单个句子超过 chunk_size
→ 按段落 → 按词 → 按字符 逐级降级
│
▼
④ 添加 overlap
→ 相邻块共享若干句子
核心优势 :SentenceSplitter 的最小分割单位是"句子"而非"字符",因此不会出现一句话被切成两半的情况。
按字符 vs 按 Token 计量
LlamaIndex 的 SentenceSplitter 默认使用 token 计量(而非字符计量),这对于 LLM 应用来说更加合理:
python
SentenceSplitter(
chunk_size=250, # 250 个 token(不是 250 个字符)
chunk_overlap=20, # 20 个 token 的重叠
separator=" ", # 合并时的分隔符
paragraph_separator="\n\n\n", # 段落分隔符
chunking_token_fn=None, # 自定义 token 计算函数
)
为什么用 token 更好?
| 计量方式 | 中文 100 字 | 英文 100 字 | 问题 |
|---|---|---|---|
| 字符数 | 100 字符 | 100 字符 | 同样 100 字符,中英文信息量差异巨大 |
| Token 数 | ~100-200 token | ~25-50 token | 更准确地反映 LLM 的"理解单位" |
3.4 实验设计
该脚本使用 Uber 2022 年第一季度 10-Q 财报作为数据源,查询一个具体的财务数据问题:"2022 年的运营亏损是多少?"
通过修改 chunk_size 参数,我们可以观察不同切块大小对检索结果的影响:
chunk_size = 50 ──→ 得到什么结果?
chunk_size = 100 ──→ 得到什么结果?
chunk_size = 250 ──→ 得到什么结果?
3.5 三种 chunk_size 的效果分析
🔹 chunk_size = 50(极小块)
检索到的块可能像这样:
块1: "Loss from operations"
块2: "for the three months"
块3: "ended March 31, 2022"
问题:
- 关键信息被切碎,"Loss from operations" 和具体数值被分到了不同的块
- LLM 看到的是碎片,无法理解完整含义
- 可能检索到很多"看起来相关但实际无用"的块
优点:
- 检索精确度较高(向量化粒度细)
- 适合定位到精确的关键词/短语
- 每个块的向量非常聚焦,语义表达清晰
何时使用:精确查找(如"找到包含 'Loss from operations' 这个词组的所有段落")
🔹 chunk_size = 100(中间块)
检索到的块可能像这样:
块1: "Loss from operations for the three months ended March 31, 2022 was $XXX"
块2: "Revenue increased by XX% compared to the same period..."
优点:
- 在精确度和上下文之间取得较好平衡
- 大多数查询场景的"甜点区"
- 单个块通常包含完整的语义单元
何时使用:大多数通用 RAG 场景的起点
🔹 chunk_size = 250(较大块)
检索到的块可能包含完整段落或多个表格项
问题:
- 一个块里可能混合了多个不同话题
- "Loss from operations" 的数据可能被淹没在大量无关信息中
- 向量化时,块的向量是整块内容的"平均",可能偏离查询意图
优点:
- 上下文最完整,不会丢失跨块信息
- 减少向量数量,检索速度更快
何时使用:需要完整段落的场景(如文档摘要、长文本问答)
3.6 chunk_size 选择的底层逻辑
为什么 chunk_size 影响这么大?核心在于向量嵌入的本质:
文本块 ──Embedding Model──→ 向量(高维空间中的一个点)
- 大块:向量是整段文本的"语义平均",可能模糊了其中的关键细节
- 小块:向量更聚焦,但可能丢失上下文关联
用一个比喻:chunk_size 就像相机镜头的焦距 ------
- 广角镜头(大 chunk_size):拍到更多场景,但细节模糊
- 长焦镜头(小 chunk_size):细节清晰,但视野狭窄
- 关键是找到适合当前场景的焦距
向量空间中的可视化
高维向量空间(简化为 2D)
查询向量
● "Loss from operations 2022?"
╱
╱ ← 相似度
╱
●块1(小) ── "Loss from operations was $613M"
│
│
●块2(大) ── "Revenue was $XX, Loss from operations was $613M,
│ R&D expenses were $YY, Marketing was $ZZ..."
│
●块3(小) ── "operational efficiency improved..."
块1 距离查询最近 → 小块向量更聚焦
块2 距离查询较远 → 大块向量被其他信息"稀释"了
块3 距离查询较远 → 虽然包含 "operational",但语义不同
3.7 chunk_overlap 的作用与最佳实践
chunk_overlap 是防止关键信息恰好落在切割点被截断的"安全网":
无 overlap:
块1: [第一段完整内容]
块2: [第二段完整内容]
↑ 如果关键信息恰好在段1末尾+段2开头,检索可能遗漏
有 overlap:
块1: [============重叠============]
块2: [=====重叠=====]
块3: [============重叠============]
↑ 重叠区域确保信息不会在切割点丢失
overlap 设置的经验法则:
| 场景 | 推荐 overlap | 原因 |
|---|---|---|
| 通用文本 | chunk_size × 10-15% | 平衡冗余和完整性 |
| 法律/医疗文档 | chunk_size × 20-25% | 信息密度高,切断代价大 |
| 代码 | chunk_size × 0-5% | 代码通常不需要 overlap(语言感知分割已保证结构完整) |
| 新闻/博客 | chunk_size × 5-10% | 信息密度低,小 overlap 足够 |
overlap 的代价:
- 存储开销增加:如果 overlap=20%,则有效存储容量增加约 20%
- 检索时可能返回重复内容:相邻块在重叠区域完全相同
- 向量化开销增加:重叠部分被重复计算 Embedding
3.8 chunk_size 调优方法论
不要凭感觉选 chunk_size,用以下方法科学调优:
Step 1: 准备 10-20 个代表性查询(覆盖不同类型)
Step 2: 准备标准答案(人工标注)
Step 3: 设定 chunk_size 候选值:[100, 200, 300, 500, 800]
Step 4: 对每个 chunk_size 运行所有查询
Step 5: 评估指标:
- 检索命中率(Hit Rate):正确答案出现在 top-k 中的比例
- 平均倒数排名(MRR):正确答案排名的倒数均值
- 答案质量评分:LLM 生成答案与标准答案的相似度
Step 6: 选择综合表现最好的 chunk_size
3.9 适用场景
任何 RAG 项目都需要进行 chunk_size 调优实验。建议用一组代表性查询在不同 chunk_size 下对比检索结果,找到最适合你数据的参数组合。
四、代码分块:语言感知 vs 通用分割
4.1 为什么代码需要特殊的分块策略?
代码和自然语言有着根本的区别:
| 维度 | 自然语言 | 代码 |
|---|---|---|
| 结构 | 段落、句子、词语 | 类、函数、控制流 |
| 分隔符 | 。、,、\n\n |
class、def、缩进 |
| 完整性 | 一句话通常自包含 | 一个函数可能依赖上下文 |
| 引用关系 | 隐式(语义关联) | 显式(import、继承、调用) |
| 缩进 | 仅格式化作用 | 定义代码块(Python 强制) |
如果用通用的文本分割器处理代码,很可能出现这样的灾难:
python
# ❌ 通用分割器可能在函数中间截断
块1:
def _execute_attack(self):
if self.stamina >= self.attack_patterns["SPECIAL"]:
damage = 50
self.stamina -=
块2:
self.attack_patterns["SPECIAL"]
return damage
return self.attack_patterns["NORMAL"]
这样的块对检索和生成都是无用的 ------ 函数被腰斩,上下文断裂,缩进丢失。
4.2 语言感知分块:完整代码
python
from langchain_text_splitters import Language, RecursiveCharacterTextSplitter
# 游戏代码示例(包含三个类:战斗系统、背包系统、任务系统)
GAME_CODE = """
class CombatSystem:
def __init__(self):
self.health = 100
self.stamina = 100
self.state = "IDLE"
self.attack_patterns = {
"NORMAL": 10, "SPECIAL": 30, "ULTIMATE": 50
}
def update(self, delta_time):
self._update_stats(delta_time)
self._handle_combat()
def _update_stats(self, delta_time):
self.stamina = min(100, self.stamina + 5 * delta_time)
def _handle_combat(self):
if self.state == "ATTACKING":
self._execute_attack()
def _execute_attack(self):
if self.stamina >= self.attack_patterns["SPECIAL"]:
damage = 50
self.stamina -= self.attack_patterns["SPECIAL"]
return damage
return self.attack_patterns["NORMAL"]
class InventorySystem:
def __init__(self):
self.items = {}
self.capacity = 20
self.gold = 0
def add_item(self, item_id, quantity):
if len(self.items) < self.capacity:
if item_id in self.items:
self.items[item_id] += quantity
else:
self.items[item_id] = quantity
def remove_item(self, item_id, quantity):
if item_id in self.items:
self.items[item_id] -= quantity
if self.items[item_id] <= 0:
del self.items[item_id]
class QuestSystem:
def __init__(self):
self.active_quests = {}
self.completed_quests = set()
self.quest_log = []
def add_quest(self, quest_id, quest_data):
if quest_id not in self.active_quests:
self.active_quests[quest_id] = quest_data
self.quest_log.append(f"Started quest: {quest_data['name']}")
def complete_quest(self, quest_id):
if quest_id in self.active_quests:
self.completed_quests.add(quest_id)
del self.active_quests[quest_id]
"""
# ⭐ 关键:指定语言为 Python
python_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=1000,
chunk_overlap=0
)
python_docs = python_splitter.create_documents([GAME_CODE])
4.3 通用分块:对照组代码
python
from langchain_text_splitters import RecursiveCharacterTextSplitter
# ❌ 不指定语言,使用默认文本分隔符
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=0,
# 使用默认分隔符:["\n\n", "\n", " ", ""]
)
text_chunks = text_splitter.create_documents([GAME_CODE])
4.4 语言感知分割器的秘密武器
当调用 from_language(Language.PYTHON) 时,LangChain 内部会自动使用一组 Python 专用的分隔符:
python
# Language.PYTHON 的默认分隔符(按优先级)
[
"\nclass ", # 类定义
"\ndef ", # 顶层函数定义
"\n\tdef ", # 类方法定义(1 级缩进)
"\n def ", # 类方法定义(4 空格缩进)
"\n\n", # 空行
"\n", # 换行
" ", # 空格
"" # 兜底:按字符
]
这意味着 Python 分割器会:
- 优先在
class之间切割 → 每个类尽量完整 - 如果类太大,在
def之间切割 → 每个方法尽量完整 - 如果方法太大,在
\n\n空行处切割 - 依次降级......保证代码结构不被轻易破坏
4.5 效果对比
对同样的游戏代码(三个类,总计约 1500 字符),两种分割器的效果:
语言感知版(✅ 结构完整):
块1: class CombatSystem: (完整的战斗系统类)
包含 __init__, update, _update_stats, _handle_combat, _execute_attack
块2: class InventorySystem: (完整的背包系统类)
包含 __init__, add_item, remove_item
块3: class QuestSystem: (完整的任务系统类)
包含 __init__, add_quest, complete_quest, get_active_quests
通用版(❌ 可能断裂):
块1: class CombatSystem: ... def _execute_attack(self): ... (包含 CombatSystem + 部分 InventorySystem)
两个类混在一起!
块2: ... self.items[item_id] = quantity ... (InventorySystem 方法的后半部分 + QuestSystem 的开头)
代码结构被完全破坏!
4.6 各语言分隔符详解
LangChain 的 Language 枚举支持多种主流语言,每种语言都有精心设计的分隔符:
Python
python
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.PYTHON)
# ["\nclass ", "\ndef ", "\n\tdef ", "\n\n", "\n", " ", ""]
JavaScript / TypeScript
python
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.JS)
# ["\nfunction ", "\nconst ", "\nlet ", "\nvar ", "\nclass ", "\nif ", "\nfor ",
# "\nwhile ", "\nswitch ", "\ncase ", "\ndefault ", "\n\n", "\n", " ", ""]
Java
python
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.JAVA)
# ["\npublic class ", "\nprivate class ", "\nprotected class ",
# "\npublic void ", "\nprivate void ", "\nprotected void ",
# "\n\n", "\n", " ", ""]
Go
python
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.GO)
# ["\nfunc ", "\nvar ", "\nconst ", "\ntype ", "\nimport ",
# "\n\n", "\n", " ", ""]
Markdown
python
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.MARKDOWN)
# ["\n## ", "\n### ", "\n#### ", "\n##### ", "\n###### ",
# "```\n", "\n\n", "\n", " ", ""]
Markdown 分割器的特色:优先在标题级别切割,保证每个块对应一个完整的章节。
HTML
python
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.HTML)
# ["<h1>", "<h2>", "<h3>", "<h4>", "<h5>", "<h6>",
# "<div>", "</div>", "<p>", "</p>", "<li>", "</li>",
# "<tr>", "</tr>", "\n\n", "\n", " ", ""]
4.7 完整支持的语言列表
| 语言 | 枚举值 | 切割粒度 |
|---|---|---|
| Python | Language.PYTHON |
class → def → 方法 |
| JavaScript | Language.JS |
function → const/let/var → class |
| TypeScript | Language.TS |
同 JS + type/interface |
| Java | Language.JAVA |
class → method(按修饰符) |
| C# | Language.CSHARP |
namespace → class → method |
| Go | Language.GO |
func → var/const/type |
| Rust | Language.RUST |
fn → impl → struct/enum |
| Ruby | Language.RUBY |
class → def → module |
| PHP | Language.PHP |
class → function |
| Swift | Language.SWIFT |
class → func → struct/enum |
| Kotlin | Language.KOTLIN |
class → fun → object |
| Scala | Language.SCALA |
class → def → object/trait |
| Markdown | Language.MARKDOWN |
h1 → h2 → h3 → 段落 |
| HTML | Language.HTML |
h1 → div → p → li |
| LaTeX | Language.LATEX |
\section → \subsection → \paragraph |
| SQL | Language.SQL |
CREATE → SELECT → INSERT → UPDATE |
4.8 代码分块的最佳实践
- chunk_size 要够大 :代码的完整结构通常比自然语言段落长,建议
chunk_size >= 1000(或按 token 计量 500+) - overlap 可以设为 0:语言感知分割器已经在结构边界切割,overlap 通常不必要
- 考虑添加元数据:记录每个块属于哪个文件、哪个类、哪个函数
python
python_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=1500, # 代码建议用更大的 chunk_size
chunk_overlap=0, # 语言感知分割器不需要 overlap
)
4.9 适用场景
任何涉及代码检索的 RAG 应用:代码搜索引擎、AI 代码助手(如 GitHub Copilot 的后端)、技术文档问答、代码审查辅助工具、代码库知识图谱构建。
五、语义分块 --- 让 AI 自己决定在哪里切割
5.1 前面几种方法的根本局限
无论是基础分割、递归分割还是语言感知分割,它们都有一个共同的局限:它们都是基于"规则"的。它们不知道:
- 这段话和下一段话是不是在讲同一个话题
- 这里换行了,但话题并没有转换
- 这里没有换行,但话题已经完全不同了
举个具体例子:
"张三毕业于清华大学计算机系,在校期间成绩优异。" ← 话题:张三的学历
"他后来加入了字节跳动,担任高级工程师。" ← 话题:张三的工作(话题转换!)
"字节跳动成立于2012年,总部位于北京。" ← 话题:字节跳动公司信息(又一次转换!)
"公司旗下产品包括抖音、TikTok等。" ← 话题:字节跳动产品(同一话题的延续)
规则分割器看到的只是"三句话,用句号分隔"。它不知道第 1→2 句和第 2→3 句是两次话题转换,而第 3→4 句是同一话题的延续。
语义分块(Semantic Chunking)就是为了解决这个问题而生的 ------ 它利用 Embedding 模型理解文本的语义连续性,在话题转换的地方自然地切割。
5.2 完整代码
python
from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import (
SentenceSplitter,
SemanticSplitterNodeParser,
)
from llama_index.embeddings.openai import OpenAIEmbedding
# 加载文档
documents = SimpleDirectoryReader(
input_files=["90-文档-Data/黑悟空/黑悟空wiki.txt"]
).load_data()
# ⭐ 创建语义分块器
splitter = SemanticSplitterNodeParser(
buffer_size=3, # 缓冲区大小
breakpoint_percentile_threshold=90, # 断点百分位阈值
embed_model=OpenAIEmbedding() # 嵌入模型
)
# 对照组:基础句子分块器
base_splitter = SentenceSplitter()
# 语义分块
semantic_nodes = splitter.get_nodes_from_documents(documents)
# 基础分块(对照)
base_nodes = base_splitter.get_nodes_from_documents(documents)
print(f"语义分块器生成的块数:{len(semantic_nodes)}")
print(f"基础句子分块器生成的块数:{len(base_nodes)}")
5.3 算法详解:五步流程
语义分块的核心算法可以拆解为以下五个步骤:
原始文档
│
▼
① 分句:将文档拆分为独立句子
S1, S2, S3, S4, S5, S6, S7, S8, S9, S10, ...
│
▼
② 分组(buffer_size=3):将句子分为滑动窗口组
组A: [S1, S2, S3]
组B: [S2, S3, S4] ← 注意:与组A有重叠
组C: [S3, S4, S5]
...
│
▼
③ Embedding:将每个组编码为向量
Vec_A = Embed("S1 S2 S3")
Vec_B = Embed("S2 S3 S4")
Vec_C = Embed("S3 S4 S5")
...
│
▼
④ 计算相邻组的余弦相似度:
sim(A,B) = cosine(Vec_A, Vec_B) = 0.92 ← 高相似度
sim(B,C) = cosine(Vec_B, Vec_C) = 0.88 ← 较高
sim(C,D) = cosine(Vec_C, Vec_D) = 0.45 ← 低!话题转换
sim(D,E) = cosine(Vec_D, Vec_E) = 0.91 ← 恢复高
...
│
▼
⑤ 确定断点:
收集所有相似度: [0.92, 0.88, 0.45, 0.91, 0.87, 0.35, 0.93]
排序: [0.35, 0.45, 0.87, 0.88, 0.91, 0.92, 0.93]
第 (100-90)% = 10% 分位数的值 → 阈值 ≈ 0.40
低于阈值的位置即为断点:
sim(C,D) = 0.45 → 不是断点(0.45 > 0.40)
sim(F,G) = 0.35 → 是断点!(0.35 < 0.40)
在断点处切割
余弦相似度详解
余弦相似度衡量两个向量在方向上的接近程度,取值范围 -1, 1:
cosine_similarity(A, B) = (A · B) / (|A| × |B|)
结果解读:
1.0 → 完全相同的方向(语义完全相同)
0.8 → 高度相似(同一话题的延续)
0.5 → 有一定相似性(相关但不同的话题)
0.0 → 正交(完全无关)
-1.0 → 完全相反的方向(实际中很少出现)
语义分块中:
相似度高 (>0.8) → 同一话题,不切割
相似度低 (<阈值) → 话题转换,在此切割
用一个直观的例子来理解(黑悟空 Wiki):
相似度曲线:
1.0 ┤
│ ●────● ●───●
0.8 ┤ \ / \
│ \ / ●───●
0.6 ┤ \ /
│ ●──●
0.4 ┤
│
0.2 ┤
└──┬────┬────┬────┬────┬────┬──→ 句子位置
游戏 角色 战斗 地图 剧情 开发
介绍 设定 系统 设计 故事 历史
|← 同话题 →| |← 同话题 →| |← 同话题 →|
不切割 不切割 不切割
话题转换处(相似度骤降)→ 切割!
5.4 两个核心参数深入理解
buffer_size --- 语义评估的"视野"
buffer_size 决定了多少个句子组成一个"评估窗口":
python
buffer_size = 1 # 窄视野:每个句子单独评估
评估: S1 vs S2, S2 vs S3, S3 vs S4 ...
→ 粒度细,对单句变化敏感
→ 但可能因一个"过渡句"而误判
→ 生成更多分割点
buffer_size = 3 # 宽视野:每 3 个句子一组评估(推荐)
评估: [S1,S2,S3] vs [S4,S5,S6], [S2,S3,S4] vs [S5,S6,S7] ...
→ 粒度适中,更稳健
→ 不会因为一个过渡句而误判
→ 但可能忽略微妙的话题转换
buffer_size = 5 # 更宽视野
评估: [S1,S2,S3,S4,S5] vs [S6,S7,S8,S9,S10] ...
→ 粒度粗,需要显著的话题转换才会触发分割
→ 适合超长文档(减少 Embedding 调用次数)
buffer_size 与 Embedding 调用次数的关系:
假设文档有 N 个句子
buffer_size = 1: 约 N-1 次相邻比较 → N-1 次 Embedding 调用
buffer_size = 3: 约 N-1 次滑动窗口比较 → N-3+1 次 Embedding 调用
buffer_size = 5: 约 N-5+1 次 Embedding 调用
注意:SemanticSplitterNodeParser 会对每个窗口调用 Embedding,
所以 buffer_size 不影响总 Embedding 调用次数(都是 N 次),
但影响分割的稳健性。
breakpoint_percentile_threshold --- 分割的"敏感度"
这个参数通过百分位数来自适应确定"多低算低":
python
# 假设所有相邻组的相似度为:
similarities = [0.92, 0.88, 0.85, 0.45, 0.91, 0.87, 0.82, 0.35, 0.93]
# 排序后:
sorted_sim = [0.35, 0.45, 0.82, 0.85, 0.87, 0.88, 0.91, 0.92, 0.93]
threshold = 80 # 取第 20 百分位
# → 阈值 ≈ 0.63
# → 0.35 和 0.45 都低于 0.63 → 2 个断点
threshold = 90 # 取第 10 百分位
# → 阈值 ≈ 0.40
# → 仅 0.35 低于 0.40 → 1 个断点
threshold = 95 # 取第 5 百分位
# → 阈值 ≈ 0.35
# → 没有低于 0.35 的 → 0 个断点(不分块!)
为什么用百分位数而非固定阈值?
因为不同文档的相似度分布差异巨大:
- 主题集中的文档:相似度普遍在 0.8-0.95 → 固定阈值 0.5 会导致不分割
- 主题分散的文档:相似度普遍在 0.3-0.8 → 固定阈值 0.5 会导致过度分割
- 百分位数自适应文档特点,始终只切割最不相似的 top-X% 处
5.5 Embedding 模型的选择
语义分块的效果很大程度上取决于 Embedding 模型的质量。常见选择:
| 模型 | 维度 | 特点 | 适用场景 |
|---|---|---|---|
text-embedding-3-small |
1536 | OpenAI 性价比最高 | 通用场景(推荐) |
text-embedding-3-large |
3072 | 精度最高,成本也高 | 高精度需求 |
text-embedding-ada-002 |
1536 | 上一代,较便宜 | 预算有限 |
BAAI/bge-small-zh |
512 | 中文优化,可本地运行 | 中文场景 + 数据隐私 |
BAAI/bge-large-zh |
1024 | 中文优化,精度更高 | 中文高精度 |
python
# 使用 OpenAI Embedding(云端)
from llama_index.embeddings.openai import OpenAIEmbedding
embed_model = OpenAIEmbedding()
# 使用本地 HuggingFace 模型(免费但需要 GPU)
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-zh")
5.6 参数组合速查表
| buffer_size | threshold | 生成块数 | 适用场景 |
|---|---|---|---|
| 1 | 75 | 很多 | 极致细分,高密度技术文档 |
| 1 | 85 | 多 | 精细分块,学术论文 |
| 1 | 95 | 少 | 粗粒度,低密度文档 |
| 3 | 75 | 中等偏多 | 细分但稳健 |
| 3 | 85 | 中等 | 信息密度适中的通用文档 |
| 3 | 90 | 中等偏少 | 通用推荐 |
| 3 | 95 | 少 | 长文档,大段落 |
| 3 | 98 | 很少 | 宽松配置,仅切割最明显的转换 |
| 5 | 90 | 少 | 超长文档(减少计算) |
| 5 | 95 | 很少 | 大段落文档 |
5.7 计算成本分析
语义分块的最大代价是 Embedding API 调用成本。以下是一个粗略估算:
假设:
文档:10,000 个句子
Embedding 模型:text-embedding-3-small
定价:$0.02 / 1M tokens
每句平均:20 tokens
计算:
总 Embedding 调用次数 ≈ 10,000 次(每句/每组一次)
总 token 数 ≈ 10,000 × 20 = 200,000 tokens
总成本 ≈ $0.004(不到 1 分钱)
时间估算:
OpenAI API:约 100ms/请求 → 10,000 × 100ms ≈ 17 分钟
使用批处理或并发可以缩短到 2-3 分钟
结论 :对于大多数文档,语义分块的 Embedding 成本可以忽略不计,但时间成本可能成为瓶颈(大型文档需要几分钟)。
5.8 与基础分块的对比
| 维度 | 基础句子分割 | 语义分割 |
|---|---|---|
| 分割依据 | 句子边界(规则) | 语义连续性(AI) |
| 话题感知 | ❌ 无 | ✅ 自动识别话题转换 |
| 块大小 | 相对均匀 | 可能差异很大(话题长短不同) |
| 计算成本 | 几乎为零 | 需要对每句话调用 Embedding |
| 处理速度 | 毫秒级 | 分钟级(取决于文档大小) |
| 可解释性 | 高(规则明确) | 较低(依赖模型判断) |
| 确定性 | 高(相同输入相同输出) | 中(Embedding 可能有微小差异) |
| 适合的文档 | 结构化文档 | 非结构化、多话题文档 |
5.9 语义分块的局限性
- 依赖 Embedding 质量:如果 Embedding 模型对中文支持不好,语义判断会出错
- 块大小不可控:一个话题可能跨越很长的文本,导致单个块过大
- "微妙转换"可能被忽略:buffer_size 较大时,轻微的话题转换可能被平滑掉
- 不适合结构化文档:对于已经有清晰标题/段落的文档,规则分割可能更精确
- 处理时间较长:大型文档需要数分钟
5.10 适用场景
高精度 RAG 场景:多话题混合文档(如 Wiki、长篇报告)、长文档问答、知识库构建、对话系统中对检索精度要求极高的场景。在计算成本可接受的前提下,语义分块通常能提供最佳的检索效果。
六、辅助工具:PDF 页面精准提取
6.1 为什么需要这个工具?
在 RAG 实践中,我们经常需要从大型 PDF(如百页的财报)中提取特定页面进行测试或分析。直接处理整个 PDF 不仅浪费时间,还可能因为内容过多而影响检索精度。
6.2 完整代码
python
from pathlib import Path
from pypdf import PdfReader, PdfWriter
def extract_pages(pdf_path, output_path, page_numbers):
"""
提取指定页码的 PDF 页面并保存为新的 PDF 文件
参数:
pdf_path: 原始 PDF 文件路径
output_path: 输出 PDF 文件路径
page_numbers: 要提取的页码列表(1-based,即人类可读的页码)
"""
try:
# 确保输出目录存在
output_dir = Path(output_path).parent
output_dir.mkdir(parents=True, exist_ok=True)
# 打开原始 PDF
reader = PdfReader(pdf_path)
writer = PdfWriter()
# 提取指定页码
for page_number in page_numbers:
if 1 <= page_number <= len(reader.pages):
writer.add_page(reader.pages[page_number - 1]) # 1-based → 0-based
else:
print(f"警告:页码 {page_number} 超出范围,PDF 共有 {len(reader.pages)} 页")
# 保存
with open(output_path, 'wb') as output_file:
writer.write(output_file)
print(f"成功提取页面 {page_numbers} 到 {output_path}")
except Exception as e:
print(f"处理 PDF 时发生错误: {str(e)}")
raise
# 使用示例:从 Uber 10-Q 财报中提取第 26-28 页
extract_pages(
pdf_path="90-文档-Data/复杂PDF/uber_10q_march_2022.pdf",
output_path="90-文档-Data/复杂PDF/uber_10q_march_2022_page1-3.pdf",
page_numbers=[26, 27, 28]
)
6.3 代码要点
- 页码转换 :用户使用 1-based 页码(符合人类直觉),代码内部转为 0-based(
page_number - 1) - 越界保护 :检查页码是否在有效范围内,避免
IndexError - 目录自动创建 :
mkdir(parents=True, exist_ok=True)确保输出路径的父目录存在 - 异常处理 :捕获并报告错误,同时通过
raise向上传播
6.4 在 RAG 流程中的位置
原始 PDF (100+ 页)
│
▼ [99-工具-PDF-切割.py]
目标页面 PDF (3 页)
│
▼ [03_LlamaIndex-分块大小影响准确性.py]
文本切块 → 向量化 → 索引 → 查询
6.5 扩展用法
python
# 提取不连续的页面
extract_pages("report.pdf", "extract.pdf", [1, 5, 12, 30])
# 提取整个章节(假设第 3 章在第 45-67 页)
extract_pages("report.pdf", "chapter3.pdf", list(range(45, 68)))
# 提取目录页
extract_pages("report.pdf", "toc.pdf", list(range(1, 6)))
七、进阶切块策略概览
除了上述 5 种核心策略,业界还有多种进阶切块方法。这些方法在本文的代码文件中没有实现,但在实际项目中经常使用,值得了解。
7.1 父-子分块(Parent-Child Chunking)
核心思想:将文档切分为"父块"和"子块"两层。检索时在子块(小块)上匹配以提高精度,返回时使用对应的父块(大块)以提供完整上下文。
原文档
│
▼
父块(大块,chunk_size=1000)
│
├── 子块 A1(小块,chunk_size=200)
├── 子块 A2
└── 子块 A3
检索流程:
查询 → 匹配到子块 A2 → 返回父块 A(完整上下文)
优势:兼顾检索精度(小块)和上下文完整性(大块)
7.2 文档结构感知分块(Structure-Aware Chunking)
核心思想:解析文档的结构(标题层级、表格、列表等),按结构边界切块。
Markdown 文档:
# 第一章 ← 切割点
## 1.1 概述 ← 切割点
正文内容...
## 1.2 方法 ← 切割点
正文内容...
按标题层级切块:
块1: "# 第一章\n## 1.1 概述\n正文内容..."
块2: "## 1.2 方法\n正文内容..."
优势:保持文档的逻辑结构,块的内容在语义上高度自洽
7.3 滑动窗口分块(Sliding Window)
核心思想:以固定步长在文档上滑动窗口,每个窗口就是一个块。
chunk_size = 100, step = 50
原文: [0-------------------200-------------------400]
块1: [0--------100]
块2: [50--------150] ← 与块1有 50% 重叠
块3: [100--------200]
块4: [150--------250]
优势 :简单直观,保证任何信息都会出现在至少一个块的"中心位置"
劣势:块数量多,冗余大
7.4 Agentic 分块(Agentic Chunking)
核心思想:使用 LLM 判断每个段落/句子应该归属于哪个"命题",然后按命题组合块。
LLM 判断:
"这个段落讨论了三个命题:价格、质量、服务"
→ 将段落拆分为三个独立的命题
→ 每个命题单独成块
优势 :最精细的语义分块
劣势:LLM 调用成本极高,处理速度极慢
7.5 多粒度分块(Multi-Granularity)
核心思想:同时使用多种粒度切块,检索时根据查询类型选择合适的粒度。
同一文档同时切块为:
粗粒度(chunk_size=1000):用于需要完整上下文的查询
中粒度(chunk_size=300):用于大多数通用查询
细粒度(chunk_size=100):用于精确查找
查询时:
"总结这段内容" → 使用粗粒度块
"XXX 的定义是什么" → 使用细粒度块
7.6 策略选择建议
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 通用 RAG | 递归字符分割 | 性价比最高,开箱即用 |
| 高精度 RAG | 语义分块 | 自动识别话题转换 |
| 代码 RAG | 语言感知分割 | 必须保持代码结构 |
| 长文档 RAG | 父-子分块 | 兼顾精度和上下文 |
| 结构化文档 | 结构感知分块 | 利用文档自身的层次 |
| 极致精度 | Agentic 分块 | 成本高但效果最好 |
总结:切块策略全景图与选型指南
策略演进全景
┌─────────────────────────────────────────────────────────────────────┐
│ 文本切块策略演进 │
├─────────────┬─────────────┬─────────────┬─────────────┬────────────┤
│ 字符分割 │ 递归分割 │ 句子分割 │ 语言感知 │ 语义分割 │
│ (01-文件) │ (02-文件) │ (03-文件) │ (04-文件) │ (05-文件) │
├─────────────┼─────────────┼─────────────┼─────────────┼────────────┤
│ 单一分隔符 │ 多级分隔符 │ 句子边界 │ 代码结构 │ AI 语义 │
│ 固定大小 │ 优先级列表 │ 可调大小 │ 类/函数级 │ 话题感知 │
│ 最简单 │ 最通用 │ 最平衡 │ 最专业 │ 最智能 │
└─────────────┴─────────────┴─────────────┴─────────────┴────────────┘
▲ ▲
│ │
成本最低 效果最好
选型决策树
你需要切什么?
│
├─ 代码 → Language-Aware Splitting(04-文件)
│ 使用 RecursiveCharacterTextSplitter.from_language()
│
├─ 结构化文档(Markdown/HTML)
│ └─ Language.MARKDOWN 或 Language.HTML
│
├─ 通用文本
│ │
│ ├─ 精度要求高? → 语义分块(05-文件)
│ │ SemanticSplitterNodeParser
│ │ buffer_size=3, threshold=90
│ │
│ ├─ 精度要求一般? → 递归字符分割(02-文件)
│ │ RecursiveCharacterTextSplitter
│ │ + 自定义分隔符列表
│ │
│ └─ 快速原型? → 基础字符分割(01-文件)
│ CharacterTextSplitter
│
└─ 需要兼顾精度和上下文? → 父-子分块(见进阶策略)
超参数调优清单
| 参数 | 推荐值范围 | 调优方法 |
|---|---|---|
chunk_size |
200 ~ 1000 | 准备一组测试查询,对比不同值下的检索命中率 |
chunk_overlap |
chunk_size × 10% ~ 20% | 从 10% 开始,逐步增大直到重叠部分不再带来收益 |
separators |
根据语言定制 | 中文必须包含 。、,;英文包含 .、, |
buffer_size |
1 ~ 5 | 信息密度高用 1,话题跨度大用 3-5 |
breakpoint_percentile_threshold |
80 ~ 98 | 从 90 开始,块太多就增大,块太少就减小 |
涉及的核心库一览
| 库 | 核心组件 | 用途 |
|---|---|---|
langchain_text_splitters |
CharacterTextSplitter |
基础字符分割 |
langchain_text_splitters |
RecursiveCharacterTextSplitter |
递归字符分割 + 语言感知 |
langchain_text_splitters |
Language |
编程语言枚举(20+ 种语言) |
langchain_community |
TextLoader |
文本文件加载 |
llama_index.core |
SentenceSplitter |
句子级分割(按 token 计量) |
llama_index.core |
SemanticSplitterNodeParser |
语义分块 |
llama_index.core |
VectorStoreIndex |
向量索引构建 |
llama_index.embeddings.openai |
OpenAIEmbedding |
OpenAI 嵌入模型 |
llama_index.llms.openai |
OpenAI |
OpenAI LLM |
pypdf |
PdfReader / PdfWriter |
PDF 读写操作 |
十条实战经验
- 先跑通再优化 --- 先用
RecursiveCharacterTextSplitter配默认参数跑通整个流程,再回来调切块 - chunk_size 比你想的更重要 --- 花 30 分钟做对比实验,比花 3 天调其他参数更有价值
- 中文文档的分隔符一定要自己配 --- 默认分隔符是为英文设计的,中文句号
。和英文句号.是不同的字符 - 代码必须用语言感知分块 --- 这是没有商量余地的,通用分块对代码来说是灾难
- overlap 是保险,不是越多越好 --- 太大的 overlap 会显著增加存储和计算开销
- 语义分块是锦上添花,不是雪中送炭 --- 如果基础分块的效果就很差,语义分块也不会有质变
- 数据源质量 > 切块策略 --- 垃圾进,垃圾出。再好的切块策略也无法挽救质量低劣的原始文档
- 考虑按 token 而非字符计量 --- 特别是使用 LLM 生成回答时,token 计量更准确地反映上下文消耗
- 不要忘记元数据 --- 记录每个块的来源文件、页码、位置等信息,便于溯源和调试
- 切块策略需要跟着业务走 --- 不同业务场景的最优策略不同,不存在"一招鲜吃遍天"