←返回文章列表

RAG 的分块策略详解:让检索结果更接近答案

RAG大语言模型AI 应用
18 分钟-阅读

RAG 的分块不是把文档切短这么简单。好的 chunking 会同时考虑语义边界、文档结构、检索粒度、上下文组装和评估指标,让召回结果既精准又可解释。

RAG 系统里最容易被低估的一步,是分块。

很多团队第一次做知识库问答时,会把 PDF、网页、Markdown 或 Word 文档直接按固定长度切成一段段文本,向量化后塞进数据库。Demo 阶段看起来能跑,真正上线后问题就来了:用户问的是“退款条件”,检索回来的 chunk 只有“满足以下任一条件”;用户问的是“接口鉴权方式”,召回结果命中了代码片段,却丢了前面的认证说明;用户要求引用来源,系统只能给出一个模糊的文件名,无法定位到章节。

这些问题不一定是模型不够强,也不一定是 embedding 模型选错了。很多时候,知识在进入向量数据库之前就已经被切碎了。

分块的目标不是让文本变短,而是让知识以适合检索的形态进入系统:块内信息足够完整,块间边界尽量自然,命中后能回到原文位置,并且能根据问题组装出可回答的上下文。

为什么 RAG 必须分块

一个 RAG 流程通常长这样:

flowchart LR
  A["原始文档"] --> B["解析与清洗"]
  B --> C["文档分块"]
  C --> D["Embedding"]
  D --> E["向量数据库"]
  F["用户问题"] --> G["检索"]
  E --> G
  G --> H["重排与上下文组装"]
  H --> I["LLM 生成答案"]

分块位于解析和向量化之间。它决定了后面检索系统能拿到什么样的“最小证据单元”。

如果不分块,直接把整份文档向量化,会遇到三个问题。

第一,长文档会超过 embedding 模型或生成模型的上下文限制。即使模型支持很长上下文,把几十页文档全部塞进去也会带来成本、延迟和注意力稀释问题。

第二,整篇文档的向量表示太粗。用户问“如何重置 API Key”,整篇开发手册的 embedding 未必能精准表达这个局部问题。

第三,引用和追溯困难。答案说“文档中提到可以 24 小时内退款”,系统需要指出具体章节、段落甚至页码,而不是只说“来自用户协议.pdf”。

但块也不能太小。比如原文是:

企业用户可以申请退款。条件包括:服务未开通、开通后 7 天内未产生调用量、账户不存在欠费。

如果切成:

企业用户可以申请退款。
条件包括:服务未开通、开通后 7 天内未产生调用量、账户不存在欠费。

用户问“企业用户退款条件是什么”时,第二块有条件但缺主语,第一块有主语但缺条件。两个块单独看都不完整。好的分块要避免把“对象、条件、结论”拆散。

好的 chunk 长什么样

一个高质量 chunk 通常满足四个条件:

  • 语义完整:读者单独看到这个块,也能理解它在说什么。
  • 信息密度高:块里围绕同一个问题展开,少混入无关段落。
  • 可追溯:保留文件名、标题路径、页码、段落编号、块类型等 metadata。
  • 可扩展上下文:命中小块后,可以回到父章节、相邻段落或原始页面补足上下文。

例如一个技术文档 chunk 不应该只有正文:

{
  "text": "调用接口时需要在 Header 中传入 Authorization: Bearer <token>。",
  "metadata": {
    "source": "api-guide.md",
    "title_path": "认证与鉴权 > Bearer Token",
    "chunk_type": "paragraph",
    "page": null,
    "section_id": "auth-bearer-token"
  }
}

text 用于检索和生成,metadata 用于过滤、重排、引用和回链。很多 RAG 系统效果差,不是没有向量检索,而是 metadata 太少,检索回来后无法判断它属于哪个章节、哪个版本、哪个业务线。

固定长度分块:最简单,也最容易误伤语义

