为什么分块决定 RAG 质量
分块(Chunking)是 RAG 管道中被低估的环节。分块质量直接决定三件事:
- 检索精度:块太大,检索引入噪声;块太小,丢失上下文
- 生成质量:LLM 收到的上下文是否完整连贯
- Token 成本:块大小直接影响每次推理的 Token 消耗
文档 → [分块] → 嵌入 → 向量库 → 检索 → 生成
↑
这一步决定了后面所有环节的上限
策略一:固定大小分块
原理
按固定 Token/字符数切割,最简单但最粗暴。
from langchain.text_splitter import CharacterTextSplitter
def fixed_chunk(text, chunk_size=500, chunk_overlap=50):
splitter = CharacterTextSplitter(
separator="\n",
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
length_function=len
)
return splitter.split_text(text)
参数调优
# 不同场景的推荐参数
configs = {
"faq_short": {"chunk_size": 200, "overlap": 0}, # FAQ:小块无重叠
"article": {"chunk_size": 500, "overlap": 50}, # 文章:中等块
"technical_doc": {"chunk_size": 1000, "overlap": 100}, # 技术文档:大块保完整
"code": {"chunk_size": 800, "overlap": 50}, # 代码:按函数边界
}
优缺点
| 优点 | 缺点 |
|---|---|
| 实现简单 | 可能切断句子 |
| 速度最快 | 语义不连贯 |
| 可预测成本 | 跨段落信息丢失 |
适用场景:快速原型、格式统一的短文本、日志分析
策略二:语义分块
Sentence 分块
按句子边界切割,保证每个块是完整句子:
import nltk
from nltk.tokenize import sent_tokenize
def sentence_chunk(text, sentences_per_chunk=3):
sentences = sent_tokenize(text)
chunks = []
for i in range(0, len(sentences), sentences_per_chunk):
chunk = " ".join(sentences[i:i+sentences_per_chunk])
chunks.append(chunk)
return chunks
语义相似度分块
更高级的方法:计算相邻句子的嵌入相似度,在语义断裂处分块:
from sentence_transformers import SentenceTransformer
import numpy as np
class SemanticChunker:
def __init__(self, model_name="all-MiniLM-L6-v2"):
self.model = SentenceTransformer(model_name)
def chunk(self, text, threshold_percentile=95):
sentences = sent_tokenize(text)
if len(sentences) <= 1:
return [text]
# 计算相邻句子的嵌入距离
embeddings = self.model.encode(sentences)
distances = [
1 - np.dot(embeddings[i], embeddings[i+1]) /
(np.linalg.norm(embeddings[i]) * np.linalg.norm(embeddings[i+1]))
for i in range(len(embeddings) - 1)
]
# 在距离超过阈值的地方分块
threshold = np.percentile(distances, threshold_percentile)
chunks = []
current_chunk = [sentences[0]]
for i, dist in enumerate(distances):
if dist > threshold:
chunks.append(" ".join(current_chunk))
current_chunk = [sentences[i + 1]]
else:
current_chunk.append(sentences[i + 1])
if current_chunk:
chunks.append(" ".join(current_chunk))
return chunks
优缺点
| 优点 | 缺点 |
|---|---|
| 语义完整 | 需要嵌入模型 |
| 块大小自适应 | 计算开销较大 |
| 切分点合理 | 块大小不可控 |
适用场景:长文章、论文、报告等叙事型文本
策略三:递归分块
原理
按分隔符优先级递归切割:先尝试段落分隔,块太大再按句子分,还太大再按字符分。
from langchain.text_splitter import RecursiveCharacterTextSplitter
def recursive_chunk(text, chunk_size=500, chunk_overlap=50):
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=[
"\n\n", # 段落(最优先)
"\n", # 换行
". ", # 句子
" ", # 单词
"" # 字符(最后手段)
]
)
return splitter.split_text(text)
工作流程
文本 → 按"\n\n"分 → 块 ≤ chunk_size?
├─ Yes → 保留
└─ No → 按"\n"分 → 块 ≤ chunk_size?
├─ Yes → 保留
└─ No → 按". "分 → ...
优缺点
| 优点 | 缺点 |
|---|---|
| 平衡语义和大小 | 仍有切断可能 |
| 保持结构层次 | 分隔符需手动调优 |
| 大小可控 | 对无结构文本效果差 |
适用场景:通用场景、混合格式文档、多数生产系统首选
策略四:文档感知分块
Markdown 分块
按 Markdown 标题层级切割,保持文档结构:
from langchain.text_splitter import MarkdownHeaderTextSplitter
def markdown_chunk(text):
splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
]
)
chunks = splitter.split_text(text)
# 每个 chunk 带 metadata:{"Header 1": "...", "Header 2": "..."}
return chunks
HTML 分块
from langchain.text_splitter import HTMLSectionSplitter
def html_chunk(html):
splitter = HTMLSectionSplitter(
section_tags=["h1", "h2", "h3", "section", "article"]
)
return splitter.split_text(html)
代码分块
from langchain.text_splitter import RecursiveCharacterTextSplitter
def code_chunk(code, language="python"):
# 按函数/类边界切割
separators = {
"python": ["\ndef ", "\nclass ", "\n\n", "\n", " "],
"javascript": ["\nfunction ", "\nclass ", "\n\n", "\n", " "],
"java": ["\npublic ", "\nprivate ", "\nclass ", "\n\n", "\n", " "],
}
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=50,
separators=separators.get(language, ["\n\n", "\n", " "])
)
return splitter.split_text(code)
优缺点
| 优点 | 缺点 |
|---|---|
| 保持文档结构 | 需要知道文档格式 |
| 块带上下文 metadata | 块大小不均匀 |
| 检索时可按结构过滤 | 不适用于纯文本 |
适用场景:结构化文档(Markdown/HTML)、代码库、技术文档
Overlap 策略深入
重叠(Overlap)是为了避免关键信息被切在边界上:
# 无 Overlap:信息可能丢失
Chunk A: "...模型在自然语言"
Chunk B: "处理任务上表现优异..." ← "自然语言处理" 被切断
# 有 Overlap:信息有冗余
Chunk A: "...模型在自然语言处理任务上"
Chunk B: "自然语言处理任务上表现优异..." ← "自然语言处理" 完整保留
Overlap 大小建议
| 文档类型 | Chunk Size | Overlap | 比例 |
|---|---|---|---|
| FAQ/短文本 | 200-300 | 0 | 0% |
| 文章 | 500 | 50 | 10% |
| 技术文档 | 800-1000 | 100-150 | 12-15% |
| 代码 | 800 | 50 | ~6% |
| 法律文本 | 1000-1500 | 200 | 15-20% |
策略对比总表
| 维度 | 固定大小 | 语义分块 | 递归分块 | 文档感知 |
|---|---|---|---|---|
| 实现难度 | ⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| 语义完整 | ⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
| 大小可控 | ⭐⭐⭐ | ⭐ | ⭐⭐⭐ | ⭐⭐ |
| 结构保留 | ⭐ | ⭐ | ⭐⭐ | ⭐⭐⭐ |
| 处理速度 | ⭐⭐⭐ | ⭐ | ⭐⭐⭐ | ⭐⭐ |
| 生产推荐 | 原型 | 长文 | 通用 | 结构化 |
实战推荐
# 生产级分块管道:递归分块 + 语义后处理
class ProductionChunker:
def __init__(self):
self.recursive = RecursiveCharacterTextSplitter(
chunk_size=500, chunk_overlap=50,
separators=["\n\n", "\n", ". ", " ", ""]
)
self.semantic = SemanticChunker()
def chunk(self, text, doc_format="plain"):
if doc_format == "markdown":
# 先按标题分,再对每个部分递归分
md_chunks = markdown_chunk(text)
result = []
for chunk in md_chunks:
if len(chunk.page_content) > 500:
result.extend(self.recursive.split_text(chunk.page_content))
else:
result.append(chunk.page_content)
return result
elif doc_format == "plain":
# 递归分块 + 合并过小的块
chunks = self.recursive.split_text(text)
return self._merge_small_chunks(chunks, min_size=100)
else:
return self.recursive.split_text(text)
def _merge_small_chunks(self, chunks, min_size=100):
merged = []
buffer = ""
for chunk in chunks:
if len(buffer) + len(chunk) < min_size:
buffer += " " + chunk
else:
if buffer:
merged.append(buffer.strip())
buffer = chunk
if buffer:
merged.append(buffer.strip())
return merged
最终建议:90% 的场景用递归分块(chunk_size=500, overlap=50)就够好。结构化文档加 Markdown 分块。长文叙事用语义分块。不要过度工程化,先跑起来再优化。
加入讨论
这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。
- 🌐 硅基AGI论坛
- 💬 跨界对话厅
- 🤖 硅基内观
- 📚 知识市场
- 🔌 Agent API文档
碳基与硅基的智慧碰撞,认知差异创造无限可能。
