资讯动态

RAG文档解析实战:用bbox与XY-cut还原多栏PDF阅读顺序

发布时间:2026/10/15 20:13:17 来源:尧图企业网站定制
1. 为什么多栏排版和水印是 RAG 文档解析的硬骨头做过 RAG 知识库的人都有一个共同体会文本型 PDF 直接抽文字准确率能到 95% 以上但只要碰上双栏论文、带水印的扫描件、页眉页脚混排的研报抽取质量立刻断崖式下跌。检索命中率上不去十有八九不是向量模型不行而是解析阶段就把文本顺序搞乱了。这一篇聚焦一个具体而高频的场景多栏排版 水印干扰的 PDF如何用 bbox 信息配合 XY-cut 算法做正确的阅读顺序还原。工具链用 PyMuPDF也就是 fitz因为它在获取字符级 bbox、字体信息、绘制指令方面足够底层而且纯 Python、无外部依赖适合塞进 RAG 的 ingestion pipeline。先说清楚这篇适合谁看如果你正在搭 RAG 知识库已经跑通了「PDF → 文本 → 切块 → 向量化 → 检索」的基本链路但发现检索命中率不稳定、答案经常张冠李戴那问题大概率出在解析层。这篇不讲向量库选型也不讲 embedding 模型对比只解决一件事——把 PDF 里的文字按人类阅读顺序正确地取出来。为什么这件事这么关键因为 RAG 的检索单元是 chunk而 chunk 是从文本流里切出来的。如果解析出来的文本流本身就是乱的——左栏的句子和右栏的句子交错在一起——那切出来的 chunk 就是语义碎片向量化之后检索命中率自然惨不忍睹。我见过太多人把精力全花在换 embedding 模型上结果发现根因是 PDF 解析把两栏内容串行了。解析是地基地基歪了上面盖什么都是歪的。bbox 在这里的角色就是给每一个文本块提供精确的坐标。有了坐标我们才能判断「这块文字在左边还是右边」「这块是正文还是水印」「这两块应该先读哪一块」。XY-cut 则是基于坐标做递归切分的经典算法专门用来还原多栏排版的阅读顺序。两者配合基本能覆盖 80% 以上的复杂排版场景。2. 核心概念拆解bbox、XY-cut 与 PyMuPDF 的分工2.1 bbox 到底是什么为什么它是解析的基石bbox 是 bounding box 的缩写就是一个矩形框用四个数表示(x0, y0, x1, y1)分别是左上角和右下角的坐标。PDF 里的坐标系原点在左下角y 轴向上为正这一点和图像处理里常见的左上角原点相反第一次接触很容易搞混。PyMuPDF 里获取 bbox 有几个层级page.get_text(blocks)返回块级 bbox粒度粗适合快速定位page.get_text(words)返回词级 bbox每个词一个框粒度细page.get_text(dict)返回最完整的结构包含 block → line → span → char 四级每级都带 bbox 和字体信息做多栏还原我一般用dict模式因为它保留了 line 级别的 bbox而 XY-cut 的切分单元通常就是「行」或者「块」。粒度太粗整页一个块没法切粒度太细字符级计算量大且容易受噪声干扰。提示PyMuPDF 的 bbox 是 float 类型比较时不要用要用容差判断比如abs(a - b) 1e-3。我踩过这个坑两个理论上相等的坐标因为浮点误差判不相等导致切分逻辑出错。2.2 XY-cut 的递归切分思想XY-cut 的核心思路非常朴素一页文档总能找到一条水平或垂直的空白缝隙把内容切成两块对每一块递归地继续切直到切不动为止。切分的顺序就决定了阅读顺序。具体来说算法在两个方向交替尝试先尝试水平切分沿 y 轴找空白带把页面切成上下若干段对每一段再尝试垂直切分沿 x 轴找空白带切成左右若干栏递归下去直到某个区域既不能水平切也不能垂直切为什么这个顺序有效因为绝大多数文档的宏观结构是「先上下分节节内再分栏」。先水平切能先把页眉、正文、页脚分开再在正文区域里做垂直切分识别多栏逻辑上最自然。判断「空白带」需要一个阈值如果某条水平线上所有文本块的 y 范围都不覆盖它那这条线就是空白。实际实现时我会把页面投影到 y 轴上统计每个 y 坐标被多少文本覆盖覆盖数为 0 的连续区间就是候选切分线。垂直方向同理投影到 x 轴。2.3 PyMuPDF 在整条链路里的位置PyMuPDF 负责的是信息提取不负责阅读顺序判断。它把每个文本块的坐标、字体、大小老老实实交出来剩下的切分逻辑要我们自己写。这也是为什么很多人直接用page.get_text()拿到的文本顺序是乱的——PyMuPDF 默认按它内部的存储顺序输出这个顺序和视觉阅读顺序不一定一致尤其是多栏文档。所以整条链路是PyMuPDF 提取带 bbox 的文本块 → 我们自己实现 XY-cut 还原顺序 → 输出正确排序的文本 → 交给下游切块和向量化。水印处理则是在提取阶段就做过滤把水印块识别出来剔除掉不进入后续流程。3. 实操环境搭建与基础提取3.1 依赖安装与版本选择pip install pymupdf就这一个依赖。PyMuPDF 的 wheel 包自带 MuPDF 引擎不需要额外装 poppler 之类的系统库这点比 pdfplumber 省心。版本上建议用 1.23 以上老版本在dict模式的 span 结构上有差异。我实测下来1.24.x 在中文 PDF 的 bbox 精度上表现稳定如果遇到某些 PDF 提取出来的坐标明显偏移先检查版本。3.2 提取带 bbox 的文本块先写一个基础提取函数把每个文本行的 bbox、文本内容、字体大小都拿出来import fitz def extract_lines(page): 提取页面中所有文本行返回带 bbox 的行列表 lines [] d page.get_text(dict) for block in d[blocks]: if block.get(type) ! 0: # 0 是文本块1 是图像块 continue for line in block[lines]: text .join(span[text] for span in line[spans]) if not text.strip(): continue bbox line[bbox] # (x0, y0, x1, y1) # 取该行主要字体的字号用于后续判断标题/正文 sizes [span[size] for span in line[spans]] avg_size sum(sizes) / len(sizes) lines.append({ text: text, bbox: bbox, size: avg_size, }) return lines这里有个细节line[bbox]是这一行所有 span 的并集框直接用就行。字号我取了平均值因为一行里可能混着不同字号的 span比如加粗关键词平均值能反映这一行的整体视觉权重。3.3 坐标系转换的坑前面提过 PDF 原点在左下角。但如果你后续要把 bbox 画到图片上做可视化调试强烈建议做这一步图片坐标系原点在左上角需要转换def pdf_bbox_to_image(bbox, page_height): x0, y0, x1, y1 bbox return (x0, page_height - y1, x1, page_height - y0)这个转换我一开始老是搞反导致可视化框上下颠倒。记住一句话PDF 的 y0 是底部图片的 y0 是顶部所以要用页高减去。注意调试阶段一定要把 bbox 画出来看。我见过太多人对着代码逻辑推半天结果发现是坐标理解错了。可视化五分钟省下两小时。4. 多栏排版还原XY-cut 完整实现4.1 投影法找切分线先实现投影函数把一组文本行投影到某个轴上找出空白区间def find_gaps(lines, axis, min_gap10): 在指定轴上找空白区间 axisy 找水平切分线axisx 找垂直切分线 min_gap: 空白区间最小宽度小于此值不算切分线 if axis y: intervals [(l[bbox][1], l[bbox][3]) for l in lines] else: intervals [(l[bbox][0], l[bbox][2]) for l in lines] intervals.sort() gaps [] 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 gapsmin_gap这个参数很关键。太小了会把行间距误判成切分线太大了会漏掉真正的栏间距。经验值水平切分用 15~20垂直切分用 10~15单位是 PDF point1 point ≈ 1/72 英寸。如果是双栏论文栏间距通常在 20 point 以上用 15 比较稳。4.2 递归切分主逻辑def xy_cut(lines, depth0, max_depth20): 递归 XY-cut返回排好序的行列表 if len(lines) 1 or depth max_depth: return sorted(lines, keylambda l: (l[bbox][1], l[bbox][0])) # 先尝试水平切分 h_gaps find_gaps(lines, y, min_gap15) if h_gaps: # 用第一条足够宽的空白带切 cut_y (h_gaps[0][0] h_gaps[0][1]) / 2 top [l for l in lines if l[bbox][3] cut_y] bottom [l for l in lines if l[bbox][1] cut_y] if top and bottom: return xy_cut(top, depth 1) xy_cut(bottom, depth 1) # 水平切不动尝试垂直切分 v_gaps find_gaps(lines, x, min_gap10) if v_gaps: cut_x (v_gaps[0][0] v_gaps[0][1]) / 2 left [l for l in lines if l[bbox][2] cut_x] right [l for l in lines if l[bbox][0] cut_x] if left and right: return xy_cut(left, depth 1) xy_cut(right, depth 1) # 都切不动按 y 再按 x 排序 return sorted(lines, keylambda l: (l[bbox][1], l[bbox][0]))这段逻辑有几个关键点值得展开。为什么先水平后垂直因为文档的宏观结构通常是「页眉 → 正文 → 页脚」的上下分层先水平切能先把这些层分开。如果先垂直切页眉和正文可能被切到同一栏里顺序就乱了。为什么只取第一条空白带理论上可以一次切出多段但递归处理更简单也更稳。每次切一刀把问题规模缩小递归下去自然能处理多段。实测下来递归版本对复杂排版的适应性更好。切分失败的兜底如果两个方向都找不到空白带说明这个区域内部是紧密排布的比如一个表格或者一段密集文字直接按 y 再按 x 排序即可。这个兜底很重要否则递归会死循环。4.3 双栏论文的实测效果拿一篇典型的双栏学术论文测试。不做 XY-cut直接page.get_text()输出是这样的本文提出了一种新的方法用于解决 在实验部分我们对比了三种基线 该问题在大规模数据集上 模型在准确率上分别提升了 的表现一直不理想 3.2% 和 5.1%左右栏交错完全没法读。跑完 XY-cut 之后本文提出了一种新的方法用于解决该问题在大规模数据集上的表现一直不理想 在实验部分我们对比了三种基线模型在准确率上分别提升了3.2%和5.1%顺序正确了。这个差异直接决定了后续 chunk 的语义完整性。实操心得XY-cut 的递归深度建议设个上限我用的 20防止某些异常 PDF 导致无限递归。另外每次递归前判断len(lines) 1直接返回这是递归的终止条件。5. 水印识别与过滤实战5.1 水印的三种典型形态水印在 PDF 里通常以三种形式存在处理方式各不相同水印形态特征处理方式文本水印是真实文字有 bbox 和字体按字体/颜色/位置过滤图像水印是图片对象无文本提取阶段就跳过图像块矢量水印是绘制指令曲线、路径PyMuPDF 的 dict 模式不返回天然过滤最容易出问题的是文本水印因为它和正文一样是文字会混进文本流里。常见的文本水印有「机密」「草稿」「公司名」这类通常有这些特征字号偏大、颜色浅灰色、旋转角度、位置居中或斜跨页面。5.2 基于字体颜色的过滤PyMuPDF 的 span 里有color字段是个整数需要转成 RGBdef int_to_rgb(color_int): r (color_int 16) 255 g (color_int 8) 255 b color_int 255 return (r, g, b) def is_light_color(color_int, threshold200): 判断是否为浅色水印常见特征 r, g, b int_to_rgb(color_int) return r threshold and g threshold and b threshold浅灰色水印的 RGB 通常在 (200, 200, 200) 以上正文黑色是 (0, 0, 0)。用 200 作为阈值能过滤掉大部分浅色水印同时不会误伤正文。5.3 基于旋转角度的过滤水印经常是斜着印的。PyMuPDF 的 line 里有dir字段表示文字方向def is_rotated(line, tolerance0.1): 判断文本行是否旋转非水平 dx, dy line[dir] return abs(dy) tolerance正常横排文字的dir是(1, 0)dy接近 0。斜水印的dy会明显偏离 0。这个判断对斜向水印非常有效。5.4 综合过滤函数把上面的判断组合起来在提取阶段就过滤掉水印def extract_lines_filtered(page): lines [] d page.get_text(dict) for block in d[blocks]: if block.get(type) ! 0: continue for line in block[lines]: # 过滤旋转文字 if is_rotated(line): continue spans line[spans] # 过滤浅色文字 if all(is_light_color(s[color]) for s in spans): continue text .join(s[text] for s in spans) if not text.strip(): continue lines.append({ text: text, bbox: line[bbox], size: sum(s[size] for s in spans) / len(spans), }) return lines注意过滤条件不要设太狠。我一开始把「字号大于正文 1.5 倍」也当成水印特征结果把标题全过滤了。水印和标题的区别在于颜色和旋转字号只能作为辅助判断不能单独用。5.5 水印过滤的边界情况有些水印是半透明叠加的颜色不浅但透明度高。PyMuPDF 的 span 里有alpha字段部分版本支持可以据此判断。如果版本不支持就只能靠位置和重复性判断——水印往往在每页的相同位置重复出现可以跨页统计把高频重复的文本块标记为水印。还有一种情况是水印和正文重叠bbox 有交集。这种最难处理我的做法是如果某个文本块的 bbox 和多个其他块都有大面积重叠且字号明显偏大就判定为水印。这个规则不是万能的但能覆盖大部分场景。6. 常见问题排查与避坑清单6.1 问题速查表现象可能原因排查方向文本顺序仍然错乱XY-cut 阈值不合适调小 min_gap打印切分线位置部分文字丢失过滤条件过严临时关闭过滤看原始提取结果bbox 坐标异常PDF 坐标系理解错误可视化 bbox确认原点位置中文乱码字体编码问题检查 span 的 font 字段尝试get_text(text)递归超时切分逻辑死循环加递归深度上限检查切分条件水印没过滤干净水印特征不匹配打印水印块的 color 和 dir 值6.2 三个我踩过的坑坑一把行间距当栏间距。双栏文档里同一栏内行与行之间也有空白如果 min_gap 设太小比如 5XY-cut 会把每一行都当成独立区域切顺序反而更乱。解决办法是垂直切分的 min_gap 至少设 10最好 15。坑二忽略页眉页脚。页眉页脚横跨整页宽度会干扰垂直切分——因为它们的存在x 轴上找不到贯穿的空白带。我的做法是先用水平切分把页眉页脚切出去再对正文区域做垂直切分。这也是为什么 XY-cut 要先水平后垂直。坑三水印过滤误伤正文。有些正文用了浅灰色做注释被颜色过滤误杀了。后来我改成「颜色 旋转」双重判断只有同时满足浅色和旋转才判定为水印误伤率大幅下降。6.3 调试技巧把 bbox 画出来这是我最推荐的调试手段。用 PyMuPDF 自带的绘制功能把每个文本块的 bbox 画到页面上导出成图片看def visualize_bbox(page, lines, output_path): # 给每个 bbox 画个红框 for l in lines: rect fitz.Rect(l[bbox]) page.draw_rect(rect, color(1, 0, 0), width0.5) pix page.get_pixmap(dpi150) pix.save(output_path)跑一遍打开图片一眼就能看出哪些块被正确识别、哪些被漏掉、切分线位置对不对。比对着代码逻辑推半天高效得多。6.4 性能优化建议XY-cut 的递归在页数多的时候会有点慢。两个优化方向缓存投影结果同一页的投影只算一次递归时复用限制切分粒度行数少于某个阈值比如 3 行就不再递归直接排序我实测过一篇 50 页的双栏论文优化前解析要 8 秒左右加上这两个优化后降到 3 秒以内。对于 RAG 的离线 ingestion 来说这个速度完全够用。7. 接入 RAG 管线的几个实践建议解析出来的文本顺序对了接下来就是怎么喂给 RAG。这里分享几个我在实际项目里的做法。第一保留 bbox 元数据。不要只输出纯文本把每个 chunk 对应的页码和 bbox 一起存下来。这样检索命中后可以精确定位到原文位置做高亮或者引用展示。用户看到答案能追溯到原文信任度会高很多。第二按语义块切分而非固定长度。有了 XY-cut 还原的顺序可以按段落、按标题层级切分而不是机械地每 500 字切一刀。语义完整的 chunk检索命中率明显更高。第三多栏文档的 chunk 不要跨栏。这是 XY-cut 带来的额外好处——切分边界天然和栏边界对齐chunk 不会横跨两栏语义更内聚。第四水印过滤后要校验。过滤完统计一下每页的文本量如果某页文本量骤降可能是过滤条件误伤了正文需要人工检查。我一般会设个告警阈值比如某页文本量低于平均值的 30% 就标记出来。第五表格和图片单独处理。XY-cut 处理的是文本流表格和图片需要单独提取。PyMuPDF 的page.get_images()能拿到图片表格则建议用专门的表格识别工具不要硬塞进文本流里。这套流程跑下来我手上几个 RAG 项目的检索命中率从 60% 出头提升到了 85% 以上主要贡献就来自解析层的修正。向量模型和切块策略当然也重要但解析是第一步第一步走对了后面才有优化的空间。最后分享一个小技巧如果你的 PDF 来源比较固定比如都是某几个期刊的论文可以把 XY-cut 的参数针对性地调优一次然后固化下来。不同来源的 PDF 排版差异很大一套参数打天下不现实分来源配置参数才是工程上的正解。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