☰
RAG文档解析实战:bbox与XY-cut破解多栏排版和水印难题
2026/10/6 17:39:46 网站建设 项目流程

1. 为什么多栏排版和水印是 RAG 文档解析的硬骨头

做过 RAG 知识库的人都有一个共识:PDF 解析是整个链路里最脏最累的活。文本型 PDF 还好,一旦碰上学术论文、技术手册、产品白皮书这类多栏排版文档,再加上企业文档里常见的“内部资料”“禁止外传”水印,普通的文本提取工具直接歇菜。你拿到的文本要么是左右栏交错串行,要么是水印文字混进正文,检索出来的内容驴唇不对马嘴,用户问东你答西。

这个问题的本质在于:PDF 格式本身不存储“阅读顺序”这个概念。它只记录每个字符、每段文字在页面上的绝对坐标,至于这些文字应该按什么顺序读,PDF 文件不管。单栏文档碰巧按坐标排序就能得到正确顺序,但多栏排版下,同一水平线上的文字可能分属左右两栏,简单按 y 坐标排序就会把两栏内容搅在一起。水印更麻烦,它通常以半透明文字或图片形式覆盖在正文上方,坐标和正文重叠,纯文本提取根本区分不了哪些是正文、哪些是水印。

bbox(bounding box,边界框)就是解决这个问题的钥匙。每个文本块在 PDF 里都有精确的坐标信息,通过分析这些坐标的分布规律,我们可以用算法还原出人类阅读的自然顺序。这套方法在 RAG 文档解析领域已经比较成熟,核心工具就是PyMuPDF(也叫 fitz),配合XY-cut算法做版面分析。我前后在几个企业知识库项目里踩了不少坑,今天把完整思路和实操细节摊开讲。

这篇文章适合正在搭建 RAG 知识库、需要处理复杂 PDF 文档的工程师,也适合对文档解析感兴趣的技术爱好者。读完你能掌握:如何用 PyMuPDF 提取带坐标的文本块、如何用 XY-cut 算法还原多栏阅读顺序、如何识别并过滤水印文字、以及实际项目中会遇到哪些坑和怎么绕过去。

2. 核心思路拆解:从坐标到阅读顺序

2.1 PDF 文本提取的三种粒度

PyMuPDF 提供了多个层级的文本提取接口,理解它们的区别是做好解析的前提。

最粗粒度的是page.get_text(),直接返回整页纯文本。它内部有自己的排序逻辑,对单栏文档效果不错,但多栏文档经常出错,而且你拿不到坐标信息,没法做后续处理。

中等粒度是page.get_text("blocks"),返回文本块列表,每个块包含(x0, y0, x1, y1, text, block_no, block_type)。这里的块是 PyMuPDF 根据文本行间距和字体信息自动合并的,比单行大、比整页小,是比较好的分析单元。但它的合并策略不一定符合你的预期,有时候会把不同栏的文字合进同一个块。

最细粒度是page.get_text("dict"),返回完整的结构化字典,包含每个 block、每行 line、每个 span(连续同格式文本片段)的详细坐标和字体信息。做精细版面分析必须用这个级别,因为水印和正文往往在字体大小、颜色、透明度上有差异,只有到 span 级别才能拿到这些属性。

我的经验是:做多栏分析用 blocks 级别做粗分割,用 dict 级别做精细判断。先用 blocks 快速定位大的文本区域,再深入 dict 检查每个块的字体特征来识别水印。

2.2 XY-cut 算法的直觉理解

XY-cut 是版面分析里的经典算法,思路非常朴素:递归地用水平线和垂直线去切分页面,直到每个区域都只包含一个逻辑块。

想象你拿一把刀,先水平切一刀,把页面上明显上下分离的内容切开;再垂直切一刀,把左右分栏的内容切开;然后对每个子区域重复这个过程。切到最后,每个小区域就是一段连续的正文,按切分的顺序输出就是正确的阅读顺序。

具体到实现,XY-cut 有两种切分策略:

  • X-cut(垂直切分):找页面上的垂直空白间隙,把左右内容分开。多栏排版主要靠这个。
  • Y-cut(水平切分):找水平空白间隙,把上下内容分开。标题和正文、段落之间主要靠这个。

