文档切分:RAG 的第一步,也是最容易踩坑的一步

文档切分:RAG 的第一步,也是最容易踩坑的一步

上一篇文章建立了 RAG 的全局认知。从这篇开始,我们深入 RAG 管线的每个环节,从第一步——文档切分开始。

文档切分是 RAG 中最容易被忽视、但影响效果最大的环节之一。很多人把精力花在选 Embedding 模型和调检索算法上,却忽略了:如果切分做得不好,后面的环节再怎么优化也无济于事。

垃圾进,垃圾出——这个原则在 RAG 中尤其适用。

一、为什么切分这么重要

RAG 的检索粒度就是 chunk。用户问一个问题,系统返回的是最相似的 k 个 chunk。如果 chunk 本身有问题,检索结果就不会好。

切分太粗的问题: 一个 chunk 里包含多个不相关内容。检索时,chunk 里只有一小部分和查询相关,其余都是噪声。Embedding 向量被这些噪声"稀释",导致语义不明确,检索精度下降。

切分太碎的问题: 一个 chunk 里只有几句话,缺乏上下文。模型拿到这个 chunk 时,不知道"如上所述"指的是什么,不知道"这个功能"说的是哪个功能。上下文断裂导致生成质量下降。

打个比方:你要在一本书里找到某个知识点。如果按页切分(太粗),一页里有十个知识点,你很难定位到具体那个。如果按句切分(太碎),一句话脱离了上下文,你根本不知道它在说什么。最好的方式是按章节或段落切分——每个片段语义完整,又不包含太多无关内容。

切分的核心矛盾:精准 vs 上下文。 越小的 chunk 检索越精准,但上下文越不完整。越大的 chunk 上下文越完整,但检索越不精准。切分策略的本质就是在这个矛盾中找到平衡。

二、五种切分策略

2.1 固定长度切分(Fixed-Size Splitting)

最简单的策略:按固定的字符数或 token 数切分,相邻 chunk 之间保留一定的 overlap。

from langchain.text_splitter import CharacterTextSplitter

splitter = CharacterTextSplitter(
    chunk_size=1000,      # 每个 chunk 1000 个字符
    chunk_overlap=200,    # 相邻 chunk 重叠 200 个字符
    separator="\n\n"      # 优先在段落边界切分
)

chunks = splitter.split_text(document)

优点:实现简单,计算快,不需要额外的模型调用。

缺点:完全忽略语义边界。一个段落可能被从中间切断,一个 chunk 可能包含两个不相关的段落。

适用场景

  • 快速原型验证
  • 文档结构不重要、内容连续的场景(如小说、连续文本)
  • 对效果要求不高的内部工具

参数选择

  • chunk_size:常见范围 256-2048 字符。技术文档建议 500-1000,论文建议 1000-2000。
  • chunk_overlap:通常为 chunk_size 的 10%-25%。太小的 overlap 会导致边界信息丢失,太大的 overlap 会产生大量重复。

2.2 递归切分(Recursive Splitting)

LangChain 默认推荐的策略。按层级递归切分:先按段落(\n\n)切,太长的段落再按句子(\n)切,还太长就按空格切,直到每个 chunk 满足大小要求。

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n\n", "\n", ". ", " ", ""]  # 按优先级依次尝试
)

chunks = splitter.split_text(document)

优点:尽量保留语义边界。段落、句子这些自然的分隔符被优先使用,不会从段落中间硬切。

缺点:仍然依赖分隔符。如果文档没有清晰的分隔符(比如没有换行符的长文本),效果会退化到接近固定长度切分。

适用场景

  • 大多数结构化文档(技术文档、博客文章、论文)
  • 通用场景的首选方案

参数选择

  • separators 的顺序很重要。越靠前的分隔符优先级越高。
  • 中文文档建议把 "。""!" 等标点加入分隔符列表。
  • chunk_size 建议比固定长度切分稍大,因为递归切分更尊重语义边界。

2.3 语义切分(Semantic Splitting)

基于 Embedding 相似度检测主题边界。先对每个句子做 Embedding,然后计算相邻句子的相似度,相似度突然下降的地方就是主题切换的边界。

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

splitter = SemanticChunker(
    OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",  # 或 "standard_deviation", "interquartile"
    breakpoint_threshold_amount=95           # 相似度低于第 95 百分位时切分
)

