恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

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

  • 首页
  • 资讯中心
  • /
  • RAG文档解析实战:利用bbox还原多栏排版与过滤水印

相关资讯

LLMs之MCP:awesome-mcp-servers 精选服务器清单与 TaoToken 统一接入实战攻略 2026/10/4 16:34:29
Arduino新手七日避坑实录:从驱动失败到串口乱码的物理层可信重建 2026/10/4 16:34:29
JSP+Servlet+MySQL客户管理系统拆解:从环境搭建到部署避坑 2026/10/4 16:34:28

最新资讯

Supacode构建实战: 从Zig源码编译GhosttyKit终端引擎的完整流程
企业上云迁移方案设计:三张表、两个校验点与灰度切流实操
ProtoBuf快速上手指南:核心原理、编码实践与工程避坑
Google Cloud报告:AI智能体五大趋势,助你抢占2026技术先机|TaoToken统一Key实战解读
LayaAir中利用CommandBuffer实现动态描边的实践与踩坑
MR25H40CDF与MKV44F128VLH16工业级数据存储组合方案

今日推荐

MR25H40CDF + PIC18F65K40:工业记录仪高可靠存储实战
基于STM32的数控恒压恒流电源设计:从硬件到PID调参全解析
LT9211 MIPI重定时器原理与双路扇出实战指南

本周热门

MR25H40CDF + PIC18F65K40:工业记录仪高可靠存储实战
基于STM32的数控恒压恒流电源设计:从硬件到PID调参全解析
LT9211 MIPI重定时器原理与双路扇出实战指南

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

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

发布时间:2026/10/4 16:34:29
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(fx0{b[0]:.1f} y0{b[1]:.1f} x1{b[2]:.1f} | {b[4][:30]})输出大概长这样x060.0 y0100.0 x1280.0 | Introduction x060.0 y0120.0 x1280.0 | The problem of ... x0310.0 y0100.0 x1550.0 | Related Work x0310.0 y0130.0 x1550.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_threshold50): 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之后给每个文本块打上列号然后排序。排序键我建议用元组列号y0x0。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(keylambda t: (t[0], t[1], t[2])) return [item[3] for item in labeled]排序之后拼接文本。左栏从上到下读完再接右栏从上到下。这样输出的文本才符合人类阅读顺序。我踩过的一个坑有些块会跨在两栏之间比如标题、表格。如果给这类块打了错误的列号它会被塞进某栏的中间位置打断上下文。处理方法是跨栏块单独提取先存起来等所有栏内文本拼接完再按跨栏块的y0插入到对应位置。简单说标题在y100处就插在左栏y100的文本之后、左栏y100的文本之前。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编码成intdir文字基线方向向量用来判断是否旋转水印文字在PDF里普遍有这样的特征字号偏大往往大于正文字号的1.5倍甚至3倍。颜色浅常见的是深灰偏浅或者与背景白底相近的灰色比如color值转成RGB后每个通道都在200以上。沿对角线排布dir向量的x和y都不为0而不是水平(1, 0)或垂直(0, 1)。内容重复。比如CONFIDENTIAL一页出现十来次或者内部资料重复铺满。位置跨页重复。同一相对位置、同一文本在多页反复出现。有了这些特征过滤方案就非常清晰。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 整体架构把多栏处理和水印过滤合并起来一个可落地的解析管线长这样加载PDF逐页读取。提取页面上所有文本块和spans记录bbox。按跨页重复位置识别水印生成水印bbox黑名单。用黑名单过滤文本块。对剩余文本块做栏检测x0聚类。按列号y0x0排序拼接为按阅读顺序排列的文本。输出结构化文本连同每个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(keylambda 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, keylambda 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拿到的坐标单位是ptpoint1/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返回空走OCRPaddleOCR输出自带bbox扫描件带图像水印水印烙在图片里先水印区域检测再OCR避免污染混合型有文字层扫描页部分页有文字层逐页判断文字层是否可用不可用才OCROCR场景提一句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切块时把同一版面区域的文本块聚成一个chunkmetadata里记录页码和bbox。检索返回答案时前端可以直接在PDF上高亮原始位置这个功能对知识库产品来说很实用。还有多栏检测的gap_threshold这个参数针对不同的PDF生产工具最佳值不太一样。Word导出的PDF栏间距通常在80~120ptLaTeX论文在50~70pt扫描件OCR出来的bbox间隙更小。写代码时别硬编码把它作为可配置参数第一次跑完看打印出来的x0分布图再微调。我见过有人为了省事把阈值调成10结果把同一栏的文本块也切开了排序瞬间乱掉。做文档解析这件事工具永远是辅助把结构还原文才是核心。多看几个真实PDF的bbox分布你就知道那些看似寻常的排版背后有多少细节。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号