6. 多格式文档处理

5 min

目前 ETL 管道只能处理 .txt 文件,但实际的知识库通常包含 PDFMarkdownWord 等多种格式。Spring AI 为每种格式提供了对应的 DocumentReader,逐个引入。

1. PDF 文档处理

PDF 是企业知识库中最常见的格式。先添加依赖:

xml
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>

PagePdfDocumentReader 按页读取 PDF,每页生成一个独立的 Document

java
var pdfResource = new ClassPathResource("docs/sample-report.pdf");
var pdfReader = new PagePdfDocumentReader(pdfResource);

List<Document> pages = pdfReader.read();

log.info("总页数:{}", pages.size());
for (Document page : pages) {
    log.info("第 {} 页,内容长度:{} 字符",
        page.getMetadata().get("page_number"),
        page.getText().length());
}
  • 一页一个 Document:按页拆分,metadata 中自动包含 page_number(页码)和 file_name
  • 二次分块:单页内容量差异大,建议读取后仍用 TokenTextSplitter 分块,既保留页码元数据,又确保块大小一致

1.1 配置选项

java
var config = PdfDocumentReaderConfig.builder()
    .withPageTopMargin(0) // 顶部裁剪边距,跳过页眉
    .withPageBottomMargin(0) // 底部裁剪边距,跳过页脚
    .withPagesPerDocument(1) // 每个 Document 包含几页,默认 1
    .build();

var pdfReader = new PagePdfDocumentReader(pdfResource, config);

1.2 测试 PDF 摄入

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

java
package com.albertstack.rag.service;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.core.io.ClassPathResource;

import java.util.List;

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

@Slf4j
class PdfIngestionTest {

    @Test
    void shouldReadPdfByPage() {
        var resource = new ClassPathResource("docs/sample-report.pdf");
        var reader = new PagePdfDocumentReader(resource);

        List<Document> pages = reader.read();
        log.info("PDF 总页数:{}", pages.size());

        assertThat(pages).isNotEmpty();

        // 验证每页都有 page_number 元数据
        for (Document page : pages) {
            assertThat(page.getMetadata()).containsKey("page_number");
            log.info("第 {} 页,内容长度:{} 字符",
                    page.getMetadata().get("page_number"),
                    page.getText().length());

            log.info("第 {} 页,前 100 字:{}",
                    page.getMetadata().get("page_number"),
                    page.getText().substring(0, Math.min(100, page.getText().length())));
        }

        // 二次分块
        var splitter = TokenTextSplitter.builder()
                .withChunkSize(200)
                .withMinChunkSizeChars(100)
                .build();
        List<Document> chunks = splitter.split(pages);
        log.info("分块后数量:{}", chunks.size());

        // 分块后仍保留页码元数据
        assertThat(chunks.get(0).getMetadata()).containsKey("page_number");
    }
}
  • 不需要 @SpringBootTestPagePdfDocumentReader 是纯工具类,直接实例化即可
  • 验证了两个关键点:按页拆分带 page_number 元数据,二次分块后元数据不丢失

2. Markdown 文档处理

Markdown 是技术文档的首选格式。添加依赖:

xml
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-markdown-document-reader</artifactId>
</dependency>

MarkdownDocumentReader 能识别标题层级,按章节智能分割。

2.1 准备测试文件

src/main/resources/docs/spring-boot-guide.md

markdown
# Spring Boot 快速入门

Spring Boot 让 Spring 应用的创建和部署变得前所未有的简单。

## 核心特性

### 自动配置

Spring Boot 根据 classpath 中的依赖自动配置 Bean。例如,添加了 spring-boot-starter-web 依赖后,Spring Boot 会自动配置嵌入式 Tomcat 和 Spring MVC。

### 起步依赖

Starter 是一组预定义的依赖描述符,简化了 Maven 配置。常用的 Starter 包括:
- spring-boot-starter-web:Web 应用
- spring-boot-starter-data-jpa:JPA 数据访问
- spring-boot-starter-security:安全框架

## 配置管理

Spring Boot 支持多种配置方式:application.properties、application.yml、环境变量、命令行参数。配置项按优先级加载,高优先级覆盖低优先级。

