RAG 文档切分不是越细越好:选择 Chunk Size 与 Overlap
摘要:切分决定了检索系统能看见什么
RAG 项目中最容易被低估的步骤不是向量数据库选型,而是文档切分。原文进入模型之前先被拆成 Chunk,随后每个 Chunk 生成向量、参与召回并被拼入上下文。切得过大,一个块同时包含多个主题,向量被平均,检索命中后还携带大量无关内容;切得过小,定义、前提、例外和代码解释被分散,模型虽然找到关键词,却无法回答完整问题。
因此不存在一个适用于所有知识库的固定答案,例如"500 字加 50 字重叠"。Chunk Size、Overlap、TopK、Embedding 模型、文档结构和问题类型彼此耦合。合理做法是先保留文档语义结构,再设计少量候选策略,通过离线检索指标和端到端回答质量选择参数。
本文从业务问题、切分方案、父子块架构、代码与表格处理、Python 实现、元数据、权限、安全、评测和性能采集展开,给出一套不依赖虚构"最佳参数"的工程方法。
一、先定义 Chunk Size 到底按什么计算
"每块 500"如果不说明单位,没有比较价值。它可能表示 500 个 Unicode 字符、500 个空格分词、500 个模型 Token,也可能是解析器估算的字节数。中文一个字与一个 Token 并非恒等,代码、URL、数字和英文缩写的比例又不同。最终上下文受模型 Token 窗口约束,所以生产系统应以目标模型或近似 Tokenizer 的 Token 数作为主要预算,字符数只能用于快速预估。
还要区分三种长度:
- 索引块长度:生成 Embedding 并参与向量召回的文本长度;
- 返回块长度:检索命中后真正返回给编排层的文本长度;
- 上下文长度:去重、扩展邻居、重排后送给大模型的总长度。
在最简单的系统中三者相同;在父子块方案中,小块用于索引,父章节用于返回;在窗口扩展方案中,一个小块命中后会补充前后块。讨论参数时必须说明是哪一层,否则团队会出现"索引块明明 300 Token,为何一次召回消耗了 5000 Token"的争论。
二、从文档结构开始,而不是先按长度切
PDF、Word、Markdown 和网页不是纯文本容器。标题层级表示章节边界,列表表示并列关系,表格的表头决定单元格含义,代码围栏保护语法结构,图片标题可能解释后文。若先把解析结果拼成一个长字符串再固定截断,后续再强的检索模型也无法恢复已经丢失的结构。
推荐的摄取流水线是:
- 解析原始文件,保留页码、标题、段落、列表、表格、代码块和图片说明;
- 清理页眉页脚、重复导航和乱码,但保留业务标识、错误码与单位;
- 按章节形成逻辑节点,计算每个节点的 Token 长度;
- 小节点在同一父章节内合并,超长节点按句子或语义边界递归拆分;
- 生成索引块、父块和相邻关系,并继承访问权限与版本信息;
- 生成 Embedding,写入向量索引和关键词索引;
- 用固定评测集验证后再发布索引版本。
结构优先并不意味着每个段落都独立成块。一个只有"注意事项"标题和一句"仅管理员可执行"的短段落,若脱离父章节会失去对象。合并时应限制在同一标题路径下,不要跨章节把两个短段落机械拼接。
三、常见切分方案与适用场景
3.1 固定字符或固定 Token 窗口
固定窗口实现简单、吞吐稳定,适合结构极差的日志、转写文本或早期基线。缺点是会从句子、表格或代码中间截断。它最适合用来建立"最低基准",而不是默认最终方案。
3.2 递归结构切分
先按标题、段落、句子等分隔符拆分,仍过长时再按 Token 截断。它能保留大部分人工结构,成本可控,是通用企业文档的推荐起点。不同语言的句末符号和 Markdown 结构必须分别处理,不能只按英文句号。
3.3 语义切分
计算相邻句向量差异,在主题突变处切分。它能发现没有标题的主题边界,但需要额外 Embedding 计算,阈值对文档类型敏感,也可能把"问题---答案"因语义差异较大而拆开。适合访谈、会议记录和长篇自然语言,不宜盲目替代结构解析。
3.4 父子块或 Small-to-Big
小块生成向量以提高定位精度,命中后返回父章节或更大窗口,兼顾召回和上下文。它适合制度手册、技术文档与代码解释,但要控制父块上限,并在多个子块命中同一父块时去重。
3.5 业务对象切分
FAQ 可以一问一答为一个对象,API 文档可以一个 endpoint 为单位,产品目录可以一个 SKU 为单位,法规可以一条款为单位。业务对象边界往往比通用算法更可靠。代价是需要领域解析器,文档格式变化时要维护。
工程上常用的不是单选,而是组合:先识别业务对象,再按标题递归拆分;超长自然语言节点用语义边界辅助;索引采用小块,返回采用父块。
四、Chunk Size、Overlap 与 TopK 的联动
小块通常主题更集中,精确问题更容易命中,但完整答案可能分布在多个块中,需要更大 TopK 或邻居扩展。大块上下文更完整,却会降低向量区分度,占用更多输入 Token,并让重排器处理更长文本。Overlap 能保留边界附近的语义,却会增加索引量和重复召回。
假设原文长度为 L,块大小为 S,重叠为 O,粗略块数可按 ceil((L - O) / (S - O)) 估算。当 O 接近 S 时,块数快速增加。重叠 20% 不只是增加约 20% 存储;它还可能让 TopK 被相似副本占满,最终上下文重复率明显升高。
参数调整需要成组进行。例如,把索引块从 600 Token 降到 250 Token,却保持 TopK=3,很可能召不全跨段答案;把 TopK 提到 12,又可能引入大量相邻重复。因此实验应至少记录:
| 变量 | 候选示例 | 观察重点 |
|---|---|---|
| 索引块 Token | 200、400、700 | Recall、主题纯度、块数量 |
| Overlap | 0、10%、20% | 边界问题、重复率、索引成本 |
| TopK | 3、6、10 | 召回增益与上下文膨胀 |
| 返回策略 | 原块、邻居窗口、父块 | 完整性、噪声与延迟 |
| Rerank 数量 | 0、20、50 | 排名质量和精排成本 |
表中的数字只是实验网格示例,不是推荐结论。最终值必须由自己的文档分布、模型和评测问题决定。
五、代码、表格和列表需要专门规则
代码块应尽量保持完整,并附带文件语言、类名、方法名和所属标题。一个方法超过上限时,可以按类、方法或语法树切分,而不是从任意行截断。索引文本可以在代码前加入短描述,例如"OrderService.cancel:取消未支付订单",提高自然语言查询的命中率;但描述必须可追溯,自动生成内容要标记来源,避免把模型幻觉当成代码事实。
表格不能只抽取单元格。每一行都必须继承表头和必要的多级表头,例如将"高级版 | 100 | 20GB"转成"套餐=高级版;月费=100 元;存储=20GB"。大表可以按行组切分,但每个块重复表头是有意义的结构冗余。涉及跨行合计的问题,返回时需要扩展到完整表或通过结构化查询解决。
列表要区分并列项与步骤。步骤列表被拆开后应保留序号和父标题,否则"执行第三步"没有上下文。定义列表应尽量把术语和解释放在同一块。OCR 文档还要保留页码和版面置信度;低置信度文本可进入人工校验队列,不能与高质量正文同等参与答案生成。
六、父子块数据模型与检索数据流
一个可用的 Chunk 至少应保存:
json
{
"chunkId": "doc-42:v7:c018",
"documentId": "doc-42",
"documentVersion": 7,
"parentId": "doc-42:v7:s004",
"sectionPath": ["部署手册", "网络配置", "代理设置"],
"ordinal": 18,
"tokenCount": 326,
"contentHash": "sha256...",
"sourcePage": 12,
"acl": ["tenant:1001", "role:ops"],
"validFrom": "2026-07-01T00:00:00Z"
}
查询时先用小块做向量与关键词召回,应用权限和文档版本过滤,再融合排名。若选择父块返回,则按 parentId 聚合子块分数,每个父块只保留一次;若采用邻居扩展,则按 ordinal 获取前后窗口。之后进行去重、精排和 Token 预算裁剪,最后把来源信息一起交给生成模型。
权限过滤应尽量发生在召回阶段,而不是把无权内容取出后再删除。后过滤可能导致 TopK 全被无权结果占满,同时向应用暴露了敏感文本。向量库不支持复杂 ACL 时,可以按租户分索引、预过滤候选 ID,或者扩大候选后严格后过滤并补召回,但必须评估隔离风险。
七、一个可测试的 Python 递归切分实现
下面示例体现三个原则:只在同一章节内合并;长度按注入的 Tokenizer 计算;Overlap 从上一块尾部选取完整句子。它不是针对所有格式的解析器,而是一个便于单元测试的核心切分器。
python
from dataclasses import dataclass
from typing import Callable, Iterable
@dataclass(frozen=True)
class Segment:
"""
最小文本片段单元(不可变数据类)
代表原始文档经过初步切分后的基础文本单元,归属同一个章节路径
:param section: 章节层级路径,例 ("第一章", "1.1"),用于区分不同章节,跨章节不能合并
:param text: 当前片段文本内容
:param ordinal: 全局序号,用于追踪Chunk由哪些原始Segment组成
"""
section: tuple[str, ...]
text: str
ordinal: int
@dataclass(frozen=True)
class Chunk:
"""
最终输出的文本块(RAG检索用chunk,不可变)
由若干连续Segment拼接而成,携带溯源信息
:param section: 所属章节路径,同一个Chunk内所有Segment章节必须一致
:param text: 拼接完成后的chunk完整文本
:param source_ordinals: 构成该chunk的所有Segment序号元组,用于溯源
:param token_count: 当前chunk文本token数量
"""
section: tuple[str, ...]
text: str
source_ordinals: tuple[int, ...]
token_count: int
class RecursiveChunker:
"""
支持章节隔离、滑动窗口重叠、超长片段递归拆分的文本分块器
核心特性:
1. 不同section(章节)的Segment不会合并到同一个Chunk
2. 达到max_tokens时自动切分,并保留尾部overlap_tokens长度文本作为下一块重叠上下文
3. 单个Segment文本超过max_tokens时,按句子二次拆分
"""
def __init__(
self,
count_tokens: Callable[[str], int],
max_tokens: int,
overlap_tokens: int,
) -> None:
"""
:param count_tokens: token计数函数,输入文本返回token数量(外部传入模型tokenizer)
:param max_tokens: 单个Chunk允许的最大token上限
:param overlap_tokens: 相邻Chunk之间保留的重叠上下文token数量
"""
if max_tokens <= 0:
raise ValueError("max_tokens must be positive")
if overlap_tokens < 0 or overlap_tokens >= max_tokens:
raise ValueError("overlap_tokens must be in [0, max_tokens)")
self.count_tokens = count_tokens
self.max_tokens = max_tokens
self.overlap_tokens = overlap_tokens
def split(self, segments: Iterable[Segment]) -> list[Chunk]:
"""
入口方法:批量处理Segment,生成Chunk列表
逻辑:按章节分组,同一章节Segment送入_flush执行滑动窗口分块;跨章节立即刷新组
:param segments: 原始Segment迭代器
:return: 切分完成的Chunk列表
"""
chunks: list[Chunk] = []
group: list[Segment] = [] # 存放当前同一章节的Segment组
for segment in segments:
# 章节发生变化:先把上一组所有Segment切分为Chunk,清空分组
if group and group[-1].section != segment.section:
chunks.extend(self._flush(group))
group = []
group.append(segment)
# 处理最后一组剩余Segment
chunks.extend(self._flush(group))
return chunks
def _flush(self, segments: list[Segment]) -> list[Chunk]:
"""
对**同一章节**的一组Segment执行滑动窗口重叠分块主逻辑
:param segments: 同章节连续Segment列表
:return: 当前章节生成的Chunk列表
"""
result: list[Chunk] = []
current: list[Segment] = [] # 正在累积、待组成Chunk的片段
for segment in segments:
# 如果单个Segment超长,先拆成多个小片段
pieces = self._split_oversized(segment)
for piece in pieces:
candidate = current + [piece]
text = "\n".join(item.text for item in candidate)
# 加入新片段后超出token上限 → 需要生成一块,并且构造重叠窗口
if current and self.count_tokens(text) > self.max_tokens:
result.append(self._to_chunk(current))
# 截取尾部片段作为重叠上下文
current = self._tail_for_overlap(current)
# 保证重叠片段 + 当前piece依然不超限;超限则不断丢弃头部片段
while current:
overlapped = current + [piece]
overlapped_text = "\n".join(item.text for item in overlapped)
if self.count_tokens(overlapped_text) <= self.max_tokens:
break
current = current[1:]
# 将当前片段加入累积队列
current.append(piece)
# 循环结束,把剩余未输出的片段打包成最后一个Chunk
if current:
result.append(self._to_chunk(current))
return result
def _split_oversized(self, segment: Segment) -> list[Segment]:
"""
处理超长Segment:单段文本token超过max_tokens时,按句子拆分成多个小Segment
:param segment: 原始超长片段
:return: 拆分后的若干新Segment,继承原section、ordinal序号
"""
if self.count_tokens(segment.text) <= self.max_tokens:
return [segment]
# 外部函数:文本切分为句子列表
sentences = split_sentences(segment.text)
# 外部函数:句子打包,保证每组token不超过上限
return [
Segment(segment.section, text, segment.ordinal)
for text in pack_sentences(
sentences, self.count_tokens, self.max_tokens
)
]
def _tail_for_overlap(self, items: list[Segment]) -> list[Segment]:
"""
从当前Segment列表尾部截取文本,总token不超过overlap_tokens,用作下一块重叠上下文
:param items: 当前组成Chunk的所有Segment
:return: 尾部重叠片段列表
"""
tail: list[Segment] = []
# 逆序从末尾向前收集片段
for item in reversed(items):
candidate = [item] + tail
text = "\n".join(value.text for value in candidate)
# 叠加后超过重叠token上限,停止收集
if self.count_tokens(text) > self.overlap_tokens:
break
tail = candidate
return tail
def _to_chunk(self, items: list[Segment]) -> Chunk:
"""
将一组连续Segment组装成最终Chunk对象
:param items: 构成一个Chunk的连续Segment
:return: Chunk实例
"""
text = "\n".join(item.text for item in items)
return Chunk(
section=items[0].section,
text=text,
source_ordinals=tuple(item.ordinal for item in items),
token_count=self.count_tokens(text),
)
split_sentences 和 pack_sentences 应针对语言实现,并对单个超长句、URL、代码块设置兜底。若一个没有句号的日志行超过上限,最终仍要按 Token 硬切,否则算法可能生成超大块。刷新旧块后还必须重新检查"Overlap 尾部 + 新 piece",示例会从最旧的重叠句开始丢弃,直到组合重新落入 max_tokens;不能因为 Overlap 本身未超上限,就默认与下一块拼接后也未超限。单元测试需验证空输入、恰好上限、单段超长、章节切换、Overlap 上限、Overlap 与大 piece 组合以及 Unicode 文本。
生产实现还应生成稳定 Chunk ID。推荐使用 documentId + documentVersion + ordinal + contentHash,不要只用数据库自增 ID。这样索引重建时能识别内容是否真的变化,也能准确删除旧版本。
八、离线评测:先测检索,再测最终回答
评测集不能只由开发者随手写十个问题。应从搜索日志、客服问题、文档 FAQ 和领域专家中采集,并按类型分层:
- 精确标识问题,如错误码、型号、接口名;
- 同义改写问题,不直接出现文档关键词;
- 需要同段多句才能回答的问题;
- 跨相邻段或跨章节组合问题;
- 表格、代码和列表问题;
- 文档中不存在答案、应该拒答的问题;
- 不同租户权限下结果应不同的问题。
每个问题标注相关文档与可接受 Chunk 集合。检索层可计算 Recall@K、MRR、NDCG、命中父章节率和无权结果率。生成层观察答案正确性、证据支持率、引用准确率、拒答准确率和上下文利用率。性能层记录 Embedding 数量、索引体积、检索 P95、Rerank P95、上下文 Token 与单次成本。
Recall@K 高不代表回答一定好:相关块可能排在最后,被 Token 裁剪;也可能多个重复块挤掉互补证据。最终回答正确率高也不能掩盖权限泄漏。因此要将检索、生成、安全和成本分层看待。
评测结果必须保存实验配置,包括解析器版本、Chunk 参数、Embedding 模型、索引版本、融合策略、Reranker、TopK 和 Prompt 版本。否则下周"同样参数"无法复现。没有真实测试环境时,可以给出指标定义和采集脚本,但不能编造某参数提升了多少百分点。
九、线上观测与性能优化
线上重点关注零结果率、低分结果率、查询改写次数、候选重复率、父块膨胀率、最终上下文 Token、引用点击或人工纠错,以及按文档类型拆分的延迟。不要把 query 原文直接作为指标标签,它既高基数又可能含敏感信息;原文进入受控日志或样本库前应脱敏。
切分越细,Embedding 数量、索引存储和更新成本越高。文档更新时可按 contentHash 增量重建,只对变化章节生成向量;发布新索引采用别名或版本指针原子切换,确认新版本健康后再回收旧索引。删除文档不仅要删向量,还要删关键词索引、父块存储和缓存。
Rerank 是常见延迟热点。可以并行执行向量与 BM25,融合后只对前几十个候选精排;设置严格超时,精排超时时退化到融合排名。父块读取适合批量查询,避免每个候选单独访问对象存储产生 N+1 请求。
十、异常、安全与数据治理边界
检索到的文档属于不可信数据,文档中可能包含"忽略系统指令"一类间接提示词注入。切分时保留来源和信任等级,生成阶段明确把内容作为参考资料,工具执行权限仍由确定性代码控制。不能因为内容来自企业知识库就默认可信,上传者账号也可能被盗。
文档解析失败要区分可重试和永久错误。对象存储超时可以重试;加密 PDF 缺少密码、格式损坏或无权限属于待人工处理。部分页面失败时不要悄悄标记整份文档"索引成功",应记录页级状态并阻止未完整版本成为正式索引,或明确展示"部分可用"。
个人信息、商业机密和保留期限必须从原文继承到 Chunk。日志、离线评测集和向量本身都可能泄露信息,不能只保护原文件。租户删除请求要能通过 documentId 定位所有派生数据并证明已清理。
十一、常见误区
第一个误区是块越小检索越准。小块可能让关键词命中更准,却破坏答案完整性,并增加 TopK 与索引成本。第二个误区是 Overlap 越大越安全。重叠过大会制造近似副本,让候选缺少多样性。第三个误区是只按字符切中文,却用 Token 控制模型上下文。
第四个误区是只评测最终回答。生成模型可能凭常识答对,掩盖检索失败;也可能检索正确却因 Prompt 或输出限制答错。第五个误区是把每个 PDF 页作为 Chunk。页面是排版单位,不一定是语义单位,段落还可能跨页。
第六个误区是只保存文本和向量。没有标题路径、版本、权限、页码和相邻关系,就无法做父子检索、引用、删除、调试和审计。第七个误区是一次调参后永久不变;文档类型、Embedding 模型与用户问题分布变化后,应重新跑固定评测集。
十二、延伸
如果被问"Chunk Size 怎么选",有说服力的回答不是背一个数字,而是说明:按 Token 计算,结构优先;设计 2 到 4 组候选;与 Overlap、TopK 和返回策略联动;分别评估检索 Recall、端到端质量、上下文 Token 和延迟;按代码、表格和跨章节问题分桶观察。
如果被问"父子块为什么有效",可以解释小块向量主题集中、便于命中,父块返回完整语境;代价是父块可能带来噪声和 Token 膨胀,需要聚合去重、长度上限和 Rerank。若问"如何处理更新",回答文档版本化、内容哈希增量构建、索引别名切换和派生数据删除链路。
进一步的问题是"语义切分一定优于递归切分吗"。答案是否定的:语义切分增加计算成本,阈值难统一,可能拆散业务对象;结构良好的文档通常优先使用标题和段落。它应由评测证明增益,而不是因算法听起来高级就默认采用。
十三、总结
RAG 切分的本质是为检索建立信息单元,而不是把长文本塞进固定长度容器。可靠方案从解析结构开始,区分索引块、返回块和最终上下文,针对代码与表格使用领域规则,并让 Chunk Size、Overlap、TopK、父子扩展和 Rerank 一起接受实验。
没有通用最佳参数,只有可复现的选择过程。把元数据、权限、版本、安全、离线评测和线上指标纳入同一条数据流,团队才能知道一次效果变化究竟来自文档、切分、检索还是生成,也才能在知识库持续增长时稳定演进。