RAG 文档切片策略:固定长度 vs 递归 vs 语义切分
RAG 文档切片策略:固定长度 vs 递归 vs 语义切分
本文是【AI 专题精讲】系列第 02 篇。 上一篇:RAG 文件解析:PDF / Word / Excel / HTML 全格式文本提取 | 下一篇:RAG 大文件上传:分片上传、断点续传、进度追踪
这篇文章你会得到什么
上一篇我们把各种格式的文件解析成了纯文本。但你不能直接把一整篇文档丢给 AI——10 万字的技术文档,光 token 就超了,而且检索时一整篇文档的相关性评分根本没法用。
所以 RAG 的第二步是切片(Chunking):把长文本切成合适大小的段落,每段独立做 Embedding 和存储。
听起来简单对吧?text.slice(0, 1000) 不就行了?
不行。切得太粗暴,一句话被拦腰截断,AI 只看到半句话,回答质量直接崩掉。
今天的目标:搞懂三种主流切片策略(固定长度、递归、语义),知道它们各自的优劣,能根据不同场景选择最合适的方案。附带一个质量评估脚本,让你能量化地比较不同切片策略的效果。
为什么切片这么重要
先看一个直观的例子。假设你的知识库里有一段关于请假制度的文档:
第三条 年假制度
员工入职满一年后,享有 5 天带薪年假。入职满三年后,年假增至 10 天。
年假需提前 3 个工作日提交申请,经直属上级审批后生效。
未使用的年假不可跨年累积,但可在当年 12 月折算为加班工资。
第四条 病假制度
员工因病需请假时,须提供医院开具的诊断证明。
病假期间工资按基本工资的 80% 发放,连续病假超过 30 天的,按当地最低工资标准发放。
用户问:“年假可以跨年吗?”
切得好:检索到的片段包含”未使用的年假不可跨年累积,但可在当年 12 月折算为加班工资”——AI 给出准确回答。
切得差:如果切片正好在”不可跨年”这句话中间断开,检索到的片段只有前半段或后半段,AI 回答就可能出错或说”信息不足”。
切片质量对 RAG 效果的影响:
| 切片质量 | 检索命中率 | AI 回答质量 | 用户体验 |
|---|---|---|---|
| 好(语义完整) | 高 | 准确、有据可查 | ”AI 真聪明” |
| 一般(偶有截断) | 中 | 大致正确,偶尔缺信息 | ”还行吧” |
| 差(频繁截断) | 低 | 答非所问或信息不全 | ”这 AI 什么也不懂” |
切片的三个核心参数
不管用哪种策略,切片都绑不开三个参数:
chunk_size(切片大小)
每个切片的最大长度。通常用字符数或 token 数衡量。
| chunk_size | 特点 | 适合场景 |
|---|---|---|
| 200-500 字符 | 精细,检索精度高,但上下文少 | FAQ、条款、定义 |
| 500-1000 字符 | 平衡,最常用 | 通用文档 |
| 1000-2000 字符 | 粗粒度,上下文丰富,但检索精度下降 | 长篇叙述、技术文章 |
我的经验:通用场景先从 800 字符开始,根据效果再调。
chunk_overlap(重叠区域)
相邻切片之间重叠的字符数。用来避免关键信息被切断。
chunk 1: [...........|overlap|]
chunk 2: [overlap|...........]
一般设为 chunk_size 的 10%-20%。比如 chunk_size=800,overlap 设 100-160。
overlap 太小:边界信息丢失。 overlap 太大:存储膨胀、检索到大量重复内容。
separators(分隔符)
按什么字符来分割文本。常见的分隔符优先级:
"\n\n" → 段落分隔(最优先)
"\n" → 换行
"。" → 句号(中文)
"." → 句号(英文)
" " → 空格
"" → 逐字符(兜底)
策略一:固定长度切片
最简单的方式——每 N 个字符切一刀。
原理
文本总长 3000 字符,chunk_size=1000,overlap=200
chunk 1: 字符 0-999
chunk 2: 字符 800-1799
chunk 3: 字符 1600-2599
chunk 4: 字符 2400-2999
Python 实现
def split_by_fixed_length(
text: str,
chunk_size: int = 800,
chunk_overlap: int = 100,
) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunk = text[start:end]
chunks.append(chunk.strip())
start = end - chunk_overlap
return [c for c in chunks if c]
用 LangChain 实现
from langchain_text_splitters import CharacterTextSplitter
splitter = CharacterTextSplitter(
separator="",
chunk_size=800,
chunk_overlap=100,
length_function=len,
)
chunks = splitter.split_text(long_text)
优缺点
| 优点 | 缺点 |
|---|---|
| 实现极其简单 | 完全无视语义边界 |
| 切片大小高度可控 | 经常在句子中间截断 |
| 速度最快 | 切片质量最差 |
适用场景:对切片质量要求不高的场景,或者作为其他策略的兜底。
策略二:递归切片(推荐默认方案)
递归切片是目前最主流的方案,LangChain 的默认实现就是它。
原理
核心思路:按分隔符优先级层层递归。
- 先尝试用
\n\n(段落)分割 - 如果某段还是太长,再用
\n(换行)分割 - 还是太长,用
。/.(句号)分割 - 最后兜底用空格或逐字符
这样切出来的片段尽可能保持在自然段落或完整句子的边界上。
Python 实现
def split_recursive(
text: str,
chunk_size: int = 800,
chunk_overlap: int = 100,
separators: list[str] = None,
) -> list[str]:
if separators is None:
separators = ["\n\n", "\n", "。", ".", "!", "?", " ", ""]
chunks = []
_split_recursive(text, chunk_size, chunk_overlap, separators, 0, chunks)
return chunks
def _split_recursive(
text: str,
chunk_size: int,
chunk_overlap: int,
separators: list[str],
sep_idx: int,
result: list[str],
):
if not text.strip():
return
# 文本已经足够短,直接作为一个 chunk
if len(text) <= chunk_size:
result.append(text.strip())
return
# 没有更多分隔符了,强制按长度切
if sep_idx >= len(separators):
for i in range(0, len(text), chunk_size - chunk_overlap):
chunk = text[i:i + chunk_size]
if chunk.strip():
result.append(chunk.strip())
return
sep = separators[sep_idx]
if sep == "":
# 空字符串分隔符 = 逐字符,走固定长度
for i in range(0, len(text), chunk_size - chunk_overlap):
chunk = text[i:i + chunk_size]
if chunk.strip():
result.append(chunk.strip())
return
parts = text.split(sep)
current = ""
for part in parts:
candidate = current + sep + part if current else part
if len(candidate) <= chunk_size:
current = candidate
else:
# 当前累积的内容可以作为一个 chunk
if current.strip():
result.append(current.strip())
# 如果单个 part 就超长了,用下一级分隔符继续分
if len(part) > chunk_size:
_split_recursive(
part, chunk_size, chunk_overlap,
separators, sep_idx + 1, result,
)
current = ""
else:
current = part
if current.strip():
result.append(current.strip())
用 LangChain 实现(更简洁)
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=100,
separators=["\n\n", "\n", "。", ".", "!", "?", " ", ""],
length_function=len,
)
chunks = splitter.split_text(long_text)
中文场景建议加上中文标点分隔符 "。", "!", "?", ";",效果比默认的英文分隔符好很多。
优缺点
| 优点 | 缺点 |
|---|---|
| 尽可能保留完整段落/句子 | 对没有明确段落结构的文本效果一般 |
| 实现成熟,LangChain 默认方案 | 仍然基于字符规则,不理解语义 |
| 速度快,适合大批量处理 | 参数需要调优 |
适用场景:大多数通用文档,推荐作为默认方案。
策略三:语义切片
语义切片是最”智能”的方案——不按字符数切,而是按语义相似度切。
原理
- 先把文本按句子分割
- 对每个句子生成 Embedding 向量
- 计算相邻句子之间的相似度
- 在相似度突然下降的地方(语义断裂点)切分
句子1: 相似度 0.92 → 句子2: 相似度 0.88 → 句子3: 相似度 0.35 → 句子4
↑ 这里语义断裂,切!
Python 实现
import numpy as np
from openai import OpenAI
import os
import re
client = OpenAI(
base_url="https://api.deepseek.com",
api_key=os.getenv("DEEPSEEK_API_KEY"),
)
def get_embeddings(texts: list[str]) -> list[list[float]]:
"""批量获取 Embedding"""
response = client.embeddings.create(
model="text-embedding-v1",
input=texts,
)
return [item.embedding for item in response.data]
def cosine_similarity(a: list[float], b: list[float]) -> float:
a, b = np.array(a), np.array(b)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
def split_into_sentences(text: str) -> list[str]:
"""按句子分割(支持中英文)"""
sentences = re.split(r'(?<=[。!?.!?\n])', text)
return [s.strip() for s in sentences if s.strip()]
def split_by_semantic(
text: str,
threshold: float = 0.5,
max_chunk_size: int = 1500,
min_chunk_size: int = 100,
) -> list[str]:
sentences = split_into_sentences(text)
if len(sentences) <= 1:
return [text.strip()] if text.strip() else []
# 获取所有句子的 Embedding
embeddings = get_embeddings(sentences)
# 计算相邻句子的相似度
similarities = []
for i in range(len(embeddings) - 1):
sim = cosine_similarity(embeddings[i], embeddings[i + 1])
similarities.append(sim)
# 在相似度低于阈值的地方切分
chunks = []
current_chunk = [sentences[0]]
for i, sim in enumerate(similarities):
current_text = "".join(current_chunk)
if sim < threshold and len(current_text) >= min_chunk_size:
chunks.append(current_text.strip())
current_chunk = [sentences[i + 1]]
elif len(current_text) > max_chunk_size:
chunks.append(current_text.strip())
current_chunk = [sentences[i + 1]]
else:
current_chunk.append(sentences[i + 1])
# 最后一段
if current_chunk:
remaining = "".join(current_chunk).strip()
if remaining:
chunks.append(remaining)
return chunks
优缺点
| 优点 | 缺点 |
|---|---|
| 切片边界最自然,语义完整性最好 | 需要调用 Embedding API,有成本 |
| 不依赖固定规则,适应各种文本结构 | 速度最慢(要对每个句子做 Embedding) |
| 检索效果通常最优 | threshold 参数需要调优 |
适用场景:高质量要求的知识库(法律、医疗、金融),文档量不大但准确率要求极高的场景。
三种策略对比
| 对比项 | 固定长度 | 递归切片 | 语义切片 |
|---|---|---|---|
| 实现复杂度 | ★ | ★★ | ★★★★ |
| 切片质量 | ★★ | ★★★★ | ★★★★★ |
| 速度 | ★★★★★ | ★★★★ | ★★ |
| 成本 | 零 | 零 | 需要 Embedding API |
| 参数敏感度 | 低 | 中 | 高 |
| 推荐场景 | 兜底/快速原型 | 通用默认方案 | 高精度场景 |
我的实际选择:90% 的场景用递归切片就够了。只有对准确率有极致要求的垂直领域(比如法律合同分析),才上语义切片。
不同文件类型的最佳切片策略
不同类型的内容适合不同的切片参数:
普通文档(技术文档、SOP、方案)
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=100,
separators=["\n\n", "\n", "。", ".", " ", ""],
)
代码文件
代码需要保持函数/类的完整性,分隔符要用代码结构:
code_splitter = RecursiveCharacterTextSplitter(
chunk_size=1500,
chunk_overlap=200,
separators=[
"\nclass ", # 类定义
"\ndef ", # 函数定义
"\n\ndef ", # 顶级函数
"\n\n", # 空行
"\n",
" ",
"",
],
)
LangChain 也提供了语言专用的切片器:
from langchain_text_splitters import Language, RecursiveCharacterTextSplitter
python_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=1500,
chunk_overlap=200,
)
js_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.JS,
chunk_size=1500,
chunk_overlap=200,
)
表格数据
表格(Excel 转成的 Markdown)尽量不要切开行:
table_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=50,
separators=["\n## Sheet:", "\n\n", "\n"],
)
FAQ / 问答对
FAQ 天然以问答为单位,每个 Q&A 就是一个 chunk:
def split_faq(text: str) -> list[str]:
"""按 Q&A 对切片"""
pattern = r'(Q[::].*?(?=Q[::]|\Z))'
matches = re.findall(pattern, text, re.DOTALL)
return [m.strip() for m in matches if m.strip()]
切片质量评估
切完之后怎么知道效果好不好?不能只凭感觉——需要量化指标。
评估维度
| 指标 | 说明 | 怎么算 |
|---|---|---|
| 平均长度 | 切片太短信息量不足,太长检索精度差 | mean(len(chunk) for chunk in chunks) |
| 长度方差 | 方差太大说明切片不均匀 | std(len(chunk) for chunk in chunks) |
| 边界完整性 | 有多少切片以完整句子结尾 | 以句号结尾的切片占比 |
| 信息密度 | 切片内有效信息的比例 | 去除空白后的字符占比 |
评估脚本
import statistics
def evaluate_chunks(chunks: list[str]) -> dict:
"""评估切片质量"""
lengths = [len(c) for c in chunks]
# 边界完整性:以句号/问号/感叹号结尾的比例
sentence_endings = "。.!?!?"
complete = sum(1 for c in chunks if c and c[-1] in sentence_endings)
boundary_score = complete / len(chunks) if chunks else 0
# 信息密度:非空白字符占比
densities = []
for c in chunks:
non_space = len(c.replace(" ", "").replace("\n", "").replace("\t", ""))
densities.append(non_space / len(c) if c else 0)
return {
"total_chunks": len(chunks),
"avg_length": statistics.mean(lengths) if lengths else 0,
"std_length": statistics.stdev(lengths) if len(lengths) > 1 else 0,
"min_length": min(lengths) if lengths else 0,
"max_length": max(lengths) if lengths else 0,
"boundary_completeness": round(boundary_score, 3),
"avg_density": round(statistics.mean(densities), 3) if densities else 0,
}
def compare_strategies(text: str) -> None:
"""对比三种切片策略"""
from langchain_text_splitters import (
CharacterTextSplitter,
RecursiveCharacterTextSplitter,
)
strategies = {
"固定长度": CharacterTextSplitter(
separator="", chunk_size=800, chunk_overlap=100,
),
"递归切片": RecursiveCharacterTextSplitter(
chunk_size=800, chunk_overlap=100,
separators=["\n\n", "\n", "。", ".", " ", ""],
),
}
print(f"原文长度: {len(text)} 字符\n")
print(f"{'策略':<10} {'切片数':<8} {'平均长度':<10} {'标准差':<10} {'边界完整性':<12} {'信息密度':<10}")
print("-" * 65)
for name, splitter in strategies.items():
chunks = splitter.split_text(text)
metrics = evaluate_chunks(chunks)
print(
f"{name:<10} "
f"{metrics['total_chunks']:<8} "
f"{metrics['avg_length']:<10.0f} "
f"{metrics['std_length']:<10.0f} "
f"{metrics['boundary_completeness']:<12} "
f"{metrics['avg_density']:<10}"
)
使用示例:
compare_strategies(long_document_text)
输出类似:
原文长度: 15000 字符
策略 切片数 平均长度 标准差 边界完整性 信息密度
-----------------------------------------------------------------
固定长度 19 789 12 0.316 0.952
递归切片 21 714 156 0.857 0.948
可以看到递归切片的边界完整性(0.857)远高于固定长度(0.316),意味着绝大部分切片都在完整句子处结束。
进阶:给切片加元数据
光有文本内容还不够,生产环境中每个切片都应该带上元数据,方便后续检索和溯源:
from dataclasses import dataclass
@dataclass
class Chunk:
content: str
metadata: dict
@property
def char_count(self) -> int:
return len(self.content)
def split_with_metadata(
text: str,
filename: str,
chunk_size: int = 800,
chunk_overlap: int = 100,
) -> list[Chunk]:
"""切片并附加元数据"""
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=["\n\n", "\n", "。", ".", " ", ""],
)
raw_chunks = splitter.split_text(text)
chunks = []
for i, content in enumerate(raw_chunks):
chunks.append(Chunk(
content=content,
metadata={
"source": filename,
"chunk_index": i,
"total_chunks": len(raw_chunks),
"char_count": len(content),
},
))
return chunks
这些元数据在检索后回传给 LLM 时非常有用:
来源: 技术方案.pdf | 第 3/15 段
和上一篇的衔接:完整 Pipeline
结合上一篇的 FileParser,现在可以串成一个完整的”解析 → 切片”链路:
def parse_and_chunk(filepath: str) -> list[Chunk]:
"""文件解析 + 切片的完整流程"""
parser = FileParser()
doc = parser.parse(filepath)
chunks = split_with_metadata(
text=doc.content,
filename=doc.filename,
chunk_size=800,
chunk_overlap=100,
)
# 把文档级元数据合并进去
for chunk in chunks:
chunk.metadata.update({
"file_type": doc.file_type,
**doc.metadata,
})
return chunks
# 使用
chunks = parse_and_chunk("./docs/技术方案.pdf")
print(f"解析并切片完成:{len(chunks)} 个切片")
for c in chunks[:3]:
print(f" [{c.metadata['chunk_index']}] {c.content[:80]}...")
chunk_size 调参经验
最后分享一些我在实际项目中总结的调参经验:
| 场景 | chunk_size | chunk_overlap | 原因 |
|---|---|---|---|
| 通用文档 | 800 | 100 | 平衡精度和上下文 |
| FAQ 知识库 | 300-500 | 50 | 问答对本身就短 |
| 法律合同 | 500-700 | 150 | 条款需要完整,overlap 大一些 |
| 技术文章 | 1000 | 150 | 段落较长,需要更多上下文 |
| 代码 | 1500 | 200 | 函数体通常较长 |
| API 文档 | 600 | 80 | 每个接口描述独立且短 |
通用原则:先用 800/100 跑起来,用评估脚本看效果,再针对性地调。不要一开始就追求完美参数——先跑通再优化。
总结
- 切片质量直接决定 RAG 效果——garbage chunk in, garbage answer out。
- 三种策略各有适用场景:固定长度最简单但最粗暴,递归切片是默认首选,语义切片精度最高但最贵。
- 递归切片是 90% 场景的最佳选择——用
RecursiveCharacterTextSplitter,加上中文标点分隔符。 - 不同内容类型需要不同参数:代码要大 chunk,FAQ 要小 chunk,合同要大 overlap。
- 切片要带元数据(来源文件、序号、总数),方便检索溯源。
- 用量化指标评估切片质量:平均长度、边界完整性、信息密度,不要凭感觉。
下一篇我们解决知识库的另一个工程难题:用户上传 100MB 的 PDF,怎么不卡不断不崩? 分片上传 + 断点续传 + 实时进度追踪的完整前后端方案。
讨论话题:你的 RAG 项目用的什么切片策略?chunk_size 设的多少?有没有遇到过切片导致检索不准的问题?评论区聊聊。