Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

竖向大表格与跨页表格:财报 PDF 里最难的 5%,决定系统上限

財務表現摘要
二零二五年第二季度
收入  184,504  161,117  15%  180,022  2%
毛利  105,013   85,895  22%  100,493  4%

这是腾讯 2025 中报第 4 页「財務表現摘要」抽成文本后的样子。先别急着庆幸——这算运气好的抽法,行名和数值还站在同一行。换个抽法,“收入、成本、毛利“是一行,“184,504、98,238、86,266“是另一行,两行互不相干的孤立字符串,一张利润表在文本流里直接解体。

第 2 篇讲 PDF 解析时,这份 119 页中报的正文已经洗完了,reading order 解决的是正文的顺序。但正文之外还有东西:表格。它在财报里占的页数不多,难度却比正文高一个量级——而且所有财务数字都在里面。

表格在 PDF 里根本不是表格

为什么会解体?因为 PDF 里压根没有“表格“这个东西。

正文是线性的:从上到下、从左到右,字符顺序就是阅读顺序。表格是二维的:行、列、合并单元格、对齐关系。而 PDF 不存“这是一张表“的语义,只存绘图指令——在 (x=72, y=500) 画一条横线,在 (x=80, y=480) 放一个字符串 “184,504”。解析器拿到的是一堆线条和坐标,要从它们反向推断出行列结构。

物理布局驱动和语义驱动之间的这道沟,就是表格难一个量级的根源。

同一张利润表,抽法不同,文本流里就是两个东西。按行抽:“收入 184,504 成本 98,238”,行名数值挨着,还救得回来。按列抽:“收入 成本 毛利 184,504 98,238 86,266”——行名一串,数值一串,各自连贯,embedding 根本不知道“收入“和“184,504“有关系。结构散在这一步,后面所有环节都是在垃圾上做功。

具体的崩法集中在两类表格上:竖向大表格和跨页表格。分开说。

竖向合并单元格:pdfplumber 的临界点

先界定一下。竖向大表格指财报里行数多、层级深的大表——利润表是典型,资产负债表、现金流量表也常长这样。它还有一种形态是整张表旋转 90° 横排,pdftotext -layout 对旋转内容直接读不出来,这种只能交给版面分析模型,这里不展开。真正折磨人的是下面这种。

Python 生态抽表格最常用 pdfplumber。它的机制就一层:检测 PDF 里的水平线和垂直线,推断单元格边界,再提取每个单元格内的文本。

机制即命门。它的一切都建在线条上,而财报表格的三个特征正好打在这条线上:

  • 竖向合并单元格。 利润表里“收入“这个总分类一格跨 5 行,下面挂着增值服务收入、网络广告收入这些子项。线条推断不出“这一格纵向跨了 5 行“的归属关系,子项和总分类的对应直接断掉。
  • 无边框表格。 财报里大量表格没有完整网格线,靠对齐和空白暗示结构。线条检测引擎对这种“隐形表格“直接失明。
  • 嵌套结构。 “其中:增值服务“缩进一层,下面又是一张小表。线条的世界里没有缩进这个概念。

三个解法,按代价排:

方案 A:多模态模型 / MinerU,页面截图直出结构化 JSON。 把表格所在页截图,发给 GPT-4o、Qwen-VL 这类多模态模型或 MinerU 的版面分析模块,让它像人眼一样直接“看“表格,输出结构化 JSON。它不需要推断线条,天然绕开了 pdfplumber 的命门。在我做过的项目里,这是对竖向大表格效果最好的方案——这是经验判断,不是评测结论,前提是表格页占比不高、调用量可控。代价是 GPU 加 API 成本,具体选哪个模型按你读到时的当代版本定。

方案 B:半自动提取。 只借 pdfplumber 拿字符和坐标,不用它的表格推断,自己写逻辑:按 y 坐标分组成行,按 x 坐标和缩进检测层级,再推断父子关系。不依赖外部 API,数据不出本地,但工程量是三个方案里最大的——本质是自己重写半个表格识别引擎。【待补:外部笔记《财报竖向大表格处理方案》未找到,缩进阈值、父子关系推断的具体规则暂缺,此处目前只有思路】

方案 C:标注 + 规则模板。 利润表、资产负债表的结构是固定的,手动标一次模板(第几行对应哪个指标),后续同类财报自动匹配。A 股财报格式高度统一,标一次吃很久,这个方案划算;港股、美股各有各的格式,模板复用率低,不成立。

跨页表格:拼接,还是继承表头

场景先说具体。一张利润表跨三页:表头只在第一页顶部,后两页只剩数据行和一个“(续)“标记。按页切 chunk(也就是分片)之后,第二页的数值块成了孤儿——RAG 检索到 “86,266”,却不知道它是毛利还是净利。数字没有上下文就不是数据,是噪声。

两种思路。

思路一:跨页拼接后再切。 先检测跨页:上一页最后一行没有表尾标记、下一页第一行以“(续)“或缩进数据行开始,就判定为同一张表;把几页的表格区域拼成一个逻辑块,再参与分片。表格完整性保住了,表头、数据、注释都在一个 chunk 里。代价是分片逻辑变复杂:得先“重组“文档再切,不能直接按页切。

思路二:每页重复表头。 清洗阶段检测到跨页表格,就把第一页的表头复制一份,插到后续每页的表格区域前面——“以下数据来自利润表:收入、成本、毛利……”。不动页面顺序,实现简单,对 RAG 检索的效果够用。

