首页 / Spring AI 入门教程 / ETL 管道

Spring AI 入门教程

ETL 管道

本教程共 45 篇 · 第 33 篇 · 更新于 2026-08-16 · 约 9 分钟阅读

Spring AIETLDocumentReaderTokenTextSplitter元数据TikaPDFRAG

本节目标:掌握把原始文档变成向量库数据的三步管道——读取、转换、写入。学会用各类 DocumentReader 读文件、用 TokenTextSplitter 切分文本、用元数据加工提升检索质量。

33.1 ETL 是什么

RAG 的效果上限,取决于数据怎么进向量库。垃圾进,垃圾出。把原始文件加工成适合检索的文档,这套流程叫 ETL。

ETL 是 Extract、Transform、Load 的缩写,对应三个阶段:

Extract(提取)。 从 PDF、Word、网页、JSON 里把文字读出来。Spring AI 里对应 DocumentReader

Transform(转换)。 把长文本切成小块、提取关键词、生成摘要。对应 DocumentTransformer

Load(加载)。 把加工好的文档写进向量库。对应 DocumentWriter,向量库就是最常见的目标。

三个接口各司其职,链路可以一行写出来:

vectorStore.write(tokenTextSplitter.split(pdfReader.read()));

读、切、写,三个词就是整条管道。

33.2 Document 类

管道里流转的文档对象是 Document,它有三个组成部分。

Document doc = new Document(
    "Spring AI 是 Spring 生态的 AI 框架。",  // 正文
    Map.of("source", "intro.md", "chapter", 32)  // 元数据
);

正文是纯文本,元数据是键值对。元数据看似配角,作用很大:检索时可以按元数据过滤,比如只查某个来源、某个分类的文档。写管道时给文档打好元数据标签,等于给检索装上了筛子。

元数据的用途有三层。过滤是第一层,上面说的按来源、分类筛数据。溯源是第二层,回答里附上”出处:第 3 章第 2 节”,用户能核实答案,信任感完全不同。增强是第三层,把标题、章节号这些元数据拼进提示词,模型回答时能带上上下文。建管道时多花几分钟设计元数据,后面检索和问答都受益。

Document 还支持附带图片、音频、视频等多模态内容,常规 RAG 用不到,知道有这个能力即可。

33.3 三大接口

ETL 的三个阶段对应三个函数式接口,设计得很轻。

DocumentReader 继承 Supplier<List<Document>>,是数据源。核心方法 read() 返回文档列表,也可以直接调用 get()

DocumentTransformer 继承 Function<List<Document>, List<Document>>,输入一批文档,输出一批文档。拆分、增强都在这层做。

DocumentWriter 继承 Consumer<List<Document>>,消费文档,负责落库。核心方法 write()

public interface DocumentReader extends Supplier<List<Document>> {
    default List<Document> read() { return get(); }
}

public interface DocumentTransformer extends Function<List<Document>, List<Document>> {
    default List<Document> transform(List<Document> docs) { return apply(docs); }
}

public interface DocumentWriter extends Consumer<List<Document>> {
    default void write(List<Document> documents) { accept(documents); }
}

接口只有这么多,具体实现按需选择。

33.4 读取:DocumentReader 全家桶

Spring AI 内置了七种常用 Reader,覆盖常见文件格式。

文本与 JSON

TextReader 最简单,读一个纯文本文件,整个内容变成一个 Document。可以设置字符集、附加自定义元数据:

TextReader textReader = new TextReader(new FileSystemResource("notes.txt"));
textReader.getCustomMetadata().put("filename", "notes.txt");
List<Document> docs = textReader.read();

JsonReader 处理结构化数据,指定 JSON 里的哪些字段作为正文:

JsonReader jsonReader = new JsonReader(
    new FileSystemResource("bikes.json"), "brand", "description");
List<Document> docs = jsonReader.get();

它会为数组里的每个对象生成一个 Document。嵌套数据可以用 JSON Pointer 精确定位,比如 get("/store/books/0") 取数组第一个元素。

HTML 与 Markdown

网页用 JsoupDocumentReader,需要引入独立依赖:

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

它用 CSS 选择器决定提取哪部分内容,还能把 <meta> 标签收进元数据:

JsoupDocumentReaderConfig config = JsoupDocumentReaderConfig.builder()
    .selector("article p")                    // 只取 <article> 里的段落
    .metadataTags(List.of("author", "date"))  // meta 标签进元数据
    .additionalMetadata("source", "my-page.html")
    .build();
JsoupDocumentReader reader = new JsoupDocumentReader(resource, config);

Markdown 文件用 MarkdownDocumentReader,依赖 spring-ai-markdown-document-reader。它的配置可以控制代码块、引用块是否单独成文档,以及是否用分隔线切分文档。

PDF 与万能格式

PDF 有两个 Reader,都在 spring-ai-pdf-document-reader 里。

PagePdfDocumentReader 按页读取,每页一个文档,withPagesPerDocument 可以控制几页合成一个。ParagraphPdfDocumentReader 利用 PDF 目录信息按段落切,适合有目录的正式文档。

PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(
    "classpath:/sample1.pdf",
    PdfDocumentReaderConfig.builder().withPagesPerDocument(1).build());
List<Document> docs = pdfReader.read();