实际算法会交替使用两种切分,优先选择“切分后空白区域最大”的方向,这样能最快地把页面拆成独立块。递归终止条件是:当前区域内没有足够宽的空白间隙,或者区域已经小到只包含一个文本块。

2.3 为什么不能只用 PyMuPDF 自带的排序

PyMuPDF 的get_text("blocks")返回的块顺序,默认是按 block_no 排的,而 block_no 是 PDF 内部的对象顺序,跟阅读顺序没有必然关系。虽然 PyMuPDF 提供了sort=True参数,它内部也做了一些排序,但那个排序逻辑比较简单,对复杂多栏排版效果有限。

我实测过,对标准的双栏学术论文,PyMuPDF 自带排序大概有 60%-70% 的准确率,剩下的要么左右栏交错,要么段落顺序错乱。对三栏以上的排版,准确率掉到 40% 以下。所以自己实现 XY-cut 是值得的,代码量不大,但效果提升明显。

2.4 水印识别的三个维度

水印的形态千奇百怪,但归纳下来可以从三个维度识别:

字体特征:水印常用特殊字体、超大字号、旋转角度、半透明颜色。PyMuPDF 的 span 信息里有size(字号)、font(字体名)、color(颜色)、flags(标志位)等字段,可以据此判断。

位置特征:水印经常出现在页面固定位置,比如居中、对角线、页脚。统计多页文档中重复出现在相同坐标的文字,大概率是水印。

文本特征:水印文字内容通常有固定模式,比如“内部资料”“机密”“样本”“禁止复制”等。维护一个关键词列表做匹配,简单有效。

实际项目里我会把三个维度结合起来打分,超过阈值就判定为水印。单一维度容易误判,比如正文里也可能有大字号标题,但标题不会同时满足“半透明+固定位置+关键词”三个条件。

3. 实操环境与核心代码实现

3.1 环境准备与依赖安装

先把环境搭起来。PyMuPDF 是核心依赖,安装很简单:

pip install pymupdf

如果你需要处理扫描件(图片型 PDF),还得加上 OCR 能力,推荐pytesseract配合Pillow:

pip install pytesseract pillow

系统层面需要装 Tesseract OCR 引擎,Ubuntu 下apt install tesseract-ocr tesseract-ocr-chi-sim,macOS 下brew install tesseract tesseract-lang。Windows 用户去官网下安装包,记得把安装路径加到 PATH 里。

提示:PyMuPDF 的导入名是fitz,不是pymupdf。这是历史遗留问题,新版虽然也支持import pymupdf,但为了兼容性,建议统一用import fitz。

3.2 提取带坐标的文本块

先写一个基础函数,把页面上的文本块连同坐标一起提取出来:

import fitz def extract_blocks(page): """提取页面文本块,返回带坐标的块列表""" blocks = page.get_text("dict")["blocks"] result = [] for block in blocks: if block["type"] != 0: # 0 表示文本块,1 表示图片块 continue # 合并 block 内所有 span 的文本 text_parts = [] for line in block["lines"]: for span in line["spans"]: text_parts.append(span["text"]) text = "".join(text_parts).strip() if not text: continue result.append({ "bbox": block["bbox"], # (x0, y0, x1, y1) "text": text, "lines": block["lines"], }) return result

这里用get_text("dict")而不是get_text("blocks"),是因为 dict 级别保留了 span 的字体信息,后面识别水印要用。block["type"]区分文本块和图片块,图片块我们暂时跳过,如果 PDF 里有图表需要单独处理。

3.3 XY-cut 算法完整实现

接下来是核心的 XY-cut 算法。我把它拆成几个辅助函数,逻辑更清晰:

