在构建 RAG(Retrieval-Augmented Generation,检索增强生成)系统的工程实践中,文本分块(Chunking)往往是决定系统成败的“阿喀琉斯之踵”。很多 Java 开发者在初涉 RAG 时,习惯将精力放在挑选大语言模型和接入 Vector DB 上,却忽略了最基础的数据预处理环节。

如果分块做得不好,会出现典型的“Garbage in, Garbage out”现象:分块太小,检索到的内容支离破碎,LLM 无法获取足够的上下文;分块太大,不仅引入大量噪音稀释向量的语义焦点,极易触发大模型的“幻觉”,甚至直接超出 Token 限制。

本文将从 Java 后端工程实践的角度,结合目前 Java 生态中最热门的 AI 框架(如 LangChain4jSpring AI),深入解析主流的 RAG 分块策略、底层逻辑、代码实现思路以及落地时的避坑指南。


一、 分块的核心概念与基础参数

在深入具体代码之前,我们需要统一两个在任何 Java RAG 框架中都绕不开的核心参数:

  1. Chunk Size(分块大小)

    衡量分块大小的单位通常是 Characters(字符数)或 Tokens(词元数)。在工程实践中,强烈建议按 Token 切分,因为 Embedding 模型对输入长度的硬性限制是基于 Token 的。在 Java 生态中,通常会结合 JTokkitOpenAiTokenizer 来精准计算 Token。一个 500 Token 的分块,往往能承载一段语义相对完整的段落。

  2. Chunk Overlap(重叠区域)

    为了防止硬切分破坏跨块的语义连贯性,相邻的两个 Chunk 之间需要保留一定的重叠部分。通常设置为 Chunk Size 的 10% - 20%。Overlap 过大会导致向量数据库中存在大量冗余,增加存储和检索成本;过小则无法起到缝合上下文的作用。


二、 主流分块策略与 Java 代码实现

1. 固定长度分块(Fixed-size Chunking)

这是最基础、实现成本最低的分块方式。它不考虑任何文本的内在逻辑,仅仅按照设定的阈值进行机械截断。

  • 实现原理:逐字扫描文本,达到设定的 Chunk Size 减去 Overlap 的长度时,执行切割。

  • 优点:极快,资源消耗几乎为零。结合 Java 8 的 Stream API 可以实现极高吞吐量的并发切分。

  • 缺点:极易“腰斩”一句话或一个专业名词,导致语义断裂。例如,把“微服务架构”切成了上一个 Chunk 的“微服务”和下一个 Chunk 的“架构”。

  • Java 代码示例(基于 LangChain4j)

    Java

    import dev.langchain4j.data.document.Document;
    import dev.langchain4j.data.document.DocumentSplitter;
    import dev.langchain4j.data.document.splitter.DocumentSplitters;
    import dev.langchain4j.data.segment.TextSegment;
    import java.util.List;
    
    public class FixedChunkingDemo {
        public static void main(String[] args) {
            Document document = Document.from("这里是一段非常非常长的企业内部知识库文本...");
    
            // 按固定字符长度切分:最大长度 500 字符,重叠 50 字符
            DocumentSplitter splitter = DocumentSplitters.byLength(500, 50);
            List<TextSegment> segments = splitter.split(document);
    
            segments.forEach(segment -> System.out.println("分块内容: " + segment.text()));
        }
    }
    
  • 适用场景:毫无结构的纯文本流、无明确语义边界的系统日志(Log)分析。通常不推荐作为严谨的业务 RAG 系统的首选。

2. 递归字符分块(Recursive Character Chunking)

这是目前业界最推荐的通用基准(Baseline)策略。它的核心思想是:尽可能保持段落、句子的完整性,只有在迫不得已时,才进行更细粒度的切分。

  • 实现原理:系统预设一个分隔符的优先级列表。通常是:双换行符 \n\n(代表段落边界) > 单换行符 \n > 句号/标点 . > 空格 > 单个字符。算法会先尝试用最高优先级的 \n\n 进行切分。如果切出来的某个 Chunk 依然大于设定的 Chunk Size,算法就会对这个超标的 Chunk 使用下一级的分隔符继续递归切分,直到所有 Chunk 都达标为止。

  • Java 代码示例(结合 Tokenizer,更符合生产环境)

    Java

    import dev.langchain4j.data.document.Document;
    import dev.langchain4j.data.document.DocumentSplitter;
    import dev.langchain4j.data.document.splitter.DocumentSplitters;
    import dev.langchain4j.data.segment.TextSegment;
    import dev.langchain4j.model.openai.OpenAiTokenizer;
    import static dev.langchain4j.model.openai.OpenAiModelName.GPT_3_5_TURBO;
    
    public class RecursiveChunkingDemo {
        public static void main(String[] args) {
            Document document = Document.from("复杂的长篇大论...");
    
            // 推荐:基于 Token 数量的递归切分。自动按段落、句子进行智能探测
            // 参数:最大 Token 数 500,重叠 Token 数 50,并传入指定的 Tokenizer
            DocumentSplitter splitter = DocumentSplitters.recursive(
                    500, 
                    50, 
                    new OpenAiTokenizer(GPT_3_5_TURBO)
            );
    
            List<TextSegment> segments = splitter.split(document);
            System.out.println("共切分出 " + segments.size() + " 个语义完整的 Chunk");
        }
    }
    
