1. ETL 管道概述
前面我们手动创建 Document 存入向量库,但实际项目中,知识库通常基于 TXT、PDF、Markdown、Word 等真实文件,需要解析转换后再存储。
Spring AI 提供了一套完整的文档 ETL(Extract-Transform-Load)管道,把文档处理标准化为三个阶段:
| 阶段 | 接口 | 职责 |
|---|---|---|
| Extract(提取) | DocumentReader |
从各种格式的文件中读取内容,生成 Document 列表 |
| Transform(转换) | DocumentTransformer |
对文档进行加工:分块、清洗、添加元数据等 |
| Load(加载) | DocumentWriter |
将处理后的文档写入目标存储(向量数据库) |
这三个接口的定义非常简洁:
// 提取:从数据源读取文档
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 接口,所以整条管道可以用一行代码表达:
vectorStore.add(transformer.transform(reader.read()));读取 -> 转换 -> 存储 ,对应三个接口各调一次。接下来逐步拆解每个环节。
2. TextReader
最简单的 DocumentReader 实现是 TextReader,用于读取纯文本文件。
2.1 准备测试数据
在 src/main/resources/docs/ 目录下创建一个文本文件:
src/main/resources/docs/spring-ai-intro.txt
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
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 为什么分块
直接把一篇长文档整体嵌入为一个向量,有两个严重问题:
- 上下文窗口限制:LLM 的输入长度有上限。如果检索出的文档太长,拼接多个文档后可能超出模型能处理的范围
- 检索精度下降:一篇 5000 字的文章被嵌入为一个向量时,这个向量是整篇文章的"平均语义"。当用户问一个具体问题时,这个粗粒度的向量很难精准匹配
解决方案就是 分块(Chunking) :把长文档切分成适当大小的文本片段,每个片段独立嵌入、独立检索。
3.2 测试 TokenTextSplitter
TokenTextSplitter 实现了 DocumentTransformer 接口,除了接口的 transform() 方法,还提供了语义更直观的 split() 方法,两者逻辑一致,后续统一用 split()。
src/test/java/com/albertstack/rag/TokenTextSplitterTest.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 默认配置
不需要自定义参数时,直接用默认配置:
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
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
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
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 问答系统。