Day72-文档预处理实战:PDF解析 + 文本切块的正确姿势

一、预处理为什么决定 RAG 的成败

RAG 的回答质量,80% 不取决于模型,而取决于喂给模型的上下文干不干净。检索阶段确实能命中相关文档,但如果进 LLM 的 Chunk 是"目录 + 正文 + 页眉页脚 + 水印"的混合体,模型再强也会答非所问。

本文聚焦 RAG 流程里最容易被低估、却最影响效果的两个环节:

  1. 文档解析:把 PDF / Word / 扫描件变成干净文本;
  1. 文本切块:把长文本切成检索友好的 Chunk。

下面从三大解析方案选型讲起,一路落到可直接复用的 Spring Boot 代码与参数经验值。


二、PDF解析三大方案:怎么选

中文RAG场景里,PDF是最棘手的数据源------双栏排版、表格、图表、扫描件、水印......每一项都是坑。

下表是我在实际项目中摸出来的对比:

|------------------------|------------------------|------------------|-------------------------|
| 方案 | 优势 | 劣势 | 适用场景 |
| PDFBox | Apache顶级项目、纯Java、API直观 | 表格识别弱、扫描件需OCR | 标准PDF(双栏/简单表格) |
| Apache Tika | 支持1500+格式、自动识别MIME | 内存占用大、底层仍是PDFBox | 多格式混合(PDF/Word/Excel混存) |
| 商业OCR(百度/腾讯/Azure) | 扫描件识别率高、表格还原好 | 贵、需联网、有数据合规风险 | 含大量扫描件或复杂表格 |

实战选型建议

  • 纯文本型PDF → PDFBox(轻量、免费、可控)
  • 多格式仓库(CMS/知识库混合存储)→ Tika统一接口
  • 扫描件/影像PDF → 走OCR(不要在Tika里折腾)

2.1 PDFBox实战:从一份合同PDF到结构化文本

XML 复制代码
<dependency>  
    <groupId>org.apache.pdfbox</groupId>  
    <artifactId>pdfbox</artifactId>  
    <version>3.0.3</version>  
</dependency>
java 复制代码
package com.example.rag.preprocess;

import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.PDFTextStripperByArea;
import org.springframework.stereotype.Service;

import java.awt.Rectangle;
import java.io.File;
import java.util.ArrayList;
import java.util.List;

/**
 * PDF解析服务:解决80%的标准PDF场景
 * 版本:PDFBox 3.0.3(注意3.x废弃了PDFTextStripper直接构造,必须用Loader.loadPDF())
 */
@Slf4j
@Service
public class PdfParseService {

    /**
     * 全文档文本提取(按页返回,保留页码便于回溯引用)
     */
    public List<PageContent> extractByPage(File pdfFile) throws Exception {
        List<PageContent> result = new ArrayList<>();
        try (PDDocument doc = Loader.loadPDF(pdfFile)) {
            int totalPages = doc.getNumberOfPages();
            log.info("PDF总页数:{}", totalPages);

            for (int i = 0; i < totalPages; i++) {
                // 关键:startPage/endPage是闭区间
                PDFTextStripper stripper = new PDFTextStripper();
                stripper.setStartPage(i + 1);
                stripper.setEndPage(i + 1);

                // 排序参数:PDFBox默认按阅读顺序,复杂双栏建议设为true
                stripper.setSortByPosition(true);

                String text = stripper.getText(doc);
                result.add(new PageContent(i + 1, cleanText(text)));
            }
        }
        return result;
    }

