Spring AI Alibaba 文档智能处理:PDF、Markdown知识入库全链路
导读:知识库的质量上限由文档处理质量决定。本文深入讲解 Spring AI Alibaba 的文档加载、内容清洗、智能分块、元数据增强、增量更新与异步向量化管道的全链路实现,这些细节决定了 RAG 系统的天花板。
一、文档处理的质量陷阱
很多团队搭了 RAG 系统,但效果不理想,根本原因往往不在模型,而在文档处理质量太差:
- PDF 里的表格变成了乱码;
- 分块策略不当,一段完整的概念被切断了;
- 无用的页眉页脚内容混入了知识块;
- 没有元数据,检索到内容后不知道来源。
这些问题在入库阶段埋下,在检索阶段爆发,而且很难排查。本文就是要把这条链路上的每个环节都做扎实。
二、文档加载器全家桶
2.1 依赖配置
xml
<!-- PDF 文档读取 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<!-- Markdown/HTML/文本读取(包含在 spring-ai-core 中) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
</dependency>
<!-- Tika(通用文档解析,支持 Word/Excel/PPT 等) -->
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parsers-standard-package</artifactId>
<version>2.9.2</version>
</dependency>
2.2 PDF 加载:两种策略
Spring AI 提供了两个 PDF 读取器,适用场景不同:
策略一:PagePdfDocumentReader(按页读取,保留结构)
java
@Service
@RequiredArgsConstructor
@Slf4j
public class DocumentLoaderService {
/**
* PDF 按页读取:每页作为一个 Document
* 适合:内容按页组织的文档(报告、手册、教材)
*/
public List<Document> loadPdfByPage(Resource pdfResource) {
PdfDocumentReaderConfig config = PdfDocumentReaderConfig.builder()
// 每页作为独立 Document
.withPagesPerDocument(1)
// 文本格式化:保留换行,过滤短行(页眉页脚)
.withPageExtractedTextFormatter(
new ExtractedTextFormatter.Builder()
.withNumberOfTopPagesToSkipBeforeDelete(0)
// 过滤掉少于 6 个 token 的行(通常是页眉页脚)
.withNumberOfBottomTextLinesToDelete(1)
.withNumberOfTopTextLinesToDelete(1)
.build()
)
.build();
PagePdfDocumentReader reader = new PagePdfDocumentReader(pdfResource, config);
List<Document> documents = reader.get();
// 为每个 Document 注入文件来源元数据
String fileName = pdfResource.getFilename();
for (int i = 0; i < documents.size(); i++) {
Document doc = documents.get(i);
doc.getMetadata().put("source", fileName);
doc.getMetadata().put("pageNumber", i + 1);
doc.getMetadata().put("totalPages", documents.size());
}
log.info("PDF [{}] 共 {} 页已加载", fileName, documents.size());
return documents;
}
/**
* PDF 段落读取:按段落分割(适合密集段落的论文、合同等)
*/
public List<Document> loadPdfByParagraph(Resource pdfResource) {
ParagraphPdfDocumentReader reader = new ParagraphPdfDocumentReader(
pdfResource,
PdfDocumentReaderConfig.builder()
.withPageTopMargin(70) // 忽略页面顶部 70pt(过滤页眉)
.withPageBottomMargin(70) // 忽略页面底部 70pt(过滤页脚)
.build()
);
return reader.get();
}
}
2.3 Markdown 加载
java
/**
* Markdown 文档加载
* 适合:技术文档、Wiki、README、博客文章
*/
public List<Document> loadMarkdown(Resource markdownResource) {
MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
// 是否将代码块作为独立 Document
.withIncludeCodeBlock(true)
// 是否将各级标题作为分割点
.withIncludeBlockquote(false)
.build();
MarkdownDocumentReader reader = new MarkdownDocumentReader(
markdownResource, config);
List<Document> documents = reader.get();
// Markdown 特殊处理:提取标题作为元数据
documents.forEach(doc -> {
String content = doc.getContent();
// 提取第一行作为标题(如果是 # 开头)
String firstLine = content.split("\n")[0];
if (firstLine.startsWith("#")) {
String title = firstLine.replaceAll("^#+\\s*", "").trim();
doc.getMetadata().put("title", title);
}
});
return documents;
}
2.4 HTML 加载(Web 内容入库)
java
/**
* HTML 文档加载:过滤标签,保留纯文本
*/
public List<Document> loadHtml(Resource htmlResource) {
// Spring AI 提供了 HtmlDocumentReader(需要 jsoup 依赖)
List<Document> documents = new ArrayList<>();
try (InputStream is = htmlResource.getInputStream()) {
// 使用 Jsoup 解析 HTML,提取纯文本
org.jsoup.nodes.Document htmlDoc = Jsoup.parse(is, "UTF-8", "");
// 移除无用标签
htmlDoc.select("script, style, nav, footer, header, aside").remove();
String cleanText = htmlDoc.body().text();
String title = htmlDoc.title();
Document doc = new Document(cleanText);
doc.getMetadata().put("title", title);
doc.getMetadata().put("source", htmlResource.getFilename());
documents.add(doc);
} catch (IOException e) {
log.error("HTML 加载失败:{}", e.getMessage());
}
return documents;
}
2.5 通用文档加载(Tika)
java
/**
* 基于 Apache Tika 的通用文档加载器
* 支持 Word、Excel、PPT、TXT 等几十种格式
*/
public List<Document> loadWithTika(Resource resource) {
Tika tika = new Tika();
try {
String content = tika.parseToString(resource.getInputStream());
// 检测文档语言(可选)
LanguageDetector detector = OptimaizeLangDetector.getDefaultLanguageDetector().loadModels();
String language = detector.detect(content).getLanguage();
Document doc = new Document(content);
doc.getMetadata().put("source", resource.getFilename());
doc.getMetadata().put("language", language);
doc.getMetadata().put("fileType",
FilenameUtils.getExtension(resource.getFilename()));
return List.of(doc);
} catch (Exception e) {
log.error("Tika 解析失败:{}", e.getMessage());
return Collections.emptyList();
}
}
三、内容清洗:让噪声远离知识库
3.1 PDF 常见噪声清洗
java
@Component
public class ContentCleaner {
/**
* PDF 内容清洗
* 处理 PDF 提取后常见的格式问题
*/
public String cleanPdfContent(String rawContent) {
if (rawContent == null) return "";
return rawContent
// 1. 修复 PDF 中常见的连字符换行(单词被拆分到两行)
.replaceAll("(\\w+)-\\n(\\w+)", "$1$2")
// 2. 去除多余的空白行(超过2个换行压缩为2个)
.replaceAll("\\n{3,}", "\n\n")
// 3. 去除行首行尾的空白
.lines()
.map(String::trim)
.filter(line -> !line.isEmpty())
.collect(Collectors.joining("\n"))
// 4. 去除页码(独立行的数字,如 "- 12 -" 或 "12")
.replaceAll("(?m)^\\s*-?\\s*\\d+\\s*-?\\s*$", "")
// 5. 去除乱码字符(PDF 字体编码问题)
.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]", "")
.trim();
}
/**
* HTML 内容清洗
*/
public String cleanHtmlContent(String rawContent) {
if (rawContent == null) return "";
return rawContent
// 去除 HTML 标签(保底处理)
.replaceAll("<[^>]+>", "")
// 替换 HTML 实体
.replace(" ", " ")
.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace(""", "\"")
// 去除多余空白
.replaceAll("\\s{2,}", " ")
.trim();
}
/**
* 判断文本块是否为有效内容(过滤噪声块)
*/
public boolean isValidContent(String content) {
if (content == null || content.trim().length() < 50) {
return false; // 太短的块过滤掉
}
// 中文字符占比检查(针对中文文档)
long totalChars = content.length();
long chineseChars = content.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF).count();
long digitAndPunctuationChars = content.chars()
.filter(c -> !Character.isLetter(c)).count();
// 如果 60% 以上是数字和标点,可能是表格或乱码
if (digitAndPunctuationChars > totalChars * 0.6) {
return false;
}
return true;
}
}
四、分块策略:知识库质量的关键
4.1 三种分块策略对比
+--------------------+------------------+-----------+---------------------------------+
| 分块策略 | 实现方式 | 优点 | 缺点及适用场景 |
+--------------------+------------------+-----------+---------------------------------+
| 固定 Token 分块 | TokenTextSplitter| 简单稳定 | 可能切断句子;适合通用场景 |
| 语义段落分块 | 按段落/标题分割 | 保留完整 | 块大小不均;适合结构化文档 |
| 重叠滑动窗口 | 含 overlap 分块 | 上下文连续| 数据冗余;适合需要跨块理解的场景 |
+--------------------+------------------+-----------+---------------------------------+
4.2 TokenTextSplitter 详细配置
java
@Component
public class SmartTextSplitter {
/**
* 面向技术文档的分块策略
* 块大小 512,重叠 100,最小块 50
*/
public TokenTextSplitter technicalDocSplitter() {
return new TokenTextSplitter(
512, // chunkSize:每块最大 Token 数
100, // chunkOverlap:相邻块重叠的 Token 数
50, // minChunkSize:最小块大小(避免产生碎片)
10000, // maxChunkSize:单块上限(防止超长段落)
true // keepSeparator:在分割符处保留换行
);
}
/**
* 面向 FAQ 的分块策略(更小的块)
* FAQ 通常是一问一答,保持完整即可
*/
public TokenTextSplitter faqSplitter() {
return new TokenTextSplitter(256, 50, 30, 5000, false);
}
/**
* 面向长篇报告的分块策略(更大的块)
*/
public TokenTextSplitter reportSplitter() {
return new TokenTextSplitter(1024, 200, 100, 20000, true);
}
}
4.3 语义感知分块(按章节标题)
java
/**
* 基于 Markdown 标题层级的语义分块
* 将文档按 # ## ### 层级划分为知识单元
*/
@Component
public class MarkdownSemanticSplitter {
public List<Document> splitByHeadings(String markdownContent,
Map<String, Object> baseMetadata) {
List<Document> chunks = new ArrayList<>();
String[] lines = markdownContent.split("\n");
StringBuilder currentChunk = new StringBuilder();
String currentHeading = "";
int headingLevel = 0;
for (String line : lines) {
if (line.startsWith("#")) {
// 遇到新标题:保存当前块
if (currentChunk.length() > 0) {
Document doc = new Document(currentChunk.toString().trim());
Map<String, Object> meta = new HashMap<>(baseMetadata);
meta.put("heading", currentHeading);
meta.put("headingLevel", headingLevel);
doc.getMetadata().putAll(meta);
chunks.add(doc);
currentChunk = new StringBuilder();
}
// 提取标题信息
headingLevel = countLeadingChars(line, '#');
currentHeading = line.replaceAll("^#+\\s*", "").trim();
currentChunk.append(line).append("\n");
} else {
currentChunk.append(line).append("\n");
}
}
// 保存最后一块
if (currentChunk.length() > 0) {
Document doc = new Document(currentChunk.toString().trim());
Map<String, Object> meta = new HashMap<>(baseMetadata);
meta.put("heading", currentHeading);
doc.getMetadata().putAll(meta);
chunks.add(doc);
}
return chunks;
}
private int countLeadingChars(String s, char c) {
int count = 0;
for (char ch : s.toCharArray()) {
if (ch == c) count++;
else break;
}
return count;
}
}
五、元数据增强:让每个知识块"自我介绍"
丰富的元数据是精准检索的基础。除了基础的文件名和页码,可以自动提取更多有价值的元数据:
java
@Component
@RequiredArgsConstructor
public class MetadataEnricher {
private final ChatClient chatClient;
/**
* AI 自动提取元数据
* 使用 LLM 从文本内容中抽取关键词、摘要等
*/
public Map<String, Object> enrichWithAI(String content) {
String prompt = String.format("""
请分析以下文本,以 JSON 格式输出:
{
"keywords": ["关键词1", "关键词2", "关键词3"],
"summary": "一句话摘要(30字以内)",
"topic": "主题分类(技术/产品/运营/财务/法务 等)",
"importance": "重要程度(高/中/低)"
}
文本内容:
%s
只输出 JSON,不要其他内容。
""", content.substring(0, Math.min(content.length(), 500)));
String jsonResult = chatClient.prompt(prompt)
.options(DashScopeChatOptions.builder()
.withModel("qwen-turbo") // 元数据提取用轻量模型节省成本
.withTemperature(0.0)
.build())
.call()
.content();
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(
jsonResult.replaceAll("```json|```", "").trim(),
new TypeReference<>() {});
} catch (Exception e) {
log.warn("元数据提取失败,使用默认值");
return Map.of("topic", "general", "importance", "中");
}
}
/**
* 规则提取元数据(无需调用 AI,成本为零)
*/
public Map<String, Object> enrichWithRules(String content, String fileName) {
Map<String, Object> metadata = new HashMap<>();
// 文件类型
metadata.put("fileType", FilenameUtils.getExtension(fileName));
// 语言检测
long chineseCount = content.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF).count();
metadata.put("language", chineseCount > content.length() * 0.3 ? "zh" : "en");
// 内容长度分级
int length = content.length();
metadata.put("contentLength", length < 200 ? "short"
: length < 1000 ? "medium" : "long");
// 是否包含代码
metadata.put("hasCode", content.contains("```") || content.contains(" "));
// 时间戳
metadata.put("ingestTime", System.currentTimeMillis());
metadata.put("ingestDate",
LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE));
return metadata;
}
}
六、增量更新机制
6.1 基于 MD5 的去重
java
@Service
@RequiredArgsConstructor
@Slf4j
public class IncrementalIngestionService {
private final VectorStore vectorStore;
private final ContentCleaner contentCleaner;
private final MetadataEnricher metadataEnricher;
// 文件 hash 缓存(实际项目存 Redis 或数据库)
private final Map<String, String> fileHashCache = new ConcurrentHashMap<>();
/**
* 增量入库:只处理内容有变化的文件
*/
public IngestionResult ingestIncremental(Resource resource,
Map<String, Object> extraMetadata) {
String fileName = resource.getFilename();
try {
// 计算文件 hash
byte[] fileBytes = resource.getInputStream().readAllBytes();
String currentHash = DigestUtils.md5DigestAsHex(fileBytes);
String cachedHash = fileHashCache.get(fileName);
if (currentHash.equals(cachedHash)) {
log.info("[{}] 文件未变化,跳过入库", fileName);
return IngestionResult.skipped(fileName);
}
// hash 有变化:删除旧版本
if (cachedHash != null) {
log.info("[{}] 检测到文件更新,删除旧版本", fileName);
deleteDocumentsBySource(fileName);
}
// 重新入库
List<Document> documents = loadAndProcess(resource, extraMetadata);
vectorStore.add(documents);
// 更新 hash 缓存
fileHashCache.put(fileName, currentHash);
log.info("[{}] 入库成功,共 {} 个文本块", fileName, documents.size());
return IngestionResult.success(fileName, documents.size());
} catch (Exception e) {
log.error("[{}] 入库失败:{}", fileName, e.getMessage(), e);
return IngestionResult.failed(fileName, e.getMessage());
}
}
private void deleteDocumentsBySource(String source) {
// 通过元数据过滤删除旧文档(具体语法依 VectorStore 实现而定)
// 注意:部分 VectorStore 不支持按条件删除,需要记录 ID 批量删除
log.info("删除来源为 [{}] 的所有旧文档", source);
}
/**
* 结果对象
*/
@Value
public static class IngestionResult {
String fileName;
Status status;
int chunksCount;
String errorMessage;
public enum Status { SUCCESS, SKIPPED, FAILED }
public static IngestionResult success(String fn, int count) {
return new IngestionResult(fn, Status.SUCCESS, count, null);
}
public static IngestionResult skipped(String fn) {
return new IngestionResult(fn, Status.SKIPPED, 0, null);
}
public static IngestionResult failed(String fn, String error) {
return new IngestionResult(fn, Status.FAILED, 0, error);
}
}
}
七、异步向量化管道(Spring Batch 集成)
当需要批量处理大量文档时(如历史存量数据迁移),同步处理会阻塞太久。Spring Batch 提供了成熟的批处理能力:
java
@Configuration
@EnableBatchProcessing
@RequiredArgsConstructor
public class VectorIngestionBatchConfig {
private final JobBuilderFactory jobBuilderFactory;
private final StepBuilderFactory stepBuilderFactory;
private final VectorStore vectorStore;
/**
* 定义批量向量化 Job
*/
@Bean
public Job vectorIngestionJob(Step ingestionStep) {
return jobBuilderFactory.get("vectorIngestionJob")
.start(ingestionStep)
.build();
}
/**
* 定义处理步骤:读取 → 处理 → 写入向量库
*/
@Bean
public Step ingestionStep() {
return stepBuilderFactory.get("ingestionStep")
.<Resource, List<Document>>chunk(10) // 每批处理 10 个文件
.reader(documentResourceReader())
.processor(documentProcessor())
.writer(vectorStoreWriter())
.faultTolerant()
.skipLimit(10) // 允许最多 10 个文件失败
.skip(Exception.class)
.build();
}
@Bean
public ItemWriter<List<Document>> vectorStoreWriter() {
return items -> {
List<Document> allDocs = items.stream()
.flatMap(Collection::stream)
.toList();
log.info("批量写入 {} 个文档到向量存储", allDocs.size());
vectorStore.add(allDocs);
};
}
}
八、完整流水线总结
文档入库完整流程:
输入文件(PDF/Markdown/HTML/Word)
|
v
[文档加载器]
PagePdfDocumentReader / MarkdownDocumentReader
ParagraphPdfDocumentReader / TikaDocumentReader
|
v
[内容清洗]
去除页眉页脚 / 修复 PDF 乱码 / 过滤无效噪声块
|
v
[智能分块]
TokenTextSplitter(固定 Token)
语义分块(按标题)
重叠滑动窗口
|
v
[元数据增强]
AI 提取关键词/摘要/分类
规则提取文件类型/语言/时间戳
文件 hash(用于增量更新去重)
|
v
[向量化]
EmbeddingModel(text-embedding-v3)
批量 Embedding 接口(减少 API 调用次数)
|
v
[写入 VectorStore]
Milvus / Elasticsearch / Redis Vector
|
v
完成,可用于 RAG 检索
九、总结
文档处理质量是 RAG 系统成败的关键,本文覆盖了:
- 多格式加载:PDF(按页/按段落)、Markdown、HTML、Tika 通用格式;
- 内容清洗:修复 PDF 连字符换行、过滤页眉页脚、验证块有效性;
- 分块策略:固定 Token(通用)、语义段落(结构化文档)、滑动窗口(需要上下文连续);
- 元数据增强:AI 提取 + 规则提取双管齐下;
- 增量更新:MD5 哈希去重,内容不变的文件跳过入库;
- Spring Batch:存量数据批量向量化,可容错可监控。
下一篇进入 Function Calling 领域:让 AI 能调用你的 Java 方法、查数据库、访问第三方 API,这是构建智能 Agent 的基础。
参考资料