4. 文档摄入管道

4 min

1. ETL 管道概述

前面我们手动创建 Document 存入向量库,但实际项目中,知识库通常基于 TXT、PDF、Markdown、Word 等真实文件,需要解析转换后再存储。

Spring AI 提供了一套完整的文档 ETL(Extract-Transform-Load)管道,把文档处理标准化为三个阶段:

阶段 接口 职责
Extract(提取) DocumentReader 从各种格式的文件中读取内容,生成 Document 列表
Transform(转换) DocumentTransformer 对文档进行加工:分块、清洗、添加元数据等
Load(加载) DocumentWriter 将处理后的文档写入目标存储(向量数据库)

这三个接口的定义非常简洁:

java
// 提取:从数据源读取文档
public interface DocumentReader extends Supplier<List<Document>> {
    List<Document> read();
}

// 转换:对文档列表进行加工
public interface DocumentTransformer extends Function<List<Document>, List<Document>> {
    List<Document> transform(List<Document> documents);
}

// 加载:将文档写入存储
public interface DocumentWriter extends Consumer<List<Document>> {
    void write(List<Document> documents);
}

VectorStore 实现了 DocumentWriter 接口,所以整条管道可以用一行代码表达:

java
vectorStore.add(transformer.transform(reader.read()));

读取 -> 转换 -> 存储 ,对应三个接口各调一次。接下来逐步拆解每个环节。

2. TextReader

最简单的 DocumentReader 实现是 TextReader,用于读取纯文本文件。

2.1 准备测试数据

src/main/resources/docs/ 目录下创建一个文本文件:

src/main/resources/docs/spring-ai-intro.txt

text
Spring AI 是 Spring 生态中的 AI 应用开发框架,它为 Java 开发者提供了一套统一的 API 来集成各种 AI 模型和服务。

Spring AI 的核心功能包括:

1. 统一的 Chat API:支持 OpenAI、Ollama、Anthropic 等多种模型供应商,切换模型只需改配置,代码无需修改。

2. Embedding 支持:将文本转换为高维向量表示,是构建 RAG 系统的基础。Spring AI 支持多种 Embedding 模型,包括本地运行的 Ollama 模型。

3. 向量存储抽象:提供统一的 VectorStore 接口,支持 SimpleVectorStore、Qdrant、PGVector、Chroma、Milvus 等多种向量数据库实现,方便开发和生产环境的切换。

4. RAG 支持:内置 QuestionAnswerAdvisor,可以快速搭建检索增强生成管道,将外部知识注入到 LLM 对话中。

5. 文档 ETL 管道:提供 DocumentReader、DocumentTransformer、DocumentWriter 三层抽象,支持 PDF、Markdown、HTML 等多种文档格式的读取和处理。

6. 函数调用(Function Calling):让 LLM 能够调用 Java 方法,实现与外部系统的交互,比如查询数据库、调用 REST API 等。

7. 结构化输出:支持将 LLM 的自然语言输出转换为 Java 对象,方便后续程序处理。

Spring AI 遵循 Spring 的一贯风格:约定优于配置,自动装配,声明式编程。如果你熟悉 Spring Boot,上手 Spring AI 几乎没有额外的学习成本。

2.2 测试 TextReader

src/test/java/com/albertstack/rag/TextReaderTest.java

java
package com.albertstack.rag;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.ClassPathResource;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
class TextReaderTest {

    @Test
    void readTextFile() {
        var resource = new ClassPathResource("docs/spring-ai-intro.txt");
        var reader = new TextReader(resource);

        // read() 将整个文件读为一个 Document,纯文本没有天然分割边界
        List<Document> documents = reader.read();

        assertThat(documents).hasSize(1);
        log.info("内容长度:{}", documents.get(0).getText().length());

        // TextReader 自动添加 charset 和 source 元数据
        var metadata = documents.get(0).getMetadata();
        log.info("元数据:{}", metadata);
        assertThat(metadata).containsKeys("charset", "source");
    }
}

