本文是【AI Agent 从 0 到 1 开发学习】专栏系列文章,聚焦 RAG 系统中文档存储、粒度选择与切割策略这一核心话题。如果你正在搭建自己的 RAG 应用,这篇文章会帮你搞清楚:文档入库前到底要经历哪些处理?chunk 切多大才合适?市面上那些切割策略各有什么利弊?


一、先搞清楚一个问题:RAG 里的文档到底存在哪?

不少刚接触 RAG 的同学会以为,文档就是直接丢进向量数据库就完事了。实际上,RAG 系统的存储层远比"存个向量"复杂。一份文档从原始文件变成可检索的知识单元,中间要经历解析、切割、向量化、持久化等多个环节,每个环节都对最终的检索质量产生直接影响。

1.1 存储架构:不只是向量库那么简单

一个成熟的 RAG 存储系统通常由三层构成:

  • 原始文档层:保存未经处理的原始文件(PDF、Word、Markdown、HTML 等),方便溯源和重新切分。这部分通常存在对象存储(如 S3、MinIO)或本地文件系统中。保留原文非常重要——当切割策略调整时,你需要从原文重新处理,而不是依赖已经切好的碎片。

  • 向量索引层:这是大家最熟悉的部分。每个 chunk 经过 Embedding 模型编码后生成高维向量,存入 Milvus、Pinecone、FAISS、Chroma 等向量数据库,用于相似度检索。但要注意,向量库存的不仅仅是向量本身,还需要同时存储 chunk 的原始文本和元数据,否则检索出来的结果无法直接使用。

  • 元数据层:这部分经常被忽视,却至关重要。每个 chunk 都需要挂载元数据,包括:来源文档名称、切割位置(第几段/第几页)、前后 chunk 指针、文档创建时间、权限标记等。元数据在检索后的过滤、排序和上下文拼接中扮演关键角色。比如你只想要最近一个月的文档,如果没有时间元数据,就只能靠模型自己判断,这显然不靠谱。

在这里插入图片描述

1.2 文档入库的完整链路

从上图可以看到,文档从进入 RAG 系统到最终可被检索,需要走完四个阶段:

第一阶段——文档加载与解析,要做的事情包括多格式文件读入、文本抽取与清洗(去标记、处理表格、统一编码)、元数据提取(标题层级、页码、来源等)。这一步的输出是"干净的结构化文本 + 元数据"。

第二阶段——文档切割(Chunking),这是本文的核心,后面会详细展开。简单来说就是把长文本切成合适大小的片段,每个片段称为一个 chunk。

第三阶段——向量化与存储,将每个 chunk 送入 Embedding 模型(如 BGE、text-embedding-ada-002、bge-m3 等)生成向量,然后连同原始文本和元数据一起写入向量数据库。

第四阶段——检索与生成,用户提问后,Query 同样被向量化,在向量库中做相似度搜索,召回 Top-K 个 chunk,连同 Query 一起喂给 LLM 生成回答。


二、粒度怎么定?这是 RAG 效果的分水岭

文档切割的粒度——也就是每个 chunk 包含多少信息——是影响 RAG 检索效果最核心的变量之一。粒度选择不当,再好的 Embedding 模型和 LLM 也救不回来。

2.1 从粗到细:四种粒度层级

我把常见的粒度层级整理成下面这张金字塔图,从最粗的"文档级"到最细的"句子级":

在这里插入图片描述

文档级粒度最粗,整篇文档作为一个存储和检索单元。这种方式实现简单,但只适合短文档场景(比如 FAQ、知识卡片)。想象一下,如果你把一篇 2 万字的技术白皮书当成一个 chunk 存进去,用户问一个具体问题,检索命中的是整篇白皮书——LLM 拿到这么长的上下文,既浪费 token 又容易被噪声干扰。

章节级按文档的自然章节切割,每个章节或小节作为一个 chunk。这对于结构清晰的长篇技术文档非常合适,因为一个章节通常围绕一个主题展开,语义完整度较高。但问题是章节长度差异可能很大——有的章节 200 字就结束了,有的章节动辄 5000 字。

段落级是 RAG 中最常用的粒度,以自然段落为切分边界。一个段落通常在 200~2000 token 之间,语义相对完整,大小也比较可控。大多数 RAG 框架的默认配置都落在段落级附近。

句子级粒度极细,按句号或换行切分。检索精度确实很高,因为每个 chunk 的话题非常聚焦。但代价是上下文严重碎片化——一个完整论述可能被拆成十几个 chunk,检索时很难全部召回,LLM 拿到的信息是断裂的。

滑动窗口是固定 token 数量加 overlap 的【切割方式】,不关心语义边界,纯粹按数量切割。最灵活但也最容易割裂语义,通常作为没有更好选择时的兜底方案。

2.2 粒度选择的三个核心权衡

粒度选择本质上是在做三个维度的权衡:

