分片代码实战:Spring系列

前面讲了分块策略的理论和ChunkViz可视化实验,这篇开始写具体的实现代码。

Java生态中主要有三个框架支持文档分片:

框架 特点 推荐度
Spring AI 官方只提供TokenTextSplitter,功能较弱 入门可用
Spring AI Alibaba 扩展了Spring AI,支持递归分片 推荐
LangChain4J 功能最丰富,支持语义分片 进阶使用

Spring AI的TokenTextSplitter

框架原生能力

在Spring AI的ETL Pipeline模块中,TextSplitter是所有文本拆分器的抽象基类。但目前官方只提供了一个具体实现:TokenTextSplitter——按token数量拆分文本。

Spring AI的分片功能较弱

Spring AI的TokenTextSplitter有两个明显的缺陷:

  • 不支持overlap(相邻块重叠),相邻文本块之间没有共享内容
  • 不支持按段落或自然语言结构分割,只能按token数硬切

如果需要更高级的分片功能,要么用Spring AI Alibaba,要么用LangChain4J,要么自己实现。

核心参数

参数 说明 默认值
chunkSize 每个文本块的目标大小(以token为单位) 800
minChunkSizeChars 每个文本块的最小字符数,太短的块会被丢弃或合并 350
minChunkLengthToEmbed 只有长度超过此值的块才会发送给向量模型 5
maxNumChunks 单个文档允许拆分出的最大块数 10000
keepSeparator 是否在块中保留分隔符(如换行符) true

示例中项目地址

  • 项目模块:ai-example-spring-ai-rag
  • 测试文档位置:项目根目录/document/Java语言特性与核心概念.md

代码示例

/**
 * Spring AI原生的TokenTextSplitter使用示例
 */
public class TokenTextSplitterSplit {

    public static List<Document> split(List<Document> documents) {
        if (CollectionUtils.isEmpty(documents)) {
            return Collections.emptyList();
        }

        //使用TokenTextSplitter进行文档分片
        TokenTextSplitter splitter = new TokenTextSplitter(
                // 每块最多600 tokens
                600,
                // 每块至少300字符再考虑断点
                300,
                // 太短的不做嵌入
                5,
                // 最多拆分8000块
                8000,
                // 保留句号、换行符
                true
        );

        return splitter.apply(documents);
    }
}

调用示例与执行结果

执行代码

System.out.println("分片前Document数量: " + docs.size());
List<Document> result = TokenTextSplitterSplit.split(docs);
System.out.println("分片后Document数量: " + result.size());

执行结果

分片前Document数量: 52
分片后Document数量: 61

可以看到,经过分片操作,document从52个变成了61个,说明按照我们的要求已经分片成功了。

自定义支持Overlap的分片器

为什么要自己实现

由于Spring AI的TokenTextSplitter不支持overlap,而overlap对于保持语义连续性很重要,所以我们需要自己实现一个。

完整代码实现

public class OverlapParagraphTextSplit extends TextSplitter {

    // 每块最大字符数
    protected final int chunkSize;
    // 相邻块之间重叠字符数
    protected final int overlap;

    public OverlapParagraphTextSplit(int chunkSize, int overlap) {
        if (chunkSize <= 0) {
            throw new RuntimeException("chunkSize 必须大于 0");
        }
        if (overlap < 0) {
            throw new RuntimeException("overlap 不能为负数");
        }
        if (overlap >= chunkSize) {
            throw new RuntimeException("overlap 不能大于等于 chunkSize");
        }
        this.chunkSize = chunkSize;
        this.overlap = overlap;
    }

    @Override
    public List<String> splitText(String text) {
        if (StrUtil.isEmpty(text)) {
            return Collections.emptyList();
        }
        String[] paragraphs = text.split("\\n+");
        List<String> allChunks = new ArrayList<>();
        StringBuilder currentChunk = new StringBuilder();

        for (String paragraph : paragraphs) {
            if (StrUtil.isEmpty(paragraph)) {
                continue;
            }
            int start = 0;
            while (start < paragraph.length()) {
                int remainingSpace = chunkSize - currentChunk.length();
                int end = Math.min(start + remainingSpace, paragraph.length());

                if (!currentChunk.isEmpty()) {
                    currentChunk.append("\n");
                }
                currentChunk.append(paragraph, start, end);

                // 如果当前块已满,保存并生成新块
                if (currentChunk.length() >= chunkSize) {
                    allChunks.add(currentChunk.toString());

                    // 计算重叠部分
                    String overlapText = "";
                    if (overlap > 0) {
                        int overlapStart = Math.max(0, currentChunk.length() - overlap);
                        overlapText = currentChunk.substring(overlapStart);
                    }

                    currentChunk = new StringBuilder();
                    if (!overlapText.isEmpty()) {
                        currentChunk.append(overlapText);
                    }
                }
                start = end;
            }
        }

        if (!currentChunk.isEmpty()) {
            allChunks.add(currentChunk.toString());
        }

        return allChunks;
    }

