一个"合同要素抽取"的 RAG 系统,从 PDF 里抽条款。上线后发现"付款条款"的抽取总是错位——因为"付款条款"在一张两列表格里,而文本切分器按"阅读顺序"输出,把"表格行"变成了"项目名 换行 内容 换行 项目名 换行 内容"——于是"语义被拉平"。
PDF 解析有三层难点:文字流(无结构)、版面(有结构但需推断)、以及语义(表格要按行列理解)。这个"层"的错位很典型:切分器假设"输入是线性文本",而 PDF 是"二维版面"——于是"它做的每一件事(按段落、按字数)都建立在错误的假设上"。
而"切分器"通常只处理第一层。
1. 三种"PDF 内容"的解析难度
| 类型 | 难点 |
|---|---|
| 电子版(有文字层) | 顺序与列错乱 |
| 扫描版(图片) | 需 OCR,错误率 |
| 混合(部分图片) | 两部分要拼 |
第一项"看起来简单":有文字就能提——而"提出来是一堆按坐标排的文字",顺序由"内容流"决定,不由"视觉"决定。这个"内容流"是"生成 PDF 时的绘制顺序":它可能是"先画所有标题,再画所有正文"——于是"提取出来的文字"与"眼睛看到的顺序"完全不同。
2. "表格被拉平"的三种表现
| 表现 | 后果 |
|---|---|
| 行列关系丢失 | "金额"与"项目"错配 |
| 单元格合并丢失 | "跨行"的内容只出现一次 |
| 跨页表格断裂 | "后半张表"成了"另一段" |
第一项的"后果"在抽取任务上很具体:"付款方式:电汇"会被拆成"两条事实",而模型可能把它们"分别引用到不同结论"里——于是"答案错得很自然"。
第一项最致命:"项目名 换行 内容"这种输出,"列"的信息完全没了——于是"付款方式:电汇"可能被读成"付款方式"与"电汇"两条独立事实。
"版面感知"的最小实现是"按坐标聚类",而它比"按换行"可靠:
// 伪代码:把"同一行的文字"按 y 坐标聚在一起
lines = {}
for ch in chars: # 每个字符带 (x, y)
key = round(ch.y / 2) # 2pt 容差
lines.setdefault(key, []).append(ch)
rows = [sorted(v, key=lambda c: c.x) for k, v in sorted(lines.items())]
// 收益:得到"视觉上的行",而不是"内容流里的行"
3. "保住表格"的三种做法
| 做法 | 效果 |
|---|---|
| 用版面感知的解析器(如按坐标聚类) | 好(能重建行列) |
| 先解析成 HTML/CSV 再切 | 最好(下游可结构化) |
| 用模型"看图"(多模态) | 灵活(成本高) |
第二项是"工程上最稳":**把每页转成"结构化块"(段落/表格/标题)**第二项的"代价"是"多一个中间层":要维护"块模型"与"块 → chunk"的映射——而它换来"切分不再依赖文字流"这件根本性改善。
,表格转成"行列结构"——于是"切分"发生在"结构之后",而不是"文字流上".
"表格 → chunk"的转换要"保语义",而它有个"最小模板":
// 一条"自解释"的表格 chunk(四行)
表名:付款条款(第 2 页)
表头:项目 | 内容
本行:账期 | 验收后 30 日
来源:合同 A-2024-11.pdf#page=2
// 判据:把这条 chunk 单独给一个"不知道上下文的人",他能读懂
4. "跨页表格"的三种处理
| 做法 | 说明 |
|---|---|
| 按"表头重复"拼接 | 依赖"表头在每页重复" |
| 按"列的坐标一致"拼接 | 更通用(需版面) |
| 不拼(标注"续表") | 最保守(但语义断裂) |
第二项的"前提"是"版式稳定":同一份合同"前几页与后几页"用同一模板——而这在"合并了多份文档的 PDF"里不成立,于是"跨文档"要"先分段"。
第二项是"最通用"的:同一张表的"列边界"在页间通常一致——于是"坐标对齐"能把"后半张"接回去.
"表格抽取"的验收要"对齐行数",否则"少几行"没人发现:
// 三步验收(每份文档)
1 行数:抽取出的行数 = PDF 里的行数(人工数一遍)
2 关键列:金额/账期等"必须有值",不能为 null
3 交叉校验:明细合计 = 表里的合计行
// 判据:三项都过,才算"表格抽取可用"
5. "切分粒度"与"表格"的冲突
| 粒度 | 问题 |
|---|---|
| 按字数(400 字) | 切断表格 |
| 按段落 | 表格是"一个块"(太大) |
| 按"行 + 表头" | 最好(每行都带表头) |
第三项是"RAG 里最实用"的:"每一行 + 表头"作为一条 chunk第三项的"收益"是"检索命中即自解释":用户搜"账期",命中的那条"自带表名与列名"——于是"模型不必再猜'这个 30 日是什么'"。
——于是"付款方式:电汇"是一条"自解释"的记录。
// 一个"表格行 chunk"的样子(自解释)
表:付款条款
| 项目 | 内容 |
| 付款方式 | 电汇 |
| 账期 | 验收后 30 日 |
// 切分结果:每行一条,且"带上表头与表名"
"扫描件"要"先判有没有文字层",否则"OCR 与提取"会互相打架:
// 三步判"是电子版还是扫描版"
1 用 pdftotext 抽一页:有内容 → 电子版
2 只有零星字符 → 混合(部分页是图)
3 完全空 → 扫描版(走 OCR)
// 判据:混合文档要"按页决定"用哪条路径
6. 排错:五种"PDF 表格问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 行列错配 | 文字流顺序 | 版面解析 |
| 金额对不上 | 合并单元格丢失 | 保留合并信息 |
| 后半张表丢失 | 跨页断裂 | 坐标拼接 |
| 抽取"少了条款" | 切分切断 | 按行 + 表头 |
| 扫描件乱码 | OCR 质量 | 提高清晰度/换引擎 |
7. 边界:什么情况"文字流"就够
| 场景 | 为什么 | 做法 |
|---|---|---|
| 纯文字报告(无表格) | 无行列语义 | 直接切 |
| 只做"全文检索"(不需精确抽取) | 错位影响可控 | 直接切 |
| 表格很小(2×2) | 拉平也能读懂 | 直接切 |
| 已有"结构化导出"(如系统导出的 Excel) | 不必从 PDF 取 | 换数据源 |
最后一条与"数据源"有关的边界:"能从系统拿结构化数据,就不要从 PDF 抠"——PDF 是"展示格式",不是"数据格式";而"用 PDF 当唯一数据源"往往是"历史遗留",不是"设计选择"。
判断口径一句话:"表格里的'列'会不会改变语义"。会(金额/账期/责任方),就必须"保住行列";不会("备注"这类自由文本),文字流也够。
补一条"成本"现实:版面感知解析器"慢",OCR 更慢——一份 100 页的合同,跑一遍可能要几分钟。所以"解析"应当是"离线批处理 + 缓存"(按文件哈希缓存解析结果),而不是"每次查询都解析"。这条在"用户等着答案"的 RAG 里尤其关键:把"分钟级"的活放到"入库时",而不是"提问时"。
而"缓存键"要用"文件哈希 + 解析器版本":换了版本要重解析,否则"新旧结果混用",而"表格错位"会以"偶发"的形式出现。
最后一个"判断"经验:"PDF 里的表格能不能被结构化"取决于"这张表是不是"由系统生成的"——由系统生成的(对账单、发票、报表)通常"列边界清晰、可稳定解析";而"人画的表"(合并单元格、跨行、手写批注)几乎总要人工兜底。所以"自动化程度"要按"表格来源"分档,而不是"一刀切"。
8. 自测
- 三种 PDF 内容的解析难度分别是什么?为什么"电子版"看起来简单却容易错?
- "表格被拉平"的三种表现里,哪一种最致命?为什么?
- "先解析成 HTML/CSV 再切"为什么最稳?它把切分放到了哪一步之后?
- "跨页表格"的三种处理里,哪一种最通用?
- "每一行 + 表头"作为 chunk 解决了什么?
- 判断"文字流是否够用"的那个问题是什么?