## 监控与管理

Spring Boot Actuator 提供了生产级的监控端点,包括健康检查、指标收集、环境信息等。

2.2 使用 MarkdownDocumentReader

java
var mdResource = new ClassPathResource("docs/spring-boot-guide.md");

var config = MarkdownDocumentReaderConfig.builder()
    .withHorizontalRuleCreateDocument(true) // 遇到水平分割线时创建新文档
    .withIncludeCodeBlock(true) // 保留代码块内容
    .withIncludeBlockquote(true) // 保留引用块内容
    .build();

var mdReader = new MarkdownDocumentReader(mdResource, config);
List<Document> docs = mdReader.read();

for (Document doc : docs) {
    log.info("标题层级:{}", doc.getMetadata().get("category"));
    log.info("内容预览:{}", doc.getText().substring(0, Math.min(100, doc.getText().length())));
}
  • 按标题分割:每个标题(#, ##, ###)下的内容成为一个独立的 Document,语义完整
  • 元数据丰富category 字段包含标题层级信息,检索时可按章节过滤

2.3 Markdown vs PDF

特性 Markdown PDF
分割策略 按标题自动分割,语义完整 按页分割,可能截断段落
元数据 标题层级、代码块语言 页码
解析准确度 非常高(纯文本格式) 依赖 PDF 类型
适用场景 技术文档、API 文档 报告、论文、合同

如果有条件将知识库文档转为 Markdown 格式,建议优先这样做。Markdown 是 RAG 系统最友好的输入格式。

2.4 测试 Markdown 摄入

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

java
package com.albertstack.rag.service;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.markdown.MarkdownDocumentReader;
import org.springframework.ai.reader.markdown.config.MarkdownDocumentReaderConfig;
import org.springframework.core.io.ClassPathResource;

import java.util.List;

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

@Slf4j
class MarkdownIngestionTest {

    @Test
    void shouldSplitByHeading() {
        var resource = new ClassPathResource("docs/spring-boot-guide.md");
        var config = MarkdownDocumentReaderConfig.builder()
            .withIncludeCodeBlock(true)
            .withIncludeBlockquote(true)
            .build();

        var reader = new MarkdownDocumentReader(resource, config);
        List<Document> docs = reader.read();

        log.info("Markdown 分割后文档数:{}", docs.size());
        assertThat(docs.size()).isGreaterThan(1);

        // 每个 Document 对应一个标题章节
        for (Document doc : docs) {
            log.info("标题层级:{},内容预览:{}",
                doc.getMetadata().get("category"),
                doc.getText().substring(0, Math.min(80, doc.getText().length())));
        }

        // 验证元数据包含标题层级信息
        assertThat(docs.get(0).getMetadata()).containsKey("category");
    }
}
  • MarkdownDocumentReader###### 标题自动分割,每个章节语义完整
  • category 元数据记录了标题层级,检索时可用于过滤特定章节

3. Tika 万能解析

前面分别引入了 PDF 和 Markdown 的专用 Reader。但如果知识库中还有 Word、PowerPoint、HTML 等格式呢?为每种格式单独写一个分支不太现实。

Apache Tika 是一个内容分析工具包,支持上千种文件格式。Spring AI 通过 TikaDocumentReader 封装了它:

xml
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
java
// 读取 Word 文档
var docxResource = new ClassPathResource("docs/meeting-notes.docx");
var tikaReader = new TikaDocumentReader(docxResource);
List<Document> docs = tikaReader.read();

log.info("提取文本长度:{}", docs.get(0).getText().length());

Tika 支持的常见格式:

格式 扩展名 说明
PDF .pdf 文字型 PDF
Word .docx, .doc Microsoft Word
PowerPoint .pptx, .ppt 演示文稿(提取文本)
Excel .xlsx, .xls 电子表格(提取单元格文本)
HTML .html, .htm 网页(去除标签,保留文本)
纯文本 .txt, .csv 文本文件
电子书 .epub EPUB 格式

3.1 为什么不全用 Tika

既然 Tika 什么都能解析,为什么还要单独讲 PDF 和 Markdown 的 Reader?

因为 Tika 是通用方案,精度换广度

  • Tika 解析 PDF 拿不到 page_number 元数据,PagePdfDocumentReader 可以
  • Tika 解析 Markdown 不会按标题分割,MarkdownDocumentReader 可以
  • Tika 把整个文件内容提取为一个 Document,必须依赖 TokenTextSplitter 做机械分块

专用 Reader 提供格式特有的结构化信息,Tika 提供最广的格式覆盖。两者的关系是:已知格式用专用 Reader,未知格式用 Tika 兜底

3.2 测试 Tika 摄入

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

java
package com.albertstack.rag.service;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.core.io.ClassPathResource;

import java.util.List;

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

@Slf4j
class TikaIngestionTest {

    @Test
    void shouldReadDocxWithTika() {
        var resource = new ClassPathResource("docs/meeting-notes.docx");
        var reader = new TikaDocumentReader(resource);

        List<Document> docs = reader.read();
        log.info("Tika 提取文档数:{}", docs.size());
        log.info("提取文本长度:{} 字符", docs.get(0).getText().length());
        log.info("文本预览:{}", docs.get(0).getText().substring(0, Math.min(200, docs.get(0).getText().length())));

        // Tika 把整个文件提取为一个 Document
        assertThat(docs).hasSize(1);
        assertThat(docs.get(0).getText()).isNotBlank();
    }
}
  • Tika 不做结构化分割,整个文件提取为 一个 Document,需要后续用 TokenTextSplitter 分块
  • 和前面 PDF、Markdown 的专用 Reader 对比:没有 page_number,没有 category,验证了"精度换广度"的 tradeoff

4. 元数据管理

多源知识库中,元数据用于追溯来源和检索时过滤。

4.1 添加自定义元数据

java
var reader = new PagePdfDocumentReader(pdfResource);
List<Document> documents = reader.read();

for (Document doc : documents) {
    doc.getMetadata().put("source", "spring-ai-docs.pdf");
    doc.getMetadata().put("category", "documentation");
    doc.getMetadata().put("author", "Spring Team");
    doc.getMetadata().put("ingested_at", Instant.now().toString());
}

4.2 基于元数据过滤检索

SearchRequest 支持 filterExpression,可以在检索时只搜索特定类别的文档:

java
// 只在 PDF 文档中搜索
var results = vectorStore.similaritySearch(
    SearchRequest.builder()
        .query("Spring AI 的核心功能")
        .topK(5)
        .filterExpression("source == 'spring-ai-docs.pdf'")
        .build()
);

// 组合条件
var results = vectorStore.similaritySearch(
    SearchRequest.builder()
        .query("Spring Boot 自动配置")
        .topK(5)
        .filterExpression("category == 'documentation' && source == 'spring-boot-guide.md'")
        .build()
);

5. 统一摄入服务

把三种 Reader 整合到 IngestionService 中,根据文件扩展名自动选择:

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

java
package com.albertstack.rag.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.reader.markdown.MarkdownDocumentReader;
import org.springframework.ai.reader.markdown.config.MarkdownDocumentReaderConfig;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.tika.TikaDocumentReader;
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;
import java.util.Map;

@Slf4j
@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, Map<String, Object> metadata) {
        List<Document> documents = readDocuments(resource);

        // 附加自定义元数据
        for (Document doc : documents) {
            doc.getMetadata().put("source", resource.getFilename());
            doc.getMetadata().putAll(metadata);
        }

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

        log.info("摄入完成:{} -> {} 个分块", resource.getFilename(), chunks.size());
        return chunks.size();
    }

    public int ingest(Resource resource) {
        return ingest(resource, Map.of());
    }

    private List<Document> readDocuments(Resource resource) {
        String ext = getFileExtension(resource);

        return switch (ext) {
            case "pdf" -> new PagePdfDocumentReader(resource).read();
            case "md" -> {
                var config = MarkdownDocumentReaderConfig.builder()
                    .withIncludeCodeBlock(true)
                    .withIncludeBlockquote(true)
                    .build();
                yield new MarkdownDocumentReader(resource, config).read();
            }
            case "txt", "csv" -> new TextReader(resource).read();
            // 未知格式用 Tika 兜底
            default -> new TikaDocumentReader(resource).read();
        };
    }

    private String getFileExtension(Resource resource) {
        String filename = resource.getFilename();
        if (filename == null || !filename.contains(".")) {
            return "";
        }
        return filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
    }
}