    @Override
    public List<Document> apply(List<Document> documents) {
        if (CollectionUtils.isEmpty(documents)) {
            return Collections.emptyList();
        }

        List<Document> result = new ArrayList<>();
        for (Document doc : documents) {
            List<String> chunks = splitText(doc.getText());
            for (String chunk : chunks) {
                result.add(new Document(chunk));
            }
        }
        return result;
    }
}

调用示例与执行结果

执行代码

System.out.println("分片前Document数量: " + docs.size());
OverlapParagraphTextSplit split = new OverlapParagraphTextSplit(
        // 每块最大300字符
        300,
        // 块之间重叠80字符
        80
);
List<Document> result = split.apply(docs);
System.out.println("分片后Document数量: " + result.size());

执行结果

分片前Document数量: 52
分片后Document数量: 76
2-Learning/05-项目/08-企业级项目深读/01-SuperAgent/06-系统讲解AI相关技术/03-RAG详细解析/assets/Fi4P5NbEryG-OVBx5JZwqdlWhfnL-6f948712

我们可以看到调用结果中,相邻文本块之间都有一段overlap重叠,这样就可以一定程度上保证了文本块的延续性。

Spring AI Alibaba的递归分片

框架原生能力

Spring AI Alibaba扩展了Spring AI,提供了RecursiveCharacterTextSplitter,这是目前最推荐的通用分片方式

递归分片的核心思想:按照一组分隔符的优先级来切割文本。先用高优先级的分隔符(如\n\n),如果切出来的块还是太大,再用下一级分隔符继续切。

注意

文档在清洗时,是不能先把空格、换行等符号给清洗掉的,因为要基于这些特殊符号进行分段,去掉了那肯定不行

代码示例

要改变清洗方式: org.javaup.ai.util.DocumentClearHandler#clearDocumentsForRecursiveSplit

/**
 * Spring AI Alibaba 递归分片的清洗方式。
 *
 * 递归分片依赖换行、段落和句号这些“结构化分隔符”来寻找自然边界,
 * 所以这里不能像固定大小分片那样把所有空白都压成一行。
 */
public static List<Document> clearDocumentsForRecursiveSplit(List<Document> documents) {
    if (CollectionUtils.isEmpty(documents)) {
        return documents;
    }

    return documents.stream()
            .map(doc -> {
                if (doc == null || doc.getText() == null) {
                    return doc;
                }

                String text = isCodeLikeDocument(doc)
                        ? cleanCodeLikeDocument(doc.getText())
                        : cleanTextForRecursiveSplit(doc.getText());

                return new Document(text, doc.getMetadata());
            })
            .collect(Collectors.toList());
}

Spring AI Alibaba 的递归分片实现

/**
 * Spring AI Alibaba 的递归分片实现。
 *
 * 这里特意没有直接使用 splitter.apply(documents):
 * 1. 我们希望过滤掉递归切分过程中产生的空 chunk;
 * 2. 我们希望自己补齐 parent_document_id / chunk_index / total_chunks 元数据,方便教学演示;
 * 3. 我们希望对代码块和 Mermaid 这类结构化内容做特殊处理,避免被切得过碎。
 */
public class SpringAiAlibabaRecursiveTextSplit {

    /**
     * 真正拿 Markdown 技术文档做 RAG 时, 500 更接近实战配置。
     */
    private static final int CHUNK_SIZE = 500;

    /**
     * 为了保留“语义完整的句子 / 段落”,这里有意不再按空格和逗号切。
     * 否则英文术语、缩写和代码标识符很容易被拆成 "Java"、"JVM"、"API" 这样的碎片。
     */
    private static final String[] SEPARATORS = {"\n\n", "\n", "。", "!", "?", ";"};

    private SpringAiAlibabaRecursiveTextSplit() {
    }

    public static List<Document> split(List<Document> documents) {
        if (CollectionUtils.isEmpty(documents)) {
            return Collections.emptyList();
        }

        RecursiveCharacterTextSplitter splitter = new RecursiveCharacterTextSplitter(CHUNK_SIZE, SEPARATORS);
        List<Document> result = new ArrayList<>();

        for (Document document : documents) {
            List<String> chunks = splitSingleDocument(document, splitter);
            if (CollectionUtils.isEmpty(chunks)) {
                continue;
            }

            for (int i = 0; i < chunks.size(); i++) {
                Map<String, Object> metadata = new LinkedHashMap<>(document.getMetadata());
                metadata.put("parent_document_id", document.getId());
                metadata.put("chunk_index", i);
                metadata.put("total_chunks", chunks.size());

                Document child = new Document(chunks.get(i), metadata);
                child.setContentFormatter(document.getContentFormatter());
                result.add(child);
            }
        }

        return result;
    }

