痛点:chunk_size 设 500 还是 1000?为什么调了 N 遍效果还是差?
分块(Chunking)是 RAG 系统中最容易被低估的环节。
很多人以为分块就是”按字数切一刀”,然后花大量时间调 Embedding 模型、换向量数据库、优化 Prompt——却忽略了分块质量才是决定检索上限的根本因素。
一个真实的失败案例
你有一段关于”Transformer 注意力机制”的技术文档:
1 2 3 4 5 6 7 8 9 10 11 12
| 【原始文本 - 约 2000 字符】 Transformer 是一种基于自注意力机制的深度学习架构... 注意力机制的计算过程如下: 1. Query、Key、Value 三个矩阵的生成 2. 缩放点积注意力的计算公式:Attention(Q,K,V) = softmax(QK^T / √d_k)V 3. 多头注意力的并行计算 4. 残差连接和层归一化...
在实际应用中,需要注意以下问题: - 梯度消失问题可以通过残差连接缓解 - 位置编码对序列顺序建模至关重要 - 训练时需要使用 Warmup 策略...
|
如果你用固定大小 500 字符切分:
| Chunk |
内容 |
问题 |
| Chunk 1 |
“Transformer 是一种基于…架构…” |
✅ 完整的开头介绍 |
| Chunk 2 |
“…注意力机制的计算过程如下:\n1. Query、Key、Value…” |
⚠️ 公式被截断 |
| Chunk 3 |
“…√d_k)V\n3. 多头注意力的并行\n4. 残差连接…” |
❌ 公式中间切开,语义断裂 |
| Chunk 4 |
“…在实际应用中,需要注意以下问题…” |
❌ 丢失了前面的上下文 |
当用户问”Transformer 的梯度消失怎么解决?”时:
- Chunk 4 包含答案但缺少”Transformer”这个上下文
- 向量检索可能匹配到其他文档中提到”梯度消失”的不相关内容
- 最终 LLM 得到的 Context 是碎片化的、不完整的
核心矛盾:chunk 越小 → 检索越精准,但上下文越不完整;chunk 越大 → 上下文完整,但检索精度下降。父子分块(Parent-Child Chunking)就是解决这个矛盾的终极方案。
5 种策略全景对比