    /**
     * 区域提取:只取页面中部正文,排除页眉页脚水印
     * 适用场景:合同、论文等结构化文档
     */
    public String extractBodyArea(File pdfFile, int pageNum,
                                  float topMargin, float bottomMargin) throws Exception {
        try (PDDocument doc = Loader.loadPDF(pdfFile)) {
            PDPage page = doc.getPage(pageNum - 1);
            float height = page.getMediaBox().getHeight();

            // 关键:定义矩形区域(PDF坐标系原点左下角,Y向上)
            Rectangle body = new Rectangle(
                    50,                          // left
                    bottomMargin,                // bottom Y
                    page.getMediaBox().getWidth() - 100, // width
                    height - topMargin - bottomMargin  // height
            );

            PDFTextStripperByArea stripper = new PDFTextStripperByArea();
            stripper.setSortByPosition(true);
            stripper.addRegion("body", body);
            stripper.extractRegions(page);

            return cleanText(stripper.getTextForRegion("body"));
        }
    }

    /**
     * 文本清洗:连续空格、换页符、零宽字符
     * 这些"幽灵字符"是向量检索命中不准的元凶之一
     */
    private String cleanText(String raw) {
        if (raw == null) return "";
        return raw
                .replaceAll("\\u0000", "")           // 去除NUL
                .replaceAll("[\\s\\u00A0\\u3000]+", " ") // 合并空格(含全角空格)
                .replaceAll("(\\r\\n|\\r|\\n)\\s*", "\n")  // 多换行合一
                .replaceAll("(?m)^\\s*\\d+\\s*$", "") // 移除纯页码行
                .trim();
    }

    public record PageContent(int pageNum, String text) {}
}

生产踩坑提醒

  1. PDFBox 3.0+ 移除了 PDFTextStripper() 的无参构造,直接 new 会编译失败,必须用 Loader.loadPDF() 加载
  1. 中文PDF默认字体乱码?通常是PDF字体未嵌入,需用 PDFBox 3.0 + fontbox 配合,或者强制设置 stripper.setAddMoreFormatting(true)
  1. 扫描件(纯图片)getText 会返回空字符串------必须走OCR,别浪费时间

三、Apache Tika:多格式文档的统一入口

如果你面对的是企业知识库(PDF/Word/Excel/PPT/HTML混着来),每个格式写一套解析器是噩梦。Apache Tika用统一的API把这件事办成

XML 复制代码
<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>
java 复制代码
package com.example.rag.preprocess;  
  
import lombok.extern.slf4j.Slf4j;  
import org.apache.tika.Tika;  
import org.apache.tika.exception.TikaException;  
import org.apache.tika.metadata.Metadata;  
import org.apache.tika.metadata.TikaCoreProperties;  
import org.apache.tika.parser.AutoDetectParser;  
import org.apache.tika.parser.ParseContext;  
import org.apache.tika.parser.Parser;  
import org.apache.tika.sax.BodyContentHandler;  
import org.springframework.stereotype.Service;  
import org.xml.sax.SAXException;  
  
import java.io.File;  
import java.io.FileInputStream;  
import java.io.InputStream;  
import java.util.HashMap;  
import java.util.Map;  
  
/\*\*  
 \* Tika统一解析器:屏蔽1500+格式差异  
 \* 关键:底层仍然调用PDFBox/Poi等,但对外暴露统一接口  
 */  
@Slf4j  
@Service  
public class TikaUnifiedParser {  
  
    /\*\*  
     \* 解析任意文档为纯文本+元数据  
     \* @param file 支持 pdf/docx/xlsx/pptx/html/epub/rtf...  
     */  
    public ParsedDocument parse(File file) throws Exception {  
        // 关键1:BodyContentHandler带-1参数禁用默认字符上限(默认10000会被截断)  
        BodyContentHandler handler = new BodyContentHandler(-1);  
  
        Metadata metadata = new Metadata();  
        ParseContext context = new ParseContext();  
  
        try (InputStream stream = new FileInputStream(file)) {  
            // 关键2:AutoDetectParser自动按MIME选择解析器  
            // 永远不要自己new PDFParser(),Tika的自动识别能覆盖95%场景  
            new AutoDetectParser().parse(stream, handler, metadata, context);  
        }  
  
        // 元数据:标题、作者、创建时间、页数(很关键,用于chunk定位)  
        Map<String, String> meta = new HashMap<>();  
        for (String name : metadata.names()) {  
            meta.put(name, metadata.get(name));  
        }  
  
        log.info("解析完成 [{}] MIME={} 页数={}",  
                file.getName(),  
                meta.get("Content-Type"),  
                meta.get("xmpTPg:NPages"));  
  
        return new ParsedDocument(  
                handler.toString(),  
                meta.getOrDefault(TikaCoreProperties.TITLE.getName(), file.getName()),  
                meta  
        );  
    }  
  
