做 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 轴向下,单位是 point(1 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_threshold=60): """blocks: 包含 bbox 字段的文本块列表 返回: {0: [blocks...], 1: [blocks...], ...} 按栏分组 """ centers = [ (block["bbox"][0] + block["bbox"][2]) / 2.0 for block in blocks ] order = sorted(range(len(blocks)), key=lambda 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_threshold=60) # 3. 先按栏的 x 顺序,再按栏内 y 顺序 for col_key in sorted(columns.keys()): col_blocks = sorted( columns[col_key], key=lambda b: b["bbox"][1] ) result.extend(col_blocks) # 4. 跨栏块按 y 顺序插入 full_width.sort(key=lambda b: b["bbox"][1]) result.sort(key=lambda 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, threshold=0.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 是“页面坐标系”下的坐标,但如果你做了 OCR,OCR 返回的坐标是“图像像素坐标”。两者相差一个缩放因子:页面坐标的 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 坐标的分布。很多时候,问题在坐标层面一眼就能看穿。