固定长度分块按字符数或 token 数直接切文本。例如每 500 个中文字符切一块,重叠 50 个字符。

def fixed_chunks(text, size=500, overlap=50):
    chunks = []
    start = 0
    while start < len(text):
        end = start + size
        chunks.append(text[start:end])
        start = end - overlap
    return chunks

它的优点是实现简单、速度快、对任何文本都能处理。做 RAG 原型时,用固定长度分块可以很快建立 baseline。

问题也很明显:它完全不理解标题、段落、列表、表格、代码块。切分点可能落在一句话中间,也可能把表格切成上下两半。

比如:

如果账户存在欠费,需要先完成补缴,才能申请退款。

固定长度可能切成:

如果账户存在欠费,需要先完成补
缴,才能申请退款。

这两个块都很糟糕。第一个块没有结论,第二个块缺主语,embedding 表达也会变弱。

固定长度分块适合三类场景:

  • 文档结构很弱,只有连续纯文本。
  • 项目早期需要快速跑通。
  • 作为兜底策略,处理其他分块方法无法覆盖的残余文本。

中文知识库可以从 chunk_size=300~800 字、chunk_overlap=10%~20% 开始试。这个范围不是规则,只是起点。真正的参数要靠评估集调整。

句子分块:保护最小语义单元

句子分块先按句号、问号、感叹号、分号等边界切句,再把若干句聚合成一个 chunk。

示例:

API Key 创建后只展示一次。请立即复制并妥善保存。如果遗失,需要删除后重新创建。

句子分块会先得到:

API Key 创建后只展示一次。
请立即复制并妥善保存。
如果遗失,需要删除后重新创建。

再按目标长度合并成:

API Key 创建后只展示一次。请立即复制并妥善保存。如果遗失,需要删除后重新创建。

它比固定长度更稳,因为不会把完整句子切断。法律法规、FAQ、新闻、客服知识库这类文本,很多答案本身就在一句或几句话里,句子分块很实用。

中文分句需要谨慎。英文工具不一定适合中文标点,尤其是带编号、括号、金额、缩写、URL 的文本。一个实用的中文分句正则可以作为起点:

import re

def split_zh_sentences(text):
    pattern = r"(?<=[。!?;])"
    return [s.strip() for s in re.split(pattern, text) if s.strip()]

生产系统里还要处理更多边界,比如:

  • 1.2.3 版本号不能被当成句号切开。
  • https://example.com/a.b 里的点不能切。
  • 表格单元格里的换行不一定代表句子结束。

句子分块的问题是块可能太短。短块召回很准,但上下文不足。常见做法是把多个句子合并到目标 token 数,并在检索命中后追加相邻句子。

递归字符分块:多数项目的稳妥起点

递归字符分块的思路是:先尝试用大的自然边界切,切不动再用更细的边界。

对中文文档,可以按这样的优先级:

Markdown 标题
空行
换行
句号 / 问号 / 感叹号 / 分号
逗号
字符

LangChain 的 RecursiveCharacterTextSplitter 就是这一类工具的典型实现。它会尽量保留较大的语义边界,只有超过目标长度时才继续向下拆。

一个典型配置:

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=600,
    chunk_overlap=80,
    separators=["\n## ", "\n### ", "\n\n", "\n", "。", ";", ",", ""],
)

chunks = splitter.split_text(markdown_text)

递归分块适合大多数混合文本:产品手册、帮助中心、技术博客、内部 Wiki、规章制度。它不如结构解析精确,但成本低、可控性强,常作为 RAG 项目的第一版生产方案。

它的短板在表格、代码和复杂版式。代码块中间被切开后,函数定义和调用可能分离;表格被切开后,后半部分丢失表头。遇到这类内容,要让结构感知策略先处理,再把普通段落交给递归分块。

结构感知分块:尊重文档本来的组织方式

强结构文档应该优先按结构切,而不是按长度切。

