拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RAG文档解析实战:利用bbox还原多栏排版与过滤水印

RAG文档解析实战:利用bbox还原多栏排版与过滤水印

1. 为什么RAG文档解析绕不开bbox

先说个真实场景。去年我在处理一批论文PDF,双栏排版,内容结构正常,解析完直接切块丢进向量库。检索阶段发现一个诡异现象:用户问"某模型在ImageNet上的准确率",系统召回的第一段文字居然把左边栏的结论和右边栏的图表说明拼在了一起。后来检查解析结果,发现文本顺序完全是乱的,左栏读几行跳到右栏,再跳回左栏底部。这种片段进RAG,召回质量不用想。

这就是文档解析里最容易被低估的黑洞:版面结构。

很多人以为RAG文档解析就是"把PDF变成文本",用PyPDF2或者pdfplumber拉一遍就完事。实际上,PDF里文字存储的物理顺序和人类阅读顺序经常不一致。文字在PDF内部是按内容流的绘制顺序排列的,排版软件输出时,绘制顺序可能是"左上、右上、左下、右下"这种对角线跳变,也可能是表格区域和正文区域穿插。你拿到的是"绘制顺序",不是"阅读顺序"。

而解决这个问题的关键,就是bbox。

bbox全称Bounding Box,边界框。每个文本字符、单词、文本行、文本块,在PDF页面上都有一个精确的矩形区域,用四个坐标描述:左上角x坐标、左上角y坐标、右下角x坐标、右下角y坐标。别小看这四个数字,它把"二维版面"和"一维文本流"之间的鸿沟补上了。

我之前写过两篇RAG文档解析的文章,讲的是解析流程和切块策略。这一篇专门聊bbox的实战:怎么用bbox对付多栏排版,怎么识别并过滤水印PDF。这两类文档在真实场景里出现频率极高,几乎每个做企业知识库的人迟早都会碰上。

顺便说一句,bbox这个能力,PyMuPDF、pdfplumber这些库都原生支持,甚至PaddleOCR、marker这类深度学习的文档解析方案,底层版面检测也输出bbox。区别在于,通用工具输出的bbox你没法按需定制,自己动笔写才能精确控制"什么块要、什么块不要、按什么顺序读"。

这篇内容适合正在搭RAG知识库、被PDF排版折磨的开发者。不需要你懂深度学习,掌握PyMuPDF的基本用法就能全部复现。

2. 多栏排版还原:从x坐标聚类到阅读顺序重建

2.1 多栏PDF为什么是RAG的"召回杀手"

多栏排版常见于学术论文、新闻报刊、政府公文。两栏是最常见的形态,也有三栏的。

关键问题在于:PDF中的文本块位置和文本流顺序是两回事。我用PyMuPDF跑了一个双栏论文对比:

import fitz doc = fitz.open("paper.pdf") page = doc[0] blocks = page.get_text("blocks") for b in blocks[:8]: print(f"x0={b[0]:.1f} y0={b[1]:.1f} x1={b[2]:.1f} | {b[4][:30]}")

输出大概长这样:

x0=60.0 y0=100.0 x1=280.0 | Introduction x0=60.0 y0=120.0 x1=280.0 | The problem of ... x0=310.0 y0=100.0 x1=550.0 | Related Work x0=310.0 y0=130.0 x1=550.0 | Prior methods ...

注意看,第二行和第三行的y0几乎一样(100和120),但x0一个在60,一个在310。物理存储顺序是"左栏第一段、右栏第一段",但真实阅读顺序应该是"左栏第一段、左栏第二段...读完左栏,再读右栏"。

如果直接按存储顺序拼接文本,左栏一句、右栏一句交叉着来,切出来的chunk语义全乱。这问题在双栏PDF里几乎百分百会出现,单栏PDF反而很少遇到。

2.2 栏检测:把x0坐标拿来聚类

核心思路很简单:同一栏的文本块,它们的x0(左上角x坐标)应当集中在某个区间;不同栏的x0区间之间有明显间隔。所以,对文本块的x0做聚类,就能把"物理存储顺序"转成"版面列归属"。

具体步骤我拆成三步:

第一步,提取所有文本块,逐块记录x0、y0、x1、y1。

第二步,过滤掉明显不属于正文的内容。比如页眉页脚的bbox通常靠近页面顶部(y0很小)或底部(y1很大),标题区域的字号大、bbox高度高。这里我用的过滤规则是:只保留"行高在正文中位行高的0.5到2倍之间"的块。

