Skip to content

文档解析:PDF 是最难啃的那块骨头 ​

1. 本节产出 ​

一个支持 PDF / Word / Markdown / HTML 的解析器:保留标题层级、识别表格、丢弃页眉页脚,并且解析失败时有明确错误而不是静默返回空文本。

2. 前置依赖 ​

3. 为什么解析质量决定 RAG 上限 ​

一个真实的量化结果:同一批文档,只换解析器,评测集召回率@k 从 61% 到 84%。没动模型、没动向量库、没调参数。

原因很直白:解析输出是后面所有步骤的唯一输入。

解析阶段的问题下游表现
表格被读成一串乱码数字检索到这个片段,模型也读不懂,答错
标题层级丢失切分无法按结构切,语义被切断
页眉页脚没去掉每个片段都带「第 3 页 共 20 页」,污染向量
双栏 PDF 按行读左右两栏交错,句子被打散
扫描件当文本读得到空字符串,静默丢文档

第五种最危险:不报错、不告警,文档"灌进去了"但内容是空的。用户问到相关内容时系统答「资料中没有」——你会以为是检索问题,其实是解析问题。

4. 核心原理 ​

4.1 四种格式的难度分级 ​

格式难度关键问题推荐方案
Markdown★几乎无直接读,保留标题符号
HTML★★导航/广告/脚本噪声Jsoup 抽取正文,去掉 nav/script/style
Word(docx)★★★样式与结构分离Apache POI,按段落样式判标题层级
PDF★★★★★只有字符坐标,没有结构PDFBox + 启发式规则;扫描件必须 OCR

为什么 PDF 最难:PDF 的本质是「把字符画在坐标上」。它记录的是「在 (100, 720) 这个位置画一个 'A'」,而不是「这是一个二级标题」。所有结构都要靠启发式反推——这就是解析器的价值所在。

4.2 PDF 结构还原的三条启发式 ​

1. 标题识别
   字号 > 正文字号 × 1.15  → 可能是标题
   加粗且独占一行          → 加强判断
   匹配 "第X章" / "1.2"    → 确认层级

2. 分栏处理
   统计每行的 x 坐标分布
   出现两个明显聚集 → 双栏
   按 x 中位数切成左右两栏,分别按 y 排序后拼接

3. 页眉页脚
   出现在每页相同 y 区间的文本 → 页眉/页脚
   出现次数 ≥ 总页数 × 0.8 → 判定为噪声,丢弃

第三条是性价比最高的一条:页眉页脚污染每个片段的向量,去掉它们对召回率的提升往往超过调参。

4.3 扫描件:唯一的硬门槛 ​

判断方法:用 PDFBox 抽取文本,如果每页字符数 < 50,基本可判定是扫描件。

情况处理
少量扫描件接 OCR(PaddleOCR / 云厂商 OCR)
大量扫描件这是独立的工程项目,先评估投入产出比
混合抽文本 → 判定 → 扫描页走 OCR → 合并

提示:不要承诺「所有 PDF 都能灌」。扫描件 OCR 的准确率、表格还原、版面复杂度都是硬骨头,遇到大量扫描件的项目,先做样本评估再报价。

5. 代码走查 ​

5.1 依赖 ​

xml
<!-- pom.xml -->
<dependency>
  <groupId>org.apache.pdfbox</groupId>
  <artifactId>pdfbox</artifactId>
</dependency>
<dependency>
  <groupId>org.apache.poi</groupId>
  <artifactId>poi-ooxml</artifactId>
</dependency>
<dependency>
  <groupId>org.jsoup</groupId>
  <artifactId>jsoup</artifactId>
</dependency>

5.2 统一接口 ​

java
// src/main/java/com/example/rag/ingest/parsers/DocumentParser.java
public interface DocumentParser {

    boolean supports(String filename);

    /** 抛出 ParseException 而不是返回空,避免静默失败 */
    ParsedDocument parse(InputStream in, String filename);
}

// 解析结果:正文 + 结构信息
public record ParsedDocument(
        String text,
        List<Section> sections,      // 标题层级,切分要用
        Map<String, Object> attrs    // 页数、作者、表格数等
) {
    public record Section(int level, String title, int startOffset, int endOffset) {}
}

关键设计:返回 Section 列表。切分章节(02-04)需要标题层级来做「结构感知切分」,如果解析阶段丢了,后面只能靠正则猜。

5.3 PDF 解析器骨架 ​

java
// src/main/java/com/example/rag/ingest/parsers/PdfParser.java
@Component
public class PdfParser implements DocumentParser {

    @Override
    public boolean supports(String filename) {
        return filename.toLowerCase().endsWith(".pdf");
    }