chunks = splitter.split_text(document)

优点:真正按语义边界切分。每个 chunk 在主题上是自洽的,不会把两个不同的话题混在一起。

缺点

  • 需要调用 Embedding 模型,有额外成本
  • 计算量大,不适合超大规模文档
  • 阈值选择需要调参

适用场景

  • 主题多样的长文档(如会议纪要、百科全书)
  • 对检索精度要求高的生产系统
  • 文档结构不规律、传统切分策略效果不好的场景

参数选择

  • breakpoint_threshold_type
    • percentile:基于百分位数,最直观
    • standard_deviation:基于标准差,适合相似度分布均匀的场景
    • interquartile:基于四分位距,对异常值更鲁棒
  • breakpoint_threshold_amount:值越大,切分越少(chunk 越大);值越小,切分越多(chunk 越小)。

2.4 父子 Chunk(Parent-Child Chunks)

解决"精准 vs 上下文"矛盾的核心方案。小块(child chunk)用于检索,大块(parent chunk)用于生成。

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import Chroma
from langchain_text_splitter import RecursiveCharacterTextSplitter

# 小块用于检索
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
# 大块用于生成
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000)

retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=InMemoryStore(),
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

# 检索时:用小块匹配(精准),返回大块给模型(有上下文)
results = retriever.invoke("公司的请假流程是什么?")

优点:同时获得精准检索和完整上下文。检索阶段用小 chunk 提高匹配精度,生成阶段用大 chunk 提供充足上下文。

缺点

  • 需要维护两份 chunk(parent 和 child),存储成本翻倍
  • 实现复杂度更高
  • 需要额外的 docstore 存储 parent chunk

适用场景

  • 生产级 RAG 系统
  • 文档段落间关联性强的场景(如法律文档、技术规范)
  • 检索精度和生成质量都要求高的场景

2.5 滑动窗口(Sentence Window)

检索单个句子,但返回该句子的上下文窗口。和父子 chunk 类似,但粒度更细——以句子为单位。

from langchain.retrievers import SentenceWindowRetriever

retriever = SentenceWindowRetriever(
    vectorstore=vectorstore,
    window_size=3,  # 检索句子前后各 3 个句子作为上下文
)

# 检索时:匹配单个句子,返回上下文窗口
results = retriever.invoke("违约条款的赔偿标准是什么?")

优点:比父子 chunk 更灵活,上下文窗口大小可调。

缺点:实现更复杂,需要维护句子级别的索引。

适用场景

  • 需要精确到句子级别的检索
  • 文档中关键信息分散在不同句子的场景

三、不同文档类型的最佳实践

没有万能的切分策略。不同类型的文档需要不同的切分方式。

3.1 技术文档(API 文档、教程、README)

特点:结构清晰,有标题层级,段落间相对独立。

推荐策略:递归切分,按标题层级切分。

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=150,
    separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " "]
)

要点

  • 优先按标题(#####)切分,每个 chunk 对应一个章节
  • 在标题分隔符前切分,确保 chunk 包含章节标题(标题是重要的上下文信息)
  • chunk_size 不宜太大,技术文档的段落通常较短

3.2 论文 / 长文

特点:段落长,上下文关联紧密,关键信息可能跨段落。

推荐策略:递归切分 + 较大的 overlap,或者语义切分。

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1500,
    chunk_overlap=400,  # 较大的 overlap 保持上下文连贯
    separators=["\n\n", "\n", ". ", " "]
)

要点

  • 论文的段落通常较长,chunk_size 需要相应增大
  • overlap 要足够大,避免关键论证被切断
  • 如果论文涉及多个主题切换,考虑语义切分

3.3 对话记录(聊天记录、会议纪要)

特点:按轮次组织,每轮相对独立,但上下文可能跨轮次。

推荐策略:按轮次切分,保留说话人信息。

# 按对话轮次切分
def split_conversations(text, max_turns_per_chunk=5):
    turns = text.split("\n")  # 假设每行是一轮对话
    chunks = []
    for i in range(0, len(turns), max_turns_per_chunk):
        chunk = "\n".join(turns[i:i + max_turns_per_chunk])
        chunks.append(chunk)
    return chunks

要点

  • 按轮次切分,每个 chunk 包含若干轮对话
  • 保留说话人信息("张三:..."),这对上下文理解很重要
  • 不要从一轮对话的中间切断