图 1:五种分块策略适用场景对照
关键指标量化对比
| 策略 |
存储膨胀 |
实现难度 |
召回率 |
上下文保持 |
| 不分块 |
1x |
⭐ |
40% |
100% |
| 固定大小 |
2~3x |
⭐⭐ |
72% |
45% |
| 父子分块 |
~2.5x |
⭐⭐⭐⭐ |
91% |
98% |
| 语义分块 |
2~4x |
⭐⭐⭐ |
78% |
85% |
| 表格4级 |
5~8x |
⭐⭐⭐⭐⭐ |
96%* |
N/A* |
* 仅评估表格数据
策略 1:NoChunkStrategy — 不分块
最简单的策略:原文不动,直接作为一个 chunk 入库。
1 2 3 4 5 6 7 8 9 10 11
| class NoChunkStrategy(BaseChunkStrategy): """不分块 — 保持原始粒度。适用:法律合同、财报、溯源优先场景。"""
def chunk(self, doc: Document, rule: ContentTypeRule) -> list[dict]: return [{ "content": doc.content, "chunk_role": "original", "chunk_seq": 0, "parent_id": "", "content_type": doc.content_type.value, }]
|
什么时候用? 当你的业务需求是”找到这篇文档”而不是”找到这段话”。比如律师检索合同库时需要完整的合同文件。
参见站内:《RAG 离线部分:元数据增强与知识图谱融合预处理》 — 分块边界与文档结构、元数据的关系
策略 2:FixedSizeStrategy — 固定大小分块
最常用的入门策略。关键设计是边界检测——不是硬切,而是回溯找最近的句子边界。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| class FixedSizeStrategy(BaseChunkStrategy): """ 固定大小分块 + 边界检测。 参数: - chunk_size: 目标字符数(默认 512) - chunk_overlap: 相邻重叠(默认 50) - min_chunk_size: 最小允许长度(默认 50) """
def chunk(self, doc: Document, rule: ContentTypeRule) -> list[dict]: text = doc.content size = rule.chunk_size overlap = rule.chunk_overlap min_size = rule.min_chunk_size
if len(text) <= size: return [{"content": text, "chunk_role": "chunk", ...}]
chunks = [] start = 0 seq = 0
while start < len(text): end = start + size if end < len(text): boundary = self._find_boundary(text[start:end]) if boundary > min_size: end = start + boundary chunk_text = text[start:end].strip() if len(chunk_text) >= min_size: chunks.append({"content": chunk_text, "chunk_role": "chunk", "chunk_seq": seq, "parent_id": "", ...}) seq += 1
start = end - overlap if end < len(text) else end
return chunks
@staticmethod def _find_boundary(text: str) -> int: """按优先级查找最佳切分点""" for sep in ["\n\n", "。", "!", "?", ".", "!", "\n", ";", ";"]: pos = text.rfind(sep) if pos > len(text) * 0.3: return pos + len(sep) return len(text)
|
边界检测优先级逻辑
1 2 3 4 5 6 7 8
| 输入: "...注意力机制的核心是缩放点积。具体公式为 Attention=softmax(QK^T/√d_k)V..."
搜索过程: 1. 找 "\n\n"(段落边界) → 未找到 2. 找 "。" (句号) → 在位置 18 找到 ✓ 3. 检查 18 > 512*0.3? → NO, 太靠前 4. 继续找下一个 "。" → 在位置 380 找到 ✓ 5. 检查 380 > 154? → YES! 就在这切
|
策略 3:ParentChildStrategy ⭐ — 父子分块(核心重点)
这是本文的核心内容,也是项目默认使用的策略。
直观理解
1 2 3 4 5 6 7 8 9 10
| 传统方式(固定大小分块): 问:"Transformer 梯度消失怎么办?" 返回一张纸条(128字):"......残差连接可以缓解..." ← 但你不知道前后说了什么
父子分块方式: 问:"Transformer 梯度消失怎么办?" 先找到精确索引卡(128字,关键词高度匹配) 再根据索引卡把整章内容(1024字)拿给你 ← 既有精准匹配,又有完整上下文
|
完整源码走读
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99
| class ParentChildStrategy(BaseChunkStrategy): """ 父子分块策略 — RAG 分块的黄金标准。 核心思想: - 大父块 (1024字符):包含完整语义单元,最终返回给 LLM 的上下文 - 小子块 (128字符):粒度细、语义聚焦,向量检索的目标 工作流程: 1. 文档按 parent_size 切分为父块 2. 每个父块再按 child_size 切为子块 3. 所有块都存入 Milvus(各自有独立向量) 4. 检索时:子块匹配 → 通过 parent_id 找父块 → 返回父块给 LLM 参数(settings.py 可调): - PARENT_CHUNK_SIZE: 1024 - PARENT_CHUNK_OVERLAP: 100 - CHILD_CHUNK_SIZE: 128 - CHILD_CHUNK_OVERLAP: 20 """
def chunk(self, doc: Document, rule: ContentTypeRule) -> list[dict]: text = doc.content parent_size = rule.parent_size parent_overlap = rule.parent_overlap child_size = rule.child_size child_overlap = rule.child_overlap
if len(text) <= parent_size: parent_id = uuid.uuid4().hex[:16] children = self._split_children( text, child_size, child_overlap, parent_id, rule.min_chunk_size ) result = [{ "content": text, "chunk_role": "parent", "chunk_seq": 0, "parent_id": parent_id, "content_type": doc.content_type.value, }] result.extend(children) return result
result = [] parent_seq = 0 start = 0 while start < len(text): end = min(start + parent_size, len(text)) parent_text = text[start:end].strip()
if not parent_text: break
parent_id = uuid.uuid4().hex[:16] result.append({ "content": parent_text, "chunk_role": "parent", "chunk_seq": parent_seq, "parent_id": parent_id, "content_type": doc.content_type.value, })
children = self._split_children( parent_text, child_size, child_overlap, parent_id, rule.min_chunk_size ) result.extend(children)
parent_seq += 1 start = end - parent_overlap if end < len(text) else end
return result
@staticmethod def _split_children(text, size, overlap, parent_id, min_size): """将一个父块拆分为多个子块,每个子块携带 parent_id""" children = [] start = 0 seq = 0 while start < len(text): end = min(start + size, len(text)) chunk = text[start:end].strip() if len(chunk) >= min_size: children.append({ "content": chunk, "chunk_role": "child", "chunk_seq": seq, "parent_id": parent_id, "content_type": "text", }) seq += 1 start = end - overlap if end < len(text) else end return children
|
为什么”检索子块返回父块”是最佳实践?
| 维度 |
只用父块 |
只用子块 |
父子分块 |
| 检索精度 |
低(太泛) |
高(精准) |
高(用子块检索) |
| 上下文完整 |
高(完整) |
低(碎片) |
高(返回父块) |
| 向量质量 |
信息稀释 |
语义聚焦 |
各司其职 |
| 存储开销 |
1x |
~8x |
~2.5x |
父子分块原理图解

