ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RAG文档解析实战:用bbox与XY-cut解决多栏PDF排序和水印过滤

RAG文档解析实战:用bbox与XY-cut解决多栏PDF排序和水印过滤 1. 为什么多栏 PDF 是 RAG 文档解析里最容易被低估的硬骨头做 RAG 的人大多有过这种体验PDF 丢进解析器文本是出来了但顺序全乱。尤其是学术论文、技术手册、产品白皮书这类多栏排版的文件解析结果经常是左栏第一段接右栏第一段再跳回左栏第二段读起来像被人剪碎了重新随机拼贴。更麻烦的是水印——很多企业文档、合同、报告上都有机密内部资料草稿这类半透明水印它们会以文字对象的形式混进正文流里让检索结果里莫名其妙多出一堆噪声。这两个问题在 RAG 链路里是致命的。RAG 的核心假设是检索到的 chunk 是语义连贯的一旦 chunk 本身顺序错乱或者混入水印文字后面的 embedding、召回、重排全都会受影响。你可能会发现检索出来的内容明明关键词匹配但答非所问根源往往就在这里。这篇要聊的是用bboxbounding box边界框做版面分析配合XY-cut算法解决多栏排序再用PyMuPDF把水印从正文里剥离出去。关键词里出现的 RAG、bbox、PDF、XY-cut、PyMuPDF 正好串成一条完整的实战链路。适合已经跑通过基础 PDF 解析、但被版面问题卡住的 RAG 开发者也适合想深入理解文档解析底层逻辑的技术人。我自己的经验是不要指望一个通用解析库开箱即用解决所有版面问题。PyMuPDF 给你的是原始的文字块和坐标怎么组织这些块是你自己的事。下面把我踩过的坑和最终跑通的方案完整拆一遍。2. 从 PyMuPDF 的 block 结构说起bbox 到底给了你什么2.1 page.get_text(dict) 返回的层级关系PyMuPDF 里最常用的取文本方式是page.get_text(dict)它返回的结构是 page → blocks → lines → spans。每个层级都带 bbox格式是(x0, y0, x1, y1)坐标系原点在页面左上角y 轴向下增长。import fitz # PyMuPDF doc fitz.open(paper.pdf) page doc[0] data page.get_text(dict) for block in data[blocks]: if block[type] ! 0: # type 0 是文本块1 是图片 continue bbox block[bbox] text .join(span[text] for line in block[lines] for span in line[spans]) print(bbox, repr(text[:40]))这里有个关键认知PyMuPDF 的 block 不等于段落。它更像是排版引擎认为可以放在一起的一组行。在多栏 PDF 里一个 block 通常落在同一栏内但偶尔会跨栏合并尤其是当两栏文字行高接近、水平间距较小时。所以直接用 block 顺序当阅读顺序在多栏场景下会翻车。2.2 为什么不能直接用 block 的默认顺序PyMuPDF 返回 block 的顺序大致是按从上到下、从左到右的某种启发式排序但它并不理解栏的概念。我实测过一篇双栏论文block 顺序是左栏标题 → 右栏标题 → 左栏正文第一段 → 右栏正文第一段……这种交错在视觉上完全错乱。判断一个 PDF 是不是多栏有个很实用的启发式统计所有文本 block 的 x 中心点分布。如果是单栏x 中心点会集中在一个窄区间如果是双栏会明显出现两个簇。import numpy as np centers [] for block in data[blocks]: if block[type] 0: x0, _, x1, _ block[bbox] centers.append((x0 x1) / 2) centers np.array(centers) # 简单看分布实际项目里可以用一维聚类 print(np.histogram(centers, bins10))注意不要用页面宽度的一半硬切。有些 PDF 是左窄右宽的不对称双栏或者带侧边批注栏硬切会把正文切碎。用聚类或者投影分析更稳。2.3 水印在 bbox 层面的特征水印文字在 PyMuPDF 里也是普通文本 span但它有几个可识别的特征旋转角度很多水印是斜着放的span 的dir字段方向向量不是(1, 0)。透明度水印通常设置了 fill alpha但 PyMuPDF 的 span 里不一定直接暴露 alpha需要看page.get_text(rawdict)或者结合绘图指令。字号和颜色水印常用大字号、浅灰色。重复出现同一段水印文字在多个页面重复甚至同一页重复多次。最稳的识别方式其实是跨页重复 旋转 颜色浅三者结合。单看某一个特征都容易误杀正常文字。3. XY-cut 算法拆解递归切分与阅读顺序还原3.1 XY-cut 的核心思想XY-cut 是版面分析里的经典算法思路非常朴素在页面上找空白缝隙沿着缝隙把页面切成若干块递归切下去直到每块里只剩一个文本单元。切分方向在水平和垂直之间交替。水平切分X-cut找一条竖直的空白带把页面分成左右两部分。这对应多栏布局。垂直切分Y-cut找一条水平的空白带把页面分成上下两部分。这对应段落之间的间距。切完之后按切分的树形结构做中序遍历就能得到符合人类阅读习惯的顺序。这个递归切分 中序输出就是 XY-cut 的精髓。3.2 用投影法找切分线实际实现时不用真的去找缝隙而是用投影projection。把所有文本 block 的 bbox 投影到 x 轴或 y 轴上统计每个像素位置有没有文字覆盖没覆盖的连续区间就是候选切分线。def find_gaps(intervals, min_gap10): intervals: [(start, end), ...] 已排序的区间列表 返回所有宽度 min_gap 的空白区间 gaps [] intervals sorted(intervals) cur_end intervals[0][1] for start, end in intervals[1:]: if start - cur_end min_gap: gaps.append((cur_end, start)) cur_end max(cur_end, end) return gapsX-cut 时把每个 block 的(x0, x1)作为 intervalY-cut 时用(y0, y1)。min_gap这个参数很关键太小会把字间距当成栏间距太大又切不开紧凑排版。我的经验值是X-cut 用 15~25 像素Y-cut 用 8~15 像素具体要看 PDF 的 DPI 和字号。3.3 递归切分的终止条件递归不能无限切下去否则每个字符都会被切成一块。终止条件一般设两个当前区域内的 block 数量 ≤ 1直接返回。找不到满足min_gap的切分线停止切分把当前区域内的 block 按 y 再按 x 排序输出。这里有个容易忽略的点切分方向的选择。标准 XY-cut 是交替切但更稳的做法是哪个方向能找到更宽的缝隙就先切哪个。因为有些版面横向缝隙明显、纵向缝隙很窄强行交替会切出奇怪的块。def xy_cut(blocks, min_gap_x20, min_gap_y10): if len(blocks) 1: return blocks # 尝试 X-cut x_intervals [(b[bbox][0], b[bbox][2]) for b in blocks] x_gaps find_gaps(x_intervals, min_gap_x) # 尝试 Y-cut y_intervals [(b[bbox][1], b[bbox][3]) for b in blocks] y_gaps find_gaps(y_intervals, min_gap_y) # 优先选缝隙更宽的方向 max_x_gap max((g[1] - g[0] for g in x_gaps), default0) max_y_gap max((g[1] - g[0] for g in y_gaps), default0) if max_x_gap 0 and max_y_gap 0: return sorted(blocks, keylambda b: (b[bbox][1], b[bbox][0])) if max_x_gap max_y_gap: cut max(x_gaps, keylambda g: g[1] - g[0]) left [b for b in blocks if b[bbox][2] cut[0] 1] right [b for b in blocks if b[bbox][0] cut[1] - 1] return xy_cut(left, min_gap_x, min_gap_y) xy_cut(right, min_gap_x, min_gap_y) else: cut max(y_gaps, keylambda g: g[1] - g[0]) top [b for b in blocks if b[bbox][3] cut[0] 1] bottom [b for b in blocks if b[bbox][1] cut[1] - 1] return xy_cut(top, min_gap_x, min_gap_y) xy_cut(bottom, min_gap_x, min_gap_y)3.4 跨栏元素的处理XY-cut 有个经典难题跨栏元素比如横跨两栏的大标题、通栏的图表、页脚的页码。这些元素如果被强行分到某一栏阅读顺序就错了。处理办法是在切分前先识别出跨栏元素把它们单独拎出来。判断标准是 block 的宽度超过页面宽度的某个比例比如 60%或者它横跨了检测到的栏边界。这些元素按 y 坐标插入到最终顺序里作为分隔点。我一般这样做先跑一遍 XY-cut 得到栏内顺序再把跨栏元素按 y 坐标合并进去。合并时用 y 坐标比较跨栏元素之前的栏内内容排在它前面之后的排在后面。这个逻辑写起来有点绕但实测比先合并再切分稳得多。4. 水印剥离从事后过滤到事前识别4.1 为什么事后正则过滤不靠谱很多人处理水印的第一反应是解析出文本后用正则把机密内部资料这类词删掉。这招在简单场景能用但问题很多水印文字可能和正文粘连比如机密两个字正好落在正文行中间正则删了会破坏正文。水印内容不固定可能是公司名、日期、员工编号正则覆盖不全。有些水印是图片形式正则根本碰不到。所以更靠谱的思路是在 bbox 层面识别水印在文本提取前就把它排除。4.2 基于旋转和颜色的水印识别前面提到水印的 span 方向向量往往不是水平的。PyMuPDF 的 span 里有dir字段for block in data[blocks]: if block[type] ! 0: continue for line in block[lines]: for span in line[spans]: dir_vec span[dir] # 通常是 (1, 0) 表示水平 if abs(dir_vec[1]) 0.1: # y 分量不为 0说明有旋转 print(疑似水印:, span[text], dir_vec)颜色方面span 的color字段是整数形式的 RGB。水印常用浅灰比如0xC0C0C0。可以设一个阈值把颜色亮度高于某值的 span 标记为疑似水印。def is_light_color(color_int, threshold0xB0): r (color_int 16) 0xFF g (color_int 8) 0xFF b color_int 0xFF return (r g b) / 3 threshold4.3 跨页重复检测最可靠的水印信号单一特征都容易误判但跨页重复这个特征非常强。正常正文不会在每一页的相同位置出现完全相同的文字而水印会。做法是遍历所有页面记录每个 span 的文本和归一化位置相对于页面宽高的比例统计出现次数。出现次数超过页面总数 50% 的基本可以判定为水印。from collections import defaultdict watermark_candidates defaultdict(int) total_pages len(doc) for page in doc: data page.get_text(dict) pw, ph page.rect.width, page.rect.height seen set() for block in data[blocks]: if block[type] ! 0: continue for line in block[lines]: for span in line[spans]: text span[text].strip() if not text: continue x0, y0, x1, y1 span[bbox] # 归一化位置量化到 0.05 精度避免浮点误差 key (text, round(x0/pw, 2), round(y0/ph, 2)) if key not in seen: watermark_candidates[key] 1 seen.add(key) watermarks {k for k, v in watermark_candidates.items() if v total_pages * 0.5}提示归一化位置时一定要量化否则浮点误差会让同一个水印在不同页面算出不同的 key。我一般量化到小数点后两位。4.4 把水印从 block 里剔除的正确姿势识别出水印后不要直接删 block因为一个 block 里可能既有水印又有正文。正确做法是在 span 级别过滤重建 block 的文本。def extract_text_without_watermark(page, watermarks): data page.get_text(dict) pw, ph page.rect.width, page.rect.height result_blocks [] for block in data[blocks]: if block[type] ! 0: continue kept_spans [] for line in block[lines]: for span in line[spans]: text span[text].strip() x0, y0, _, _ span[bbox] key (text, round(x0/pw, 2), round(y0/ph, 2)) if key in watermarks: continue kept_spans.append(span) if kept_spans: new_text .join(s[text] for s in kept_spans) result_blocks.append({bbox: block[bbox], text: new_text}) return result_blocks这样既保住了 block 的 bbox后续 XY-cut 还要用又把水印文字清掉了。5. 把 bbox、XY-cut、水印过滤串成一条 RAG 解析流水线5.1 整体流程设计到这一步各个零件都有了需要把它们组装成一条可复用的流水线。我的流程是这样的打开 PDF逐页处理。对每页先用get_text(dict)拿到所有 block。第一遍遍历所有页面做跨页水印检测得到水印集合。第二遍逐页处理过滤水印 span重建 block。对每页的 block 跑 XY-cut得到阅读顺序。把有序 block 的文本拼接按语义切分成 chunk。chunk 送入 embedding 和向量库。这里第 3 步和第 4 步分两遍是必要的因为水印检测需要全局视野。如果 PDF 很大可以只采样前 20 页做水印检测够用了。5.2 分栏检测与 XY-cut 参数的联动XY-cut 的min_gap_x参数应该跟分栏检测结果联动。如果检测到是双栏min_gap_x要设得比栏间距小一点如果是单栏min_gap_x可以设大一点避免把段落内的空格误判成栏缝。一个实用的做法是先跑一次粗粒度的投影分析找出页面主要的空白带宽度然后取这个宽度的 0.6 倍作为min_gap_x。def estimate_column_gap(blocks, page_width): x_intervals sorted([(b[bbox][0], b[bbox][2]) for b in blocks]) gaps find_gaps(x_intervals, min_gap5) if not gaps: return page_width * 0.05 # 默认值 # 取最宽的缝隙作为栏间距估计 widest max(gaps, keylambda g: g[1] - g[0]) return (widest[1] - widest[0]) * 0.65.3 chunk 切分时如何利用 bbox 信息普通 RAG 教程里 chunk 切分就是按字符数或 token 数硬切但有了 bbox 和阅读顺序可以做得更聪明按 block 边界切一个 block 通常是一个段落或一个标题天然是语义单元。优先在 block 边界切 chunk。标题识别字号明显大于正文的 block 是标题可以作为 chunk 的元数据帮助检索时定位。跨页合并如果上一页最后一个 block 和下一页第一个 block 是同一段落比如上一页末尾没有句号可以合并。def build_chunks(ordered_blocks, max_chars800): chunks [] buf for b in ordered_blocks: text b[text].strip() if not text: continue if len(buf) len(text) max_chars and buf: chunks.append(buf) buf text else: buf \n text if buf else text if buf: chunks.append(buf) return chunks5.4 实测效果对比我拿一篇 12 页的双栏学术论文做了对比测试。不做任何处理直接解析检索实验方法相关问题时召回的前 5 个 chunk 里有 3 个是错乱的跨栏文本。加上 XY-cut 和水印过滤后前 5 个 chunk 全部语义连贯人工评估相关性从 40% 提升到 90% 以上。水印方面测试文档每页都有斜向的CONFIDENTIAL水印。不做过滤时检索结果里频繁出现这个词干扰排序。过滤后水印词在 chunk 里彻底消失。处理方式阅读顺序正确率水印残留检索相关性直接解析约 50%严重低仅 XY-cut约 90%严重中XY-cut 水印过滤约 95%无高6. 那些文档里不会写的踩坑记录6.1 坐标系陷阱PyMuPDF 的 y 轴方向PyMuPDF 的坐标系原点在左上角y 向下增长这跟很多图像处理库一致但跟 PDF 规范本身的坐标系原点在左下角相反。如果你混用了其他库的坐标很容易搞反。我一开始用 PDF 规范坐标写 XY-cut结果整个阅读顺序是倒的排查了半天。记住PyMuPDF 里bbox (x0, y0, x1, y1)其中y0 y1y0是上边y1是下边。6.2 旋转页面的处理有些 PDF 页面本身带旋转page.rotation ! 0这时候 bbox 是相对于旋转后的坐标系还是原始坐标系容易搞混。稳妥做法是先用page.set_rotation(0)把页面转正再取文本。或者用page.rect而不是page.mediabox来获取尺寸前者已经考虑了旋转。6.3 表格和图片的干扰XY-cut 只处理文本 block但页面上的表格和图片会占据空间导致文本 block 的投影出现假缝隙。比如一个横跨两栏的表格会把上下两部分的文本隔开XY-cut 可能因此切错。处理办法是把图片和表格的 bbox 也纳入投影计算作为占位区间。这样切分线就不会穿过它们。# 把图片 block 也加入 interval 计算 all_intervals [] for block in data[blocks]: x0, y0, x1, y1 block[bbox] all_intervals.append((x0, x1)) # X-cut 时用6.4 性能优化大文档怎么办一篇 500 页的 PDF逐页跑 XY-cut 加跨页水印检测可能要几十秒。优化思路水印检测只采样前 20 页。XY-cut 的递归深度设上限比如 10 层避免极端版面导致递归爆炸。用page.get_text(dict, flagsfitz.TEXTFLAGS_TEXT)只取文本跳过图片解析能快不少。6.5 一个反直觉的经验不要过度追求完美顺序我一开始想把阅读顺序做到 100% 正确后来发现投入产出比很低。RAG 场景下chunk 只要大致连贯、不混入噪声检索效果就够用了。把精力花在水印过滤和 chunk 边界优化上收益更大。80 分的解析 好的 chunk 策略往往比 95 分的解析 烂 chunk 策略效果更好。7. 几个可以立刻用上的实操建议如果你现在就要动手我建议按这个顺序来先做水印检测。这是投入最小、收益最明显的。跨页重复检测的代码不到 30 行能解决大部分噪声问题。再做分栏检测。用 x 中心点的直方图判断单栏还是多栏决定要不要上 XY-cut。XY-cut 参数从宽到严调。先用大的min_gap看切分结果再逐步调小直到切分合理。chunk 切分优先按 block 边界。block 是天然的语义单元比按字符数硬切好得多。保留 bbox 元数据。chunk 里带上页码和 bbox检索命中后可以定位到原文位置做高亮展示时非常有用。关于工具选型PyMuPDF 在速度和 API 友好度上是我用过最顺手的。它的get_text(dict)直接给了完整的层级和坐标省去了很多自己解析的功夫。如果你用的是其他库只要它能提供 block 级别的 bbox上面的思路都能迁移。最后分享一个我最近才想明白的点文档解析不是 RAG 的预处理步骤而是 RAG 质量的上限。解析阶段丢掉的顺序和混入的噪声后面用再好的 embedding 模型和重排模型都补不回来。花时间把 bbox 和版面分析做扎实是性价比最高的投入。
返回列表