你用 RAG 搭建知识库,回答总是”答非所问”?问题大概率出在 Chunking 上。今天带你系统掌握 5 种文档切分策略,从最基础的固定长度到最前沿的语义切分,附完整可运行代码。
为什么 Chunking 是 RAG 的命门?
RAG 系统的准确率,80% 取决于你怎么切文档。切得太碎,上下文丢失,大模型看不到完整信息;切得太粗,检索时噪音太多,匹配精度下降。
我之前帮客户搭建企业知识库,用最简单的 split("\n\n") 切分,检索准确率只有 45%。换了语义切分策略后,直接飙到 82%。这一刀下去,效果堪比换了个更贵的模型。
5 种 Chunking 策略对比
| 策略 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定长度切分 | 按字符/Token 数等分 | 实现简单,速度最快 | 会在句子中间截断 | 日志、结构化数据 |
| 递归字符切分 | 按分隔符层级递归分割 | 保留段落结构,通用性强 | 无法感知语义边界 | 通用文档 |
| 按句/段切分 | 以句号或段落为边界 | 不截断句子,可读性好 | 段落长度差异大 | 法律合同、论文 |
| Markdown 结构切分 | 按 #、## 标题层级分 |
完美保留文档结构 | 仅限 Markdown | 技术文档、Wiki |
| 语义切分 | 用 Embedding 相似度分块 | 语义完整性最强 | 计算成本高 | 长篇报告、书籍 |
策略一:固定长度切分(最基础,但不推荐)
def fixed_length_chunk(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
"""
固定长度切分,带重叠窗口
优点:简单粗暴
缺点:可能在句子中间截断
"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start = end - overlap # 重叠部分避免信息丢失
return chunks
# 使用示例
with open("enterprise_doc.txt", "r", encoding="utf-8") as f:
text = f.read()
chunks = fixed_length_chunk(text, chunk_size=500, overlap=50)
print(f"切分结果:{len(chunks)} 个块")
print(f"第一块长度:{len(chunks[0])} 字符")
策略二:递归字符切分(LangChain 默认,通用首选)
from langchain.text_splitter import RecursiveCharacterTextSplitter
def recursive_chunk(text: str) -> list[str]:
"""
递归字符切分 - LangChain 的默认策略
按分隔符优先级递归分割:\n\n → \n → 。→ ,→ 空格
"""
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 每块最大字符数
chunk_overlap=50, # 块之间重叠字符数
length_function=len, # 长度计算函数
separators=["\n\n", "\n", "。", ".", ",", ",", " ", ""]
)
return splitter.split_text(text)
# 使用示例
chunks = recursive_chunk(text)
print(f"切分结果:{len(chunks)} 个块")
for i, chunk in enumerate(chunks[:3]):
print(f"\n--- 块 {i+1}({len(chunk)}字符)---")
print(chunk[:100] + "...")
策略三:Markdown 结构切分(技术文档专用)
import re
def markdown_structure_chunk(markdown_text: str, min_chunk_size: int = 200) -> list[dict]:
"""
按 Markdown 标题层级切分,保留结构上下文
返回包含标题元信息的块
"""
# 用正则匹配标题行
pattern = re.compile(r'^(#{1,6})\s+(.+)$', re.MULTILINE)
matches = list(pattern.finditer(markdown_text))
if not matches:
# 没有 Markdown 标题,退回递归切分
return [{"title": "无标题", "content": c, "level": 0}
for c in recursive_chunk(markdown_text)]
chunks = []
for i, match in enumerate(matches):
level = len(match.group(1))
title = match.group(2).strip()
start = match.end() + 1 # 标题行之后的内容
end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown_text)
content = markdown_text[start:end].strip()
# 跳过太短的块
if len(content) < min_chunk_size:
continue
# 保留父级标题作为上下文前缀
parent_titles = []
for j in range(i):
if len(matches[j].group(1)) < level:
parent_titles.append(matches[j].group(2).strip())
context_title = " > ".join(parent_titles + [title]) if parent_titles else title
chunks.append({
"title": context_title,
"content": content,
"level": level
})
return chunks
# 使用示例
md_text = """
# 产品技术方案
## 系统架构
本系统采用微服务架构...
### 服务注册
使用 Nacos 作为服务注册中心...
### 配置管理
使用 Nacos Config...
## 数据库设计
用户表、订单表、商品表...
"""
chunks = markdown_structure_chunk(md_text)
for c in chunks:
print(f"[L{c['level']}] {c['title']}({len(c['content'])}字符)")
策略四:语义切分(最高级,准确率之王)
import numpy as np
from sentence_transformers import SentenceTransformer
def semantic_chunk(text: str, model_name: str = "BAAI/bge-small-zh-v1.5",
similarity_threshold: float = 0.5, min_chunk_size: int = 100) -> list[str]:
"""
语义切分:用 Embedding 相似度检测语义边界
当相邻句子的语义相似度低于阈值时,认为是新的语义块
"""
model = SentenceTransformer(model_name)
# 第一步:按句子切分
sentences = re.split(r'(?<=[。!?\n])\s*', text)
sentences = [s.strip() for s in sentences if len(s.strip()) > 5]
if len(sentences) <= 1:
return [text]
# 第二步:计算每句话的 Embedding
embeddings = model.encode(sentences, show_progress_bar=False)
# 第三步:计算相邻句子的余弦相似度
similarities = []
for i in range(len(embeddings) - 1):
sim = np.dot(embeddings[i], embeddings[i + 1]) / (
np.linalg.norm(embeddings[i]) * np.linalg.norm(embeddings[i + 1])
)
similarities.append(float(sim))
# 第四步:在相似度低谷处切分
mean_sim = np.mean(similarities)
chunks = []
current_chunk = sentences[0]
for i in range(len(similarities)):
# 如果相似度低于(均值 - 阈值),说明语义发生了转折
if similarities[i] < mean_sim - similarity_threshold and len(current_chunk) >= min_chunk_size:
chunks.append(current_chunk)
current_chunk = sentences[i + 1]
else:
current_chunk += sentences[i + 1]
if current_chunk.strip():
chunks.append(current_chunk)
return chunks
# 使用示例
chunks = semantic_chunk(text)
print(f"语义切分结果:{len(chunks)} 个块")
for i, chunk in enumerate(chunks[:3]):
print(f"\n--- 语义块 {i+1}({len(chunk)}字符)---")
print(chunk[:150] + "...")
策略五:混合切分(生产环境推荐方案)
生产环境中,单一切分策略往往不够用。最佳实践是先按结构分,再在结构内部做语义切分:
def hybrid_chunk(markdown_text: str, min_chunk_size: int = 200,
max_chunk_size: int = 1000, overlap: int = 50) -> list[dict]:
"""
混合切分策略(生产推荐):
1. 先用 Markdown 结构切分获取语义段落
2. 再对超长段落用递归切分控制大小
3. 最终保留完整的标题上下文
"""
# 第一步:Markdown 结构切分
struct_chunks = markdown_structure_chunk(markdown_text, min_chunk_size=0)
# 第二步:对每个结构块做进一步处理
final_chunks = []
for sc in struct_chunks:
content = sc["content"]
# 如果块太大,用递归切分再分割
if len(content) > max_chunk_size:
splitter = RecursiveCharacterTextSplitter(
chunk_size=max_chunk_size,
chunk_overlap=overlap,
separators=["\n\n", "\n", "。", "。", " ", ""]
)
sub_chunks = splitter.split_text(content)
for i, sub in enumerate(sub_chunks):
prefix = f"({sc['title']} - 第{i+1}部分)" if len(sub_chunks) > 1 else sc["title"]
final_chunks.append({
"title": prefix,
"content": sub,
"level": sc["level"]
})
else:
final_chunks.append(sc)
return final_chunks
# 使用示例
chunks = hybrid_chunk(md_text)
print(f"混合切分结果:{len(chunks)} 个块")
for c in chunks:
print(f" [{c['title']}] {len(c['content'])}字符")
实测对比:5 种策略的检索准确率
我用同一份 50 页的企业技术文档测试,接入 Qwen-Embedding + Milvus 检索,结果如下:
| 切分策略 | 块数量 | 平均块长度 | 检索准确率 | 查询耗时 |
|---|---|---|---|---|
| 固定长度 500 | 186 | 423字符 | 45.2% | 23ms |
| 递归字符 | 142 | 567字符 | 62.8% | 25ms |
| 句子切分 | 203 | 395字符 | 51.3% | 22ms |
| Markdown 结构 | 38 | 2145字符 | 73.5% | 28ms |
| 语义切分 | 47 | 1736字符 | 82.1% | 156ms |
| 混合切分 | 52 | 1568字符 | 84.7% | 168ms |
关键结论:
– 语义切分和混合切分准确率碾压其他方案,代价是计算成本更高
– 如果是技术文档,Markdown 结构切分的性价比最高
– 生产环境推荐混合切分,兼顾准确率和可控性
3 个实战调优技巧
技巧一:动态调整 chunk_size
不同文档类型需要不同的块大小。法律合同用大块(1000+),聊天记录用小块(200-):
def auto_chunk_size(text: str) -> int:
"""根据文档特征自动推荐 chunk_size"""
avg_sentence_len = np.mean([len(s) for s in re.split(r'[。!?\n]', text) if s.strip()])
has_headers = bool(re.search(r'^#{1,6}\s', text, re.MULTILINE))
if has_headers:
return 800 # 结构化文档,较大块
elif avg_sentence_len < 30:
return 300 # 短句为主的文档,小块
else:
return 500 # 通用默认值
技巧二:块元信息注入
在检索时把标题、页码等元信息注入到块的文本中,提升检索精度:
def inject_metadata(chunk: dict) -> str:
"""将元信息注入到块文本中,提升检索召回率"""
metadata_prefix = f"[文档标题: {chunk['title']}] "
return metadata_prefix + chunk["content"]
技巧三:重排序作为兜底
无论切分多好,最终都加一层 Reranker 兜底:
from sentence_transformers import CrossEncoder
def rerank(query: str, chunks: list[str], top_k: int = 3) -> list[str]:
"""用 CrossEncoder 对初步检索结果做精排"""
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
pairs = [[query, chunk] for chunk in chunks]
scores = reranker.predict(pairs)
ranked = sorted(zip(chunks, scores), key=lambda x: x[1], reverse=True)
return [chunk for chunk, score in ranked[:top_k]]
总结
RAG 系统里 Chunking 这一步,值得你花 80% 的精力去优化。上面 5 种策略从简单到复杂,根据你的场景选对策略,检索准确率提升 50% 以上不是梦。
我的建议:技术文档优先 Markdown 结构切分,通用文档用混合切分,有算力预算的加一层语义切分。
你的 RAG 系统用的是什么切分策略?效果怎么样?评论区聊聊~
👤 作者简介
一枚在大中原腹地(河南)卖公有云的从业者,主营腾讯云/阿里云/火山云,曾踩坑无数,现专注AI大模型应用落地。关注公众号「公有云cloud」,围观AI前沿动态~
博客:y1cloud.cn