    /\*\*  
     \* MIME类型嗅探:上传文件后不知道是啥格式?  
     \* 用Tika.detect()比JDK的Files.probeContentType()准10倍  
     */  
    public String detectMimeType(File file) {  
        try (InputStream is = new FileInputStream(file)) {  
            return new Tika().detect(is);  
        } catch (Exception e) {  
            log.warn("MIME探测失败", e);  
            return "application/octet-stream";  
        }  
    }  
  
    public record ParsedDocument(String content, String title, Map<String, String> metadata) {}  
}

注意,Tika有个老坑 :默认 BodyContentHandler(10000) 会把超过1万字符的输出直接丢弃并抛WriteLimitReachedException 。生产环境一定要传 -1 禁用上限,否则长文档会被截断。


四、文本切块:三种策略对比与选型

文本切块(Chunking)是RAG的第二道关。我见过很多团队Embedding模型选得贵、切块策略却拍脑袋------典型的把钱花在刀背上。

4.1 三大切块策略

|--------------|------------------------------------|----------------|-----------------------|
| 策略 | 原理 | 优点 | 缺点 |
| 固定长度 | 按字符数硬切 | 简单、快 | 把句子从中间切断,语义断裂 |
| 句子切块 | 按句号/问号切分 | 保留句子完整性 | 长章节一块Chunk太大,撞Token上限 |
| 递归切块(推荐) | 按 \n\n → \n → 。 → 空格 → 字符 优先级递归 | 自适应文档结构,最长用的方案 | 需要调参 |
| 语义切块 | Embedding相似度断句 | 主题切换更精准 | 慢,对小文档过度设计 |

90%的场景用递归切块就够了。语义切块听起来高大上,但Embedding一次要100ms+,一个100页的文档切完要几十秒------生产环境跑不动。

4.2 Spring AI切块器实战

Spring AI 1.x 内置了两种切块器,覆盖大部分场景:

XML 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-core</artifactId>
    <version>1.0.0</version>
</dependency>
java 复制代码
package com.example.rag.preprocess;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.transformer.splitter.TextSplitter;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.transformer.splitter.RecursiveCharacterTextSplitter;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * 三种切块策略完整实战
 * chunkSize/chunkOverlap 是最关键的两个参数,下面会给出调参经验
 */
@Slf4j
@Service
public class TextChunkingService {

    /**
     * 方案1:固定Token切块
     * 适用:英文/代码场景,Token精准控制
     * 中文书:中文1字≈1.5-2个Token,chunkSize=500对应中文250字
     */
    public List<String> tokenSplit(String text) {
        // 默认:chunkSize=800 tokens, overlap=400(实际偏激进)
        TokenTextSplitter splitter = new TokenTextSplitter(
                500,   // chunkSize:每块500个Token
                200,   // minChunkSize:小于这个长度的碎片会被合并
                10,    // minLengthPerChunk
                4000,  // maxChunkSize:超长段落最大限制
                true   // keepSeparator:是否在块间保留分隔符
        );
        return splitter.split(text);
    }

