微信扫码
添加专属顾问
我要投稿
RAG系统效果不佳?可能是文档分块出了问题!这份实战指南教你从基础分块到智能切割,为RAG系统打下坚实基础。 核心内容: 1. 分块的本质与必要性:平衡上下文完整性与信息密度 2. 基础分块策略详解:固定长度分块及其适用场景 3. 代码实践与参数调优:chunk_size和chunk_overlap的实战应用
你是否也遇到过这样的情况:RAG系统里的LLM明明很强大,Prompt也精心调校过,但最终的问答效果就是不尽如人意?答案时常上下文不全,甚至出现事实性错误。
我们排查了检索算法,优化了Embedding模型,却往往忽略了数据进入向量库之前的最关键一步:文档分块。
不恰当的分块,就像是给模型提供了一堆被打乱顺序、信息残缺的“坏数据”。模型能力再强,也无法从支离破碎的知识中推理出正确、完整的答案。可以说,分块的质量,直接决定了RAG系统性能的下限。
这篇文章,我们就来深入聊聊这个基础却至关重要的环节。本文不谈空泛的理论,而是聚焦于各类分块策略的实战代码和经验总结,希望能帮你为你的RAG系统打下最坚实的地基。
分块的必要性源于两个核心限制:
理想的分块是在上下文完整性与信息密度之间找到最佳平衡。chunk_size
和 chunk_overlap
是调控这一平衡的基础参数。chunk_overlap
通过在相邻块之间保留部分重复文本,确保了跨越块边界的语义连续性。
这是最直接的方法,按预设的字符数进行切割。它不考虑文本的任何逻辑结构,实现简单,但容易破坏语义完整性。
chunk_size
切分文本。•适用场景:结构性弱的纯文本,或对语义要求不高的预处理阶段。from langchain_text_splitters importCharacterTextSplitter
sample_text =(
"LangChain was created by Harrison Chase in 2022. It provides a framework for developing applications "
"powered by language models. The library is known for its modularity and ease of use. "
"One of its key components is the TextSplitter class, which helps in document chunking."
)
text_splitter =CharacterTextSplitter(
separator =" ",# 按空格分割
chunk_size=100,# 增大块大小
chunk_overlap=20,# 调整重叠比例
length_function=len,
)
docs = text_splitter.create_documents([sample_text])
for i, doc in enumerate(docs):
print(f"--- Chunk {i+1} ---")
print(doc.page_content)
LangChain推荐的通用策略。它按预设的字符列表(如 ["\n\n", "\n", " ", ""]
)进行递归分割,尝试优先保留段落、句子等逻辑单元的完整性。
from langchain_text_splitters importRecursiveCharacterTextSplitter
# 使用与上文相同的 sample_text
text_splitter =RecursiveCharacterTextSplitter(
chunk_size=100,
chunk_overlap=20,
# 默认分隔符为 ["\n\n", "\n", " ", ""]
)
docs = text_splitter.create_documents([sample_text])
for i, doc in enumerate(docs):
print(f"--- Chunk {i+1} ---")
print(doc.page_content)
参数调优说明:对于固定长度和递归分块,chunk_size
和 chunk_overlap
的设置至关重要:
chunk_size
: 决定了每个块的大小。块太小,可能导致上下文信息不足,模型无法充分理解;块太大,则可能引入过多噪声,降低检索的信噪比,并增加API调用成本。通常根据嵌入模型的最佳输入长度和文本特性来选择,例如 256, 512, 1024。•chunk_overlap
: 决定了相邻块之间的重叠字符数。设置合理的重叠(如 chunk_size
的10%-20%)可以有效防止在块边界处切断完整的语义单元(如一个长句子),是保证语义连续性的关键。以句子为最小单元进行组合,确保了最基本的语义完整性。
import nltk
try:
nltk.data.find('tokenizers/punkt')
except nltk.downloader.DownloadError:
nltk.download('punkt')
from nltk.tokenize import sent_tokenize
def chunk_by_sentences(text, max_chars=500, overlap_sentences=1):
sentences = sent_tokenize(text)
chunks =[]
current_chunk =""
for i, sentence in enumerate(sentences):
if len(current_chunk)+ len(sentence)<= max_chars:
current_chunk +=" "+ sentence
else:
chunks.append(current_chunk.strip())
# 创建重叠
start_index = max(0, i - overlap_sentences)
current_chunk =" ".join(sentences[start_index:i+1])
if current_chunk:
chunks.append(current_chunk.strip())
return chunks
long_text ="This is the first sentence. This is the second sentence, which is a bit longer. Now we have a third one. The fourth sentence follows. Finally, the fifth sentence concludes this paragraph."
chunks = chunk_by_sentences(long_text, max_chars=100)
for i, chunk in enumerate(chunks):
print(f"--- Chunk {i+1} ---")
print(chunk)
注意事项:语言模型的选择许多标准库的默认配置是为英文设计的。例如,nltk.tokenize.sent_tokenize
默认使用基于英文训练的Punkt模型进行分句。如果直接用于处理中文文本,会因无法识别中文标点而导致分句失败。 处理中文时,必须采用适合中文的分割方法,例如:
利用文档固有的结构信息(如标题、列表、对话轮次)作为分块边界,这种方法逻辑性强,能更好地保留上下文。
from langchain_text_splitters importMarkdownHeaderTextSplitter
markdown_document ="""
# Chapter 1: The Beginning
## Section 1.1: The Old World
Thisis the story of a time long past.
## Section 1.2: A New Hope
A new hero emerges.
# Chapter 2: The Journey
## Section 2.1: The Call to Adventure
The hero receives a mysterious call.
"""
headers_to_split_on =[
("#","Header 1"),
("##","Header 2"),
]
markdown_splitter =MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
md_header_splits = markdown_splitter.split_text(markdown_document)
for split in md_header_splits:
print(f"Metadata: {split.metadata}")
print(split.page_content)
print("-"*20)
dialogue =[
"Alice: Hi, I'm having trouble with my order.",
"Bot: I can help with that. What's your order number?",
"Alice: It's 12345.",
"Alice: I haven't received any shipping updates.",
"Bot: Let me check... It seems your order was shipped yesterday.",
"Alice: Oh, great! Thank you.",
]
def chunk_dialogue(dialogue_lines, max_turns_per_chunk=3):
chunks =[]
for i in range(0, len(dialogue_lines), max_turns_per_chunk):
chunk ="\n".join(dialogue_lines[i:i + max_turns_per_chunk])
chunks.append(chunk)
return chunks
chunks = chunk_dialogue(dialogue)
for i, chunk in enumerate(chunks):
print(f"--- Chunk {i+1} ---")
print(chunk)
这类方法超越了文本的物理结构,根据内容的语义含义进行切分。
import os
from langchain_experimental.text_splitter importSemanticChunker
from langchain_huggingface importHuggingFaceEmbeddings
os.environ["TOKENIZERS_PARALLELISM"]="false"
embeddings =HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
# 创建 SemanticChunker 实例
# LangChain 的 SemanticChunker 默认使用 percentile 阈值
# 可以尝试不同的 breakpoint_threshold_type: "percentile", "standard_deviation", "interquartile", "gradient"
text_splitter =SemanticChunker(
embeddings,
breakpoint_threshold_type="percentile",# 使用百分位作为阈值类型
breakpoint_threshold_amount=70# 设置阈值为80
)
print("SemanticChunker configured.")
print("-"*50)
long_text =(
"The Wright brothers, Orville and Wilbur, were two American aviation pioneers "
"generally credited with inventing, building, and flying the world's first successful motor-operated airplane. "
"They made the first controlled, sustained flight of a powered, heavier-than-air aircraft on December 17, 1903. "
"In the following years, they continued to develop their aircraft. "
"Switching topics completely, let's talk about cooking. "
"A good pizza starts with a perfect dough, which needs yeast, flour, water, and salt. "
"The sauce is typically tomato-based, seasoned with herbs like oregano and basil. "
"Toppings can vary from simple mozzarella to a wide range of meats and vegetables. "
"Finally, let's consider the solar system. "
"It is a gravitationally bound system of the Sun and the objects that orbit it. "
"The largest objects are the eight planets, in order from the Sun: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune."
)
docs = text_splitter.create_documents([long_text])
for i, doc in enumerate(docs):
print(f"--- Chunk {i+1} ---")
print(doc.page_content)
print()
参数调优说明:SemanticChunker
的效果高度依赖 breakpoint_threshold_amount
这个阈值参数。
import numpy as np
import re
from sklearn.feature_extraction.text importCountVectorizer
from sklearn.decomposition importLatentDirichletAllocation
import nltk
from nltk.corpus import stopwords
try:
stopwords.words('english')
exceptLookupError:
nltk.download('stopwords')
def lda_topic_chunking(text: str, n_topics:int=3)-> list[str]:
"""
基于LDA主题模型的分块函数。
:param text:需要分块的原始文本。
:param n_topics:期望从文本中发现的主题数量。
:return:文本块列表。
"""
# 1. 文本预处理 和 重新定义“文档”单元
# 将文本按段落分割,一个段落作为一个“文档”
paragraphs =[p.strip()for p in text.split('\n\n')if p.strip()]
if len(paragraphs)<=1:
return[text]# 如果只有一个段落,无需分割
# 简单的文本清洗:移除特殊字符,转为小写
cleaned_paragraphs =[re.sub(r'[^a-zA-Z\s]','', p).lower()for p in paragraphs]
# 2. 词袋模型 + 去停用词
# min_df=1 因为在单文档上下文中,一个词只在一个段落出现也是有意义的
vectorizer =CountVectorizer(min_df=1, stop_words=stopwords.words('english'))
X = vectorizer.fit_transform(cleaned_paragraphs)
# 如果词汇表为空,则无法进行LDA,直接返回段落
if X.shape[1]==0:
return paragraphs
# 3. LDA 主题建模
# n_components 是正确的参数名
lda =LatentDirichletAllocation(n_components=n_topics, random_state=42)
lda.fit(X)
# 4. 计算每个段落的主导主题
topic_dist = lda.transform(X)
# np.argmax 返回每行(每个段落)最大值的索引(即主导主题的编号)
dominant_topics = np.argmax(topic_dist, axis=1)
# 5. 实现正确的分块逻辑:在主题变化时切分
chunks =[]
current_chunk_paragraphs =[]
# 第一个段落的主题作为起始主题
current_topic = dominant_topics[0]
for i, paragraph in enumerate(paragraphs):
if dominant_topics[i]== current_topic:
# 如果主题相同,则加入当前块
current_chunk_paragraphs.append(paragraph)
else:
# 如果主题变化,则保存上一个块,并开始一个新块
chunks.append("\n\n".join(current_chunk_paragraphs))
current_chunk_paragraphs =[paragraph]
current_topic = dominant_topics[i]
# 添加最后一个块
chunks.append("\n\n".join(current_chunk_paragraphs))
return chunks
document ="""
...............................
"""
final_chunks = lda_topic_chunking(document, n_topics=3)
print(f"文档被分成了 {len(final_chunks)} 个块:")
print("="*80)
for i, chunk in enumerate(final_chunks):
print(f"--- 块 {i+1} ---")
print(chunk)
print("-"*80)
注意事项:基于主题的分块在实践中需要谨慎使用,因为它存在一些固有挑战:
n_topics
)等超参数,而这往往难以准确估计。因此,当直接应用此类方法时,可能会发现分出的块在逻辑上不连贯,或者与实际主题边界不符。 此方法更适合作为一种探索性工具,在主题边界清晰的长文档上使用,并需要进行充分的实验验证。
from langchain.embeddings importOpenAIEmbeddings
from langchain_text_splitters importRecursiveCharacterTextSplitter
from langchain.retrievers importParentDocumentRetriever
from langchain_community.document_loaders importTextLoader
from langchain_chroma importChroma
from langchain.storage importInMemoryStore
# 假设 Document 已被导入 from langchain_core.documents import Document
# docs = [Document(page_content="......")] # 假设这是你的文档
embeddings = OpenAIEmbeddings()
"split_parents") vectorstore = Chroma(embedding_function=embeddings, collection_name=
store = InMemoryStore()
# parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=400)
# retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=store,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# retriever.add_documents(docs)
"query") sub_docs = vectorstore.similarity_search(
"query") retrieved_docs = retriever.get_relevant_documents(
print(retrieved_docs[0].page_content)
import textwrap
from langchain_openai importChatOpenAI
from langchain.prompts importPromptTemplate
from langchain_core.output_parsers importPydanticOutputParser
from pydantic importBaseModel,Field
from typing importList
classKnowledgeChunk(BaseModel):
chunk_title: str =Field(description="这个知识块的简洁明了的标题")
chunk_text: str =Field(description="从原文中提取并重组的、自包含的文本内容")
representative_question: str =Field(description="一个可以被这个块内容直接回答的典型问题")
classChunkList(BaseModel):
chunks:List[KnowledgeChunk]
parser =PydanticOutputParser(pydantic_object=ChunkList)
prompt_template ="""
【角色】:你是一位顶尖的科学文档分析师,你的任务是将复杂的科学文本段落,分解成一组核心的、自包含的“知识块(KnowledgeChunks)”。
【核心任务】:阅读用户提供的文本段落,识别其中包含的独立的核心概念。
【规则】:
1.**自包含性**:每个“知识块”必须是“自包含的(self-contained)”。
2.**概念单一性**:每个“知识块”应该只围绕一个核心概念。
3.**提取并重组**:从原文中提取与该核心概念相关的所有句子,并将它们组合成一个通顺、连贯的段落。
4.**遵循格式**:严格按照下面的JSON格式指令来构建你的输出。
{format_instructions}
【待处理文本】:
{paragraph_text}
"""
prompt =PromptTemplate(
template=prompt_template,
input_variables=["paragraph_text"],
partial_variables={"format_instructions": parser.get_format_instructions()},
)
# 以下 model 定义需要根据实际情况修改
# model = ChatOpenAI(model="Qwen3-235B-A22B", base_url="http://10.1.18.99:8089/v1",api_key="sk-",temperature=0.0).bind(
# response_format={"type": "json_object"}
# )
# chain = prompt | model | parser
def agentic_chunker(paragraph_text: str)->List[KnowledgeChunk]:
try:
# result: ChunkList = chain.invoke({"paragraph_text": paragraph_text})
# return result.chunks
# 模拟返回,因为无法执行 chain.invoke
print("模拟 agentic_chunker 调用")
return[]
exceptExceptionas e:
return[]
document ="""
水循环,也称为水文循环,描述了水在地球表面、之上和之下的连续运动。这个循环至关重要,因为它确保了水对所有生命形式的可用性。循环的第一阶段是蒸发,这是水从海洋、湖泊和河流等表面转化为水蒸气并上升到大气中的过程,植物的蒸腾作用也对此有贡献。当温暖、潮湿的空气上升并冷却时,会发生第二阶段:凝结。在这个阶段,水蒸气变回微小的液态水滴,形成云。随着这些水滴碰撞并增长,它们最终变得足够重,以降水的形式落回地球,这是第三阶段,形式可以是雨、雪、雨夹雪或冰雹。最后,一旦水到达地面,它可能以多种方式移动,构成了第四个阶段:汇集。一些水会作为地表径流流入河流、湖泊和海洋。其他水则会渗入地下,成为地下水,最终也可能返回地表或海洋,从而重新开始整个循环。
"""
paragraphs = document.strip().split('\n\n')
all_chunks =[]
for i, para in enumerate(paragraphs):
print(f"--- 正在处理第 {i+1}/{len(paragraphs)} 段 ---")
chunks_from_para = agentic_chunker(para)# 调用新函数
if chunks_from_para:
all_chunks.extend(chunks_from_para)
print(f"成功从该段落中提取了 {len(chunks_from_para)} 个知识块。")
ifnot all_chunks:
print("未能生成任何知识块。")
else:
for i, chunk in enumerate(all_chunks):
print(f"【知识块 {i+1}】")
print(f" - 标题: {chunk.chunk_title}")
print(f" - 代表性问题: {chunk.representative_question}")
print(f" - 文本内容:")
wrapped_text = textwrap.fill(chunk.chunk_text, width=78, initial_indent=' ', subsequent_indent=' ')
print(wrapped_text)
print("-"*80)
在实践中,单一策略往往难以应对所有情况。混合分块结合了多种策略的优点,是一种非常实用的技巧。
代码示例 (结构化 + 递归混合):
from langchain_text_splitters importMarkdownHeaderTextSplitter,RecursiveCharacterTextSplitter
from langchain_core.documents importDocument
markdown_document ="""
# 第一章:公司简介
本公司成立于2017年,致力于推动人工智能技术的创新与应用。我们的使命是通过先进的AI解决方案,为各行各业赋能,创造更大的价值。我们拥有一支由顶尖科学家和工程师组成的团队,专注于深度学习、自然语言处理和计算机视觉等前沿领域。
## 1.1 发展历程
公司自创立以来,经历了快速的发展。从最初的几人团队,到如今拥有数百名员工的规模,我们始终坚持技术驱动、客户至上的原则。
# 第二章:核心技术
本章将详细介绍我们的核心技术。我们的技术框架基于先进的分布式计算理念,确保了高可用性和可扩展性。系统的核心是一个自主研发的深度学习引擎,它能够处理海量数据并进行高效的模型训练。这个引擎支持多种神经网络结构,包括卷积神经网络(CNNs)用于图像识别,以及循环神经网络(RNNs)和Transformer模型用于自然语言理解。我们特别优化了Transformer架构,提出了一种名为“注意力压缩”的新机制,该机制在保持模型性能的同时,显著减少了计算资源的需求。这一创新使得我们能够在边缘设备上部署复杂的AI模型,为物联网(IoT)应用场景提供了强大的支持。不仅如此,我们还构建了一套完整的数据处理流水线,从数据采集、清洗、标注到最终的模型评估,全程自动化,极大地提升了研发效率。这套流水线处理的数据量已达到PB级别,每日处理的请求数超过十亿次。为了保障数据安全,我们采用了端到-端加密和联邦学习等多种先进技术,确保客户数据的隐私和安全。我们相信,强大的技术实力是公司发展的基石,也是我们服务客户的信心所在。我们不断探索技术的边界,致力于将最新的科研成果转化为可靠、易用的产品和服务,帮助客户在激烈的市场竞争中保持领先地位。
## 2.1 技术原理
我们的技术原理融合了统计学、机器学习和运筹学等多个学科的知识。
# 第三章:未来展望
展望未来,我们将继续加大在人工智能领域的投入,探索通用人工智能(AGI)的可能性。
"""
# 定义混合分块函数
def hybrid_chunking_optimized(
markdown_document: str,
coarse_chunk_threshold:int=400,# 定义一个粗粒度块的大小阈值
fine_chunk_size:int=100,# 定义精细切分的目标大小
fine_chunk_overlap:int=20# 定义精细切分的重叠大小
)-> list[Document]:
"""
使用结构化+递归的混合策略,并确保元数据在二次切分中得以保留。
:param markdown_document:完整的Markdown格式文档。
:param coarse_chunk_threshold:粗粒度块的长度阈值,超过则进行二次切分。
:param fine_chunk_size:二次切分时,每个小块的目标长度。
:param fine_chunk_overlap:二次切分时,小块间的重叠长度。
:return:Document对象的列表,包含最终的所有分块。
"""
# 宏观的结构化分块 (按H1和H2标题)
headers_to_split_on =[("#","Header 1"),("##","Header 2")]
markdown_splitter =MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
coarse_chunks = markdown_splitter.split_text(markdown_document)
# 初始化精细分块器
fine_splitter =RecursiveCharacterTextSplitter(
chunk_size=fine_chunk_size,
chunk_overlap=fine_chunk_overlap
)
# 迭代处理,对过大的块进行二次切分
final_chunks =[]
for chunk in coarse_chunks:
# 检查块的文本长度是否超过阈值
if len(chunk.page_content)> coarse_chunk_threshold:
print(f"--- 块过大 (长度: {len(chunk.page_content)}), 正在进行二次切分... ---")
print(f" 原始元数据: {chunk.metadata}")
finer_chunks = fine_splitter.split_documents([chunk])
final_chunks.extend(finer_chunks)
else:
final_chunks.append(chunk)
return final_chunks
final_chunks = hybrid_chunking_optimized(markdown_document)
for i, chunk in enumerate(final_chunks):
print(f"--- 最终块 {i+1} (长度: {len(chunk.page_content)}) ---")
print(f"元数据: {chunk.metadata}")
print("文本内容:")
print(chunk.page_content)
print("-"*80)
面对众多策略,合理的选择路径比逐一尝试更重要。个人建议遵循以下分层决策框架,从简单高效的基准开始,逐步引入更复杂的策略。
第一步:从基准策略开始
RecursiveCharacterTextSplitter
无论处理何种文本,这都是最稳妥的起点。它在通用性、简单性和效果之间取得了很好的平衡。首先使用它建立一个性能基线。第二步:检查结构化特征
MarkdownHeaderTextSplitter
等相应的结构化分块方法。这是成本最低、收益较好的优化步骤。第三步:当精度成为瓶颈时
SemanticChunker
:适用于需要块内语义高度一致的场景。•ParentDocumentRetriever
(小-大分块):适用于既要保证检索精准度,又需要为LLM提供完整上下文的复杂问答场景。第四步:应对极端复杂的文档
MarkdownHeaderTextSplitter
进行宏观切分,再对过长的块用RecursiveCharacterTextSplitter
进行二次细分。为了方便查阅和对比,下表总结了所有讨论过的分块策略。
固定长度分块 | |||
递归分块 | |||
基于句子的分块 | |||
结构化分块 | |||
语义分块 | |||
基于主题的分块 | |||
混合分块 | |||
小-大分块 | |||
代理式分块 |
文档分块,远不止是简单的文本预处理,它深刻影响着RAG系统中信息流的质量,是典型的“细节决定成败”的环节。 通过本文的梳理,我们应形成三个核心认知:
RecursiveCharacterTextSplitter
等简单、可靠的方法入手建立基线,再根据需要逐步引入结构化、语义化乃至混合策略,是一种高效且稳健的实践路径。3.分块即“建模”:从某种意义上说,如何分块,就是你如何理解和“建模”你的知识。一个高质量的分块,本身就是对原始数据的一种结构化和语义增强。最终,高质量的分块是通往高质量生成结果的前提。掌握这项技能,是每一位RAG应用构建者提升系统性能的必经之路。
53AI,企业落地大模型首选服务商
产品:场景落地咨询+大模型应用平台+行业解决方案
承诺:免费POC验证,效果达标后再合作。零风险落地应用大模型,已交付160+中大型企业
2025-08-19
告别知识库"大海捞针"!Dify元数据过滤让RAG检索效率翻倍
2025-08-18
一图看懂传统 RAG 与 Agentic RAG 的实战差异
2025-08-18
优化 GraphRAG:LightRAG的三大改进
2025-08-17
深入解析RAG多轮会话优化:从查询重写到高级策略
2025-08-17
基于LLM知识图谱构建高精度RAG
2025-08-17
从图的视角看 RAG:GraphRAG 的工作方式与思考
2025-08-16
使用RAG构建高质量知识库(三)- 数据嵌入
2025-08-14
RAG实践技巧:将向量库降级为“语义路由器”,让答案更合理
2025-05-30
2025-06-05
2025-06-06
2025-06-05
2025-05-27
2025-06-05
2025-06-20
2025-07-15
2025-06-24
2025-06-05
2025-08-11
2025-08-05
2025-07-28
2025-07-09
2025-07-04
2025-07-01
2025-07-01
2025-07-01