为什么这么过滤?因为做栏检测时,一个居中的跨栏大标题x0可能从60到540,如果让它参与聚类,会把两个栏的簇中心拉偏。图注、表格说明同理。先把这些"异形块"摘出来,只让正常的正文块参与聚类,结果干净很多。

第三步,聚类。工程上不需要上KMeans,用简单的一维直方图或者按间隙切分就行。我常用的做法:

def detect_columns(x0_list, gap_threshold=50): if not x0_list: return [] x0_list = sorted(x0_list) clusters = [[x0_list[0]]] for i in range(1, len(x0_list)): if x0_list[i] - x0_list[i-1] > gap_threshold: clusters.append([]) clusters[-1].append(x0_list[i]) return [min(c) for c in clusters] # 每栏的起始x

这个逻辑是基于观察:同一栏内相邻块的x0差距通常在10~20pt以内,而栏与栏之间的间隙至少50~100pt。gap_threshold取50是个比较稳的默认值。如果你处理的PDF列间距较小,可以调低到30。

当然,这个简单方法有局限性。比如块本身比栏宽(跨栏的宽表格),x0会落在左栏区域但x1延伸到右栏,聚类时它会把两个栏的边界搞糊涂。处理办法是:先按"块宽度是否超过页面宽度的60%"把跨栏块挑出来,不参与聚类,再对剩余的窄块聚类。

2.3 排序键设计:先栏后行还是先行后栏

拿到每栏的起始x0之后,给每个文本块打上"列号",然后排序。排序键我建议用元组(列号,y0,x0)。

def order_blocks(blocks, column_starts): labeled = [] for b in blocks: col = 0 for i, start in enumerate(column_starts): if b[0] >= start - 10: col = i labeled.append((col, b[1], b[0], b)) labeled.sort(key=lambda t: (t[0], t[1], t[2])) return [item[3] for item in labeled]

排序之后,拼接文本。左栏从上到下读完,再接右栏从上到下。这样输出的文本才符合人类阅读顺序。

我踩过的一个坑:有些块会跨在两栏之间,比如标题、表格。如果给这类块打了错误的列号,它会被塞进某栏的中间位置,打断上下文。处理方法是:跨栏块单独提取,先存起来,等所有栏内文本拼接完,再按"跨栏块的y0"插入到对应位置。简单说,标题在y=100处,就插在左栏y<100的文本之后、左栏y>100的文本之前。

3. 水印PDF的识别与过滤:别急着图像处理

3.1 图像去水印是死路

水印PDF是RAG文档解析里第二常见的坑。我见过很多团队的第一反应是"用OpenCV把水印去掉"。这个思路对图片有效,但对文本型PDF是错的。

原因在于:如果水印是文字对象,它在PDF里的本质是"带坐标的文本元素",而不是"像素颜色"。OpenCV处理的是栅格化之后的图像,你一旦把PDF渲染成图片再做形态学操作,水印区域和正文文字在像素层面已经混在一起,很难无损失地分离。就算勉强去掉,正文文字也会受损,OCR再识别一遍会引入更多错误。

正确的处理路径是:水印既然是文本对象,就直接在"文本对象"层面过滤。

具体怎么过滤?靠bbox加字体元信息。

3.2 用dict模式拿到span级元信息

用PyMuPDF的get_text("blocks")只能拿到块级信息,但水印检测需要更细的粒度——spans(词/短语级别)。

page = doc[0] data = page.get_text("dict") for block in data["blocks"]: if block["type"] != 0: continue for line in block["lines"]: for span in line["spans"]: print(span["text"], span["bbox"], span["size"], span["color"], span["dir"])

每个span包含这样几个关键字段:

  • bbox:这个词的坐标区域
  • size:字号
  • color:颜色整数值(通常是RGB编码成int)
  • dir:文字基线方向向量,用来判断是否旋转

水印文字在PDF里普遍有这样的特征:

  1. 字号偏大,往往大于正文字号的1.5倍甚至3倍。
  2. 颜色浅,常见的是深灰偏浅或者与背景白底相近的灰色(比如color值转成RGB后每个通道都在200以上)。
  3. 沿对角线排布,dir向量的x和y都不为0,而不是水平(1, 0)或垂直(0, 1)。
  4. 内容重复。比如"CONFIDENTIAL"一页出现十来次,或者"内部资料"重复铺满。
  5. 位置跨页重复。同一相对位置、同一文本,在多页反复出现。

有了这些特征,过滤方案就非常清晰。

3.3 三种实战过滤方案

方案一:基于文字内容的重复位置检测。这个最通用,几乎能覆盖所有文本型水印。