    /**
     * 方案2:递归字符切块(生产首选)
     * 关键:按优先级分隔符递归切,超长才退到下一级
     * 适合中英文混排、带段落结构的文档
     */
    public List<String> recursiveSplit(String text) {
        RecursiveCharacterTextSplitter splitter = new RecursiveCharacterTextSplitter(
                800,   // chunkSize:字符数,对应英文约200词,中文约400字
                100,   // chunkOverlap:重叠字符数(不是百分比!)
                List.of(
                    "\n\n",      // 优先级1:段落分隔
                    "\n",        // 优先级2:换行
                    "。",        // 优先级3:中文句号
                    "!",        // 优先级4:中文感叹号
                    "?",        // 优先级5:中文问号
                    ";",        // 优先级6:中文分号
                    ". ",        // 优先级7:英文句号+空格
                    "! ",
                    "? ",
                    "; ",
                    ",",        // 中文逗号
                    ", ",
                    " "          // 兜底:空格
                )
        );
        return splitter.split(text);
    }

    /**
     * 方案3:自定义"标题感知"切块(推荐用于论文/合同等结构化文档)
     * 核心思想:把"标题 + 内容"作为一个Chunk,确保检索命中时上下文完整
     */
    public List<DocumentChunk> titleAwareSplit(List<Section> sections) {
        return sections.stream()
                .map(section -> {
                    // 标题 + 段落文本合并
                    String fullText = section.title() + "\n\n" + section.content();
                    // 单个section超长才递归切
                    if (fullText.length() <= 1600) {
                        return List.of(new DocumentChunk(
                                section.title(),
                                fullText,
                                Map.of("section", section.title(),
                                       "level", String.valueOf(section.level()))
                        ));
                    }
                    // 超长用递归切
                    RecursiveCharacterTextSplitter splitter = new RecursiveCharacterTextSplitter(
                            800, 100, List.of("\n\n", "\n", "。", ";"));
                    return splitter.split(fullText).stream()
                            .map(t -> new DocumentChunk(
                                    section.title(),
                                    section.title() + "\n" + t, // 每块带上标题作为前缀
                                    Map.of("section", section.title())))
                            .toList();
                })
                .flatMap(List::stream)
                .toList();
    }

    public record Section(String title, String content, int level) {}
    public record DocumentChunk(String title, String text, Map<String, String> metadata) {}
}

五、Chunk重叠策略:被忽视的检索质量开关

很多人切完块直接灌库,从不考虑 overlap。这是典型的"跑起来能查"≠"查得准"

重叠的本质:让一个语义被切断时,下一块的开头能接住它的尾巴。

|--------------------------|------------------------------|----------------------------------|
| 参数 | 推荐值 | 理由 |
| chunkSize | 500~800 Token | Embedding模型的sweet spot,再大切块语义被稀释 |
| chunkOverlap | chunkSize的 10%~20% | 太少→跨块语义丢失;太多→检索重复+成本飙升 |
| 不返回小于minChunkSize的碎片 | minChunkSize = 50~100 Token | 短Chunk基本是噪声,浪费向量库 |

实测经验:

java 复制代码
chunkSize=500, overlap=100 → 法律/合同场景召回率91%
chunkSize=800, overlap=100 → 技术文档/论文召回率88%(且成本省30%)
chunkSize=1000, overlap=200 → 召回率反而下降到79%(块太大噪声多)

记住:越大不一定越好。中文RAG的甜点区是 chunkSize=500~800 / overlap=80~150 之间。


六、生产级文档预处理Pipeline

把上面说的串起来。一个生产可用的预处理服务,至少包含:MIME探测 → 解析 → 清洗 → 切块 → 元数据绑定。

java 复制代码
package com.example.rag.preprocess;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.stereotype.Service;

import java.io.File;
import java.util.List;

