RAG系统中的Markdown文档分割策略:从实现到效果分析
今天看Ruoyi-vue-pro项目,发现其中AI子模块中添加了对Markdown文档的分块功能,特别是针对QA格式的Markdown文档,设计了专门的切片器(MarkdownQaSplitter)。在此前搭建基于Redis的知识库过程中,当时就比较好奇如何对文章进行合适的拆分,从而得到较好的召回效果。
基于Redis 8实现RAG工程实战(一):无GPU搭建可用的向量检索方案全景
基于Redis 8实现RAG工程实战(二):Qwen3-Embedding在CPU上的向量化落地
基于Redis 8的RAG工程实战(三):向量检索与全文检索的混合召回
基于Redis 8实现RAG工程实战(四):诗词数据清洗与繁简转换实现
现在正好通过阅读项目中的实现代码,结合测试结果,分析下不同分块策略的设计思路、实现细节。
一、背景:为什么文档分块是 RAG 的关键环节?
RAG(Retrieval-Augmented Generation,检索增强生成)系统的核心流程可以概括为三个阶段:
知识索引(Indexing) → 知识检索(Retrieval) → 内容生成(Generation)
在知识索引阶段,原始文档被拆分为适当大小的片段(Chunk),然后通过 Embedding 模型转换为向量并存储到向量数据库中。当用户提问时,系统在向量数据库中进行相似度搜索,找到最相关的知识片段,再将其注入LLM的 Prompt进行回答。
文档分块是连接”原始文档”和”向量检索”的桥梁,直接决定了:
-
检索精度:分块是否语义完整,决定了Embedding向量能否准确表达内容含义 -
回答质量:注入Prompt的知识片段质量,直接决定了LLM回答的准确性和完整性 -
系统效率:分块数量影响向量存储成本和检索计算开销
二、项目中的分块策略体系
在Ruoyi-vue-pro的AI模块中,文档分块策略通过枚举AiDocumentSplitStrategyEnum定义了五种策略:
publicenum AiDocumentSplitStrategyEnum { AUTO("auto", "自动识别"), TOKEN("token", "Token 切分"), PARAGRAPH("paragraph", "段落切分"), MARKDOWN_QA("markdown_qa", "Markdown QA 切分"), SEMANTIC("semantic", "语义切分");}
2.1 自动检测机制(AUTO)
当策略设为AUTO时,系统会通过detectDocumentStrategy()方法自动分析文档特征,选择最优策略:
private AiDocumentSplitStrategyEnum detectDocumentStrategy(String content, String url){if (StrUtil.isEmpty(content)) {return AiDocumentSplitStrategyEnum.TOKEN; }// 1. 检测 Markdown QA 格式if (isMarkdownQaFormat(content, url)) {return AiDocumentSplitStrategyEnum.MARKDOWN_QA; }// 2. 检测普通 Markdown 文档if (isMarkdownDocument(url)) {return AiDocumentSplitStrategyEnum.SEMANTIC; }// 3. 默认使用语义切分return AiDocumentSplitStrategyEnum.SEMANTIC;}
检测优先级:Markdown QA 格式 > 普通 Markdown > 默认语义切分。
2.2 QA 格式检测规则
系统通过isMarkdownQaFormat()判断文档是否为 QA 格式:
privatebooleanisMarkdownQaFormat(String content, String url){// 文件扩展名判断:必须是 .md 文件if (StrUtil.isNotEmpty(url) && !url.toLowerCase().endsWith(".md")) {returnfalse; }// 统计二级标题数量long h2Count = content.lines() .filter(line -> line.trim().startsWith("## ")) .count();// 要求一:至少包含 2 个二级标题if (h2Count < 2) {returnfalse; }// 要求二:H2 标题占比超过 10%long totalLines = content.lines().count();double h2Ratio = (double) h2Count / totalLines;return h2Ratio > 0.1;}
判定条件:
-
文件扩展名为 .md -
二级标题( ##)数量 >= 2 -
二级标题行数占总行数比例 > 10%
三、核心切片器实现详解
3.1 MarkdownQaSplitter — QA专用切片器
设计思想:QA文档的特点是”问题-答案”成对出现,二级标题(##)通常就是问题本身。保持问答对的完整性,是保证检索质量的关键。
核心算法:
Step 1: 使用正则匹配所有##标题,解析出[问题, 答案]对列表Step 2: 对于每个QA对: - 如果Token数 <= chunkSize → 保持完整,作为一个片段; - 如果Token数 > chunkSize → 智能切分答案,但每个片段都保留完整问题。
智能切分答案的三级策略:
-
段落级切分:以 \n\n为分隔符,将答案拆分为段落,按Token限制合并; -
句子级切分:当单个段落超过Token限制时,以 。!?.!?为分隔符按句子拆分; -
强制切分:当单个句子超过Token限制时,强制截断。
关键特性:长答案被切分后,每个子片段都会重新拼接完整的问题标题,确保每个片段都是独立可理解的。
3.2 SemanticTextSplitter — 语义切片器
设计思想:对于非QA格式的文档(如技术博客、API文档),需要在保持语义完整性的同时合理控制片段大小。
核心算法:
Step 1: 检查文本是否超过chunkSize,不超过则直接返回Step 2: 按分隔符优先级尝试切分: - \n\n\n(三个换行) - \n\n(双换行) - \n(单换行)Step 3: 如果没有换行符,按句子边界切分(句号、问号、感叹号)Step 4: 合并小片段,控制Token数不超过chunkSize
重叠机制(Chunk Overlap):
这是SemanticTextSplitter的重要特性。在合并片段形成Chunk时,当一个新的Chunk完成后,会将末尾的片段”重叠”到下一个Chunk的开头:
private List<String> getOverlappingChunks(List<String> chunks, String separator){// 从后往前取片段,直到达到chunkOverlap的Token数 List<String> overlapping = new ArrayList<>();int tokens = 0;for (int i = chunks.size() - 1; i >= 0; i--) { String chunk = chunks.get(i);int chunkTokens = tokenEstimator.estimate(chunk);if (tokens + chunkTokens > chunkOverlap) {break; } overlapping.add(0, chunk); tokens += chunkTokens; }return overlapping;}
有趣之处在于,它通过重叠机制保证了相邻Chunk之间的上下文衔接,避免关键信息被硬分割截断。尽管这种做法会在多个Chunk中产生一定的信息冗余,但是我认为在实际的RAG场景中,这种冗余是值得的。
递归切分:当单个片段超过chunkSize时,会递归调用splitTextRecursive() 进行更细粒度的切分,直到所有片段都在限制范围内。
3.3 其他策略
-
TOKEN:使用Spring AI框架提供的 TokenTextSplitter,按Token数量机械切分 -
PARAGRAPH:本质上是 SemanticTextSplitter的特例(overlap = 0),按段落边界切分但不产生重叠
四、Token 估算器
两种切片器共享相同的 Token 估算逻辑:
privateintestimate(String text){int chineseChars = 0;int englishWords = 0;for (char c : text.toCharArray()) {if (c >= 0x4E00 && c <= 0x9FA5) { // CJK 统一汉字 chineseChars++; } } String[] words = text.split("\\s+");for (String word : words) {if (word.matches(".*[a-zA-Z].*")) { englishWords++; } }return chineseChars + (int) (englishWords * 1.3);}
估算规则:
-
中文:1 个汉字 ≈ 1 个 Token -
英文:1 个单词 ≈ 1.3 个 Token
这是一个轻量级估算,适用于分块场景的粗略控制,实际 Token 数取决于具体使用的 Embedding 模型的 Tokenizer。
五、分块效果分析
为了直观感受两种切片器的差异,我用一段典型的 FAQ 文档来演示。假设 chunkSize = 500:
## 什么是 Spring Boot?Spring Boot 是一个基于 Spring 框架的快速开发框架,它简化了 Spring 应用的初始搭建和开发过程。通过约定优于配置的理念,Spring Boot 让开发者能够快速创建独立运行的、生产级别的 Spring 应用程序。Spring Boot 的核心优势在于它提供了大量的自动配置,大大减少了开发者的配置工作。同时,它还内置了 Tomcat、Jetty 等 Web 服务器,使得开发者无需部署 WAR 包即可运行应用。## Spring Boot 和 Spring Framework 有什么区别?Spring Framework 是一个基础框架,提供了依赖注入、AOP、事务管理等核心功能。Spring Boot 则是在 Spring Framework 基础上构建的快速开发框架。主要区别包括:1. Spring Boot 提供了自动配置,而 Spring Framework 需要手动配置。2. Spring Boot 内置了 Web 服务器,而 Spring Framework 需要手动部署。3. Spring Boot 提供了起步依赖,简化了依赖管理。4. Spring Boot 提供了生产级别的监控和管理功能。
5.1 MarkdownQaSplitter 的切分结果
MarkdownQaSplitter 会将每个问答对切为一个独立片段:
片段 1(~144 tokens):
## 什么是 Spring Boot?Spring Boot 是一个基于 Spring 框架的快速开发框架,它简化了 Spring 应用的初始搭建和开发过程...Spring Boot 的核心优势在于它提供了大量的自动配置...
片段 2(~135 tokens):
## Spring Boot 和 Spring Framework 有什么区别?Spring Framework 是一个基础框架...主要区别包括:1. Spring Boot 提供了自动配置,而 Spring Framework 需要手动配置。...
每个片段的Token数远低于500的限制,问答对完整保留。这样的片段在Embedding时,问题标题(如”Spring Boot 和 Spring Framework 有什么区别?”)会成为强语义信号,当用户提问”Spring Boot跟Spring有啥不一样”时,检索命中率很高。
5.2 SemanticTextSplitter的切分结果
同样的内容,SemanticTextSplitter会按段落边界合并成一个片段:
片段 1(~500 tokens):
## 什么是 Spring Boot?Spring Boot 是一个基于 Spring 框架的快速开发框架...(完整内容)## Spring Boot 和 Spring Framework 有什么区别?Spring Framework 是一个基础框架...主要区别包括:1. ... 2. ...
虽然只有1个片段(因为总Token未超过限制),但可以想象:当文档更长、主题更多时,SemanticTextSplitter会在段落边界处切分,并通过重叠机制将上一个片段末尾的内容带入下一个片段开头,确保跨片段的信息不会丢失。
5.3 两种策略的核心差异
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
简单来说,QA切片器”按问题切片”,语义切片器”按段落切片”。前者对精确问答场景效果更好,后者对通用的长文档更友好。
六、策略选择建议
6.1 不同文档类型的推荐
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6.2 关于自动检测的一个问题
当前QA格式检测要求”二级标题占比超过10%”,但是这个阈值可能过高。对于一个典型的FAQ文档,虽然每个问题都以二级标题开头,但答案部分通常会占据大量行数,导致H2占比很难达到10%。此外,例如技术文档可能包含大量的代码块、列表等非文本内容,这些内容会进一步降低H2的占比。这一点上或许可以进行更为细致的调整。
七、总结
回到最初的好奇——如何对文章进行合适的拆分以获得较好的召回效果?通过分析Ruoyi-vue-pro中的实现,我的理解是:
文档分块的核心原则是”保持语义完整”。不同的文档有不同的语义结构:FAQ文档的语义单位是问答对,技术文档的语义单位是段落或章节。MarkdownQaSplitter和SemanticTextSplitter正是针对这两种结构设计的专用切片器,前者”按问题切”,后者”按段落切”。
在实际RAG流程中,优秀的分块策略能提升向量检索的命中率,减少无关信息的注入,帮助LLM生成更准确的回答。而自动检测机制detectDocumentStrategy()让系统可以根据文档特征自动选择最优策略,降低了使用门槛——前提是检测规则本身需要足够准确,这也是当前实现还有优化空间的地方。
夜雨聆风