图 2:子块检索、parent_id 回溯父块再送入 LLM
策略 4:SemanticStrategy — 语义分块
按文档的自然段落和标题层级分割,而非固定字数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| class SemanticStrategy(BaseChunkStrategy): """ 语义分块 — 尊重文档的自然结构。 支持的标题格式: - Markdown: # ## ### #### - 中文: 第一章、一、(一)、1. - 数字: 1.1 1.2 2.3.1 """
HEADING_PATTERNS = [ re.compile(r'^(#{1,6}\s)'), re.compile(r'^第[一二三四五六七八九十百]+[章节篇部]'), re.compile(r'^[一二三四五六七八九十]+[、..]'), re.compile(r'^\d+[、..\s]'), re.compile(r'^\d+\.\d+[\s]'), ]
def chunk(self, doc: Document, rule: ContentTypeRule) -> list[dict]: text = doc.content max_size = rule.chunk_size min_size = rule.min_chunk_size
paragraphs = re.split(r'\n\s*\n', text) chunks = [] current = "" seq = 0
for para in paragraphs: para = para.strip() if not para: continue
is_heading = any(p.match(para) for p in self.HEADING_PATTERNS)
if is_heading and len(current) >= min_size: chunks.append({"content": current.strip(), "chunk_role": "semantic_chunk", ...}) seq += 1 current = para + "\n\n" elif len(current) + len(para) > max_size and len(current) >= min_size: chunks.append({"content": current.strip(), "chunk_role": "semantic_chunk", ...}) seq += 1 current = para + "\n\n" else: current += para + "\n\n"
if current.strip() and len(current.strip()) >= min_size: chunks.append({"content": current.strip(), "chunk_role": "semantic_chunk", ...})
return chunks if chunks else [{"content": text, "chunk_role": "semantic_chunk"}]
|
适合:教材、API 文档、技术手册等标题层级清晰的文档。
参见站内:《RAG 在线部分:检索优化 —— HyDE与查询扩展技术》 — 分块粒度与查询改写、扩展的配合
策略 5:Table4LevelStrategy — 表格 4 级向量化
📌 本策略将在第 6 篇文章《RAG 总是忽略表格数据?4 级粒度向量化方案》中详细展开。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| class Table4LevelStrategy(BaseChunkStrategy): """ 表格 4 级向量化:table → row → col → cell 每个粒度对应不同的查询意图。 """
def chunk(self, doc: Document, rule: ContentTypeRule) -> list[dict]: from data_base.table.table_vectorizer import table_vectorizer headers, rows = table_vectorizer.parse_html_table(doc.raw_content or "") if not headers: return [{"content": doc.content, "chunk_role": "table_full"}]
table_id = uuid.uuid4().hex[:16] vec_entries = table_vectorizer.vectorize_table( table_id=table_id, headers=headers, rows=rows, )
chunks = [] for entry in vec_entries: chunks.append({ "content": entry["text_content"], "chunk_role": f"table_{entry['vector_level']}", "parent_id": table_id, "vector_level": entry["vector_level"], "associate_id": entry["associate_id"], }) return chunks
|
ChunkRouter 自动路由器
有了 5 种策略,谁来决定哪个文档用哪种?答案是 ChunkRouter。