召回率 vs 噪声:粒度越粗,一个 chunk 包含的信息越多,用户查询时更容易"命中"相关内容,但同时也会带入大量无关信息(噪声)。粒度越细,每个 chunk 话题更聚焦,检索精度更高,但可能出现"明明相关但不在同一个 chunk 里"的漏召回问题。

上下文完整度 vs 存储成本:粗粒度保留了更多上下文,LLM 看到的信息更完整。但粗粒度意味着每个 chunk 更大,向量数据库的存储和检索开销也更大。细粒度存储效率高,但需要额外的上下文拼接策略来弥补信息断裂。

Embedding 表达力:这一点很多人忽略了。Embedding 模型在处理短文本和长文本时的表现差异很大。过长的文本会导致向量"稀释"——一篇 3000 字的文档被压缩成一个 768 维向量,关键信息的信号被大量背景信息淹没。而过短的文本(比如一句话)又缺乏足够的语义上下文让模型生成有区分度的向量。

实践中的经验值:大多数场景下,256~512 token 是一个比较好的起步粒度,配合 10%~20% 的 overlap。然后根据你的文档类型和检索效果逐步调整。


三、文档切割(Chunking)策略详解

搞清楚了粒度的概念,接下来看看具体的切割策略。不同的策略在语义完整性、大小均匀性和实现复杂度上有很大差异。

在这里插入图片描述

3.1 固定大小切割(Fixed-size Chunking)

最简单的策略:按照固定的字符数或 token 数切分,切到哪算哪。为了缓解边界信息丢失问题,通常会设置一个 overlap(重叠区域),让相邻 chunk 之间有部分内容重复。

from langchain.text_splitter import CharacterTextSplitter

splitter = CharacterTextSplitter(
    separator="\n",
    chunk_size=400,      # 每个 chunk 最大 400 字符
    chunk_overlap=50,     # 相邻 chunk 重叠 50 字符
)
chunks = splitter.split_text(document_text)

这种方式的优点是实现极简,chunk 大小均匀可控,存储和检索的负载可预测。缺点也显而易见——完全无视语义边界,可能把一句话从中间劈开,也可能把一个完整论述拆成两半。对于结构规范的文档来说,这种"暴力切割"的效果往往不够好。

适用场景:快速原型验证、日志类文档、对语义完整性要求不高的场景。

3.2 递归字符切割(Recursive Character Chunking)

这是 LangChain 的默认切割策略,也是实际项目中最常用的方案。核心思路是:定义一组分隔符的优先级列表(比如 \n\n > \n > > 空格),然后递归地尝试按优先级从高到低切分。先按双换行(段落边界)切,如果切出来的块还是太大,再按单换行(行边界)切,以此类推。

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    separators=["\n\n", "\n", "。", "!", "?", ".", " ", ""],
    chunk_size=500,
    chunk_overlap=50,
)
chunks = splitter.split_text(document_text)

递归切割的优势在于:它在"目标大小可控"和"尊重自然边界"之间找到了一个不错的平衡点。大多数情况下,它能保证 chunk 不会从一个句子的中间断开。不过,分隔符优先级需要根据文档语言和格式手动调整——中文文档用 作分隔符没问题,但中英文混排文档就可能遇到英文句号 . 误切缩写词(如 e.g.vs.)的问题。

适用场景:通用 RAG 应用、文档类型多样的场景、作为默认策略使用。

3.3 文档结构切割(Document-structure Chunking)

如果你的文档本身有清晰的结构标记(Markdown 标题层级、HTML 标签、Word 内置样式),那就可以直接利用这些结构信息来切割。每个章节或小节自然形成一个 chunk。

from langchain.text_splitter import MarkdownHeaderTextSplitter

headers_to_split_on = [
    ("#", "Header 1"),
    ("##", "Header 2"),
    ("###", "Header 3"),
]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on)
chunks = splitter.split_text(markdown_text)

这种方式的优势是语义完整度极高——每个 chunk 对应文档的一个逻辑单元,和人类阅读文档的方式完全一致。检索命中的某个 chunk,用户溯源时可以直接跳到对应章节。但劣势也很明显:它严重依赖文档格式规范。如果你的 Markdown 文档标题层级混乱,或者 PDF 解析后丢失了结构信息,这个策略就失效了。此外,章节长度差异可能导致 chunk 大小极不均匀——一个 5000 字的大章节和一个 100 字的注意事项被同等对待,对检索效果和 LLM 上下文窗口的使用都不友好。

适用场景:Markdown/HTML 技术文档、API 文档、组织良好的知识库。

3.4 语义切割(Semantic Chunking)

语义切割是更"智能"的一种方式:先对文档中的每一句话做 Embedding,然后计算相邻句子之间的余弦相似度。当相似度出现明显下降时(即话题发生了转换),就在这个位置切一刀。

from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh")
splitter = SemanticChunker(embeddings, breakpoint_threshold_type="percentile")
chunks = splitter.split_text(document_text)