Markdown、HTML、Word 标题、PDF 目录、API 文档、表格、代码块都有自己的边界。结构感知分块会把这些边界当成切分依据。

以 Markdown 为例:

# 支付接口

## 鉴权

调用接口需要携带 Bearer Token。

## 退款

退款仅支持原路退回,通常在 3 个工作日内处理。

理想 chunk 不是每 500 字切一次,而是:

{
  "text": "调用接口需要携带 Bearer Token。",
  "metadata": {
    "title_path": "支付接口 > 鉴权"
  }
}
{
  "text": "退款仅支持原路退回,通常在 3 个工作日内处理。",
  "metadata": {
    "title_path": "支付接口 > 退款"
  }
}

标题路径非常重要。用户问“支付接口怎么鉴权”时,正文里可能只有“携带 Bearer Token”,没有“支付接口”几个字。把标题路径注入 metadata,检索和重排时就能利用这个上下文。

HTML 文档也类似。正文可能散落在 <h2>、<p>、<li>、<table> 里。结构感知分块会先解析 DOM,再按标题层级和块级元素组合内容。

表格要特别处理。下面这个表不能按行随便切:

参数 类型 是否必填 说明
user_id string 是 用户 ID
page_size int 否 每页数量,默认 20

如果只召回第二行:

page_size | int | 否 | 每页数量,默认 20

读者能勉强看懂,但模型可能丢失表头语义。更好的做法是把表头和行一起序列化:

表格:请求参数
参数=user_id;类型=string;是否必填=是;说明=用户 ID
参数=page_size;类型=int;是否必填=否;说明=每页数量,默认 20

如果表格很长,可以按行组切分,但每个 chunk 都重复表头和表格标题。

对话分块:按轮次,而不是按字符

客服记录、会议纪要、访谈稿不适合简单按段落切。它们的基本单位是“谁在什么时候说了什么”。

例如:

用户:我的订单为什么还没发货?
客服:请提供订单号。
用户:订单号是 A123。
客服:查到了,仓库今天 18 点前发出。

如果只召回“仓库今天 18 点前发出”,答案缺少订单背景。对话分块通常按轮次窗口聚合:

用户:我的订单为什么还没发货?
客服:请提供订单号。
用户:订单号是 A123。
客服:查到了,仓库今天 18 点前发出。

重叠也不应该用字符重叠,而应该用轮次重叠。比如每个 chunk 包含 6 轮对话,前后重叠 1~2 轮。

metadata 同样关键:

{
  "conversation_id": "ticket-2026-001",
  "speaker_path": ["user", "agent", "user", "agent"],
  "turn_start": 12,
  "turn_end": 15,
  "topic": "物流发货"
}

用户问“客服有没有承诺发货时间”时,检索结果不仅要有文本,还要能定位到具体工单和轮次。

语义分块:根据话题变化切边界

语义分块不依赖标题或段落,而是根据句子之间的语义相似度判断边界。

一个长段落可能先讲“模型训练”,再讲“部署成本”,最后讲“安全评估”。如果原文没有清晰标题,结构分块发现不了话题变化。语义分块会把每个句子转成 embedding,检测相邻句子或滑动窗口之间的相似度变化。

简化流程:

分句
  ↓
计算每句 embedding
  ↓
计算相邻窗口相似度
  ↓
相似度突然下降的位置作为候选切分点
  ↓
结合最小 / 最大 chunk 长度修正

示例:

模型训练阶段需要准备高质量语料。训练数据要经过去重和安全过滤。上线后更大的成本来自推理。推理服务需要关注并发、缓存和限流。

语义分块可能切成:

模型训练阶段需要准备高质量语料。训练数据要经过去重和安全过滤。
上线后更大的成本来自推理。推理服务需要关注并发、缓存和限流。

语义分块适合论文、白皮书、长报告、论证型文章。它能发现没有显式标题的话题转移。

