☰
PDF多栏排版与水印解析:基于bbox和XY-cut的RAG文档处理实战
2026/10/6 14:33:18 网站建设 项目流程

1. 为什么多栏排版和水印是 PDF 解析里最难啃的两块骨头

做 RAG 的人迟早会撞上同一堵墙:纯文本抽取跑得挺欢,一换到真实业务文档就原形毕露。尤其是学术论文、产品手册、合同扫描件这三类,几乎清一色是双栏甚至三栏排版,页面上还压着"内部资料""禁止外传"之类的半透明水印。你拿page.get_text()直接一抽,出来的文字顺序能把人看哭——左栏第一段接右栏第一段,再接左栏第二段,读起来像被人剪碎了重新粘的。

这不是工具不行,而是 PDF 本身的组织方式决定的。PDF 里根本没有"栏"这个概念,它只认字符的坐标。一个字符画在哪,它就记在哪,至于这些字符在语义上属于哪一栏、哪一段,PDF 格式压根不关心。所以解析器的任务,本质上是从一堆散落的坐标点里,把人类阅读时默认的"从上到下、从左到右、先读完一栏再读下一栏"这个顺序给反推出来。这个反推过程,就是版面分析(Layout Analysis)。

水印则是另一个维度的麻烦。它通常以两种形态存在:一种是直接画在页面内容流里的文字或图形,和正文混在一起;另一种是作为独立的 XObject 或注释层叠加上去。前者会让你的文本里凭空多出一堆重复的"机密"字样,后者虽然不污染文本流,但会干扰基于视觉的版面分析——因为水印的 bbox 可能横跨整个页面,把正常的栏切分逻辑搅乱。

这篇要聊的,就是怎么用 bbox(bounding box,边界框)这个最基础也最可靠的工具,把多栏排版和水印这两个问题一起收拾掉。核心工具是 PyMuPDF,配合 XY-cut 算法做递归切分。整套思路不依赖任何深度学习模型,纯几何计算,跑起来快、可解释性强、调参直观,特别适合对延迟敏感或者不想引入重型依赖的 RAG 文档解析管线。

如果你正在搭 RAG 知识库,手头的 PDF 又恰好是论文、报告、手册这类"排版讲究"的文档,那这篇的内容基本能覆盖你 80% 的解析需求。下面从 bbox 的获取开始,一步步把多栏切分和水印过滤的完整链路拆开讲。

2. 用 PyMuPDF 拿到可靠的 bbox:从字符到行到块

2.1 三种粒度的 bbox 及其适用场景

PyMuPDF 提供了多个层级的文本提取接口,每个层级返回的 bbox 含义不同,用错了地方就会事倍功半。

最细的是字符级,通过page.get_text("rawdict")拿到,每个字符都有精确的bbox,格式是(x0, y0, x1, y1),分别代表左上角和右下角的坐标。字符级 bbox 最精确,但数量巨大,一页论文动辄几千个字符,直接拿来做版面分析计算量偏大。

中间层是行级,page.get_text("dict")返回的结构里,每个 block 包含若干 line,每个 line 有自己的 bbox 和 spans。行级 bbox 是版面分析最常用的粒度,因为它天然对应了"一行文字"这个阅读单元,数量适中,一页通常几十到一百多行。

最粗的是块级,同样从"dict"里拿,block 的 bbox 覆盖一个段落或一个表格区域。块级 bbox 适合做粗粒度的区域划分,但 PyMuPDF 的块划分有时候会把相邻的两栏文字合并成一个块,所以不能完全信任。

我的经验是:做栏切分用行级 bbox,做水印检测用字符级或行级,做区域归类用块级。三者配合使用,而不是死磕某一个。

import fitz # PyMuPDF doc = fitz.open("paper.pdf") page = doc[0] # 行级 bbox text_dict = page.get_text("dict") lines = [] for block in text_dict["blocks"]: if block.get("type") != 0: # 0 表示文本块,1 表示图像块 continue for line in block["lines"]: lines.append({ "bbox": line["bbox"], "text": "".join(span["text"] for span in line["spans"]), "spans": line["spans"] }) print(f"本页共提取到 {len(lines)} 行")

2.2 坐标系陷阱:原点在左上角还是左下角

这是新手最容易翻车的地方。PDF 规范里,页面的默认坐标系原点在左下角,y 轴向上增长。但 PyMuPDF 为了符合大多数人的直觉,把返回的坐标转换成了左上角原点、y 轴向下增长。也就是说,y0越小,位置越靠上。

