
1. 为什么PDF内容提取需要“合流”而不是“单干”做过PDF解析的人都有一个共同感受单看某一类内容方案满地都是一旦把原生文字、混排图片、图内文字放在一起输出就开始打架。我最早做合同批量入库时用的是最朴素的路子——先抽文本层抽不到就整页转图片丢给OCR。结果一份三十页的采购协议正文识别得漂漂亮亮可夹在条款中间的签章图、表格截图、扫描附件全被当成“无文字页面”跳过最后入库的字段缺了金额和日期返工了两天。问题的根子在于PDF从来不是一种“内容格式”它更像一个“打印描述文件”。同一页里文字可能是真正的文本对象也可能是被切成几百个小碎片的路径图片可能是独立XObject也可能是被裁切、旋转、叠加过的图层而图内文字更是完全没有语义只有像素。PDF内容提取的合流设计说的就是把这些来源不同、结构不同、可信度不同的内容按阅读顺序重新拼成一份统一的输出——不管是TXT、Markdown还是HTML。这套设计能解决三个具体问题。第一是顺序问题原生文字和图片在PDF里的物理顺序往往不等于人眼阅读顺序双栏排版、页眉页脚、浮动图注都会打乱它。第二是去重问题有些PDF是“扫描件隐藏文本层”的组合直接抽文本会得到一份OCR又会得到一份不合流就会重复。第三是格式问题原生文字自带加粗、字号、段落信息OCR出来的只有纯文本两者混在一起输出Markdown时标题层级和表格结构很容易崩。适合看这篇的人我大致分三类。一类是做文档中台、知识库入库的工程师需要把PDF统一转成可检索的结构化文本一类是做RAG应用的朋友PDF是语料大头抽取质量直接决定召回效果还有一类是日常要处理扫描合同、论文、说明书的运营和法务同学想自己搭一套能跑通的流程。下面我按“整体设计—核心细节—实操实现—问题排查”的顺序把踩过的坑和验证过的方案摊开讲。2. 合流方案的整体设计与选型逻辑2.1 三种内容源的特性对比与合流目标在动手写代码之前得先想清楚三类内容各自的“脾气”。我把它们整理成一张表这张表基本决定了后面所有选型。内容类型数据来源优势短板可信度原生文字PDF文本对象带字体、字号、坐标可精确还原样式扫描件为空乱码字体难解高混排图片页面XObject保留原始视觉信息无文字语义需定位中图内OCR图片像素识别能救回扫描件和截图文字有错字无样式顺序靠猜中低合流的目标不是“把三者拼起来”而是以阅读顺序为骨架以可信度为权重做一次内容仲裁。举个具体例子一页里既有原生文字段落又有一张带文字的流程图。理想输出是段落文字在前、图内文字紧随其后并且图内文字要标注来源方便人工复核。如果这页是纯扫描件那原生文字为空就整体走OCR但OCR结果要按版面分析切成块而不是一整页糊成一团。提示合流的核心不是技术堆叠而是“先定顺序、再定取舍”。顺序错了后面再准也是白搭。2.2 为什么选择“分层抽取统一中间表示”的架构我试过两种架构。一种是“边抽边拼”抽到一段文字就写进输出缓冲遇到图片就调OCR再追加。这种写法代码短但一旦遇到双栏或图片跨页顺序就彻底乱掉而且没法做去重。另一种是“分层抽取统一中间表示”先把每页的所有元素抽成一个带坐标和类型的列表再统一排序、仲裁、渲染。我最终选了后者原因是它把“抽取”和“输出”解耦了。统一中间表示我用的是一组简单的数据类每个元素至少包含类型text/image/ocr、页码、边界框x0, y0, x1, y1、原始内容、可信度、来源标记。有了这个结构排序就变成对列表按阅读顺序排序去重就变成对重叠区域做比对输出就变成遍历列表渲染成TXT或Markdown。这套结构还有一个好处调试时可以把中间表示打印出来一眼看出哪一页的顺序错了不用去猜。选型上原生文字抽取我用的是PyMuPDF也就是fitz它对文本块坐标和字体的支持最细速度也快。OCR我选的是PaddleOCR中文场景下准确率和版面分析都比Tesseract省心尤其是竖排和表格线。版面分析这块如果PDF本身有文本层我直接用文本块坐标推断栏数如果是纯扫描件就用PaddleOCR自带的检测框做聚类。这样避免了引入过重的深度学习版面模型部署成本可控。2.3 输出格式的取舍TXT、Markdown还是HTML热词里TXT、Markdown、HTML都出现了说明大家需求不一样。我的做法是中间表示只做一份输出层做多态渲染。TXT适合做全文检索和喂给下游NLPMarkdown适合做知识库和人工阅读HTML适合做网页展示和保留样式。三者共用同一份排序后的元素列表只是渲染规则不同。具体来说TXT渲染时只输出纯文本图片位置用占位符标记OCR文字直接拼接Markdown渲染时根据字号和加粗推断标题层级表格区域尝试还原成Markdown表格HTML渲染时把原生文字的字体信息转成CSS图片用base64内嵌或外链。这里有个经验不要试图在抽取阶段就决定输出格式否则后面想换格式就得重跑整个流程。中间表示一旦稳定输出格式就是一层薄薄的渲染函数。3. 核心细节解析与实操要点3.1 原生文字抽取坐标、字体与阅读顺序的还原原生文字抽取最容易踩的坑是“按流顺序读”。PDF里的文本对象顺序是写入顺序不是阅读顺序。我见过一份双栏论文左栏第一段和右栏第一段在流里是挨着的直接读出来就是“左栏开头接右栏开头”完全没法看。解决办法是按坐标做栏检测先统计所有文本块的x0分布用一维聚类找出栏边界再在每栏内按y坐标从上到下、x坐标从左到右排序。字体信息也要保留。PyMuPDF的get_text(dict)能拿到每个span的字体名、字号、flags是否加粗、斜体。这些信息在输出Markdown时非常有用字号明显大于正文的判为标题flags带加粗的判为强调。我一般会先统计全文档的字号分布取众数作为正文字号比众数大1.2倍以上的判为标题大1.5倍以上的判为一级标题。这个阈值不是死的遇到排版夸张的文档要微调。注意有些PDF用了子集化字体抽出来的文字可能是乱码或问号。这种情况要先检测字符映射是否正常如果大面积异常就退回OCR路线别硬解。3.2 混排图片的定位与图内OCR的触发策略图片定位的关键是拿到它在页面上的边界框并且判断它和周围文字的关系。PyMuPDF的get_images能拿到图片的xref和位置但要注意有些图片是被遮罩或旋转过的直接抽可能得到错误区域。我的做法是先用page.get_image_info()拿到渲染后的实际位置再和文本块坐标做重叠检测。图内OCR不是每张图都要跑。我设了三条触发规则第一图片面积占页面比例超过5%才值得OCR太小的图标跳过第二图片区域和已有文本块重叠度低于30%说明它不是文字的背景图第三如果整页原生文字字符数低于阈值比如50说明这页大概率是扫描件直接整页OCR不用逐图判断。这三条规则实测能省掉六成以上的无效OCR调用速度提升明显。OCR结果要带位置信息回来。PaddleOCR返回的检测框是四点坐标我把它转成边界框和图片的边界框做映射这样OCR文字就能和原生文字在同一个坐标系里排序。这一步是合流的关键否则OCR文字只能整块追加在页尾顺序全乱。3.3 去重与仲裁隐藏文本层和OCR结果的冲突处理扫描件加隐藏文本层的PDF很常见尤其是扫描仪自带的OCR功能生成的。这种文件直接抽文本能拿到一份结果但质量参差不齐有的错字连篇有的干脆是乱码。我的仲裁策略是按可信度和覆盖率打分。原生文字如果字符数正常、乱码率低于5%可信度设为高OCR结果可信度设为中。两者重叠区域超过70%时保留原生文字丢弃OCR重叠低于30%时两者都保留但OCR结果标注来源。去重不能只靠文本相似度还要看位置。我遇到过一份文件原生文本层和OCR结果文字完全一样但位置偏移了半页原因是文本层是后期叠加的坐标系没对齐。这种情况如果只按文本去重会误删按位置去重又会重复。我的做法是先用文本相似度粗筛再用位置做二次确认两者都满足才判定为重复。提示去重阈值不要设得太激进。我一开始把相似度阈值设到0.9结果把“甲方”和“乙方”这种只差一个字的字段误判为重复损失了关键信息。后来降到0.75并加上位置约束才稳定下来。4. 实操过程与核心环节实现4.1 环境准备与依赖安装这套流程我是在Python 3.10下跑的依赖不算多但版本要卡一下。PyMuPDF用1.23以上PaddleOCR用2.7系列Pillow用于图片处理。安装命令如下pip install pymupdf1.23.8 pip install paddlepaddle2.5.2 pip install paddleocr2.7.0.3 pip install pillow10.1.0PaddleOCR第一次运行会自动下载模型国内网络环境下建议提前把模型缓存好否则会卡在下载环节。模型默认存在~/.paddleocr/下可以手动拷贝到目标机器。如果只是做中文识别用默认的ch模型就够了不需要额外下多语言包。4.2 分层抽取的代码骨架先定义中间表示的数据结构我用的是dataclass简单直接from dataclasses import dataclass, field from typing import List, Optional dataclass class Element: type: str # text / image / ocr page: int bbox: tuple # (x0, y0, x1, y1) content: str confidence: float 1.0 source: str native # native / ocr font_size: float 0.0 bold: bool False抽取主流程分三步遍历页面、抽原生文字和图片、对图片跑OCR。这里给一个简化版的核心逻辑import fitz def extract_page(page, page_num): elements [] # 抽原生文字 text_dict page.get_text(dict) for block in text_dict[blocks]: if block[type] ! 0: continue for line in block[lines]: for span in line[spans]: if not span[text].strip(): continue elements.append(Element( typetext, pagepage_num, bboxspan[bbox], contentspan[text], font_sizespan[size], boldbool(span[flags] 2**4), )) # 抽图片 for img in page.get_image_info(): elements.append(Element( typeimage, pagepage_num, bboximg[bbox], content, )) return elements这段代码跑完每页就得到一个元素列表。注意get_image_info返回的bbox是渲染后的实际位置比get_images更可靠。如果页面是纯扫描件文本块会很少这时候要触发整页OCR分支。4.3 阅读顺序排序的实现细节排序是合流里最考验细节的一步。我的排序函数分三档单栏、双栏、复杂版面。单栏最简单按y0排序y0相近的按x0排。双栏要先聚类出栏边界再在栏内排序。复杂版面比如三栏加跨栏标题我用的是一种“递归切分”的思路先按x方向找空白列把页面切成若干竖条再在每个竖条内按y排序。栏边界检测我用的是投影法把所有文本块的x0和x1投影到x轴上统计每个x位置的覆盖密度密度低谷就是栏间空白。这个方法对规整排版很有效遇到不规则排版会失效这时候就退回按y排序并在输出里标注“顺序可能不准”提示人工复核。def sort_elements(elements): # 先按页分组 pages {} for e in elements: pages.setdefault(e.page, []).append(e) result [] for page_num in sorted(pages): page_elems pages[page_num] # 简单起见这里用单栏排序 page_elems.sort(keylambda e: (round(e.bbox[1] / 10), e.bbox[0])) result.extend(page_elems) return result实际生产里我会把栏检测加进去这里为了讲清楚逻辑做了简化。排序完的元素列表就是合流的骨架后面所有输出都基于它。4.4 多格式渲染从中间表示到TXT和Markdown渲染层我写了一个基类TXT和Markdown各实现一个子类。TXT渲染最简单遍历元素text和ocr直接输出内容image输出[图片]占位。Markdown渲染要复杂一些需要根据字号推断标题层级根据加粗推断强调表格区域还要尝试还原。def render_markdown(elements): lines [] for e in elements: if e.type image: lines.append(\n\n) elif e.type in (text, ocr): text e.content.strip() if not text: continue if e.font_size 18: lines.append(f## {text}\n) elif e.font_size 14: lines.append(f### {text}\n) elif e.bold: lines.append(f**{text}**\n) else: lines.append(text \n) return \n.join(lines)这里有个细节OCR出来的文字没有字号信息所以不会触发标题推断统一按正文输出。如果希望OCR区域也能识别标题可以在OCR后做一次简单的规则判断比如文字长度短且独立成行的判为标题。这个规则不完美但比没有强。注意Markdown渲染时连续的空行要合并否则输出会有一堆多余换行。我在渲染最后加了一步正则清洗把三个以上连续换行压成两个。5. 常见问题与排查技巧实录5.1 抽取结果乱序的三种典型场景乱序是最高频的问题我整理了三种典型场景和对应解法。第一种是双栏排版解法是栏检测加栏内排序。第二种是页眉页脚混入正文解法是按y坐标过滤掉页面顶部和底部5%区域内的文本块除非该区域文字密度异常高。第三种是浮动图注图注文字在物理位置上靠近图片但在流顺序里可能离得很远解法是把图注和图片做关联排序时把图注紧跟在图片后面。场景表现解法双栏排版左右栏文字交错投影法检测栏边界栏内排序页眉页脚每页首尾出现重复文字按y坐标过滤边缘区域浮动图注图注远离图片图注与图片bbox关联绑定排序5.2 OCR错字与漏检的应对策略OCR错字没法完全避免但可以降低影响。我的做法是建立领域词典做后处理。比如做合同抽取时把“甲方”“乙方”“金额”“日期”这些高频词加入词典OCR结果里出现形近字时用编辑距离匹配词典做纠正。这个方法对专有名词特别有效能把“申方”纠正成“甲方”。漏检主要出现在小字号和低对比度区域。PaddleOCR默认的检测阈值对浅色文字不敏感可以调低det_db_thresh参数但调太低会引入噪声。我的经验是先用默认参数跑一遍统计漏检区域的特征如果集中在某类图片上再针对性调参。另外图片先做一次锐化和对比度增强再送OCR漏检率能降不少。提示OCR结果一定要保留置信度。置信度低于0.6的文字在输出时标注出来方便人工复核。我见过太多因为OCR错字导致下游字段错误的案例标注来源是最低成本的兜底。5.3 大文件处理的内存与速度优化几百页的PDF一次性加载会吃光内存。我的做法是按页流式处理每页抽完就释放页面对象中间表示只保留必要字段。PyMuPDF的page对象在处理完后调page.clean_contents()能释放一部分资源。OCR是最耗时的环节我用多进程池并行处理图片进程数设为CPU核心数的一半避免和主进程抢资源。速度上还有一个技巧先判断页面是否需要OCR。如果一页的原生文字字符数超过200且乱码率低于5%就跳过OCR。这个判断能把纯文本PDF的处理速度提升十倍以上。只有文字稀少的页面才走OCR分支整体吞吐量就上来了。优化手段效果注意事项按页流式处理内存占用降80%及时释放page对象多进程OCR速度提升3-5倍进程数别超过CPU核心数跳过无需OCR页面纯文本PDF提速10倍阈值要按文档类型调5.4 输出格式兼容性的排查清单输出环节的问题往往在最后才暴露。我整理了一份排查清单每次上线前过一遍。第一检查TXT输出是否有乱码尤其是中文编码统一用UTF-8。第二检查Markdown的表格是否对齐OCR还原的表格经常缺列。第三检查HTML内嵌图片是否过大base64内嵌会让文件膨胀超过10MB建议改外链。第四检查特殊字符转义Markdown里的*和_要转义否则会破坏格式。def escape_markdown(text): for ch in [*, _, #, , [, ]]: text text.replace(ch, \\ ch) return text这个转义函数看着简单但能避免很多格式崩坏。我一开始没做转义结果一份技术文档里的代码片段把整个Markdown结构搞乱了排查了半天才发现是星号没转义。6. 我在实际项目中的几点体会这套合流设计我前后迭代了三个版本最大的体会是不要追求一步到位的完美抽取而要追求可解释、可复核的中间结果。中间表示一旦清晰后面无论是换OCR引擎、调排序规则还是加新的输出格式都只是局部改动。反过来如果一开始就把抽取和渲染揉在一起后面每改一个需求都要动全身。另一个体会是关于阈值的。所有阈值——字号标题阈值、OCR触发面积、去重相似度——都没有万能值必须按文档类型调。我的做法是把这些阈值抽成配置项针对合同、论文、说明书各存一套预设跑新类型文档时先小批量试看中间表示的输出再定阈值。这个过程花时间但比上线后返工划算得多。最后分享一个小技巧调试排序问题时把中间表示渲染成一张带编号的HTML页面每个元素标上序号和bbox用浏览器打开一眼就能看出顺序对不对。这个可视化工具我用了两年排查效率比看日志高一个数量级。