3. TokenTextSplitter

3.1 为什么分块

直接把一篇长文档整体嵌入为一个向量,有两个严重问题:

  1. 上下文窗口限制:LLM 的输入长度有上限。如果检索出的文档太长,拼接多个文档后可能超出模型能处理的范围
  2. 检索精度下降:一篇 5000 字的文章被嵌入为一个向量时,这个向量是整篇文章的"平均语义"。当用户问一个具体问题时,这个粗粒度的向量很难精准匹配

解决方案就是 分块(Chunking) :把长文档切分成适当大小的文本片段,每个片段独立嵌入、独立检索。

3.2 测试 TokenTextSplitter

TokenTextSplitter 实现了 DocumentTransformer 接口,除了接口的 transform() 方法,还提供了语义更直观的 split() 方法,两者逻辑一致,后续统一用 split()

src/test/java/com/albertstack/rag/TokenTextSplitterTest.java

java
package com.albertstack.rag;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.ClassPathResource;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
class TokenTextSplitterTest {

    @Test
    void splitDocument() {
        // 先用 TextReader 读取文档
        var reader = new TextReader(new ClassPathResource("docs/spring-ai-intro.txt"));
        List<Document> documents = reader.read();

        // 配置分块参数(故意设小,让示例文本也能分出多块)
        var splitter = TokenTextSplitter.builder()
            .withChunkSize(200) // 每块目标 200 Token(非字符,中文一字约 1-2 Token)
            .withMinChunkSizeChars(100) // 不足 100 字符的尾块会合并到上一块
            .withKeepSeparator(true) // 保留换行等分割符,上下文更完整
            .build();

        List<Document> chunks = splitter.split(documents);

        log.info("原始文档数:{}", documents.size());
        log.info("分块后数量:{}", chunks.size());
        assertThat(chunks.size()).isGreaterThan(documents.size());

        for (int i = 0; i < chunks.size(); i++) {
            log.info("块 {}:{} 字符", i, chunks.get(i).getText().length());
        }
    }
}

3.3 默认配置

不需要自定义参数时,直接用默认配置:

java
var splitter = TokenTextSplitter.builder().build();
参数 默认值 说明
chunkSize 800 每块目标 Token 数(上面的测试用了 200,方便演示分块效果)
minChunkSizeChars 350 块最小字符数(上面的测试用了 100)
minChunkLengthToEmbed 5 低于此字符数的块会被丢弃
maxNumChunks 10000 单文档最大分块数
keepSeparator true 是否保留分割符

对大多数场景来说,默认配置已经够用。

3.4 分块策略

分块大小的选择直接影响 RAG 的检索质量:

块大小 优点 缺点 适用场景
过小(< 200 Token) 检索精准度高 缺乏上下文,答案不完整 FAQ、短问答
过大(> 1500 Token) 上下文充足 引入噪声,检索不够精准 长篇论述、法律条款
适中(300-1000 Token) 兼顾精准度和上下文 需要根据内容调整 大多数 RAG 场景

注意细节:

  • 一致性:同知识库使用相同分块策略,混合块大小会导致短块在相似度计算中占优
  • 重叠:建议开启块间重叠,减少上下文丢失( TokenTextSplitter 需自定义实现)
  • 文档结构:优先在段落、章节边界分割,Markdown 可用 MarkdownDocumentReader 按标题自动切分

4. 元数据流转

ETL 管道中一个重要的细节: 元数据会在管道的每个阶段流转和保留

4.1 测试元数据流转

src/test/java/com/albertstack/rag/MetadataFlowTest.java

java
package com.albertstack.rag;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.ClassPathResource;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
class MetadataFlowTest {

