1. 为什么多栏排版和水印是 RAG 文档解析的硬骨头
做过 RAG 知识库的人都有一个共同体会:PDF 解析是整个链路里最脏最累的活。文本层提取本身不难,PyMuPDF 几行代码就能把字抠出来,但抠出来的顺序对不对、段落有没有被拦腰截断、页眉页脚和水印有没有混进正文,这些才真正决定后面检索和生成的质量。我见过太多项目,向量库建得漂漂亮亮,召回率一测就崩,回头一查,切片里全是“内部资料 请勿外传”和左右栏交错拼接的乱码。
这篇要聊的是 bbox 实战,也就是基于坐标框的版面分析。核心场景有两个:多栏排版和水印 PDF。多栏的痛点是阅读顺序,人眼知道先读左栏再读右栏,但机器默认按 y 坐标从上往下扫,结果就是把两栏的文字按行交错拼在一起,语义直接碎掉。水印的痛点是干扰,水印通常是大字号、浅颜色、旋转角度、覆盖全页,如果按普通文本块处理,它会反复出现在每个切片里,污染检索结果。
为什么用 bbox 而不是纯文本流?因为 PDF 里的文字本质上是带坐标的绘制指令,page.get_text("dict")拿到的每个 span 都带bbox,也就是(x0, y0, x1, y1)。有了坐标,我们就能做版面聚类、栏位切分、水印识别。XY-cut 是这里面最经典的算法,思路是先按 x 方向找投影空白切竖栏,再按 y 方向切段落,递归下去直到切不动为止。PyMuPDF 负责取 bbox,XY-cut 负责组织顺序,两者配合基本能覆盖 80% 的学术论文、报告、说明书类 PDF。
适合谁来参考?如果你正在搭 RAG 知识库、做文档问答、搞合同或论文解析,并且已经被多栏和水印折磨过,这篇就是给你写的。下面我会把选型逻辑、bbox 结构、XY-cut 实现、水印过滤、参数调优、踩坑记录全部摊开讲,代码可以直接抄。
2. 整体方案设计与技术选型思路
2.1 为什么是 PyMuPDF 而不是 pdfplumber 或 pdfminer
选型这件事我踩过不少坑。pdfminer 是最底层的,信息全但慢,API 反人类,做个 bbox 提取要写一堆 LTPage 遍历。pdfplumber 基于 pdfminer,封装友好,extract_words()直接给 bbox,调试方便,但大文件性能一般,而且它对旋转文字和复杂字体的处理偶尔会丢字。PyMuPDF 底层是 MuPDF,C 实现,速度快一个量级,get_text("dict")返回的结构清晰,span 级别带字体、字号、颜色、bbox,做水印识别时颜色信息特别关键。
我实测过一个 300 页的双栏学术 PDF,pdfplumber 提取加版面分析大概 40 秒,PyMuPDF 只要 6 秒左右。RAG 场景往往要批量处理成千上万个文档,这个差距会被放大到不可接受。所以主力用 PyMuPDF,遇到它解析异常的个别页面再用 pdfplumber 兜底,这是我目前的稳定组合。
2.2 bbox 坐标系与 PDF 的“坑爹”原点
这里必须先讲清楚坐标系,否则后面所有计算都是错的。PDF 的原点在左下角,y 轴向上为正。但 PyMuPDF 为了符合阅读习惯,默认把原点转到了左上角,y 轴向下为正。也就是说page.get_text("dict")里的 bbox,y0是上边,y1是下边,y0 < y1。这个转换很贴心,但如果你同时用page.get_pixmap()渲染图片,图片坐标又是左上原点,两者能对上,这点比 pdfplumber 省心。
一个 span 的 bbox 长这样:(x0, y0, x1, y1),分别对应左、上、右、下。宽度是x1 - x0,高度是y1 - y0。做栏位切分时我们关心 x 方向的投影,做段落合并时关心 y 方向的间距。记住一个经验值:正常正文行高在 10 到 14 之间,行间距小于行高的 0.6 倍通常属于同一段,大于 1.5 倍往往是段落分隔。
2.3 XY-cut 的核心思想与适用边界
XY-cut 本质是递归投影切分。先统计所有文本块在 x 轴上的覆盖情况,找到那些“没有任何文本经过”的竖直空白带,用它们把页面切成若干竖条,这就是分栏。然后在每个竖条内部,再统计 y 轴的空白带,切成横条,也就是段落。递归进行,直到切出的区域里只剩一个文本块或者空白带宽度小于阈值。
它的优势是无需训练、可解释、速度快,对规整的多栏排版效果极好。边界也很明确:遇到不规则图文混排、文字绕图、斜排文字时会失效。所以我的策略是 XY-cut 打底,配合 bbox 重叠检测做修正,而不是指望它包打天下。水印处理则是另一条线,靠颜色、字号、旋转角度、覆盖范围四个特征联合判断,不依赖 XY-cut。
3. 核心细节解析与实操要点
3.1 用 get_text("dict") 拿到结构化 bbox
先看基础提取代码,这是所有后续处理的输入。
import fitz # PyMuPDF doc = fitz.open("sample.pdf") page = doc[0] data = page.get_text("dict") for block in data["blocks"]: if block["type"] != 0: # 0 是文本块,1 是图片块 continue for line in block["lines"]: for span in line["spans"]: print(span["bbox"], span["text"], span["size"], span["color"])这里有几个关键点。blocks里混着文本块和图片块,用type区分,图片块没有lines。span是最小单位,通常一个 span 内字体字号颜色一致。color是整数,需要转成 RGB 看,比如0是黑色,16711680是红色。水印识别时这个字段是命门。
注意:
get_text("dict")默认按 PDF 内部绘制顺序返回,不保证阅读顺序。多栏 PDF 里经常出现右栏的块排在左栏前面,所以千万别直接按返回顺序拼接文本,一定要自己做排序。
3.2 文本块归一化与无效块过滤
原始 block 粒度太粗,一个 block 可能横跨两栏,直接用它做 XY-cut 会出错。我的做法是把所有 span 提升为独立的“文本单元”,每个单元带 bbox、文本、字号、颜色、是否旋转。然后做一轮过滤:去掉空白 span、去掉纯页码(通常是单数字且位于页面顶部或底部边缘)、去掉页眉页脚(y 坐标在页面上下 5% 区域内且字号偏小)。
def normalize_spans(page): units = [] h = page.rect.height w = page.rect.width for block in page.get_text("dict")["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"] # 过滤页眉页脚 if y1 < h * 0.05 or y0 > h * 0.95: continue units.append({ "bbox": (x0, y0, x1, y1), "text": text, "size": span["size"], "color": span["color"], "dir": line["dir"], # 方向向量,判断旋转 }) return unitsline["dir"]是个二元组,正常水平文字是(1, 0),旋转文字会变。水印经常带旋转,这个字段能帮上忙。
3.3 水印的四个识别特征
水印不是靠单一特征能认出来的,我总结了一套组合拳,实测准确率很高。
第一是颜色。水印为了不遮挡正文,通常是浅灰、浅蓝、浅红,RGB 值偏高且饱和度低。正文黑色是(0,0,0),把 color 转成 RGB 后,如果三个通道都大于 180,基本可以怀疑。
第二是字号。水印往往比正文大很多,正文 10 到 12,水印可能 30 到 60。但要注意有些小水印字号也小,所以字号只作为辅助。
第三是旋转角度。从dir算角度,math.atan2(dir[1], dir[0]),非零角度高度可疑。
第四是覆盖范围。水印通常横跨页面大部分宽度,或者重复出现多次。统计同一文本在页面内出现的次数,出现 3 次以上且内容相同的,大概率是水印。
import math def is_watermark(unit, page_w, page_h): x0, y0, x1, y1 = unit["bbox"] r = (unit["color"] >> 16) & 255 g = (unit["color"] >> 8) & 255 b = unit["color"] & 255 light = r > 180 and g > 180 and b > 180 big = unit["size"] > 25 angle = math.degrees(math.atan2(unit["dir"][1], unit["dir"][0])) rotated = abs(angle) > 5 wide = (x1 - x0) > page_w * 0.5 score = sum([light, big, rotated, wide]) return score >= 2用打分制而不是一刀切,是因为不同 PDF 的水印特征不一样,两票通过比较稳。你可以根据自己文档的特点调整阈值。
4. 实操过程与核心环节实现
4.1 XY-cut 递归切分完整实现
下面是我实际在用的 XY-cut,做了工程化封装。核心是投影函数和递归切分。
def projection(units, axis): """axis=0 投影到 x 轴,axis=1 投影到 y 轴""" intervals = [] for u in units: x0, y0, x1, y1 = u["bbox"] if axis == 0: intervals.append((x0, x1)) else: intervals.append((y0, y1)) return intervals def find_gaps(intervals, min_gap): """在区间集合里找空白带""" if not intervals: return [] 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 gaps def xy_cut(units, page_w, page_h, depth=0): if len(units) <= 1 or depth > 10: return units # 先切 x(分栏) x_intervals = projection(units, 0) x_gaps = find_gaps(x_intervals, min_gap=page_w * 0.03) if x_gaps: # 用第一个足够宽的空白带切 cut = x_gaps[0] left = [u for u in units if u["bbox"][2] <= cut[0] + 1] right = [u for u in units if u["bbox"][0] >= cut[1] - 1] if left and right: return xy_cut(left, page_w, page_h, depth+1) + \ xy_cut(right, page_w, page_h, depth+1) # 再切 y(分段) y_intervals = projection(units, 1) y_gaps = find_gaps(y_intervals, min_gap=8) if y_gaps: cut = y_gaps[0] top = [u for u in units if u["bbox"][3] <= cut[0] + 1] bottom = [u for u in units if u["bbox"][1] >= cut[1] - 1] if top and bottom: return xy_cut(top, page_w, page_h, depth+1) + \ xy_cut(bottom, page_w, page_h, depth+1) return units这里min_gap是灵魂参数。x 方向我用页面宽度的 3%,A4 纸约 595 宽,也就是 18 点左右,能区分大多数双栏间距。y 方向用 8 点,对应行间距的阈值。切分顺序先 x 后 y,是因为分栏优先级高于分段,先确定栏位再在栏内分段,逻辑更干净。
4.2 切分后的阅读顺序重建
XY-cut 返回的是切分后的单元列表,但顺序还需要按“先左栏后右栏、栏内从上到下”重排。我的做法是给每个单元算一个排序键:先按栏位(x 中心落在哪个区间),再按 y0。
def rebuild_order(units, page_w): # 简单两栏假设,多栏可扩展 mid = page_w / 2 def sort_key(u): x0, y0, x1, y1 = u["bbox"] cx = (x0 + x1) / 2 col = 0 if cx < mid else 1 return (col, y0) return sorted(units, key=sort_key)如果是三栏或更复杂,可以把 XY-cut 切出的栏位区间记下来,按区间左边界排序。我一般会在切分时把栏位信息带出来,而不是事后猜。
4.3 水印过滤与正文合并
水印识别出来后,直接从 units 里剔除,再走 XY-cut。这里有个顺序问题:先滤水印再切分,因为水印横跨全页会破坏投影空白带,导致 XY-cut 找不到栏间空隙。我一开始就是先切分后滤水印,结果双栏文档死活切不开,排查半天才发现是水印把中间空白填满了。
def parse_page(page): units = normalize_spans(page) page_w, page_h = page.rect.width, page.rect.height # 先滤水印 units = [u for u in units if not is_watermark(u, page_w, page_h)] # 再 XY-cut ordered = xy_cut(units, page_w, page_h) ordered = rebuild_order(ordered, page_w) # 合并成段落文本 text = "\n".join(u["text"] for u in ordered) return text合并时我建议按 y 间距判断是否换行还是换段。同一行内多个 span 直接拼接,行间距小于 1.5 倍行高视为同段,否则加换行。这样出来的文本段落完整,切片时语义不碎。
4.4 参数调优的实测记录
我拿三类文档做了对比测试:双栏学术论文、带水印的合同、单栏技术手册。关键参数是 x 方向 min_gap 和 y 方向 min_gap。
| 文档类型 | x_min_gap | y_min_gap | 切分准确率 |
|---|---|---|---|
| 双栏论文 | 页面宽 3% | 8 | 95% |
| 带水印合同 | 页面宽 3% | 10 | 92% |
| 单栏手册 | 页面宽 5% | 6 | 98% |
单栏文档 x 方向其实不需要切,但设太小会误切(比如表格里的列),所以单栏我调到 5%。y 方向合同用 10 是因为合同行距大,用 8 会把本该分开的条款合并。这些值不是固定的,建议你先拿 20 页样本跑一遍,看切分结果再定。
5. 常见问题与排查技巧实录
5.1 切分结果错乱的排查思路
最常见的症状是“文字顺序还是乱的”。排查顺序我固定为三步。第一步,打印所有 unit 的 bbox,看有没有异常大的块横跨页面,如果有,说明 span 归一化没做好,可能某个 block 没拆开。第二步,检查水印是否滤干净,把 is_watermark 的判断结果打出来,看有没有漏网。第三步,看 XY-cut 的 gaps 是否为空,如果为空说明投影没有空白带,可能是文字块之间重叠了,需要放宽 min_gap 或者先做块合并。
5.2 水印漏检与误杀
漏检通常是水印颜色不够浅,或者字号不够大。这时候把打分阈值从 2 降到 1,但会带来误杀风险。误杀正文的典型是浅色标题被当成水印,解决办法是加一条“文本长度”判断,水印往往短且重复,正文标题通常较长且唯一。我一般会统计全文档的文本频次,出现次数超过页面数 30% 的短文本才纳入水印候选。
5.3 旋转文字与竖排文本
有些 PDF 的页边有竖排文字,dir不是(1,0)。这类文字如果混进正文会很难看。我的处理是:角度绝对值大于 30 度的直接丢弃或单独存到 metadata,不参与正文拼接。竖排中文在 dir 上表现为(0,1)或(0,-1),同样处理。
5.4 性能优化:大文档批量处理
批量处理时不要每页都重新 open。用fitz.open打开一次,遍历页面。另外get_text("dict")比get_text("text")慢,但信息全,值得。如果文档超过 1000 页,建议分块处理并加进度日志,避免内存堆积。我实测 1000 页双栏 PDF,全流程约 90 秒,内存稳定在 500MB 以内。
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 文字顺序交错 | 水印未滤或 span 未拆 | 先滤水印,检查 block 拆分 |
| 栏位切不开 | 水印填满空白带 | 调整滤水印顺序 |
| 段落被截断 | y_min_gap 过大 | 调小 y 方向阈值 |
| 正文被误删 | 水印阈值过松 | 提高打分阈值或加长度判断 |
| 处理速度慢 | 逐页重复打开 | 复用 doc 对象,批量遍历 |
6. 我在实际项目里的几点体会
bbox 实战这件事,工具和算法都是现成的,真正花时间的是调参和边界处理。我最大的体会是:不要追求一步到位的通用方案。不同来源的 PDF 差异极大,学术论文、扫描件、合同、说明书各有各的排版习惯。我的做法是先做一套默认参数,然后针对每个文档来源建一个小的参数配置,跑样本验证后再批量。这样虽然前期麻烦,但后期召回率稳定得多。
另一个体会是水印过滤宁可保守一点。漏掉几个水印,最多是切片里多几句噪声;误杀正文,那是直接丢信息,检索时怎么都找不回来。所以我的阈值一直偏严,宁可放过也不误杀。还有个小技巧,把滤掉的水印文本单独存一份日志,出问题时能快速定位是哪个规则误伤了。
最后说个扩展方向。XY-cut 对规整排版够用,但如果你的文档里有大量表格和图文混排,可以考虑引入版面检测模型做辅助,把 bbox 喂给模型判断区域类型,再决定切分策略。这条路我还在试,目前看对复杂报表类 PDF 提升明显,但成本和复杂度也上去了,是否值得取决于你的文档构成。