我的选择是先上思路二。它只要清洗阶段加一条规则,比思路一省一半工程量——这是单个项目里的经验,不是测量结论。需要精确表格问答的场景,思路一的完整性才值回票价。

比思路选择更重要的是三条原则:

  • 表格不要切。 一张表完整作为一个 chunk,宁可 chunk 大一点。
  • 用结构化格式(Markdown / JSON)代替纯文本。 纯文本里 “(79,491)” 这种括号负号的会计写法,有的解析器会吞掉括号、负值变正值;结构化输出至少能保住字符原样。数字保真是另一个坑,这里只提一句,不展开。
  • 跨页表格自动合并。 不让一张表分成两块进索引。

三条原则背后是一句话:表格是数字的载体,切散了数字就没意义。

PDF 工具在表格上的实际表现

先交代口径。这张表合并自两份工程笔记:一份按“纯文本、规则表格、合并单元格、跨页、多栏、中文“六个维度记录,一份按“中文、跨页、旋转、输出格式“四个维度记录,个别定级还冲突——pdfplumber 的中文支持,一处标“好“(字符层面它抽中文确实没问题),一处标“一般“(中文财报大量无边框表格,表格场景下确实只有一般)。我统一成一个口径:中文财报表格场景下的实际表现。“—“表示我没有上手记录,不猜。整份表是使用记录,不是 benchmark 评测结论。

工具纯文本规则表格竖向合并单元格跨页表格旋转内容中文支持
pdftotext -layout好一般差差读不出好
pypdf一般差差差—好
pdfplumber好好差差,需手动拼不行一般
Camelot———不支持不行一般
Unstructured———部分支持部分支持差
MinerU好好好能自动合并能处理原生优化
GPT-4o / Qwen-VL——最好最好—好

输出格式单独说一句:MinerU 直接出 Markdown 表格;Camelot 原生支持 CSV、JSON、Excel、HTML、Markdown 导出,转 Markdown 就一行 to_markdown();pdfplumber 抽到的结构得自己转。

结论写在表里了:没有单个工具通吃。

MinerU 得单独拎出来说,它是表里唯一在中文财报表格上全维度都能打的。调用本身不复杂:

from magic_pdf.data.data_reader_writer import FileBasedDataReader
from magic_pdf.data.dataset import PymuDocDataset
from magic_pdf.pipe.UNIPipe import UNIPipe
from magic_pdf.pipe.OCRPipe import OCRPipe

reader = FileBasedDataReader("")
pdf_bytes = reader.read("财报.pdf")
ds = PymuDocDataset(pdf_bytes)

# 文本型走 UNIPipe,扫描件走 OCRPipe
pipe = OCRPipe(ds, None) if ds.classify() == "ocr" else UNIPipe(ds, None)
pipe.pipe_classify()
pipe.pipe_analyze()
pipe.pipe_parse()

content = pipe.pipe_mk_markdown("images", drop_mode="none")

(MinerU 1.x,即 magic-pdf 包。调用走 pipe 管线:classify → analyze → parse → 出 Markdown,2.x 起 API 又改了几轮——2026 年 9 月时 mineru 包已迭代到 4.x,照抄前先对一遍你装的版本。)

关键在 classify() 和 pipe 的分工:ds.classify() 先判断这份 PDF 是扫描件还是文本型——扫描件走 OCRPipe,文本型走 UNIPipe;版面分析藏在 pipe_analyze() 里;最后 pipe_mk_markdown() 输出 Markdown,表格直接是 Markdown 表格,不用再自己拼结构。代价在部署侧:要装 magic-pdf 依赖、下载几 GB 的模型权重,CPU 推理慢,GPU 显著加速。

所以实际跑的是组合管线:pdftotext -layout 管约 80% 的非表格内容,pdfplumber 管规则表格,MinerU 或多模态模型专啃竖向大表格和跨页表格,最后全文合并。每个工具只干自己扛得住的那一段。七个评估场景(多栏、竖向合并、跨页表、扫描件、图文混排、大文件性能、中文竖排)的逐项实测数据,留给番外 A1 的工具对比报告,本篇只给结论。

最难的 5% 决定上限

最后算标题里那笔账。口径先讲清楚:5–10% 的页数占比、约 50% 的 ingestion 工程时间,这两个数字来自我单个项目(腾讯中报这条流水线)的工程估算,不是多份财报的统计结果,当经验数字看。

单看账面,这笔买卖很不划算:5–10% 的页数,吃掉一半工程时间。但这笔钱该花——财报的关键数字全在这 5% 里。收入、毛利、经营现金流,用户问的都是表格里的东西。表格区域烂掉,前面 80% 的正文洗得再干净,系统也只是“能聊财报,答不了数字“。所以 ingest 预算的一半花在表格上,我不觉得是浪费。

务实的落地顺序:先用第 2 篇的方法搞定 80% 正文,让系统上线可用;表格区域用多模态模型针对性处理,不追求 100% 自动化;跨页表格先上思路二,有精确表格问答的需求再回头补思路一。

表格抽出来之后还有一步:标 period_type(年度/中期/季度)、unit(百万元/亿元)、table_type(利润表/资产负债表)这些 metadata。这一步决定数字能不能被正确检索,展开是第 5 篇的事,这里只提一句。

到这里,文本和表格都抽干净了,119 页干净文本摆在面前。下一个问题自然浮出来:怎么切?按长度切会切出什么事故、“按语义切“具体又怎么改——第 4 篇《分片实战:从“按长度切“到“按语义切“的改造记录》接着讲。