语义切割的优势是真正做到了"按话题边界切割"——同一个 chunk 内的话题高度一致,不会出现半段讲 A 话题、半段讲 B 话题的情况。但它的劣势也很突出:首先是计算开销大,每个句子都要调一次 Embedding 模型,大规模文档处理速度很慢;其次是 chunk 大小完全不可控,可能一个话题就两句话(chunk 太小),也可能一个话题写了三页纸(chunk 太大);最后是相似度阈值需要调优,不同类型文档的"话题转换信号"强度不一样,一个固定阈值很难通用。

适用场景:对话记录、叙事类文本、对语义质量要求极高的场景。

3.5 Agent 式智能切割(Agentic Chunking)

这是最前沿也最"奢侈"的切割方式:直接让 LLM 读取文档,由模型自己判断在哪里切割最合适。LLM 可以理解文档的逻辑结构、论点关系和话题转换,给出比任何规则都更精准的切割方案。

# 伪代码示意
prompt = f"""
请阅读以下文档,按照逻辑主题将其切分为若干片段。
每个片段应围绕一个完整的主题,大小在 200~800 token 之间。
输出格式:每个片段用 === CHUNK === 分隔。

文档内容:
{document_text}
"""
chunks = llm.generate(prompt).split("=== CHUNK ===")

优势是切割质量最高,能适应任意类型的文档。劣势是成本极高——每个文档都要调一次 LLM,处理速度慢,对于数万篇文档的知识库来说完全不现实。目前更适合文档量少、对质量要求极高的精品知识库场景。

3.6 父子切割(Parent-Child Chunking)

理解了前面几种策略各自的局限-一固定大小可能切断语义,语义边界切割质量好但粒度不好控制,你自然会问:有没有一种策略能同时兼顾检索精度和上下文完整?父子切割就是在回答这个问题。这是在实际工程里效果提升最显著的策略之一,核心思路可以用一句话概括:检索时用放大镜(小块,精准定位),返回时用全景图(大块,上下文完整)

存储时,同一段内容存两份。一份是细粒度的小chunk(比如200 token),专门用于向量检索,因为小chunk语义聚焦,围绕一个小话题,检索精度高。另一份是包含这个小chunk前后上下文的大chunk(比如1000 token),通过ID与对应的小 chunk关联。检索时用小chunk找到精准的命中点,然后根据关联ID取出对应的大chunk,把完整的上下文交给LLM阅读,生成质量更好。

好比图书馆找书:你用目录卡(小chunk)快速定位到某章某节,但拿出来读的是完整的那一章(大chunk),不是只读目录卡上的那句简介。

这种策略的代价是存储量翻倍,索引构建也更复杂,但在对召回质量要求较高的场景下,这个成本是值得的。


四、实战选型:怎么给自己的 RAG 系统选切割策略?

聊了这么多策略,最后给一个实用的选型建议:

你的情况 推荐策略
刚开始做 RAG 原型,想快速跑通 递归字符切割(默认配置即可)
文档是 Markdown/HTML 技术文档 文档结构切割 + 递归切割兜底
文档是纯文本/无结构,追求语义质量 语义切割(小规模)或递归切割(大规模)
文档量少(<1000 篇),愿意投入成本 Agent 式切割
不确定哪种好 递归字符切割 512 token + 50 overlap,先跑基线再调优

无论选哪种策略,有几个实践原则是通用的:

第一,一定要设置 overlap。 overlap 是缓解边界割裂最简单有效的方法。通常设置 chunk_size 的 10%~20% 作为 overlap。比如 chunk_size=500,overlap=50~100。overlap 不是越大越好——过大的 overlap 会导致相邻 chunk 高度重复,既浪费存储又可能让检索结果重复。

第二,给每个 chunk 挂上足够的元数据。 至少包括:来源文档名、切割位置(第几段/第几页)、前后 chunk ID、文档标题。这些元数据在检索后的上下文拼接中非常重要——当你需要把多个相关 chunk 拼成完整上下文送给 LLM 时,元数据就是拼接的依据。

第三,不要一刀切。 一个知识库里可能同时存在 FAQ(短文本)、技术文档(长文本,有结构)、聊天记录(无结构,对话体),不同类型的文档适合不同的切割策略。最好的做法是按文档类型路由到不同的切割 pipeline。

第四,持续评估和调优。 切割策略没有银弹,你需要在真实查询集上持续评估检索效果。关注两个指标:召回率(相关 chunk 是否被检索到)和精确率(检索结果中真正相关 chunk 的占比)。根据评估结果调整 chunk_size、overlap 和切割策略。


五、总结

RAG 系统中的文档存储和切割,看似是数据预处理的"体力活",实际上是决定检索质量的根基。文档入库前要经历解析、切割、向量化三个关键步骤,存储层也不仅是向量库,还包括原始文档层和元数据层。粒度选择上,256~512 token 是大多数场景的甜蜜点,但需要根据文档类型和检索需求灵活调整。切割策略方面,递归字符切割是最实用的默认选择,文档结构切割适合格式规范的文档,语义切割和 Agent 式切割适合对质量有极致要求的场景。

一句话总结:切割策略没有“银弹”,只有最适合你场景的解。先跑基线,再迭代优化。


Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