RAG 分块策略全面对比——我踩过的 5 个坑
RAG 分块策略全面对比——我踩过的 5 个坑
适读人群:正在做或准备做 RAG 系统的开发者 | 阅读时长:约 15 分钟 | 核心价值:用真实测试数据帮你少走弯路
去年有个项目,客户是一家做建筑法规的律所,文档里全是这种句子:
"根据《建筑法》第四十七条第二款之规定,施工单位应当……同时参照住建部2021年第8号公告……"
我当时用的是最朴素的按固定字符数分块,chunk_size=512,overlap=50。
上线第一周,客户就投诉了:"明明文档里有这个规定,系统就是找不到。"
我调了两天,发现问题所在:一个完整的法规引用被切在了两个 chunk 的边界,检索的时候两边都匹配不上,就像把一把钥匙从中间锯断,两半都开不了锁。
从那之后我认真研究了分块策略,踩了不少坑,今天把这些整理出来。
分块这件事,大多数人想简单了
很多教程把分块说得很轻描淡写,好像就是个切文本的活儿,随便怎么切都行。
现实是:分块策略决定了你的 RAG 系统的上限。向量模型再好、LLM 再强,如果检索回来的 chunk 本身就是语义残缺的,生成质量一定差。
我把自己踩过的坑归成 5 类,对应 5 种分块场景的失败。
坑 1:Naive 固定大小分块——法规文档的灾难
这是最常见的做法,也是问题最多的做法。
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separators=["\n\n", "\n", "。", ",", " ", ""]
)
docs = splitter.split_text(text)问题不在代码本身,在于对 chunk_size 的理解。
实测数据(法规文档,约200页):
| chunk_size | 召回率@5 | 平均 chunk 语义完整性(人工评分) |
|---|---|---|
| 256 | 61% | 3.2/5 |
| 512 | 68% | 3.6/5 |
| 1024 | 71% | 4.0/5 |
| 2048 | 69% | 4.2/5 |
看起来 1024 是个平衡点,但这是平均值,掩盖了真实问题。
真正的问题是:文档里有些关键信息段天然就是跨越了 1024 字符的。比如一个完整的合同条款,可能有 1500 字,不管你 chunk_size 设多少,必然被切断。
这个坑的本质:固定大小分块对文档结构是盲目的。
坑 2:只依赖 separator——Markdown 文档的假象
有人说,用 \n\n 做分隔符不就行了,段落自然分开了。
这个思路对于干净的 Markdown 文档确实不错,但大多数真实文档不是这样的。
我遇到过一个知识库项目,文档是研发团队自己写的 Wiki,有人喜欢一段写两三百字,有人喜欢一段写两千字。用 \n\n 分出来的 chunk 大小从 50 字到 3000 字不等,向量模型对这种极端长度差异的处理很差。
更隐蔽的问题是:有的文档里,相关内容就是用 \n\n 分开的,但它们在语义上强相关。比如:
## 部署要求
操作系统:CentOS 7.6 或以上
内存:最低 16GB,推荐 32GB
## 安装步骤
1. 执行以下命令安装依赖……部署要求 和 安装步骤 之间有强依赖关系,但用 \n\n 分块会把它们完全分开。用户问"安装需要什么内存",检索到的是 安装步骤 那个 chunk,里面没有内存信息,回答就会错。
坑 3:Semantic Chunking 不是万能的
Semantic Chunking 的思路是:计算相邻句子之间的语义相似度,相似度突降的地方就是 chunk 边界。
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings()
splitter = SemanticChunker(
embeddings,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=95 # 相似度跌到前5%就切断
)
docs = splitter.create_documents([text])听起来很聪明,实测发现两个严重问题。
问题一:技术文档里的代码块
代码和它的说明文字,语义相似度很低。Semantic Chunker 会把代码单独切成一个 chunk,但单独的代码块没有上下文,检索到了也没用。
问题二:成本和速度
对一份 10 万字的文档做 Semantic Chunking,需要对每个句子调用 Embedding 模型,API 成本大概是 Naive 分块的 30-50 倍,速度也慢了一个数量级。
在处理大批量文档时,这个成本是不可接受的。
我的实测对比(同一份技术手册,100 个测试问题):
| 方法 | 召回率@5 | 处理1万字耗时 | API成本/万字 |
|---|---|---|---|
| Naive 512 | 68% | 0.1s | $0.001 |
| Semantic Chunking | 73% | 4.2s | $0.03 |
| 提升 | +5% | 42x慢 | 30x贵 |
5% 的召回率提升,换来 30 倍成本,值不值自己算。
坑 4:Late Chunking 的适用边界
Late Chunking 是 2024 年 jina-embeddings-v3 推出时带火的概念,思路很不一样:
普通做法:先切 chunk,再对每个 chunk 独立 Embedding。 Late Chunking:先对整个文档做 Token-level Embedding,再按位置切分,每个 chunk 的向量是带了全文上下文信息的。
这个思路解决了一个真实问题:代词问题。
# 文档片段示例
"小李是我们团队的主程序员。他在2019年加入公司,负责后端架构……
三年后,他带领团队完成了整个系统的重构。"如果切成两个 chunk:
- Chunk 1:小李是我们团队的主程序员……
- Chunk 2:三年后,他带领团队完成了整个系统的重构。
Chunk 2 里的"他"指代谁,独立 Embedding 时模型不知道,向量质量就差。
Late Chunking 因为是对全文一起 Embed 再切,"他"的向量是带有"小李"这个上下文的。
用 jina-embeddings-v3 实现 Late Chunking:
import numpy as np
from transformers import AutoTokenizer, AutoModel
import torch
def late_chunking_embed(text: str, chunk_spans: list[tuple[int, int]]) -> list[np.ndarray]:
"""
text: 完整文档文本
chunk_spans: [(start_char, end_char), ...] 字符级别的切分位置
返回: 每个chunk的向量列表
"""
tokenizer = AutoTokenizer.from_pretrained("jinaai/jina-embeddings-v3", trust_remote_code=True)
model = AutoModel.from_pretrained("jinaai/jina-embeddings-v3", trust_remote_code=True)
# 对全文 tokenize
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=8192)
with torch.no_grad():
outputs = model(**inputs, output_hidden_states=True)
# 取最后一层的 token embeddings
token_embeddings = outputs.last_hidden_state[0] # [seq_len, hidden_dim]
# 把字符位置映射到 token 位置
char_to_token = inputs.char_to_token # 这个方法在某些 tokenizer 里有
chunk_vectors = []
for start_char, end_char in chunk_spans:
# 找到这个 chunk 对应的 token 范围
start_token = inputs.char_to_token(0, start_char)
end_token = inputs.char_to_token(0, end_char - 1)
if start_token is None or end_token is None:
continue
# 对这个 token 范围内的 embedding 做 mean pooling
chunk_embedding = token_embeddings[start_token:end_token + 1].mean(dim=0)
chunk_vectors.append(chunk_embedding.numpy())
return chunk_vectors但是,Late Chunking 有个硬限制:文档不能超过模型的 context window。jina-embeddings-v3 最大 8192 tokens,超过了就得截断,截断之后全文上下文就没了,Late Chunking 的优势也没了。
对于动辄几十万字的长文档,Late Chunking 目前用不了。
坑 5:忽略了文档类型差异
这是最容易被忽视的坑:不同类型的文档,最优分块策略完全不同。
我自己总结的经验:
结构化文档(法规、合同、规范)
用基于标题层级的分块,保留完整的条款结构:
import re
from dataclasses import dataclass
from typing import Optional
@dataclass
class DocChunk:
content: str
level: int # 标题层级 1/2/3
title: str # 所属标题
parent_title: str # 父级标题
def hierarchical_split(text: str, max_chunk_size: int = 1500) -> list[DocChunk]:
"""按标题层级切分,保留层级上下文"""
# 识别标题行(Markdown格式)
title_pattern = re.compile(r'^(#{1,3})\s+(.+)$', re.MULTILINE)
chunks = []
last_pos = 0
title_stack = {1: "", 2: "", 3: ""} # 各层级当前标题
current_level = 0
current_title = ""
for match in title_pattern.finditer(text):
level = len(match.group(1))
title = match.group(2).strip()
# 保存上一段内容
if match.start() > last_pos:
content = text[last_pos:match.start()].strip()
if content:
parent = title_stack.get(current_level - 1, "")
chunk = DocChunk(
content=content,
level=current_level,
title=current_title,
parent_title=parent
)
# 如果内容太长,再做二次切分
if len(content) > max_chunk_size:
sub_chunks = _secondary_split(content, max_chunk_size,
current_level, current_title, parent)
chunks.extend(sub_chunks)
else:
chunks.append(chunk)
# 更新标题栈
title_stack[level] = title
# 清除更深层级的标题
for l in range(level + 1, 4):
title_stack[l] = ""
current_level = level
current_title = title
last_pos = match.end() + 1
# 处理最后一段
if last_pos < len(text):
content = text[last_pos:].strip()
if content:
parent = title_stack.get(current_level - 1, "")
chunks.append(DocChunk(
content=content,
level=current_level,
title=current_title,
parent_title=parent
))
return chunks
def _secondary_split(content: str, max_size: int, level: int,
title: str, parent: str) -> list[DocChunk]:
"""对过长内容做二次分割,保留段落完整性"""
paragraphs = content.split('\n\n')
result = []
current_text = ""
for para in paragraphs:
if len(current_text) + len(para) > max_size and current_text:
result.append(DocChunk(
content=current_text.strip(),
level=level,
title=title,
parent_title=parent
))
current_text = para
else:
current_text += "\n\n" + para if current_text else para
if current_text:
result.append(DocChunk(
content=current_text.strip(),
level=level,
title=title,
parent_title=parent
))
return result关键在于:每个 chunk 存入向量库时,把 title 和 parent_title 也作为 metadata 存进去,检索到 chunk 后,回答时把这个上下文带上。
技术文档(有代码块)
代码块必须单独处理,不能被切断:
def split_with_code_awareness(text: str, max_text_size: int = 800) -> list[dict]:
"""识别代码块,代码块保持完整,文本部分按大小切分"""
code_block_pattern = re.compile(r'```[\s\S]*?```', re.MULTILINE)
chunks = []
last_end = 0
for match in code_block_pattern.finditer(text):
# 处理代码块之前的文本
text_before = text[last_end:match.start()].strip()
if text_before:
# 文本部分按段落切分
paragraphs = text_before.split('\n\n')
current = ""
for p in paragraphs:
if len(current) + len(p) > max_text_size and current:
chunks.append({"type": "text", "content": current.strip()})
current = p
else:
current += "\n\n" + p if current else p
if current:
chunks.append({"type": "text", "content": current.strip()})
# 代码块整体保留
code_content = match.group(0)
# 如果代码块超过阈值,加上前面最近的文本chunk作为上下文
if chunks and chunks[-1]["type"] == "text":
# 把代码块和最近的说明文字合并
combined = chunks[-1]["content"] + "\n\n" + code_content
if len(combined) < 3000: # 合并后不超过3000字
chunks[-1] = {"type": "code_with_context", "content": combined}
else:
chunks.append({"type": "code", "content": code_content})
else:
chunks.append({"type": "code", "content": code_content})
last_end = match.end()
# 处理最后的文本
remaining = text[last_end:].strip()
if remaining:
chunks.append({"type": "text", "content": remaining})
return chunks我现在用什么方案
经过大半年的实际项目,我的选择很简单:
生产环境不追求复杂,追求可控。
我的默认方案是分两步走:
- 按文档类型做结构化预处理(识别标题层级、代码块、表格)
- 在结构边界内做 Recursive 分块,保证语义完整性
Semantic Chunking 只在文档类型比较混乱、没有明显结构的时候才用,而且要控制成本。
Late Chunking 目前只在文档比较短(5000字以内)且需要处理大量代词指代的场景用,其他场景性价比不高。
一个容易被忽视的评估问题
很多人测试分块效果,用的是"召回率",测的是"能不能检索到"。
但更重要的是"检索到的 chunk 是否完整可用"。
我建议每个 RAG 项目都要手工评估 100-200 个真实用户问题,对检索结果做两个维度评分:
- 相关性:检索到的 chunk 和问题是否相关(0-5分)
- 完整性:检索到的 chunk 是否提供了足够回答问题的信息(0-5分)
光有相关性没有完整性,一样回答不好问题。这个教训我花了两个月才真正搞清楚。
