RAG 系列(四):文档处理——从原始文件到高质量 Chunk

为什么「怎么切」和「切什么」一样重要?

前面三篇文章,我们搭好了 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 个字符剪一刀。简单高效,但可能刚好剪断一句话的中间。

代码

python 复制代码
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 个块的内容:

markdown 复制代码
块 1(489 字符):
# 微服务架构设计指南 本文介绍如何设计和实现一套生产级的微服务架构...

块 2(504 字符):
- **读服务 vs 写服务**:读多写少的场景,读写分离可以独立扩缩容...

块 3(457 字符):
**gRPC** 基于 HTTP/2 和 Protocol Buffers。优点是:...

问题暴露:

注意块 2 的开头:- **读服务 vs 写服务**... 这是一个列表项的中间部分。固定大小分块把上一块末尾的列表硬生生切断了,块 2 从一个不完整的列表项开始。如果用户问"读写分离有什么优势?",Retriever 召回这块时,LLM 看到的是残缺的信息。


策略二:递归字符分块(Recursive Character)

原理

比固定大小「聪明」一点:它有一组分隔符优先级列表,按顺序尝试------先按段落(\n\n)切,如果还太大就按换行(\n)切,再不行按句子(. )切,最后按单词( )切。

像是一个有经验的编辑:优先在段落边界下刀,实在不行再在句子边界下刀,绝不在单词中间切断。

代码

python 复制代码
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 个块的内容:

shell 复制代码
块 1(441 字符):
# 微服务架构设计指南  本文介绍如何设计和实现一套生产级的微服务架构...

块 2(452 字符):
### 1.2 按技术特性拆分  除了业务边界,也可以按技术特性拆分:...

块 3(457 字符):
微服务之间最常见的同步通信方式是 HTTP REST 和 gRPC。...

对比固定大小的改进:

块 2 现在以 ### 1.2 按技术特性拆分 开头------一个完整的标题。递归字符分块成功地在标题边界处切开了,没有切断列表项。

但注意它的 separators 列表里用的是 . (英文句点+空格),对中文文档来说,它不会按中文句号(。)切分。所以中文文档中它的行为和固定大小很接近,主要靠 \n\n\n 来切分。


策略三:语义分块(Semantic Chunking)

原理

前两种策略都是「按长度切」,而语义分块是「按意思切」。

具体做法:

  1. 先把文档拆成句子
  2. 计算每个句子的 Embedding(语义向量)
  3. 比较相邻句子的语义相似度
  4. 如果相似度突然下降(低于设定阈值),就在此处切开

想象你在看一部电影,场景从办公室突然切到了海边------这就是语义边界。语义分块能识别这种「场景切换」,确保每个块内部讲的是同一件事。

代码

python 复制代码
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 超限

arduino 复制代码
ValueError: input batch size 1000 > maximum allowed batch size 32

→ 解决:OpenAIEmbeddings(chunk_size=32)

坑 2:单条 token 超限

less 复制代码
Error code: 413 - input must have less than 512 tokens

→ 解决:设置 buffer_size=0,不让 SemanticChunker 拼接相邻句子

坑 3:空字符串导致 400

vbnet 复制代码
Error code: 400 - The parameter is invalid

→ 解决:继承 SemanticChunker 重写 _get_single_sentences_list,过滤空字符串

python 复制代码
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 的 ###### 标题,严格按照标题层级来划分。

每个块的边界就是标题边界:从某个标题开始,到下一个同级或更高级标题之前结束。

代码

python 复制代码
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 里记录了它属于哪个标题层级:

python 复制代码
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 / 论文 / 书籍 文档结构分块 保留章节结构,检索结果可溯源
专业术语密集的文档(法律/医学) 语义分块 块内语义一致,减少跨主题干扰
对分块速度要求极高(实时场景) 固定大小分块 零计算开销,纯字符串操作
代码文档 递归字符分块 + 自定义分隔符 按函数/类边界切分

选型建议

css 复制代码
第一步:先用递归字符分块跑通 baseline
    ↓
第二步:如果文档是 Markdown/HTML,试试文档结构分块
    ↓
第三步:如果检索质量不满意,再上语义分块(成本最高但效果最好)

小结

本文用同一份文档、四种策略,让你直观感受到「怎么切」对 RAG 质量的影响:

  • 固定大小:简单但粗暴,适合快速原型
  • 递归字符:最通用的 baseline,80% 场景够用
  • 语义分块:效果最好但成本最高,适合精度要求高的场景
  • 文档结构:结构化文档的最佳选择,检索结果自带上下文

关键认知: 没有完美的分块策略,只有适合当前文档类型和业务场景的策略。实际项目中,建议用本文的对比脚本,拿自己的文档跑一遍,用数据说话。

相关推荐
冬奇Lab1 小时前
一天一个开源项目(第89篇):Warp - AI 驱动的现代化 Rust 终端
人工智能·rust·开源
蔡俊锋1 小时前
AI是一面镜子
人工智能·ai·规格说明书·ai是一面镜子
四方云1 小时前
Kamailio 启动报错 “invalid curve” 与 “freeing already freed pointer” 的终极解决方案
人工智能
沪漂阿龙2 小时前
OpenAI Agents SDK 深度解析(三):执行层——Agent 的“幕后指挥部”
人工智能·深度学习
还是奇怪2 小时前
AI 提示词工程入门:用好的语言与模型高效对话
大数据·人工智能·语言模型·自然语言处理·transformer
健忘的萝卜2 小时前
Clawdbot 爆红硅谷,也把 AI Agent 和 Mac mini 推上风口
人工智能·macos·agent·数字员工·clawbot
迁旭2 小时前
claude code 提示词
人工智能·语言模型·gpt-3·知识图谱
不知名的老吴2 小时前
深度探索:直接预测多个token可行吗?
人工智能·回归
数智工坊2 小时前
【SAM-DETR论文阅读】:基于语义对齐匹配的DETR极速收敛检测框架
网络·论文阅读·人工智能·深度学习·transformer