基于bbox的PDF文档解析:多栏重排与水印过滤实战
1. 项目概述多栏和水印为什么是 RAG 解析里的两座山先说个场景。上个月我把一批技术论文和旧版产品手册塞进自建的 RAG 知识库原以为 PDF 解析早就不是问题结果检索结果让我怀疑人生同一个问题的答案碎片化有的片段左边一栏读到一半突然跳到右栏有的片段把每页底部的“Confidential”水印当成正文喂给了向量库最后回答里全是莫名其妙的口水话。排查了一圈问题不是出在模型而是出在 bbox 这一层——我当年用 PDF 解析库取文本时只看字符串压根没想过“文字在页面哪个位置”这件事有多重要。这个项目本质上就是一个 RAG 文档解析链路里的高发事故区多栏排版和水印 PDF。多栏会让纯文本流式读取顺序错乱水印会让文本内容带上大量重复噪声两者叠加后向量检索的召回质量会被拉低得惨不忍睹。我在这个系列的前两篇里写过 PDF 的文本抽取和表格还原这次专门把 bbox 实战拿出来写透用一套可复现的 Python 流程解决两类最恶心的 PDF。适合谁看正在搭 RAG 知识库、做文档解析管道、或者被扫描版论文和带水印的公开报告折磨过的人。这篇不会只讲理论我会直接给出坐标体系、列检测算法、水印过滤规则、以及一套从 PDF 页面到干净文本的流水线代码。就算你之前没碰过 bbox 也没关系读完后你自己上手改参数就能用。1.1 一个真实场景从论文库到检索问答我最初接到的需求很朴素把 2000 篇 PDF 论文和 100 多份厂商手册做成一个可问答的知识库。单栏排版的白皮书几乎没花时间文本抽出来就能用。麻烦的是那些双栏排版论文和带官方水印的 PDF 报告。双栏排版最常见的问题是文本流错位。很多 PDF 阅读器或解析库按“文本对象在文件里的出现顺序”抽取但 PDF 内部对象顺序和人类阅读顺序经常不一致。你可能以为在解析输出里先看到左栏上段实际却是左栏、右栏、左栏、右栏地交错出现。举个例子一页双栏论文的文本被抽出来之后如果把这段文本直接切块第一块可能是左栏第一段的开头加右栏第一段的开头第二块可能是左栏第一段结尾加右栏第一段结尾。嵌到一起之后语义是碎的。水印的问题就更隐蔽了。很多水印是文本对象会被普通解析器当成正文比如“Confidential”“DRAFT”或公司名在每一页以中等字号、半透明的样式出现在页面中央或底部。抽取文本时你会得到一份每页都重复若干次的水印词表向量化之后它们会占据高频词位置检索时容易命中水印而不是正文。我当时的目标很明确解析后的文本必须尽可能接近“人眼阅读顺序”而且要把水印这类视觉噪声从文本流里剔除。这两件事都必须依赖 bbox。1.2 bbox 是什么跟 RAG 有什么关系bbox 是 bounding box 的缩写指文本对象在页面上的最小外接矩形。一般用四个数字表示左上角 x、左上角 y、右下角 x、右下角 y。对 PDF 解析来说词级 bbox 是重建阅读顺序的最重要线索对 OCR 来说检测框也是 bbox只不过坐标系的单位不同。为什么 RAG 需要它因为文本向量化本质上只处理一维字符流它不关心二维视觉位置。如果从 PDF 提取到的字符流顺序错了那么后续的每个 RAG 环节都会继承错误切块会切出语义断裂的片段向量检索会召回乱七八糟的内容生成回答时模型只能在坏素材上强行组织语言。换句话说RAG 系统的天花板很大程度取决于文档解析是否保留了正确的版面顺序而 bbox 就是修正版面顺序的锚点。在实际项目中我经常用 bbox 做三件事一是多栏检测判断一页里文本分几栏再按视觉列重组二是阅读顺序排序把同一栏内按从上到下排序栏之间按从左到右拼接三是水印过滤识别那些位置重复、颜色异常、透明度高、字号特殊的干扰文本并剔除。下一节开始我会把 PDF 坐标体系和版面分析的基本概念讲清楚然后再给实操代码。2. 核心概念拆解坐标、版面分析与阅读顺序很多人用 PyMuPDF 或 pdfplumber 时直接调page.get_text()拿字符串认为这就完成了解析。这其实是把 PDF 里的很多信息丢掉。PDF 本身是一个二维版面模型页面上的每个字符都有精确坐标只是大多数文本抽取接口默认按内部对象顺序输出而这个顺序跟人类阅读顺序不完全一致。2.1 PDF 坐标体系从页面坐标到词级 bboxPDF 的坐标原点通常在页面左下角x 轴向右y 轴向上单位是点point1 point 1/72 英寸。但 PyMuPDF 返回坐标时用的是左上角为原点的转换也就是说 y 轴向下增长0 在页面顶部数值越大越靠下。和 PDF 原生坐标相反。pdfplumber 则保留 PDF 原生坐标y 轴向上0 在页面底部。这个差异非常坑。我第一次把 PyMuPDF 和 pdfplumber 的坐标混用做列投影时怎么都不对后来发现两个库的 y 方向是相反的。建议在一个项目里固定只用一个坐标体系或者统一转换成同一参考系。我自己习惯用 PyMuPDF 的坐标因为后续要画框调试时左上角原点跟常见的图像坐标一致跟 OpenCV 也能无缝对接。拿到词级 bbox 之后每个词的形态是这样的# PyMuPDF 返回的 word 结构 # (x0, y0, x1, y1, word, block_no, line_no, word_no)坐标的单位是点不是像素。如果 PDF 页面宽 612 点对应 letter 纸的宽度如果页面要用到 150 DPI 渲染成图片需要做一个简单换算像素 点数 * DPI / 72。这个换算是 OCR 场景的常见操作也是后续 bbox 过滤和图像渲染对齐的前提。2.2 版面分析启发式规则和版面模型的取舍版面分析不外乎两条路一条是用规则启发式一条是训练或调用版面模型。规则启发式基于坐标、字号、颜色、缩进这些浅层特征做判断版面模型则是用深度学习模型检测段落、标题、表格、图片区域并输出结构化坐标。对多栏问题我一开始试过基于深度学习的版面模型比如开源版面分析工具效果不错但部署成本和推理延迟都比较高。在实际 RAG 管道里每天解析几百上千页文件离线批量解析还能忍但如果是实时文档问答每一页都走模型推理会拖慢链路。更简单的办法是先上规则判断页内是否有明显的列结构如果有就用列投影来做重排如果遇到特殊页面代码块、表格、复杂示意图再降级到版面模型或人工处理。我的结论是不要一开始就上模型。先用规则跑通 80% 的常规 PDF剩下 20% 的特殊页面单独标记定期抽检人工修复这样工程上更稳妥。规则做不了的事再交给模型补位。2.3 多栏检测的底层逻辑多栏检测的核心思路是“投影”。把所有文本的最小 y 坐标映射到 x 轴统计不同 x 区间里出现的文本密度。如果是单栏x 轴上的文字分布几乎是连续一段如果是双栏文字分布会出现中间明显空洞——那个空洞就是栏间距。实际操作时我会按词级 bbox 的中线(x0 x1) / 2做投影也可以按行级单位做。统计每个 x 区间内的字符数或词数再用一个阈值判断空洞是否足够宽足够宽就切分栏。这个算法简单有效尤其适用于规则排版的论文页。还有一种老方法是找页面里所有文字块的左上角 x 坐标做聚类比如 KMeans 或类似 DBSCAN 的密度聚类。聚类数大于 1 且块之间水平分布明显分离时就判定为多栏。但聚类对噪声敏感而且需要预设类别数。相比之下投影直方图的解释更强也更容易调试所以我先用投影不行再上聚类。3. 工具选型与环境准备工具选型决定了后边所有代码的写法所以我单独开一节讲。我做 PDF 解析这几年试过一堆库最后在 RAG 场景里最顺手的组合是 PyMuPDF 负责版面解析pdfplumber 做表格和高精度文本时需要时备用PaddleOCR 负责扫描件 OCR。3.1 工具链对比PyMuPDF、pdfplumber、PaddleOCRPyMuPDF 的优势是快安装包里自带了 MuPDF 渲染引擎能同时拿文本、坐标、字体信息还能直接渲染页面成图片。用page.get_text(words)一行就能拿到词级 bbox性能很强适合批量解析。pdfplumber 的优势是表格抽取精细能把线框表格还原成行列结构。但它基于 pdfminer速度偏慢遇到几百页的大文件容易磨叽。我只在需要处理表格时调用日常文本抽取不用它。PaddleOCR 是目前我用下来中文扫描件识别准确率最稳的而且能输出每个文本检测框的坐标和置信度。缺点是依赖 PaddlePaddle 环境安装体积大。如果不想引入重依赖也可以拿 Tesseract 顶一顶但中文识别效果要差一些尤其是弯曲、低分辨率的文字。工具对比我用一个表说清楚工具定位优点缺点适用场景PyMuPDF文本与版面快、能拿词级 bbox、渲染页面表格还原弱原生 PDF、批量解析pdfplumber精细表格表格结构化好、坐标完整慢表格密集的 PDFPaddleOCROCR中文好、出检测框依赖大、重扫描件、带水印图片文本TesseractOCR 备用轻量中文略差服务器环境受限时3.2 环境安装与一个小坑我先给一套最小依赖Python 3.9 以上都行pip install pymupdf pip install paddlepaddle paddleocr pip install pdfplumber pip install opencv-python numpy装 PaddleOCR 的时候有个容易踩的坑新版本默认使用paddle后端如果本机 Python 刚好是 3.12 或者缺少某些编译库可能会报一堆动态链接错误。我当时的解决方法是先装 CPU 版 PaddlePaddle再装 paddleocr并且把模型下载路径设置到有写权限的目录避免在服务器上用 root 跑下载到/root/.paddleocr。这一步看似无关紧要但卡住的人不在少数尤其是公司内网环境模型下载失败会直接中断整个解析流程。装完之后可以做一次冒烟测试拿一页 PDF 试提取词级 bbox确认坐标不是全零、文字不是乱码再进下一步。4. 实战用 bbox 完成多栏 PDF 的重排现在进入正文戏。目标是把双栏论文页面重排成正确的从上到下、从左到右的阅读顺序。我用一个简化但真实可跑的流程先提取词级 bbox再做列检测最后按列和行重排。4.1 提取词级 bbox第一手坐标长什么样用 PyMuPDF 提取每页的词级信息非常简单import fitz def extract_words(page): return page.get_text(words) def draw_debug_boxes(page, words, out_path): for w in words: x0, y0, x1, y1 w[:4] page.draw_rect(fitz.Rect(x0, y0, x1, y1), color(1, 0, 0), width0.5) page.get_pixmap(dpi100).save(out_path)get_text(words)返回的每条记录包含 x0、y0、x1、y1、word、block_no、line_no、word_no。注意这里的 word 是按空白拆分的结果中文没有问题但英文里的连字符和跨行断词需要额外处理。拿到坐标之后我建议立刻做一步调试把词级 bbox 画回页面渲染成 PNG。这一步的重要性怎么强调都不过分。有一次我发现某页文本抽出来顺序乱画框之后才发现那页其实是三栏但中间栏是极窄的边注。不画框靠肉眼很难看出这类问题。4.2 列检测算法投影直方图的思路列检测我采用“x 坐标投影”的算法。核心是统计每一列 x 区间内的字符密度找到明显空洞作为栏分隔线。实现时不需要真的生成图片直接用词级 bbox 统计即可def detect_columns(words, page_width, gap_threshold30): # 取每个词的横向中线 centers [(x0 x1) / 2 for x0, y0, x1, y1, *_ in words] # 按中线排序后找相邻中心之间的空隙 centers.sort() gaps [] for i in range(1, len(centers)): gap centers[i] - centers[i - 1] if gap gap_threshold: gaps.append((centers[i - 1], centers[i], gap)) if len(gaps) 2: midpoints [(g[0] g[1]) / 2 for g in gaps] return midpoints # 列分隔点 return []这个简化版适合大多数双栏页面。它的逻辑是双栏论文的两栏之间通常有一个 20 到 40 点的空白沟足够大就能被检测到。如果文字字号大、行间距宽阈值可以调大一些如果表格密集或页边距混乱阈值调小但也要接受误报的可能。实际生产里我不会只靠这一个算法而是结合几个特征栏间空洞宽度、左右两栏的文字宽度是否接近、同一 y 行内是否出现左右两栏的文字同时存在。只有同时满足这些条件才判定为多栏。这样能避免把“标题跨栏”或“表格列间空白”误判成真正的栏。4.3 Z 字排序与文本拼接检测出栏位之后接下来就是把文本按视觉顺序拼接。我用两个阶段先给每个词分配栏号再在栏内按 y 坐标排序。最后按左栏到右栏的顺序输出。def reassign_columns(words, column_split_x): new_words [] for w in words: x0, y0, x1, y1, word w[:5] cx (x0 x1) / 2 col 0 for split in column_split_x: if cx split: col 1 else: break new_words.append((col, y0, w)) new_words.sort(keylambda r: (r[0], r[1])) return new_words def build_text(reordered_words): lines [] last_col None last_y None line_parts [] for col, y0, w in reordered_words: if (last_col is not None and col ! last_col) or (last_y is not None and abs(y0 - last_y) 5): lines.append( .join(line_parts)) line_parts [] line_parts.append(w[4]) last_col col last_y y0 lines.append( .join(line_parts)) return \n.join(lines)这个流程有几个细节要注意。第一判断同一行时用的 y 坐标不能只比 y0因为同一行文字的字号不同基线位置会有偏差最好允许 5 到 8 点的误差。第二词之间的空格偶尔会丢join的时候最好保留但中文不需要空格英文必须保留所以最好根据目标语言调整拼接规则。第三遇到跨栏的大标题时标题 x 方向跨了整页列检测后会把它分配到第一栏这没问题但需要把它单独识别为“全栏标题”否则左栏正文会跟标题混在一起。我的经验是如果一个文本行的宽度超过页面宽度的 80%就先提取出来当作独立段落不参与栏内排序。排序完成后文本流基本接近人类阅读顺序。再用这个文本流去做切块和向量化RAG 的召回质量会明显好转。5. 实战水印 PDF 的过滤水印比多栏更阴险。因为很多水印在页面上是半透明的视觉上不影响阅读但一旦被抽成文本就成了污染源。下面按水印的形态和处理方式拆开讲。5.1 水印在 PDF 对象层的四种形态第一种是文本型水印直接在页面上放置一串重复的文本对象颜色浅、字号不定、角度可能是斜的。PyMuPDF 的get_text(dict)能返回每个 span 的文本、字体、字号、颜色和透明度。第二种是图形型水印用矢量图形拼出来的公司 Logo 或背景底纹文本抽取拿不到但渲染时能看到。第三种是图片型水印水印作为半透明 PNG 或 JPEG 图像叠加在页面上文本抽取拿不到。第四种是复合水印既包含文本又包含图片多用于公文和官方声明。对 RAG 来说真正会破坏文本流的是第一种和第四种里的文本部分。图形和图片水印如果不影响 OCR就不用太操心如果影响就需要在图像层做去水印。5.2 用 bbox 加样式特征过滤水印针对文本型水印我总结了一套“四看”规则。一看重复性水印文本通常每一页都在同一个位置附近出现或者同一页内多次出现二看位置比如页面底部、页面正中央、斜穿页面三看颜色水印一般是非黑色或者低对比度颜色四看透明度PyMuPDF 在dict模式下能拿到 span 的opacity半透明的对象很可疑。举个例子某 PDF 每页底部都有“DRAFT”字样坐标基本在(page_width*0.3, page_height*0.92)附近颜色是灰色字号固定。只要把全页词按 bbox 聚类出现频率高且位置集中就能自动识别。我用一段代码说明过滤思路def is_watermark_span(span, page_width, page_height): bbox span[bbox] if bbox[3] page_height * 0.9: # 底部区域 if span.get(opacity, 1) 0.5: return True if span[color] not in [(0, 0, 0), (1, 0, 0)]: return True # 页面中央的大范围重复文字 return False这里color是 sRGB 颜色值PyMuPDF 默认以 8 位整数表示。黑色是 0白色是 16777215。水印常见的灰色大约在 8421504 附近。你不需要死记这些值关键是建立“正文默认黑色且不透光”的基线凡是偏离基线的都放到待剔除列表里。我踩过一个坑有些水印是“透明墨粉”处理过的实际字体颜色是黑色但透明度很低也有一些正文段落用了灰色高亮。所以“四看”规则一定要组合判断单一规则容易误杀。比如正文里有一条灰色注释位置不在重复区域那就不该删。5.3 扫描件里的水印OCR 和图片双重处理如果 PDF 是扫描件文本抽取拿不到内容只能走 OCR。水印会直接出现在图像上OCR 识别的时候可能会把水印文字一起识别出来。更麻烦的是水印文字覆盖了正文区域比如“官方文件”斜跨整页导致正文被遮挡OCR 结果里既有水印又有残缺的正文。我的处理方法是两步走。第一步先用 PaddleOCR 的检测框输出所有文本区域得到每个文本框的 bbox 和置信度。第二步根据 bbox 的位置和面积判断哪些框是水印。例如水印通常面积大、倾斜角明显、每页重复出现、识别置信度忽高忽低正文框则位置规整、字数均衡。不断言 OCR 能精准“去掉”水印但至少能在文本流里把水印文本过滤掉。图像层的水印我一般不做像素修复因为容易把正文也修糊只在极少数必须展示截图的场景里我才会用 OpenCV 的 inpaint 做局部修复。给一个 PaddleOCR 快速调用的示例from paddleocr import PaddleOCR ocr PaddleOCR( use_angle_clsTrue, langch, show_logFalse, ocr_versionPP-OCRv4 ) result ocr.ocr(page.png, clsTrue) # result[0] 里每一项是 [box, (text, score)] # box 是四个角点坐标可以算 bbox拿到检测框之后把每页出现的检测框按坐标重叠度和文本内容聚类。如果同一个文本比如“水印”或“机密”在超过 90% 的页面里反复出现并且位置相近我就把它加入全局过滤词表。这个“全局重复检测”是扫描件水印过滤里最实用的一招比单纯看位置可靠得多。6. 完整实现从 PDF 到可入库文本的流水线前面几节把关键技术点拆开了这一节我给出一个能直接抄的流水线整体结构。它把多栏重排、水印过滤、扫描件 OCR 兜底整合在一起最终输出干净的文本文件和对应的页面信息方便下游切片。6.1 流水线结构与顺序我设定的处理顺序是先判断页面是原生文本还是扫描件再分别走两条路。原生文本页走“提取词级 bbox - 水印过滤 - 列检测 - 排序拼文本”扫描件页走“渲染图片 - Ocr - 水印过滤 - 列检测 - 排序拼文本”。最后统一输出。这个顺序是有讲究的。水印过滤必须放在列检测之前因为水印通常横跨多栏或者独立于栏结构不先删掉会干扰列投影。列检测需要干净的文字分布否则水印的“中央大字”会让投影直方图出现伪栏位。流程图我不用画了直接看代码结构更实在。6.2 核心代码从页面到干净文本import fitz from paddleocr import PaddleOCR class PDFParser: def __init__(self): self.ocr PaddleOCR(use_angle_clsTrue, langch, show_logFalse, ocr_versionPP-OCRv4) def parse_page(self, page): text page.get_text(words) if not text: page_pix page.get_pixmap(dpi150) page_pix.save(temp_page.png) ocr_data self.ocr.ocr(temp_page.png, clsTrue) words self._ocr_to_words(ocr_data) else: words self._words_to_struct(text) words self._filter_watermark(words, page) columns self._detect_columns(words, page.rect.width) reordered self._sort_by_columns(words, columns) return self._to_text(reordered)这段代码故意写得精简重点在函数之间的衔接。每一个函数就是你前面看到的核心逻辑。如果你想一次性跑通还需要补_ocr_to_words和_words_to_struct两个转换函数把 OCR 输出或 PyMuPDF 输出统一成(x0, y0, x1, y1, text, page_id)的结构。统一结构是整条流水线最重要的设计决定它让两种数据源可以共用后面的水印过滤和列检测逻辑。6.3 质量评估怎么知道自己做对了解析完不等于解析对必须有一个可以量化的评估办法。我常用三个指标。第一是“阅读顺序还原率”随机抽 20 页人工看重排后的文本流统计连续语义片段正确拼接的数量占比。第二是“水印残留率”检测最终文本里是否还包含已知水印词比如“机密”“DRAFT”等出现次数除以总页数理想情况是接近 0。第三是“切块语义完整性”把重排文本切块后让检索系统跑一组预置问题人工判断召回内容里是否出现“一句话被硬生生截断”的块。我还会做一个更粗暴的端到端测试用同一个文档解析结果去做 RAG 问答分别对比解析前和解析后的答案质量。如果修复解析后答案的引用片段明显更完整就说明 bbox 这一层的功夫没有白花。这个测试看起来不严谨但对业务方最有说服力。7. 常见问题与排查实录这节把我在实战里遇到的高频问题整理成速查每个问题都附上我当时的排查过程和最终解决方案。7.1 表格、首行缩进和浮动块的误判表格是多栏检测的头号干扰项。一个三列宽的表可能会被误判成三栏但表格内部横向关系比列更重要。我的处理办法是在列检测之前先用 pdfplumber 或者 PyMuPDF 的find_tables()识别表格区域把表格 bbox 整体抠出来不参与列投影。等到文本重排完成之后再把表格内容按单独的结构追加到文本流里。这样既不会破坏正文的栏位逻辑也不会丢失表格语义。首行缩进也很坑。中文论文常见首行缩进两个字符但 bbox 计算的是词框缩进会让每一行的开头 x 坐标参差不齐。如果我的行合并逻辑只看 x0就会把缩进造成的小位移误当成另起一行。解决办法是放宽行合并阈值只看 y 坐标是否接近不要求 x0 严格单调。这点在build_text函数里已经体现。7.2 中文与英文混排中的 bbox 差异中英文混排是另一个容易翻车的场景。中文词检测依赖分词结果PyMuPDF 的get_text(words)对中文往往会把整句当作一个“词”这会导致 bbox 很大造成列投影和行合并的偏差。我刚碰到这个问题时也懵了为什么有些页面重排后列检测总是多出一栏查了半天才发现某段中文长句的 bbox 横跨到了旁边的栏把栏间空洞堵住了。解决办法有两个一是用get_text(dict)按 span 提取因为 span 是按字体和样式切分的中文 span 通常更合理二是对中文文本先不做词级处理直接按行级 bbox 走列检测。实践下来第二种更稳毕竟对中文来说行才是排版的基本单元。7.3 性能问题与缓存经验2000 篇 PDF 动辄几千页每页都跑 OCR 的话时间成本很吓人。我一开始没做缓存结果同一批文件反复解析了好几遍CPU 全被吃掉。后来我加了一层缓存以 PDF 文件的哈希值和页码为 key把解析出来的 clean text 存成 JSON。第二次解析时直接读取缓存只有当 PDF 文件变化时才重新跑。另外OCR 对低 DPI 图片的识别率会骤降但也别盲目上 300 DPI因为渲染时间和显存占用会成倍增长。我实测 150 DPI 对大多数扫描件已经够用只有字体小于小五号或者图片模糊的才需要提升到 200 到 300。处理时按页释放对象避免内存被几十张大图撑爆。8. 实操体会与扩展思路我在实际项目里最深的体会是RAG 的文档解析质量不取决于用了多强的模型而是取决于有没有认真对待 bbox 这类基础信息。很多团队把大量精力花在调 embedding 模型上却忽略了文本源头已经乱成一团。embedding 再强也救不了顺序错乱的字符流。关于后续扩展我认为这套 bbox 思路还能往几个方向深挖。第一结合版面模型把标题层级抽出来给切块提供结构化上下文让 RAG 的召回具备更细的段落边界。第二把水印过滤和“图像敏感区域”检测结合在解析时直接屏蔽含审核敏感信息的区域减轻下游合规压力。第三把多栏重排和表格识别整合成流水线算子封装成服务每次解析结果都输出一份带 bbox 的 JSON 文档方便调试和回放。最后再分享一个小技巧很多问题页面不要急着加复杂模型先用 bbox 画框把每页的真实版面看一遍。页面上一个不起眼的边注、一个跨栏表格、一段被手动旋转的文字都会让规则失效。你用视觉排查一次比读十次文档都管用。先把绝大多数常规页面稳定处理好再把异常页面列入慢速兜底通道这套工程思路比一上来就追求“万能解析”要务实得多。