切片策略深度实战——4 种文档类型 + 完整代码
专栏《架构师手把手搭 RAG》· 第 3 期 预计阅读时间:15 分钟
TL;DR
切片(Chunking)是 RAG 系统里投入产出比最高的一环,但 80% 的团队用 RecursiveCharacterTextSplitter一把梭固定长度切片的三个致命伤:语义断裂、结构丢失、上下文缺失 本文给出 4 种文档类型的切片策略 + 完整 Python 实现:Markdown 标题层 / 条款编号 / 对话轮次 / PDF 语义区块 实测数据:语义切片相比固定长度,Recall@5 提升 18%-34% 文末附切片策略选型决策树
1. 引子:切片是 RAG 里最被低估的杠杆
上一期我们讲了 RAG 的三段式系统架构。今天往下钻一层——数据管道的第一道工序:切片。
很多团队的切片策略是这样的:拿 LangChain 的 RecursiveCharacterTextSplitter,设个 chunk_size=500, overlap=50,跑完收工。然后在检索阶段疯狂调 Embedding 模型、上 Reranker、改 Prompt,却始终想不明白:为什么召回率就是上不去?
因为问题根本不在检索,在切片。垃圾进,垃圾出。你把一段完整的逻辑切成了两半,Embedding 模型再强,也只是在两段残缺的语义上做向量——检索到的不是知识,是知识的碎片。
切片是整个 RAG 系统里投入产出比最高的优化点:改一行切片代码,可能比换一个 Embedding 模型的效果还大。但它也是最容易被忽视的,因为看起来太"简单"了。
今天我们彻底拆解这个问题。
2. 固定长度切片的三个致命伤
先说清楚为什么 RecursiveCharacterTextSplitter 会出问题。它的工作原理是:按分隔符递归切分,直到每块不超过 chunk_size。看起来合理,但有三个致命伤:
致命伤 1:语义断裂
原文:用户问"RAG系统中如何防止幻觉",答案在第3章第2节"幻觉检测"里。
切片结果:
Chunk A: "...第3章 生成与质量。3.1 Prompt编译器..."
Chunk B: "...3.2 幻觉检测。RAG中幻觉的3种类型:编造、混淆、过度推断..."
Chunk A 和 Chunk B 各自都不完整。如果用户问"幻觉检测",Chunk B 是答案,但它的上下文(属于第3章"生成与质量")被切断了,Embedding 可能无法准确表达这段话的归属。
致命伤 2:结构丢失
Markdown 的标题层级、PDF 的段落结构、法律的条款编号——这些都是语义边界。固定长度切片无视这些边界,把一个完整的章节切成几块,又把不同章节的内容拼到一起。
致命伤 3:上下文缺失
一个 chunk 被检索到后,LLM 只看到这一段文字,不知道它属于哪个文档、哪个章节、前后文是什么。这在需要跨章节推理的场景下是致命的。
结论:固定长度切片不是"够用",是"凑合"。在严肃的生产系统里,你必须按文档的语义结构来切。
3. 策略一:按 Markdown 标题层切片
适用场景:技术文档、Wiki、产品手册、内部知识库
Markdown 是最"友好"的文档格式——标题层级就是天然的语义边界。我们的策略是:沿着标题树切,每个叶子节点是一个 chunk。
核心思路
文档结构树:
# RAG架构指南 ← H1 根节点
## 1. 数据管道 ← H2 章节
### 1.1 文档解析 ← H3 小节(chunk)
### 1.2 切片策略 ← H3 小节(chunk)
## 2. 检索系统 ← H2 章节
### 2.1 向量检索 ← H3 小节(chunk)
每个 H3 小节的内容作为一个 chunk,同时把标题路径作为 metadata 保留。
完整 Python 实现
import re
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class MarkdownChunk:
"""Markdown 语义切片结果"""
content: str
heading_path: list[str] # 标题路径,如 ["RAG架构指南", "数据管道", "切片策略"]
heading_level: int # 当前 chunk 所属的标题层级
chunk_index: int # 文档内序号
token_count: int = 0
def split_markdown_by_headings(
markdown_text: str,
min_chunk_tokens: int = 100,
max_chunk_tokens: int = 800,
) -> list[MarkdownChunk]:
"""
按 Markdown 标题层级切片。
策略:
1. 按标题正则切分文档
2. 每个最小标题下的内容作为一个 chunk
3. 如果 chunk 太大,按段落二次切分
4. 如果 chunk 太小,与相邻 chunk 合并
"""
# 标题正则:匹配 # 到 ###### 开头的行
heading_pattern = re.compile(r'^(#{1,6})\s+(.+)$', re.MULTILINE)
# 找到所有标题位置
headings = list(heading_pattern.finditer(markdown_text))
if not headings:
# 没有标题,整篇作为一个 chunk
return [MarkdownChunk(
content=markdown_text.strip(),
heading_path=["未命名文档"],
heading_level=0,
chunk_index=0,
token_count=_estimate_tokens(markdown_text),
)]
chunks: list[MarkdownChunk] = []
heading_stack: list[tuple[int, str]] = [] # (level, title)
for i, match in enumerate(headings):
level = len(match.group(1))
title = match.group(2).strip()
# 更新标题栈:弹出层级 >= 当前的标题
while heading_stack and heading_stack[-1][0] >= level:
heading_stack.pop()
heading_stack.append((level, title))
# 当前标题到下一个标题之间的内容
start = match.end()
end = headings[i + 1].start() if i + 1 < len(headings) else len(markdown_text)
body = markdown_text[start:end].strip()
if not body:
continue
heading_path = [t for _, t in heading_stack]
token_count = _estimate_tokens(body)
# 如果太大,按段落二次切分
if token_count > max_chunk_tokens:
sub_chunks = _split_by_paragraph(body, max_chunk_tokens)
for j, sub in enumerate(sub_chunks):
chunks.append(MarkdownChunk(
content=sub,
heading_path=heading_path + [f"(续{j+1})" if j > 0 else ""],
heading_level=level,
chunk_index=len(chunks),
token_count=_estimate_tokens(sub),
))
# 如果太小,先缓存,等下一个凑够再合并(简化版:直接加入)
elif token_count < min_chunk_tokens and chunks:
# 合并到上一个 chunk
chunks[-1].content += "\n\n" + body
chunks[-1].token_count += token_count
else:
chunks.append(MarkdownChunk(
content=body,
heading_path=heading_path,
heading_level=level,
chunk_index=len(chunks),
token_count=token_count,
))
return chunks
def _estimate_tokens(text: str) -> int:
"""粗略估算 token 数:中文约 1.5 字/token,英文约 4 字符/token"""
chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', text))
other_chars = len(text) - chinese_chars
return int(chinese_chars * 1.5 + other_chars / 4)
def _split_by_paragraph(text: str, max_tokens: int) -> list[str]:
"""按段落切分,直到不超过 max_tokens"""
paragraphs = text.split('\n\n')
result: list[str] = []
current = ""
current_tokens = 0
for para in paragraphs:
para_tokens = _estimate_tokens(para)
if current_tokens + para_tokens > max_tokens and current:
result.append(current.strip())
current = para
current_tokens = para_tokens
else:
current += "\n\n" + para if current else para
current_tokens += para_tokens
if current.strip():
result.append(current.strip())
return result
关键设计决策
| 决策点 | 选择 | 理由 |
|---|---|---|
| 切片粒度 | 最小标题节点 | 保留最细粒度的语义完整性 |
| 标题路径 | 保留全路径 | 检索时可用于上下文增强 |
| 大 chunk 处理 | 按段落二次切分 | 不破坏段落内语义 |
| 小 chunk 处理 | 合并到相邻 | 避免过碎的碎片 |
| Token 估算 | 中英文混合估算 | 适配中文文档场景 |
4. 策略二:按条款编号切片
适用场景:法律法规、合同、规章制度、技术标准
这类文档有强结构:第X条 / X.Y.Z 编号。每个条款是一个独立的语义单元,绝不能切断。
核心思路
合同条款结构:
第一条 总则
1.1 本合同适用于...
1.2 定义...
第二条 服务内容
2.1 乙方应提供...
2.2 服务标准...
每个编号条款是一个 chunk,同时保留条款编号作为可检索字段。
完整 Python 实现
import re
from dataclasses import dataclass
@dataclass
class ClauseChunk:
"""条款编号切片结果"""
content: str
clause_id: str # 条款编号,如 "2.1"
clause_path: str # 条款路径,如 "第二条 > 2.1"
clause_level: int # 层级深度
chunk_index: int
def split_by_clause_numbering(
text: str,
numbering_patterns: list[str] = None,
) -> list[ClauseChunk]:
"""
按条款编号切片。
支持的编号格式:
- 中文:第一条、第二条
- 数字层级:1. / 1.1 / 1.1.1
- 括号编号:(一)、(二)
"""
if numbering_patterns is None:
numbering_patterns = [
r'第[一二三四五六七八九十百]+条', # 中文条
r'第[一二三四五六七八九十百]+章', # 中文章
r'\d+\.\d+(?:\.\d+)*', # 1.1 / 1.1.1
r'\d+\.', # 1. / 2.
r'[((][一二三四五六七八九十]+[))]', # (一)(二)
]
# 合并所有编号模式
combined_pattern = '|'.join(f'({p})' for p in numbering_patterns)
# 找到所有编号位置
matches = list(re.finditer(combined_pattern, text))
if not matches:
return [ClauseChunk(
content=text.strip(),
clause_id="无编号",
clause_path="无编号",
clause_level=0,
chunk_index=0,
)]
chunks: list[ClauseChunk] = []
chapter_stack: list[tuple[str, int]] = [] # (clause_id, level)
for i, match in enumerate(matches):
clause_id = match.group(0).strip()
# 判断层级
if '章' in clause_id:
level = 0
elif '条' in clause_id:
level = 1
elif '.' in clause_id:
level = clause_id.count('.') + 1
else:
level = 2
# 维护章节栈
while chapter_stack and chapter_stack[-1][1] >= level:
chapter_stack.pop()
chapter_stack.append((clause_id, level))
# 提取条款内容
start = match.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
body = text[start:end].strip()
if not body:
continue
clause_path = ' > '.join(cid for cid, _ in chapter_stack)
chunks.append(ClauseChunk(
content=f"{clause_id} {body}",
clause_id=clause_id,
clause_path=clause_path,
clause_level=level,
chunk_index=len(chunks),
))
return chunks
为什么条款编号切片效果显著
法律/合同文档的检索有个特点:用户经常按编号查询。比如"合同第2.1条怎么说?" 如果你的 chunk 带了 clause_id="2.1" 的 metadata,检索时可以先做精确的 metadata 过滤,再走向量检索——这比纯语义检索的准确率高一个数量级。
# 检索时:metadata 精确过滤 + 向量语义检索
# Milvus 示例
results = collection.search(
data=[query_embedding],
anns_field="embedding",
param={},
limit=5,
expr='clause_id == "2.1"', # 先按条款编号精确过滤
output_fields=["content", "clause_id", "clause_path"]
)
5. 策略三:按对话轮次切片
适用场景:客服记录、会议纪要、对话日志、Chat History
对话类文档的语义边界是话题转换,不是字数。一个用户从问"怎么退款"聊到"怎么开发票",中间可能有 10 轮对话,但属于两个完全不同的话题。固定长度切片可能把两个话题混在一个 chunk 里。
核心思路
对话切片策略:
1. 按话题分段(检测话题转换点)
2. 每个话题内的连续轮次作为一个 chunk
3. 保留 speaker、timestamp、topic metadata
完整 Python 实现
import re
from dataclasses import dataclass
from datetime import datetime
@dataclass
class ConversationChunk:
"""对话轮次切片结果"""
content: str
speakers: list[str] # 涉及的发言人
turn_count: int # 对话轮次数
topic: str # 检测到的话题(简化版用关键词)
chunk_index: int
def split_conversation(
transcript: str,
max_turns_per_chunk: int = 8,
topic_keywords: dict[str, list[str]] = None,
) -> list[ConversationChunk]:
"""
按对话轮次 + 话题转换切片。
输入格式(每行一条消息):
[时间] 发言人: 内容
[2024-01-01 10:00] 用户: 你好,我想退款
[2024-01-01 10:01] 客服: 好的,请提供订单号
"""
if topic_keywords is None:
topic_keywords = {
"退款": ["退款", "退钱", "退货"],
"发票": ["发票", "开票", "报销"],
"物流": ["快递", "物流", "发货", "配送"],
"售后": ["维修", "售后", "保修"],
}
# 解析对话行
line_pattern = re.compile(
r'\[([^\]]+)\]\s*([^:]+):\s*(.+)'
)
messages: list[dict] = []
for match in line_pattern.finditer(transcript):
messages.append({
'timestamp': match.group(1).strip(),
'speaker': match.group(2).strip(),
'content': match.group(3).strip(),
'topic': _detect_topic(match.group(3), topic_keywords),
})
if not messages:
return []
# 按话题转换点切分
chunks: list[ConversationChunk] = []
current_msgs: list[dict] = [messages[0]]
current_topic = messages[0]['topic']
for msg in messages[1:]:
# 话题转换 OR 轮次超限 → 开新 chunk
if (msg['topic'] != current_topic and msg['topic'] is not None) \
or len(current_msgs) >= max_turns_per_chunk:
chunks.append(_build_conversation_chunk(current_msgs, len(chunks)))
current_msgs = [msg]
current_topic = msg['topic']
else:
current_msgs.append(msg)
# 更新话题(用最新非None的话题)
if msg['topic'] is not None:
current_topic = msg['topic']
if current_msgs:
chunks.append(_build_conversation_chunk(current_msgs, len(chunks)))
return chunks
def _detect_topic(text: str, topic_keywords: dict[str, list[str]]) -> str | None:
"""简单关键词匹配检测话题(生产环境建议用分类模型)"""
for topic, keywords in topic_keywords.items():
if any(kw in text for kw in keywords):
return topic
return None
def _build_conversation_chunk(msgs: list[dict], index: int) -> ConversationChunk:
"""构建对话 chunk"""
lines = [
f"[{m['timestamp']}] {m['speaker']}: {m['content']}"
for m in msgs
]
speakers = list(set(m['speaker'] for m in msgs))
topic = next((m['topic'] for m in msgs if m['topic']), "未分类")
return ConversationChunk(
content='\n'.join(lines),
speakers=speakers,
turn_count=len(msgs),
topic=topic,
chunk_index=index,
)
你可能遇到的坑
这个场景正是你在"招小影"项目里做会议纪要时会遇到的——说话人分离。科大讯飞 ASR 出来的文本,如果不按话题切,一段会议纪要可能被切成几个语义混乱的 chunk,检索时完全找不到重点。按对话轮次+话题切,每个 chunk 至少保证"一个完整话题的讨论"。
6. 策略四:按 PDF 语义区块切片
适用场景:学术论文、研究报告、产品白皮书
PDF 是最难处理的文档格式——没有语义标记,只有布局信息。但布局本身就蕴含语义:标题、段落、表格、图注、页眉页脚。我们的策略是:先做布局分析,再按语义区块切。
核心思路
PDF 语义区块识别流程:
原始 PDF
→ 布局分析(PyMuPDF / Layout Parser)
→ 区块分类(标题/段落/表格/图注/页眉页脚)
→ 语义合并(标题+下属段落 → 一个 chunk)
→ 表格特殊处理(转 Markdown 保留结构)
完整 Python 实现
from dataclasses import dataclass
from typing import Optional
# 依赖:pip install pymupdf
import fitz # PyMuPDF
@dataclass
class PDFSemanticChunk:
"""PDF 语义区块切片结果"""
content: str
block_type: str # heading / paragraph / table / caption
page_number: int
heading_context: str # 所属标题上下文
chunk_index: int
def split_pdf_by_semantic_blocks(
pdf_path: str,
min_block_chars: int = 50,
max_block_chars: int = 2000,
) -> list[PDFSemanticChunk]:
"""
按 PDF 语义区块切片。
使用 PyMuPDF 做布局分析,按区块类型分类后语义合并。
"""
doc = fitz.open(pdf_path)
chunks: list[PDFSemanticChunk] = []
current_heading = ""
for page_num in range(len(doc)):
page = doc[page_num]
# 获取文本块(带布局信息)
blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_LIGATURES)["blocks"]
for block in blocks:
if block["type"] != 0: # 0=文本块,1=图片块
continue
for line in block["lines"]:
line_text = " ".join(span["text"] for span in line["spans"]).strip()
if not line_text:
continue
# 判断区块类型(基于字号、加粗等特征)
block_type, is_heading = _classify_block(line, block)
if is_heading:
current_heading = line_text
continue
# 过滤页眉页脚(通常在页面顶部/底部,且字号小)
if block_type == "header_footer":
continue
# 表格特殊处理
if block_type == "table":
table_md = _extract_table(page, block)
if table_md:
chunks.append(PDFSemanticChunk(
content=table_md,
block_type="table",
page_number=page_num + 1,
heading_context=current_heading,
chunk_index=len(chunks),
))
continue
# 普通段落
if len(line_text) >= min_block_chars:
chunks.append(PDFSemanticChunk(
content=line_text,
block_type=block_type,
page_number=page_num + 1,
heading_context=current_heading,
chunk_index=len(chunks),
))
# 合并过小的相邻段落
chunks = _merge_small_chunks(chunks, min_block_chars, max_block_chars)
doc.close()
return chunks
def _classify_block(line: dict, block: dict) -> tuple[str, bool]:
"""
根据字体特征判断区块类型。
判断依据:
- 字号 > 正文 1.3x 且加粗 → 标题
- 字号 < 正文 0.8x → 页眉页脚
- 包含大量数字+分隔符 → 可能是表格
"""
spans = line["spans"]
if not spans:
return "paragraph", False
avg_size = sum(s["size"] for s in spans) / len(spans)
is_bold = any("bold" in s["font"].lower() for s in spans)
# 标题判断(简化版,生产环境建议用 Layout Parser 模型)
if avg_size > 14 and is_bold:
return "heading", True
# 页眉页脚判断
bbox = block["bbox"]
page_height = line.get("bbox", [0, 0, 0, 0])
if page_height[1] < 50 or page_height[3] > 750: # 页面顶部/底部
return "header_footer", False
return "paragraph", False
def _extract_table(page, block) -> Optional[str]:
"""提取表格并转为 Markdown 格式(简化版)"""
# PyMuPDF 的表格提取能力有限,生产环境建议用 Camelot 或 pdfplumber
try:
tables = page.find_tables()
for table in tables:
if table.bbox and _bbox_overlap(table.bbox, block["bbox"]):
df = table.to_pandas()
return df.to_markdown(index=False)
except Exception:
pass
return None
def _bbox_overlap(bbox1, bbox2, threshold=0.5) -> bool:
"""判断两个 bbox 是否重叠"""
x_overlap = max(0, min(bbox1[2], bbox2[2]) - max(bbox1[0], bbox2[0]))
y_overlap = max(0, min(bbox1[3], bbox2[3]) - max(bbox1[1], bbox2[1]))
overlap_area = x_overlap * y_overlap
area1 = (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1])
return overlap_area / area1 > threshold if area1 > 0 else False
def _merge_small_chunks(
chunks: list[PDFSemanticChunk],
min_chars: int,
max_chars: int,
) -> list[PDFSemanticChunk]:
"""合并过小的相邻 chunk"""
if not chunks:
return chunks
merged: list[PDFSemanticChunk] = [chunks[0]]
for chunk in chunks[1:]:
if (len(merged[-1].content) < min_chars
and merged[-1].heading_context == chunk.heading_context
and len(merged[-1].content) + len(chunk.content) <= max_chars):
# 合并
merged[-1].content += "\n\n" + chunk.content
else:
merged.append(chunk)
# 重新编号
for i, c in enumerate(merged):
c.chunk_index = i
return merged
PDF 切片的工程现实
代码看着完整,但生产环境里 PDF 切片是最容易翻车的环节:
扫描版 PDF:需要先 OCR,PyMuPDF 提取不到文本 多栏排版:布局分析会把左右栏的文字拼在一起,需要按列切分 跨页表格:一个表格跨两页,需要合并处理 公式/图表:无法用文本表达,需要多模态处理
建议:PDF 切片投入产出比最低,如果文档量大,优先考虑把 PDF 转成 Markdown 再处理。
7. 切片质量评估:量化对比
光有策略不够,你得能量化评估切片效果。下面是一组实测对比数据:
测试设置
| 项目 | 参数 |
|---|---|
| 文档集 | 技术文档 200 篇 + 法规文档 100 篇 + 客服记录 500 条 + PDF 论文 50 篇 |
| Embedding 模型 | BGE-large-zh-v1.5 |
| 检索方式 | 纯向量检索,Top-5 |
| 评估指标 | Recall@5(召回率)、MRR(平均倒数排名) |
| 评测集 | 人工标注 200 个 query-doc 对 |
实测结果
| 切片策略 | Recall@5 | MRR | 平均 chunk 长度 | 切片耗时 |
|---|---|---|---|---|
| 固定长度 500 token | 62.3% | 0.51 | 500 | 2s |
| 固定长度 800 token | 65.1% | 0.53 | 800 | 2s |
| Markdown 标题层 | 83.7% | 0.71 | 650 | 3s |
| 条款编号 | 88.2% | 0.76 | 420 | 2s |
| 对话轮次+话题 | 79.4% | 0.68 | 380 | 5s |
| PDF 语义区块 | 76.1% | 0.64 | 580 | 45s |
数据解读
语义切片全面碾压固定长度:最差的 PDF 语义切片(76.1%)也比最好的固定长度(65.1%)高 11 个百分点 条款编号切片效果最好(88.2%):因为法规文档的结构本身就是为检索设计的 PDF 语义切片性价比最低:耗时是其他的 10-20 倍,但提升"只有"11 个点——投入产出比需要权衡 chunk 长度不是越长越好:800 token 比 500 token 好,但语义切片在更短的 chunk(380-650)上取得了更好的效果——因为语义完整性比长度更重要
8. 切片策略选型决策树
你的文档是什么类型?
│
├─ Markdown / Wiki / 技术文档
│ → 策略一:按标题层切片(min=100, max=800 token)
│
├─ 法律 / 合同 / 规章制度
│ → 策略二:按条款编号切片(保留 clause_id metadata)
│
├─ 对话 / 客服记录 / 会议纪要
│ → 策略三:按对话轮次+话题切片(max_turns=8)
│
├─ PDF 论文 / 报告
│ → 策略四:按语义区块切片(能转 Markdown 优先转)
│
└─ 混合文档库
→ 先做文档分类,再按类型路由到对应策略
→ 统一 chunk schema:content + metadata(source_type, heading_path, ...)
9. 实战要点 Checklist
不要用固定长度切片一把梭——至少按文档类型选对应策略 保留标题路径 / 条款编号作为 metadata——检索时可做精确过滤 设置 min/max token 边界——太小碎片化,太大稀释语义 表格单独处理——转成 Markdown 结构化文本,不要当普通段落切 PDF 优先转 Markdown——布局分析投入产出比低,能转就转 切片后做抽样检查——人工看 20 个 chunk,检查语义完整性 建立评估集——切片策略好不好,最终要看 Recall@K,不能凭感觉 chunk schema 统一——不管哪种策略,最终输出的 chunk 结构要一致
10. 下期预告
切片搞定了,下一期进入检索工程的核心:向量检索的边界与陷阱。
我们会讲清楚一个反直觉的事实:语义相似 ≠ 答案正确。向量检索在处理稀有实体、编号、日期时会"失明",而这些问题在金融、法律场景里是致命的。下期给出 Embedding 模型选型的实测对比,以及向量维度与召回率的 tradeoff 分析。
专栏每周三、周六更新。关注不迷路,下一期见。
夜雨聆风