Word、PPT 这类 Office 格式用 TikaDocumentReader,它基于 Apache Tika,能解析几十种格式:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
TikaDocumentReader reader = new TikaDocumentReader(resource);
List<Document> docs = reader.read();
Note

Reader 大多通过 Resource 读取文件,支持 classpath、文件系统、URL。不要把用户输入的 URL 直接传给 Reader,有安全风险。

33.5 转换:把长文切成小块

模型有上下文窗口限制,一整本书塞不进去。所以读进来的长文档必须切块,这就是 TokenTextSplitter 的活。它是 TextSplitter 抽象类的实现,按 token 数量切分。

TokenTextSplitter splitter = TokenTextSplitter.builder()
    .withChunkSize(800)          // 每块目标 800 token
    .withMinChunkSizeChars(350)  // 最短块长度
    .withKeepSeparator(true)     // 保留换行符
    .build();
List<Document> chunks = splitter.apply(documents);

默认 chunkSize 是 800。切分时会优先在句子边界断开,避免把一个意思切成两半。默认的标点边界是英文的 .?! 和换行,中文文本要换成中文标点:

TokenTextSplitter splitter = TokenTextSplitter.builder()
    .withChunkSize(800)
    .withPunctuationMarks(List.of('。', '?', '!', ';'))
    .build();

2.0 起有个贴心变化:文本没超过 chunkSize 时不再按标点硬切,小块内容保持完整。

切块大小是 RAG 调优的第一站。块太大,噪声多、token 贵;块太小,语义不完整。按经验值,先以 300-500 token 起步,再根据检索效果调整。

33.6 转换:元数据增强

有些转换器用大模型给文档加料,让检索更聪明。

KeywordMetadataEnricher 让模型给每个文档提取关键词,存进 excerpt_keywords 元数据:

KeywordMetadataEnricher enricher = KeywordMetadataEnricher.builder(chatModel)
    .keywordCount(5)
    .build();
List<Document> enriched = enricher.apply(documents);

SummaryMetadataEnricher 生成摘要,还能带上前后相邻文档的摘要,让切块之间互相有上下文:

SummaryMetadataEnricher enricher = new SummaryMetadataEnricher(chatModel,
    List.of(SummaryType.PREVIOUS, SummaryType.CURRENT, SummaryType.NEXT));

增强后的元数据字段(section_summaryprev_section_summary)在检索阶段可以拼进提示词,也可以作为过滤条件。代价是每次增强都要调一次模型,批量处理时注意成本和耗时。

33.7 管道的工程化

管道跑通之后,还要考虑怎么”重复跑”。入库不是一次性任务,文档会更新,管道会重跑。

幂等设计。 给每个文档的元数据打上来源 ID 和版本号。重跑前先按来源 ID 删除旧数据,再写入新数据,避免库里堆满历史版本。第 34 章会讲按过滤表达式删除的方法。

分批与重试。 大批量文档一次嵌入容易触发模型限流。把文档分批提交,每批几百条,失败的重试。Spring AI 的向量库实现内部自带分批策略,但入库任务本身也要设计成可断点续跑。

先小后大。 新管道先用几页文档试跑,检查切块效果和元数据,确认无误再全量导入。切块粒度不对,全量导入后返工成本很高。

33.8 写入:两种 Writer

管道终点有两个选择。

FileDocumentWriter 把文档写回文件,常用于调试——看看切块效果到底怎么样:

FileDocumentWriter writer = new FileDocumentWriter("output.txt", true, MetadataMode.ALL, true);
writer.accept(documents);

第二个参数 withDocumentMarkers 为 true 时,会输出 ### Doc: [index], pages:[start,end] 这样的文档标记,方便核对分块与页码的对应关系。

VectorStore 实现 DocumentWriter,是生产环境的目的地。写入时它自动调用 EmbeddingModel 计算向量,再连同正文、元数据一起入库。

33.9 完整管道示例

把前面所有环节串起来,一个典型的入库任务长这样:

@Component
public class DocumentIngestionService {

    private final VectorStore vectorStore;

    public DocumentIngestionService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public void ingestPdf(String classpathResource) {
        // 1. 读取:PDF 按页读入
        PagePdfDocumentReader reader = new PagePdfDocumentReader(
            classpathResource,
            PdfDocumentReaderConfig.builder().withPagesPerDocument(1).build());

        // 2. 转换:按 token 切块,中文标点断句
        TokenTextSplitter splitter = TokenTextSplitter.builder()
            .withChunkSize(800)
            .withPunctuationMarks(List.of('。', '?', '!', ';'))
            .build();

        // 3. 写入:向量库自动完成嵌入和存储
        vectorStore.write(splitter.split(reader.read()));
    }
}
Tip

大批量入库建议做幂等设计:给每个文档的元数据打上文档 ID 和版本号,重复导入前先按 ID 删除旧数据,避免向量库里堆满重复内容。删除方法第 34 章讲。

33.10 小结

ETL 管道是 RAG 的数据入口,由读取、转换、写入三步组成。Reader 负责把各种格式变成 Document,Splitter 把长文切成适合检索的小块,Writer 把结果送进向量库。元数据贯穿全程,是后续过滤检索的关键。下一章看管道终点——VectorStore 统一抽象。