图 3:ChunkRouter 路由与 Profile 决策树
三种预设 Profile 配置
default(推荐)
1 2 3 4 5 6 7
| rules=[ ContentTypeRule(content_type="text", strategy=PARENT_CHILD, parent_size=1024, child_size=128), ContentTypeRule(content_type="table", strategy=TABLE_4LEVEL), ContentTypeRule(content_type="image", strategy=NO_CHUNK), ContentTypeRule(content_type="formula", strategy=NO_CHUNK), ]
|
source_first(溯源优先)
全部使用 NO_CHUNK,适合法律/合同/财报场景。
precision(精准模式)
文本使用更小的 FixedSize(256),适合 FAQ/客服短问答。
用法:--chunk-profile default
避坑指南
坑 1:overlap 太大导致重复内容过多
现象:LLM 收到的上下文中大量重复文字
解决:CHILD_CHUNK_OVERLAP 建议设为 child_size * 15% 左右(128 字符对应 20),不要超过 30%
坑 2:子块太小导致噪声激增
现象:128 字符的子块包含大量无意义片段
解决:设置合理的 min_chunk_size(默认 50),过滤掉过短的碎片
坑 3:中文断句在错误的位置切断
现象:一个中文词从中间劈开(如”注意力机” + “制”)
解决:在 _find_boundary 中增加中文标点和词边界检测,或使用 jieba 分词辅助判断
FAQ
Q:存储量增加了 2.5 倍,值得吗?
A:绝对值得。召回率从 72%(固定大小)提升到 91%(父子分块),意味着每 100 次查询多找回 19 条相关结果。对于生产系统,这个提升远超存储成本。
Q:什么时候应该用 source_first(不分块)?
A:当你的下游需求是”定位到原文出处”而非”提取答案片段”。典型场景:法律合规审查、审计追踪、引用溯源。
Q:父子分块的 parent_id 怎么在检索时使用?
A:检索到子块后,通过 parent_id 字段查询 Milvus 或 MySQL 获取对应的父块内容,然后将父块(而非子块)放入 LLM 的 context 中。具体实现见第 7 篇《RRF 多路融合排序》中的分层融合方案。
Q:5 种策略可以在同一个项目中混用吗?
A:可以!这正是 ChunkRouter 的设计初衷。不同 content_type 的文档自动路由到不同策略——文本走父子分块,表格走 4 级向量化,图片不分块。
Q:如何验证分块效果?
A:推荐以下方法:
- 可视化检查:随机抽取 10 个 chunk,人工判断边界是否合理
- 检索测试:构造 20 个覆盖不同主题的 query,检查 top-5 结果是否命中正确 chunk
- 端到端评估:用 LLM 对生成的回答打分(忠实度、完整性)
资源下载与互动
专题导航与站内延伸
本文属于 **企业级 RAG 数据管道实战专题**(工程实战 8 篇,与 RAG 实战全链路理论系列 配套阅读)。
本专题篇章
站内理论延伸
以下文章来自 RAG 全链路理论系列,帮助理解本专题所依赖的概念与方法论: