☰
RAG文档解析实战:用bbox解决多栏PDF与水印噪声
2026/10/15 14:23:29 网站建设 项目流程

最近在搞RAG知识库的文档解析,被多栏PDF和水印折磨得不轻。如果你的知识库里塞了一堆学术论文、行业报告或者老版扫描件,多半也遇到过这种诡异事:检索出来的片段看着是连续的,读起来却前言不搭后语;明明原文里没有水印,切出来的chunk却带着一排“内部资料,仅供参考”的重复噪音。这问题的根源,十有八九是你压根没看文字的bbox。

bbox全称是bounding box,即每个文字、图片、表格在页面上的最小外接矩形。它看起来只是四个坐标值,实际上决定了你能不能在RAG里正确还原文档的阅读顺序。这篇博文是我做RAG文档解析系列的第三篇,专门聊bbox的实战:多栏排版怎么拆、水印PDF怎么净、页眉页脚怎么清,以及把这些规则揉进解析Pipeline时踩过的坑。内容适合正在搭知识库、做文档结构化解析、或者跟PDF死磕的研发同学,也适合想了解“为什么PDF解析不能只靠文本提取”的初学者。

1. bbox是什么,为什么RAG离不开它

1.1 PDF的“无秩序”本质

先搞清楚一个关键事实:PDF本身不存“段落”“栏目”“阅读顺序”这些语义概念。它只记录一堆绘制指令——在某个坐标画一段文字、在另一个坐标贴一张图、在第三个坐标线条加粗,仅此而已。文本内容在PDF内部是沿着内容流(content stream)顺序存放的,但这个顺序经常和视觉阅读顺序完全无关。生成PDF的工具经常先画背景装饰、再画水印、最后画正文,如果你直接按提取顺序把文字串起来,得到的就是“背景文字 + 水印 + 正文”的大杂烩。

所以要让RAG拿到干净的文本,必须回归到几何层面:拿到每段文字的bbox,通过坐标判断它在页面上的真实位置,再重建人类阅读时的浏览轨迹。这也是为什么说bbox是文档解析的基础设施,没有它,后面所有版面分析、多栏切分、表格识别、水印过滤都无从谈起。

1.2 坐标体系:最容易踩的坑

PDF的坐标系统默认是左下角为原点,x轴向右,y轴向上,单位是点(1点=1/72英寸)。PyMuPDF和pdfplumber返回的bbox都遵循这个坐标系。但很多做NLP的同事习惯用PIL或者OpenCV,那些图像处理库是左上角原点、y轴向下的坐标系。这就导致一个非常尴尬的场景:用PyMuPDF取到bbox,再用PIL可视化时,如果不做y轴翻转,画出来的框全部上下颠倒,文字明明在左上角,框却标到了左下角。

我一开始就被这个坑绊过,所以现在统一做法是:解析阶段全程用PyMuPDF坐标系,可视化时再手动翻转。判断公式很简单:翻转后的y = 页面高度 - 原始y。页面高度可以用page.rect.height拿到。记住这个换算,后面调试能省很多时间。

1.3 bbox背后的“文本图”思路

现在语言模型对PDF的理解,其实已经不只是把文字抽出来。业界有个说法叫“文本图”(text map),本质就是把文本和它的位置信息打包,让模型同时看到“说了什么”和“在哪说的”。比如GPT-4o这类多模态模型读PDF时,靠的就是视觉特征和文本的布局特征。即使不做多模态,RAG解析时把bbox保留下来,对表格转Markdown、跨栏合并、水印过滤都有决定性帮助。

一句话总结:RAG文档解析的核心不是“提取文字”,而是“理解版面结构”,而版面结构的起点就是bbox。

2. 多栏排版解析实战:从检测到线性化

2.1 怎么判断页面是不是多栏

做多栏解析的第一步,不是急着切,而是先判断页面到底是不是多栏。常见的学术论文、行业白皮书大多是双栏,但有些页面是标题页、图表页,可能是单栏居中布局。我用的方法是统计文本块x0坐标的分布,再加一根分界线。

具体来说,用page.get_text("blocks")拿到页面上每个文本块,记下它们的x0(左边界)。如果页面正常是单栏,绝大多数文本块的x0都集中在左边界附近,比如50到100点之间。如果是双栏,x0会明显分成两簇:一簇在60左右,另一簇在页面中线附近,比如300左右。这时候分界线取两簇中心点的中点,或者直接看直方图的谷底。

用聚类更省心。我直接用sklearn的KMeans,聚类数固定2,特判一下如果某一簇的样本数占比不足15%,就当成单栏处理。实测下来,对学术论文、行业报告、说明书这类排版规范的双栏文档,准确率在95%以上。唯一要小心的是标题页和纯图片页,前者文本块数量少,后者几乎没有文本块,走特判逻辑就行。

2.2 跨栏元素怎么处理:标题、表格、通栏图

双栏文档里的标题、跨栏表格、通栏图片是很麻烦的存在。它们的bbox宽度接近整个页面,如果硬按x0归到某一栏,会把左右两栏的对齐关系彻底打乱。我的经验是:先识别跨栏元素,再分栏。

识别逻辑很简单:如果一个文本块或者图片的bbox宽度大于页面宽度的75%,就认为它是跨栏元素。对跨栏元素单独提取,记录它的y坐标;对剩下的窄文本块,按x0聚类分成左栏和右栏。全部处理完之后,在生成线性文本流时,按照y坐标大小把跨栏元素插入到对应位置。这样标题还在开头,跨栏表格还在它原来的位置,但左右栏的正文已经各归各位了。

需要注意:表格如果跨栏出现,它的内部单元格也可能自带多栏结构,不能简单当成通栏处理。我的做法是把跨栏表格单独抽出来,交给后面专门的结构化通道,不掺和在正文线性化里。

2.3 排序与合并:核心代码

实现多栏线性化的核心流程是:每页解析blocks,判断单栏/双栏,双栏先分栏再按y排序,最后合并出整页线性文本。贴一段我常用的PyMuPDF代码:

import pymupdf def page_layout_to_linear(page): # 页面宽度和高度 W, H = page.rect.width, page.rect.height blocks = page.get_text("blocks") # 每个元素: x0,y0,x1,y1,text,block_no,block_type if not blocks: return "" # 过滤太小的块(噪声块) blocks = [b for b in blocks if len(b[4].strip()) > 2] # 判断是否双栏:统计x0聚类 text_blocks = [b for b in blocks if b[6] == 0] if len(text_blocks) < 3: return "\n".join(b[4] for b in blocks) x0s = [b[0] for b in text_blocks] mid = W / 2 left_count = sum(1 for x in x0s if x < mid) right_count = len(x0s) - left_count # 双栏判断:左右都有相当数量的文本块 if left_count > len(x0s) * 0.3 and right_count > len(x0s) * 0.3: left_col = sorted([b for b in text_blocks if b[0] < mid], key=lambda b: b[1]) right_col = sorted([b for b in text_blocks if b[0] >= mid], key=lambda b: b[1]) full_width_blocks = [b for b in text_blocks if b[2] - b[0] > W * 0.75] full_width_blocks.sort(key=lambda b: b[1]) # 按跨栏元素的y位置,将左右栏文本分段拼接 parts = [] last_y = 0 for fw in full_width_blocks: fy = fw[1] parts.append(join_cols(left_col, right_col, last_y, fy)) parts.append(fw[4]) last_y = fy parts.append(join_cols(left_col, right_col, last_y, H)) return "\n".join(p for p in parts if p.strip()) else: text_blocks.sort(key=lambda b: (b[1], b[0])) return "\n".join(b[4] for b in text_blocks) def join_cols(left_col, right_col, y_start, y_end): """取出左右栏在y_start到y_end之间的文本,按左栏先、右栏后拼接""" left_text = "\n".join(b[4] for b in left_col if y_start <= b[1] < y_end) right_text = "\n".join(b[4] for b in right_col if y_start <= b[1] < y_end) return left_text + "\n" + right_text

这段代码的核心思想是:双栏页面阅读时,人眼通常先读完左栏整列,再跳回右栏。所以对每一段被跨栏元素切分出来的横向区域,我输出顺序是“左栏全部文字 + 右栏全部文字”。标题、通栏图片则按照它的y位置,插入到对应的区域末尾。逻辑不复杂,但对学术论文的效果立竿见影。

2.4 切分策略与栏保护

多栏解析完,紧接着就是RAG切分。很多人在这一步犯的错误是:先切成大段文本再做embedding切分,导致一个chunk里同时混了左栏和右栏的文字。正确做法是,在多栏线性化之后,按栏为单位保护段落。也就是说,切分器在整个线性文本流上运行时,必须知道哪些内容属于同一栏,跨栏边界处要强制断开。

我习惯在解析阶段就给每段文本打一个元数据标记,比如{"page": 3, "column": "left", "block_no": 5},切分器遇到column字段变化时,即使文本长度没达到chunk_size也强制截断。这样做的好处是每个chunk语义完整,检索出来的片段不会出现“上一句在左栏、下一句在右栏”的错乱。

chunk_size的设置也要跟着栏目走。双栏论文每栏宽度大概300点,一栏能放300到500个字,所以chunk_size可以设置成400到600,overlap设80到120。如果单栏文档,chunk_size可以适当调大到800到1000。这个值不是拍脑袋定的,是根据栏宽、字号、行距算出来的。

3. 水印PDF的净化:从检测到过滤

3.1 水印的几种常见形态

水印PDF大概是RAG解析里最烦的一类输入。它不像多栏那样结构规律,水印的形态五花八门。我归纳了四种常见类型:一是文本水印,每页同一个位置反复出现同一行字,比如“仅供参考”“内部资料”,这种最常见,也最好处理;二是图片水印,整页铺一个半透明的Logo图,文字本身不出现,但会让后续OCR或者视觉模型误读;三是页眉页脚类,严格说不是水印,但特征类似——每页顶部或底部固定出现文档标题、页码、日期;四是不可见文本水印,用颜色很浅的文字铺在页面上,肉眼看不到,但文本提取时会被抓出来。

这四类水印的共同特征是什么?重复性和位置固定性。真正干净的文档不会在每一页的同一个坐标位置反复出现同一段文字。这就是检测水印的抓手。

3.2 用bbox特征做水印检测

既然水印是“每页同一位置反复出现的小字”,那么只要统计跨页重复出现的bbox区域就能揪出来。我的做法是:

第一,把每一页的span级别文本提取出来,记录每个span的文本、字号、颜色、bbox。第二,把所有span的左上角坐标映射到相对位置(x0/W和y0/H)。第三,对跨页面聚合:如果同一个相对位置在超过70%的页面都出现了文本,且字号明显小于正文中位字号,那么这个位置标记为水印或页眉页脚区域。

下面是我用的一套核心过滤逻辑,代码不长,但实战里很能打:

from collections import defaultdict def detect_repeated_regions(pages_spans, min_ratio=0.7): # pages_spans: list,每个元素是list[span],span包含text,x0,y0,x1,y1,size,color pos_counter = defaultdict(list) page_count = len(pages_spans) for page_idx, spans in enumerate(pages_spans): for span in spans: h = span["y1"] - span["y0"] if h < 3: # 过滤杂散点 continue rel_x = round((span["x0"] + span["x1"]) / 2 / 612, 2) # 归一化中心x rel_y = round((span["y0"] + span["y1"]) / 2 / 792, 2) # 归一化中心y key = (rel_x, rel_y) pos_counter[key].append((page_idx, span)) repeated_regions = [] for key, items in pos_counter.items(): if len(items) > page_count * min_ratio: repeated_regions.append(key) return repeated_regions

这个逻辑比较简单,判断相对坐标一致并且跨页重复出现。实际工程里还要再加几道保险:比对各页的重复文本是否一致,如果每页都是“内部资料”这四个字,基本可以断定是水印;比对字号与全页中位字号,水印字号通常在正文的50%以下;检查颜色和页面背景的对比度,水印用浅灰或浅蓝很常见。

3.3 Span级别的清洗与页面净化

拿到重复区域列表后,真正的清洗是逐span进行的,不是逐块进行的。这里有个细节:你绝对不能用page.get_text("blocks")去做水印过滤,因为block是多个span聚合的结果,一个block里可能混着正文和水印,一刀切会把正文也误删。我最终用的是page.get_text("dict"),它可以拿到span级别的数据,包括font、size、color、bbox,粒度最细。

清洗逻辑按优先级做三件事:

  • 第一层:直接按“重复区域 + 文本一致”删除,这是最安全的一层,误删率接近0。
  • 第二层:按“位置在页面上下边距 + 字号偏小”删除,这层主要针对页眉页脚,顺带处理页码。页面正文区域一般占据中间70%的纵向范围,超出这个范围的固定文本基本都是页眉页脚。
  • 第三层:按“颜色极浅 + 字号偏小”过滤,这层专门收拾不可见文本水印。颜色的判断需要注意,PDF里的颜色值通常是24位RGB,0xFFFFFF是白色。如果一个span的颜色灰度值大于0.85,同时字号又小于正文中位字号的60%,那它极大概率是不可见水印。

三层过滤都记录日志,方便回溯。谁被删了、为什么被删、在哪一页被删,全部写进JSON,调试时一目了然。

3.4 图片型水印和半透明水印

图片型水印是另一个麻烦。文本提取阶段它完全隐形,因为图片没有文本内容。但如果你做OCR,或者后续把渲染后的页面交给多模态模型,图片水印就会成为一个大噪声源:它不仅可能被识别成一堆无意义的“文字”,还会干扰模型对正文区域的注意力。

处理图片水印,我用的仍然是几何思路。水印图片的特征是:覆盖范围大、跨页位置固定、透明度一般设置得很低。用page.get_image_info()可以拿到页面上每张图片的bbox,再跨页比对位置。如果同一张图片在每一页都出现在同一个相对位置,并且它的大小覆盖了页面中央大部分区域,直接把它从页面上“剔除”。这里的剔除不是真的删掉图片,而是在后续OCR和图像理解阶段,把这个区域Mask掉,不让模型和水印发生关系。

半透明水印则稍微复杂一点。PDF的半透明通过ExtGState的CA和ca属性控制,PyMuPDF无法在span级别直接给我们透明度值。所以项目里我对半透明水印不专门做透明度检测,而是靠两招组合拳:一是看颜色,半透明水印通常伴随着比较浅的RGB值;二是靠位置重复性,只要位置特征符合重复区域,就算透明度没拿到也照样过滤。我遇到的90%的水印,都能靠“位置 + 颜色 + 字号 + 重复性”四个特征组合干掉。

3.5 页眉页脚、页码一并处理

页眉页脚和水印一样,是RAG解析里常见的噪声源。每页顶部一行文档标题、底部一个页码,看起来无伤大雅,但切分成chunk后,每个chunk都会带着同样的页眉页脚文字。embedding的时候,这些重复内容会让不同chunk的向量产生虚假的相似度,检索时非常容易误召回。我观察过,一批有页眉页脚的文档,检索命中率至少下降10个百分点。

处理上我用一个更狠的规则:页面顶部和底部各留出10%的边距,在这个边距内的所有span,除非是正文段落跨越了边距线(这种情况极少),否则全部视为页眉页脚候选。再结合和上一节相同的跨页重复性验证,准确率非常高。页码就更好办了,正则匹配纯数字、位于底部中心或两侧、跨页连续递增,满足条件就删。

因为页眉页脚、水印本质是一种“固定位置的重复文本”,所以我在代码里把它们统一抽象成“repeated patterns”处理,一套逻辑复用,只是不同区域、不同阈值。

4. 工程化落地与问题排查

4.1 完整Pipeline怎么串

多栏解析和水印清洗单独拿出来看都挺简单,但合在一起就容易出一堆“相互作用”的问题。比如水印过滤要是把跨栏标题误删了,多栏排序就会错位。所以我自己项目里的完整流程是固定的,一步一步串下来,每一步的输出都存成中间JSON:

第一步,解析PDF,逐页提取span级文本和bbox;第二步,做页面级的版面分析,判断单栏/双栏、识别跨栏元素;第三步,多栏线性化,生成整页阅读顺序文本;第四步,水印和页眉页脚清洗,在全页文本流里做过滤;第五步,结构标记,把标题、表格、图片这些特殊区域打上标签;第六步,按栏目和段落边界切分chunk;第七步,embedding入库。

这个流水线里最关键的一个设计决定是:水印过滤必须放在多栏线性化之前,但可视化调试要在多栏线性化之后做。为什么?因为多栏线性化会把跨页重复的页眉页脚文本移动到不同的位置,水印检测的跨页位置特征就被破坏了。反过来,调试的时候只看清洗前的原始版面图,才能肉眼判断过滤规则是否误伤了正文。

4.2 可视化调试:别用眼睛直接看文本

有一个经验我踩了三次坑才总结出来:解析质量问题,绝对不要靠“读抽取出来的文本”来验证。文本抽取后的顺序已经经过代码处理,如果你看到的乱序,你很难判断到底是排序逻辑的问题还是清洗逻辑的问题。正确做法是给每页画bbox调试图——把文本块的bbox直接画在原页面图像上,每个块标上序号和预测的栏位。

