老魏 · 折腾铺
民宿站监控台双色球点哪抠哪老照片修复Murdoku科学探险家
折腾铺/Java RAG 连载
Java RAG 连载2026-09-09老魏

Java RAG 合集④:垃圾进垃圾出,ETL 管道与切分策略

RAG 效果差,八成不是模型的锅,是文档被切烂了。本篇讲透 Spring AI 2.0 的 ETL 管道:Reader 怎么选、切分器五参数怎么调(构造函数已废弃)、三种切分策略,以及表格被截断、代码块被切断两个致命坑。
Java RAG 合集④:垃圾进垃圾出,ETL 管道与切分策略

Java RAG 合集④:垃圾进,垃圾出——ETL 管道与切分策略

上篇我们把"一句话怎么变成一串数字"讲透了。但有个事我一直憋着没说:

Embedding 之前,文档得先切好块。切错了一切白搭。

你满怀期待地把公司手册扔进系统,问了句"年假怎么折算",AI 一本正经地胡说八道。你的第一反应是"这模型不行",换 GPT-5、换 Claude,还是胡说。

八成不是模型的问题,是你的文档被切烂了。 计算机圈有条老话:Garbage In, Garbage Out——垃圾进,垃圾出。这一篇就讲怎么不往系统里灌垃圾。

读完你应该能: - 搞懂 Spring AI 的 ETL 管道三段式:读 → 切 → 写 - 知道 TokenTextSplitter 五个参数各管什么(2.0 构造函数已废弃,网上教程还在教老写法) - 会选切分策略:固定 / 按标题 / 语义 - 避开两个致命坑:表格被拦腰截断、代码块被切断


一、ETL 管道:读 → 切 → 写

Spring AI ETL 管道

Spring AI 把"把文档变成向量库里的数据"这件事抽象成了三段:

Reader 和 Writer 是体力活,Transformer 才是技术活。 块切得好不好,直接决定后面检索的天花板——切烂了,你换什么模型都救不回来。

Reader 怎么选

Spring AI 内置了一堆 Reader,按文件类型对号入座:

你的文件 用哪个 Reader
PDF 按页(最常用) PagePdfDocumentReader
PDF 按段落(排版规整) ParagraphPdfDocumentReader
Word / PPT / HTML(万金油) TikaDocumentReader
Markdown / 纯文本 TextReader / MarkdownDocumentReader
JSON(结构化数据) JsonReader

PDF 加依赖(以 PGVector 篇为例,其他向量库同理换 artifact):

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>

读一下:

PagePdfDocumentReader reader = new PagePdfDocumentReader(
        "classpath:/company-handbook.pdf",
        PdfDocumentReaderConfig.builder()
                .withNumberOfTopPagesToSkipBeforeRead(2)  // 跳过封面、目录页
                .build());
List<Document> docs = reader.get();

坑 3:网上有教程说能配"只读第 3-5 页"——没有这个 API。2.0 只有 withNumberOfTopPagesToSkipBeforeRead(n) 跳过前 N 页,不能指定页区间。想要某几页?自己 subList() 去。

坑 4:spring-ai-pdf-document-reader 修过递归漏洞(CVE-2026-47851)。务必用 2.0.1 及以上,老版本读恶意构造的 PDF 可能栈溢出。


二、TokenTextSplitter:五个参数,一个都不能瞎设

切分的主力选手是 TokenTextSplitter。先把 2.0 的正确写法亮出来:

TokenTextSplitter splitter = TokenTextSplitter.builder()
        .withChunkSize(800)          // 每块目标 token 数(默认 800)
        .withMinChunkSizeChars(350)  // 每块最少字符数(默认 350)
        .withMinChunkLengthToEmbed(5)// 短于这个数的块直接丢弃(默认 5)
        .withMaxNumChunks(10000)     // 单文档最多切多少块,防失控(默认 10000)
        .withKeepSeparator(true)     // 保留分隔符,如 markdown 的 ##(默认 true)
        .build();

List<Document> chunks = splitter.apply(docs);

坑 5(本篇最大坑):TokenTextSplitter 的构造函数在 2.0 已标记废弃。网上 90% 的教程还在教 new TokenTextSplitter(800, 350, 5, 10000, true)——五个裸参数,IDE 直接给你画删除线。新代码一律用上面的 Builder。不传参 TokenTextSplitter.builder().build() 就是全默认值。

参数怎么调?给个体感:

坑 6:2.0 有个行为变更——token 数不超过 chunkSize 的小文本,不再在标点处切分(1.x 会切)。整篇短文就是一个块。这是官方故意改的,别当成 bug 去社区提问,会被老外围观。

三种切分策略

三种切分策略对比

TokenTextSplitter 只是"固定切分"这一派。江湖上共有三派:

① 固定切分(TokenTextSplitter):每块一样大,切到哪算哪。简单、可控、不漏内容——但句子经常被拦腰截断。适合快速起步。

② 按标题切(Markdown / 目录结构):一块 = 一个完整小节。语义完整、天然带层级、溯源方便。手册、规范、Wiki 类文档的首选。

