
1. 为什么多栏排版和水印是 PDF 解析里最难啃的两块骨头做 RAG 的人迟早会撞上同一堵墙纯文本抽取跑得挺欢一换到真实业务文档就原形毕露。尤其是学术论文、产品手册、合同扫描件这三类几乎清一色是双栏甚至三栏排版页面上还压着内部资料禁止外传之类的半透明水印。你拿page.get_text()直接一抽出来的文字顺序能把人看哭——左栏第一段接右栏第一段再接左栏第二段读起来像被人剪碎了重新粘的。这不是工具不行而是 PDF 本身的组织方式决定的。PDF 里根本没有栏这个概念它只认字符的坐标。一个字符画在哪它就记在哪至于这些字符在语义上属于哪一栏、哪一段PDF 格式压根不关心。所以解析器的任务本质上是从一堆散落的坐标点里把人类阅读时默认的从上到下、从左到右、先读完一栏再读下一栏这个顺序给反推出来。这个反推过程就是版面分析Layout Analysis。水印则是另一个维度的麻烦。它通常以两种形态存在一种是直接画在页面内容流里的文字或图形和正文混在一起另一种是作为独立的 XObject 或注释层叠加上去。前者会让你的文本里凭空多出一堆重复的机密字样后者虽然不污染文本流但会干扰基于视觉的版面分析——因为水印的 bbox 可能横跨整个页面把正常的栏切分逻辑搅乱。这篇要聊的就是怎么用 bboxbounding 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(matrixfitz.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_ratio0.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 gaps3.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_threshold0.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 watermarks4.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, tolerance0.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。