3.4 代码文件

特点:有明确的语法结构(类、函数、注释),上下文跨行。

推荐策略:按函数或类切分。

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=100,
    separators=["\n\ndef ", "\n\nclass ", "\n\n", "\n", " "]
)

要点

  • 优先按函数(def)和类(class)切分
  • 保留函数签名和 docstring
  • 代码的上下文关联紧密,overlap 不要太小

3.5 PDF / 扫描件

特点:格式不可控,可能有表格、图表、页眉页脚、OCR 错误。

推荐策略:先清洗,再切分。

# 1. 提取文本
raw_text = extract_pdf_text("document.pdf")

# 2. 清洗:去除页眉页脚、页码、OCR 噪声
cleaned_text = clean_pdf_text(raw_text)

# 3. 切分
splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n\n", "\n", ". ", " "]
)
chunks = splitter.split_text(cleaned_text)

要点

  • PDF 的文本提取质量参差不齐,先清洗再切分
  • 表格内容需要特殊处理(转换为文本描述或保留结构化格式)
  • 扫描件的 OCR 质量直接影响切分效果

四、Overlap 怎么选

Overlap(相邻 chunk 之间的重叠区域)是切分中最容易被忽视的参数。

Overlap 太小:边界信息丢失。一个段落的最后一句话和下一段的第一句话可能有关联,如果没有 overlap,这个关联就丢了。

Overlap 太大:大量重复内容。不仅浪费存储和计算,还可能导致检索结果中大量重复的 chunk,降低多样性。

经验法则

chunk_size 推荐 overlap 说明
256 50-64 约 20%-25%
512 100-128 约 20%-25%
1024 150-250 约 15%-25%
2048 200-400 约 10%-20%

chunk_size 越大,overlap 的比例可以越小。因为大 chunk 本身已经包含了足够的上下文,不需要太多重叠来保持连贯。

五、切分效果的评估

怎么知道你的切分策略好不好?几个实用的评估方法:

5.1 人工检查

最简单也最有效的方法:随机抽 10 个 chunk,人工判断:

  • 每个 chunk 是否语义完整?(能脱离上下文理解吗?)
  • 每个 chunk 是否只包含一个主题?
  • 相邻 chunk 之间有没有重要的信息被切断?

5.2 检索命中率

准备一批测试问题,每个问题标注了"正确答案所在的文档段落"。然后检查:

  • 检索结果中是否包含了正确段落?
  • 包含正确段落的比例是多少?

如果命中率低,可能是切分太粗(chunk 里噪声太多)或太碎(关键信息被切断)。

5.3 Chunk 大小分布

import matplotlib.pyplot as plt

sizes = [len(chunk) for chunk in chunks]
plt.hist(sizes, bins=30)
plt.xlabel("Chunk Size (characters)")
plt.ylabel("Count")
plt.title("Chunk Size Distribution")
plt.show()

如果分布过于分散(有的 chunk 50 字符,有的 5000 字符),说明切分策略没有很好地控制 chunk 大小,需要调整。

六、常见误区

误区 1:chunk_size 越大越好。 错。chunk 越大,检索精度越低。一个 5000 字符的 chunk 里可能包含十个不同的主题,检索时很难精准匹配。

误区 2:chunk_size 越小越好。 错。chunk 越小,上下文越不完整。一个 50 字符的 chunk 可能只有一句话,模型无法理解它在说什么。

误区 3:overlap 越大越好。 错。overlap 太大会产生大量重复内容,浪费存储和计算,还会降低检索结果的多样性。

误区 4:一种策略打天下。 错。不同类型的文档需要不同的切分策略。技术文档按标题切分,对话按轮次切分,代码按函数切分。

误区 5:切分是一次性的。 错。切分策略需要根据实际效果持续调整。建议建立评估流程,定期检查切分质量。

七、总结

文档切分是 RAG 管线的第一步,也是最影响效果的环节之一。

核心要点:

  • 没有万能的切分策略,根据文档类型选择合适的策略
  • 核心矛盾是精准 vs 上下文,父子 chunk 是解决这个矛盾的最佳方案
  • overlap 很重要,通常为 chunk_size 的 15%-25%
  • 评估切分质量,不要靠猜

下一篇文章深入第二个环节——向量检索。Embedding 模型怎么选?向量数据库怎么选?混合检索怎么做?


系列