
1. 为什么 bbox 能治多栏排版和水印 PDF 的“病”先交代一下背景。我在给 RAG 知识库做文档解析时最头疼的输入不是那种干净的单栏 PDF而是两类“刺头”一类是学术论文、财报、报纸截图导出的多栏排版 PDF一类是带各种水印的扫描件或网上下载的资料。这两类文件如果直接用 pdfplumber 或 PyMuPDF 按默认顺序抽取文本抽出来的结果基本没法看——多栏文件会把左栏和右栏的文字交错混在一起水印 PDF 会把“机密”“内部资料”“仅供学习”这种水印文字当成正文一并入库。等检索阶段一查命中的 chunk 可能是一段被拦腰截断的句子或者一条没头没尾的水印文本。整个 RAG 管线的召回质量直接从 90 分掉到 60 分而且你还很难定位问题出在哪一步。解决这个问题的关键就是标题里的 bboxbounding box边界框。说白了bbox 就是每个文字块、每个单词、每张图片在页面上的坐标位置通常用(x0, y0, x1, y1)四个值表示。有了坐标我们就可以回答三个灵魂问题这段文字在页面的什么位置它和旁边的文字是同一栏还是跨栏它到底属于正文、页眉、页脚还是水印这三个问题回答清楚多栏排版和水印 PDF 的解析问题就解决了一大半。这篇文章是我“RAG 文档解析”系列的第三篇前面的内容分别讲了整体解析管线的搭建和文本清洗这篇专门聚焦 bbox 实战。我会把多栏排版怎么分栏、水印怎么识别和剔除从思路到代码再到踩坑记录完整过一遍。适合正在搭 RAG 知识库、被文档解析质量折磨的工程师和数据爱好者参考哪怕你之前没接触过 bbox 概念跟着实操一遍也能上手。在继续往下读之前先建立一个基本认知文档解析本质上不是“读文字”而是“还原版式”。人类阅读纸质文档时眼睛会自动根据版面布局决定阅读顺序但机器没有这个能力。如果不在解析阶段显式地利用版面信息任何 PDF 抽取工具给出的都只是“文本流”而不是“人类理解的文本顺序”。bbox 的价值就是让我们能把机器看到的“文本流”重新组织成符合人类阅读习惯的“文本块序列”。2. 前置准备拿到可靠的 bbox 信息2.1 三种获取 bbox 的途径bbox 数据从哪来取决于 PDF 的类型。我实际工作中通常把它分成三种情况第一原生文本型 PDF。也就是文字还能被鼠标选中的 PDF。这种情况下不需要 OCR直接用 PyMuPDFfitz或者 pdfplumber 就能拿到每个字符、单词、行、块的精确坐标。PyMuPDF 的速度比 pdfplumber 快不少处理几百页的文档也不觉得卡我主力用 PyMuPDFpdfplumber 用来做交叉验证。第二扫描图片型 PDF。页面本质上是图片没有文本层必须先走 OCR。PaddleOCR 是我用得最多的工具它返回的识别结果里每一行都带一个四边形的四点坐标左上、右上、右下、左下转换成 bbox 就是x0 min(x坐标),y0 min(y坐标),x1 max(x坐标),y1 max(y坐标)。这一步是 OCR 的标配能力几乎所有主流 OCR 都支持。第三带文本层但排版混乱的 PDF。比如从某些网站导出的电子书有文本层但 Tagged PDF 结构缺失或者文本层的顺序是乱的。这种情况我一般先尝试直接用 PyMuPDF 抽取再用 bbox 重新排序。如果文本层本身就是乱序的比如一段话被拆成几十个碎片随机存放那就要考虑对页面渲染成图片再做 OCR用 OCR 结果里的 bbox 重建阅读顺序。我这里有一个执行顺序上的建议能不开 OCR 就不开 OCR。OCR 不仅慢还会引入识别错误而且对公式、代码、特殊字符的识别能力参差不齐。PDF 有原生文本层时直接抽坐标是又快又准的方案。但是要注意原生文本层的坐标也是“从 PDF 内部结构读取的坐标”并不保证是合理的阅读顺序所以即使有文本层分栏和水印过滤的逻辑依然需要。2.2 最小实战环境准备我这里用一个最小实验环境来演示避免依赖太多库导致大家复现困难pip install pymupdf pdfplumber paddleocr paddlepaddle scikit-learn numpy如果你的机器装 PaddlePaddle 比较麻烦可以用paddleocr的 CPU 版本识别速度会慢一点但处理少量调试页面完全够用。还有一个更轻量的替代方案是用rapidocr_onnxruntime它基于 ONNX Runtime不需要安装 PaddlePaddle 全家桶对 CPU 环境更友好我这里演示以 PaddleOCR 为主但思路在 RapidOCR 上完全一致。另外如果你处理的 PDF 本身是带文本层的那只需要pymupdf就够了OCR 相关的依赖可以不装。2.3 PyMuPDF 输出的 bbox 数据结构用 PyMuPDF 抽取单词级坐标非常简单import fitz doc fitz.open(sample.pdf) page doc[0] # 获取页面尺寸单位是 point1/72 英寸 page_width page.rect.width page_height page.rect.height # 抽取所有单词每个元素包含 # (x0, y0, x1, y1, word, block_no, line_no, word_no) words page.get_text(words) print(len(words)) print(words[:5])输出大概是这样的[(72.0, 106.5, 89.3, 119.2, Abstract, 0, 0, 0), (90.1, 106.5, 155.7, 119.2, —, 0, 0, 1), (156.5, 106.5, 178.9, 119.2, This, 0, 1, 0), ...]每个 tuple 的前四个值是 bbox第五个是单词文本最后三个是块号、行号、词号。这个数据对我们有用吗有用但 block_no 和 line_no 是 PyMuPDF 自己根据 PDF 内部结构算出来的在多栏排版时往往不符合人类阅读顺序。所以我们的原则是block_no、line_no 只能当辅助参考真正的排序依据是 bbox 坐标本身。再补充一个重要概念PyMuPDF 的坐标单位是 point和 PDF 内部坐标一致。如果你渲染成图片再 OCR得到的 bbox 单位是像素。两种单位不一致但逻辑一致换算关系是像素 point × 渲染分辨率 / 72。调试多栏排版时我建议统一用 point 坐标系这样不容易搞混。3. 多栏排版实战把两栏 PDF 还原成正确阅读顺序3.1 分栏的核心思路聚类而不是画中线多栏排版最大的问题是什么两栏文字从上到下、从左到右交叉排列。如果你直接按 y 坐标从上到下排序取文本你会得到左栏第一行、右栏第一行、左栏第二行、右栏第二行……读起来像两段文字被做了拉链拼接语义完全断裂。RAG 切块时如果正好切在这个“拉链区”检索效果会非常差。最常见的分栏做法是以页面宽度中线为界左边归左栏右边归右栏。这个方案对“严格两栏等宽排版”有效但遇到不等宽栏位、三栏混排、或者页面中有一个跨栏标题时就会翻车。比如论文首页经常是标题和作者信息横跨两栏然后正文才分成两栏如果一刀切按中线分标题文字会被切成左半句和右半句。所以我的做法是用聚类算法按文字块中心点的 x 坐标来分栏而不是机械地画中线。思路是这样的把页面里的所有文本块或单词收集起来计算每个文本块中心点的x_center (x0 x1) / 2。把这些中心点作为一维样本做聚类比如用 scikit-learn 的 KMeans簇数设为 2 或根据页面宽度自动判断。聚类完成后每个文本块会得到一个“栏标签”然后按“栏标签 y 坐标”排序输出。我用一个简化版的代码演示import numpy as np from sklearn.cluster import KMeans def group_columns(words, max_columns2): 根据单词的 x 中心点坐标做聚类给每个单词分配栏号。 words: PyMuPDF 的 get_text(words) 输出 返回words 栏号列表 if not words: return [], [] centers_x np.array([(w[0] w[2]) / 2.0 for w in words]).reshape(-1, 1) # 如果所有词都挤在页面一小块区域直接判定为单栏 x_std centers_x.std() if x_std 50: # 阈值需要根据实际情况调整 labels [0] * len(words) return words, labels n_clusters min(max_columns, len(set(np.round(centers_x.flatten() / 20)))) if n_clusters 2: labels [0] * len(words) return words, labels km KMeans(n_clustersn_clusters, n_init10, random_state42) labels km.fit_predict(centers_x) # 把栏号按左侧坐标排序保证左边是第0栏右边是第1栏 cluster_x0 {} for idx, label in enumerate(labels): cluster_x0.setdefault(label, []).append(words[idx][0]) sorted_labels sorted(cluster_x0.keys(), keylambda lbl: min(cluster_x0[lbl])) label_map {old: new for new, old in enumerate(sorted_labels)} labels [label_map[l] for l in labels] return words, labels然后按栏分组、组内按 y 排序def reorder_by_columns(words, labels): columns {} for w, label in zip(words, labels): columns.setdefault(label, []).append(w) ordered [] for label in sorted(columns.keys()): col_words sorted(columns[label], keylambda w: (w[1], w[0])) # 按行聚合y 坐标相近的视为同一行 lines merge_words_into_lines(col_words) ordered.extend(lines) return orderedmerge_words_into_lines的作用是把同一行的单词合并成句子不能直接按单词排序输出否则一个句子被单词级拆散。合并逻辑是基于 y 坐标的相近性如果两个单词的 y 中心点差小于某个阈值比如 12pt就认为是同一行。3.2 跨栏标题和特殊元素的处理聚类分栏解决了大部分问题但还有一个隐藏陷阱跨栏元素。论文首页的标题、作者列表、摘要有时是跨栏的有时摘要本身又是单栏的。如果这些元素被聚类进了某一个栏那就会导致栏内文本顺序错乱。我的处理方式是在分栏前先做一次“跨栏判断”把横跨大半页面的文本块单独拿出来def is_spanning_block(w0, w1, page_width): 判断一个文本块是否横跨页面的大部分宽度 span_ratio (w1 - w0) / page_width return span_ratio 0.6如果文本块的宽度占页面宽度 60% 以上就认为它是跨栏标题或跨栏段落不参与聚类直接作为独立区块放在整页文本的最前面因为跨栏标题通常在正文之前。这个 60% 的阈值不是我拍脑袋定的而是测试了一批论文 PDF 后得到的经验值。你可以根据自己的文档类型调整。关键点是先识别跨栏块再对剩余块做聚类顺序不能反。如果先聚类再识别标题已经被拆散分进两个栏了后面就很难再还原。3.3 多栏顺序还原的校验方法排完序之后怎么知道对不对靠肉眼翻页看太慢我一般用两个快捷的校验手段。第一个是输出顺序审查把重排后的文本按行打印出来每行前面打上行号随机抽几页查看。重点看栏目衔接处——左栏最后一行是否完整右栏第一行是否应该是下一句。如果出现明显的“断句”现象说明栏内排序或行合并逻辑有问题。第二个是嵌入前验证在处理好的文本上先跑一遍检索测试。拿几个典型问题去查看命中的 chunk 是否语义完整。多栏解析做得好的话检索质量不会比单栏 PDF 差太多做得不好往往会召回一些“前言不搭后语”的文本。我强烈建议你在正式批量解析前先把一套“校验样本”确定下来包含至少 5 种不同类型的多栏 PDF论文首页、财报两栏、报纸式多栏、含跨栏标题的页面、含表格的页面。每次改动解析逻辑先跑校验样本肉眼确认输出质量再放量处理。这套方法帮我避免了好几次“改一处、炸一片”的事故。4. 水印 PDF 实战识别、定位、剔除4.1 水印的形态分类水印是 RAG 文档解析里最容易“看着不起眼、实际很致命”的问题。如果水印文本进了知识库轻则浪费 embedding 的存储空间重则导致检索结果返回无意义文本用户一问问题召回的水印“机密”两个字体验非常糟。我接触过的水印场景大致分四类水印类型典型特征对解析的影响重复文字水印“机密”“禁止外传”“样例”等字样在页面上大量重复出现直接混入正文文本流污染 chunk半透明图形水印图片形式透明度高铺满整页或斜向分布视觉有干扰但 OCR 一般不会把它识别为文本页眉页脚式水印“第 X 页 / 共 Y 页”“公司内部文件”固定在页面顶部或底部每个 chunk 都可能重复包含稀释有效信息斜向大字水印文字沿对角线排列字号大、颜色浅与正文文字区域重叠OCR 可能把水印文字和正文混在一起识别处理水印的核心原则和分栏一样不能用“是否在文本流里”判断一段文字是不是水印要用布局特征判断。bbox 恰恰就是布局特征的量化表示。4.2 基于 bbox 特征的规则过滤方案我实战中用的水印识别方案是一个“规则组合”不依赖机器学习模型解释性和可控性都很好。对每个文本块我计算下面这些特征重复性同一页面中是否有大量文本块的文本内容完全一致。如果某个字符串在页面上出现 3 次以上而且都分布在不同的 bbox 位置基本可以断定是水印。位置分布水印文字块通常分散在页面各个区域但和正文不同它的位置与阅读顺序无关经常落在页面的中部或偏离常规正文区域。可以统计文本块中心点坐标的分布做一个简单的一维聚类如果某个“位置簇”里的文本块内容高度重复判定为水印。面积占比水印文字块常为“大面积稀疏文本”即 bbox 很宽但包含的字很少。比如斜向水印每个字的 bbox 跨度可能很大但实际文本只有两三个字。旋转特征PyMuPDF 的page.get_text(dict)或rawdict里可以拿到字符的旋转信息。水印文字经常有 45 度或 90 度的旋转正文文字的旋转角度通常为 0。颜色特征PDF 原生文本的dict结构里有 color 信息。水印文字经常是浅灰色灰度值接近 0.7~0.9而正文是黑色或深色。这里给一段基于 PyMuPDF 原始 dict 数据抽取颜色和旋转信息的示例def extract_block_info(page): data page.get_text(dict) blocks_info [] for block in data[blocks]: if lines not in block: continue for line in block[lines]: for span in line[spans]: bbox (span[bbox][0], span[bbox][1], span[bbox][2], span[bbox][3]) blocks_info.append({ text: span[text], bbox: bbox, color: span[color], # 十进制 RGB 值 size: span[size], font: span[font], # 旋转角度需要从 rawdict 或 CharFlags 里提取不同版本 API 有差异 }) return blocks_infospan[color]是一个十进制整数需要转成 RGB。比如 0x999999 是浅灰色换算方式是color 0xFFFFFF再拆出 R、G、B。实战中我主要看灰度是否偏高RGB 且数值大于 150 左右。再结合文本的重复性判断from collections import Counter def detect_repeated_watermark(blocks_info, min_repeat3): text_list [b[text].strip() for b in blocks_info if b[text].strip()] counter Counter(text_list) repeated_texts {t for t, c in counter.items() if c min_repeat} watermark_blocks [ b for b in blocks_info if b[text].strip() in repeated_texts ] return watermark_blocks这个规则简单有效尤其对付“机密”“内部资料”这类整页重复的水印。但要注意不要一看到重复就删正文里完全可能有合法的重复内容比如“目录”两个字在每章标题前都会出现。所以需要结合其他特征交叉验证。4.3 与 OCR 文本置信度结合的水印过滤扫描型 PDF 的情况和原生文本型不一样OCR 输出的每行不仅带 bbox还带置信度。水印文字因为和背景对比度低、可能被正文遮住一部分OCR 置信度普遍偏低。我在处理扫描件水印时会把置信度作为一个重要的过滤维度。具体做法是OCR 完成后把识别结果按 bbox 还原到页面上统计每个文本行的置信度分布。如果某一行和其他行相比置信度明显偏低比如低于 0.7而页面上其他行都在 0.9 以上并且该行的内容与页面上其他行有重复就把它标记为疑似水印。不过这里有个坑正文中有些元素本身 OCR 置信度就低比如公式、带底色块上的文字、印章盖住的字。如果把“低置信度”一刀切过滤会误删正文。我的策略是水印文本必须同时满足“低置信度”和“高重复性”才删除只满足一个条件就先保留留到人工审核阶段再处理。还有一类特殊情况整页都是水印图片也就是 PDF 的每一页底面都覆盖了一张半透明的水印图而正文是后印上去的文字。这种情况在 PDF 结构层里有明确体现页面上有图片对象且图片的 bbox 覆盖页面大部分区域图片之上才是文字。PyMuPDF 可以直接读取页面图片的 bboxdef get_image_bboxes(page): image_rects [] for img in page.get_images(fullTrue): # 获取图片在页面上的放置位置 rects page.get_image_rects(img[0]) image_rects.extend([(round(r.x0, 1), round(r.y0, 1), round(r.x1, 1), round(r.y1, 1)) for r in rects]) return image_rects如果某张图片的 bbox 与页面尺寸相当比如覆盖超过 80% 页面同时页面上还有正常的文字块那这张图基本就是背景水印图。它对文本抽取没有影响但对图片解析管线可能有影响——如果你后续要把页面图片也纳入多模态 RAG需要把这类背景水印图排除掉不能让它作为独立图片进知识库。注意这里说的“剔除水印文本”是针对 RAG 解析流程里的文本过滤不是要把 PDF 视觉上的水印删掉那属于 PDF 编辑和去水印软件的工作范畴。我们是做知识库整理目标是让入库内容干净不需要改原文件。5. 常见问题与排查技巧实录5.1 表格跨栏被切成两半怎么办多栏论文里经常出现“表格横跨两栏”的情况比如一个宽表格宽度超过了单栏范围排版时被放在页面顶部或底部跨栏显示。聚类分栏时这种跨栏表格块因为宽度大、中心点在页面中央可能会形成一个独立的簇也可能被误分到某一栏里。无论哪种情况表格内容被夹在正文文本流中RAG 检索时会把表格碎片和正文混在一起。我的处理方案分两步第一步先用规则识别表格区域。PyMuPDF 的page.find_tables()新版本里叫page.find_tables()能返回表格的 bbox 和单元格结构。pdfplumber 也提供类似能力。识别出来后可以把表格 bbox 从“分栏聚类”的样本里排除掉让表格单独处理。第二步表格内容单独抽取、单独入库。表格按 bbox 落到“靠近页面顶部”或“靠近页面底部”时我会把它作为独立的 block 追加到该页文本流的开始或末尾而不是按坐标插到正文中间。这样虽然牺牲了“绝对位置还原度”但保住了表格数据的完整性RAG 检索表格内容时效果好很多。5.2 扫描件上的水印误删正文刚才提到低置信度规则这里展开讲一个我踩过的坑。有一批扫描 PDF页面左上角有浅的“扫描专用”水印同时正文中有一段“专用术语”也被 OCR 识别得很差置信度同样很低。我第一版过滤规则把所有低置信度且重复出现的文本都删了结果把正文里的术语表删掉了一部分。后来排查发现被误删的文本块虽然置信度低但它们的位置和上下文关系是连续的前一块讲的是 A 内容后一块讲的是 A 内容的延伸中间被删的块刚好是衔接部分。于是我改进规则只有同时满足“重复出现”“位置分散”“上下文不连续”三个条件才判定为水印。正文中的术语即使置信度低它在阅读顺序中的上下文是连贯的不会同时满足位置分散和上下文不连续。5.3 页眉页脚和页码干扰严格来说页眉页脚不算水印但处理逻辑很接近。RAG 解析时页码和重复的页眉会污染 chunk 一致性。比如“第 3 页 / 共 120 页”这种字符串出现在每个 chunk 里检索时既占 token 又没信息量。我用 bbox 直接做一刀切定义页眉区为页面顶部 10% 高度范围页脚区为底部 8% 高度范围。凡是 bbox 完全落在这些区域内的文本块直接过滤。这个规则简单粗暴但有个反例有的论文页眉里包含了章节标题这对检索其实有正面价值。所以我增加了白名单机制——保留包含“关键词/摘要/引言/方法/结论”等章节关键词的页眉过滤纯重复的页眉。具体阈值上顶部 10% 底部 8% 是我处理 A4 学术 PDF 的经验值。如果你的文档有比较宽的页边距需要根据实际渲染效果调整。我的建议是先对几个典型页面打印 bbox 坐标分布看页眉页脚到底落在哪个区间再定阈值。5.4 代码环境层面的经典问题处理几百个 PDF 时PaddleOCR 偶尔会崩常见原因有三个一是显存不足。如果批量 OCR 扫描 PDF建议单张图长边限制在 2000 像素以内既能保证识别精度又不会让显存爆炸。如果机器性能差可以加--use_fp16或者干脆换成 RapidOCR。二是坐标单位不统一。PaddleOCR 返回的坐标是图像像素坐标而 PyMuPDF 返回的是 point 坐标。同一套代码里同时处理原生 PDF 和扫描 PDF 时一定要先做一个单位统一函数把所有坐标转成同一个坐标系再进入后续的逻辑否则分栏和水印判断会全部错乱。三是多线程处理 PDF 时OCR 引擎的并发问题。PaddleOCR 的predict方法线程不安全我用的是进程池而不是线程池每个进程单独初始化一个 OCR 引擎。实测下来进程数为 CPU 核心数的一半时速度和稳定性最均衡。5.5 效果评估的量化方法最后分享一个评估解析效果的小工具。我手动整理了一批“标准答案”拿 20 个多栏页面手工标出每一块的阅读顺序然后写脚本比较算法输出顺序和标准答案的顺序一致性。指标用简单的“逆序对比例”——计算算法输出中的相邻块关系与标准答案的差异逆序对越少越好。这个指标虽然粗糙但足够用来做回归测试。每次改进解析逻辑就跑一遍这 20 个页面看逆序对比例是否下降。如果下降了才继续测试更大规模的 batch。这样做的好处是避免“感觉修好了其实只是换了另一种错误方式”的陷阱。在实际项目中我用这个评估方法调了三轮参数第一轮把聚类距离阈值从 50 调到了 80第二轮给跨栏表格加了预识别第三轮给页眉加了白名单机制。每轮调整都有量化指标支撑而不是靠感觉拍板。6. 一点个人经验这套 bbox 处理流程我在本地 RAG 知识库项目上反复打磨过。最大的感悟是文档解析没有银弹规则越多越要控制在可解释范围内。不要一上来就堆深度学习版面分析模型先从 bbox 规则入手把 80% 的常见问题解决掉剩下的边角情况再考虑模型。给读者一个实操顺序建议第一步把你手头所有 PDF 按“原生文本型”和“扫描型”分类第二步只对原生文本型做 PyMuPDF 坐标抽取先跑通多栏排序第三步再处理扫描型的 OCR 水印过滤。每一步都拿 5 个典型页面做肉眼验证确认没问题再扩大批量。最后再分享一个调试技巧解析阶段把 bbox 信息可视化输出Windows 上可以直接用 PyMuPDF 把 bbox 画到页面上生成标注图看一眼哪里分栏错了、哪里水印没删干净一目了然。我刚入门时靠这个可视化方法省了大量时间——很多问题看文字日志死活找不到原因一看标注图就明白了。后续这个系列我可能还会写一篇关于表格解析和版面模型选型的实战对比如果你现在手头有更刁钻的文档类型卡住了也欢迎在评论区分享你的案例我可以拿真实的项目数据继续做测试。