这个转换本身是好事,但你如果同时用了其他库(比如 pdfplumber 默认也是左上角,但某些底层接口可能返回原始坐标),混着用就会乱套。我踩过一次坑:用 PyMuPDF 拿 bbox,用另一个库做可视化,结果框全画反了,排查了半天才发现是坐标系原点不一致。

提示:统一以 PyMuPDF 的左上角原点坐标系为准,所有几何计算都在这个坐标系里做。如果要从别的库导入坐标,先确认它的原点位置,必要时做y' = page_height - y的翻转。

2.3 页面旋转与缩放对 bbox 的影响

还有一个隐蔽的坑:页面可能带有/Rotate属性。比如扫描件经常被旋转 90 度存储,PyMuPDF 在page.rect里反映的是旋转后的尺寸,但get_text返回的 bbox 是否已经考虑了旋转,取决于你用的接口版本和参数。

实测下来,PyMuPDF 较新版本(1.23+)的get_text("dict")返回的 bbox 已经是在旋转后的坐标系里,和page.rect一致。但如果你用的是get_text("words")或者更老的接口,可能拿到的是未旋转的原始坐标。稳妥的做法是:在解析前先检查page.rotation,如果不为 0,要么用page.set_rotation(0)归一化,要么在后续计算里手动做坐标变换。

缩放的问题主要出现在你把页面渲染成图片做可视化调试时。page.get_pixmap(matrix=fitz.Matrix(2, 2))会把页面放大两倍,此时图片上的像素坐标是 bbox 坐标的两倍。画框的时候记得同步缩放,否则框会偏到姥姥家。

3. XY-cut 递归切分:把散落的行重新组织成阅读顺序

3.1 XY-cut 的核心思想:投影找空白

XY-cut 是个老算法,上世纪 80 年代就有了,但放到今天依然好用,因为它简单、可解释、无需训练。核心思想就一句话:在页面上找横向或纵向的空白带,沿着空白带把页面切开,递归处理每个子区域。

具体来说,算法交替执行两个操作:

  • 水平切分(X-cut):把所有行的 bbox 投影到 x 轴上,找那些没有任何行覆盖的 x 区间,这些区间就是纵向空白带。沿着这些空白带把页面切成左右几块,对应不同的栏。
  • 垂直切分(Y-cut):在某个区域内,把所有行的 bbox 投影到 y 轴上,找没有行覆盖的 y 区间,这些是横向空白带。沿着它们把区域切成上下几块,对应不同的段落或章节。

递归执行,直到某个区域内的行数少于阈值,或者再也找不到足够宽的空白带为止。最后按切分出来的区域顺序输出文本,就得到了符合人类阅读习惯的顺序。

3.2 投影间隙的阈值怎么定

阈值是 XY-cut 唯一的调参点,也是最影响效果的地方。间隙太小,正文里正常的字间距、词间距会被误判成栏间空白,导致一行被切成两半;间隙太大,真正的栏间空白又检测不到,双栏还是被当成一栏处理。

我的经验值是:纵向切分(找栏)的间隙阈值设为页面宽度的 3% 到 5%,横向切分(找段落)的间隙阈值设为行高的 1.5 到 2 倍。以 A4 纸为例,宽度约 595 磅,3% 就是约 18 磅,这个宽度足以区分栏间距(通常 20-30 磅)和正常词间距(通常 3-5 磅)。

但这个值不是死的。有些论文栏间距很窄,只有 15 磅左右,这时候 5% 就太大了。稳妥的做法是动态计算:先统计所有相邻行之间的水平间隙分布,取一个明显的峰值作为词间距,然后阈值设为词间距的 3 到 5 倍。这样能自适应不同排版的文档。

def find_x_gaps(lines, page_width, min_gap_ratio=0.03): """找纵向空白带(用于分栏)""" min_gap = page_width * min_gap_ratio # 收集所有行的 x 区间 intervals = sorted([(l["bbox"][0], l["bbox"][2]) for l in lines]) gaps = [] cur_end = intervals[0][1] for x0, x1 in intervals[1:]: if x0 - cur_end > min_gap: gaps.append((cur_end, x0)) cur_end = max(cur_end, x1) return gaps

3.3 递归终止条件与阅读顺序拼接

递归不能无限进行下去,否则会把每个字都切成独立区域。终止条件通常设两个:一是区域内行数少于某个阈值(比如 3 行),二是找不到宽度超过阈值的空白带。满足任一条件就停止切分,把当前区域作为一个叶子节点。