代价也更高:需要对句子或窗口计算 embedding,还要调阈值。短文档、格式清晰的文档没必要一上来就用语义分块。一个更实用的选择是:先按结构粗切,只有当某个章节太长时,再对这个章节内部做语义分块。

主题分块:适合长篇多主题材料

主题分块比语义分块更偏宏观。它通常会对句子或段落 embedding 聚类,识别主题标签,再根据主题切换切分。

例如一份年度报告可能包含:

  • 市场表现
  • 产品路线
  • 财务数据
  • 风险因素
  • 组织调整

如果原文结构混乱,主题分块可以帮助系统把内容归类。但它有几个前提:

  • 文档足够长。
  • 主题数量相对稳定。
  • 可以接受一定预处理成本。

小文档不适合主题分块。十几个段落强行聚类,结果往往不稳定。主题分块更适合书籍、行业报告、研究综述这类长文档。

小-大分块:小块召回,大块回答

很多 RAG 系统会在“检索精度”和“上下文完整性”之间摇摆。

小块适合检索。用户问“API Key 会展示几次”,句子级 chunk 很容易命中:

API Key 创建后只展示一次。

但生成答案时只给这一句又太少。用户可能还需要知道“遗失后怎么办”。这时应该把它所在段落一起带上:

API Key 创建后只展示一次。请立即复制并妥善保存。如果遗失,需要删除后重新创建。

这就是小-大分块。

它通常有两层:

  • 小块:句子、短段落,用于向量检索,追求命中精准。
  • 大块:完整段落、章节片段,用于回答,提供上下文。

数据结构可以这样设计:

{
  "child_chunk": "API Key 创建后只展示一次。",
  "parent_id": "api-guide#auth-key",
  "parent_text": "API Key 创建后只展示一次。请立即复制并妥善保存。如果遗失,需要删除后重新创建。"
}

检索时只索引 child_chunk,命中后根据 parent_id 拉取 parent_text。这样既保留了小块检索的精确度,又避免答案上下文过窄。

父子分块:把“检索单元”和“上下文单元”分开

父子分块和小-大分块经常一起出现,但它们不是同一个概念。

父子分块是一种数据建模方式:每个子块都知道自己属于哪个父块。小-大分块是一种检索流程:用小块召回,再取大块回答。

一个父子结构可以长这样:

父块:支付接口 > 退款规则
  子块 1:退款仅支持原路退回。
  子块 2:已开票订单需要先完成红冲。
  子块 3:退款通常在 3 个工作日内处理。

用户问“已开票订单怎么退款”,命中子块 2。系统随后取整个父块作为候选上下文,让模型同时看到退款方式、开票限制和处理时间。

父子分块还能帮助去重。多个子块命中同一个父块时,不需要把父块重复塞进 prompt,可以合并后统一重排。

Agent-Based Chunking:让 LLM 辅助判断复杂边界

有些文档很难靠规则处理:PDF 里混着正文、表格、脚注、公式、代码、图片说明;会议纪要里有多人插话;技术文档里一段代码跨越多页。

这时可以让小模型或 LLM Agent 辅助判断分块边界。

一个可控的 Agent-Based Chunking 任务可以这样定义:

输入:一段从 PDF 页面解析出的文本和布局信息
输出:JSON 数组,每个元素包含 start_offset、end_offset、chunk_type、title_path、reason

规则:
- 不允许在代码块、表格、公式中间切分
- 每个 chunk 尽量只包含一个主题
- 如果 chunk 超过 900 字,优先在段落边界切
- 必须保留标题链路

输出示例:

[
  {
    "start_offset": 0,
    "end_offset": 486,
    "chunk_type": "paragraph",
    "title_path": "安装指南 > Docker 部署",
    "reason": "同一小节内连续说明 Docker 启动步骤"
  }
]

这种方式很灵活,但成本和不确定性也更高。生产环境里要加护栏:

  • 输出必须是可解析 JSON。
  • offset 必须能映射回原文。
  • chunk 长度要做硬约束。
  • 失败时回退到结构分块或递归分块。

