PDF 解析:抽不出文本早不是问题了,reading order 才是
把 119 页的腾讯 2025 中报扔进 pdftotext,文本出来了,raw.txt 282KB。但打开细看,顺序是碎的:两栏页面左右穿插拼成一行,句子说到一半被分页符切断。目录页抢走召回第一名的症状,上一篇已经见过。
抽得出文本,和抽得出对的顺序,是两件事。上一篇(第 1 篇,系列总纲)结尾说,RAG 的链路是从上游开始烂起的。这篇就讲最上游:PDF。
一、抽完之后,四个不对劲的地方
先不急着分析原因,把抽取结果摆出来看。
问“毛利率多少“,召回第一名是目录页。不难理解:目录页把“收入、毛利、经营现金流、风险因素“这些词完整抄了一遍,和查询的向量相似度拉满,但目录本身没有一个数字。相似度满分,信息量零分。
页脚在每页重复。“二零二五年中期報告“加公司名,119 页出现 119 次。在 embedding 模型眼里这是强信号,结果是所有 chunk 被这些噪声“压“得彼此过于接近,区分度下降。
句子被分页切断。模型读到的是“截至 6 月 30 日止六個月,本公司權益持有人應佔“——下半句在下一页开头。按字符数或 token 数切 chunk 时,这种断点经常正好落在语义中间。
表格最典型。“收入 成本 毛利“是一行,“184,504 98,238 86,266“是另一行,在文本流里它们就是两行互不相干的字符串。embedding 看到的是孤立数字,不知道它们属于哪个指标、哪个期间。
单独看,每一项都不致命。叠在一起,系统的回答会“看起来对、其实错“——而且错得很流畅。这是最难排查的一种错。
二、为什么财报 PDF 和 demo 文档不是一种东西
根因一句话:RAG demo 里常用的博客、手册、FAQ 是线性文档,从上到下按阅读顺序排;财报是非线性的——多栏布局、内嵌表格、跨页段落、页眉页脚反复、图文穿插。
PDF 的文本流是物理布局驱动的,不是语义驱动的。所以“抽出来“只解决了字符问题,没解决顺序问题。后面三节都是这句话的展开。
三、抽取工具:四个主流选择各自死在哪儿
主流选择就四个,各自的死法不同:
| 工具 | 优点 | 财报场景下的致命问题 |
|---|---|---|
| pypdf | 快,纯 Python,不依赖外部命令 | 没有 layout 模式,多栏页面文本顺序完全乱掉 |
| pdfplumber | 能拿字符坐标,支持表格提取 | 竖向合并单元格、跨页表格处理失败;全本抽取 CPU 和内存消耗大 |
| pdftotext -layout | 保留物理布局,稳定,命令行友好 | 中文竖排、多栏布局仍可能错位 |
| MinerU | 纯自动方案里 layout 恢复最强,直接输出 Markdown | 依赖重:几个 GB 的模型权重,CPU 推理慢、要 GPU 才实用【待核实:发布前复核 MinerU 当月版本的部署依赖和权重大小】 |
文本型中文财报,我的首选是 pdftotext -layout:
pdftotext -layout 腾讯-2025-中期报告.pdf raw.txt
(pdftotext 来自 poppler:macOS 上 brew install poppler,Linux 装 poppler-utils 包。)
关键在 -layout:用空格和换行把文本在页面上的物理位置模拟出来,而不是按 PDF 内部文本流顺序直接吐。它不完美——多栏页照样会错——但比纯文本流好得多。代价是 raw.txt 里留着分页符、空格对齐这些“为打印保留“的痕迹,这些交给下一步清洗。
pdfplumber 我不拿来做全本抽取,遇到竖向合并单元格或跨页表格时表现不稳定;但把范围限死在“特定页的特定表格“,它是可用的。MinerU 是另一个极端:能力最强,成本在部署,适合愿意为它配 GPU 的场景。
pypdf 单独说一句:它是很多人的第一反应——纯 Python,pip 装上就能用——但它对页面布局没有任何概念,多栏页面的文本顺序完全乱掉。财报场景可以直接排除,不用试。顺带交代名字:老代码里的 PyPDF2 2022 年后就停更了,项目合并回 pypdf 持续维护,新代码直接装 pypdf。
边界先说清:以上结论只适用于文本型 PDF。扫描件是另一条路,抽取层得换成 PaddleOCR 或多模态模型,raw.txt 的语义都不同了。
四、清洗:先全本清洗,再切 chunk
顺序本身就值得强调:先切 chunk 再清洗是常见错误。一旦目录噪声和正文混进同一个 chunk,清理成本成倍上升——你得在每个 chunk 内部重新识别噪声,而不是在页级别一次删掉。
我的清洗清单四项:
- 页眉页脚。正则匹配“二零二五年中期報告“、公司全称、页码单独成行。注意不同章节页脚不一样,有的章节是“未经審核“,有的是“經審核“,规则要覆盖全。
- 目录页。页码模式(右对齐数字、连续的“章节名……页码“行)是很好的检测特征,检测到就整段删除。目录里的“标题→页码“映射理论上对 section 识别有用,但除非愿意专门做 toc-aware 解析,否则直接删是更稳的选择——这点信息不值得引入风险。
- 纯页码。单独成行的数字,前后不是数值上下文。
- 分页断句。上一页最后一行以“截至““包括”“本公司“这类词结尾且没有句号,就和下一页第一行拼上。
顺带提一个隐藏坑:会计写法 (79,491) 是负数,有的解析器会吞掉括号,把负值变正。这个错误单看文本很难发现,对数字问答却是致命的。清洗后值得做一次括号配对检查。
清洗的工程原则是“宁可漏删,不要误删“。代价不对称:误删一段正文的数字,永远找不回来;留一段页脚在 chunk 里,噪声会被多段正文稀释,影响有限。保守是理性的。
这份中报洗完,raw.txt 282KB 变成 cleaned.txt 190KB——三分之一的内容是噪声。
还有一点值得说:清洗不是把所有文本拼成一大坨,而是按页保留边界。我的 cleaned.txt 里每页开头有一行 ===== PAGE n ===== 标记。这个标记后面用处很大——页码可以直接当 citation 展示给用户,前端也能跳回 PDF 原页核对。清洗阶段删掉的是噪声,不是结构。
五、reading order:被低估的核心问题
绝大多数讲分块的教程有个隐性假设:输入文本的顺序是对的。PDF 经常打破这个假设,而且坏得悄无声息——英文里叫 silently corrupt。raw.txt 看着是成段的文字,检索结果“看起来相关“,回答里却出现莫名其妙的事实错误。没有报错,没有异常,就是错了。
财报里有三个高发场景。
多栏页面,左右栏穿插。腾讯中报的“公司資料“页是两栏:左边公司基本信息,右边往来银行和核数师。PDF 文本流从左到右逐行抽,出来的是“腾讯控股有限公司 香港上海滙豐銀行有限公司 置地廣場 皇后大道中 1 號……“——两栏内容交替拼在一起,不再是两个独立的语义块。
表格内嵌文本,位置不定。正文中间穿插的表格,在 PDF 层级上和正文没有明确的先后关系,抽出来可能落在段落前面、中间或后面。
图表说明文字,顺序不定。图片注释在文本流里的位置取决于排版引擎的实现,有时在图上方有时在下方,和正文的逻辑顺序对不上。
没有银弹。我自己的做法是三个手段组合着用,按可靠度排。
坐标排序是基础。用 pdfplumber 拿到每个字符的 (x, y) 坐标,按 y 降序(从上到下)、x 升序(从左到右)重排。单栏页面基本够用,多栏页面会出错,得先做栏位检测。
栏位检测处理多栏。沿 x 方向找连续空白带(gutter):存在超过一定宽度的空白带,就判定为多栏,先按栏切区域,再在每个栏内做 y 降序。这个宽度阈值我的经验值是 40pt——在 A4 版式的中文财报上试出来的,换个版式要重新调,别当普适标准。
最可靠的是最笨的办法:关键页面人工 review。119 页的中报,真正承载关键财务数据的页面大约 15 到 20 页,逐页对照原 PDF 检查文本顺序,30 分钟能做完。这也是我的经验值,不是行业标准。但财报场景里数字错一个就是事故,这 30 分钟是整条流水线里性价比最高的投入。
六、收尾:没有完美工具,只有拆开的子问题
回到开头的结论:PDF 是给人眼设计的,机器要的线性文本流和它的物理排版之间是结构化鸿沟,不存在一键抹平它的工具。能做的是把问题拆开——抽取选对工具、清洗先于 chunk、reading order 在关键页面人工兜底——每个子问题各自解决。
拆开的另一个好处是可回溯、可复用。这条流水线跑完落地四个文件:raw.txt、cleaned.txt、chunks.jsonl(345 个 chunk,327KB)、metadata.json。换一份新报告,替换 PDF,30 秒重跑全部四层【待核实:这组产物数字(282KB / 190KB / 327KB / 345 个 chunk / 30 秒)来自腾讯 2025 中报的实际记录,发布前如有条件重跑脚本核对,对不上就更新】。chunk 怎么切、metadata 怎么设计,是另外两个独立的问题,分别留给第 4 篇和第 5 篇。
但先别急着收工。本篇解决的是“正文的顺序“,而财报里所有关键数字不在正文里,在表格里。坐标排序、栏位检测、人工 review 这三招在正文页有效,碰到竖向合并单元格和跨页表格会集体失效——pdfplumber 直接崩溃,MinerU 也很吃力。表格是比正文难一个量级的问题,而这最难的 5% 恰恰是数字所在。下一篇(第 3 篇)《竖向大表格与跨页表格:财报 PDF 里最难的 5%,决定系统上限》,专门啃这块硬骨头。