切分顺序决定了最终的阅读顺序。对于 X-cut(分栏),从左到右处理;对于 Y-cut(分段),从上到下处理。递归树的中序遍历结果,就是正确的阅读顺序。

这里有个细节:先做 X-cut 还是先做 Y-cut。标准 XY-cut 是交替进行的,但实际文档里,栏的划分通常比段落划分更"硬"——栏间空白往往贯穿整个页面高度,而段落间的空白只在小范围内存在。所以我的做法是优先做 X-cut,先把栏分清楚,再在每个栏内部做 Y-cut 分段。这样能避免跨栏的段落被错误合并。

3.4 处理跨栏元素:标题、图表、脚注

纯 XY-cut 有个软肋:跨栏元素。论文的标题、大图、宽表格经常横跨两栏,它们的 bbox 宽度接近页面宽度。如果直接参与投影,会把栏间空白"填满",导致 X-cut 找不到切分点。

解决办法是在做 X-cut 之前,先把这些跨栏元素识别出来单独处理。识别方法很简单:如果一个行的 bbox 宽度超过页面宽度的 70%,就认为它是跨栏元素。把这些行先摘出来,剩下的行再做 XY-cut。最后按 y 坐标把跨栏元素插回正确位置。

脚注是另一个特殊情况。它们通常在页面底部,用一条短横线和正文隔开。XY-cut 可能会把脚注和正文最后一段合并。处理办法是检测页面底部 15% 区域内的行,如果它们和上方正文之间有明显的横向空白带,就单独归为脚注区域,不参与正文的阅读顺序拼接。

4. 水印识别与过滤:从文本重复度和 bbox 特征入手

4.1 水印的两种存在形态及检测思路

前面提过,水印分两种。第一种是内容流水印,直接作为文字画在页面里,和正文混在一起。这种水印的文本会出现在get_text的结果里,特征是:同一段文字在多个页面重复出现,或者在同一页面以不同角度、不同位置重复出现。

第二种是注释层或 XObject 水印,它不在正文文本流里,get_text默认拿不到。这种水印不污染文本,但会出现在渲染后的图像里,干扰基于视觉的版面分析。检测它需要遍历页面的注释列表(page.annots())或者检查 XObject 列表。

对于 RAG 场景,我们主要关心第一种,因为它直接影响抽取出来的文本质量。第二种如果只是视觉干扰,不影响文本,可以暂时不管;但如果它导致版面分析出错(比如水印的 bbox 横跨页面,被误判为跨栏元素),就需要处理。

4.2 基于文本重复度的水印过滤

最直接的水印过滤方法是统计文本重复度。把文档所有页面的文本行收集起来,统计每个文本出现的频率。如果某段文本在超过 30% 的页面上都出现,且位置不固定(或者固定在某个角落),那它大概率是水印。

但这个方法有个前提:你得先把整个文档解析一遍才能统计。对于流式处理或者单页处理的场景不适用。这时候可以用单页内的重复度:如果同一段文本在同一页面出现两次以上,且其中至少一次的 bbox 角度不是 0(旋转过),那它很可能是水印。

def detect_watermark_by_repeat(lines, page_count_threshold=0.3): """基于跨页重复度检测水印""" from collections import Counter text_counter = Counter() for line in lines: text_counter[line["text"].strip()] += 1 total_pages = len(set(l["page"] for l in lines)) watermarks = set() for text, count in text_counter.items(): if count / total_pages > page_count_threshold and len(text) < 20: watermarks.add(text) return watermarks

4.3 基于 bbox 几何特征的水印识别

光靠文本重复度不够,因为有些水印文字是动态的(比如带日期的"2024-01-01 机密"),每次都不一样。这时候要看 bbox 的几何特征。

水印的 bbox 通常有几个特点:角度非零(旋转过)、位置居中或覆盖大面积、字号明显大于或小于正文、颜色浅(虽然 PyMuPDF 的文本提取拿不到颜色,但可以通过 span 的 flags 或 color 字段判断)。

其中角度是最可靠的信号。正常正文的行角度基本都是 0(水平),而水印经常旋转 30 度、45 度。PyMuPDF 的 line 结构里有dir字段,表示文字方向向量(cos, sin)。如果dir不是(1, 0)或接近它,就说明这行是旋转的。

import math def is_rotated(line, tolerance=0.1): """判断一行文字是否旋转""" if "dir" not in line: return False dx, dy = line["dir"] angle = math.degrees(math.atan2(dy, dx)) return abs(angle) > tolerance and abs(abs(angle) - 180) > tolerance