    @Test
    void metadataSurvivesThroughPipeline() {
        // 1. Reader 自动添加 source、charset
        var reader = new TextReader(new ClassPathResource("docs/spring-ai-intro.txt"));
        List<Document> docs = reader.read();
        log.info("Reader 元数据:{}", docs.get(0).getMetadata());
        // {charset=UTF-8, source=spring-ai-intro.txt}

        // 2. 手动添加业务元数据
        reader.getCustomMetadata().put("category", "framework");
        reader.getCustomMetadata().put("author", "Albert");
        docs = reader.read();
        log.info("自定义元数据:{}", docs.get(0).getMetadata());
        // {charset=UTF-8, source=spring-ai-intro.txt, category=framework, author=Albert}

        assertThat(docs.get(0).getMetadata()).containsKeys("source", "category", "author");

        // 3. Splitter 分块后,每个 chunk 继承父文档的全部元数据
        var splitter = TokenTextSplitter.builder().build();
        List<Document> chunks = splitter.split(docs);

        for (Document chunk : chunks) {
            log.info("块元数据:{}", chunk.getMetadata());
            assertThat(chunk.getMetadata()).containsKeys("charset", "source", "category", "author");
        }
    }
}

4.2 元数据的用途

元数据在后续的检索阶段非常有用:

  • 来源追溯:回答中标注"该信息来自 xxx.pdf 第 3 页"
  • 过滤检索:只在特定分类的文档中搜索
  • 权限控制:根据文档的部门/密级标签控制访问
  • 去重标识:通过 source + 时间戳判断文档是否已经摄入过

5. 完整 ETL 示例

现在把所有环节串联起来,构建一个完整的文档摄入服务。

5.1 IngestionService

src/main/java/com/albertstack/rag/service/IngestionService.java

java
package com.albertstack.rag.service;

import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class IngestionService {

    private final VectorStore vectorStore;
    private final TokenTextSplitter splitter;

    public IngestionService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
        this.splitter = TokenTextSplitter.builder()
            .withChunkSize(800)
            .withMinChunkSizeChars(350)
            .build();
    }

    public int ingest(Resource resource) {
        // Extract:读取文本文件
        var reader = new TextReader(resource);
        reader.getCustomMetadata().put("source", resource.getFilename());

        // Transform:分块
        List<Document> chunks = splitter.split(reader.read());

        // Load:写入向量存储
        vectorStore.add(chunks);

        return chunks.size();
    }
}

5.2 测试 IngestionService

src/test/java/com/albertstack/rag/service/IngestionServiceTest.java

java
package com.albertstack.rag.service;

import org.junit.jupiter.api.Test;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.ClassPathResource;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class IngestionServiceTest {

    @Autowired
    private IngestionService ingestionService;

    @Autowired
    private VectorStore vectorStore;

    @Test
    void shouldIngestAndSearch() {
        // 摄入文档
        var resource = new ClassPathResource("docs/spring-ai-intro.txt");
        int chunkCount = ingestionService.ingest(resource);

        // 验证分块数量
        assertThat(chunkCount).isGreaterThan(0);

        // 验证检索
        var results = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query("Spring AI 支持哪些功能")
                .topK(3)
                .build()
        );

        assertThat(results).isNotEmpty();
        assertThat(results.get(0).getText()).contains("Spring AI");

        // 验证元数据保留
        assertThat(results.get(0).getMetadata()).containsKey("source");
    }
}

这个测试覆盖了 ETL 管道的三个关键环节:

  • Extract:从 classpath 读取文本文件
  • Transform:分块(通过验证 chunkCount > 0 间接确认)
  • Load + Search:写入后能检索到,且元数据完整

6. 本章小结

本章构建了完整的文档摄入管道,实现了从文件读取到向量存储的自动化流程。

知识点 说明
ETL 管道架构 DocumentReader -> DocumentTransformer -> DocumentWriter 三阶段流水线
TextReader 读取纯文本文件,自动添加 source 元数据
TokenTextSplitter 基于 Token 的智能分块,分块策略直接影响检索质量
元数据流转 Reader 添加 -> Splitter 保留 -> 检索时可用,贯穿整个管道
IngestionService 封装完整的摄入流程,从文件到向量存储一步到位

接下来把检索和 LLM 生成串联起来,搭建第一个 RAG 问答系统。