def get_gaps(blocks, axis, min_gap=10): """在指定轴上找空白间隙 axis='x' 找垂直间隙(用于分栏) axis='y' 找水平间隙(用于分段) """ if axis == "x": intervals = [(b["bbox"][0], b["bbox"][2]) for b in blocks] else: intervals = [(b["bbox"][1], b["bbox"][3]) for b in blocks] 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(blocks, min_gap=10): """递归 XY-cut,返回排序后的块列表""" if len(blocks) <= 1: return blocks # 先尝试 X-cut(垂直切分,处理多栏) x_gaps = get_gaps(blocks, "x", min_gap) if x_gaps: # 选最大的间隙作为切分点 gap = max(x_gaps, key=lambda g: g[1] - g[0]) mid = (gap[0] + gap[1]) / 2 left = [b for b in blocks if b["bbox"][2] <= mid] right = [b for b in blocks if b["bbox"][0] >= mid] # 处理跨越切分线的块,归到重叠更多的一侧 cross = [b for b in blocks if b not in left and b not in right] for b in cross: if b["bbox"][0] < mid: left.append(b) else: right.append(b) return xy_cut(left, min_gap) + xy_cut(right, min_gap) # 再尝试 Y-cut(水平切分,处理段落) y_gaps = get_gaps(blocks, "y", min_gap) if y_gaps: gap = max(y_gaps, key=lambda g: g[1] - g[0]) mid = (gap[0] + gap[1]) / 2 top = [b for b in blocks if b["bbox"][3] <= mid] bottom = [b for b in blocks if b["bbox"][1] >= mid] cross = [b for b in blocks if b not in top and b not in bottom] for b in cross: if b["bbox"][1] < mid: top.append(b) else: bottom.append(b) return xy_cut(top, min_gap) + xy_cut(bottom, min_gap) # 无法切分,按 y 坐标排序 return sorted(blocks, key=lambda b: (b["bbox"][1], b["bbox"][0]))

这段代码有几个关键点需要解释:

优先 X-cut 还是 Y-cut:我选择优先 X-cut,因为多栏排版是主要矛盾。如果先做 Y-cut,可能把左右栏的同一行文字切到同一个区域,反而增加混乱。先 X-cut 把栏分开,再在每栏内部做 Y-cut 排序,逻辑更顺。

min_gap 的选择:这个参数决定多宽的空白才算“间隙”。太小会把字间距误判为栏间距,太大会漏掉真正的分栏。经验值是 10-15 像素,具体要看 PDF 的 DPI 和排版密度。我一般先用 10 试,效果不好再调。

跨切分线的块处理:有些文本块可能横跨切分线(比如通栏标题),简单按中心点归属可能出错。我的策略是按块的起始坐标归属,因为阅读顺序上,起始位置更决定它属于哪一栏。

3.4 水印识别与过滤

水印识别我写了一个打分函数,综合三个维度:

WATERMARK_KEYWORDS = ["内部资料", "机密", "禁止外传", "样本", "试用版", "禁止复制", "confidential", "draft"] def is_watermark(block, page_width, page_height): """判断文本块是否为水印,返回 (是否水印, 得分)""" score = 0 bbox = block["bbox"] text = block["text"] # 维度1:文本关键词匹配 for kw in WATERMARK_KEYWORDS: if kw in text: score += 3 break # 维度2:字体特征(检查第一个 span) if block["lines"] and block["lines"][0]["spans"]: span = block["lines"][0]["spans"][0] # 超大字号 if span["size"] > 30: score += 2 # 半透明颜色(PyMuPDF 中 alpha 信息在 color 的高位) color = span["color"] alpha = (color >> 24) & 0xFF if color > 0xFFFFFF else 255 if alpha < 200: score += 2 # 旋转文字(flags 中包含旋转标志) if span["flags"] & 2**4: score += 1 # 维度3:位置特征(居中且覆盖大面积) block_center_x = (bbox[0] + bbox[2]) / 2 block_center_y = (bbox[1] + bbox[3]) / 2 if abs(block_center_x - page_width / 2) < page_width * 0.1: score += 1 if abs(block_center_y - page_height / 2) < page_height * 0.1: score += 1 return score >= 4, score

阈值设为 4 是权衡后的结果。太低会误杀正文(比如居中的大标题),太高会漏掉水印。实际项目里我会先跑一遍,把得分 3-5 的块打印出来人工确认,再微调阈值和关键词表。