把旋转的行单独拎出来,如果它们的文本内容符合水印特征(短、重复、含"机密""内部"等词),就可以安全过滤掉。

4.4 过滤水印后如何验证没有误伤正文

过滤水印最大的风险是误伤。有些正文里的数学公式、化学结构式、特殊符号也可能是旋转的,或者字号异常。一刀切地过滤所有旋转文本,会把公式也干掉。

我的做法是双重验证:先按几何特征筛出候选水印,再按文本特征确认。只有同时满足"旋转或位置异常"和"文本重复或含敏感词"两个条件的,才判定为水印。这样能大幅降低误伤率。

验证阶段,我会把过滤前后的文本都导出来,人工抽查几页。重点看:公式是否完整、图表标题是否还在、页眉页脚是否被误删。如果发现误伤,就调整阈值,或者把某些文本加入白名单。

注意:水印过滤宁可漏过,不可误杀。漏掉一个水印,最多是文本里多几个噪声词,对 RAG 检索影响有限;误杀一段正文,可能直接导致关键信息丢失,检索时永远找不到。所以阈值要偏保守。

5. 把 bbox 解析结果喂给 RAG:分块策略与元数据设计

5.1 按版面区域分块,而不是按固定字数

很多人做 RAG 分块,习惯按固定字数切,比如每 500 字一块。这在纯文本上还行,但在 PDF 上就是灾难——它会把一个完整的段落从中间切断,或者把两个不相关的栏的内容拼在一起。

正确的做法是按版面区域分块。XY-cut 切出来的每个叶子区域,天然就是一个语义单元:可能是一个段落、一个标题、一个表格区域。以这些区域为基本单位做分块,能保证每块内容的语义完整性。

如果某个区域太大(比如一个长段落超过 1000 字),可以在区域内部按句子边界二次切分。如果某个区域太小(比如一个孤立的标题),可以和相邻区域合并。这样切出来的块,既不会太碎,也不会太长。

5.2 给每个块打上版面元数据

bbox 解析的另一个价值,是能给出每个块的版面元数据。这些元数据在检索时非常有用:

元数据字段含义检索时的用途
page_num所在页码定位原文,支持"跳到第 N 页"
bbox边界框坐标高亮显示原文位置
block_type块类型(正文/标题/表格/图注)按类型过滤,比如只检索正文
column所在栏号处理跨栏引用
font_size主要字号判断标题层级
is_watermark是否水印过滤噪声

有了这些元数据,检索时就能做更精细的控制。比如用户问"论文里关于注意力的公式在哪",你可以优先返回block_type为公式的块;用户问"第三章讲了什么",你可以按font_size和block_type定位到章节标题,再返回其后的正文块。

5.3 多栏文档的阅读顺序对检索质量的影响

阅读顺序错了,检索质量会断崖式下跌。原因很简单:RAG 的检索是基于语义相似度的,如果一段话被拆得七零八落、顺序错乱,它的语义向量就会偏离原意,检索时匹配不上。

我做过对比测试:同一篇双栏论文,用get_text()直接抽取(顺序错乱)和用 XY-cut 重排后抽取,在同一个检索任务上,后者的召回率高出一大截。尤其是涉及跨栏引用的内容,比如"如图 3 所示"后面跟着的图注在另一栏,顺序错了就完全对不上。

所以多栏文档的解析,阅读顺序重排不是锦上添花,而是必做项。XY-cut 虽然简单,但在这个任务上足够可靠。

6. 实测中遇到的几个坑和应对办法

6.1 表格区域被 XY-cut 切碎

表格是 XY-cut 的天敌。表格内部有大量的横向和纵向空白带,XY-cut 会把一个完整的表格切成几十个小格子,每个格子单独成块,阅读顺序完全乱掉。

应对办法是在 XY-cut 之前先做表格检测。PyMuPDF 有page.find_tables()接口,能返回表格的 bbox。把这些区域标记出来,XY-cut 时跳过它们,表格整体作为一个块处理。表格内部的文本提取用table.extract(),能拿到结构化的行列数据。

如果find_tables()没检测出来(有些无线表格它认不出),可以退而求其次:检测那些行数多、列对齐明显的区域,手动标记为表格候选。

6.2 页眉页脚干扰栏切分

页眉页脚通常横跨整个页面宽度,如果参与 X-cut,会把栏间空白填满,导致分栏失败。处理办法是在 XY-cut 之前,先把页面顶部 8% 和底部 8% 区域内的行摘出来,单独判断是否为页眉页脚。