Agent-Based Chunking 更适合高价值、复杂格式、低吞吐的文档处理,不适合所有文档都走一遍。

混合分块:生产环境更常见的选择

单一分块策略很难覆盖所有文档。生产系统更常见的是混合流程。

flowchart TD
  A["原始文档"] --> B["解析格式"]
  B --> C{"文档类型"}
  C -->|Markdown / HTML| D["结构分块"]
  C -->|PDF / Word| E["版式与段落解析"]
  C -->|对话记录| F["轮次分块"]
  C -->|代码| G["语法边界分块"]
  D --> H{"块是否过长"}
  E --> H
  F --> H
  G --> H
  H -->|否| I["写入 metadata"]
  H -->|是| J["递归 / 语义细分"]
  J --> I
  I --> K["小块索引"]
  I --> L["父块存储"]
  K --> M["检索"]
  L --> N["上下文组装"]
  M --> N

一个稳妥的生产流程可以分四步:

  1. 先按结构粗切:标题、段落、列表、表格、代码块、对话轮次。
  2. 再处理异常长块:普通文本用递归分块,论证型长段落用语义分块。
  3. 建立父子关系:小块用于检索,父块用于上下文组装。
  4. 保留 metadata:标题路径、页码、块类型、字符偏移、文档版本。

三种档位可以这样选:

档位 策略 适用场景
Fast 结构分块 + 递归分块 快速上线、文档格式相对简单
Balanced 结构分块 + 递归分块 + 父子分块 + rerank 大多数生产知识库
Quality Balanced + 语义分块 + Agent 边界修正 + 更强重排 法律、金融、医疗、复杂企业文档

Fast 档位能快速建立可用系统。Balanced 档位通常是最划算的生产选择。Quality 档位适合高价值问答,但要接受更高的解析、embedding、评估和维护成本。

chunk_overlap 不是越大越好

重叠窗口用来缓解边界断裂。比如一个定义跨越两个段落,适当 overlap 可以让两个 chunk 都保留关键上下文。

但 overlap 太大会带来三个问题:

  • 向量库条目变多,存储和检索成本上升。
  • 重复内容增多,top-k 里可能挤满相似 chunk。
  • 生成阶段上下文冗余,模型更难聚焦。

一个常见起点是 10%~20%。但结构化文档里,很多 overlap 可以由 metadata 和父块回填替代。

比如每个 chunk 都带:

{
  "title_path": "管理员手册 > 权限管理 > 角色继承"
}

这比简单重复前后 100 个字符更干净。命中后再取相邻块或父块,通常比盲目加大 overlap 更有效。

分块参数怎么调

分块调优不要凭感觉。最好准备一小批真实问题和标准答案,固定其他环节,只改分块策略。

评估时可以看这些指标:

  • Recall@k:正确证据是否出现在前 k 个召回结果里。
  • MRR:正确证据排得是否靠前。
  • nDCG:排序结果整体质量如何。
  • faithfulness:生成答案是否忠实于检索上下文。
  • 引用准确率:答案引用是否能定位到正确章节或段落。
  • 上下文利用率:送进 LLM 的 chunk 有多少真正被答案使用。

一个最小评估表可以这样记录:

问题 标准证据位置 策略 A 是否召回 策略 B 是否召回 备注
API Key 会展示几次 认证 > API Key 是,rank=2 是,rank=1 B 的标题路径更准
已开票订单如何退款 支付 > 退款规则 否 是,rank=3 A 把表格切断

调参时一次只改一个变量:

  • 固定 embedding 模型和 reranker,只改 chunk_size。
  • 固定 chunk_size,只改 overlap。
  • 固定检索 top-k,只改是否启用父块回填。

如果多个变量一起改,结果变好也很难知道是谁起了作用。

不同文档类型的推荐策略

