Spring AI 2.0 企业级文档ETL实战:从 DocumentReader 到 EmbeddingStore 的完整知识库数据管道

上线三个月,知识库问答效果一直不行------团队的第一反应是换更牛的模型,结果换了三个,效果纹丝不动。问题根本不在模型。**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)正是为了解决这类问题而生。 它用 DocumentReaderDocumentTransformerDocumentWriter 三个接口,把"从原始文档到向量数据库"的混沌过程,变成了可编排、可观测、可回滚的标准数据管道。

对 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);
    }
}

这三个接口分别继承自 SupplierFunctionConsumer,意味着它们天然可以:

  • 用方法引用链式组装: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) 方法中:

  1. Token 化 :用 jtokkit 的 CL100K_BASE 编码器把文本编码为 token 数组
  2. 窗口滑动 :按 defaultChunkSize 取窗口,但窗口边界不会硬切在单词中间
  3. 标点回溯 :在窗口末端向前回溯,寻找最近的句子结束标点(。!?.!?
  4. 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 做了包名重构:

  • TokenTextSplitterorg.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,大批量下耗时线性增长。

解法

  1. 控制批次大小(建议 50-100)

  2. 对 EmbeddingModel 调用层做熔断降级(Resilience4j)

  3. 使用本地 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 框架把"文档 → 向量"的过程抽象成了三个清晰的接口:DocumentReaderDocumentTransformerDocumentWriter。这个设计本身并不复杂------甚至可以说,它的优雅正体现在没有发明新轮子 ,而是用 Java 工程师最熟悉的 SupplierFunctionConsumer 来建模数据流。

但真正决定 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。完整项目代码可在评论区留言获取。

相关推荐
吃饱了得干活1 小时前
Spring Boot + Redis 分布式锁:一个订单重复提交引发的六次迭代
java·redis·后端
xiaoqiMikko1 小时前
pom 里没有、代码里没调过,但 WebClient 默认用的就是 netty-resolver-dns
java·spring boot
wuminyu1 小时前
Kafka的写入延迟与磁盘IO抖动原理分析
java·linux·c语言·jvm·c++
aramae1 小时前
模拟实现strcpy(字符串拷贝)(C语言)
java·c语言·开发语言·算法
IT毕设实战小研1 小时前
基于大数据的跨国外派人员适应满意度与留存影响因素可视化分析
android·java·大数据·python·django·课程设计
letisgo51 小时前
JAVA 高级进阶10篇《消息队列实战:RocketMQ/Kafka选型与“不丢不重有序“三连解》
java·面试·kafka·消息队列·rocketmq
2401_850481171 小时前
UVA101
java
不会写代码的女程序猿1 小时前
明理 AI 四诊仪到底有哪些优势?
大数据·人工智能·科技·ai·健康医疗
Lyyaoo.2 小时前
【回溯】【中等】全排列
java·数据结构·算法