RAG文档解析实战:bbox与XY-cut破解多栏排版和水印难题
1. 为什么多栏排版和水印是 RAG 文档解析的硬骨头做过 RAG 知识库的人都有一个共识PDF 解析是整个链路里最脏最累的活。文本型 PDF 还好一旦碰上学术论文、技术手册、产品白皮书这类多栏排版文档再加上企业文档里常见的“内部资料”“禁止外传”水印普通的文本提取工具直接歇菜。你拿到的文本要么是左右栏交错串行要么是水印文字混进正文检索出来的内容驴唇不对马嘴用户问东你答西。这个问题的本质在于PDF 格式本身不存储“阅读顺序”这个概念。它只记录每个字符、每段文字在页面上的绝对坐标至于这些文字应该按什么顺序读PDF 文件不管。单栏文档碰巧按坐标排序就能得到正确顺序但多栏排版下同一水平线上的文字可能分属左右两栏简单按 y 坐标排序就会把两栏内容搅在一起。水印更麻烦它通常以半透明文字或图片形式覆盖在正文上方坐标和正文重叠纯文本提取根本区分不了哪些是正文、哪些是水印。bboxbounding 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 提供了sortTrue参数它内部也做了一些排序但那个排序逻辑比较简单对复杂多栏排版效果有限。我实测过对标准的双栏学术论文PyMuPDF 自带排序大概有 60%-70% 的准确率剩下的要么左右栏交错要么段落顺序错乱。对三栏以上的排版准确率掉到 40% 以下。所以自己实现 XY-cut 是值得的代码量不大但效果提升明显。2.4 水印识别的三个维度水印的形态千奇百怪但归纳下来可以从三个维度识别字体特征水印常用特殊字体、超大字号、旋转角度、半透明颜色。PyMuPDF 的 span 信息里有size字号、font字体名、color颜色、flags标志位等字段可以据此判断。位置特征水印经常出现在页面固定位置比如居中、对角线、页脚。统计多页文档中重复出现在相同坐标的文字大概率是水印。文本特征水印文字内容通常有固定模式比如“内部资料”“机密”“样本”“禁止复制”等。维护一个关键词列表做匹配简单有效。实际项目里我会把三个维度结合起来打分超过阈值就判定为水印。单一维度容易误判比如正文里也可能有大字号标题但标题不会同时满足“半透明固定位置关键词”三个条件。3. 实操环境与核心代码实现3.1 环境准备与依赖安装先把环境搭起来。PyMuPDF 是核心依赖安装很简单pip install pymupdf如果你需要处理扫描件图片型 PDF还得加上 OCR 能力推荐pytesseract配合Pillowpip install pytesseract pillow系统层面需要装 Tesseract OCR 引擎Ubuntu 下apt install tesseract-ocr tesseract-ocr-chi-simmacOS 下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_gap10): 在指定轴上找空白间隙 axisx 找垂直间隙用于分栏 axisy 找水平间隙用于分段 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_gap10): 递归 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, keylambda 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, keylambda 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, keylambda 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)里检查type1的图片块结合图片位置和透明度判断。图片水印的过滤更复杂通常需要图像处理手段本文不展开。3.5 完整解析流程串联把上面的模块串起来形成一个完整的页面解析函数def parse_page(page, min_gap10): 解析单页返回过滤水印后的有序文本 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 解析开销大计时各步骤耗时只对需要的页做精细解析其余用 blocks4.2 踩过的坑跨栏标题的处理学术论文里经常有通栏标题比如章节标题横跨左右两栏这种块在 XY-cut 时会被分到某一栏导致阅读顺序出错。我最初的实现里通栏标题要么跑到左栏末尾要么跑到右栏开头怎么都不对。后来想明白了通栏标题应该作为切分点而不是被切分的对象。具体做法是在做 X-cut 之前先识别出宽度接近页面宽度的块把它们单独拿出来作为 Y-cut 的分隔符。这样标题会正确地出现在它该在的位置左右栏内容分别在标题下方。def split_full_width_blocks(blocks, page_width, threshold0.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_pages5): 通过多页统计识别水印 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_pathdebug.png): 在页面上画出块边界和顺序编号 mat fitz.Matrix(2, 2) # 放大 2 倍看得清楚 pix page.get_pixmap(matrixmat) # 这里省略具体绘图代码核心是用 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 解析结果如何喂给 RAGbbox 解析出来的有序文本只是 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参数可以控制提取行为。比如flagsfitz.TEXT_PRESERVE_LIGATURES保留连字flagsfitz.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 已经能解决核心痛点投入产出比很高。先把这套用熟再考虑更复杂的方案。