资讯动态

用bbox解决RAG文档解析中的多栏排版与水印PDF难题

发布时间:2026/10/14 9:04:14 来源:尧图企业网站定制
做 RAG 项目的人大概都有过这种经历资料库整理得挺用心PDF 也都转成文本了但一上线检索就“答非所问”。我排查这类问题排查了大半年最后发现根子不在向量模型而在 RAG 文档解析那一步——尤其是碰到多栏排版和水印 PDF 时如果不手动拿 bbox 做版面处理后面再怎么调参都白搭。bbox 全称 bounding box说白了就是版面上每个元素的位置框。它听起来是个很底层的“小坐标”但在文档解析这个环节里它决定了你能不能分清正文和页眉、能不能把双栏文章读对顺序、能不能在切分时保证每一个 chunk 是语义连贯的。这篇文章我就拿自己的实战经历聊聊怎么用 bbox 去解决 RAG 文档解析里最让人头疼的两类文件多栏排版 PDF 和水印 PDF。1. 为什么 bbox 是 RAG 文档解析的“隐形地基”1.1 RAG 的检索质量其实取决于“能不能读懂版面”先讲一个真实的排查场景。我维护的一个知识库里面放了大量行业报告用户提问后召回率看着不低但答案质量一直上不去。后来我把召回出来的文本段打出来一看发现问题非常明显有一段文本的主语在左栏谓语和宾语却跑到了右栏整个句子被“腰斩”了。再往下翻好几条 chunk 开头都带着同一句“内部资料请勿外传”这显然是从水印里混进去的。这种问题不是 embedding 模型能解决的。向量模型再强输入的文章本身是乱序的、被污染的它也只能在乱序和污染之上做语义表示。RAG 管线里最容易被低估的环节就是文档解析而文档解析的质量很大程度上取决于有没有读“版面结构”。读版面的第一步就是拿到并理解每个元素的 bbox。1.2 bbox 到底是什么坐标系、格式与语义bbox 通常是一个矩形框用四个数值表示常见格式是(x0, y0, x1, y1)分别代表左上角横坐标、左上角纵坐标、右下角横坐标、右下角纵坐标。在 PDF 的世界里这个坐标系一般以页面左上角为原点x 轴向右y 轴向下单位是 point1 point 1/72 英寸。但注意不同工具返回的 bbox 语义略有差异。PyMuPDF 的page.get_text(dict)返回的 bbox 是页面坐标系下的绝对坐标pdfplumber 则是在cropbox基础上计算的坐标如果是 OCR 引擎比如 PaddleOCR输出的坐标又是以图像像素为单位的。混用这些坐标的时候一定要先统一坐标系否则你辛辛苦苦写好的版面算法换个工具就全乱套。bbox 的语义也不只是“文字在哪”。在结构化解析中bbox 隐含了三层信息空间位置元素在页面上哪一块区域这个信息可以用来推断阅读顺序。类型归属这个 bbox 里装的是标题、正文、图片还是表格版面模型可以直接输出类型。层级关系PDF 解析工具返回的 block、line、span 本身就是一层层嵌套的结构每个层级都有自己的 bbox。理解这三层后面做栏检测、水印剔除、切分策略时思路会清晰很多。1.3 bbox 在 RAG 解析管线里的三个角色根据我的实践bbox 在解析管线里至少承担三个不可替代的职责阅读顺序推断。PDF 的内部文本顺序和视觉阅读顺序经常不一致。没有 bbox你只能按内部流式顺序硬读有了 bbox你才能根据元素坐标把顺序重排成“从上到下、从左到右”。主体与噪声判别。页眉、页脚、页码、水印这些噪声在坐标上有明显规律。页眉页脚贴着页面上下边缘水印往往在页面中下部且多页同位置重复。没有 bbox你根本没法写规则把它们筛出去。结构化切分边界。RAG 切分时最理想的状态是“一段语义完整的段落被切成一个 chunk”。段落、栏、标题这些结构单元都需要借助 bbox 来确定边界而不是靠纯字符长度硬切。说句实话bbox 这玩意看起来是“脏活累活”但这辈子做的恰恰是给后面的召回质量兜底。这部分做扎实了检索才不至于“先天残疾”。2. 从 PDF 里拿到 bbox 的三种路径2.1 PyMuPDF最快上手的坐标提取PyMuPDF也就是 fitz是我日常用得最多的工具没有之一。它的page.get_text(dict)可以直接把页面内容解析成一个嵌套字典里面包含blocks、lines、spans三级结构每一级都有bbox字段。核心代码非常简单import fitz doc fitz.open(sample.pdf) page doc[0] blocks page.get_text(dict)[blocks] for block in blocks: if block[type] ! 0: # 0 表示文本块1 表示图片块 continue bbox block[bbox] text .join( span[text] for line in block[lines] for span in line[spans] ) print(bbox, text[:80])这个方案的优点是完全离线、速度快、不需要 GPU对于文本型 PDF 已经能拿到相当干净的结果。缺点也很明显它对“语义”一无所知不会告诉你某个 bbox 是标题还是正文判断逻辑得自己写。2.2 深度版面模型语义更准但成本也更高如果你处理的是复杂版面比如扫描件、图文混排、封面页、多级标题体系单靠 PyMuPDF 的 blocks 就不太够用了。现在比较成熟的选择是 marker 项目里用的 surya 系列模型它可以直接输出带类别标签的 bbox比如Title、Text、Table、Image、Footer等。这类方案的用法大致是用模型对页面做版面分析拿到每个区域的 bbox 和类别再基于这些信息重建阅读顺序。相比 PyMuPDF它对“栏”的感知能力更强比如一个栏区域模型会直接给一个大的Text块 bbox而不是碎成几十个小 block。但代价也摆在那儿模型需要下载权重推理需要 GPU 或者较长的 CPU 时间部署复杂度明显上升。对于上传文件量大的线上服务还需要考虑排队和超时问题。2.3 规则坐标重建不依赖模型时的兜底方案介于两者之间还有一种做法就是完全靠 PyMuPDF 的原始 blocks 输出然后通过坐标规则去重建版面结构。比如通过分析所有文字块 x 坐标的分布识别出页面分了几栏通过分析 y 坐标的位置识别出页眉页脚。这种方案的优点是轻量、可控、可解释。出问题时你能清楚地知道是哪个规则没写对。缺点则是面对不规则版面时规则容易失效维护成本随着版面类型增加而线性上升。2.4 三种方案怎么选我自己的选择逻辑是这样的方案优点缺点适用场景PyMuPDF 裸提取快、离线、零成本无语义类型规则要自己写单栏排版为主的文本 PDF深度版面模型语义识别强、抗复杂版面需要 GPU/权重部署重扫描件、复杂排版、样式多变的文档规则坐标重建轻量、可控、好排查版面类型多时会崩双栏/三栏格式固定的报告、论文我的建议是先做一批样本看排版复杂度。如果 80% 的文件是标准期刊双栏排版用 PyMuPDF 加规则重建就非常够用了如果文件来源很杂排版五花八门那就值得上深度版面模型。3. 多栏排版先从 bbox 里“找到栏”再谈阅读顺序3.1 双栏文档为什么会让 RAG 翻车双栏排版在论文、行业报告、新闻简报里太常见了。问题在于PDF 内部的文本存储顺序通常不代表视觉阅读顺序。很多 PDF 生成器会把左栏第一行、右栏第一行、左栏第二行、右栏第二行这样交错存放。如果解析时不做任何处理直接按内部顺序抽出文本RAG 切分出来的 chunk 里文字就会来回跳一句话的前半截在左栏后半截跑到右栏语义彻底被打碎。检索时拿提问的向量去匹配匹配到的 chunk 本身是“前言不搭后语”的回答质量自然上不去。3.2 基于 bbox 的栏检测算法设计与代码要解决这个问题第一步是判断页面到底是一栏还是多栏然后找到每栏的边界。我的做法是收集页面内所有文本 block 的 x 中心坐标对这些中心坐标做一维聚类聚成几类就说明页面有几栏。这里给出一段能直接跑的检测逻辑from collections import defaultdict def detect_columns(blocks, gap_threshold60): blocks: 包含 bbox 字段的文本块列表 返回: {0: [blocks...], 1: [blocks...], ...} 按栏分组 centers [ (block[bbox][0] block[bbox][2]) / 2.0 for block in blocks ] order sorted(range(len(blocks)), keylambda i: centers[i]) col_groups {} current_label 0 prev_center None for idx in order: center centers[idx] if prev_center is not None and center - prev_center gap_threshold: current_label 1 col_groups.setdefault(current_label, []).append(blocks[idx]) prev_center center return col_groups这个算法的本质是找相邻文字块的 x 中心之间有没有“大空隙”。空隙超过gap_threshold就意味着这两个块大概率不属于同一栏。阈值一般取 60~80 point具体要根据页面宽度微调。但有个细节要小心如果某一块是一个横跨两栏的大标题它的 x 中心恰好落在页面正中聚类时可能会被单独分成一栏。对这类块我会额外判断如果一个块的宽度大于页面宽度的 70%就认定它是“跨栏块”单独提取出来作为整行元素处理不参与栏聚类。3.3 栏内阅读顺序恢复先 y 后 x 的原则分好栏之后下一步是恢复阅读顺序。基本原则是“先栏后行”同一栏内的块按 y0纵坐标从上到下排序不同栏之间按 x0横坐标从左到右排序。代码实现如下def reorder_by_columns(blocks, page_width): blocks preprocess_span_blocks(blocks) result [] # 1. 找出跨栏块通常为标题 full_width [b for b in blocks if is_full_width(b, page_width)] narrow [b for b in blocks if not is_full_width(b, page_width)] # 2. 对窄块做栏检测 columns detect_columns(narrow, gap_threshold60) # 3. 先按栏的 x 顺序再按栏内 y 顺序 for col_key in sorted(columns.keys()): col_blocks sorted( columns[col_key], keylambda b: b[bbox][1] ) result.extend(col_blocks) # 4. 跨栏块按 y 顺序插入 full_width.sort(keylambda b: b[bbox][1]) result.sort(keylambda b: b[bbox][1]) return result实际处理中我会在得到有序块列表后再重新计算每个块的“视觉序号”并且把同一个段落内连续的小块合并成一个大的区块便于后续切分。3.4 单栏/多栏混排的自适应处理还有一个容易被忽视的场景一份文档里有的页面是单栏有的页面是双栏甚至同一页面内上半部分单栏、下半部分双栏。这种情况在 PPT 导出的 PDF、产品手册里特别多。遇到这种文档我的做法是“逐页检测”不做全局假设。每一页单独跑一遍栏检测如果检测到多栏则做列重排如果检测结果是单栏就按 y 顺序自然读取。这样页面之间互不影响适应性好很多。还有一个进一步的办法把页面按 y 方向切成多个水平条带每个条带内分别做栏检测。但这样做实现复杂度较高除非你处理的文档排版特别乱否则逐页检测已经够用。4. 水印 PDF用“多页重叠”特征揪出隐形噪声4.1 水印对 RAG 检索的实际伤害水印文字看起来只是“背景噪声”但对检索的影响是实打实的主要体现在两个层面。第一是语义污染。水印里经常出现“内部资料”“请勿外传”“预览版”“Sample”这类词。当这些词和正文混在一起时embedding 会把噪声词的语义也编码进去。用户明明问的是“某个产品的技术参数”匹配到的文档却因为高频出现“内部资料”而偏向其他文档召回排序被干扰。第二是权重稀释。假设一份 20 页的 PDF每页都被同一个水印覆盖那这个水印文本会在每个 chunk 里出现。经过向量化之后水印的特征被重复强化在检索相似度比对时占的比重甚至可能超过正文关键词这就是典型的“词频稀释”现象。4.2 检测思路坐标一致性 文本重复水印最明显的特征不是文字内容本身而是跨页的高度重复性。正常正文内容不可能每一页都在同一个坐标位置出现完全相同的文本。基于这个特征可以用下面的流程检测遍历所有页面收集每个文本块的 bbox 和文本内容。将 bbox 坐标做量化处理比如除以 5 后取整以过滤微小的坐标偏移。把(量化坐标, 文本内容)作为一个签名统计该签名在文档中出现的页数。如果同一个签名出现在超过 80% 的页面上基本可以断定它是水印或固定页眉页脚。代码示意import fitz from collections import Counter def collect_signatures(doc): pages_sigs [] for page in doc: page_sig set() for block in page.get_text(dict)[blocks]: if block[type] ! 0: continue text .join( span[text] for line in block[lines] for span in line[spans] ).strip() if not text: continue x0, y0, x1, y1 block[bbox] key (round(x0 / 5), round(y0 / 5), round(x1 / 5), round(y1 / 5)) page_sig.add((key, text)) pages_sigs.append(page_sig) return pages_sigs def detect_repeated_noise(pages_sigs, page_count, threshold0.8): counter Counter() for page_sig in pages_sigs: counter.update(page_sig) repeated { sig for sig, cnt in counter.items() if cnt max(2, page_count * threshold) } return repeated这里用set()是因为一页内可能出现多个相同位置相同文本的 span我们只需要每页出现一次就够了。4.3 与页眉页脚、页码的区别处理水印、页眉、页脚都有一个共同点固定位置、重复出现。所以单纯靠“多页重复”检测会把三者混在一起。要区分它们仍然要结合 bbox 的位置特征页眉bbox 的 y0 非常接近页面顶部边缘通常是文档标题或章节名。页脚bbox 的 y1 接近页面底部边缘通常是版权信息、页码。水印bbox 通常位于页面中部区域且可能横跨正文区域与正文块重叠也可能是斜向排列的不过斜向水印在 PDF 文本层里处理起来更麻烦。我常用的过滤策略是先做“多页重复”检测得到候选噪声集合。对每个候选块计算它在页面上的相对位置。如果 y 位置落在顶部 10% 区域标记为页眉候选落在底部 10% 区域标记为页脚候选落在中间标记为水印候选。对页眉页脚通常可以直接删除对水印则需要进一步判断它是否与正文块重叠如果重叠则必须在水印剔除时保留被压住的正文。实操时还要注意水印文字常常被旋转过尤其是企业内部的“CONFIDENTIAL”斜向水印。斜向水印在 PyMuPDF 的dict输出里可能是独立的 span坐标虽然还是 bbox但内容上与周边正文块有交叉重叠。这种情况可以参考spans里的dir字段来判断文字方向一旦发现某个重复块的方向与正文主方向明显不一致直接加入噪声候选。4.4 被水印污染时的后处理技巧处理水印时我最怕的不是“识别不出来”而是“识别出来但删错”。如果直接按 bbox 把识别的块删掉有可能会把和它重叠的正文一并删掉或者因为坐标量化误差漏掉部分水印内容。我们的生产代码里用了一个更稳妥的办法不是物理删除而是用填充策略。把检测出的水印文本替换为空字符串同时保留 bbox用于后续判断这是一个“空块”。这样正文块的位置信息还在切分时不会因为块消失导致版面结构断裂。def mask_noise_blocks(blocks, noise_keys): cleaned [] for block in blocks: if block[type] ! 0: cleaned.append(block) continue bbox block[bbox] key (round(bbox[0] / 5), round(bbox[1] / 5), round(bbox[2] / 5), round(bbox[3] / 5)) text .join(...) if (key, text) in noise_keys: # 这里把 block 里的 spans 文本置空但保留 bbox block clear_block_text(block) cleaned.append(block) return cleaned这个方法的好处是删完噪声之后栏检测和阅读顺序模块还能复用同一套 bbox 逻辑不需要再为“删了块之后页面布局变化”的问题做二次调整。5. 整合成一条可用于生产的 RAG 文档解析管线5.1 管线架构从 PDF 到可切分的结构单元前面几节是单个技术点实际落地时它们必须组合成一条完整管线。我现在用的解析流程是这样的页面级加载用 PyMuPDF 按页读取原始 blocks。水印/噪声检测多页扫描识别跨页重复的噪声块标记或清空内容。跨栏块识别找出横跨多个栏的标题块。栏检测在窄块集合上做 x 中心聚类确定每页的栏结构。阅读顺序恢复按“先栏后行栏内按 y栏间按 x”排序。块合并把排序后连续的、行距相近的文本块合并成段落级单元。语义切分在段落单元内部按句子边界再做切分生成最终 chunk。向量化入库把每个 chunk 连同页号、bbox 元数据一起写入索引。这套流水线的核心思想是先用 bbox 把版面结构“还原”出来再在还原后的结构上做切分。而不是一上来就按字符长度硬切。5.2 切分策略升级bbox 合并后再语义切分早期我做 RAG 切分就是recursive_character_splitter按 800 字一个 chunk 硬切。但在论文类文档上效果很差经常把一个小节的标题和正文拆成两半。后来我改成“先按 bbox 结构合并再按语义边界切”的两段式策略第一阶段把所有在阅读顺序上连续、且属于同一个自然段的块合并。判断依据是行间距和首行缩进。如果两个块之间的垂直间距明显小于行高并且后一个块的首行没有缩进我默认它们属于同一段落。第二阶段对合并后的段落做长度判断如果段落很短直接作为一个 chunk如果段落很长再用句号、分号、换行符这些语义边界切开。这样切出来的 chunk大部分时候是一条完整的信息单元检索时命中率和回答连贯性都会明显改善。5.3 一个真实案例的优化效果对比我拿一批双栏排版且有水印的行业报告做过一次对比实验大概 200 份 PDF总共 3000 多页。评估方式是抽了 50 个知识性问题比较回答的准确率。解析方式召回命中率回答准确率备注直接抽文本 长度切分58%41%大量跨栏乱序 chunk简单栏排序无去水印73%62%消除乱序但水印仍在污染bbox 完整管线84%78%乱序和水印同时处理这个结果挺直观即便只做栏排序效果已经提升明显加上水印剔除之后又往上走了一截。对于知识库场景来说78% 的准确率配合人工校验已经具备上线条件。6. 实战中我踩过的几个坑6.1 坐标系混淆PDF 坐标、旋转与图像坐标这是我踩过最多次的坑。PyMuPDF 返回的 bbox 是“页面坐标系”下的坐标但如果你做了 OCROCR 返回的坐标是“图像像素坐标”。两者相差一个缩放因子页面坐标的 1 point 对应图像里的若干像素通常是 72/150 或 72/300取决于你 OCR 时用的渲染分辨率。另外PDF 页面可能带旋转属性。有些扫描软件会把横向页面存成“页面旋转 90 度”导致 bbox 坐标跟着旋转变化。处理时要在读取页面后先检查page.rotation如果非 0需要考虑坐标变换否则栏检测结果会完全反过来。6.2 表格区域的 bbox 处理表格是一个常年让人头疼的例外。PyMuPDF 的get_text(dict)会把表格里的文字也拆成普通 block但它们的位置关系是网格状的列与列之间没有明显的 x 空隙所以多栏检测算法识别表格内部结构时经常失灵。我的经验是如果 PDF 里表格占比高建议单独用camelot或tabula提取表格结构拿到表格的完整 bbox 后把整个表格区域从普通 block 列表中剔除当成一个特殊类型处理。不要让表格碎片混进正文阅读顺序里否则切分出来的 chunk 会非常奇怪。6.3 扫描件没有文本 bbox扫描类 PDF 没有文本层PyMuPDF 拿不到文本 block必须走 OCR。OCR 引擎输出的通常是单词级 bbox而不是段落级 block所以后面合并的工作量比原生 PDF 大很多。处理这类文件时我会先用 OCR 拿到单词和坐标然后按“行聚类再按块聚类”的方式逐步合并。行聚类看 y 坐标y 接近的单词归到同一行块聚类看行间距行间距突然变大就说明可能进入了另一个段落。这个逻辑不复杂但写起来要细心尤其是水平间距的判断标点和单词之间的空隙大小波动挺大。6.4 重叠 bbox 的取舍最后一个是重叠 bbox 的问题。水印块和正文块经常发生重叠栏检测时这种重叠会影响聚类效果。如果你的水印剔除是在栏检测之前做的那么重叠问题会好处理很多。还有一类重叠是“标题压住了正文图片的边角”导致两个 block 的 bbox 区域交叠。我的处理原则是保留面积更大、文字内容更完整的块把面积小且内容高置信度属于噪声的块剔除。判断置信度可以用文本长度、字体大小、颜色这几个特征组合。最后再说个个人体会。bbox 这套东西初看好像只是解析过程中的“中间产物”但真正跑起来之后你会发现它是整个 RAG 预处理里性价比最高的改进点之一。魔改 embedding 模型、调向量检索参数效果往往都不如老老实实把版面读对、把噪声清干净来得直接。如果你也正在做 RAG 知识库翻车原因恰好集中在“解析后文本乱序”或“水印混入正文”上可以先用 PyMuPDF 把每个页面的 blocks 打印出来肉眼看一下那些 bbox 坐标的分布。很多时候问题在坐标层面一眼就能看穿。

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

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

免费获取报价 →
↑