    private static List<String> splitSingleDocument(Document document, RecursiveCharacterTextSplitter splitter) {
        String text = document.getText();
        if (!StringUtils.hasText(text)) {
            return Collections.emptyList();
        }

        // 代码块 / Mermaid 块本身已经是结构化内容,演示时保留原块更容易理解。
        if (isCodeLikeDocument(document) || text.length() <= CHUNK_SIZE) {
            return List.of(text.trim());
        }

        return splitter.splitText(text).stream()
                .map(String::trim)
                .filter(StringUtils::hasText)
                .toList();
    }

    private static boolean isCodeLikeDocument(Document document) {
        Object category = document.getMetadata().get("category");
        return "code_block".equals(category) || document.getMetadata().containsKey("lang");
    }
}

调用:org.javaup.ai.service.DocumentPreprocessService#process

public List<Document> process(File file) {
    try {
        // 1. 读取文档
        log.info("开始读取文档: {}", file.getName());
        List<Document> docs = readerHandlerContext.read(file);
        log.info("读取完成,共 {} 个Document", docs.size());

        // 2. 清洗文档
        log.info("开始清洗文档");
        // 注意:不同分片器要配不同的清洗策略。
        // 1) TokenTextSplitter / OverlapParagraphTextSplit 偏固定大小切块,可以把空白压成一行;
        //docs = DocumentClearHandler.clearDocumentsForFlatSplit(docs);

        // 2) Spring AI Alibaba 的递归分片依赖 \n\n、\n、句号等边界,不能把这些结构信息清掉。
        docs = DocumentClearHandler.clearDocumentsForRecursiveSplit(docs);
        log.info("清洗完成");

        // 3. 添加元数据
        log.info("添加元数据");
        for (Document doc : docs) {
            doc.getMetadata().put("filename", file.getName());
            doc.getMetadata().put("processTime", System.currentTimeMillis());
        }
        System.out.println("分片前Document数量: " + docs.size());

        // ==================== 方式一:Spring AI 原生 TokenTextSplitter ====================
        // 特点:简单直接,适合演示“固定大小切块”。
        // 使用时建议把上面的清洗切换为 DocumentClearHandler.clearDocumentsForFlatSplit(docs)
        //List<Document> result = TokenTextSplitterSplit.split(docs);

        // ==================== 方式二:自定义 Overlap 分片 ====================
        // 特点:可以控制 chunk overlap,适合讲解“上下文重叠”。
        // 使用时同样建议使用 DocumentClearHandler.clearDocumentsForFlatSplit(docs)
        //OverlapParagraphTextSplit split = new OverlapParagraphTextSplit(300, 80);
        //List<Document> result = split.apply(docs);

        // ==================== 方式三:Spring AI Alibaba 递归分片 ====================
        // 特点:优先尊重段落、换行、句号等自然边界,是更贴近真实 RAG 的通用方案。
        // 这里使用的是“实战版配置”,和文档里为了展示效果而写的 100 字符示例不同:
        // - chunkSize 调大,避免技术文档被切得过碎
        // - 不再按空格继续拆,避免 Java / JVM / API 这类术语单独成块
        // - 过滤空 chunk,并保留 parent_document_id / chunk_index / total_chunks 元数据
        List<Document> result = SpringAiAlibabaRecursiveTextSplit.split(docs);
        System.out.println("分片后Document数量: " + result.size());
        return result;
    } catch (Exception e) {
        log.error("处理文档失败: {}", file.getName(), e);
        throw new RuntimeException("文档处理失败: " + e.getMessage(), e);
    }
}

执行结果

2-Learning/05-项目/08-企业级项目深读/01-SuperAgent/06-系统讲解AI相关技术/03-RAG详细解析/assets/FinauDBNsVZnDfmujrypTH1RZD8w-2b9e8c71

使用递归分片的注意事项

文档不能先把空格、换行等符号清洗掉!

因为递归分片正是依赖这些特殊符号(\n\n\n等)来找切分点的。如果你在文档预处理阶段把换行符清理了,递归分片就没法正确工作了。

结论

  • 入门场景:Spring AI的TokenTextSplitter够用
  • 通用场景:推荐Spring AI Alibaba的RecursiveCharacterTextSplitter
  • 需要overlap:用自定义的OverlapParagraphTextSplitter
  • 需要语义分片或父子分片:看下一篇

小结

这篇讲了Spring系列的分片代码实战:

  • Spring AI的TokenTextSplitter:官方原生,但不支持overlap
  • 自定义OverlapParagraphTextSplitter:基于Spring AI扩展,支持overlap
  • Spring AI Alibaba的RecursiveCharacterTextSplitter:推荐的通用方案

下一篇讲LangChain4J的语义分段和父子分块技术。


企业级项目导航:⬅️ 06-文档切片策略选择 | 07-分片代码实战:Spring系列 | ➡️ 08-ChunkViz实验与可视化