RAG 灵魂拷问:Chunk 大小、父子分块与格式处理,到底该怎么选?
大家好,我是你们的老朋友,一名在代码和文字之间反复横跳的程序员。
最近很多同学在构建 RAG(检索增强生成)应用时,都会遇到一个让人头秃的问题:“我的文档切分(Chunking)策略到底对不对?”
切得太细,上下文丢失,AI 答非所问;切得太粗,噪声太多,检索精度下降,还浪费 Token。更别提那些复杂的 PDF 表格、代码片段和多级标题了。
今天,我们就把 RAG 中最核心、也最容易被忽视的环节——文档分块策略,掰开揉碎了讲清楚。我们将重点讨论 Chunk 大小的黄金法则、父子分块(Parent-Child Indexing)的妙用,以及不同文档格式的避坑指南。
为什么分块策略如此重要?
在 RAG 系统中,分块是连接“原始数据”和“向量数据库”的桥梁。
想象一下,你把一本《红楼梦》扔进搜索引擎。如果搜索条件是“林黛玉葬花”,系统返回了整本书,LLM(大语言模型)会因为上下文过长而“晕头转向”,或者因为关键信息被淹没在海量文字中而无法提取。
优秀的分块策略,旨在实现两个目标的平衡:
- 语义完整性:每个 Chunk 包含足够的信息,能独立表达一个完整的意思。
- 检索精准度:每个 Chunk 足够小且聚焦,以便向量相似度计算能精准命中。
核心概念解析:Chunk 大小与重叠
1. Chunk Size(块大小):没有绝对的标准答案
很多教程会告诉你:“固定 500 或 1000 个字符”。但这其实是个误区。
- 太小(< 200 tokens):可能切断句子或逻辑,导致语义破碎。例如,“Python 是一种…”和“…强大的编程语言”被分开,检索时可能只命中前半句,AI 无法理解全貌。
- 太大(> 2000 tokens):包含过多无关信息(噪声),稀释了关键信息的向量密度,导致排序靠后。
建议起点:通常从512 - 1024 tokens开始尝试。注意,这里说的是 Tokens 而不是字符,因为 LLM 是基于 Token 理解的。
2. Overlap(重叠窗口):防止“断章取义”
为了防止关键信息正好被切在两块的中间,我们需要设置重叠。
最佳实践:重叠大小通常设置为 Chunk 大小的10% - 20%。例如,Chunk 为 1000 tokens,Overlap 设为 100-200 tokens。
进阶策略:父子分块(Parent-Child Indexing)
这是解决“检索精度”与“上下文完整性”矛盾的神器。
什么是父子分块?
- 子块(Child Chunks):较小的文本块,用于向量检索。因为它们小且聚焦,所以能被精准匹配。
- 父块(Parent Chunks):较大的文本块(甚至整个文档章节),用于最终生成。当子块被命中时,系统不直接返回子块,而是返回它所属的父块。
工作流程图解
优势:你既享受了小切片带来的高检索命中率,又拥有了大上下文带来的高质量回答。
不同文档格式的取舍与处理
现实世界中,文档格式千奇百怪。PDF、Markdown、Code、HTML,每种都需要特殊对待。
1. PDF:最大的痛点
PDF 本质上是排版格式,而非结构格式。直接按字符切割往往会破坏表格、页眉页脚和多栏布局。
- 策略:不要直接用简单的
split_by_character。 - 工具推荐:使用专门的解析库,如
PyMuPDF、Unstructured或LlamaParse。 - 技巧:先识别文档结构(标题、段落、表格),再基于结构进行分割,而不是基于字符数。
2. Markdown / HTML:利用结构标签
这类文档自带层级结构(H1, H2, H3,<p>,<li>)。
- 策略:递归字符分割(Recursive Character Splitting)。
- 原理:优先按
\n\n(段落)分割,如果还太大,再按\n(换行),最后才按空格或字符分割。同时,保留当前的标题路径作为元数据。
3. 代码文件:保持语法完整性
代码不能随便从中间切开,否则变量定义和函数调用会分离。
- 策略:基于语法树(AST)或特定分隔符分割。
- 分隔符选择:优先按类、函数、方法分割。
- 示例:对于 Python,可以按
def或class进行初步分割。
实战演示:LangChain 中的分块策略
下面我们用 Python 和 LangChain 来演示几种常见的分块方式。
1. 基础:递归字符分割(推荐通用场景)
这是大多数情况下的首选,它会尝试保持段落和句子的完整性。
fromlangchain.text_splitterimportRecursiveCharacterTextSplitter# 模拟一段长文本text=""" RAG 系统是结合检索和生成的架构。 它首先从知识库中检索相关信息。 然后,将这些信息作为上下文提供给 LLM。 最后,LLM 生成准确的答案。 这种架构有效解决了幻觉问题。 """# 初始化分割器text_splitter=RecursiveCharacterTextSplitter(chunk_size=50,# 每个块的大小chunk_overlap=10,# 重叠大小length_function=len,# 长度计算函数separators=["\n\n","\n"," ",""]# 分割优先级)chunks=text_splitter.create_documents([text])fori,chunkinenumerate(chunks):print(f"Chunk{i+1}:{chunk.page_content}")2. 进阶:父子索引实现思路
虽然 LangChain 有高级封装,但理解底层逻辑很重要。我们可以简单模拟这个过程。
fromlangchain.text_splitterimportRecursiveCharacterTextSplitterimportuuid# 1. 定义父块分割器(大块,用于上下文)parent_splitter=RecursiveCharacterTextSplitter(chunk_size=1000,chunk_overlap=100)# 2. 定义子块分割器(小块,用于检索)child_splitter=RecursiveCharacterTextSplitter(chunk_size=200,chunk_overlap=20)# 假设这是我们的文档内容document_content="这是一篇关于人工智能的长文章..."*10# 3. 生成父块parent_docs=parent_splitter.create_documents([document_content])# 4. 为每个父块生成子块,并建立映射关系index_mapping={}# key: child_id, value: parent_docall_child_chunks=[]forparent_docinparent_docs:# 为父块生成唯一IDparent_id=str(uuid.uuid4())# 从父块内容中切分出子块child_chunks=child_splitter.split_text(parent_doc.page_content)forchild_textinchild_chunks:child_id=str(uuid.uuid4())# 存储映射:通过子块ID能找到父块index_mapping[child_id]={"parent_content":parent_doc.page_content,"parent_metadata":parent_doc.metadata}# 这里通常会将 child_text 嵌入向量并存入向量数据库# vector_db.add_texts([child_text], ids=[child_id])all_child_chunks.append(child_text)print(f"生成了{len(all_child_chunks)}个子块用于检索")print(f"建立了{len(index_mapping)}条父子映射关系")# 模拟检索后的操作# 假设检索到的最佳子块ID是 first_child_idfirst_child_id=list(index_mapping.keys())[0]retrieved_context=index_mapping[first_child_id]["parent_content"]print("\n--- 最终送给 LLM 的上下文 (父块) ---")print(retrieved_context[:100]+"...")# 打印前100字符示意总结与最佳实践建议
设计 RAG 分块策略没有“银弹”,但遵循以下原则可以少走很多弯路:
- 从递归分割开始:
RecursiveCharacterTextSplitter是最稳健的起点,适用于大多数文本。 - 重视元数据:在分块时,务必保留来源、页码、标题层级等元数据。这对于后续过滤和溯源至关重要。
- 针对格式选型:
- PDF:先用专业工具清洗结构,再分块。
- 代码:按函数/类分割。
- 问答对:如果数据本身就是 QA 格式,不要强行分割,保持一问一答的完整性。
- 尝试父子分块:如果你的应用场景对回答的完整性要求很高(如法律、医疗文档),强烈建议实施父子索引策略。
- 评估驱动优化:不要凭感觉调整 Chunk Size。建立一个小的测试集,改变 Chunk 大小,观察检索命中率(Recall)和最终回答质量(Faithfulness),用数据说话。
希望这篇博客能帮你理清 RAG 分块的思路。记住,好的数据处理是 RAG 成功的基石,花在清洗和分块上的时间,永远会在后续的模型效果中得到回报。
参考资料
- LangChain Text Splitters Documentation
- LlamaIndex Data Ingestion
- Pinecone: Chunking Strategies for RAG