上线三个月,知识库问答效果一直不行------团队的第一反应是换更牛的模型,结果换了三个,效果纹丝不动。问题根本不在模型。**RAG 的效果等于六个环节的乘积:数据质量、切分、向量化、检索、重排、模型------模型只是最后一环。**90% 的 RAG 效果差,问题出在检索之前。今天这篇,我们把 Spring AI 2.0 的文档 ETL Pipeline 从接口源码到生产踩坑一次讲透。

一、为什么文档 ETL 是 RAG 的生死线
2026 年 9 月,企业级 AI 落地已进入"工程化深水区"。CSDN 热榜上「LangChain 实战指南」持续霸榜,但少有人点破一个事实:再精妙的检索算法和再强大的大模型,都救不了一份被切得支离破碎的文档。
我亲历过一个金融合规项目。团队用 Spring AI 1.x 搭建了 RAG 系统,接入 GPT-6 Astra,结果用户问"2026 年 Q2 信用卡费率调整规则",系统召回的 chunk 上半截是 Q1 的旧规则,下半截是 Q2 的新规则------模型被两段矛盾信息搞懵了,输出了一句"建议咨询客服"。
根因不在模型,在 ETL 阶段:
-
PDF 解析时把表格和正文混成了一团
-
TokenTextSplitter 按 800 token 硬切,刚好把"第四条 费率调整"的标题和正文切到了两个 chunk
-
元数据里没注入文档版本号,导致新旧文档的 chunk 在向量空间里混在一起
Spring AI 2.0 的 ETL 框架(Extract-Transform-Load)正是为了解决这类问题而生。 它用 DocumentReader → DocumentTransformer → DocumentWriter 三个接口,把"从原始文档到向量数据库"的混沌过程,变成了可编排、可观测、可回滚的标准数据管道。
对 Java 工程师来说,这是熟悉的领域------我们不就是在写 ETL 吗?只不过数据源从 MySQL binlog 变成了 PDF 合同,目标库从 ClickHouse 变成了 PgVector。Java 工程师做 AI,工程化能力才是护城河。
二、Spring AI 2.0 ETL 核心架构:三大接口的源码级拆解
Spring AI 2.0 的 ETL 框架定义在 org.springframework.ai.document 包下,核心就三个接口,设计极简却极具扩展性:
java
// DocumentReader:数据源抽取
public interface DocumentReader extends Supplier<List<Document>> {
default List<Document> read() {
return get();
}
}
// DocumentTransformer:文档转换
public interface DocumentTransformer extends Function<List<Document>, List<Document>> {
default List<Document> transform(List<Document> documents) {
return apply(documents);
}
}
// DocumentWriter:数据加载
public interface DocumentWriter extends Consumer<List<Document>> {
default void write(List<Document> documents) {
accept(documents);
}
}
这三个接口分别继承自 Supplier、Function、Consumer,意味着它们天然可以:
-
用方法引用链式组装:
vectorStore.write(splitter.transform(reader.read())) -
接入 Java Stream API 做并行处理
-
被 Spring Integration 或 Spring Cloud Data Flow 编排进更大的数据管道
而贯穿三者的是 Document 类------它不只是"一段文本",而是一个携带元数据的多媒体内容容器:
java
public class Document {
private final String id; // 唯一标识,默认 UUID
private final String text; // 文本内容
private final Map<String, Object> metadata; // 元数据:来源、页码、版本、作者等
private final List<Media> media; // 多媒体附件(图片、音频、视频)
// ...
}
Spring AI 2.0 的一个关键改进是 Document.media 字段。 这意味着 Document 不再局限于纯文本,可以携带图片、音频、视频等多模态内容。对于需要处理扫描版 PDF(内嵌图片)或视频字幕的企业场景,这是基础设施级别的升级。
三、DocumentReader 深度解析:Tika 4.0 与 PDFBox 的选型博弈
3.1 TikaDocumentReader:万能解析器的荣光与代价
TikaDocumentReader 基于 Apache Tika,支持 1000+ 种文件格式。2026 年 8 月 21 日,Apache Tika 4.0.0 正式发布,默认输出切换为 Markdown,解析进程移至 crash-isolated forked 进程,并新增了对 VLM(Vision Language Model)解析器的支持(Claude、Gemini、OpenAI)。
java
@Bean
public DocumentReader universalDocumentReader(@Value("classpath:docs/*") Resource[] resources) {
// Tika 4.0 默认输出 Markdown,保留标题层级结构
return () -> Arrays.stream(resources)
.flatMap(resource -> new TikaDocumentReader(resource).get().stream())
.peek(doc -> doc.getMetadata().put("parser", "tika-4.0"))
.collect(Collectors.toList());
}
Tika 4.0 的 Markdown 默认输出对 RAG 来说是重大利好。 以前 Tika 3.x 输出纯文本,PDF 里的表格被拍平成一段混乱的文字;现在表格被保留为 Markdown 表格格式,大模型更容易理解结构化数据。
但 Tika 的代价也很明显:
-
依赖极其沉重 :
spring-ai-tika-document-reader拉取了近百个传递依赖,包含 PDFBox、POI、JSoup 等,Docker 镜像直接膨胀 200MB+ -
启动慢 :首次加载 Parser 类需要扫描 classpath 上的所有解析器
-
内存敏感:大文件(>100MB 的 PDF)容易触发 OOM
3.2 PagePdfDocumentReader:PDF 专家的精准打击
如果数据源以 PDF 为主,更轻量的选择是 PagePdfDocumentReader(基于 PDFBox):
java
@Bean
public DocumentReader pdfDocumentReader(@Value("classpath:contracts/*.pdf") Resource[] resources) {
PdfDocumentReaderConfig config = PdfDocumentReaderConfig.builder()
.withPageTopMargin(0)
.withPageBottomMargin(0)
.withPagesPerDocument(1) // 每页作为一个独立 Document
.build();
return () -> Arrays.stream(resources)
.flatMap(resource -> new PagePdfDocumentReader(resource, config).get().stream())
.peek(doc -> doc.getMetadata().put("source_type", "pdf_per_page"))
.collect(Collectors.toList());
}
PagePdfDocumentReader 的核心优势是页级隔离。当一份 200 页的合同被逐页切分时,检索召回的 chunk 不会跨页,避免了"第 3 页的风险条款 + 第 4 页的定义条款"这种令人困惑的组合。
源码层面,PagePdfDocumentReader 通过 PDFTextStripperByArea 提取每页文本,并在 get() 方法中为每页生成独立的 Document 实例,自动注入 page_number 元数据。 这是 TikaDocumentReader 做不到的------Tika 会把整份 PDF 的文本连成一片输出。
3.3 ParagraphPdfDocumentReader:按目录结构的语义切分
对于带有 PDF Catalog(目录)结构的文档,ParagraphPdfDocumentReader 能按段落和 TOC 层级拆分:
java
ParagraphPdfDocumentReader reader = new ParagraphPdfDocumentReader(
resource,
PdfDocumentReaderConfig.defaultConfig(),
"\t" // 段落分隔符
);
但这个 Reader 有一个致命前提 :PDF 必须包含完整的 Catalog 结构。现实中,扫描版 PDF、从 Word 导出的 PDF、或者某些老旧系统生成的 PDF,往往没有完整的 Catalog。调用 ParagraphPdfDocumentReader 会静默退化成按固定长度切分,导致切分结果不可预期。
生产建议:先用 PDFParser.getDocumentCatalog() 检测 Catalog 完整性,再决定用 Paragraph 还是 Page Reader。
四、TokenTextSplitter 源码分析:切分不是体力活,是技术活
如果说 DocumentReader 解决"读得到"的问题,那 TokenTextSplitter 解决的就是"切得对"的问题。这是 ETL 管道中最容易被低估、也是翻车最频繁的环节。
4.1 源码结构:从 TextSplitter 到 Token 边界
java
public class TokenTextSplitter extends TextSplitter {
private final EncodingRegistry registry = Encodings.newLazyEncodingRegistry();
private final Encoding encoding; // 默认可配置,Spring AI 2.0 默认 CL100K_BASE
private int defaultChunkSize = 800; // 每块目标 token 数
private int minChunkSizeChars = 350; // 每块最小字符数
private int minChunkLengthToEmbed = 5; // 低于此长度直接丢弃
private int maxNumChunks = 10000; // 单文档最大 chunk 数
private boolean keepSeparator = true; // 是否保留分隔符
// Spring AI 2.0 新增:Builder 模式
public static Builder builder() { return new Builder(); }
}
Spring AI 2.0 的关键改进:TokenTextSplitter 从 document.splitter 包迁移到了 transformer.splitter 包,并引入了 Builder 模式。 1.x 时代的构造函数参数列表(new TokenTextSplitter(500, 50, 10, 10000, true))被弃用,新代码应该使用:
java
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(512) // 中文语料建议从 512 起步
.withMinChunkSizeChars(128)
.withChunkOverlap(50) // 跨块重叠,防止上下文断裂
.build();
4.2 切分算法的核心逻辑
TokenTextSplitter 的切分不是简单的"每 800 个 token 一刀切"。它的核心算法在 splitText(String text) 方法中:
- Token 化 :用 jtokkit 的
CL100K_BASE编码器把文本编码为 token 数组 - 窗口滑动 :按
defaultChunkSize取窗口,但窗口边界不会硬切在单词中间 - 标点回溯 :在窗口末端向前回溯,寻找最近的句子结束标点(。!?.!?
) - Fallback:如果回溯距离超过阈值(默认 50 token),则硬切
java
// 简化版核心逻辑示意
protected List<String> splitText(String text) {
List<Integer> tokens = encoding.encode(text);
List<String> chunks = new ArrayList<>();
int start = 0;
while (start < tokens.size()) {
int end = Math.min(start + defaultChunkSize, tokens.size());
// 尝试在标点处切断
int splitPoint = findNearestSentenceEnd(tokens, end);
if (splitPoint <= start) splitPoint = end; // fallback 硬切
List<Integer> chunkTokens = tokens.subList(start, splitPoint);
chunks.add(encoding.decode(chunkTokens));
start = splitPoint - chunkOverlap; // 重叠窗口
}
return chunks;
}
这个算法的本质是在"token 预算"和"语义完整性"之间做权衡。 它比普通字符切分更聪明,但还没有演进到"基于语义相似度的层级切分"。对于制度类、合同类文档,仅靠 TokenTextSplitter 往往不够------一个"第四条 费率调整"的完整条款可能被切成两半。
4.3 结构感知切分:Java 工程师的扩展方案
对于带结构的文档(制度、合同、技术规范),我们需要在 TokenTextSplitter 之上做一层结构感知切分:
java
@Component
public class StructureAwareDocumentTransformer implements DocumentTransformer {
// 匹配"第X章""第X条""第X节"或 Markdown 标题
private static final Pattern SECTION_PATTERN = Pattern.compile(
"(?m)^(第[一二三四五六七八九十百千]+[章条节]|#{1,3}\\s+.+)"
);
private final TokenTextSplitter fallbackSplitter = TokenTextSplitter.builder()
.withChunkSize(512)
.withMinChunkSizeChars(128)
.build();
@Override
public List<Document> apply(List<Document> documents) {
return documents.stream()
.flatMap(this::splitByStructure)
.collect(Collectors.toList());
}
private Stream<Document> splitByStructure(Document doc) {
String text = doc.getText();
String[] sections = SECTION_PATTERN.split(text);
Matcher matcher = SECTION_PATTERN.matcher(text);
List<String> headers = new ArrayList<>();
while (matcher.find()) {
headers.add(matcher.group().trim());
}
List<Document> result = new ArrayList<>();
for (int i = 0; i < sections.length; i++) {
String section = sections[i].trim();
if (section.length() < minChunkLengthToEmbed) continue;
String header = (i > 0 && i <= headers.size()) ? headers.get(i - 1) : "";
String fullText = header + "\n" + section;
// 如果单节仍然太长,再用 TokenTextSplitter 兜底
if (estimateTokens(fullText) > 512) {
Document tempDoc = new Document(fullText, doc.getMetadata());
result.addAll(fallbackSplitter.apply(List.of(tempDoc)));
} else {
Document newDoc = new Document(fullText, new HashMap<>(doc.getMetadata()));
newDoc.getMetadata().put("section_header", header);
result.add(newDoc);
}
}
return result.stream();
}
private int estimateTokens(String text) {
// 中文按 1 token ≈ 1.5 字符估算
return text.length() / 2;
}
}
这个自定义 Transformer 的核心思想是:结构边界优先于 token 边界。 一个完整的"第四条"作为整体被保留,如果单条太长,再用 TokenTextSplitter 兜底切分。这比盲切的检索召回质量高出至少一个数量级。
五、企业级项目实战:金融合规文档智能知识库
接下来,我们围绕一个金融合规文档审查系统(ComplianceDoc)完整落地 Spring AI 2.0 的 ETL Pipeline。
5.1 系统架构
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ PDF/Word/Excel │ -> │ DocumentReader │ -> │ DocumentTrans- │ -> │ VectorStore │
│ 合同/制度文件 │ │ (Tika + PDFBox)│ │ former │ │ (PgVector) │
└─────────────────┘ └─────────────────┘ │ - 结构感知切分 │ └─────────────────┘
│ - 元数据注入 │ │
│ - 版本控制 │ ▼
└─────────────────┘ ┌─────────────────┐
│ EmbeddingModel │
│ (BGE-M3/通义) │
└─────────────────┘
5.2 核心服务:DocIngestionPipeline
java
@Service
@Slf4j
@RequiredArgsConstructor
public class DocIngestionPipeline {
private final VectorStore vectorStore;
private final EmbeddingModel embeddingModel;
private final MeterRegistry meterRegistry; // Micrometer 监控
// 批量入库,每批 100 个 document
private static final int BATCH_SIZE = 100;
@Transactional
public IngestionResult ingestDocuments(List<Resource> resources, String docVersion) {
Timer.Sample sample = Timer.start(meterRegistry);
// Step 1: Extract - 统一用 Tika 4.0 读取(输出 Markdown)
List<Document> rawDocs = resources.stream()
.flatMap(resource -> {
try {
TikaDocumentReader reader = new TikaDocumentReader(resource);
return reader.get().stream().peek(doc -> {
doc.getMetadata().put("source", resource.getFilename());
doc.getMetadata().put("doc_version", docVersion);
doc.getMetadata().put("ingested_at", Instant.now().toString());
doc.getMetadata().put("doc_type", inferDocType(resource.getFilename()));
});
} catch (Exception e) {
log.error("Failed to parse resource: {}", resource.getFilename(), e);
meterRegistry.counter("doc.ingest.errors",
"reason", "parse_failed").increment();
return Stream.empty();
}
})
.collect(Collectors.toList());
log.info("Extracted {} raw documents from {} resources", rawDocs.size(), resources.size());
// Step 2: Transform - 结构感知切分 + 元数据增强
StructureAwareDocumentTransformer structureTransformer =
new StructureAwareDocumentTransformer();
List<Document> structuredDocs = structureTransformer.apply(rawDocs);
// 追加元数据:章节信息、合规标签
structuredDocs.forEach(doc -> {
doc.getMetadata().put("chunk_index",
doc.getMetadata().getOrDefault("chunk_index", 0));
doc.getMetadata().put("compliance_tags",
extractComplianceTags(doc.getText()));
});
// Step 3: Load - 批量写入 PgVector,带重试
List<List<Document>> batches = Lists.partition(structuredDocs, BATCH_SIZE);
int successCount = 0;
for (List<Document> batch : batches) {
try {
vectorStore.accept(batch);
successCount += batch.size();
meterRegistry.counter("doc.ingest.batch.success").increment();
} catch (Exception e) {
log.error("Batch write failed, attempting individual retry", e);
// 单条重试,隔离坏数据
for (Document doc : batch) {
try {
vectorStore.accept(List.of(doc));
successCount++;
} catch (Exception ex) {
log.error("Individual doc write failed: {}",
doc.getMetadata().get("source"), ex);
meterRegistry.counter("doc.ingest.errors",
"reason", "vector_store_write").increment();
}
}
}
}
sample.stop(meterRegistry.timer("doc.ingest.duration"));
return new IngestionResult(
resources.size(), rawDocs.size(), structuredDocs.size(),
successCount, docVersion
);
}
private String inferDocType(String filename) {
if (filename.contains("合同") || filename.contains("contract")) return "contract";
if (filename.contains("制度") || filename.contains("policy")) return "policy";
if (filename.contains("合规") || filename.contains("compliance")) return "compliance";
return "general";
}
private List<String> extractComplianceTags(String text) {
List<String> tags = new ArrayList<>();
if (text.contains("反洗钱")) tags.add("aml");
if (text.contains("个人信息保护")) tags.add("pii");
if (text.contains("费率") || text.contains("利率")) tags.add("pricing");
if (text.contains("风险")) tags.add("risk");
return tags;
}
}
5.3 版本化文档管理:新旧版本隔离
金融合规场景的一个核心需求是文档版本隔离。新制度发布后,旧制度的 chunk 不应该再被召回。我们在元数据层实现版本控制,并在检索时过滤:
java
@Service
@RequiredArgsConstructor
public class VersionedDocumentRetriever {
private final VectorStore vectorStore;
public List<Document> retrieve(String query, String requiredVersion) {
SearchRequest request = SearchRequest.builder()
.query(query)
.topK(10)
.filterExpression(
Filter.Expression.builder()
.field("doc_version")
.operator(Filter.Expression.ExpressionType.EQ)
.value(requiredVersion)
.build()
)
.build();
return vectorStore.similaritySearch(request);
}
// 清理过期版本(异步任务)
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2 点
public void sweepExpiredVersions() {
// 保留最近 3 个版本,其余删除
// PgVector 通过 metadata filter 批量删除
}
}
六、生产踩坑清单:文档 ETL 的 10 个真实陷阱
坑 1:Tika 依赖爆炸导致镜像 OOM
现象 :引入 spring-ai-tika-document-reader 后,Docker 镜像从 180MB 膨胀到 520MB,容器启动时频繁 OOMKilled。
根因 :Tika 拉取了 POI、PDFBox、JSoup、Tesseract 等上百个传递依赖,其中 tika-parsers-standard-package 包含了大量用不到的解析器(如 3D 模型、CAD 文件)。
解法:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
<exclusions>
<!-- 排除不需要的解析器 -->
<exclusion><groupId>org.apache.tika</groupId><artifactId>tika-parser-sqlite3-module</artifactId></exclusion>
<exclusion><groupId>org.apache.tika</groupId><artifactId>tika-parser-scientific-module</artifactId></exclusion>
</exclusions>
</dependency>
坑 2:TokenTextSplitter 的 chunkOverlap 与向量重复
现象:检索时同一段内容的不同 chunk 同时被召回,模型输出重复答案。
根因 :chunkOverlap 默认开启(Spring AI 2.0 默认 50 token),重叠区域的 chunk 在向量空间高度相似,导致 topK 召回结果被同一内容的多个切片占据。
解法 :重叠是必需的(防止上下文断裂),但检索层需要去重。自定义 DocumentRetriever:
java
public List<Document> deduplicateBySource(List<Document> docs) {
return docs.stream()
.collect(Collectors.toMap(
d -> d.getMetadata().get("source") + "#" + d.getText().hashCode(),
Function.identity(),
(a, b) -> a // 保留第一个
))
.values().stream().toList();
}
坑 3:PDF 表格解析成乱码
现象:PDF 里的表格被 Tika 解析成一段没有结构的文字,行列信息完全丢失。
根因:PDF 是布局格式而非结构格式。Tika 4.0 虽然默认输出 Markdown,但复杂表格(合并单元格、嵌套表格)的解析仍然依赖启发式规则。
解法 :对表格密集型文档,先用 Apache PDFBox 的 PDFTextStripperByArea 提取表格区域,再用 OpenCSV 或自定义解析器处理。或者改用 PagePdfDocumentReader 按页切分,让模型自行理解表格结构。
坑 4:中文 Embedding 模型维度不匹配
现象:使用 BGE-M3(1024 维)生成向量,但 PgVector 表定义是 1536 维(OpenAI ada-002 的维度),写入直接报错。
根因:不同 Embedding 模型的输出维度不同。BGE-M3 是 1024 维,OpenAI text-embedding-3-small 是 1536 维,text-embedding-3-large 是 3072 维。
解法 :PgVector 的向量列类型使用 vector(1536) 等固定维度,切换模型时必须重建表。生产环境建议抽象一个 EmbeddingConfig:
java
@Configuration
public class EmbeddingConfig {
@Bean
public int embeddingDimensions(EmbeddingModel model) {
// 运行时探测模型维度
return model.embed("test").length;
}
}
坑 5:大文件解析阻塞主线程
现象:上传一份 200MB 的扫描版 PDF,整个 ETL 管道卡死 5 分钟,期间其他文档无法处理。
根因 :DocumentReader 的 get() 方法是同步阻塞的,大文件解析在 Tomcat 工作线程上执行。
解法 :用 Spring 的 @Async + CompletableFuture 将解析任务放入独立线程池:
java
@Async("docIngestionExecutor")
public CompletableFuture<IngestionResult> ingestAsync(List<Resource> resources, String version) {
return CompletableFuture.completedFuture(ingestDocuments(resources, version));
}
@Bean("docIngestionExecutor")
public Executor docIngestionExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("doc-ingest-");
executor.initialize();
return executor;
}
坑 6:元数据注入导致的向量库索引膨胀
现象:PgVector 表体积膨胀到 50GB,查询速度从 50ms 降到 2s。
根因 :每个 Document 的 metadata 被完整序列化为 JSONB 存储,而 compliance_tags 等大列表字段导致单行体积巨大。
解法:metadata 只存储检索必需的字段(source、version、page_number),其他业务字段存入关系型数据库,通过 doc_id 关联。
坑 7:TokenTextSplitter 默认 chunkSize 不适合中文
现象:中文文档按默认 800 token 切分后,每块只有 300 多个汉字,chunk 数量爆炸,检索召回质量下降。
根因:CL100K_BASE 编码下,中文平均 1 token ≈ 1.5 个汉字。800 token 约等于 500-600 汉字,对于制度类文档偏短。
解法 :中文语料建议 chunkSize 从 512 起步(约 300-400 汉字),制度类长段落可以放到 1024。关键在于根据实际检索效果做 A/B 测试,而不是照搬英文文档的参数。
坑 8:Spring AI 2.0 API 迁移陷阱
现象 :从 Spring AI 1.x 升级后,TokenTextSplitter 找不到类,VectorStoreRetriever 编译报错。
根因 :Spring AI 2.0 做了包名重构:
-
TokenTextSplitter从org.springframework.ai.document.splitter移到org.springframework.ai.transformer.splitter -
VectorStoreRetriever改名为VectorStoreDocumentRetriever -
QuestionAnswerAdvisor被移除,替换为RetrievalAugmentationAdvisor
解法:升级时全局替换包名,并参考官方 migration guide 逐条检查 Advisor 链的 API 变化。
坑 9:Embedding 批量写入超时
现象:批量写入 1000 个 chunk 时,PgVector 连接超时,或者 OpenAI Embedding API 触发速率限制。
根因 :vectorStore.accept(docs) 内部会逐个调用 EmbeddingModel,大批量下耗时线性增长。
解法 :
-
控制批次大小(建议 50-100)
-
对 EmbeddingModel 调用层做熔断降级(Resilience4j)
-
使用本地 Embedding 模型(如 Ollama + BGE-M3)规避第三方 API 限流
坑 10:文档编码问题导致中文乱码
现象:某些 Word 文档解析后,中文全部变成问号或乱码。
根因:Word 文档(特别是 .doc 而非 .docx)的内部编码可能是 GBK、GB2312 或 Big5,Tika 的编码探测偶尔会失败。
解法 :在 TikaDocumentReader 之前,先用 Tika 类单独检测编码:
java
Tika tika = new Tika();
String detectedEncoding = tika.detect(resource.getInputStream());
if (!"UTF-8".equals(detectedEncoding)) {
// 先转码为 UTF-8 再交给 Reader
}
七、总结:Java 工程师的 ETL 思维是 AI 落地的核心竞争力
Spring AI 2.0 的 ETL 框架把"文档 → 向量"的过程抽象成了三个清晰的接口:DocumentReader、DocumentTransformer、DocumentWriter。这个设计本身并不复杂------甚至可以说,它的优雅正体现在没有发明新轮子 ,而是用 Java 工程师最熟悉的 Supplier、Function、Consumer 来建模数据流。
但真正决定 RAG 系统成败的,不是框架本身,而是工程化细节 :
-
PDF 表格怎么保结构?
-
制度文档怎么按条款切分?
-
新旧版本怎么隔离?
-
大文件怎么异步处理?
-
元数据怎么设计才能既支持检索过滤又不膨胀存储?
这些问题没有标准答案,只有在具体业务场景中不断迭代才能找到最优解。而 Java 工程师在多年企业级开发中积累的 ETL 经验、数据质量意识、性能调优能力和监控思维,恰恰是解决这些问题的最佳武器。
做 AI 不用转 Python。用 Java 的工程化能力,把文档 ETL 做到极致,RAG 就已经成功了一半。
本文源码示例基于 Spring AI 2.0.0 GA + Spring Boot 4.1 + Apache Tika 4.0.0 + PgVector。完整项目代码可在评论区留言获取。