注意:有些水印是图片形式而非文字,PyMuPDF 的文本提取拿不到。这种情况需要在get_text("dict")里检查type=1的图片块,结合图片位置和透明度判断。图片水印的过滤更复杂,通常需要图像处理手段,本文不展开。

3.5 完整解析流程串联

把上面的模块串起来,形成一个完整的页面解析函数:

def parse_page(page, min_gap=10): """解析单页,返回过滤水印后的有序文本""" page_width = page.rect.width page_height = page.rect.height blocks = extract_blocks(page) # 过滤水印 clean_blocks = [] for b in blocks: is_wm, score = is_watermark(b, page_width, page_height) if not is_wm: clean_blocks.append(b) # XY-cut 排序 ordered = xy_cut(clean_blocks, min_gap) # 拼接文本 return "\n".join(b["text"] for b in ordered)

对多页文档,循环调用parse_page即可。如果 PDF 有目录或章节结构,可以结合page.get_toc()做更细粒度的切分。

4. 实际项目中的坑与排查技巧

4.1 常见问题速查表

问题现象可能原因排查方法解决方案
左右栏文字交错min_gap 太小,栏间距被忽略打印所有块的 x 坐标分布增大 min_gap 到 15-20
段落顺序错乱Y-cut 切分点选错可视化切分线位置调整 min_gap,或改用块中心点排序
水印混入正文关键词表不全或阈值太低打印得分 3-5 的块人工检查补充关键词,提高阈值
标题和正文粘连标题与正文间距小于 min_gap检查标题块的字体大小对标题块单独处理,按字号切分
表格内容错乱表格被当作普通文本块检查是否有表格线用page.find_tables()单独提取表格
提取速度慢逐页 dict 解析开销大计时各步骤耗时只对需要的页做精细解析,其余用 blocks

4.2 踩过的坑:跨栏标题的处理

学术论文里经常有通栏标题(比如章节标题横跨左右两栏),这种块在 XY-cut 时会被分到某一栏,导致阅读顺序出错。我最初的实现里,通栏标题要么跑到左栏末尾,要么跑到右栏开头,怎么都不对。

后来想明白了:通栏标题应该作为切分点,而不是被切分的对象。具体做法是,在做 X-cut 之前,先识别出宽度接近页面宽度的块,把它们单独拿出来,作为 Y-cut 的分隔符。这样标题会正确地出现在它该在的位置,左右栏内容分别在标题下方。

def split_full_width_blocks(blocks, page_width, threshold=0.8): """分离通栏块和普通块""" full_width = [] normal = [] for b in blocks: width = b["bbox"][2] - b["bbox"][0] if width > page_width * threshold: full_width.append(b) else: normal.append(b) return full_width, normal

然后在主流程里,先按 y 坐标把页面分成若干段,每段内如果有通栏块,就以它为界再分。这个逻辑稍微复杂点,但效果立竿见影。

4.3 踩过的坑:水印的字体信息丢失

有些 PDF 的水印是用特殊方式嵌入的,PyMuPDF 提取时 span 的字体信息可能是空的或者默认值,导致基于字体的判断失效。我遇到过一个案例,水印文字的字号显示为 0,颜色也是默认黑色,完全看不出异常。

解决办法是结合多页统计。水印通常在每一页的相同位置出现,统计所有页面上相同坐标区域的文字,如果某个文字在多页重复出现且位置固定,基本可以判定为水印。这个方法不依赖字体信息,鲁棒性更好。

def detect_repeated_watermark(doc, sample_pages=5): """通过多页统计识别水印""" from collections import Counter position_texts = Counter() for i in range(min(sample_pages, len(doc))): page = doc[i] for b in extract_blocks(page): # 把坐标量化到 10 像素精度,避免微小偏差 key = (round(b["bbox"][0] / 10), round(b["bbox"][1] / 10), b["text"]) position_texts[key] += 1 # 出现次数超过一半页面的,判定为水印 threshold = sample_pages / 2 return {k for k, v in position_texts.items() if v >= threshold}

4.4 实操心得:先可视化再调参

XY-cut 的参数调优,光看文本输出很难判断问题在哪。我的做法是先把切分结果画出来,用 PyMuPDF 的绘图功能在页面上标注每个块的顺序和切分线,直观看到哪里出了问题。

