RAG 实战优化:基于结构化问答对的自定义文档切分术

在构建基于检索增强生成(RAG)的应用时,文档的切分(Chunking)策略是决定最终模型回复质量的关键因素之一。传统的切分方法,如按固定字符数或自然段落切分,对于非结构化文本表现良好,但对于我们自己精心准备的结构化知识库,我们需要一种更精准、更具语义完整性的切分方法。

本文将分享一种针对问答对(QA Pair)格式知识库的自定义切分策略,并探讨它在 Spring AI 等 RAG 框架中的实践意义。

一、通用切分方法的局限性与痛点

许多知识库文档的格式是人工整理或通过程序生成的,它们往往具备清晰的结构。以我们常见的问答对格式为例:

question: 什么是向量数据库?
answer: 向量数据库是一种专门用于存储、管理和检索向量嵌入的数据库,是 RAG 架构的核心组件之一。

question: Spring AI 框架的作用是什么?
answer: Spring AI 提供了一套统一的 API,用于集成各种大型语言模型(LLM)和向量数据库,简化了 RAG 应用的开发。

如果我们采用固定字符数或常规分隔符进行切分,就会遇到以下痛点:

  • 语义断裂:一个完整的问答对可能会被拦腰截断,导致语义不完整,检索时无法提供完整的上下文。
  • 低效召回:检索时可能只命中问题的上半部分或答案的下半部分,缺乏完整的上下文,严重影响 LLM 的最终回答质量和精确度。

二、自定义切分策略:以问答对为最小单元

为了解决通用切分器的不足,我们必须将每一个“问答对”视为一个最小的、不可分割的语义单元进行切分。由于我们掌控了文档格式,我们可以利用自定义的 question: 标记作为最可靠的切分边界。

2.1. 核心技术:正则表达式的正向先行断言

要实现精准的问答对切分,关键在于如何在切分时保留分隔符 question:,以便每个切分后的块都拥有完整的上下文起点。我们采用 Java 正则表达式的正向先行断言(Positive Lookahead) 技术:(?=(?m)^question:)

下表详细解释了该正则表达式的组成部分:

组件 含义 目的
question: 匹配分隔符本身 找到切分点
(?m) 多行模式(Multiline Flag) 确保 ^ 能匹配每一行的开头
^ 行首锚点 确保只在 question: 位于行首时切分
(?=...) 正向先行断言 关键:找到切分点但不将其从结果中移除

代码片段(关键切分逻辑):

// 使用先行断言,确保 "question:" 留在每个切分块的开头
final String QA_SPLIT_REGEX = "(?=(?m)^question:)";
String[] qaBlocks = normalizedText.split(QA_SPLIT_REGEX);

2.2. 健壮性处理:处理噪音和空文档

为了保证切分结果的健壮性和纯净度,splitCustomized 方法还包含了两个必要的预处理步骤:

  • 换行符标准化:统一不同操作系统下的换行符格式(\r\n, \r)为 \n,确保多行模式 (?m) 能够准确识别行首。
  • 忽略前导噪音:过滤掉文档开头在第一个 question: 标记之前出现的任何非问答文本(例如文档标题、作者信息等),保证向量库中存储的都是干净的问答知识块。

三、在 RAG 流程与 Spring AI 中的实践应用

这种自定义切分方法完美地嵌入了 RAG 的预处理流程中,尤其适用于 Spring AI 框架下的知识库构建:

  • 数据准备阶段:通过更强大的 LLM 或人工方式,生成高质量的结构化问答对。
  • 切分阶段:在将原始文档传递给 Spring AI 的 VectorStore 之前,调用 splitCustomized() 方法,将大文件精确地拆分成多个独立的 Document 对象。
  • 向量化与存储:Spring AI 框架接管这些切分后的 Document 列表。由于每个 Document 都是一个完整的问答对,向量化后的嵌入会更准确地捕获该知识点的全部语义。
  • 检索与生成:当用户提问时,相似性搜索会直接命中包含完整问答上下文的高质量 Chunk,从而为 LLM 提供最精确的证据,极大提升了最终答案的准确性和相关性。

总结:针对结构化知识库采用自定义切分,是提高 RAG 应用召回准确性和最终生成质量的有效且实用的工程实践。它将切分的粒度与知识的语义边界完美对齐,是构建高性能知识库的基石。

完整方法

/**
 * 自定义文档切分工具类,用于将大型文档拆分成更小的问答块(QA Chunks)。
 */
public class DocumentChunker {

    /**
     * 根据 "question:" 分隔符,将文档列表拆分为更小、结构化的文档块。
     * 这种方法适用于文档内容为结构化的问答对(例如:question: <Q> answer: <A>)。
     *
     * @param documents 输入的文档列表。
     * @param aiDataset 此参数目前未使用,如果不需要,后续可移除或替换为具体类型。
     * @return 拆分后的新文档块列表。
     */
    public List<Document> splitCustomized(List<Document> documents, Object aiDataset) {

        List<Document> result = new ArrayList<>();
        
        // 正则表达式用于匹配行首的 "question:",并使用正向先行断言。
        // (?m) 启用多行模式,使得 ^ 匹配行首。
        // (?=...) 是正向先行断言,确保在分隔符出现的位置进行切分,但不会“消费”(移除)分隔符本身。
        // 这样可以确保 "question:" 留在每个切分块的开头。
        final String QA_SPLIT_REGEX = "(?=(?m)^question:)";

        for (Document doc : documents) {
            String text = doc.getText();

            // 1. 保留空文档或文本为 null 的文档。
            if (text == null || text.trim().isEmpty()) {
                result.add(doc);
                continue;
            }

            // 2. 标准化换行符(CRLF, CR -> LF),确保正则表达式能够正确匹配行首 (^)。
            String normalizedText = text.replace("\r\n", "\n").replace("\r", "\n");

            // 3. 使用先行断言正则表达式进行切分。
            String[] qaBlocks = normalizedText.split(QA_SPLIT_REGEX);

            boolean isFirstBlock = true;
            for (String block : qaBlocks) {
                String trimmedBlock = block.trim();
                if (trimmedBlock.isEmpty()) continue;

                // 4. 处理前导“噪音”块(即第一个 "question:" 之前可能存在的文本)。
                // 如果是第一个块,且它不是以 "question:" 开头,则认为它是非问答的引言或噪音,通常需要跳过。
                if (isFirstBlock && !trimmedBlock.toLowerCase().startsWith("question:")) {
                    // 这里选择跳过噪音块,以保证所有输出块都是有效的问答对。
                    isFirstBlock = false;
                    continue;
                }

                // 5. 为每个问答块创建一个新的 Document 对象。
                // 继承原始文档的元数据。
                Map<String, Object> enhancedMetadata = new HashMap<>(doc.getMetadata());

                result.add(Document.builder()
                        .text(trimmedBlock)
                        .metadata(enhancedMetadata)
                        .score(doc.getScore())
                        .build());
                
                isFirstBlock = false;
            }
        } 

        return result;
    }
}
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