③ 语义切分:把相邻句子两两算 embedding 相似度,相似度"断崖"的地方切一刀。块内语义最聚合,召回质量天花板最高——但每个句子都要过一遍 Embedding,慢且贵,离线批量跑才划算。

我的建议写在图里了:90% 的场景,先按标题切,超长章节再用 TokenTextSplitter 二次兜底。这是性价比最高的组合拳,别一上来就整语义切分的花活。


三、两个致命坑:表格和代码块

切分的两大坑

固定切分最擅长干的事,就是把完整的东西从中间劈成两半。最容易中招的是表格和代码块。

坑 A:表格被拦腰截断。 表头在 Chunk A,数据在 Chunk B。用户问"哪个产品库存最多"——A 有表头没数据,B 有数据没表头,两个块都召不回完整信息,LLM 只能开始编。

坑 B:代码块被切断。 方法签名和闭合括号分家,语法都不完整。LLM 拿半段代码去解释,编得比表格还起劲。

解法:让代码块独立成块

如果你的文档是 Markdown(技术团队的知识库大多是),有现成解法——MarkdownDocumentReader:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-markdown-document-reader</artifactId>
</dependency>
MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
        // false = 代码块不跟正文混在一个 Document 里,独立成块
        // 切分只发生在标题处,代码块内部永远不会被劈开
        .withIncludeCodeBlock(false)
        .withIncludeBlockquote(false)  // 引用块同理,独立成块
        .withHorizontalRuleCreateDocument(true) // --- 分隔线也切一块
        .withAdditionalMetadata("source", "design-doc.md")
        .build();

MarkdownDocumentReader mdReader =
        new MarkdownDocumentReader("classpath:/design-doc.md", config);

坑 7(反直觉):withIncludeCodeBlock(false) 的意思不是"不要代码块",而是"代码块跟正文分家、各自独立成 Document"。传 true 才是"代码块和周围正文挤在一个块里等着被混切"。而且默认值就是 false——不少教程建议显式写出来,别赌默认值。

表格暂时没有这么贴心的开关(withIncludeTables 之类不存在于 Markdown reader),大表格的土办法:预处理时把表格转成"每行一句自然语言"("A-01 产品单价 12.5 元,库存 340 件"),一句话一个语义单元,怎么切都不散。


四、元数据:现在偷懒,将来流泪

切块时顺手把元数据补上,这是最容易被跳过、后面哭得最惨的一步:

Document doc = new Document("……正文……", Map.of(
        "source", "company-handbook.pdf",  // 来源:溯源展示用
        "version", "2026-09",             // 版本:知识库更新时按它删旧
        "tenant", "beijing-branch",       // 租户:多租户隔离过滤
        "updatedAt", "2026-09-09"         // 时间:权限/时效判断
));

为什么非要加?三个真实场景:

  1. 溯源:答案后面挂"出自 company-handbook.pdf 第 12 页",可信度完全不同
  2. 更新:手册改版了,按 source + version 精准删旧插新,不用全库重灌
  3. 隔离:北京分公司的问答不能检索到上海分公司的制度——元数据过滤一把梭(第五篇细讲)

关键词和摘要这种要调 LLM 生成的元数据(KeywordMetadataEnricher / SummaryMetadataEnricher),每次跑都要花 token 钱——知识库百万字级时先算算账,别开箱就上。


五、串起来:一条能跑的 ETL 管道

三段拼装,一个方法搞定:

@Component
public class KnowledgeIngestion {

    private final VectorStore vectorStore; // 第五篇教你怎么配

    public void ingestMarkdown(String path) {
        // ① 读:Markdown 按标题成块,代码块独立
        MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
                .withIncludeCodeBlock(false)
                .withAdditionalMetadata("source", path)
                .build();
        List<Document> docs =
                new MarkdownDocumentReader(path, config).get();

        // ② 加工:超长块二次兜底
        List<Document> chunks = TokenTextSplitter.builder()
                .withChunkSize(800)
                .build()
                .apply(docs);

        // ③ 写:一把梭进向量库(内部自动做 embedding)
        vectorStore.accept(chunks);
    }
}

vectorStore.accept(chunks) 这一行内部会自动调 EmbeddingModel 把每块文本转向量再写入——你不需要手动 embed。这就是第①篇全景图里"离线索引"那条线的全部代码。


写在最后

这一篇是 RAG 的"地基篇":管道三段式、五参数、三派切法、两个致命坑、元数据四件套。切分决定天花板——地基打歪了,后面选型再豪华都是危楼。

下一篇《向量库选型与 PGVector 实战》:Milvus / PGVector / Redis / ES 到底选谁?Docker 五分钟起一个 PGVector,顺便填上今天埋的 initialize-schema 那个坑。

点个"在看"——垃圾进垃圾出这学费,咱们替读者交过了。


作者:老魏 · 一个把公众号和代码当副业的散户投资者 系列:Java RAG 从零到上线 · 第 4/10 篇 下期:向量库选型与 PGVector 实战