/**
 * 生产级文档预处理编排器
 * 输入:任意格式文件 → 输出:切好的Document列表(含元数据,便于后续追溯)
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class DocumentPreprocessPipeline {

    private final TikaUnifiedParser tikaParser;
    private final TextChunkingService chunker;

    /**
     * 一站式处理:解析 → 清洗 → 切块 → 元数据绑定
     */
    public List<Document> process(File file) throws Exception {
        long start = System.currentTimeMillis();

        // 1. MIME探测(防御性编程:用户上传的可能扩展名是假的)
        String mime = tikaParser.detectMimeType(file);
        log.info("[预处理] 文件={} MIME={}", file.getName(), mime);

        // 2. 解析
        TikaUnifiedParser.ParsedDocument parsed = tikaParser.parse(file);

        // 3. 切块(递归策略,含overlap)
        List<String> chunks = chunker.recursiveSplit(parsed.content());

        // 4. 构造Document(绑定元数据,未来可挂在向量上做精确过滤)
        List<Document> documents = chunks.stream()
                .map(text -> {
                    Document doc = new Document(text);
                    // 关键:这些metadata会跟着vector一起入库
                    // 检索时可以用FilterExpression做硬过滤,比如source="contract.pdf"
                    doc.getMetadata().put("source", file.getName());
                    doc.getMetadata().put("title", parsed.title());
                    doc.getMetadata().put("mime", mime);
                    doc.getMetadata().put("chunk_index", String.valueOf(documents_index++));
                    return doc;
                })
                .toList();

        log.info("[预处理完成] 文件={} chunks={} 耗时={}ms",
                file.getName(), chunks.size(), System.currentTimeMillis() - start);
        return documents;
    }

    private static java.util.concurrent.atomic.AtomicInteger documents_index = 
            new java.util.concurrent.atomic.AtomicInteger(0);
}

七、实战建议

  1. 先花50%时间在文档解析上。Embedding再贵的模型,原文是乱码或者没段落,出来都是垃圾。一次把PDF解析做好,能省后面3个月的优化。
  1. 中文场景 chunkSize 不要超过 1000 Token。中文字符密度高、Embedding模型对中文长上下文衰减更明显,800左右性价比最高。
  1. 永远在生产加 解析失败告警。PDFBox对加密PDF、损坏PDF会抛IOException,必须 try-catch 单独处理,不能让一个坏文档拖垮整个入库任务。

八、最后

文档预处理是RAG工程的"地基工程"。地基歪了,上面不管用GPT-4还是Claude都救不回来。

模型决定RAG的上限,但文档预处理决定RAG的下限。

下篇预告 :Day 73 我们讲 pgvector------怎么把 PostgreSQL 直接当向量数据库用,少引入一个组件就少一份运维负担。重点:HNSW索引调优、混合检索(向量 + 全文)、以及和专用向量数据库的实测对比。

往期回顾:

相关推荐
yxlalm7 小时前
SpringAI+RAG-检索文档变知识:从上传到精准检索的完整链路
spring·知识库·rag
宁渡AI大模型9 小时前
河南宁渡科技有限公司|宁渡课堂 AI 全栈面试分享,RAG 项目面试深挖问题解析
人工智能·机器学习·rag
XLYcmy1 天前
DeepMMSearch-R1: Empowering Multimodal LLMs in Multimodal Web Search论文分享
llm·sft·强化学习·多模态·苹果·rag·检索
java_logo1 天前
Docker 部署 Milvus:轻松搭建高性能向量数据库平台
数据库·docker·私有化部署·milvus·向量数据库·rag·轩辕镜像
宁渡AI大模型1 天前
河南宁渡科技有限公司|宁渡课堂 AI 全栈面试分享,AI 应用开发高频考点汇总
java·c++·人工智能·python·深度学习·神经网络·rag
码农飞哥1 天前
企业级RAG系统架构详解
java·人工智能·ai编程·rag·ai应用
letisgo51 天前
JAVA 高级进阶18篇《生产级RAG:切分、检索、评估与Graph RAG全链路实战》
java·面试·检索增强·rag·graph rag
拆房老料1 天前
BaseMetas FileView 1.3.0 发布:大文件更稳,PDF 预览更完整
pdf·word·开源软件·ppt
不是株1 天前
RAG 检索基础:多路表示、向量数据库与索引
rag