ARTICLE DETAIL

资讯详情

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

RAG文档解析实战:bbox解决多栏排版与水印过滤

RAG文档解析实战:bbox解决多栏排版与水印过滤 做 RAG 文档解析做到第三篇终于要写点真正动手的东西了。这一篇的主角是 bbox——也就是边界框bounding box。前两篇聊了标题解析和表格识别评论区里问得最多的两个场景一个是多栏排版怎么拆另一个是 PDF 水印怎么去。这两个问题其实是同一类问题你在做 Rag 文档解析时拿到的往往不是一条干净的文本流而是一整页带坐标信息的版面。解读不了这些坐标双栏论文会被读成串行文本带水印的 PDF 会把水印词条灌进知识库检索阶段就会莫名其妙蹦出“内部资料”“仅供预览”之类的垃圾。这篇就把 bbox 实战讲透直接给出能跑通的多栏重排和水印过滤方案适合正在搭 RAG 知识库、或者被“文档结构化解析”折磨的工程师参考。1. 先搞清楚bbox 在文档解析里到底管什么1.1 bbox 的数据格式与文档解析管线bbox 本质就是一个矩形区域在文档解析场景里它通常表示为四个数字左上角的 x 坐标、左上角的 y 坐标、右下角的 x 坐标、右下角的 y 坐标也就是(x0, y0, x1, y1)。不同的渲染引擎给出的单位可能不一样PyMuPDF 用的单位是点ptOpenCV 处理渲染图时用的是像素px排版分析模型输出的也可能是归一化坐标0 到 1 之间。做文档解析时第一时间就要确认清楚坐标系是谁家的别混着用。在管线里bbox 把视觉信息和语义信息串了起来。版面分析模型比如 PP-Structure、LayoutLMv3会扫描整页图像输出哪些区域是标题、正文、表格、图片、页眉页脚每个区域都带一个 bbox 和类别标签。拿到这些 bbox 之后我们才能回答几个关键问题这段文本属于哪一列这个表格跨了几栏水印覆盖在什么位置文本块应该按什么顺序拼接可以说没有 bbox文档解析就是“读一张图猜内容”有了 bbox才能做到“按版面结构还原阅读顺序”。1.2 多栏排版与 PDF 水印为什么必须靠 bbox先看多栏排版。一篇双栏论文PDF 的文本层其实是一堆带坐标的文本片段。如果你直接用简单的“按 y 坐标从上到下排序”来拼文本会得到这样的结果左栏第一行、右栏第一行、左栏第二行、右栏第二行……拼出来的内容完全是两篇文章互相穿插语义彻底碎了。RAG 拿这种文本去切 chunk、做向量化检索出来的片段牛头不对马嘴问答系统就会答非所问。必须要分析每个文本块的 x 坐标把属于同一栏的块归组然后按栏内顺序读取。再看 PDF 水印。很多电子版 PDF 的“内部资料”“仅供预览”水印本质是页面上一堆重复的文本对象或半透明图片。RAG 解析时如果对文本层全盘接收水印文字会被当成正常内容写进知识库。检索“资料”之类的词向量库里全是水印噪音。更麻烦的是水印如果和正文层层叠叠切分文本时会把水印词句粘到正文句子里。靠 bbox 锁定水印所在区域在文本提取阶段就直接跳过或者在渲染图上做区域擦除才能保证知识库的基本纯净度。1.3 工具选型解析方案的组合思路我实测下来的常用组合是PaddleOCR 的 PP-StructureV2 做版面分析PyMuPDF 读取文本行和坐标OpenCV 做图像级的水印检测和擦除必要时用 YOLO 训练一个专属的水印检测模型。环节工具选项我的选择理由版面分析PP-StructureV2 / LayoutLMv3 / markerPP-StructureV2中文场景稳能输出标题、正文、表格、图片的 bbox 和类别坐标读取PyMuPDF / pdfplumberPyMuPDF兼顾文本层提取和页面渲染速度也快水印检测OpenCV 规则 / 训练 YOLOOpenCV 规则 YOLO 兜底规则法快定制模型更准按场景二选一批量工具PitStop、Adobe Acrobat不用成品去水印软件RAG 管道需要的是“解析时过滤”不是“交付干净 PDF”marker 这类工具确实能一键出结构化结果但它帮你做完了所有判断黑盒模式下你没法根据业务场景调优。做 RAG 的文档解析我始终坚持一件事核心逻辑要握在自己手里所以这期内容全部围绕 bbox 手工实现。2. 多栏排版实战把双栏论文读回“人话”2.1 拿到可靠的文本行 bbox版面分析模型的使用要点版面分析是第一道工序。我用 PP-StructureV2 对页面渲染图做检测输出结果大致长这样[{bbox: [40.5, 120.1, 330.2, 180.7], label: text, confidence: 0.98}, {bbox: [350.8, 120.3, 640.1, 160.2], label: text, confidence: 0.96}]拿到的是左上角点坐标和右下角点坐标如果模型输出的是四角坐标转换成 x0y0x1y1 也很简单。这里有一个细节要注意PP-Structure 的 bbox 是基于渲染图像素的PyMuPDF 的文本坐标是基于 PDF 页面空间的 pt1pt 1/72 英寸两者需要做一次换算。一般情况下页面 A4 的宽度是 595pt如果你用 150 DPI 渲染图像宽度就是 595 / 72 × 150 ≈ 1239px换算系数就是 150 / 72 ≈ 2.08。我习惯于统一到 PDF 页面的 pt 坐标体系里因为后续切文本、拼顺序都用这一套坐标。如果不需要版面模型那么重的检测也可以用 PyMuPDF 直接拿文本行的坐标import fitz doc fitz.open(paper.pdf) page doc[0] blocks page.get_text(dict)[blocks] for b in blocks: if b[type] 0: # 文本块 for line in b[lines]: x0, y0, x1, y1 line[bbox] text .join(span[text] for span in line[spans]) print(x0, y0, x1, y1, text)这种方式快但拿到的只是文本行的 bbox没有“标题”“正文”“表格”的语义分类。做严格的多栏重排我建议先跑一遍版面分析让 text、figure、table 区域先分好类再对各区域内部的文本行做排序。2.2 分栏算法按左边界聚类比按 y 排序靠谱多栏排版的难点在于“归组”而不是“排序”。如果你按 y 坐标直接排双栏文本左右交替顺序必乱。正确思路是先把所有块按 x 方向归到不同的列再在每列内部按 y 坐标从上到下排序。怎么判断一个块属于哪一列我常用的办法是取每个块的左边界x0对这些左边界做聚类。双栏论文里左栏文本块的 x0 通常集中在页面左侧某个值附近比如 50pt右栏文本块的 x0 集中在另一个值附近比如 350pt。对一维的 x0 序列做 KMeans 聚类当页面上明显是双栏时n_clusters2就能分出左右两群。from sklearn.cluster import KMeans import numpy as np x0s np.array([[b[x0]] for b in text_blocks if is_normal_text(b)]) kmeans KMeans(n_clusters2, random_state0, n_init10).fit(x0s) centers sorted(kmeans.cluster_centers_.flatten()) left_label 0 if centers[0] centers[1] else 1 col_groups {0: [], 1: []} for b, label in zip(text_blocks, kmeans.labels_): col_groups[label].append(b) for col in col_groups: col_groups[col].sort(keylambda b: (b[y0], b[x0])) ordered_text [] for col in sorted(col_groups.keys()): for b in col_groups[col]: ordered_text.append(b[text])有一个细节要提醒KMeans 对离群点敏感。如果页面顶部有一个跨整页宽的标题块它的 x0 可能接近 50也可能接近 0会被强行分进某一栏然后和栏内正文混排。所以先要过滤掉“跨栏块”再对剩下的普通文本块做列聚类。我习惯的做法如果块的宽度大于页面宽度的 60%直接判定为跨栏块或标题块不参与分栏。2.3 跨栏元素与标题区的特殊处理跨栏元素是分栏逻辑里最容易翻车的地方。论文里的大表格、宽图、跨栏公式它们的 bbox 横跨左右两栏如果参与列聚类要么被错误归入一栏要么干扰聚类中心。处理策略是“先摘出来再单独处理”。我在代码里这样判断page_width page.rect.width is_wide_block (b[x1] - b[x0]) page_width * 0.6这些宽块在输出顺序上往往出现在它所属那一栏段落的前面或后面。严谨来说你应该根据它在页面上的 y 坐标和上下文判断插到哪个位置但我实测下来放在所有栏块之前如果它在页面顶部区域或者放在当前栏段落后对 RAG 切 chunk 的影响并不大。真正重要的是宽块不能参与列聚类否则分栏就崩了。列内排序还有一个意外场景同一栏里的并排小图。比如左栏里上下排列两张图每张图下面有 figure caption。版面模型会把图区域和 caption 分别检出只要它们都分配到同一列按 y 排序基本不会出错。真正麻烦的是 caption 出现在图的左侧或右侧y 坐标相近排序时容易乱。这种我建议把 caption 的 y0 调整为图的 y1让它强制排在图的后面效果会好很多。2.4 实测效果双栏论文解析对比我拿一篇 IEEE 双栏论文做了个简单对比。原始 PDF 直接按文本层顺序提取出来的段落开头是这样的In recent years, the demand for and deployment of long-reach passive optical networks have been increasing. Fiber-to-the-x architectures have been widely deployed. Because of their tree-and-branch structure, optical networks are subject to various faults.而未经分栏处理、直接按 y 排序的结果是In recent years, the demand for and deployment of long-reach passive optical networks have been increasing. A schematic diagram of the proposed scheme is shown in Fig. 1. Fiber-to-the-x architectures have been widely deployed. The proposed architecture consists of an OLT, an optical line terminal, and several ONUs.你仔细看就能发现第二段的“A schematic diagram...”其实来自右栏第一行完全打断了左栏正文的语义流。经过 bbox 分栏重排之后左右栏各自按顺序输出段落接缝才恢复正常。我后来把这个流程接到 RAG 里用同一批文档做检索测试排在前三的相关性明显比不处理的高出一截。多说一句分栏重排的正确率没法百分之百自动化验证。我的笨办法是抽样把解析后的文本按“段接缝是否语义连贯”人工打标100 页里抽查 10 页如果正确率在 95% 以上就可以上线。别指望一个模型解决所有版式规则 模型结合才是长期可维护的方案。3. 水印 PDF 实战识别、定位到去水印的一条龙3.1 先分清三种水印形态水印不是一种东西处理策略完全不同。我按实际操作中遇到的频率把它分成三类水印类型特征典型来源推荐处理思路文本水印PDF 对象层有独立文本 span颜色浅、字号大、重复出现企业办公系统自动加上的“内部资料”对象层直接过滤/删掉图片水印半透明 PNG 或 JPG 平铺渲染后叠在正文上专业软件批量加水印渲染成图mask 擦除扫描水印像素级灰字和正文粘连无法用对象层区分纸质文件扫描件灰度分析 局部处理效果看运气判断一份 PDF 的水印属于哪一类有个快速的初筛方法直接用 PyMuPDF 解析文本层如果水印文字出现在文本层里就是第一类最好处理如果文本层干净但渲染图上能看到水印就是第二类或第三类。下面分别讲做法。3.2 用 OpenCV 按灰度特征定位水印区域图片水印和扫描水印都得靠图像分析。先渲染页面成图片然后转灰度再分析像素亮度。物理上大多数水印是灰色、半透明的叠加在白色纸面上会形成 180–250 区间的灰度值正文深色文字的灰度往往低于 150。一个非常粗暴但有效的思路是在灰度图上取一个高阈值比如 200把大于阈值的区域变成 mask。但这样会把空白区域也包进来所以还需要进一步用连通域分析把面积过小、非条状的连通域过滤掉。水印文字通常是一排排重复出现的斜体或横排文本连通域特征是“大面积连续横条”或“有规律的对角分布”。具体代码import cv2 import numpy as np img cv2.imread(page.png) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 提取偏白/浅灰的像素 _, mask cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY) # 形态学开运算去掉细碎的正文边缘 kernel np.ones((5, 5), np.uint8) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations2) # 找连通域并按面积过滤 n_labels, labels, stats, _ cv2.connectedComponentsWithStats(mask) watermark_mask np.zeros_like(gray) for i in range(1, n_labels): x, y, w, h, area stats[i] if area page_area * 0.02 and w 100: cv2.rectangle(watermark_mask, (x, y), (x w, y h), 255, -1)水印检测出来之后有两个使用方向一是把你切分正文文本时落在水印区域内的 OCR 结果直接丢弃二是用cv2.inpaint对水印区域做图像修复修干净后再执行 OCR。我自己的经验是先做一次对象层过滤再对残余水印做像素擦除两层配合才不会误伤正文。3.3 用 PDF 对象分析直接在源头过滤水印文本水印最好办直接从 PDF 对象层下手。PyMuPDF 的get_text(dict)里每个 span 都带颜色、字号、字体名、bbox。文本水印有几个硬特征颜色是灰色RGB 三个通道相等或接近字号通常比正文大字体可能是专门的水印字体内容在整页重复出现。我实战的过滤逻辑是def is_watermark_span(span, page_width): color span[color] # color 是 24 位整数拆 RGB r (color 16) 0xFF g (color 8) 0xFF b color 0xFF # 灰色判断RGB 接近且取值中等偏高 if abs(r - g) 10 and abs(g - b) 10 and 180 r 250: return True size span[size] if size page_width * 0.08: # 超大字号 return True return False过滤后把文本重新组装水印词就进不了知识库了。注意这里有个反例有些 PDF 的页眉页脚也是灰色小字直接全过滤会把页码、版权信息也丢掉。我的建议是再加一道“重复性检测”统计同一文本在整份文档里出现的次数如果一页内同一个词出现 N 次且分散在多个 bbox 区域基本就是水印无误。页码这种虽然重复但它的 bbox 位置具有强规律性都在页尾应该单独摘除而不是跟水印混在一起。3.4 水印 bbox 与正文 bbox 重叠时的取舍策略这是水印实战里最难的部分。文本水印通常是单独对象不重叠时直接删没问题。但图片水印和扫描水印是叠在正文上的水印 bbox 和正文 bbox 一定相交。如果你拿到水印 bbox 就粗暴删除相交区域的文本你会把正文内容全部误删——因为水印压着的区域底下就是文字。我的实战取舍策略分三层如果水印在文本对象层是独立存在的优先用对象层过滤完全不影响正文。如果水印在图像层且和文字重叠不删文本而是先用像素处理把水印擦淡。比如检测到浅灰叠加区域用cv2.inpaint只为水印区域做修复修复后重新 OCR。如果水印和正文分辨率都太低、无法分离我建议直接把这一页标记为“低质量页”在切分 chunk 时降低权重或者强制拆得更碎避免水印文字和正文长句粘连。一个很重要的经验在生产环境里宁愿保留少量水印词也不要用大范围 bbox 删除误伤正文。因为水印词最多造成检索噪音而误删正文会造成语义残缺后者对 RAG 的伤害大得多。4. 可直接抄作业的完整流程代码4.1 环境准备我用的环境是 Python 3.10核心依赖就四个pip install pymupdf opencv-python scikit-learn paddleocr paddlepaddlePaddleOCR 安装包比较大如果只需要版面分析也可以单独用layoutparser替代但 PP-Structure 的检测效果在我测的中英文混合场景里更好。第一次运行会下载模型建议提前跑一段测试代码把模型缓存下来。4.2 多栏重排与水印过滤核心代码我把整套流程封装成一个类核心是两件事先对页面做版面分析拿到分栏信息再对文本块做水印过滤。这里是去掉细节后的主干代码import fitz import cv2 import numpy as np from sklearn.cluster import KMeans from paddleocr import PPStructure class BboxDocParser: def __init__(self, pdf_path): self.doc fitz.open(pdf_path) self.engine PPStructure(show_logFalse, langch) # 版面分析 def parse_page(self, page_index): page self.doc[page_index] pix page.get_pixmap(dpi150) img np.frombuffer(pix.samples, dtypenp.uint8).reshape(pix.height, pix.width, pix.n) img cv2.cvtColor(img, cv2.COLOR_RGB2BGR) # 1. 版面分析得到文本/表格/图片区域 res self.engine(img) text_blocks [] for r in res: if r[type] text: # 坐标从像素换算回 pt x0 r[bbox][0] / 2.083 y0 r[bbox][1] / 2.083 x1 r[bbox][2] / 2.083 y1 r[bbox][3] / 2.083 text_blocks.append({ x0: x0, y0: y0, x1: x1, y1: y1, text: r[text] }) # 2. 过滤水印文本块对象层判断 clean_blocks [b for b in text_blocks if not self.is_watermark(b, page)] # 3. 分栏重排 ordered self.reorder_columns(clean_blocks, page) return \n.join(ordered) def is_watermark(self, block, page): # 这里可接 3.3 的 span 级过滤逻辑这里做块级粗筛 return block[text].count(内部资料) 0 def reorder_columns(self, blocks, page): page_width page.rect.width normal [b for b in blocks if (b[x1] - b[x0]) page_width * 0.6] wide [b for b in blocks if (b[x1] - b[x0]) page_width * 0.6] # 宽块不参与聚类先按 y 排好 wide.sort(keylambda b: (b[y0], b[x0])) if len(normal) 2: normal.sort(keylambda b: (b[y0], b[x0])) return [b[text] for b in wide normal] x0s np.array([[b[x0]] for b in normal]) kmeans KMeans(n_clusters2, random_state0, n_init10).fit(x0s) col0 [b for b, lb in zip(normal, kmeans.labels_) if lb 0] col1 [b for b, lb in zip(normal, kmeans.labels_) if lb 1] # 列判断哪一列在左边 if col0 and col1: if min(b[x0] for b in col0) min(b[x0] for b in col1): col0, col1 col1, col0 for col in [col0, col1]: col.sort(keylambda b: (b[y0], b[x0])) # 输出顺序宽块按 y 位置穿插这里简化为先标题宽块、再左右栏 return [b[text] for b in wide col0 col1]需要注意这个reorder_columns里我把宽块全放在前面了如果你处理的论文顶部有标题区这样没问题但如果你要处理正文中夹着的跨栏表格宽块插回段落之间会更合理你可以额外根据宽块的 y 坐标落入哪一段范围再做插入。工程上先保证核心链路跑通再逐步精细化。4.3 几个关键参数的经验值这些参数我调过很多次给你一个可以直接上手的起点参数经验值说明DPI 渲染150–200太低影响版面检测太高影响速度跨栏块宽度阈值页面宽度 × 0.6大于此值视为跨栏/宽块分栏聚类 K 值2 或 3根据杂志/论文常见排版不固定时用 5.3 的间隙法水印灰度阈值180–250太高漏检太低误伤正文背景最小水印连通域面积页面面积 × 0.02过滤小噪点只保留大面积水印条水印字号阈值页面宽度 × 0.08超过这个字号的大字多半是水印或标题每个人的文档场景不一样这组参数不是死的。我的习惯是每换一批新文档都先抽选三页把 bbox 画出来看一遍确认参数是否适用再批量跑。5. 实操中的坑与排查记录5.1 表格被 bbox 切碎如何合并表格区域版面分析模型对表格的识别经常不完整。一张跨栏的大表可能被识别成三四个碎片每个碎片都是独立的 table bbox中间夹杂着 text bbox。如果你把这些碎片当成不同区域分栏重排时就会把表格的行打散。我的解决办法是对同类别的 bbox 做 IOU 合并。如果两个 table 区域的 IoU 大于 0或者它们的 bbox 在垂直方向上有重叠且水平方向有连接就先合并成一个更大的 bbox再按一个整体处理。def merge_bboxes(bboxes): # bboxes: list of (x0, y0, x1, y1) merged [] for bbox in sorted(bboxes, keylambda b: (b[1], b[0])): if not merged: merged.append(bbox) continue last merged[-1] # 如果新 bbox 与最后一个 bbox 有 y 重叠或间隙很小合并 if bbox[0] last[2] and abs(bbox[1] - last[1]) 20: last (min(last[0], bbox[0]), min(last[1], bbox[1]), max(last[2], bbox[2]), max(last[3], bbox[3])) merged[-1] last else: merged.append(bbox) return merged合并之后再做分栏和排序表格区域的行序基本就不乱了。这个合并逻辑我用在 table、figure 这些“非纯文本”区域上对 text 区域不做强合并因为正文块本来就该拆细。5.2 水印误删正文颜色范围太宽导致正文丢失有一次我把水印灰度阈值设成了 150结果正文里所有浅色小字全部被当成了水印一个章节的灰色注释全被过滤掉了知识库少了一大块内容。排查的时候把过滤前后的文本 diff 一眼就看出来了。后来我把阈值收紧到 200 以上并且限定“同一文本在页内重复出现超过 2 次”才算是水印误删率立刻降到接近零。这里有个检查技巧每次跑完解析都随机抽几页把过滤掉的文本打印到一个日志文件里。如果日志里出现了有语义的正文句子说明过滤条件太激进及时调参。别等到向量库建完再发现重建一次可太痛了。5.3 分栏时 K 值不固定用“列间隙投影法”代替固定 KMeans双栏论文用 KMeans 没问题但杂志有单栏、双栏、三栏混排的情况。这时候固定n_clusters2就会把单栏文本硬分成两列产生大量乱序。我后来改成用列间隙去判断把所有文本块的 x0 和 x1 投影到 x 轴上统计每个 x 位置被文本块覆盖的频率然后在频率图上找明显低谷。如果页面只有一栏投影图上是一个连续的宽高原。如果有两栏中间会有一个明显的低谷左右栏之间的空白区域。这个方法的好处是完全不需要预设栏数低谷数量就是栏数减一。代码也不复杂import numpy as np def detect_cols(blocks, page_width, gap_threshold40): hist np.zeros(int(page_width) 1) for b in blocks: x0 int(b[x0]); x1 int(min(b[x1], page_width)) hist[x0:x11] 1 # 找连续为 0 的间隙宽度超过阈值视为分栏间隙 gaps [] in_gap False gap_start 0 for x in range(len(hist)): if hist[x] 0 and not in_gap: in_gap True gap_start x elif hist[x] 0 and in_gap: if x - gap_start gap_threshold: gaps.append((gap_start, x)) in_gap False return len(gaps) 1 # 栏数按列间隙切分后每个文本块根据 x 中心点落进哪个区间决定归属比 KMeans 更稳。特别适合处理“整页只有一栏”的普通文档不会误分。5.4 性能优化先读文本层只在必要时渲染 OCR版面分析模型一次推理大概要两三秒100 页文档就要跑五六分钟这还是没算 OCR 的时间。我的性能优化顺序是先用 PyMuPDF 提取文本层如果能拿到高质量文本就不必渲染页面做全页 OCR。大部分电子版 PDF 的文本层都能提取只有扫描件才需要走 OCR 流程。所以完整的项目流程大概是这样的先快速扫描一遍文档按页统计文本层的字符密度。字符密度高的页面直接走文本层 PyMuPDF bbox 分栏字符密度接近零的页面才渲染图像走 PP-Structure OCR。实测下来混排文档能省掉 70% 以上的渲染和推理时间。水印这块也有优化空间。文本水印检测完全不需要渲染图像直接解析文本对象做颜色和重复性判断就行。只有图片水印才需要渲染成图。所以我会先做对象层水印过滤再判断剩下的页面是否需要图像级处理。说到底bbox 不是什么高深技术它就是把坐标分析做到位。做 RAG 文档解析这几年来我最大的体会是文档解析的难点根本不在模型选得多高级而在对具体版式的理解是否细致。多栏排版要解决的是“阅读顺序”水印 PDF 要解决的是“噪音过滤”两者都是靠 bbox 把版面信息显式化。你拿到一个复杂版式的 PDF别急着上大模型先把 bbox 画出来在页面上铺开看一遍很多思路自然就清晰了。哪怕工具从 PaddleOCR 换成 LayoutLMv3甚至以后有更强的版面模型这套“bbox 分栏 bbox 过滤水印”的骨架都还能继续用。
返回列表