ETL 管道
本教程共 45 篇 · 第 33 篇 · 更新于 2026-08-16 · 约 9 分钟阅读
本节目标:掌握把原始文档变成向量库数据的三步管道——读取、转换、写入。学会用各类 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();
NoteReader 大多通过
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_summary、prev_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 统一抽象。