def visualize_blocks(page, blocks, output_path="debug.png"): """在页面上画出块边界和顺序编号""" mat = fitz.Matrix(2, 2) # 放大 2 倍,看得清楚 pix = page.get_pixmap(matrix=mat) # 这里省略具体绘图代码,核心是用 page.draw_rect 画框 # 用 page.insert_text 标注顺序号 pix.save(output_path)

调参时我一般会准备 3-5 个典型样本:标准双栏论文、三栏技术手册、带水印的企业文档、图文混排的产品说明。每调一次参数,跑一遍所有样本,看整体效果。不要只盯着一个样本调,容易过拟合。

4.5 性能优化:大文档的处理策略

几百页的 PDF 逐页做 dict 解析,速度会很慢。我的优化策略是分级处理:

第一级,用page.get_text("blocks")快速扫描,判断页面是否复杂(块数量、坐标分布)。简单页面直接用自带排序,不跑 XY-cut。

第二级,只对复杂页面做 dict 解析和 XY-cut。判断标准可以是:块数量超过阈值、x 坐标分布有明显双峰(多栏特征)、或者检测到水印关键词。

第三级,对确实需要精细处理的页面,才做完整的 span 级分析。这样能把整体处理时间压缩到原来的 30%-40%。

另外,PyMuPDF 支持多进程处理,用multiprocessing.Pool把不同页面分到不同进程,能进一步提速。但要注意 PyMuPDF 的 Document 对象不是进程安全的,每个进程要独立打开文档。

5. 从解析到 RAG:后续环节的衔接

5.1 解析结果如何喂给 RAG

bbox 解析出来的有序文本,只是 RAG 链路的第一步。接下来要做的是分块(chunking)。多栏文档解析后,文本已经按阅读顺序排好了,但还需要按语义切分成合适大小的块。

我的经验是:按段落切分,每块 300-500 字,块之间保留 50-100 字重叠。重叠是为了避免关键信息被切断。如果文档有明确的章节结构,优先按章节切,章节内再按段落。

这里有个细节:bbox 解析时保留的坐标信息,在分块时可以用来做位置元数据。比如每个块记录它来自哪一页、哪个区域,检索时可以按位置过滤或加权。这对提升 RAG 检索精度有帮助,尤其是当用户的问题涉及“文档开头”“第三章”这类位置描述时。

5.2 多栏解析对检索质量的影响

我做过对比测试:同一份双栏学术论文,用普通文本提取和 bbox 解析分别建库,然后用相同的问题集测试检索准确率。结果 bbox 解析的准确率高出 25%-30%,尤其是在涉及具体数据、公式、图表说明的问题上,差距更明显。

原因很简单:普通提取把左右栏文字交错后,语义完全乱了,embedding 模型拿到的是一堆无意义的文字组合,检索自然不准。bbox 解析还原了正确的阅读顺序,每个 chunk 都是语义完整的段落,embedding 质量高得多。

5.3 水印过滤对知识库纯净度的价值

水印文字混入知识库,轻则干扰检索(用户搜“内部资料”搜出一堆水印),重则泄露敏感信息(水印里可能包含文档密级、部门名称)。过滤水印不仅是技术问题,也是合规问题。

我的做法是在解析阶段就把水印过滤掉,而不是等到检索时再处理。因为水印文字一旦进入 embedding,就会污染向量空间,后续很难清理。解析阶段过滤是最干净的方案。

提示:过滤水印时建议保留过滤日志,记录哪些文字被判定为水印、得分多少。这样万一误杀,可以回溯调整。日志本身不要存敏感内容,只存坐标和得分即可。

5.4 扩展方向:表格与图片的处理

bbox 解析主要解决文本的阅读顺序问题,但 PDF 里的表格和图片是另外的挑战。PyMuPDF 提供了page.find_tables()接口,能识别表格结构并返回单元格内容。图片则可以用page.get_images()提取,配合 OCR 或图像理解模型处理。

对 RAG 知识库来说,表格和图片往往包含关键信息,不能简单丢弃。我的建议是:表格提取后转成 Markdown 格式存入知识库,图片提取后做 OCR 或生成描述文本,和正文一起建索引。这样检索时不会漏掉图表里的信息。

