
本系列前两篇把文档解析在 RAG 链路里的位置、常见工具怎么选型都过了一遍。今天这篇直接落到 bbox 上聊聊我被问得最多的两个场景多栏排版和水印 PDF。这两个问题不解决后面接什么 embedding 都会发现检索结果像乱序的剪报——不是模型不好用是喂给它的文本顺序和内容本身就有问题。bbox 是 bounding box 的缩写也就是每个文字块、图片、表格在页面上的外接矩形坐标。有了它你才能把物理的页面顺序还原成人类阅读顺序也才能把水印区域从文本流里挑出来扔掉。这篇内容适合正在做 RAG 知识库、却卡在文档结构化解析的同学尤其是处理学术论文、双栏排版的企业报告以及带着水印的扫描件或批量下载文档的人。1. 为什么 RAG 解析常被多栏和水印卡住1.1 多栏排版物理顺序不等于阅读顺序很多 PDF 的文本提取顺序是由页面对象写入顺序决定的并不保证符合人类阅读顺序。尤其是双栏论文、三栏会议资料、杂志版面提取器可能先读左栏第一行再读右栏第一行然后回到左栏下一行。这种“乱序”对 RAG 是致命的切分出来的 chunk 内部如果混着两栏内容embedding 向量会被无关上下文稀释如果按原始顺序硬拼接又会丢掉段落的前后关系。举个常见例子一篇双栏论文左栏第一行是“大语言模型”右栏第一行是“在垂直行业的应用”提取器返回一个看似通顺但语义断裂的句子“大语言模型在垂直行业的应用”。实际上左右两栏根本是两段独立内容。用户拿“垂直行业”做检索时embedding 会把这句误判成同一主题导致明明应该命中的右栏段落被左栏关键词干扰。这个问题如果不在解析阶段解决后续做 rerank 也很难救回来——噪声已经被编码进向量里了。1.2 水印 PDF文字层里的“背景噪音”水印也是让 RAG 解析头疼的常见原因。我处理过的 PDF 里水印形式五花八门半透明的“内部资料”“评估稿”、铺满整页的 logo、沿对角线重复的公司名称。它们在视觉上不影响人眼阅读但在文本层会混进正文。直接用page.get_text()提取时文字层会把水印文字和正文一起返回。常规切分之后每个 chunk 里都可能混进同一个水印词。比如一个两页 PDF每页都有“机密”水印那所有 chunk 都带“机密”这个 tokenembedding 模型会把这些 chunk 的向量往同一个方向拉产生大量“假相似”。更麻烦的是水印是半透明的视觉上压在正文中间OCR 识别时还可能把水印边缘和正文笔画叠在一起生成一些根本不存在的新字。所以水印过滤不是“洁癖”是 RAG 召回质量的基本保障。2. bbox 是什么以及它在文档解析中的定位2.1 坐标系里的“外接矩形”我常把 bbox 理解成“萝卜坑”文档里每个元素——一个词、一行字、一张图、一个表格——都有自己的坐标框。计算机不认识“意思”但认得坐标。一个 bbox 通常用左上角 x、左上角 y、右下角 x、右下角 y 表示也就是(x0, y0, x1, y1)有些工具也习惯用中心点加宽高。在 PDF 解析里单位通常是 point1 point 等于 1/72 英寸。有了 bbox 你就能回答大量布局问题A 块在 B 块左边还是下面两个块有没有重叠这个块占页面多大面积哪一种字号的文字块是标题哪块文字颜色特别浅这些判断是做阅读顺序重建、版面分析、表格线识别、水印滤除的共同底座。没有 bbox 的文档解析基本等于闭着眼切文本。2.2 bbox 从哪来OCR、版面模型和视觉语言模型不同工具输出的 bbox 位置、粒度、坐标系都不一样。我在项目里主要用四种来源。来源输出粒度坐标系典型工具适用场景OCR 引擎词级、行级 bbox通常以渲染图像像素为单位PaddleOCR、Tesseract扫描件、图片型 PDF版面分析模型区域级 bbox 加类型标签像素坐标或归一化坐标PP-StructureV2、LayoutParser、Marker复杂版面结构化比如标题、正文、表格分区PDF 文本库文本块、图片块 bboxPDF point 坐标原点一般在左上角PyMuPDF、pdfplumber原生文本型 PDF速度快不需要 OCR视觉语言模型自然语言描述或 JSON 形式的 bbox不定取决于提示词GPT-4V、Qwen-VL少样本处理极端复杂版面成本高、延迟高我的选型习惯是原生文本型 PDF 直接用 PyMuPDF 拿块级 bbox速度快、免费、可控扫描件再叠加 PaddleOCR 做词级 bbox最后映射回 PDF 坐标。版面分析模型用来处理“规则很难写清楚”的问题比如分栏数量不确定、存在复杂表格跨栏、图文混排严重的情况。至于 VLM我只在少数做一次性高价值文档时才用因为它逐页调用大模型成本和延迟都不可控。3. 多栏排版识别与切分的 bbox 实战3.1 先用“X 轴投影”判断页面是否分栏判断一页是不是双栏最有效的办法不是看每一块在左还是在右而是看整个页面的 X 轴投影。把页面上所有文字块的 bbox 映射到 X 轴上相当于把所有文字块的左边界到右边界都涂黑然后统计“每一列被文字覆盖”的分布。双栏版面的中间栏距处会出现一段明显的空白带单栏则不会有。我一般把页面宽度等分成 200 个柱子遍历所有文本块在它覆盖的柱子区间里标记为 1。然后找出连续为 0 的最长区间这个区间宽度占页面总宽的比例就是判断分栏的关键参数。如果最宽空白的占比在 10% 到 40% 之间并且位于页面水平中线附近基本可以判定为双栏如果空白大于 40%可能是页边距或者中间放了一张大图。实际调参时200 个柱子已经能覆盖大多数页面太密会有噪声太疏又看不出栏距。import fitz def detect_column_gap(pdf_path, page_index0, bins200): doc fitz.open(pdf_path) page doc[page_index] blocks page.get_text(blocks) width page.rect.width hist [0] * bins for b in blocks: x0 int(max(b[0], 0) / width * bins) x1 int(min(b[2], width) / width * bins) 1 for i in range(x0, min(x1, bins)): hist[i] 1 max_gap 0 cur_gap 0 gap_start 0 max_gap_start 0 for i, v in enumerate(hist [1]): if v 0: if cur_gap 0: gap_start i cur_gap 1 else: if cur_gap max_gap: max_gap cur_gap max_gap_start gap_start cur_gap 0 gap_ratio max_gap / bins center_ratio (max_gap_start max_gap / 2) / bins return gap_ratio, center_ratio, blocks这个函数返回两个关键值gap_ratio 和 center_ratio。我在实际项目中会打印这两个值先看一眼而不是直接硬编码阈值。因为不同 PDF 的页边距、栏间距差别很大先跑 20 个代表性页面看看分布再定阈值比一上来就猜“页面宽度的 10%”要靠谱得多。3.2 双栏重排栏内按 y 排序栏间按 x 排序确认页面是双栏之后重排的核心思想是“栏内按 y 排序栏间按 x 排序”。我先剔除横跨整页的全宽块比如一级标题、跨栏图片、表格标题再把剩余文本块按中心 x 坐标划到左栏或右栏两栏内部各自按 y0 从上到下排序。最后输出时先按 y 位置把全宽块插进左栏序列再把右栏接到后面。可能有人会问为什么不直接按全局 y 坐标排序然后遇到跨栏块插入因为双栏阅读顺序不是“同一行左右交替”而是先把左栏整列读完再读右栏。如果左右栏高度不一致按全局 y 排序会把右栏多出的部分提前插进来语义就乱了。最简单的可靠策略就是先左后右同时保证全宽块不重复、不丢失。def reorder_blocks_for_columns(blocks, page_width): full, left, right [], [], [] midline page_width / 2 for b in blocks: x0, x1 b[0], b[2] center (x0 x1) / 2 if x0 midline x1: full.append(b) # 横跨中线标题、大图、跨栏表格 elif center midline: left.append(b) else: right.append(b) full.sort(keylambda b: b[1]) left.sort(keylambda b: (b[1], b[0])) right.sort(keylambda b: (b[1], b[0])) # 将 full 按 y 位置插入 left 栏序列保持阅读顺序 merged [] i j 0 while i len(full) or j len(left): if i len(full) and (j len(left) or full[i][1] left[j][1]): merged.append(full[i]) i 1 else: merged.append(left[j]) j 1 ordered merged right return \n.join(b[4].strip() for b in ordered if b[4].strip())这段代码适合绝大多数双栏论文全宽标题会按 y 位置出现在左栏相对位置左栏文本按纵向往下排右栏再完整接在后面。它不优雅但足够实用。遇到三栏、不规则栏距或者表格跨栏挤压文本块的情况我会改用版面分析模型的分栏输出而不是死磕规则。3.3 重排后的文本怎么喂给后续切分重排完不等于可以直接切 chunk。我的经验是先不要急着做 token 切分而是先把解析结果转成中间 JSON每个文本块保留四个关键信息页号、bbox、块类型、重排序号。这样后续切分器可以按“重排序号顺序”聚合文本而不是按 PDF 原始顺序。比如一页生成大约 20 到 40 个文本块每块可能有 1 到 5 行。按重排后的块拼接成文本后再按段落边界切分通常比按固定 token 数切更稳。具体我会看相邻块的 y0 差值如果两块之间的垂直空隙明显大于正文行间距就在那里断开。这个处理对双栏文档特别有效因为栏与栏之间的先左后右顺序不会造成“跨栏切块”问题。还有一点容易被忽略图片和表格也要保留 bbox。如果后续要用多模态检索图片可以按区域裁剪出来配文字一起进索引表格可以单独走表格结构化流程。只保留纯文本会丢掉 RAG 对图文混排文档的一半能力。4. 水印 PDF 的检测与规避4.1 水印的 bbox 特征重复、透明、大面积覆盖水印虽然形式多样但在 bbox 层面有三个非常明显的特征重复、透明、覆盖面积大。文字水印通常是同一个词或短语反复出现位置分布在页面四角、中央或对角线上图片水印则是同一个 logo 或图标不断平铺。因为这些重复我们可以用“重复次数 bbox 重叠关系”来做第一层过滤。透明这一点在文本层里很难直接读出来但可以通过叠加关系判断水印文字的 bbox 往往会和正文文字块的 bbox 有大面积交集。如果我从 PDF 文本层提取出的一个块内容和好几个其他块都重叠那它很可能是压在正文上的水印而不是正常排版。正常文本块彼此之间几乎不会大面积重叠只有少量页眉页脚和注释会出现边缘交错。还有一个特征是“旋转”。很多水印沿 45 度对角线排版而正文文字基本是水平方向。如果解析工具能输出文字的方向角度这是很好的判别指标。但并不是所有工具都提供旋转角度所以我把旋转当作辅助信号不当作必要条件。4.2 一个可用的水印过滤规则针对原生文本型 PDF我常用“重复次数 重叠度”组合规则过滤。规则很简单如果一个文本块的内容在全文出现次数大于等于 3并且它的 bbox 与任意其他文本块的重叠面积占自身面积超过 30%就把它标记为水印。这两个条件单独用都容易误判组合起来就好很多。def overlap_area(a, b): x0 max(a[0], b[0]) y0 max(a[1], b[1]) x1 min(a[2], b[2]) y1 min(a[3], b[3]) if x1 x0 or y1 y0: return 0 return (x1 - x0) * (y1 - y0) def is_watermark_block(block, all_blocks, min_repeat3, min_overlap0.3): text block[4].strip() if not text: return False repeat sum(1 for b in all_blocks if b[4].strip() text) if repeat min_repeat: return False area (block[2] - block[0]) * (block[3] - block[1]) if area 0: return False for other in all_blocks: if other[4].strip() text: continue inter overlap_area(block, other) if inter / area min_overlap: return True return False这套规则我用在内部资料、评估稿这类文字水印上效果不错但千万不要以为它是万能的。如果水印是公司 logo 或者矢量图形文本层根本提取不到就得走视觉检测。此时可以把页面渲染成图片用目标检测模型识别 logo 区域再拿区域 bbox 回到文本层过滤。4.3 不让水印进入 RAG 的三种处理路径第一种是“抽取时过滤”。也就是我上面写的规则解析 PDF 时直接检测并丢弃水印文本块。优点是速度快、不改变原始 PDF缺点是只能识别文本层水印。第二种是“渲染后视觉遮挡”。把 PDF 页面渲染成 RGB 图片在水印 bbox 区域内涂白然后再做 OCR。这个方法适合图片型水印或扫描件水印也适合水印和正文在视觉上叠加严重的情况。但涂白的时候要小心遮挡框稍微放大一点就可能盖住正文边缘。我一般扩张 bbox 一两个像素就够了不要贪多。第三种是“物理清除水印对象”。用 PyMuPDF 或专门 PDF 编辑器把水印对应的矢量对象删掉。这能彻底去除水印但会改变原始文档而且在批量处理时容易误删正文底纹、背景色块适合单个文档慢慢调不适合 RAG 生产线。这里必须多说一句水印去除这个话题一定要谨慎。处理自己拥有版权或已获授权的文档没问题但不要拿别人的书、论文或商业文档去“去水印”后重新分发。我们做 RAG 知识库应该把重点放在解析时规避水印干扰而不是研究如何盗用内容。5. 实操过程中避坑指南5.1 bbox 坐标系与 DPI 换算bbox 实战最容易踩的坑是坐标系不统一。PDF 内置文本层用的坐标通常以 point 为单位原点在页面左上角OCR 输出的坐标以渲染图像的像素为单位。如果直接拿 OCR 坐标和 PDF 坐标相加结果全错。我在项目里养成了一个习惯先确定当前环节在哪个坐标系工作。比如 PyMuPDF 的page.get_text(blocks)返回 PDF point 坐标PaddleOCR 返回的是输入图像的像素坐标。要合并两边的结果需要把像素坐标按72 / dpi缩放回 point。图像宽度除以 PDF 页面宽度也可以得到比例因子。另一个容易忽略的是页面的旋转和裁剪。有些 PDF 首页是横版表格内页是竖版文字如果不统一旋转后的坐标系重排逻辑会突然全部失效。我会在每个页面读取时检查page.rotation先把旋转后的 rect 拿到再算分栏而不是用原始 mediabox。5.2 模型置信度阈值和“误杀”正文无论用版面模型还是规则过滤阈值都要小心。分栏检测时如果把“最长空白区间占比”设得太小单栏页面会被误判成双栏导致从左到右一切正常段落被劈成两半设得太大双栏页面又会漏检重排逻辑完全不生效。我建议先用 30 到 50 个有代表性的页面做统计把 gap_ratio 的分布画出来再定阈值。水印过滤也一样。“重复次数大于 3”这个条件对“内部资料”这类词没问题但如果是普通文档里的页眉页脚比如每页顶部都有“产品白皮书”也会重复很多次。这时要增加“与正文重叠面积”这个条件页眉页脚虽然重复但它不和正文块大面积重叠不会被误删。我在实际调参时先跑一遍规则把标记为水印的块可视化输出到一张图上眼睛扫一遍比看日志快得多。5.3 我的建议重排加过滤优于暴力去水印在多个 RAG 项目里跑下来我的体感是很多朋友一上来就找“去水印工具”想把 PDF 洗干净再解析。但对 RAG 来说水印不需要物理消失只需要不进入后续文本流。暴力去水印会带来两个新问题一是可能破坏原文档二是处理大量文件时稳定性难保证。与其这样不如在解析层把水印区域识别出来把水印文本排除把正文保留。我的标准流程是先用 PyMuPDF 提取块级 bbox做分栏检测和重排再用水印规则过滤最后把干净的块按页号和重排序号写入中间 JSON。这套流程在普通文本型 PDF 上速度几乎可以忽略不计但检索质量的提升非常明显——没有水印关键词拉近向量后召回结果终于不再“一锅乱炖”。如果你正在处理扫描件那就绕不开 OCR先渲染页面再检测水印区域涂白后 OCR。虽然慢一些但至少水印不会混进文本。你要记住bbox 不是万能钥匙但它是让文档解析从黑盒变成可控流程的关键一步。多栏和水印只是两个最典型的场景页眉页脚、脚注、图表标题处理套路都差不多。遇到新问题先把 bbox 可视化出来看几页很多答案会自己浮出来。