设计思路:

  • 已知格式走专用 Reader:PDF 拿到页码,Markdown 按标题分割
  • 未知格式走 Tikadefault 分支兜底,不管什么格式都能提取文本
  • 方法重载ingest(Resource)ingest(Resource, Map) 两个版本,后者支持附加元数据

5.1 整合测试

前面分别测试了每种 Reader 的解析能力。现在通过 IngestionService 验证统一摄入流程,重点测试:自动格式分派、摄入后检索命中、自定义元数据保留。

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

java
package com.albertstack.rag.service;

import lombok.extern.slf4j.Slf4j;
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 java.util.Map;

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

@Slf4j
@SpringBootTest
class MultiFormatIngestionTest {

    @Autowired
    private IngestionService ingestionService;

    @Autowired
    private VectorStore vectorStore;

    @Test
    void shouldIngestMultipleFormats() {
        // TXT -> TextReader
        int txtChunks = ingestionService.ingest(new ClassPathResource("docs/spring-ai-intro.txt"));
        log.info("TXT 摄入分块数:{}", txtChunks);

        // PDF -> PagePdfDocumentReader
        int pdfChunks = ingestionService.ingest(new ClassPathResource("docs/sample-report.pdf"));
        log.info("PDF 摄入分块数:{}", pdfChunks);

        // Markdown -> MarkdownDocumentReader
        int mdChunks = ingestionService.ingest(new ClassPathResource("docs/spring-boot-guide.md"));
        log.info("Markdown 摄入分块数:{}", mdChunks);

        // DOCX -> TikaDocumentReader(兜底)
        int docxChunks = ingestionService.ingest(new ClassPathResource("docs/meeting-notes.docx"));
        log.info("DOCX(Tika) 摄入分块数:{}", docxChunks);

        assertThat(txtChunks).isGreaterThan(0);
        assertThat(pdfChunks).isGreaterThan(0);
        assertThat(mdChunks).isGreaterThan(0);
        assertThat(docxChunks).isGreaterThan(0);

        // 跨格式检索
        var results = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query("Spring Boot 自动配置")
                .topK(5)
                .build()
        );
        log.info("跨格式检索结果数:{}", results.size());
        results.forEach(doc -> log.info("  来源:{},内容预览:{}",
            doc.getMetadata().get("source"),
            doc.getText().substring(0, Math.min(80, doc.getText().length()))));