    @Override
    public ParsedDocument parse(InputStream in, String filename) {
        try (PDDocument doc = Loader.loadPDF(in.readAllBytes())) {
            int pages = doc.getNumberOfPages();

            // 1. 扫描件检测:先看平均字符数
            if (isScanned(doc)) {
                throw new ParseException("疑似扫描件,需要 OCR:" + filename);
            }

            PDFTextStripper stripper = new PDFTextStripper();
            String raw = stripper.getText(doc);

            // 2. 去页眉页脚:统计跨页重复行
            String cleaned = removeHeaderFooter(raw, pages);

            // 3. 还原标题层级(简化版:按行首的数字编号与字号推断)
            List<Section> sections = detectSections(doc);

            return new ParsedDocument(cleaned, sections,
                    Map.of("pages", pages, "source", filename));
        } catch (IOException e) {
            throw new ParseException("PDF 解析失败:" + filename, e);
        }
    }

    private boolean isScanned(PDDocument doc) throws IOException {
        int sample = Math.min(3, doc.getNumberOfPages());
        int total = 0;
        PDFTextStripper s = new PDFTextStripper();
        for (int i = 1; i <= sample; i++) {
            s.setStartPage(i); s.setEndPage(i);
            total += s.getText(doc).length();
        }
        return (total / (double) sample) < 50;
    }

    private String removeHeaderFooter(String text, int pages) {
        // 统计每行出现次数,跨页高频行判定为页眉页脚
        Map<String, Integer> freq = new HashMap<>();
        for (String line : text.split("\n")) {
            String t = line.trim();
            if (!t.isEmpty()) freq.merge(t, 1, Integer::sum);
        }
        int threshold = (int) Math.ceil(pages * 0.8);
        return Arrays.stream(text.split("\n"))
                .filter(line -> freq.getOrDefault(line.trim(), 0) < threshold)
                .collect(Collectors.joining("\n"));
    }
}

5.4 HTML 解析器 ​

java
// src/main/java/com/example/rag/ingest/parsers/HtmlParser.java
@Component
public class HtmlParser implements DocumentParser {

    @Override
    public ParsedDocument parse(InputStream in, String filename) {
        org.jsoup.nodes.Document doc = Jsoup.parse(in, "UTF-8", "");

        // 去掉噪声节点——这一步对网页正文抽取至关重要
        doc.select("script,style,nav,footer,header,aside,.ad,.comment").remove();

        // 选一个最可能的正文容器
        Element main = doc.selectFirst("main,article,#content,.content");
        Element body = main != null ? main : doc.body();

        // 按 h1-h6 还原层级
        List<Section> sections = body.select("h1,h2,h3,h4,h5,h6").stream()
                .map(h -> new Section(level(h.tagName()), h.text(),
                        offsetOf(h), offsetOf(h) + h.text().length()))
                .toList();

        return new ParsedDocument(body.text(), sections, Map.of("source", filename));
    }

    private static int level(String tag) {
        return Integer.parseInt(tag.substring(1));    // h2 → 2
    }
}

5.5 解析编排与失败处理 ​

java
// src/main/java/com/example/rag/ingest/ParserRegistry.java
@Service
public class ParserRegistry {

    private final List<DocumentParser> parsers;

    public ParsedDocument parse(Path file) {
        String name = file.getFileName().toString();
        DocumentParser p = parsers.stream()
                .filter(it -> it.supports(name))
                .findFirst()
                .orElseThrow(() -> new ParseException("不支持的格式:" + name));

        ParsedDocument doc = p.parse(Files.newInputStream(file), name);

        // 关键防御:空文本必须报错,不能静默通过
        if (doc.text().isBlank()) {
            throw new ParseException("解析结果为空,疑似解析失败:" + name);
        }
        return doc;
    }
}

6. 跑起来 ​

bash
git checkout ch02-03-document-parsing
mvn -q test -Dtest=PdfParserTest

准备三类测试文档各一份(放在 src/test/resources/docs/):普通文本 PDF、带表格的 PDF、扫描件 PDF。

检查项通过标准
普通 PDF文本完整,标题层级正确(至少识别出一级标题)
页眉页脚输出中不含「第 X 页」等重复内容
带表格 PDF表格内容至少是「可读的行列文本」,不是纯乱码
扫描件抛 ParseException,错误信息指明需要 OCR
HTML导航、广告、脚本内容已被剔除

扫描件必须抛异常而不是返回空——这是本节最重要的验收点。

7. 生产避坑 ​

  1. 解析失败必须抛异常,绝不能返回空文本静默通过。返回空会导致文档「看起来灌进去了」,用户问不到内容时你完全无从排查。做法是:解析后强制校验文本长度,为空直接失败并把文件挪到失败目录待人工处理。
  2. 页眉页脚不去会污染所有向量。每个片段都带着「XX公司机密 第3页」,既占了 Token,又让向量偏向这些无意义词。这条规则的收益极高且实现简单,务必做。
  3. 不要用「字符数」判断解析成功。一份 200 页的 PDF 可能抽出 5000 个字符但全是乱码。做法是抽样检查:随机取三个片段人工看一眼,这是最省事也最有效的验收方式。

8. 延伸与锚点 ​

  • 思考题:表格被解析成一串数字后,模型基本读不懂。有什么办法保住表格语义?(提示:把表格转成 Markdown 表格或「字段名: 值」的键值对——答案见 02-04 结构感知切分)
  • 代码锚点:git checkout ch02-03-document-parsing
  • 下一课时:02-04 切分策略
  • 对应课件:L02-03 文档解析