Appearance
文档解析:PDF 是最难啃的那块骨头
1. 本节产出
一个支持 PDF / Word / Markdown / HTML 的解析器:保留标题层级、识别表格、丢弃页眉页脚,并且解析失败时有明确错误而不是静默返回空文本。
2. 前置依赖
- 02-01 RAG 全景
- 02-02 选型决策树
- Maven 能拉到 Apache PDFBox / Tika
3. 为什么解析质量决定 RAG 上限
一个真实的量化结果:同一批文档,只换解析器,评测集召回率@k 从 61% 到 84%。没动模型、没动向量库、没调参数。
原因很直白:解析输出是后面所有步骤的唯一输入。
| 解析阶段的问题 | 下游表现 |
|---|---|
| 表格被读成一串乱码数字 | 检索到这个片段,模型也读不懂,答错 |
| 标题层级丢失 | 切分无法按结构切,语义被切断 |
| 页眉页脚没去掉 | 每个片段都带「第 3 页 共 20 页」,污染向量 |
| 双栏 PDF 按行读 | 左右两栏交错,句子被打散 |
| 扫描件当文本读 | 得到空字符串,静默丢文档 |
第五种最危险:不报错、不告警,文档"灌进去了"但内容是空的。用户问到相关内容时系统答「资料中没有」——你会以为是检索问题,其实是解析问题。
4. 核心原理
4.1 四种格式的难度分级
| 格式 | 难度 | 关键问题 | 推荐方案 |
|---|---|---|---|
| Markdown | ★ | 几乎无 | 直接读,保留标题符号 |
| HTML | ★★ | 导航/广告/脚本噪声 | Jsoup 抽取正文,去掉 nav/script/style |
| Word(docx) | ★★★ | 样式与结构分离 | Apache POI,按段落样式判标题层级 |
| ★★★★★ | 只有字符坐标,没有结构 | 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. 生产避坑
- 解析失败必须抛异常,绝不能返回空文本静默通过。返回空会导致文档「看起来灌进去了」,用户问不到内容时你完全无从排查。做法是:解析后强制校验文本长度,为空直接失败并把文件挪到失败目录待人工处理。
- 页眉页脚不去会污染所有向量。每个片段都带着「XX公司机密 第3页」,既占了 Token,又让向量偏向这些无意义词。这条规则的收益极高且实现简单,务必做。
- 不要用「字符数」判断解析成功。一份 200 页的 PDF 可能抽出 5000 个字符但全是乱码。做法是抽样检查:随机取三个片段人工看一眼,这是最省事也最有效的验收方式。
8. 延伸与锚点
- 思考题:表格被解析成一串数字后,模型基本读不懂。有什么办法保住表格语义?(提示:把表格转成 Markdown 表格或「字段名: 值」的键值对——答案见 02-04 结构感知切分)
- 代码锚点:
git checkout ch02-03-document-parsing - 下一课时:02-04 切分策略
- 对应课件:L02-03 文档解析