        assertThat(results).isNotEmpty();
    }

    @Test
    void shouldPreserveCustomMetadata() {
        var resource = new ClassPathResource("docs/spring-ai-intro.txt");
        ingestionService.ingest(resource, Map.of("category", "framework"));

        var results = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query("Spring AI")
                .topK(1)
                .build()
        );

        assertThat(results).isNotEmpty();
        log.info("自定义元数据:{}", results.get(0).getMetadata());
        assertThat(results.get(0).getMetadata())
            .containsEntry("category", "framework")
            .containsKey("source");
    }
}

6. 本章小结

为 RAG 系统增加了多格式文档处理能力,从专用 Reader 到 Tika 兜底,构建了统一的摄入服务。

知识点 说明
PagePdfDocumentReader 按页读取 PDF,保留页码元数据
MarkdownDocumentReader 按标题分割 Markdown,语义完整
TikaDocumentReader 万能兜底,支持 Word、PPT、HTML 等上千种格式
专用 vs 通用 已知格式用专用 Reader(元数据更丰富),未知格式用 Tika 兜底
元数据过滤 filterExpression 支持按来源、分类等条件过滤检索
统一摄入服务 switch 表达式按扩展名分派 Reader,default 走 Tika

接下来引入模块化 RAG 架构,让检索管道更灵活可控。