6. 几个容易被忽略的细节

6.1 坐标系的原点问题

PyMuPDF 的坐标系原点在页面左上角,y 轴向下。这和数学坐标系相反,写代码时容易搞混。比如判断“上方”的块,y 坐标更小;判断“下方”的块,y 坐标更大。我最初写 Y-cut 时就把方向搞反了,排序结果完全颠倒,排查了半天才发现。

另外,PDF 的页面可能有旋转(page.rotation),旋转后的坐标需要转换。PyMuPDF 的page.rect返回的是旋转后的尺寸,但块的坐标是旋转前的。处理旋转页面时,要么先把页面旋转归零,要么手动转换坐标。这个坑我在处理扫描件时踩过,扫描仪出来的 PDF 经常带 90 度旋转。

6.2 字体编码与乱码

有些 PDF 用了非标准字体编码,PyMuPDF 提取出来的文字是乱码。这种情况通常是字体没有嵌入或者用了自定义编码。解决办法是尝试不同的提取参数,或者用 OCR 兜底。

PyMuPDF 的get_text有个flags参数,可以控制提取行为。比如flags=fitz.TEXT_PRESERVE_LIGATURES保留连字,flags=fitz.TEXT_DEHYPHENATE处理断词连字符。遇到乱码时,可以试试调整这些标志。

6.3 空白页与页眉页脚

多页文档里经常有空白页、页眉、页脚,这些内容对 RAG 没有价值,反而增加噪音。我的做法是在解析阶段就过滤掉:

  • 空白页:文本块数量为 0 或总字符数少于阈值(比如 10 个字符)。
  • 页眉页脚:位置在页面顶部或底部固定区域(比如上下各 5% 高度),且在多页重复出现。

过滤逻辑可以复用前面水印识别的多页统计方法,把重复出现的页眉页脚文字识别出来。

6.4 阅读顺序的边界情况

XY-cut 不是万能的,有些排版它处理不了。比如:

  • 不规则分栏:页面左侧是单栏,右侧是双栏,这种混合排版 XY-cut 会懵。
  • 文字环绕图片:文字绕着图片排,坐标分布不规则,切分线不好找。
  • 脚注与正文:脚注在页面底部,但阅读顺序上应该跟在引用它的正文后面,XY-cut 按位置排序会把脚注放到最后。

这些边界情况没有通用解法,只能针对具体文档类型做特殊处理。我的建议是:先覆盖 80% 的常见排版,剩下的 20% 用规则或人工兜底。不要追求 100% 自动化,成本太高不划算。

7. 我个人的实操体会

这套 bbox + XY-cut 的方案,我在三个企业知识库项目里用过,处理了上万份 PDF 文档。整体效果是满意的,多栏文档的解析准确率从原来的 60% 左右提升到 90% 以上,水印过滤准确率大概 85%-90%。

但我要诚实地说,没有银弹。每换一批文档,都要重新调参、补充关键词、处理新的边界情况。我的做法是建立一个“解析质量监控”流程:每次批量解析后,随机抽样 20-30 页人工检查,记录问题类型和比例,持续迭代解析规则。

另外,PyMuPDF 的版本更新比较频繁,不同版本的 API 和提取行为可能有细微差异。生产环境建议锁定版本,升级前先在测试集上验证。我遇到过一次升级后水印识别失效的情况,排查发现是新版本改变了 span 的 color 字段编码方式,alpha 信息的提取逻辑要跟着改。

最后分享一个小技巧:解析结果里保留原始坐标信息。即使当前用不到,后续做检索加权、位置过滤、可视化调试时都用得上。存储成本很低,但价值很大。我现在的解析输出格式是 JSON,每个块包含 text、bbox、page、order 四个字段,用起来很灵活。

这套方案不是终点,PDF 解析领域还有很多值得探索的方向,比如基于深度学习的版面分析模型、端到端的文档理解方案。但对大多数 RAG 项目来说,bbox + XY-cut 已经能解决核心痛点,投入产出比很高。先把这套用熟,再考虑更复杂的方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询