文档类型 推荐分块方式 注意点
Markdown / HTML 技术文档 标题结构分块 + 递归细分 保留标题路径和锚点
PDF 白皮书 版式解析 + 段落分块 + 语义细分 注意页眉页脚、脚注、图表说明
FAQ 问答对作为原子块 不要把问题和答案切开
表格 / CSV / Excel 表头重复 + 行组分块 每块保留表名、表头、主键字段
代码仓库 按函数、类、文件结构分块 保留文件路径、语言、符号名
客服对话 按轮次窗口分块 保留 speaker、turn_id、工单号
法律 / 合同 条款结构分块 + 句子聚合 保留条款编号,避免拆散条件和例外

FAQ 是一个典型例子。原文可能是:

Q:如何修改绑定手机号?
A:进入账号设置,完成短信验证后即可修改。

这个问答对应该作为一个原子块。如果把问题和答案分开,用户问“手机号怎么改”时可能只召回问题,没有答案。

代码仓库也不能按普通文本处理。函数签名、注释、函数体、类名、文件路径都影响检索。一个代码 chunk 至少要带:

{
  "file_path": "src/auth/token_service.py",
  "symbol": "TokenService.refresh_token",
  "language": "python",
  "chunk_type": "function"
}

用户问“refresh token 在哪里过期”,这些 metadata 会比纯文本更有用。

常见误区

误区一:chunk_size 越大越好。
大块上下文完整,但检索信号会被稀释。用户问一个细节,整章内容的向量未必能精准命中。

误区二:chunk_size 越小越好。
小块检索精准,但容易丢条件、例外和定义。小块通常要配父块回填或邻接扩展。

误区三:overlap 能解决所有边界问题。
overlap 只能缓解相邻文本断裂,不能修复表格切断、标题丢失、问答拆散这类结构问题。

误区四:只看向量召回,不看最终答案。
RAG 的目标是回答问题。召回指标很重要,但还要看答案是否忠实、引用是否准确、上下文是否被模型真正使用。

误区五:所有文档用同一套分块参数。
合同、代码、客服对话、表格、产品手册的知识结构完全不同。统一参数通常只能得到平均但不出色的结果。

一套可落地的默认方案

如果没有特殊约束,可以从下面这套方案起步:

  1. 文档解析阶段识别标题、段落、列表、表格、代码块、页码。
  2. Markdown / HTML 按标题层级粗切,PDF / Word 按段落和版式粗切。
  3. 普通文本块超过 800 中文字时,用递归分块细分。
  4. 对论文、报告中的超长论证段落,额外尝试语义分块。
  5. 表格、代码、FAQ、对话作为特殊类型处理,不走普通字符切分。
  6. 建立父子映射:子块索引,父块存储。
  7. 每个 chunk 写入 source、title_path、page、offset、chunk_type、parent_id。
  8. 用 30~100 个真实问题做离线评估,按 Recall@k、MRR、引用准确率调参。

这套方案不追求花哨,重点是稳。它尊重文档结构,又保留递归分块的通用性;它允许小块精准召回,也允许父块补足上下文。

结语

RAG 的分块策略不是预处理细节,而是检索系统的知识建模方式。

固定长度分块能让系统跑起来,句子分块保护最小语义单元,递归分块适合作为通用起点,结构感知分块让文档边界不被破坏,语义分块能处理隐含话题变化,小-大分块和父子分块则把“检索精度”和“回答上下文”拆开优化。

真正好用的 RAG 系统通常不是靠某一种分块方法赢,而是根据文档类型、问题类型和评估结果组合策略。让检索结果更接近答案,第一步就是让 chunk 本身更像一条可引用、可理解、可回溯的证据。

YL

我是姚磊

大模型应用开发者,关注 RAG、Agent 和产品化工程。沉淀实践中的判断、踩坑记录和可复用方法。联系我:yaolei_agent@163.com

LLM AppsRAGProductEngineering Notes