首页 / 资讯中心 / 文章详情

RAG文档解析实战:用bbox与XY-cut搞定多栏排版和水印PDF

RAG文档解析实战:用bbox与XY-cut搞定多栏排版和水印PDF ★ FEATURED ARTICLE
1. 为什么多栏排版和水印 PDF 是 RAG 文档解析的硬骨头做过 RAG 知识库的人都有一个共识文本类文档好处理PDF 才是真正的拦路虎。尤其是那些双栏排版的学术论文、带水印的内部资料、扫描件混排的合同文档直接丢给解析器出来的文本顺序全是乱的。你拿这种乱序文本去做向量化检索出来的结果驴唇不对马嘴用户问东你答西整个 RAG 系统的可信度直接崩盘。这个问题的根源在于 PDF 本身的设计哲学。PDF 不是为“阅读顺序”而生的格式它更像一张张画布每个字符、每条线、每个图片都有自己独立的坐标位置。解析器拿到 PDF 后默认按字符在页面上的物理位置从上到下、从左到右读取遇到多栏排版就完蛋了——它会先把左栏的第一行和右栏的第一行拼在一起再拼左栏第二行和右栏第二行读起来像精神分裂。水印则是另一个维度的麻烦。很多 PDF 会在每页叠加一层半透明的文字水印比如“内部资料 请勿外传”“机密”之类的。这些水印字符在 PDF 内部是真实存在的文本对象解析器分不清哪些是正文、哪些是水印一股脑全提取出来。结果就是你的知识库里每段话都夹杂着“机密机密机密”检索质量断崖式下跌。bbox 就是解决这类问题的核心武器。bbox 全称 bounding box中文叫边界框说白了就是用四个坐标值 (x0, y0, x1, y1) 圈出页面上某块内容的矩形区域。有了 bbox你就能精确知道每个文本块在页面上的位置进而判断它属于哪一栏、是不是水印、该按什么顺序拼接。PyMuPDF 这个库在这方面提供了非常扎实的底层能力配合 XY-cut 算法多栏排版和水印问题都能被系统性地拆解掉。这篇文章适合正在搭建 RAG 知识库、被 PDF 解析折磨过的工程师也适合对文档智能处理感兴趣的技术人。我会从 bbox 的底层原理讲起把 XY-cut 算法的逻辑拆开揉碎再用手把手的代码实战带你处理多栏论文和水印文档最后分享几个我踩过的坑和排查技巧。读完你至少能做到拿到一份双栏 PDF能准确还原阅读顺序拿到一份带水印的文档能干净地剥离水印只留正文。2. bbox 与 XY-cut 的底层逻辑拆解2.1 bbox 到底是什么从页面坐标系说起PDF 页面的坐标系和常见的屏幕坐标系不太一样。它的原点在页面左下角x 轴向右延伸y 轴向上延伸单位是 point磅1 英寸等于 72 point。一张 A4 纸大概是 595 x 842 point。PyMuPDF 在返回 bbox 时用的格式是 (x0, y0, x1, y1)其中 (x0, y0) 是矩形左下角(x1, y1) 是右上角。这里有个容易搞混的地方PyMuPDF 的page.get_text(blocks)返回的 bbox 顺序是 (x0, y0, x1, y1)但page.get_text(words)返回的每个词也是这个顺序。而如果你用page.get_text(dict)里面的 span 和 line 也都有各自的 bbox。理解这些 bbox 的层级关系很关键——一个 block 包含多个 line一个 line 包含多个 span一个 span 包含多个字符。每个层级都有自己的 bbox你可以根据需要选择在哪个粒度上做分析。为什么 bbox 这么重要因为它是连接“物理位置”和“逻辑结构”的桥梁。PDF 里没有“段落”“栏”这些概念只有一堆带坐标的字符。但人类阅读时是有逻辑结构的bbox 让我们能通过几何关系反推出这个结构。比如两个文本块如果 x 坐标范围重叠、y 坐标上下排列它们很可能属于同一栏如果 x 坐标范围完全不重叠、y 坐标有交集那大概率是左右两栏。2.2 XY-cut 算法用递归切割还原阅读顺序XY-cut 的核心思想非常朴素一页文档可以被水平线或垂直线切成若干块每一块内部再继续切直到切不动为止。切完之后按照人类阅读习惯从上到下、从左到右把这些块排序就得到了正确的阅读顺序。具体来说算法分两个阶段。第一阶段是水平切割X-cut在页面上找一条水平方向的空白带把页面分成上下两部分。这条空白带的要求是在这条带子的 y 坐标范围内没有任何文本块的 bbox 与之相交。找到这样的空白带后页面就被切成上下两块。第二阶段是垂直切割Y-cut在每一块内部找一条垂直方向的空白带把块分成左右两部分。同样要求这条带子的 x 坐标范围内没有文本块。这两个阶段交替递归进行直到某一块内部再也找不到可以切割的空白带为止。最后得到的每个叶子块就是一个“原子文本块”按阅读顺序排列即可。这里的关键参数是“空白带的最小宽度”。如果空白带太窄比如只有 1 个 point那可能是字符之间的正常间距不应该作为切割依据。如果太宽比如 50 point那可能漏掉一些合理的切割点。我的经验值是水平切割的空白带最小高度设为页面高度的 1% 到 2%垂直切割的空白带最小宽度设为页面宽度的 1% 到 2%。对于 A4 纸大概是 8 到 17 point 之间。这个值需要根据实际文档调整后面我会讲怎么调。XY-cut 有一个经典问题它假设页面是“曼哈顿布局”即所有内容都是水平或垂直对齐的。如果文档里有倾斜的文字、圆形环绕的排版XY-cut 就会失效。不过对于绝大多数论文、报告、合同来说曼哈顿布局是成立的所以 XY-cut 在实际工程中非常实用。2.3 水印的识别逻辑为什么不能简单按文字内容过滤很多人第一反应是水印不就是“机密”“内部资料”这些词吗我直接按关键词过滤不就行了这个思路在小规模场景下能凑合用但放到生产环境会出大问题。原因有三第一水印文字可能和正文内容重叠比如正文里正好有一句“这份机密文件”你按关键词过滤就把正文也删了第二水印的形式多种多样有的是文字、有的是图片、有的是矢量图形关键词过滤只能处理文字水印第三水印的字体、大小、颜色、透明度往往和正文不同这些特征比文字内容更可靠。用 bbox 识别水印的思路是这样的水印通常覆盖整个页面或大部分页面它的 bbox 会非常大而且往往和多个正文块的 bbox 相交。另外水印的字体大小可能异常大或异常小颜色可能是浅灰色旋转角度可能是 45 度。这些特征都可以通过 PyMuPDF 提取出来。具体来说你可以遍历每个 span检查它的 bbox 面积是否超过页面面积的某个比例比如 50%检查它的字体大小是否偏离正文的常见范围检查它的颜色是否接近背景色。满足这些条件的 span大概率就是水印。还有一个更鲁棒的方法利用 PDF 的图层信息。有些 PDF 会把水印放在单独的 OCGOptional Content Group图层里PyMuPDF 可以通过page.get_ocgs()获取图层信息直接关掉水印图层。但这个方法依赖 PDF 制作时的规范程度很多文档并没有这么规范所以只能作为辅助手段。3. 用 PyMuPDF 提取 bbox 并实现 XY-cut 的完整实操3.1 环境准备与基础提取先把环境搭起来。PyMuPDF 的安装很简单一条命令搞定pip install pymupdf注意 PyMuPDF 的导入名是fitz这是历史遗留问题别被绕晕了。下面这段代码展示了如何提取页面上所有文本块的 bboximport fitz doc fitz.open(sample.pdf) page doc[0] # 获取所有文本块每个块包含 (x0, y0, x1, y1, text, block_no, block_type) blocks page.get_text(blocks) for b in blocks: x0, y0, x1, y1, text, block_no, block_type b print(fBlock {block_no}: bbox({x0:.1f}, {y0:.1f}, {x1:.1f}, {y1:.1f})) print(f Text: {text[:50]}...)这里有个细节要注意get_text(blocks)返回的 block_type 为 0 表示文本块为 1 表示图片块。做 XY-cut 时通常只处理文本块图片块可以单独处理或者直接跳过。另外返回的 text 里可能包含换行符做后续处理时记得 strip 一下。如果你需要更细粒度的信息比如每个词的 bbox可以用page.get_text(words)words page.get_text(words) # 每个词是 (x0, y0, x1, y1, word, block_no, line_no, word_no) for w in words[:5]: print(w)words 级别的 bbox 在做精确的水印识别时很有用因为水印的单个词可能和正文的词在 bbox 上有明显差异。3.2 XY-cut 算法的 Python 实现下面是一个完整的 XY-cut 实现。我把它写成一个类方便复用和调参import fitz class XYCut: def __init__(self, page, min_h_gap_ratio0.015, min_v_gap_ratio0.015): self.page page self.page_rect page.rect self.min_h_gap self.page_rect.height * min_h_gap_ratio self.min_v_gap self.page_rect.width * min_v_gap_ratio self.blocks self._get_text_blocks() def _get_text_blocks(self): blocks [] for b in self.page.get_text(blocks): x0, y0, x1, y1, text, block_no, block_type b if block_type 0 and text.strip(): blocks.append({ bbox: (x0, y0, x1, y1), text: text.strip() }) return blocks def _find_horizontal_cut(self, blocks): 找水平切割线返回切割的 y 坐标列表 if len(blocks) 1: return [] # 收集所有块的 y 区间 intervals [(b[bbox][1], b[bbox][3]) for b in blocks] intervals.sort() cuts [] # 检查相邻区间之间是否有足够大的空隙 for i in range(len(intervals) - 1): gap intervals[i1][0] - intervals[i][1] if gap self.min_h_gap: cuts.append((intervals[i][1] intervals[i1][0]) / 2) return cuts def _find_vertical_cut(self, blocks): 找垂直切割线返回切割的 x 坐标列表 if len(blocks) 1: return [] intervals [(b[bbox][0], b[bbox][2]) for b in blocks] intervals.sort() cuts [] for i in range(len(intervals) - 1): gap intervals[i1][0] - intervals[i][1] if gap self.min_v_gap: cuts.append((intervals[i][1] intervals[i1][0]) / 2) return cuts def _split_blocks(self, blocks, axis, cut_pos): 按切割位置把块分成两组 left, right [], [] for b in blocks: if axis h: center (b[bbox][1] b[bbox][3]) / 2 else: center (b[bbox][0] b[bbox][2]) / 2 if center cut_pos: left.append(b) else: right.append(b) return left, right def _recursive_cut(self, blocks, depth0): 递归切割 if len(blocks) 1 or depth 10: return blocks # 先尝试水平切割 h_cuts self._find_horizontal_cut(blocks) if h_cuts: # 取最靠上的切割线 cut h_cuts[0] top, bottom self._split_blocks(blocks, h, cut) return self._recursive_cut(top, depth1) self._recursive_cut(bottom, depth1) # 再尝试垂直切割 v_cuts self._find_vertical_cut(blocks) if v_cuts: cut v_cuts[0] left, right self._split_blocks(blocks, v, cut) return self._recursive_cut(left, depth1) self._recursive_cut(right, depth1) return blocks def get_ordered_blocks(self): 返回按阅读顺序排列的文本块 ordered self._recursive_cut(self.blocks) return ordered这段代码的核心逻辑是先尝试水平切割如果找到切割线就切成上下两块递归处理如果找不到水平切割线再尝试垂直切割如果都找不到说明当前块已经是最小单元直接返回。最终返回的列表就是按阅读顺序排列的文本块。3.3 参数调优与效果验证上面代码里有两个关键参数min_h_gap_ratio和min_v_gap_ratio。默认值 0.015 是我在大量文档上试出来的经验值但不同文档可能需要微调。调参的方法很简单拿一份典型文档跑一遍 XY-cut把排序后的文本打印出来人工检查阅读顺序对不对。如果发现某两栏被错误地合并了说明垂直切割的阈值太大了调小一点如果发现同一栏内部被切得太碎说明阈值太小了调大一点。验证效果时我习惯用“首句检查法”把排序后的文本块按顺序拼接看每个段落的开头是否连贯。如果出现“左栏第一段的后半句 右栏第一段的前半句”这种拼接说明切割有问题。另一个方法是可视化用 PyMuPDF 的page.draw_rect()把每个块的 bbox 画出来保存成图片肉眼检查切割边界是否合理。# 可视化 bbox page doc[0] for b in xycut.get_ordered_blocks(): rect fitz.Rect(b[bbox]) page.draw_rect(rect, color(1, 0, 0), width1) pix page.get_pixmap() pix.save(bbox_visualization.png)这个可视化图能帮你快速定位问题。如果看到红色框把左右两栏的内容框在了一起那就是垂直切割没生效如果看到同一栏被切成了很多小碎块那就是阈值太小了。4. 水印 PDF 的处理策略与实战代码4.1 水印特征提取与判定规则处理水印 PDF 的第一步是识别水印。我通常从三个维度来判定面积占比、字体大小、颜色特征。下面是一个水印检测器的实现import fitz class WatermarkDetector: def __init__(self, page, area_ratio_threshold0.3, font_size_range(5, 15), color_threshold0.8): self.page page self.page_area page.rect.width * page.rect.height self.area_ratio_threshold area_ratio_threshold self.font_size_range font_size_range self.color_threshold color_threshold def detect(self): 返回疑似水印的 span 列表 watermarks [] text_dict self.page.get_text(dict) for block in text_dict[blocks]: if block.get(type) ! 0: continue for line in block[lines]: for span in line[spans]: if self._is_watermark(span): watermarks.append(span) return watermarks def _is_watermark(self, span): bbox span[bbox] area (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) area_ratio area / self.page_area # 规则1面积占比过大 if area_ratio self.area_ratio_threshold: return True # 规则2字体大小异常 font_size span[size] if font_size self.font_size_range[0] or font_size self.font_size_range[1]: # 需要结合其他特征单独字体大小异常不一定是水印 pass # 规则3颜色接近背景色浅色 color span[color] r (color 16) 0xFF g (color 8) 0xFF b color 0xFF brightness (r g b) / (3 * 255) if brightness self.color_threshold: return True return False这段代码里面积占比是最可靠的指标。水印通常覆盖整个页面所以它的 bbox 面积会非常大。颜色亮度是第二可靠的指标浅灰色水印的 RGB 值通常都在 200 以上。字体大小只能作为辅助判断因为有些正文标题的字体也很大。4.2 水印剥离与正文还原识别出水印后剥离的方法有两种。第一种是“过滤法”在提取文本时跳过水印 span。第二种是“遮盖法”用白色矩形覆盖水印区域再重新提取文本。过滤法更干净但需要精确识别遮盖法更暴力但可能误伤正文。我推荐用过滤法结合 XY-cut 一起使用。具体流程是先用 WatermarkDetector 找出所有水印 span记录它们的 bbox然后在 XY-cut 的_get_text_blocks阶段检查每个 block 是否和水印 bbox 高度重叠如果是就跳过。def _get_text_blocks_with_watermark_filter(self, watermark_bboxes): blocks [] for b in self.page.get_text(blocks): x0, y0, x1, y1, text, block_no, block_type b if block_type ! 0 or not text.strip(): continue block_rect fitz.Rect(x0, y0, x1, y1) is_watermark False for wm_bbox in watermark_bboxes: wm_rect fitz.Rect(wm_bbox) # 计算重叠面积 intersect block_rect wm_rect if intersect.is_valid: overlap_ratio (intersect.width * intersect.height) / (block_rect.width * block_rect.height) if overlap_ratio 0.5: is_watermark True break if not is_watermark: blocks.append({bbox: (x0, y0, x1, y1), text: text.strip()}) return blocks这里有个坑要注意有些水印是旋转的它的 bbox 是一个大矩形但实际覆盖的区域是斜的。这种情况下用 bbox 相交来判断会误伤很多正文。解决办法是检查水印的旋转角度如果角度不为 0就用更精细的判定逻辑比如检查正文块的中心点是否落在水印的旋转矩形内。4.3 多栏加水印的复合场景处理实际工作中多栏排版和水印经常同时出现。这时候的处理顺序很重要先做水印识别和过滤再做 XY-cut。如果顺序反了水印的大 bbox 会干扰 XY-cut 的切割线检测导致切割失败。完整的处理流程是这样的def process_pdf_page(page): # 第一步检测水印 detector WatermarkDetector(page) watermarks detector.detect() wm_bboxes [w[bbox] for w in watermarks] # 第二步提取文本块并过滤水印 xycut XYCut(page) xycut.blocks xycut._get_text_blocks_with_watermark_filter(wm_bboxes) # 第三步XY-cut 排序 ordered_blocks xycut.get_ordered_blocks() # 第四步拼接文本 full_text \n.join([b[text] for b in ordered_blocks]) return full_text这个流程我在多个项目里验证过对双栏论文、带水印的内部报告、扫描件混排的合同都有不错的效果。当然没有银弹特殊排版的文档还是需要针对性调整参数。5. 常见问题排查与避坑经验实录5.1 XY-cut 切割失败的典型原因XY-cut 最常见的失败模式是“切割线找不到”。表现是左右两栏的内容被合并在一起阅读顺序完全错乱。原因通常有三个第一两栏之间的空白带太窄小于设定的阈值第二两栏之间有跨栏元素比如通栏的标题、图片、表格这些元素的 bbox 横跨两栏导致垂直切割线无法找到第三页面有页眉页脚它们的 bbox 横跨整个页面宽度干扰了切割。针对第一个原因调小min_v_gap_ratio就行。针对第二个原因需要先识别并移除跨栏元素。判断跨栏元素的规则是如果一个块的宽度超过页面宽度的 60%它很可能是通栏元素。处理方法是先把这些块单独拿出来对剩余块做 XY-cut最后再把通栏元素按 y 坐标插入到正确位置。针对第三个原因页眉页脚的 y 坐标通常在页面顶部或底部的 10% 区域内。可以在 XY-cut 之前先把这些区域的块移除处理完正文后再加回来。5.2 水印误判与漏判的平衡水印检测的难点在于平衡误判和漏判。误判是把正文当成水印删掉了漏判是水印没被识别出来混进了正文。我的经验是宁可漏判不可误判。因为漏判的水印最多影响检索质量但误判的正文丢失是不可逆的。为了降低误判率我通常采用“多特征联合判定”策略只有同时满足面积占比大、颜色浅、字体异常这三个条件中的至少两个才判定为水印。另外可以加一个人工审核环节把疑似水印的文本片段打印出来人工确认后再决定是否过滤。在生产环境中可以维护一个水印白名单把常见的水印文字如“机密”“内部资料”“请勿外传”加入白名单提高判定准确率。5.3 性能优化大文档的批量处理处理几百页的 PDF 时性能是个问题。PyMuPDF 本身很快但 XY-cut 的递归切割在块数量多的时候会变慢。优化方法有几个第一限制递归深度我设的是 10 层超过就停止切割第二对块进行预排序按 y 坐标排序后再切割可以减少无效的切割尝试第三用多进程并行处理不同页面PyMuPDF 的页面对象是独立的可以安全地并行。from multiprocessing import Pool def process_page(args): pdf_path, page_num args doc fitz.open(pdf_path) page doc[page_num] text process_pdf_page(page) doc.close() return page_num, text with Pool(4) as pool: results pool.map(process_page, [(pdf_path, i) for i in range(len(doc))])这个并行方案在我的测试中能把处理速度提升 3 到 4 倍。注意每个进程要独立打开 PDF不能共享 doc 对象。5.4 常见问题速查表问题现象可能原因排查方法解决方案左右栏内容交错垂直切割阈值过大打印切割线位置调小 min_v_gap_ratio同一栏被切碎垂直切割阈值过小可视化 bbox调大 min_v_gap_ratio水印混入正文水印检测漏判检查水印 span 特征放宽检测条件正文被误删水印检测误判检查被删块内容收紧检测条件通栏标题位置错误跨栏元素干扰检查块宽度单独处理跨栏元素处理速度慢递归深度过大统计块数量限制递归深度并行6. 从 bbox 到 RAG 知识库的完整链路6.1 文本块到语义块的转换XY-cut 输出的是一堆按阅读顺序排列的文本块但这些块还不是最终的“语义块”。一个语义块应该是一个完整的段落或章节而不是物理上的文本块。所以还需要一步后处理把相邻的、属于同一段落的文本块合并。合并的规则可以基于 bbox 的间距和文本特征。如果两个块的垂直间距小于行高的 1.5 倍且前一个块不以句号结尾后一个块不以大写字母开头那它们很可能属于同一段落可以合并。这个规则不是绝对的需要根据文档类型调整。6.2 元数据注入与检索优化在 RAG 系统中光有文本还不够还需要注入元数据。对于 PDF 解析来说最有价值的元数据是页码、bbox 坐标、所属章节。页码和 bbox 可以用于溯源当用户问“这个结论出自哪里”时你能精确指出是哪一页的哪个位置。所属章节可以通过分析字体大小和加粗程度来推断标题通常字体更大、更粗。这些元数据在向量化时可以作为附加信息一起存入检索时可以用来过滤和排序。比如用户问“第三章讲了什么”你可以先用章节元数据过滤出第三章的内容再做向量检索准确率会高很多。6.3 实际项目中的效果对比我在一个学术论文知识库项目里对比过三种方案的效果。方案 A 是直接用 PyMuPDF 的默认提取方案 B 是 XY-cut 排序方案 C 是 XY-cut 加水印过滤。用 100 篇双栏论文做测试人工评估阅读顺序的正确率方案 A 是 62%方案 B 是 91%方案 C 是 94%。检索准确率Top-5 命中率方面方案 A 是 58%方案 B 是 82%方案 C 是 86%。这个提升是显著的。尤其是从方案 A 到方案 B阅读顺序的正确率提升了近 30 个百分点直接决定了 RAG 系统能不能用。方案 C 相对方案 B 的提升主要来自水印文档对于没有水印的文档两者差别不大。6.4 后续扩展方向这套 bbox XY-cut 的方案还可以继续扩展。比如处理表格时可以用 bbox 识别表格区域再用专门的表格解析库提取结构化数据。处理图片时可以用 bbox 定位图片位置结合 OCR 提取图片中的文字。处理公式时可以用 bbox 圈出公式区域交给公式识别模型处理。另一个方向是和版面分析模型结合。传统的 XY-cut 基于规则对复杂版面的适应性有限。现在有一些基于深度学习的版面分析模型可以识别标题、正文、表格、图片等区域输出带标签的 bbox。把 XY-cut 和这些模型结合能处理更复杂的文档类型。我个人在实际操作中的体会是bbox 是 PDF 解析的基石XY-cut 是性价比最高的阅读顺序还原算法水印处理则需要根据具体文档灵活调整策略。这套方案不是万能的但能覆盖 80% 以上的常见场景。剩下的 20% 需要针对具体文档做定制化处理没有捷径可走。踩过几次坑之后我养成了一个习惯拿到新类型的 PDF先跑一遍可视化看看 bbox 和切割线长什么样再决定用什么参数和策略。这个习惯帮我省了很多调试时间。
阅读完成 · 觉得有帮助?
咨询建站