【Spring AI 实战】六、RAG文档 ETL 实战:PDF/Word/Markdown 解析与文本分割

【Spring AI 实战】六、文档 ETL 实战:PDF/Word/Markdown 解析与文本分割

大家好,我是冰点,今天我们继续聊SpringAI的基本用法和特性

建议先阅读第五篇《RAG 核心原理》,了解文档 ETL 在 RAG 流程中的定位。

第二阶段·RAG 检索增强

文章目录

  • [【Spring AI 实战】六、文档 ETL 实战:PDF/Word/Markdown 解析与文本分割](#【Spring AI 实战】六、文档 ETL 实战:PDF/Word/Markdown 解析与文本分割)
    • [一、文档 ETL 在 RAG 中的角色](#一、文档 ETL 在 RAG 中的角色)
    • [二、Spring AI 文档读取体系](#二、Spring AI 文档读取体系)
    • [三、PDF 解析:PdfReader 详解](#三、PDF 解析:PdfReader 详解)
      • [3.1 依赖](#3.1 依赖)
      • [3.2 基本用法](#3.2 基本用法)
      • [3.3 带分页和元数据的读取](#3.3 带分页和元数据的读取)
      • [3.4 提取元数据](#3.4 提取元数据)
    • [四、Word 文档解析:WordDocumentReader](#四、Word 文档解析:WordDocumentReader)
      • [4.1 依赖](#4.1 依赖)
      • [4.2 基本用法](#4.2 基本用法)
      • [4.3 读取表格内容](#4.3 读取表格内容)
    • [五、Markdown 解析:MarkdownReader](#五、Markdown 解析:MarkdownReader)
    • [六、HTML 网页解析](#六、HTML 网页解析)
    • [七、JSON 文档解析](#七、JSON 文档解析)
    • 八、批量文档读取工厂
    • [九、文本分割:TextSplitter 核心原理](#九、文本分割:TextSplitter 核心原理)
      • [9.1 为什么不能直接喂整篇文档?](#9.1 为什么不能直接喂整篇文档?)
      • [9.2 Spring AI 内置分割器](#9.2 Spring AI 内置分割器)
      • [9.3 语义感知分割:RecursiveCharacterTextSplitter](#9.3 语义感知分割:RecursiveCharacterTextSplitter)
      • [9.4 Markdown 语义分割:保留标题层级](#9.4 Markdown 语义分割:保留标题层级)
    • [十、自定义分割器:SentenceTransformers 语义分割](#十、自定义分割器:SentenceTransformers 语义分割)
    • 十一、分割策略实战参数建议
    • [十二、完整 ETL 流程示例](#十二、完整 ETL 流程示例)
    • 十三、本章小结

一、文档 ETL 在 RAG 中的角色

一个完整的 RAG 问答系统,数据处理流程如下:

复制代码
原始文档(PDF/Word/HTML/TXT)
    ↓
  读取(Read)    ← ETL 的 E(Extract)
    ↓
  解析(Parse)   ← ETL 的 T(Transform)
    ↓
  分割(Split)   ← ETL 的 L(Load 前置步骤)
    ↓
  向量化(Embed) → 存入 VectorStore

ETL = Extract(抽取) + Transform(转换) + Load(加载),其中"Load"在 RAG 语境下就是向量化存储。本篇文章重点覆盖前三步。


二、Spring AI 文档读取体系

Spring AI 提供了一套统一的 DocumentReader 接口,所有文档源都实现这个接口:

java 复制代码
// 核心接口
public interface DocumentReader {
    List<Document> read();
}

// 常用实现
// -  PdfReader         PDF 文件
// -  WordDocumentReader Word/ DOCX 文件
// -  MarkdownReader    Markdown 文件
// -  TxtReader         纯文本文件
// -  HtmlDocumentReader HTML 网页
// -  JsonReader         JSON 文件
// -  ApachePoiDocumentReader Apache POI 读取 Word/Excel

三、PDF 解析:PdfReader 详解

3.1 依赖

xml 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-readers-spring-boot-starter</artifactId>
</dependency>
<!-- PDF 解析底层依赖二选一 -->
<dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>3.0.1</version>
</dependency>

3.2 基本用法

java 复制代码
@Bean
public DocumentReader pdfReader() {
    return new org.springframework.ai.reader.pdf.PdfReader(
        "classpath:/docs/product-manual.pdf"
    );
}

// 读取并打印
List<Document> docs = pdfReader().read();
docs.forEach(doc -> System.out.println(doc.getContent()));

3.3 带分页和元数据的读取

java 复制代码
@Bean
public DocumentReader pdfReaderWithMetadata() {
    PdfReader pdfReader = new PdfReader(
        PathUtils.getResourceURL("classpath:/docs/product-manual.pdf")
    );

    // PdfReader 支持自定义 PageExtractionStrategy
    PdfReaderConfig config = PdfReaderConfig.builder()
        .pageExtractionStrategy(
            // ALL_PAGES:所有页; ODD_PAGES:奇数页; EVEN_PAGES:偶数页
            PageExtractionStrategy.ODD_PAGES
        )
        // 每页自定义过滤(去掉页眉页脚)
        .filterPhraseMapper(phrase ->
            phrase.replace("CONFIDENTIAL", "")
                  .replace("Page {n} of {total}", "")
        )
        .build();

    return new org.springframework.ai.reader.pdf.PdfReader(
        PathUtils.getResourceURL("classpath:/docs/product-manual.pdf"),
        config
    );
}

3.4 提取元数据

PDF 的元数据(页码、章节标题、文件路径)会自动注入到 Document 的 metadata 中:

java 复制代码
List<Document> docs = pdfReader().read();

Document firstPage = docs.get(0);
// Spring AI 会自动填充以下元数据
firstPage.getMetadata().forEach((k, v) -> System.out.println(k + ": " + v));
// 输出示例:
// source: classpath:/docs/product-manual.pdf
// page-number: 1
// total-pages: 42

四、Word 文档解析:WordDocumentReader

4.1 依赖

xml 复制代码
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.2.5</version>
</dependency>

4.2 基本用法

java 复制代码
@Bean
public DocumentReader wordReader() {
    return new WordDocumentReader(
        PathUtils.getResourceFile("classpath:/docs/company-policy.docx"),
        WordDocumentReaderConfig.builder()
            .perPageConverter(false)  // false: 整篇文档为一个 Document;true: 每页一个
            .build()
    );
}

4.3 读取表格内容

Word 中的表格默认不自动提取,需要配合自定义处理器:

java 复制代码
public class TableAwareWordReader extends WordDocumentReader {

    public TableAwareWordReader(Resource resource) {
        super(resource, WordDocumentReaderConfig.builder().build());
    }

    @Override
    public List<Document> read() {
        List<Document> docs = super.read();
        // 手动解析 Word 中的表格并追加到文本
        // Apache POI 解析 XWPFTable 逻辑...
        return docs;
    }
}

五、Markdown 解析:MarkdownReader

Markdown 是最干净的格式,解析时能保留结构化语义。

java 复制代码
@Bean
public DocumentReader markdownReader() {
    return new MarkdownReader(
        PathUtils.getResourceFile("classpath:/docs/api-guide.md"),
        // 自定义元数据提取器:从 YAML Front Matter 读取
        new DefaultContentAggregator(
            MetadataMode.ALL,
            null  // frontMatterExtractor
        )
    );
}

Markdown 解析的额外优势

  • 标题层级自动映射到 Document metadata 的 headings 字段
  • 代码块自动识别并标记语言类型
  • 支持 YAML Front Matter 元数据提取

六、HTML 网页解析

java 复制代码
@Bean
public DocumentReader webPageReader() {
    return new HtmlDocumentReader(
        "https://docs.company.com/api-reference/auth",
        HtmlDocumentReaderConfig.builder()
            .charset(StandardCharsets.UTF_8)
            .xpathSelector("//div[@class='article-content']")  // 只提取正文区域
            .build()
    );
}

七、JSON 文档解析

适合处理结构化数据源,如日志、产品目录:

java 复制代码
@Bean
public DocumentReader jsonReader() {
    return new JsonReader(
        PathUtils.getResourceFile("classpath:/docs/products.json"),
        // 每条记录中的文本字段
        List.of("name", "description", "specs"),
        // 元数据字段(不参与 Embedding,但会附加到 Document)
        List.of("category", "price", "sku"),
        "product_id"  // 唯一 ID 字段
    );
}

八、批量文档读取工厂

生产环境中通常需要一次性读取整个目录的文档:

java 复制代码
@Component
public class DocumentLoaderFactory {

    @Value("${spring.ai.rag.document.path}")
    private String documentPath;

    public List<Document> loadAllDocuments() {
        List<Document> allDocs = new ArrayList<>();

        // 读取 PDF
        loadDirectory(documentPath + "/pdf", "*.pdf",
            () -> new PdfReader(PathUtils.getResourceFile("classpath:")));

        // 读取 Word
        loadDirectory(documentPath + "/word", "*.docx",
            () -> new WordDocumentReader(PathUtils.getResourceFile("classpath:")));

        // 读取 Markdown
        loadDirectory(documentPath + "/markdown", "*.md",
            () -> new MarkdownReader(PathUtils.getResourceFile("classpath:")));

        return allDocs;
    }

    private void loadDirectory(String path, String glob, Supplier<DocumentReader> readerFactory) {
        File dir = new File(path);
        if (!dir.exists()) return;

        File[] files = dir.listFiles((d, name) -> name.matches(glob.replace("*", ".*")));
        if (files == null) return;

        for (File file : files) {
            // 根据具体文件路径创建 Reader...
            DocumentReader reader = readerFactory.get();
            allDocs.addAll(reader.read());
        }
    }
}

九、文本分割:TextSplitter 核心原理

文档解析完成后,需要将长文本切分为适合检索的"块(Chunk)"。这是整个 ETL 流程中最关键的一步,切分策略直接影响检索质量。

9.1 为什么不能直接喂整篇文档?

问题 原因
上下文超限 LLM 有 Token 限制(如 GPT-3.5 的 16K),超长文档塞不进去
检索精度下降 整篇文档主题杂糅,相关性打分不准确
成本增加 每次检索都带入大量无关上下文,Token 费用翻倍
embedding 效果差 长文本 embedding 会被"平均化",失去局部焦点

9.2 Spring AI 内置分割器

java 复制代码
// 按字符数分割(最简单)
TextSplitter characterSplitter = new CharacterTextSplitter(
    500,    // 每块目标字符数
    50,     // 块间重叠字符数(避免割裂语义)
    true,   // 是否保留分隔符
    " "     // 分隔符
);

// 按 Token 数分割(更精确,基于 LLM tokenizer)
TextSplitter tokenSplitter = new TokenTextSplitter(
    300,    // 每块目标 Token 数
    50,     // 重叠 Token 数
    true,   // 是否保留分隔符
    true    // 是否追加块序号
);

9.3 语义感知分割:RecursiveCharacterTextSplitter

这是最推荐的生产级分割器,它按优先级尝试多种分隔符,逐层拆分:

复制代码
尝试按 "\n\n\n" (三级标题)分割
    ↓ 如果块太大
尝试按 "\n\n"  (段落)分割
    ↓ 如果块太大
尝试按 "\n"  (换行)分割
    ↓ 如果块太大
尝试按 " "  (句子)分割
    ↓ 如果块太大
按字符数硬截断(最后手段)
java 复制代码
@Bean
public DocumentReader semanticSplitterReader() {
    // 推荐配置:重叠 20%,保留段落边界
    return new RecursiveCharacterTextSplitterReader(
        pdfReader(),
        RecursiveCharacterTextSplitterReaderConfig.builder()
            .chunkSize(500)           // 每块目标字符数
            .chunkOverlap(100)        // 重叠 20%
            .separators(List.of("\n\n", "\n", "。", "!", "?", " "))
            .keepSeparator(true)
            .build()
    );
}

9.4 Markdown 语义分割:保留标题层级

对于 Markdown 文档,用 MarkdownHeaderTextSplitter 可以在标题处自然断点:

java 复制代码
List<Document> docs = markdownReader().read();

MarkdownHeaderTextSplitter splitter = new MarkdownHeaderTextSplitter(
    List.of(
        // 按 ## 二级标题分割
        new HeaderMetadata("#", 1),
        new HeaderMetadata("##", 2),
        new HeaderMetadata("###", 3)
    ),
    300,  // fallback chunk size
    50    // overlap
);

List<Document> chunks = splitter.split(docs);
// 每个 chunk 自动带上 headings metadata: ["Spring AI", "安装配置"]

十、自定义分割器:SentenceTransformers 语义分割

当对语义完整性要求极高时,可以用 Embedding 模型做语义感知分割------只在新主题出现时才断点:

java 复制代码
@Component
public class SemanticChunker {

    private final EmbeddingModel embeddingModel;
    private final double similarityThreshold = 0.7;

    public List<Document> semanticChunk(String text, List<String> sentences) {
        List<List<String>> chunks = new ArrayList<>();
        List<String> currentChunk = new ArrayList<>();

        for (String sentence : sentences) {
            if (currentChunk.isEmpty()) {
                currentChunk.add(sentence);
                continue;
            }

            // 计算当前句子与 Chunk 最后一句的语义相似度
            double similarity = computeSimilarity(
                currentChunk.get(currentChunk.size() - 1),
                sentence
            );

            if (similarity < similarityThreshold) {
                // 低于阈值,创建新 Chunk
                chunks.add(new ArrayList<>(currentChunk));
                currentChunk.clear();
            }
            currentChunk.add(sentence);
        }
        if (!currentChunk.isEmpty()) {
            chunks.add(currentChunk);
        }

        return chunks.stream()
            .map(c -> new Document(String.join("", c)))
            .collect(Collectors.toList());
    }

    private double computeSimilarity(String s1, String s2) {
        Embedding e1 = embeddingModel.embed(s1);
        Embedding e2 = embeddingModel.embed(s2);
        return cosineSimilarity(e1, e2);
    }

    private double cosineSimilarity(Embedding a, Embedding b) {
        double dot = 0, normA = 0, normB = 0;
        for (int i = 0; i < a.getValues().length; i++) {
            dot += a.getValues()[i] * b.getValues()[i];
            normA += a.getValues()[i] * a.getValues()[i];
            normB += b.getValues()[i] * b.getValues()[i];
        }
        return dot / (Math.sqrt(normA) * Math.sqrt(normB));
    }
}

十一、分割策略实战参数建议

文档类型 chunk_size chunk_overlap 推荐分割器
短文档(FAQ、条款) 300~500 50~100 CharacterTextSplitter
长文本文档(手册、论文) 500~800 100~200 RecursiveCharacterTextSplitter
结构化文档(Markdown) 400~600 80~120 MarkdownHeaderTextSplitter
代码库 200~400 50~100 CodeTextSplitter
对话记录 800~1500 200~300 TokenTextSplitter

十二、完整 ETL 流程示例

java 复制代码
@Service
public class DocumentETLService {

    private final VectorStore vectorStore;
    private final DocumentReader pdfReader;
    private final DocumentReader markdownReader;

    @PostConstruct
    public void etlAndIndex() {
        List<Document> allDocs = new ArrayList<>();

        // Step 1: Extract - 读取多源文档
        allDocs.addAll(pdfReader.read());
        allDocs.addAll(markdownReader.read());

        // Step 2: Transform - 语义分割
        RecursiveCharacterTextSplitter splitter =
            new RecursiveCharacterTextSplitter(600, 120);
        List<Document> chunks = splitter.split(allDocs);

        // 为每个 Chunk 添加来源元数据
        for (Document chunk : chunks) {
            chunk.getMetadata().put("indexed_at", LocalDateTime.now().toString());
        }

        // Step 3: Load - 存入向量数据库
        vectorStore.add(chunks);

        System.out.println("ETL 完成:处理文档 " + allDocs.size() + " 篇,生成 Chunk " + chunks.size() + " 个");
    }
}

十三、本章小结

知识点 核心要点
DocumentReader 体系 统一接口,Pdf/Word/Markdown/HTML/JSON 各有实现
元数据保留 页码、章节、来源路径等元数据对可溯源性至关重要
chunk_size 选择 500~800 是生产环境最常见的区间
chunk_overlap 重叠 15~25% 避免语义割裂
分割策略优先级 RecursiveCharacterTextSplitter > MarkdownHeaderTextSplitter > Character
语义分割 SentenceTransformers 方案适合高精度场景

下一篇预告:《七、Embedding 向量化与向量数据库选型对比》------ 深度对比 Milvus、Pinecone、Redis、Chroma、Elasticsearch 的适用场景与 Spring AI 集成方式。


📌 系列导航

📎 示例说明:本文侧重 ETL 管道与分割策略,若你准备做完整知识库系统,建议继续阅读第七、八篇。

相关推荐
道可云2 小时前
AI正在改变一切!园区举办AI智能体主题沙龙
人工智能
lulu12165440782 小时前
Claude Opus 4.7有哪些重大升级?软件工程和视觉能力全面解析
java·人工智能·python·软件工程·ai编程
笃℃2 小时前
【多模态大语言模型】Qwen-VL 系列解读,持续更新中。。。
人工智能·语言模型·自然语言处理
ar01232 小时前
AR航空装配:赋能航空航天制造的智能化转型
人工智能·ar
七夜zippoe2 小时前
OpenClaw 定时任务与 Cron 调度:自动化运维的智能引擎
运维·人工智能·自动化·cron·openclaw
动恰客流管家2 小时前
动恰3DV3丨客流统计系统:工业农业文旅类景区智慧客流解决方案
大数据·人工智能·3d
freewlt2 小时前
当地图拥有大脑:AI Agent如何重构下一代位置服务
人工智能·重构
工具箱大集合2 小时前
8 款主题班会 ppt 一键生成工具实测分享
大数据·人工智能
SWAGGY..2 小时前
【AI大模型NLP自然语言处理学习】(1)
人工智能·深度学习·lstm