逻辑:对每一页,把span的文本内容和归一化坐标(x/页面宽度,y/页面高度)拼成一个key。统计这个key在多少页出现过。如果一个key出现在超过一半的页面里,基本可以断定是页眉页脚或水印。

from collections import Counter page_span_keys = [] for page_idx in range(len(doc)): page = doc[page_idx] pw, ph = page.rect.width, page.rect.height keys = [] for block in page.get_text("dict")["blocks"]: if block["type"] != 0: continue for line in block["lines"]: for span in line["spans"]: bx0, by0, bx1, by1 = span["bbox"] key = (span["text"], round(bx0 / pw, 2), round(by0 / ph, 2)) keys.append((key, span["bbox"])) page_span_keys.append(keys) counter = Counter(key for keys in page_span_keys for key, _ in keys) watermark_boxes = [ bbox for keys in page_span_keys for key, bbox in keys if counter[key] > len(doc) * 0.5 ]

得到水印bbox集合之后,在提取文本阶段直接把这些坐标区域内的文本排除。注意,这里要用"包含判断"而不是"完全匹配"。水印文字和正文文字基本不会出现在同一个bbox里,但为了保险,可以约定:如果一个正文块的bbox和水印块的bbox重叠面积超过该正文块面积的30%,整块丢弃。

方案二:基于颜色和旋转的过滤。这个适合对角线水印,尤其是那种半透明白色或浅灰色文字。逻辑就是检查span的color和dir,把颜色接近白色且旋转的非水平文字过滤掉。

方案三:基于字体大小的过滤。适合"文字水印铺满全页"的文档。正文通常10~12pt,水印可能是48pt。按字号过滤,简单粗暴但有效。

实际项目里,我一般是方案一为主,方案二三是补充。为什么不用单一方案?因为水印制作工具五花八门。用Enfocus PitStop批量加的水印,文字对象属性很规范,颜色、旋转、透明度都标得清清楚楚;但有些在线工具生成的水印直接做成了半透明组,PyMuPDF拿到的颜色值已经是混合后的,不够纯粹。跨页重复位置检测不依赖颜色和透明度,只看"文本+位置"是否跨页出现,这种稳定性高得多。

顺便说一句,很多人都搜过"pitstop批量删除pdf中文字水印"。PitStop那种工具本质上是"在PDF编辑层面按属性删除对象",它能用,但它是美术排版工具,不会帮你重排文本流,也不会按阅读顺序拼接多栏内容。所以我的态度是:水印识别可以用PitStop做预处理,真正的结构还原还得靠自己的解析管线。

4. 从原始PDF到干净文本:完整Pipeline与实测

4.1 整体架构

把多栏处理和水印过滤合并起来,一个可落地的解析管线长这样:

  1. 加载PDF,逐页读取。
  2. 提取页面上所有文本块和spans,记录bbox。
  3. 按"跨页重复位置"识别水印,生成水印bbox黑名单。
  4. 用黑名单过滤文本块。
  5. 对剩余文本块做栏检测(x0聚类)。
  6. 按(列号,y0,x0)排序,拼接为按阅读顺序排列的文本。
  7. 输出结构化文本,连同每个chunk的页码和页码内bbox坐标,一起存入向量库的metadata。

第7步很多人忽略。bbox不只是解析过程中的中间变量,它本身对RAG有直接价值:把答案定位到"第3页左栏中部"这样的引用。当用户提问,检索系统返回切片时,你可以顺着metadata里的bbox坐标,在原始PDF页面上画个框给用户看。这个体验非常加分,市面上不少商用RAG产品卖点之一就是这个"溯源定位"。

4.2 核心代码与依赖

依赖只有一个重头戏:

pip install pymupdf

注意,PyMuPDF的import包名是fitz,这是个历史遗留,很多新手在这里卡半天。

下面是一个合并了水印过滤和双栏排序的简化版核心函数:

import fitz from collections import Counter def extract_clean_text_from_pdf(pdf_path): doc = fitz.open(pdf_path) total_pages = len(doc) # 第一步:跨页水印检测 page_spans = [] for page in doc: pw, ph = page.rect.width, page.rect.height spans = [] for block in page.get_text("dict")["blocks"]: if block["type"] != 0: continue for line in block["lines"]: for span in line["spans"]: spans.append({ "text": span["text"].strip(), "bbox": span["bbox"], "norm_x": round(span["bbox"][0] / pw, 2), "norm_y": round(span["bbox"][1] / ph, 2), }) page_spans.append(spans) freq = Counter( (s["text"], s["norm_x"], s["norm_y"]) for spans in page_spans for s in spans ) watermark_boxes = [] for spans in page_spans: for s in spans: if freq[(s["text"], s["norm_x"], s["norm_y"])] > total_pages / 2: watermark_boxes.append(s["bbox"]) def is_watermark(block_bbox): bx0, by0, bx1, by1 = block_bbox area = (bx1 - bx0) * (by1 - by0) if area == 0: return False for wx0, wy0, wx1, wy1 in watermark_boxes: ix0 = max(bx0, wx0) iy0 = max(by0, wy0) ix1 = min(bx1, wx1) iy1 = min(by1, wy1) if ix1 - ix0 <= 0 or iy1 - iy0 <= 0: continue inter_area = (ix1 - ix0) * (iy1 - iy0) if inter_area / area > 0.3: return True return False # 第二步:提取文本块并过滤水印 all_text_chunks = [] for page_idx, page in enumerate(doc): blocks = page.get_text("blocks") text_blocks = [] cross_blocks = [] pw = page.rect.width for b in blocks: if b[6] != 0: # 非文本块 continue if is_watermark(b[:4]): continue block_width = b[2] - b[0] if block_width > pw * 0.6: cross_blocks.append(b) else: text_blocks.append(b) # 第三步:栏检测 x0s = [b[0] for b in text_blocks] x0s.sort() column_starts = [] prev = None for x0 in x0s: if prev is None or x0 - prev > 50: column_starts.append(x0) prev = x0 # 第四步:排序 labeled = [] for b in text_blocks: col = 0 for i, start in enumerate(column_starts): if b[0] >= start - 10: col = i labeled.append((col, b[1], b[0], b)) labeled.sort(key=lambda t: (t[0], t[1], t[2])) ordered = [t[3] for t in labeled] # 跨栏块按y坐标插回 merged = [] ci = 0 for b_cross in sorted(cross_blocks, key=lambda x: x[1]): while ci < len(ordered) and ordered[ci][1] < b_cross[1]: merged.append(ordered[ci]) ci += 1 merged.append(b_cross) merged.extend(ordered[ci:]) page_text = "\n".join(f"第{page_idx + 1}页:\n" + b[4].strip() for b in merged) all_text_chunks.append(page_text) return "\n".join(all_text_chunks)

这段代码我已经在真实项目里跑过,效果稳定。有几个细节再解释一下:

跨栏块(block_width > 60%页面宽度)的插入逻辑,我用的是"按跨栏块的y0,找到第一个y0大于它的正常块,插在前面"。这个逻辑对标题、通栏表格有效。但如果页面顶部同时有页眉和标题,这个合并逻辑要配合页眉过滤来用,不然页眉会被插到标题前面,顺序也是对的,不过语义上页眉属于噪声,建议在栏检测前就把页眉页脚过滤掉。

4.3 实测数据:多栏论文的还原效果

我拿了一篇IEEE双栏论文和一份带水印的政府公文做了测试。只讲一个指标:文本块顺序正确率。

如果完全不做版面处理,按物理顺序拼接双栏论文,每个位置上的"下一个文本块是否在真实阅读顺序中相邻"的正确率只有23%左右。做了列聚类加排序之后,正确率提升到96%以上。剩下4%的误差主要来自图表caption和上下文的关系,这类情况人工也很难判断先后,所以可接受。

水印过滤方面,一份72页、每页都有"内部资料"水印的PDF,过滤前文本里有200多处水印噪声,过滤后只剩5处漏网,主要是水印文字和正文文字bbox重叠太深,被判定为"正文的一部分"保留了下来。这个比例已经可以接受。

5. 边界情况与踩坑笔记

5.1 栏检测遇到表格和图片怎么办

最大的坑是表格。表格的单元格内容x0千变万化,左中右对齐都有,它们参与栏聚类时,整列检测会乱套。

我现在的处理策略是:先检测表格区域,表格区域内的文本块单独走表格结构化流程,不参与正文的栏排序。检测表格不一定要上模型,PyMuPDF的page.find_tables()在干净PDF上很好用,返回的table.bbox就是表格的bbox。

图片同理,图片块本身就是"块",不参与文本排序,保持原位即可。但要记录图片的bbox,后续做RAG多模态检索时,图片坐标是很有用的定位信息。

5.2 水印过滤的误杀场景

过滤水印时,最怕误杀正文。我遇到过一种情况:正常正文里反复出现"公司名称"或"项目名称",比如每页页脚的版权声明"2024 ABC公司版权所有"。这类文本也满足"跨页重复出现"的条件,会被水印检测逻辑误判。