我用的是PyMuPDF自带的可视化能力:渲染页面为图片,然后把每个span的bbox画成矩形,颜色按栏位区分,左栏蓝色、右栏绿色、跨栏元素红色、水印区域灰色。这样一眼就能看出排序有没有错、水印有没有漏删、跨栏元素有没有被错误归属。调试图配合文本输出一起看,效率翻倍。

另外强烈建议所有关键解析结果都保存成JSON,包含页码、block序号、span位置、文本、置信度。这些数据既是调试材料,也是后续做解析质量评估的输入。质量评估不要只看“抽取率”,还要看“版面还原率”——也就是预测的阅读顺序和实际阅读顺序的一致性。这块我目前是靠人工抽检,抽检量不大,但每个页面都要看到bbox图。

4.3 常见问题速查表

在实战里,不同团队遇到的问题高度相似。我整理一个速查表,可以直接对照排查:

现象可能的原因解决办法
左右栏文字交替混在一起分栏判断失败,把双栏页当成了单栏检查文本块x0分布,确认是否因为跨栏元素过多导致聚类失衡;调大跨栏宽度阈值
同一栏内段落顺序乱序只按y0排序,没考虑同一行内多个span的水平顺序排序键改成(y0, x0)组合;对y0差值小于5的span,按x0升序排列
标题被分到某一栏里,位置错乱跨栏元素识别阈值太严格(如只认80%宽度)降低跨栏宽度阈值到70%,同时忽略图片块,只对文本块计算宽度
水印删了但正文出现小字缺失过滤阈值过严,把注释、脚注、小字说明当水印删了增加位置黑名单校验:只在重复区域且颜色浅的情况下过滤;注释通常只出现一次,不满足跨页重复条件
每页开头多了一段重复文字页眉页脚过滤没有生效检查重复区域检测的min_ratio是否太高;如果文档页数少于5页,降低阈值到0.5
图片水印干扰OCR结果没有做图片级Mask用get_image_info拿图片bbox,跨页比对固定位置出现的图片做区域屏蔽
解析耗时太长把整页渲染成图片再跑OCR能用文本层提取就绝不用OCR;只在扫描件场景开OCR,并用多进程并行
旋转页面导致坐标错乱PDF某些页带旋转属性解析前统一处理page.rotation,或者用page.rect.width和height动态适配坐标

4.4 性能优化建议

最后一个务实问题:一批PDF几千页,解析会不会很慢?我的经验是,如果都是文本型PDF(有文本层),只跑PyMuPDF提取和规则清洗,单页大概在50到150毫秒之间,瓶颈集中在get_text("dict")的大页面和复杂表格上。我通常用多进程按文件粒度并行,一个文件一个进程,避免GIL限制。内存方面,逐页处理、不缓存整本书的span列表,峰值内存能控制在500MB以内。如果遇到扫描件需要OCR,那就完全是另一套思路了,OCR单页就要1到3秒,需要上一整个OCR并行集群。

我在实际项目里还有一个优化细节:对同一个PDF重复解析时,可以把解析结果缓存成中间格式(比如JSON Lines),下次直接读缓存。知识库要更新时,新增文件走解析,旧文件直接复用缓存,避免重复计算。

写在最后的一些体会

我做RAG文档解析算下来也有一年多了,最强烈的感受是:bbox这东西看着简单,实际是版面理解的敲门砖。很多人做PDF解析,第一步就是拿正则和规则去抽文本、抽表格,忽略了文本在页面上的位置信息。结果就是,结构化文档做得越多,越发现纯文本提取的天花板低得可怜——翻页、分栏、页眉页脚、水印、表格嵌套,任何一个稍微复杂的版面都能让规则崩溃。

而一旦把视角从“提取文字”转成“理解版面”,bbox就是那个打开局面的钥匙。有了坐标,多栏可以线性化,水印可以检测,页眉页脚可以清除,表格可以结构化,甚至还能把版式信息喂给多模态模型做更高级的页面理解。这篇实战里给的代码和阈值都是能直接跑的,但更重要的,是把坐标意识和版面意识带进你的解析流程里。最后再分享一个心得:任何排序和过滤规则上线前,一定要跑一个几十页的样本,把处理前后的bbox图放在一起人工扫一遍,宁可慢一点,也别让坏规则在全部文档上跑完才发现。

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

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

立即咨询