L2-1:RAG 大文档怎么喂给AI?文本分块的切分艺术
RAG 通关系列文章
第2关|大文档怎么喂给AI?文本分块的切分艺术
第3关|把文字变成数字:向量化到底是什么魔法
第4关|存得进还要找得出:向量库索引构建实战
第5关|AI怎么“懂”你的问题?语义向量化核心解密
第6关|Milvus、Chroma、Weaviate…向量库到底选哪个?
第7关|大海捞针不迷路:向量召回策略全解析
第8关|召回只是第一步,Rerank才是精准命中的关键
第9关|终极一击:如何把检索结果组装成高质量Prompt


🎯 学习价值
读者学完这9关后,将能够:
1.理解RAG全流程原理
2.掌握每个环节的核心技术
3.避开常见坑点
4.独立构建生产级RAG系统
你有没有遇到过这样的情况:辛辛苦苦把公司几百页的技术文档导入了RAG系统,心想这下知识库齐活了。结果一测试,问”Redis怎么配置集群”,系统给你召回了一段讲Redis安装,另一段讲Java代码规范,还有一段是”3.2.1 章节目录”——完全答非所问。
你可能会想:是不是向量化有问题?是不是召回策略不够好?
但打开向量库一看,傻眼了——文档被切成了几千个碎片,每个碎片只有一两句话,上下文全丢了;有些碎片干脆把”Redis集群”这个词拆成了”Redis”和”集群”两半,分别存到了不同的块里。
这就是文本分块的问题。
为什么分块这么重要?
先给你讲个我亲历的案例。
在以前为某公司搭建了一个法律文档问答系统,用了5万份合同、法律条文。团队把文档按每500字符一切,想着”小块更精准”。结果用户问”合同违约金怎么算”,系统召回了三段内容:
块1:...违约金按照实际损失计算,但不得超过合同总金额的30%...
块2:...计算方式为:违约金 = 损失金额 × 违约系数...
块3:...甲方未按时付款的,应支付违约金...
看起来都对,但问题来了:块1说的是”合同违约金上限”,块2说的是”计算公式”,块3说的是”甲方违约情形”。这三段来自同一份文档的不同章节,本来应该作为一个完整的答案,但被切散了,检索时虽然都召回了,却因为顺序和权重问题,LLM最终生成的答案支离破碎。
更糟糕的是,还有个块4:
块4:...租赁合同的违约金为月租金的200%,计算方式见附件三...
这个块被错误地召回了,因为它也包含”违约金”和”计算”两个关键词。但这是租赁合同的条款,和用户问的采购合同完全是两码事。
问题出在哪?分块策略太粗暴。500字符一切,不考虑段落、句子边界,更不考虑文档结构。结果语义被切断,噪音被引入,召回质量直线下降。
分块在整个RAG流程中的位置
分块是RAG流程中承上启下的关键环节:
向上承接数据清洗:清洗后的文档需要切分成合适的块才能向量化
向下影响向量检索:块的大小、质量直接决定召回的准确度
最终影响答案生成:块是否完整、是否包含足够的上下文,决定LLM能不能生成好答案
可以说,分块策略选得对不对,直接决定了你的RAG系统是”精准狙击”还是”撒网捕鱼”。
四种分块策略,各有千秋
分块策略没有绝对的好坏,关键看你的文档类型和应用场景。在这里我一个一个讲下。
策略一:固定长度分块——简单但粗暴
最原始的方法:按字符数或token数直接切。
from langchain.text_splitter import CharacterTextSplitter
splitter = CharacterTextSplitter(
chunk_size=500, # 每500个字符切一块
chunk_overlap=50, # 相邻块重叠50个字符
separator='\n\n' # 按段落切
)
chunks = splitter.split_text(text)
这个方法有个致命问题:它会在第500个字符处”咔嚓”一刀,不管那里是句子中间还是词中间。
举个真实的例子:
原文:"检索增强生成(RAG)是一种结合检索和生成的AI技术,它通过从知识库中检索相关文档来增强大语言模型的生成能力。"
分块后:
块1:"检索增强生成(RAG)是一种结合检索和生成的AI技术,它通过从知识库中检索相关文档来增强大语言模型的生成能"
块2:"力。"
“生成能力”这个词被切成了”生成能”和”力”两部分。向量化后,这两个块生成的向量都和原意相差甚远,检索时根本匹配不上。
什么时候用?
-
• 快速原型验证 -
• 文档格式比较统一、段落边界清晰固定的场景 -
• 作为baseline对比其他策略
什么时候别用?(基本就是除了上面那三种)
-
• 专业术语多的文档(医疗、法律、技术) -
• 代码文件 -
• 语义密度高的内容
策略二:递归字符分块——LangChain的默认推荐
这个策略聪明多了。它不是”一刀切”,而是按优先级依次尝试不同的分隔符:
优先级1:按段落切(\n\n)
如果还是太大 → 优先级2:按句子切(\n)
还是太大 → 优先级3:按词语切(空格)
还是太大 → 优先级4:按字符切
这样,它会优先在语义边界处切分,尽量保持句子和段落的完整性。
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=700,
chunk_overlap=100,
separators=["\n\n", "\n", "。", "!", "?", " ", ""]
)
chunks = splitter.split_text(text)
注意中文场景,我加了。!?作为句子分隔符,比英文的句号更准确。
实际效果对比:
还是刚才那段话,用递归分块:
块1:"检索增强生成(RAG)是一种结合检索和生成的AI技术,它通过从知识库中检索相关文档来增强大语言模型的生成能力。"
完整的句子被保留下来了。向量化后,这个块能准确匹配到”RAG技术”、”检索增强生成”等相关查询。
什么时候用?
-
• 通用文本(新闻、博客、文档) -
• 没有特殊结构的文档 -
• 作为默认分块策略
策略三:按文档结构分块——结构化文档的最佳选择
如果你的文档有明确的层级结构(Markdown的标题、HTML的h1/h2、PDF的章节),那就应该利用这个结构。
Markdown文档:
from langchain.text_splitter import MarkdownTextSplitter
splitter = MarkdownTextSplitter(
chunk_size=500,
chunk_overlap=50
)
chunks = splitter.split_text(markdown_text)
它会尊重Markdown的标题结构,让每个标题下的内容成为相对独立的块。
HTML文档:
from langchain.text_splitter import HTMLHeaderTextSplitter
splitter = HTMLHeaderTextSplitter(
headers_to_split_on=[
("h1", "header1"),
("h2", "header2"),
("h3", "header3"),
]
)
chunks = splitter.split_text(html_text)
这样,一个h2标题下的所有内容会被归为一个块,标题和内容不会被拆散。
实际案例:
我有一次处理企业内部Wiki,文档是Markdown格式的。一开始用递归分块,结果”## 3.2 Redis集群配置”这个标题和下面的内容被切开了。检索”Redis集群怎么配置”时,召回了内容块,但标题块没召回,导致答案缺乏上下文。
后来改用MarkdownTextSplitter,每个标题+内容作为一个整体,召回质量立刻提升。
策略四:代码语法感知分块——程序员的福音
代码文件绝对不能乱切。你见过把一个函数切成两半的吗?切完后上半部分是函数定义,下半部分是函数体,检索时匹配到函数体,但不知道是哪个函数——完全没意义。
LangChain支持按编程语言语法切分,自动识别函数、类等语法边界:
from langchain.text_splitter import RecursiveCharacterTextSplitter, Language
# Python代码
py_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=300,
chunk_overlap=0
)
# JavaScript代码
js_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.JS,
chunk_size=300,
chunk_overlap=0
)
chunks = py_splitter.split_text(python_code)
目前支持26种编程语言:Python、JavaScript、Java、Go、Rust、C++、TypeScript、SQL、HTML、CSS…
代码分块的特殊性:
-
1. overlap通常设为0:代码的语义边界很清晰,overlap反而会引入冗余 -
2. chunk_size要小:一个函数通常200-500 tokens就够,太大的块会让检索变模糊 -
3. 保留元数据:代码块最好带上文件名、函数名,方便追溯
chunk_size怎么选?这是个艺术
这是被问到最多的问题。结合我的实际经验,给你一个实操指南:
根据文档类型选择:
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
根据嵌入模型调整:
如果你用的是OpenAI的text-embedding-3-small,它的上下文窗口是8191 tokens。但并不意味着你的chunk_size可以设到8000——那样检索时会召回一大块,里面大部分内容可能和问题无关。
一般建议chunk_size不要超过嵌入模型窗口的1/10到1/8,这样召回的块更精准。
根据召回效果调优:
没有万能公式,最好的办法是A/B测试:
-
1. 准备一批测试查询(比如100个) -
2. 用不同的chunk_size分别构建向量库 -
3. 对比召回的准确率、召回率 -
4. 选择效果最好的参数
我的经验:512 tokens + 15% overlap是一个不错的起点,适合大多数场景。
三大分块陷阱,一个比一个致命
陷阱一:语义切断,专业术语被腰斩
这是我见过最惨的案例。一个医疗问答系统,文档里有大量医学术语,比如”糖化血红蛋白测定”。用固定长度分块后,这个词被切成了:
块1:"...糖化血红蛋白测"
块2:"定..."
用户问”糖化血红蛋白怎么测”,系统召回的是”糖化血红蛋白测”这个块,但后面被切掉了,LLM生成的答案支离破碎。
怎么避免?
-
• 用递归分块,让它先尝试按句子切 -
• 对专业术语多的文档,把overlap提高到25-30% -
• 考虑Sentence Window策略:检索时用小块,但返回时带上父块的上下文
# Sentence Window实现思路
def get_context_window(chunk_id, all_chunks, window_size=2):
"""获取一个块周围的上下文窗口"""
start = max(0, chunk_id - window_size)
end = min(len(all_chunks), chunk_id + window_size + 1)
return all_chunks[start:end]
陷阱二:chunk_size一刀切,不分文档类型
有个团队把所有文档——代码、Markdown、PDF、HTML——统统用chunk_size=500切。结果代码被切成单字符没了意义,Markdown的标题和内容被拆散,HTML的标签残留把向量”毒化”了。
怎么避免?
根据文档类型选择不同的分块策略:
def get_splitter_for_file(filepath):
"""根据文件类型返回合适的分块器"""
ext = filepath.split('.')[-1].lower()
# 代码文件
if ext in ['py', 'js', 'java', 'go', 'rs']:
lang_map = {
'py': Language.PYTHON,
'js': Language.JS,
'java': Language.JAVA,
}
return RecursiveCharacterTextSplitter.from_language(
language=lang_map.get(ext, Language.PYTHON),
chunk_size=300,
chunk_overlap=0
)
# Markdown文档
elif ext in ['md', 'markdown']:
return MarkdownTextSplitter(
chunk_size=500,
chunk_overlap=50
)
# HTML文档
elif ext in ['html', 'htm']:
return HTMLHeaderTextSplitter(
headers_to_split_on=[("h1", "h1"), ("h2", "h2")]
)
# 通用文本
else:
return RecursiveCharacterTextSplitter(
chunk_size=700,
chunk_overlap=100
)
陷阱三:忽略文档结构,标题和内容分家
前面提到的法律文档案例,问题就出在这里。文档有明确的章节结构(第一章、第二章…),但分块时完全忽略了这些结构,导致召回时只能拿到孤立的内容片段。
怎么避免?
-
• Markdown文档优先用MarkdownTextSplitter -
• HTML文档优先用HTMLHeaderTextSplitter -
• PDF文档如果有目录,可以用目录信息指导分块 -
• 没有结构信息的文档,考虑用NLP工具自动识别段落结构
# 带标题元数据的分块
from langchain.text_splitter import MarkdownTextSplitter
splitter = MarkdownTextSplitter(chunk_size=500)
docs = splitter.create_documents([markdown_text])
# 每个文档块会自动带上标题元数据
for doc in docs:
print(f"标题: {doc.metadata.get('headers', [])}")
print(f"内容: {doc.page_content[:50]}...")
分块后的元数据,别浪费了
很多人分完块就把元数据扔了,这是巨大的浪费。
元数据可以帮你:
1. 过滤召回结果
# 只召回某个章节的内容
retriever = vectorstore.as_retriever(
search_kwargs={
"k": 5,
"filter": {"chapter": "第3章"}
}
)
2. 排序优化
召回后,可以按元数据排序:最新的文档排前面、重要章节排前面。
3. 可追溯性
回答里可以标注”根据《XX文档》第X章”,用户可以溯源验证。
应该保留哪些元数据?
-
• 文档来源(文件名、URL) -
• 章节信息(标题、层级) -
• 时间戳(创建时间、更新时间) -
• 文档类型(代码、文档、FAQ) -
• 自定义标签(重要、最新、已验证)
一个完整的分块Pipeline
最后,给你一个我经常用的可以直接上生产的分块Pipeline代码,覆盖了常见的文档类型,强烈建议收藏备用:
from langchain.text_splitter import (
RecursiveCharacterTextSplitter,
MarkdownTextSplitter,
HTMLHeaderTextSplitter,
Language
)
from langchain.schema import Document
from typing import List, Dict, Optional
import os
class SmartSplitter:
"""智能分块器:根据文档类型自动选择策略"""
def __init__(self):
self.splitter_map = {}
def split_document(
self,
content: str,
filepath: str,
metadata: Optional[Dict] = None
) -> List[Document]:
"""根据文件类型智能分块"""
ext = os.path.splitext(filepath)[1].lower()
metadata = metadata or {}
metadata['source'] = filepath
# 代码文件
if ext in ['.py', '.js', '.java', '.go', '.rs', '.ts']:
chunks = self._split_code(content, ext, metadata)
# Markdown
elif ext in ['.md', '.markdown']:
chunks = self._split_markdown(content, metadata)
# HTML
elif ext in ['.html', '.htm']:
chunks = self._split_html(content, metadata)
# 通用文本
else:
chunks = self._split_text(content, metadata)
return chunks
def _split_code(self, code: str, ext: str, metadata: Dict) -> List[Document]:
"""代码分块"""
lang_map = {
'.py': Language.PYTHON,
'.js': Language.JS,
'.java': Language.JAVA,
'.go': Language.GO,
'.ts': Language.TS,
}
splitter = RecursiveCharacterTextSplitter.from_language(
language=lang_map.get(ext, Language.PYTHON),
chunk_size=300,
chunk_overlap=0
)
chunks = splitter.split_text(code)
return [
Document(page_content=chunk, metadata={**metadata, 'chunk_id': i})
for i, chunk in enumerate(chunks)
]
def _split_markdown(self, text: str, metadata: Dict) -> List[Document]:
"""Markdown分块"""
splitter = MarkdownTextSplitter(
chunk_size=500,
chunk_overlap=50
)
chunks = splitter.split_text(text)
return [
Document(page_content=chunk, metadata={**metadata, 'chunk_id': i})
for i, chunk in enumerate(chunks)
]
def _split_html(self, text: str, metadata: Dict) -> List[Document]:
"""HTML分块"""
splitter = HTMLHeaderTextSplitter(
headers_to_split_on=[
("h1", "h1"),
("h2", "h2"),
("h3", "h3"),
]
)
chunks = splitter.split_text(text)
# 添加额外元数据
for i, chunk in enumerate(chunks):
chunk.metadata.update(metadata)
chunk.metadata['chunk_id'] = i
return chunks
def _split_text(self, text: str, metadata: Dict) -> List[Document]:
"""通用文本分块"""
splitter = RecursiveCharacterTextSplitter(
chunk_size=700,
chunk_overlap=100,
separators=["\n\n", "\n", "。", "!", "?", " ", ""]
)
chunks = splitter.split_text(text)
return [
Document(page_content=chunk, metadata={**metadata, 'chunk_id': i})
for i, chunk in enumerate(chunks)
]
# 使用示例
if __name__ == "__main__":
splitter = SmartSplitter()
# 测试Python代码
py_code = """
def calculate_rag_score(query, documents):
'''计算RAG相关性得分'''
scores = []
for doc in documents:
score = cosine_similarity(query, doc)
scores.append(score)
return sorted(scores, reverse=True)
"""
chunks = splitter.split_document(py_code, "test.py")
print(f"Python代码分块:{len(chunks)}个块")
for chunk in chunks:
print(f"块{chunk.metadata['chunk_id']}: {chunk.page_content[:50]}...")
# 测试Markdown
md_text = """
# RAG技术详解
## 什么是RAG
RAG是检索增强生成的缩写...
## RAG的优势
RAG有以下优势...
"""
chunks = splitter.split_document(md_text, "test.md")
print(f"\nMarkdown分块:{len(chunks)}个块")
本关小结
文本分块是RAG系统的”手术刀”,切得好,召回精准;切不好,就是乱砍。
记住这三点:
-
1. 策略要匹配文档类型——代码按语法切、Markdown按结构切、通用文本用递归切 -
2. 参数要根据场景调优——512 tokens + 15% overlap是不错的起点,但不是万能公式 -
3. 避免语义切断——专业术语多的文档,overlap要放大;结构化文档,优先用结构感知分块器
下一关,我们讲向量化。你可能会问”向量化不就是调用个API吗”,但这里面的门道比你想象的多:选哪个模型?中英文怎么处理?维度越高越好吗?
这些问题,下一关我们细细讲来:第3关|把文字变成数字:向量化到底是什么魔法。
思考题:
-
1. 你的项目里,文档类型有哪些?每种类型应该用什么分块策略? -
2. 你有没有遇到过召回的内容和问题相关但不完整的情况?是不是分块切断了上下文?
欢迎在评论区分享你的经验。
夜雨聆风