3. 基于文档结构的分块(Structural / Document-based Chunking)

当我们处理结构化或半结构化文档时,单纯依赖标点符号就不够聪明了。Java 生态有着极其丰富的文档解析库,利用文档自身的结构标签进行切分,是保证高质召回的关键。

  • HTML/XML 分块

    在 Java 中,可以使用 Jsoup 解析 HTML,根据 DOM 树的 <h1><h3><p> 等节点进行遍历切分。提取文本的同时,将父级标题作为元数据(Metadata)附加到 Chunk 中。

  • Markdown 分块

    可以使用 commonmark-java 库将 Markdown 解析为 AST(抽象语法树),遇到 ### 等标题节点时进行截断,天然保留了章节的上下文意图。

  • PDF 解析的痛点

    PDF 属于视觉排版格式,底层没有“段落”或“表格”概念。在 Java 中,通常需要结合 Apache PDFBoxApache Tika 进行预处理。对于包含复杂表格和多栏排版的 PDF,单纯的文本提取往往乱码,建议引入 OCR 服务(如 PaddleOCR)或特定的版面分析模型还原结构后,再应用结构化分块。

4. 语义分块(Semantic Chunking)

语义分块是近年来的前沿探索,不再依赖硬性的标点符号,而是遵循:“内容话题发生转变时,就是切分的边界”。

  • 实现原理

    1. 使用 NLP 工具(如 OpenNLP 或 CoreNLP)将文本切分为最小的句子集合。

    2. 调用轻量级的 Embedding 模型,将所有句子转化为向量。

    3. 计算相邻两个句子向量的余弦相似度。设句子向量为 $A$ 和 $B$,其余弦相似度公式为:

      $$cosine\_sim(A, B) = \frac{A \cdot B}{||A|| \cdot ||B||}$$

    4. 当发现相似度骤降,跌破了预设的百分位阈值(说明话题发生了跳跃),就在该处打断,形成一个新的 Chunk。

  • 缺点:计算成本高昂。在构建索引阶段就需要频繁调用 Embedding 服务,对整个预处理 Pipeline 的并发控制和限流(Rate Limiting)提出了很高的要求,通常需要引入线程池和异步编排(CompletableFuture)来优化性能。

5. 面向检索的高阶策略:解耦“检索”与“生成”

在企业级 RAG 中,“容易被检索到的短文本”和“LLM 需要的详细上下文”往往存在矛盾。因此衍生出了以下解耦策略,这在 Java 后端数据建模时尤为重要:

  • 父子文档检索(Parent-Document Retrieval)

    在处理数据入库时,将文档进行层级切分。比如大块(父块,1000 Tokens)和小块(子块,256 Tokens)。仅将子块向量化存入 Vector DB(如 Milvus / Qdrant)用于检索。同时,在关系型数据库(如 MySQL / PostgreSQL)中维护一条 child_id -> parent_id 的外键映射。当向量库命中子块时,Java 业务层通过映射 ID 取出完整的父块喂给大模型。

  • 句子窗口检索(Sentence Window Retrieval)

    将单句话作为最小检索单元,因为短句包含的关键字密度极高。但在组装 Prompt 时,Java 程序会根据该句子的索引位置,自动从缓存或数据库中提取出其前后的 $k$ 句话(滑动窗口),拼接成具有完整上下文的片段。


三、 生产环境落地与避坑指南

在真实的 Java 企业级开发中,选择分块策略绝不是孤立的行为,需要结合整个架构进行统筹:

1. Metadata(元数据)的注入与 Hybrid Search

切分后的孤立文本块往往会丢失其全局定位信息。在 LangChain4j 等框架中构建 TextSegment 时,务必注入丰富的 Metadata

Java

dev.langchain4j.data.document.Metadata metadata = new Metadata();
metadata.put("doc_title", "2023年年度财务报表");
metadata.put("chapter", "第三章 利润分析");
metadata.put("page_number", "15");

TextSegment segment = TextSegment.from("具体的业务文本...", metadata);

在检索时,通过 Vector DB 的标量过滤(Scalar Filtering)+ 向量相似度进行混合检索(Hybrid Search),能解决 80% 以上的“张冠李戴”问题。

2. 大规模切分时的内存管理

在处理动辄几个 GB 的知识库时,不要将整个文档全部加载到内存中进行 String 切分。应当采用流式读取(BufferedReaderFiles.lines()),结合生产者-消费者模型,边读取、边分块、边批量(Batch)请求 Embedding 接口,最后批量写入向量库,防止发生 OutOfMemoryError

3. 拒绝“凭感觉”调参

不要单纯依靠“直觉”调整 Chunk Size。如果条件允许,应该抽样构建一个包含 50 个典型 QA 的测试集,编写一套自动化的单元测试。自动遍历不同的切分组合(如 512 + 10%,1024 + 15%),计算召回率,用数据指导架构决策。


总结:

RAG 的分块本质上是在“检索精准度”与“上下文完整性”之间寻找平衡。对于刚刚搭建 Java RAG 服务的团队,建议直接采用 DocumentSplitters.recursive(...) 配合 500 Token 和 10% Overlap 起步

结合你在 Java 项目中的实际情况,你目前需要接入 RAG 的主要数据源是什么(例如:纯文本日志、带表格的 PDF、还是从关系型数据库导出的结构化数据)?我们可以针对特定的数据源进一步探讨解析和切分方案。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