
RAG 文档切分策略对比固定长度、语义分段与递归切分的工程决策一、深度引言与场景痛点做 RAG 的人都知道一个残酷的事实向量检索的上限不取决于你的 embedding 模型有多强而取决于你的文档切得有多好。这个教训是我在一个合同审核 RAG 项目上深刻体会到的。项目初期我们用了最简单的方案——把每份合同按固定 500 字符切块相邻块之间 50 字符重叠。检索时效果还行直到有一天法务团队投诉说这份合同的违约金条款明明在第 8.3 条为什么你们的 AI 告诉我合同里没有违约条款排查之后发现第 8.3 条正好被切在了两个 chunk 的边界上——若乙方逾期交付须按日支付合同总金额的千分在 chunk_A 的末尾之五作为违约金在 chunk_B 的开头。两个 chunk 单独拿出来都看不出这是个完整的违约金条款检索时自然就漏掉了。这不是 embedding 模型的锅而是切分策略的锅。固定长度切分是最简单但也是最伤害语义的方式它完全不理解文档的结构——不知道哪里是段落的开始和结束不知道标题和正文的关系更不知道语义的完整性。在生产环境里只靠固定长度切分就像用菜刀做手术——也能切开但代价太高。本文来系统对比三种主流的文档切分策略固定长度切分、语义分段切分和递归切分从原理到代码到场景适配帮你找到最适合你项目的方案。二、底层机制与原理深度剖析三种切分策略的本质区别在于对文档结构的理解层次不同flowchart TD A[原始文档] -- B{切分策略选择} B --|策略1| C[固定长度切分] B --|策略2| D[语义分段切分] B --|策略3| E[递归切分] subgraph C_Detail [固定长度] C1[按字符数截断] -- C2[滑动窗口重叠] -- C3[Chunk 列表] end subgraph D_Detail [语义分段] D1[Markdown/HTML 解析] -- D2[结构识别: 标题/段落/列表/表格] -- D3[按语义单元切分] -- D4[子块合并/拆分] end subgraph E_Detail [递归切分] E1[第一层: 段落分隔符 \\n\\n] -- E2{chunk 阈值?} E2 --|是| E3[第二层: 句子分隔符 。] E2 --|否| E5[保留当前 chunk] E3 -- E4{chunk 阈值?} E4 --|是| E6[第三层: 字符级切分] E4 --|否| E5 E6 -- E5 end C3 -- F[向量化 → 存入向量库] D4 -- F E5 -- F三种策略的核心逻辑固定长度切分设定 chunk_size如 500 字符和 overlap如 50 字符然后像切香肠一样把文档切成等长的段。它的优点是实现极简、速度快、对任何格式的文档都一视同仁。缺点是会无情地切断语义一个句子可能被劈成两半。语义分段切分先解析文档的结构Markdown 的标题层级、HTML 的标签结构、PDF 的段落标记然后按语义单元标题 正文、完整段落、独立表格进行切分。它的优点是保持了每个 chunk 的语义完整性缺点是依赖文档格式、对纯文本无效。递归切分这是前两种策略的折中。它从粗粒度的分隔符开始如双换行\n\n如果切出来的 chunk 还是太大就降级到更细粒度的分隔符如单句号直到 chunk 满足大小要求。它的优点是在不依赖文档格式的情况下尽可能保持语义缺点是可能过度切分、产生大量小碎片。三、生产级代码实现import re import hashlib from typing import List, Dict, Optional, Iterator from dataclasses import dataclass, field from abc import ABC, abstractmethod from enum import Enum class ChunkStrategy(Enum): FIXED fixed SEMANTIC semantic RECURSIVE recursive dataclass class Chunk: 文档块 content: str metadata: Dict[str, str] field(default_factorydict) chunk_id: str def __post_init__(self): if not self.chunk_id: self.chunk_id hashlib.md5(self.content.encode()).hexdigest()[:12] def __len__(self): return len(self.content) class BaseSplitter(ABC): 切分器基类 def __init__(self, chunk_size: int 500, chunk_overlap: int 50): self.chunk_size chunk_size self.chunk_overlap chunk_overlap abstractmethod def split(self, text: str, metadata: Optional[Dict] None) - List[Chunk]: pass def _create_chunk(self, content: str, metadata: Optional[Dict] None) - Chunk: return Chunk(contentcontent.strip(), metadatametadata or {}) class FixedLengthSplitter(BaseSplitter): 固定长度切分器 优势最快、实现最简单、不依赖文档格式 劣势会切断句子和段落 def split(self, text: str, metadata: Optional[Dict] None) - List[Chunk]: chunks [] start 0 text_len len(text) while start text_len: end min(start self.chunk_size, text_len) content text[start:end] chunks.append(self._create_chunk(content, metadata)) # 按重叠量滑动窗口 start self.chunk_size - self.chunk_overlap if start text_len: break return chunks class SemanticSplitter(BaseSplitter): 语义分段切分器 支持 Markdown、HTML 等结构化文档 按标题、段落、列表、表格等语义单元切分 # Markdown 标题模式 HEADING_PATTERN re.compile(r^(#{1,6})\s(.)$, re.MULTILINE) def split(self, text: str, metadata: Optional[Dict] None) - List[Chunk]: sections self._parse_sections(text) chunks [] current_chunk current_meta {} for section_title, section_content in sections: full_section f{section_title}\n{section_content} if section_title else section_content # 如果当前 section 本身就超过 chunk_size进一步按段落拆分 if len(full_section) self.chunk_size: # 先保存当前累积的 chunk if current_chunk: chunks.append(self._create_chunk(current_chunk, {**metadata, **current_meta})) current_chunk # 按段落拆分大 section paragraphs section_content.split(\n\n) para_chunk for para in paragraphs: para_with_title f{section_title}\n{para} if section_title else para if len(para_chunk) len(para_with_title) self.chunk_size: para_chunk ( if not para_chunk else \n\n) para else: if para_chunk: chunks.append(self._create_chunk( f{section_title}\n{para_chunk} if section_title else para_chunk, {**metadata, **current_meta} )) para_chunk para if para_chunk: current_chunk f{section_title}\n{para_chunk} if section_title else para_chunk current_meta {section: section_title} else: if len(current_chunk) len(full_section) self.chunk_size: current_chunk ( if not current_chunk else \n\n) full_section else: chunks.append(self._create_chunk(current_chunk, {**metadata, **current_meta})) current_chunk full_section current_meta {section: section_title} if current_chunk: chunks.append(self._create_chunk(current_chunk, {**metadata, **current_meta})) return chunks def _parse_sections(self, text: str) - List[tuple]: 解析 Markdown 结构返回 [(section_title, section_content)] sections [] lines text.split(\n) current_title current_content [] for line in lines: match self.HEADING_PATTERN.match(line) if match: if current_content: sections.append((current_title, \n.join(current_content))) current_title line current_content [] else: current_content.append(line) if current_content: sections.append((current_title, \n.join(current_content))) return sections class RecursiveSplitter(BaseSplitter): 递归切分器 从粗粒度到细粒度逐级尝试切分 1. 段落级\\n\\n 2. 句子级。\\n 3. 字符级强制截断 优势在不依赖文档格式的前提下尽量保持语义 # 分隔符优先级从高到低即从粗到细 SEPARATORS [ \n\n, # 段落 \n, # 换行 。, # 中文句号 , # 中文感叹号 , # 中文问号 ., # 英文句号 !, ?, , # 中文分号 ;, , # 中文逗号 ,, , # 空格 , # 字符级 ] def split(self, text: str, metadata: Optional[Dict] None) - List[Chunk]: return list(self._split_recursive(text, metadata)) def _split_recursive( self, text: str, metadata: Optional[Dict] None, depth: int 0 ) - Iterator[Chunk]: 递归切分核心逻辑 if len(text) self.chunk_size: if text.strip(): yield self._create_chunk(text, metadata) return # 选择当前深度的分隔符 separator self.SEPARATORS[min(depth, len(self.SEPARATORS) - 1)] if separator: splits text.split(separator) current_chunk for i, split in enumerate(splits): candidate current_chunk (separator if current_chunk else ) split if len(candidate) self.chunk_size: current_chunk candidate else: # 当前累计够了产出 chunk if current_chunk.strip(): yield self._create_chunk(current_chunk, metadata) # 如果单个 split 就超过 chunk_size递归用更细分隔符 if len(split) self.chunk_size: yield from self._split_recursive(split, metadata, depth 1) current_chunk else: current_chunk split if current_chunk.strip(): yield self._create_chunk(current_chunk, metadata) else: # 字符级强制按 chunk_size 截断 for i in range(0, len(text), self.chunk_size - self.chunk_overlap): chunk_text text[i:i self.chunk_size] if chunk_text.strip(): yield self._create_chunk(chunk_text, metadata) # 策略选择辅助 class SplitterFactory: 切分器工厂 - 根据文档类型自动选择策略 staticmethod def create( strategy: ChunkStrategy, chunk_size: int 500, chunk_overlap: int 50, ) - BaseSplitter: if strategy ChunkStrategy.FIXED: return FixedLengthSplitter(chunk_size, chunk_overlap) elif strategy ChunkStrategy.SEMANTIC: return SemanticSplitter(chunk_size, chunk_overlap) elif strategy ChunkStrategy.RECURSIVE: return RecursiveSplitter(chunk_size, chunk_overlap) raise ValueError(f未知切分策略: {strategy}) staticmethod def auto_select(doc_format: str) - BaseSplitter: 根据文档格式自动选择最佳策略 format_map { markdown: ChunkStrategy.SEMANTIC, html: ChunkStrategy.SEMANTIC, plain_text: ChunkStrategy.RECURSIVE, pdf_parsed: ChunkStrategy.RECURSIVE, } strategy format_map.get(doc_format.lower(), ChunkStrategy.RECURSIVE) return SplitterFactory.create(strategy) # 使用示例 def demo(): markdown_text # 合同条款 ## 四、边界分析与架构权衡 本合同所称甲方系指……定义内容较长…… ## 五、总结 乙方应按照附件一的技术规格书……交付标准描述…… ### 2.1 交付时间 乙方应在合同生效后 30 日内完成交付。 ### 2.2 验收标准 甲方收到交付物后 15 个工作日内完成验收。 ## 第三条 违约条款 若乙方逾期交付须按日支付违约金。 # 语义切分适用于 Markdown splitter SplitterFactory.create(ChunkStrategy.SEMANTIC, chunk_size200, chunk_overlap30) chunks splitter.split(markdown_text) for chunk in chunks: print(f[{chunk.chunk_id}] ({len(chunk)} chars) {chunk.content[:80]}...) if __name__ __main__: demo()四、边界分析与架构权衡1. 固定长度切分的适用场景不要完全否定固定长度切分。在以下场景中它反而是最优选择文档已经是均匀的小段如 FAQ、Tweets 集合对延迟极度敏感不需要任何解析步骤文档格式混乱、无法可靠解析作为基准线用于对比其他策略的效果2. 语义切分的隐性成本语义切分依赖文档解析这意味着你需要为不同格式Markdown、HTML、PDF、DOCX维护不同的解析器。更棘手的是解析器的 bug 会直接导致切分失败或语义错误。我之前遇到过 PDF 解析器把一个两栏排版的合同解析成了混乱的交错文本语义切分器拿到这种输入后切出了大量无意义的碎片。3. 递归切分的碎片化问题当文档没有明确的段落结构时比如爬虫抓取的网页正文递归切分会一路降级到字符级产生大量不完整的小 chunk。这些小 chunk 单独来看语义不完整检索时就容易召回一堆但不相关。对策是在切分后追加一个后处理步骤——如果 chunk 长度小于某个最小阈值如 50 字符尝试把它合并到相邻 chunk 中。4. 重叠量的艺术chunk_overlap不是越大越好。overlap 太大意味着相邻 chunk 重复信息多向量库中会出现大量近似重复的向量检索时容易召回一堆高度相似的 chunk 但缺乏多样性。经验值是overlap ≈ chunk_size * 0.1即 10%需要根据你的文档类型和检索场景做调整。五、总结文档切分是 RAG 系统里做好了不显眼、做差了要命的环节。我的建议是分两步走第一上线前用递归切分作为默认方案——它不依赖文档格式能覆盖大部分场景且效果远好于固定长度切分。第二如果文档有清晰的结构Markdown 技术文档、HTML 帮助中心文章毫不犹豫地切换到语义分段切分它能带来的召回率提升往往在 15%-30%。还有一个小技巧在 metadata 里记录每个 chunk 的语义地址比如 3.1 异步任务在检索时把这些地址也作为特征之一可以显著提升上下文相关性。下一篇聊聊 Redis 缓存一致性——在向量检索这个高并发场景下缓存怎么设才能既快又准。