
1. 为什么多栏排版和水印是 RAG 文档解析的硬骨头做过 RAG 知识库的人都有一个共识PDF 解析是整个链路里最脏最累的活。文本型 PDF 还好一旦碰上多栏排版、带水印、带页眉页脚的学术论文或者扫描件很多解析工具直接歇菜。我见过太多项目embedding 模型选得挺好向量库也搭得漂亮结果检索出来的 chunk 全是乱的——两栏文字被交错拼在一起水印文字混进了正文页眉页脚反复出现在每个片段里。这种数据喂给大模型回答质量能好才怪。这篇要聊的是 bbox 实战核心解决两个具体问题多栏排版的阅读顺序还原和水印文字的识别与剔除。用到的工具是 PyMuPDF也就是 fitz配合 XY-cut 算法做版面分析。整套方案不依赖任何外部 OCR 服务纯本地跑适合对数据隐私有要求的场景。适合谁看如果你正在搭 RAG 知识库已经过了能跑通的阶段开始被解析质量卡脖子那这篇就是写给你的。如果你还没接触过 bbox 这个概念也不用慌我会从最基础的坐标系统讲起保证你能跟着复现。先说清楚 bbox 是什么。bbox 就是 bounding box边界框PDF 里每一个文本块、每一行、每一个字符都有自己的坐标范围通常用 (x0, y0, x1, y1) 表示分别是左上角和右下角的坐标。PyMuPDF 的page.get_text(dict)能把整页内容拆成 block、line、span 三级结构每一级都带 bbox。拿到这些坐标我们就能做版面分析——判断哪些文字属于左栏哪些属于右栏哪些是水印。为什么不用现成的 PDF 解析库比如 pdfplumber、pdfminer 这些。它们也能拿到坐标但在处理复杂版面时要么速度慢要么对旋转文字、倾斜水印的支持不够。PyMuPDF 底层是 MuPDFC 写的速度快坐标信息全还能拿到文字的旋转角度和字体信息做水印识别时这些信息很关键。XY-cut 算法是版面分析里的经典方法思路很朴素递归地在水平和垂直方向上找空白切割线把页面切成一个个独立的区域。多栏排版的核心特征就是中间有一条垂直的空白带XY-cut 能自动找到这条带把左右栏分开。水印的识别则要靠字体、颜色、旋转角度、透明度这几个维度的特征组合判断。下面我会把整个流程拆开讲从环境准备到代码实现再到踩坑经验尽量做到你复制过去就能用。2. 环境准备与 PyMuPDF 基础操作2.1 安装与版本选择PyMuPDF 的安装很简单pip install PyMuPDF但版本选择有讲究。1.23.x 之后的版本对get_text(dict)的输出结构做过调整如果你参考的教程比较老可能会对不上。我目前用的是 1.24.x稳定性和功能都比较均衡。安装完可以这样验证import fitz print(fitz.__doc__)输出里会显示 MuPDF 的版本和 PyMuPDF 的版本。如果显示的是 1.24 以上后面的代码基本可以直接跑。注意PyMuPDF 的 import 名是fitz不是pymupdf。虽然新版本也支持import pymupdf但为了兼容性建议统一用fitz。2.2 理解 PDF 的坐标系统PDF 的坐标原点和我们平时用的屏幕坐标不一样。原点在页面左下角x 轴向右y 轴向上。但 PyMuPDF 做了转换它返回的坐标是原点在左上角y 轴向下这样更符合我们的直觉。这一点很重要后面做 XY-cut 的时候判断上方和下方都基于这个坐标系。拿一个 A4 页面举例宽度约 595 点point高度约 842 点。1 点等于 1/72 英寸。如果你拿到一个 bbox 是 (50, 100, 300, 120)意思是这个文本块从左边 50 点、顶部 100 点开始到右边 300 点、底部 120 点结束。宽度 250 点高度 20 点大概是一行文字。2.3 提取页面结构化文本核心 API 是page.get_text(dict)返回的结构是这样的{ width: 595.0, height: 842.0, blocks: [ { type: 0, # 0 表示文本块1 表示图片 bbox: (x0, y0, x1, y1), lines: [ { bbox: (x0, y0, x1, y1), wmode: 0, # 书写模式0 水平1 垂直 dir: (1, 0), # 文字方向向量 spans: [ { bbox: (x0, y0, x1, y1), text: 文字内容, font: 字体名, size: 12.0, flags: 0, color: 0, origin: (x, y), } ] } ] } ] }span 是最小单位通常对应一段连续的同格式文字。line 是一行block 是一个段落或一个独立的文本区域。做版面分析时我们主要在 block 和 line 这两个层级上操作。我习惯先把所有 span 拍平成一个列表每个元素带上它所属的 block 索引和 line 索引方便后续回溯def extract_spans(page): spans [] data page.get_text(dict) for bi, block in enumerate(data[blocks]): if block[type] ! 0: continue for li, line in enumerate(block[lines]): for si, span in enumerate(line[spans]): spans.append({ block: bi, line: li, span: si, bbox: span[bbox], text: span[text], font: span[font], size: span[size], color: span[color], dir: line[dir], origin: span[origin], }) return spans这个列表就是后面所有分析的原材料。2.4 可视化调试把 bbox 画出来光看坐标数字很难判断对不对我强烈建议先把 bbox 画到页面上看一眼。PyMuPDF 支持在页面上画矩形def draw_bboxes(page, spans, output_path): for span in spans: rect fitz.Rect(span[bbox]) page.draw_rect(rect, color(1, 0, 0), width0.5) pix page.get_pixmap(dpi150) pix.save(output_path)跑一遍打开图片你就能直观看到每个 span 的范围。这一步在调试 XY-cut 参数时特别有用能帮你快速定位问题。我刚开始做的时候就是因为没可视化调了半天参数都不知道错在哪。3. XY-cut 算法还原多栏阅读顺序3.1 多栏排版的本质问题先说说多栏排版为什么会让解析结果乱掉。PyMuPDF 返回的 block 顺序默认是按 PDF 内部的绘制顺序来的不一定是人眼的阅读顺序。对于两栏论文PDF 可能先画完左栏第一段再画右栏第一段再画左栏第二段……这样提取出来的文本就是交错的。更麻烦的是有些 PDF 的 block 会把左右栏的同一行合并成一个 block因为它们在垂直方向上有重叠。这时候你按 block 提取得到的就是左栏文字 右栏文字混在一行。XY-cut 的思路是不管 PDF 内部怎么组织我只看坐标。如果页面中间有一条从上到下的空白带把页面分成左右两部分那这两部分就是两个独立的阅读区域应该先读完左边再读右边。3.2 XY-cut 的核心逻辑XY-cut 是一个递归算法每一步做两件事投影把所有 bbox 投影到 x 轴和 y 轴上得到每个位置的覆盖情况。找切割线在投影的空白区域里找一条最宽的切割线把当前区域切成两块。递归对切出来的两块分别重复上述过程直到无法再切。判断用水平切还是垂直切有个经验规则先尝试垂直切分栏如果切出来的区域宽度比例合理就采用否则尝试水平切分段。实际实现时通常是两种都试选切割线最宽的那个方向。投影的具体做法假设当前区域的范围是 (x0, y0, x1, y1)把所有落在这个区域内的 bbox 拿出来在 x 轴上做区间覆盖。比如有三个 bbox 的 x 范围分别是 [50, 300]、[320, 570]、[50, 300]那么 x 轴上的覆盖情况是50-300 被覆盖两次320-570 被覆盖一次300-320 之间是空白。这条 300-320 的空白带就是候选的垂直切割线。3.3 代码实现先写投影函数def project(bboxes, axis): 把 bboxes 投影到指定轴上返回覆盖区间列表 intervals [] for bbox in bboxes: if axis x: intervals.append((bbox[0], bbox[2])) else: intervals.append((bbox[1], bbox[3])) intervals.sort() merged [] for start, end in intervals: if merged and start merged[-1][1]: merged[-1] (merged[-1][0], max(merged[-1][1], end)) else: merged.append((start, end)) return merged再写找切割线的函数def find_gap(merged, min_gap10): 在合并后的区间里找最大的空白间隙 gaps [] for i in range(len(merged) - 1): gap_start merged[i][1] gap_end merged[i 1][0] if gap_end - gap_start min_gap: gaps.append((gap_start, gap_end, gap_end - gap_start)) if not gaps: return None return max(gaps, keylambda g: g[2])然后是递归切割def xy_cut(bboxes, depth0, max_depth10): 返回切割后的区域列表每个区域是一个 bbox 列表 if depth max_depth or len(bboxes) 1: return [bboxes] # 尝试垂直切 x_merged project(bboxes, x) x_gap find_gap(x_merged) # 尝试水平切 y_merged project(bboxes, y) y_gap find_gap(y_merged) # 选择更宽的切割线 if x_gap and (not y_gap or x_gap[2] y_gap[2]): cut_pos (x_gap[0] x_gap[1]) / 2 left [b for b in bboxes if b[2] cut_pos] right [b for b in bboxes if b[0] cut_pos] if not left or not right: return [bboxes] return xy_cut(left, depth 1, max_depth) xy_cut(right, depth 1, max_depth) elif y_gap: cut_pos (y_gap[0] y_gap[1]) / 2 top [b for b in bboxes if b[3] cut_pos] bottom [b for b in bboxes if b[1] cut_pos] if not top or not bottom: return [bboxes] return xy_cut(top, depth 1, max_depth) xy_cut(bottom, depth 1, max_depth) else: return [bboxes]这段代码有几个关键参数需要调min_gap最小空白宽度默认 10 点。太小会把字间距当成切割线太大会漏掉真正的分栏。A4 两栏论文的栏间距通常在 15-25 点之间10 是个安全的起点。max_depth递归深度默认 10。防止无限递归一般 5-6 层就够了。切割线位置取间隙的中点避免偏向某一侧。3.4 把切割结果映射回文本XY-cut 返回的是一堆 bbox 分组每个分组代表一个阅读区域。接下来要按阅读顺序把这些区域排好再提取文字。阅读顺序的排序规则先按区域的上边界 y0 排序y0 相近的按 x0 排序。对于两栏论文左栏的 y0 和右栏的 y0 可能差不多但左栏的 x0 更小所以左栏会排在前面。这样就能保证先读左栏再读右栏。def sort_regions(regions): 按阅读顺序排序区域 def region_key(region): y0 min(b[1] for b in region) x0 min(b[0] for b in region) return (round(y0 / 10), x0) # y0 分桶避免微小差异影响排序 return sorted(regions, keyregion_key)round(y0 / 10)这个分桶操作很关键。如果两个区域的 y0 差了几个点直接比较会导致排序不稳定。分桶后y0 在同一个 10 点范围内的区域会被认为在同一行再按 x0 排序。最后把区域内的 span 按 (y0, x0) 排序拼接文字def extract_ordered_text(spans, regions): ordered_regions sort_regions(regions) result [] for region in ordered_regions: region_spans [s for s in spans if s[bbox] in region] region_spans.sort(keylambda s: (round(s[bbox][1] / 5), s[bbox][0])) text .join(s[text] for s in region_spans) result.append(text) return \n.join(result)3.5 实操心得什么时候 XY-cut 会失效XY-cut 不是万能的我踩过几次坑第一栏间距太小或者没有栏间距。有些 PDF 的两栏之间只有几个点的间隙min_gap10就找不到切割线了。这时候要把min_gap调小到 5 甚至 3但代价是可能把字间距误判成切割线。我的做法是先可视化看一眼确认栏间距的实际宽度再定参数。第二跨栏的图表或标题。如果页面顶部有一个横跨两栏的大标题XY-cut 会先水平切一刀把标题和正文分开这是对的。但如果图表跨栏且和正文有重叠切割就会出问题。这种情况我一般手动处理或者用page.get_drawings()把图形区域排除掉再做 XY-cut。第三三栏以上的排版。XY-cut 递归处理三栏没问题但排序规则要调整。三栏的阅读顺序是左、中、右按 x0 排序就行。但如果中间栏的某段文字和左右栏的 y0 差异很大分桶策略可能要调。实操建议XY-cut 的参数没有万能值每换一批 PDF 都要重新调。我的习惯是先用 20 份样本跑一遍人工检查阅读顺序统计错误率再决定参数。4. 水印识别与剔除的实战方案4.1 水印的几种常见形态水印这东西形态比多栏排版还杂。我见过的至少有这几类文字水印斜着铺满页面的机密、样本、仅供内部使用之类。图片水印一张半透明的 logo 或图案重复平铺。页眉页脚严格说不算水印但同样会污染正文处理思路类似。背景色块某些 PDF 会有浅色背景提取文字时可能带出多余字符。文字水印是最好处理的因为它在get_text(dict)里就是普通的 span只是特征和正文不一样。图片水印要靠page.get_images()或者page.get_drawings()来识别处理起来更麻烦这篇主要讲文字水印。4.2 水印的特征维度判断一个 span 是不是水印我通常看这几个维度特征正文的典型值水印的典型值字体宋体、黑体、Times 等常见字体但可能加粗或特殊字号10-14 点通常很大30-60 点颜色黑色 (0) 或深色浅灰、浅红等非黑色旋转角度0 度水平常见 45 度或 -45 度透明度不透明半透明位置在正文区域内可能超出正文边界重复性不重复同一页或跨页重复出现单一特征都不够可靠要组合判断。比如仅供内部使用这几个字如果字号大、颜色浅、还带旋转那基本可以确定是水印。但如果只是字号大可能是标题不能误杀。4.3 旋转角度的计算PyMuPDF 的 span 里没有直接的旋转角度但 line 的dir字段给了方向向量。水平文字的 dir 是 (1, 0)垂直的是 (0, 1)45 度旋转的可能是 (0.707, 0.707)。import math def get_rotation_angle(dir_vector): 从方向向量计算旋转角度度 dx, dy dir_vector angle math.degrees(math.atan2(dy, dx)) return angle正文的 angle 通常是 0 或接近 0。水印的 angle 可能是 45、-45、90 等。判断时给个容差比如abs(angle) 5就认为是旋转文字。4.4 颜色和透明度的判断颜色在 span 里是一个整数需要转成 RGBdef int_to_rgb(color_int): 把 PyMuPDF 的颜色整数转成 RGB 元组 r (color_int 16) 0xFF g (color_int 8) 0xFF b color_int 0xFF return (r, g, b)正文通常是黑色 (0, 0, 0) 或接近黑色。水印如果是浅灰RGB 值会比较高比如 (200, 200, 200)。判断时可以设个阈值比如sum(rgb) 400就认为是浅色。透明度 PyMuPDF 拿不到直接的值但可以通过颜色和背景的对比来间接判断。如果文字颜色和背景色很接近说明透明度高。这个判断比较复杂实际项目中我一般先用颜色阈值不够再补其他规则。4.5 综合判断函数把上面的特征组合起来def is_watermark(span, page_width, page_height): 综合判断一个 span 是否是水印 bbox span[bbox] text span[text].strip() if not text: return False # 特征1旋转角度 angle get_rotation_angle(span[dir]) is_rotated abs(angle) 5 # 特征2字号异常大 is_large span[size] 25 # 特征3颜色浅 rgb int_to_rgb(span[color]) is_light sum(rgb) 400 # 特征4位置异常超出正文区域 is_out_of_bounds ( bbox[0] 20 or bbox[2] page_width - 20 or bbox[1] 20 or bbox[3] page_height - 20 ) # 特征5文字内容匹配常见水印词 watermark_keywords [机密, 样本, 内部, 禁止, 复制, 草稿, 试用] has_keyword any(kw in text for kw in watermark_keywords) # 组合判断旋转 浅色或者大字号 浅色或者关键词 浅色 score 0 if is_rotated: score 2 if is_large: score 1 if is_light: score 2 if is_out_of_bounds: score 1 if has_keyword: score 2 return score 4这个打分机制的好处是灵活。不同 PDF 的水印特征不一样你可以根据实际情况调整权重和阈值。比如某批 PDF 的水印都是黑色的那就把is_light的权重降下来把is_rotated的权重提上去。4.6 跨页重复检测有些水印在每一页都出现而且位置固定。这种可以通过跨页比对来识别def find_repeated_spans(pages_spans, tolerance5): 找出在多页中重复出现的 span from collections import defaultdict position_map defaultdict(list) for page_idx, spans in enumerate(pages_spans): for span in spans: # 把 bbox 量化到 tolerance 精度 key ( round(span[bbox][0] / tolerance), round(span[bbox][1] / tolerance), span[text].strip() ) position_map[key].append(page_idx) # 出现在超过一半页面上的认为是水印 threshold len(pages_spans) / 2 repeated set() for key, pages in position_map.items(): if len(set(pages)) threshold: repeated.add(key) return repeated这个方法对固定位置的水印特别有效比如页脚的公司名、页眉的文档标题。但要注意如果文档本身就有重复的正文内容比如模板化的合同可能会误判。所以跨页检测的结果最好和单页特征判断结合使用。4.7 剔除水印后的文本重组识别出水印后从 span 列表里过滤掉再走 XY-cut 流程def clean_and_extract(page): spans extract_spans(page) page_width page.rect.width page_height page.rect.height # 过滤水印 clean_spans [ s for s in spans if not is_watermark(s, page_width, page_height) ] # 对剩余 span 做 XY-cut bboxes [s[bbox] for s in clean_spans] regions xy_cut(bboxes) # 按阅读顺序提取文字 text extract_ordered_text(clean_spans, regions) return text注意过滤水印要在 XY-cut 之前做。如果先做 XY-cut水印的 bbox 会干扰投影导致切割线找错。我一开始就是顺序搞反了调了半天才发现问题。5. 完整流程串联与参数调优5.1 端到端代码把前面的模块串起来import fitz def parse_pdf(pdf_path, min_gap10, watermark_threshold4): doc fitz.open(pdf_path) all_text [] for page_num, page in enumerate(doc): spans extract_spans(page) page_width page.rect.width page_height page.rect.height # 过滤水印 clean_spans [ s for s in spans if not is_watermark(s, page_width, page_height) ] if not clean_spans: continue # XY-cut bboxes [s[bbox] for s in clean_spans] regions xy_cut(bboxes, max_depth8) # 提取文字 text extract_ordered_text(clean_spans, regions) all_text.append(f--- Page {page_num 1} ---\n{text}) doc.close() return \n\n.join(all_text)5.2 参数调优的实操方法参数调优没有捷径就是拿样本反复试。我的流程是准备样本集至少 20 份 PDF覆盖单栏、双栏、三栏、带水印、不带水印各种情况。可视化对比用draw_bboxes把切割结果画出来人工检查阅读顺序对不对。统计错误率定义几个指标——阅读顺序错误率、水印漏检率、正文误删率。网格搜索对min_gap和watermark_threshold做网格搜索找错误率最低的组合。我实测下来min_gap在 8-15 之间比较稳watermark_threshold在 3-5 之间。具体值要看你的 PDF 来源。5.3 性能优化PyMuPDF 本身很快但 XY-cut 的递归在页面元素多的时候会慢。优化点提前终止如果当前区域的 span 数量少于 3 个直接返回不再递归。缓存投影结果同一区域的投影只算一次。限制递归深度max_depth6通常够用再深收益很小。我测过一个 200 页的论文合集单页解析时间从最初的 80ms 优化到 25ms 左右整体跑完不到 6 秒。6. 常见问题与排查技巧实录6.1 问题速查表问题现象可能原因排查方法解决方案左右栏文字交错XY-cut 没找到垂直切割线可视化 bbox看栏间距调小 min_gap水印混进正文水印特征不满足阈值打印水印 span 的特征值调整权重或阈值正文被误删正文特征像水印检查被删 span 的内容降低关键词权重阅读顺序颠倒排序规则不对打印区域排序结果调整 y0 分桶大小解析速度慢递归太深或元素太多加计时日志限制 max_depth提前终止表格内容错乱表格线干扰投影检查表格区域的 bbox排除图形区域6.2 几个我踩过的坑坑一把标题当成水印删了。有些 PDF 的章节标题字号很大颜色也偏浅结果被is_watermark误判。解决办法是加一条规则如果 span 的 y0 在页面上部 1/4 区域且文字长度小于 30 字降低水印分数。坑二XY-cut 把页眉页脚切成了独立区域。页眉页脚本身就该剔除但 XY-cut 会把它们当成正文区域参与排序导致阅读顺序里混入页眉页脚。我的做法是在 XY-cut 之前先用 y 坐标过滤掉页面顶部 5% 和底部 5% 的 span。坑三旋转文字的方向向量不准确。有些 PDF 的旋转文字dir字段返回的是 (1, 0)但实际是旋转的。这种情况要靠span[origin]和 bbox 的关系来判断。如果 origin 不在 bbox 的左下角附近说明文字可能是旋转的。坑四跨页水印的 bbox 有微小偏移。跨页检测时如果 tolerance 设得太小同一水印在不同页的 bbox 对不上。我一般设 tolerance5能容忍几个点的偏移。6.3 独家避坑技巧技巧一先分类再解析。不要对所有 PDF 用同一套参数。我习惯先跑一遍根据页面特征把 PDF 分成几类单栏、双栏、带水印、扫描件每类用不同的参数组合。分类可以用简单的规则比如统计页面中间区域的空白宽度大于 15 点的归为多栏。技巧二保留原始 span 信息。解析结果不要只存纯文本把每个 span 的 bbox、字体、字号也存下来。后面如果发现解析错误可以回溯到原始数据重新处理不用重新解析 PDF。技巧三人工抽检。自动化再厉害也要人工抽检。我一般随机抽 5% 的页面人工核对阅读顺序和水印剔除效果。发现系统性问题就调参数个别问题就手动修正。技巧四水印词库要维护。watermark_keywords这个列表要根据实际遇到的 PDF 不断补充。我现在的词库有 30 多个词覆盖了大部分常见水印。7. 与 RAG 链路的衔接解析出来的文本怎么喂给 RAG这里也有讲究。bbox 信息不要丢它在 chunk 切分时能派上大用场。传统的 chunk 切分是按字符数或按段落但 PDF 解析出来的文本段落边界不一定准确。有了 bbox你可以按阅读区域来切 chunk——每个 XY-cut 出来的区域就是一个天然的语义单元。这样切出来的 chunk语义完整性比按字符数切好得多。具体做法在extract_ordered_text里不要把所有区域拼成一个字符串而是每个区域单独作为一个 chunk带上它的 bbox 和页码作为元数据。检索时如果命中某个 chunk可以顺带返回它的位置信息方便溯源。def extract_chunks(page, page_num): spans extract_spans(page) clean_spans [s for s in spans if not is_watermark(s, page.rect.width, page.rect.height)] bboxes [s[bbox] for s in clean_spans] regions xy_cut(bboxes) ordered_regions sort_regions(regions) chunks [] for region in ordered_regions: region_spans [s for s in clean_spans if s[bbox] in region] region_spans.sort(keylambda s: (round(s[bbox][1] / 5), s[bbox][0])) text .join(s[text] for s in region_spans) if len(text.strip()) 10: continue chunks.append({ text: text, page: page_num, bbox: ( min(s[bbox][0] for s in region_spans), min(s[bbox][1] for s in region_spans), max(s[bbox][2] for s in region_spans), max(s[bbox][3] for s in region_spans), ), }) return chunks这样每个 chunk 都带位置信息检索命中后可以高亮原文位置用户体验会好很多。而且 chunk 的语义完整性高embedding 的质量也会提升。我在实际项目里对比过用 bbox 区域切 chunk 和按字符数切 chunk检索准确率能差 15-20 个百分点。这个差距在 RAG 场景里是很显著的。最后分享一个小技巧如果你的 PDF 里有大量表格XY-cut 对表格的处理不太理想。可以考虑先用page.find_tables()把表格区域识别出来单独处理剩下的区域再走 XY-cut。PyMuPDF 1.23 之后内置了表格识别效果还不错值得一试。