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

0 阅读10分钟

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

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

代码

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 个块的内容:

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

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

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

问题暴露:

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


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

原理

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

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

代码

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 个块的内容:

块 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. 如果相似度突然下降(低于设定阈值),就在此处切开

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

代码

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 超限

ValueError: input batch size 1000 > maximum allowed batch size 32

→ 解决:OpenAIEmbeddings(chunk_size=32)

坑 2:单条 token 超限

Error code: 413 - input must have less than 512 tokens

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

坑 3:空字符串导致 400

Error code: 400 - The parameter is invalid

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

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

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

代码

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

chunk.metadata = {
    "Header 1": "微服务架构设计指南",
    "Header 2": "1. 服务拆分策略",
    "Header 3": "1.1 按业务边界拆分(DDD)"
}

这意味着检索时,你不仅拿到了内容,还知道它来自哪个章节。这在后续做引用溯源("答案来自文档第 X 章")时非常有价值。


四种策略横向对比

统计对比总表

策略块数平均长度中位数最大最小
固定大小分块12453.5476.5506128
递归字符分块13431.5457.050788
语义分块9590.9422.0204717
文档结构分块20266.5259.040271

可视化对比:同一个问题的召回差异

假设用户问:"微服务拆分有哪些反模式?"

策略召回的块问题
固定大小块 4(含部分反模式内容,但开头被切断)列表项从中间开始,LLM 看不到完整上下文
递归字符块 5(完整包含"1.3 拆分的常见反模式"小节)较好,但如果小节很长会截断
语义分块块 3(聚合了反模式 + 部分后续内容)可能混入无关内容
文档结构块 6(精确对应"### 1.3 拆分的常见反模式")最佳,结构精确匹配

策略选择决策表

场景推荐策略理由
通用技术文档(PDF/Word)递归字符分块最稳妥的 baseline,不需要特殊格式
Markdown / 论文 / 书籍文档结构分块保留章节结构,检索结果可溯源
专业术语密集的文档(法律/医学)语义分块块内语义一致,减少跨主题干扰
对分块速度要求极高(实时场景)固定大小分块零计算开销,纯字符串操作
代码文档递归字符分块 + 自定义分隔符按函数/类边界切分

选型建议

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

小结

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

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

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