判断依据是:这些行是否在多个页面重复出现,或者是否包含页码、日期、文档标题等特征。确认是页眉页脚的,直接过滤掉,不参与正文解析。

6.3 扫描件没有文本层怎么办

前面讲的都是基于文本层 bbox 的方法。如果 PDF 是扫描件,根本没有文本层,get_text()返回空,那这套方法就用不了。

这时候需要先做 OCR。OCR 之后,每个识别出来的文本块也会带 bbox,后续的 XY-cut 和水印过滤逻辑可以复用。但 OCR 的 bbox 精度通常不如原生文本层,阈值需要调大一些,容错空间要留足。

OCR 工具的选择上,如果追求精度可以用 PaddleOCR 或 Tesseract,如果追求速度可以用轻量级模型。关键是 OCR 输出的 bbox 格式要统一成(x0, y0, x1, y1),方便后续处理。

6.4 性能优化:大文档怎么跑得快

一篇几百页的论文,逐页做 XY-cut 和水印检测,如果实现得不好,可能要跑几分钟。优化点有几个:

  • 并行处理:PyMuPDF 的页面解析是独立的,可以用多进程并行。注意每个进程要独立打开文档,不要共享fitz.Document对象。
  • 提前终止:水印检测如果已经确认了水印文本,后续页面直接按文本匹配过滤,不用重复做几何分析。
  • 缓存中间结果:行级 bbox 提取一次就够,后续的 XY-cut 和水印检测都复用这份数据,不要重复调用get_text。
  • 降低精度:如果不需要字符级精度,用行级 bbox 就够了,能省不少内存和计算。

实测下来,一篇 200 页的双栏论文,用多进程并行,整体解析时间能压到 10 秒以内,完全能满足 RAG 离线索引的需求。

7. 几个我踩过的具体坑和排查过程

7.1 栏间空白被公式撑满导致分栏失败

有一次解析一篇数学论文,XY-cut 死活分不出栏。排查发现,论文里有个跨栏的公式,它的 bbox 宽度接近页面宽度,把栏间空白填满了。但公式本身不是文本行,get_text("dict")里它可能被拆成多个 span,每个 span 的 bbox 不宽,但合起来就宽了。

解决办法是在做 X-cut 之前,先把同一个 block 内的所有 span 合并成一个整体 bbox,再判断是否跨栏。如果跨栏,就单独摘出来。这样公式就不会干扰分栏了。

7.2 水印文字和正文用同一字体导致误判

还有一次,文档的水印用的是和正文一样的字体、一样的字号,只是颜色浅、旋转了 45 度。我一开始只按文本重复度过滤,结果水印文字"内部资料"在正文里也出现过(作为标题的一部分),导致正文被误删。

后来改成双重验证:既要求旋转,又要求文本完全匹配水印词表。这样正文里的"内部资料"因为没旋转,就不会被误删。这个教训是:单一特征不可靠,多特征交叉验证才稳。

7.3 递归深度过大导致栈溢出

XY-cut 是递归实现的,如果页面元素特别碎(比如一个复杂的表格),递归深度可能很大,Python 默认递归限制是 1000 层,极端情况下会栈溢出。

解决办法是加一个最大递归深度限制,比如 20 层。超过就停止切分,把当前区域整体作为一个块。实际文档里,超过 20 层的切分基本没有意义,因为再切下去每个块就只剩一两个字了。

8. 写在最后的一点个人体会

这套 bbox + XY-cut + 水印过滤的方案,我在好几个 RAG 项目里用过,处理论文、手册、合同都挺稳。它最大的好处是可解释——每个块为什么这么切、为什么被过滤,都能追溯到具体的 bbox 和阈值,出了问题好排查。相比之下,纯深度学习方案虽然在某些场景下精度更高,但调参像开盲盒,出了问题很难定位。

如果你刚开始做 PDF 解析,我的建议是先把这套几何方法跑通,它能覆盖大部分规整排版的文档。等遇到几何方法搞不定的场景(比如手写体、复杂图表混排),再考虑引入模型。不要一上来就上重型方案,那样调试成本太高。

另外,解析结果一定要做可视化验证。把 bbox 画到页面上,看看切分是否符合预期,水印是否被正确过滤。这一步花的时间,远比事后排查检索效果差要划算。我习惯用page.get_pixmap()渲染页面,再用fitz.Rect画框,导出成图片人工抽查。这个习惯帮我提前发现了很多隐蔽的 bug。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询