目前 ETL 管道只能处理 .txt 文件,但实际的知识库通常包含 PDF、Markdown、Word 等多种格式。Spring AI 为每种格式提供了对应的 DocumentReader,逐个引入。
1. PDF 文档处理
PDF 是企业知识库中最常见的格式。先添加依赖:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>PagePdfDocumentReader 按页读取 PDF,每页生成一个独立的 Document:
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 配置选项
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
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");
}
}- 不需要
@SpringBootTest,PagePdfDocumentReader是纯工具类,直接实例化即可 - 验证了两个关键点:按页拆分带
page_number元数据,二次分块后元数据不丢失
2. Markdown 文档处理
Markdown 是技术文档的首选格式。添加依赖:
<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
# 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
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 类型 |
| 适用场景 | 技术文档、API 文档 | 报告、论文、合同 |
如果有条件将知识库文档转为 Markdown 格式,建议优先这样做。Markdown 是 RAG 系统最友好的输入格式。
2.4 测试 Markdown 摄入
src/test/java/com/albertstack/rag/service/MarkdownIngestionTest.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 封装了它:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>// 读取 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 | |
| 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
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 添加自定义元数据
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,可以在检索时只搜索特定类别的文档:
// 只在 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
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 按标题分割
- 未知格式走 Tika:
default分支兜底,不管什么格式都能提取文本 - 方法重载:
ingest(Resource)和ingest(Resource, Map)两个版本,后者支持附加元数据
5.1 整合测试
前面分别测试了每种 Reader 的解析能力。现在通过 IngestionService 验证统一摄入流程,重点测试:自动格式分派、摄入后检索命中、自定义元数据保留。
src/test/java/com/albertstack/rag/service/MultiFormatIngestionTest.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 架构,让检索管道更灵活可控。