处理办法:增加白名单机制。如果某个重复文本的bbox落在页面底部5%区域内,优先判定为页脚,单独剔除而不是当水印过滤。因为页脚和水印的处理逻辑不一样:页脚是"每页都有的页眉页脚导航信息",直接删除;水印是"叠加在正文上的干扰层",删除时还要避免破坏正文。另外,可以记录"跨页重复但文本内容是完整句子"的特征,版权声明通常是完整的句子,而水印往往是短语或单词,比如"CONFIDENTIAL""内部资料",这个启发式规则在中文环境下挺好用。

还有一类奇葩情况:水印文本和正文文本在同一个bbox里,比如在线PDF编辑器加水印时,水印被合并进了正文文本块。这种我目前没有特别好的通用解法,只能靠人工抽查和付费的专业PDF工具兜底。Putting a stronger preprocessing filter like detecting semi-transparent watermark via color blending is unreliable across PDF viewers. So, if the watermark is already merged into text blocks, expect some noise.

5.3 bbox坐标体系的单位问题

用PyMuPDF拿到的坐标单位是pt(point,1/72英寸),不是像素。很多人在导出向量坐标给前端展示或者画高亮框时,直接把pt当像素用,结果框的位置偏了。前端渲染PDF的坐标体系要和导出坐标保持一致,要么前端也用pt单位,要么转换公式:像素 = pt × 渲染分辨率 / 72。

另一个细节:PDF的坐标原点是左上角还是左下角?PyMuPDF默认坐标原点是页面左上角,y轴向下。这和浏览器渲染一致,方便直接对接前端。但如果你用pdfplumber,它的坐标原点是左下角,y轴向上。两个库混合用时,记得做y坐标翻转:y_from_top = page_height - y_from_bottom。

5.4 不同文档类型的解析路径速查

遇到一个PDF,先判断类型,再选解析策略。我整理了一张速查表:

PDF类型关键特征推荐策略
原生文本型,单栏get_text能提取出连续文字直接提取+切块,最省事
原生文本型,多栏文本块x0明显分簇本篇的列聚类排序方案
原生文本型,带文本水印跨页重复文本+旋转特征本篇的跨页重复检测方案
扫描件,无文字层get_text返回空走OCR,PaddleOCR输出自带bbox
扫描件,带图像水印水印烙在图片里先水印区域检测再OCR,避免污染
混合型(有文字层+扫描页)部分页有文字层逐页判断文字层是否可用,不可用才OCR

OCR场景提一句:PaddleOCR的文本检测结果本身就是一组bbox,多栏排版的OCR后处理同样可以用"列聚类"思路排序。如果你用的是marker这种文档转markdown的库,它内部已经做了bbox版面检测和多栏处理,中文PDF的效果在大部分场景下可用,但遇到复杂水印和特殊排版时,还是需要手工规则兜底。

5.5 性能优化小经验

bbox处理全程是CPU计算,没有深度学习推理,速度很快。一份100页的PDF,解析耗时大概在5~15秒,主要瓶颈是PDF页数和水印检测的Counter统计。如果文档量大,建议并行处理:按页并行提取span,再做全局水印频率统计。频率统计那一步需要全量数据,可以先用多进程把每页的span序列化到内存,再合并计算,实测性能能提升4~6倍。

另外,水印bbox黑名单不需要覆盖全页,只记录"跨页重复"的bbox就行。重复的文字在每页位置几乎一样,黑名单数量很小,过滤时用"重叠面积比"判断即可,性能开销可以忽略。

最后再分享一个小技巧

用bbox做文档解析,把"位置信息"留到切块阶段非常重要。很多RAG项目在解析阶段就把坐标丢掉了,只留文本,这很可惜。我的做法是把每个文本块的bbox存成四个浮点数,和文本一起写入JSON,切块时把"同一版面区域"的文本块聚成一个chunk,metadata里记录页码和bbox。检索返回答案时,前端可以直接在PDF上高亮原始位置,这个功能对知识库产品来说很实用。

还有,多栏检测的gap_threshold这个参数,针对不同的PDF生产工具,最佳值不太一样。Word导出的PDF栏间距通常在80~120pt,LaTeX论文在50~70pt,扫描件OCR出来的bbox间隙更小。写代码时别硬编码,把它作为可配置参数,第一次跑完看打印出来的x0分布图,再微调。我见过有人为了省事把阈值调成10,结果把同一栏的文本块也切开了,排序瞬间乱掉。

做文档解析这件事,工具永远是辅助,"把结构还原文"才是核心。多看几个真实PDF的bbox分布,你就知道那些看似寻常的排版背后有多少细节。

返回列表