RAG文档解析:用bbox解决PDF多栏排版与水印污染
做RAG项目最怕什么不是模型效果差是喂进知识库里的PDF本身就是脏的。我今年在做企业内部知识库的RAG项目资料里既有大量双栏排版的行业研究报告还有不少带着大字号水印的制度文件。前者按默认顺序直接提取文本句子像被刀砍过一样断成残句检索阶段一命中就是一连串不完整的内容后者水印文字混进正文每页都重复出现“内部资料注意保密”这类字样语义检索的召回结果经常被这些噪声污染。这两类问题表面看毫无关联但实际解法是同一个解析时不能只看文字内容还要充分利用每个元素在页面上的位置信息——也就是bboxbounding box边界框。这篇文章是RAG文档解析系列的第三篇专门记录bbox在两类脏数据场景下的实战用法与踩坑过程。先交代一下适用对象如果你正在做RAG、文档问答、法律合同审查或者任何需要从PDF里抽取结构化文本的事情手里又拿着大量排版不规整、带水印的文档这篇笔记应该能帮你少走半天弯路。内容会从最基础的坐标系讲起然后分别拆解多栏排版的分区逻辑和水印的识别剔除最后给一套可以直接运行的核心代码流程。1. 先把bbox坐标系搞清楚所有后续操作的基石1.1 PDF页面坐标到底是怎么定义的说到bbox就绕不开PDF的坐标系统。PDF标准里坐标原点在页面左下角x轴向右、y轴向上单位是point1 point等于1/72英寸。A4纸的高度大约是842 point宽度约595 point。但在实际处理中我们几乎不会直接面对这个原始坐标系因为主流解析库都帮你做了转换。我用得最多的是PyMuPDF也就是fitz。它的page.get_text(words)返回的元素格式是固定的元组import fitz doc fitz.open(sample.pdf) page doc[0] words page.get_text(words) for w in words[:5]: print(w)每个word是一个tuple按顺序为(x0, y0, x1, y1, word, block_no, line_no, word_no)其中x0、y0是文字包围盒左上角坐标x1、y1是右下角坐标。PyMuPDF这里的坐标系原点在页面左上角y轴向下和PDF原生坐标系正好相反但库内部已经帮你换算好了直接用就行。我也见过不少人用pdfplumber它的逻辑类似用extract_words()拿到每个词及其x0、top、x1、bottom属性。这里必须强调一个容易踩的坑fitz的words元组顺序是从左上到右下而pdfplumber的char对象里x0表示左边界、top表示上边界、x1是右边界、bottom是下边界概念一致但实现细节不同。如果你同时用两个库干活一定要先统一坐标系再做运算别直接拿fitz的y1去跟pdfplumber的top做算术坐标基准错乱的数据会让你绕一整天才排查出来。1.2 为什么需要自己操作bbox而不是直接用现成提取工具可能有人会问PDF解析不是有现成工具吗pdfplumber一行extract_text()就搞定了为什么要手动折腾bbox真实原因是现成工具的输出顺序遵循PDF内部的“内容流”顺序它是逻辑流不是视觉排版顺序。这句话请画个重点。PDF文件内部记录的是画布上依次绘制哪些文字绘制顺序由生成文档时的程序决定。对于单栏、无复杂排版的文档逻辑流顺序恰好和阅读顺序一致所以常规解析器表现正常。可一旦遇到双栏、三栏、侧边栏这些情况逻辑流顺序经常是先写完第一列的内容再回头写第二列甚至有跨block交错的情况。你直接用extract_text()拿到的文本就是左栏前半段接右栏前半段或者一整列接一整列跟人眼阅读路径完全对不上。这种错乱对RAG是致命的。后续不管你是按固定窗口切chunk还是做语义切分喂进embedding的文本块都是破碎的。想让RAG效果变好第一步就得让解析出来的文本顺序贴近人眼阅读顺序这正是bbox发挥作用的地方每个词在页面上都有确定坐标坐标之间的关系能让我们重建出视觉阅读路径。1.3 bbox能解决的三类典型问题结合实战归纳一下bbox在RAG文档解析里最常见的用途有三类恢复阅读顺序基于坐标把同一视觉行的文字聚合起来再按从右到左、从上到下的阅读规则重组。版面区域划分把页面分成标题、正文、页脚、边注、页眉等不同区块按需取舍。噪声过滤比如水印本质上是坐标高度重复且样式异常的元素用坐标统计和视觉特征可以识别并剔除。搞清楚了这些底层逻辑我们进入第一个实战场景多栏排版。2. 多栏PDF的分区逻辑让文本顺序回到人眼一致的阅读路径2.1 多栏为什么难处理乱序长什么样我处理的那批行业报告超过六成是学术论文常见的双栏排版。双栏PDF在内容流里的存储方式五花八门有的先写完整左栏再写右栏有的是按block交替写还有最坑的一种把左右两栏的文字放在同一行里用大量空格分隔提取出来的每一“行”就是左栏半句加右栏半句拼在一起。举个例子。直接跑page.get_text(text)提取双栏论文输出经常是这样深刻理解检索增强生成的核心环节 至关重要。传统的文本解析方 式在面对复杂版面时会失效 而基于语义的切块策略通常 依赖于文本本身的逻辑结构 需要预先处理版面信息表面看像单栏文本但如果对照原页面就会发现内容错乱第一段是左栏开头第二段接着左栏中间但第三段突然跳到了右栏前部——因为内容流是按block顺序倾倒的它根本不知道“栏”是什么概念。最要命的是有些双栏PDF的每一行记录会把左右两栏文字混在一起断句完全混乱这种数据直接喂给RAG检索出来的答案可能连主语宾语都对应不上。2.2 基于bbox的双栏拆分三步法我的处理思路分三步按行聚合、按栏分区、按顺序重组。第一步按行聚合。把y0坐标在一定容差范围内的词视为同一视觉行再在行内按x0从小到大排序。容差通常取3到5个point太小会把同一行拆散太大会把上下两行并成一行。def group_words_into_lines(words, y_tolerance4.0): lines [] current_line [] current_y None sorted_words sorted(words, keylambda w: (w[1], w[0])) for w in sorted_words: if current_y is None: current_y w[1] current_line.append(w) continue if abs(w[1] - current_y) y_tolerance: current_line.append(w) if w[1] current_y: current_y w[1] else: lines.append(current_line) current_line [w] current_y w[1] if current_line: lines.append(current_line) return lines第二步判断页面是单栏还是双栏。最朴素的方法是用页面宽度中线做分界每个词算几何中心中心落在中线左侧进左栏右侧进右栏。page_width page.rect.width mid page_width / 2 left_words [] right_words [] for w in words: center_x (w[0] w[2]) / 2 if center_x mid: left_words.append(w) else: right_words.append(w)这里有个细节用词的几何中心而不是左边界来判断能避免跨栏长词被错分。跨栏词通常出现在页眉、顶部通栏标题或底部页脚它们虽然横跨中线但数量少也不参与正文交替。第三步重建阅读顺序。将左右两栏各自按行y坐标从上到下排序然后交替输出左栏第1行、右栏第1行、左栏第2行、右栏第2行。按行交替在大多数双栏文档上能恢复大部分语义连贯性。我在测试集里对比过“按行交替”和“按整段交替”两种策略前者实现简单、稳定性更好因为段落边界检测本身就可能出错。2.3 更鲁棒的做法用聚类检测真实栏边界单纯用页面中线分界在标准双栏论文上没问题但遇到栏宽不对称、左边距过大、侧边栏混排时就失灵了。我后面换成了一维gap检测把所有文字元素或行的x0收集起来排序后计算相邻x0的差值如果出现一个远超平均gap的大间隔就认为这里存在栏边界。手写这个逻辑也不复杂def detect_column_boundaries(words, page_width, min_gap_ratio0.12): x0_values sorted(w[0] for w in words) gaps [] for i in range(len(x0_values) - 1): gaps.append((x0_values[i 1] - x0_values[i], x0_values[i])) boundaries [] avg_gap sum(g[0] for g in gaps) / max(len(gaps), 1) for gap, x_left in gaps: if gap max(page_width * min_gap_ratio, avg_gap * 3): boundaries.append(x_left gap / 2) return boundariesgap检测法不用假设栏数单栏、双栏、三栏都能应对代码还简单。实际项目里我这样组合先用gap检测判断栏数和栏边界如果检测失败或检测到超过三栏再退回中线方案并输出日志人工抽查这一页。这个兜底逻辑在跑批量解析时帮我避开了不少烂页面。2.4 跨栏元素怎么处理通栏标题和浮动块多栏PDF里最容易被错误分区的是跨栏元素比如双栏论文顶部的论文标题、作者列表。它们宽度接近整页宽度中心落在中线附近用中线法会把标题拦腰截断。处理办法是加一个“通栏判定”如果某行bbox宽度超过页面宽度的60%到70%就认为它是通栏元素单独抽出来放到正文流最前面或最后面不参与左右栏交替。def is_spanning_line(line_bbox, page_width, threshold0.7): x0 min(w[0] for w in line_bbox) x1 max(w[2] for w in line_bbox) return (x1 - x0) / page_width threshold页脚页码这类重复信息也可以用类似思路处理它们每页出现在同一位置直接过滤。到这一步多栏拆分的逻辑就通了接下来是更难缠的水印。3. 水印PDF的识别与剔除从视觉特征入手3.1 水印的常见形态与它们在PDF里的痕迹我先统一一下概念这里说水印指铺在正文底下、不影响阅读但严重影响文本提取的那些内容。常见形态分三类水印类型视觉特征在PDF文本层中的痕迹文字型水印浅灰色半透明大字斜向或平铺每个字都是普通文本对象带颜色信息重复出现在固定坐标图片型水印logo、底纹平铺矢量drawing或嵌入图像文本层里不可见页眉页脚型每页顶部或底部的固定文字文本对象每页同一坐标反复出现水印难处理的根本原因是它和正文一样也是文本对象用常规文本提取根本分不出来。比如“内部资料注意保密”这8个字在几十页里反复出现切chunk时这些token全成了噪声embedding向量会被带偏。3.2 策略一跨页重复加坐标稳定性的组合判断水印最典型的特征是跨页重复且位置几乎一致。正文段落不会在每一页的同一个坐标出现水印会。我的第一个检测策略是聚合统计把全文档所有文本按内容聚合看每个文本出现在多少页以及它的归一化坐标方差有多大。from collections import defaultdict text_position_map defaultdict(list) for page in doc: page_words page.get_text(words) for w in page_words: text w[4] norm_x (w[0] w[2]) / 2 / page.rect.width norm_y (w[1] w[3]) / 2 / page.rect.height text_position_map[text].append((norm_x, norm_y, page.number))然后检查每个文本的出现页数和位置方差。如果同一文本在超过60%的页面出现且坐标方差很小就标记为疑似水印。这个策略对“内部资料”“draft”“confidential”这类固定词组非常有效。但要注意误杀有些正文章节标题也会跨页重复比如制度文件每页都会出现的“第一条”“甲方”之类。所以需要结合下面的颜色特征进一步判断。3.3 策略二颜色灰度与字体大小的联合过滤水印字通常是灰色或者很浅的颜色正文默认是纯黑。PyMuPDF的get_text(dict)能直接拿到每个span的颜色码def extract_text_with_color(page): data page.get_text(dict) spans [] for block in data[blocks]: if lines not in block: continue for line in block[lines]: for span in line[spans]: spans.append({ text: span[text], bbox: span[bbox], color: span[color], size: span[size], }) return spansspan[color]是一个int通常是RGB颜色码。把颜色码拆成RGB分量如果三个通道值接近即灰度且整体偏亮大概率是水印。叠加上字号判断——水印字号往往远大于正文——准确率能到九成以上def is_watermark_span(span, page_height): color span[color] r (color 16) 0xFF g (color 8) 0xFF b color 0xFF # 灰度且偏亮 if max(r, g, b) - min(r, g, b) 30: return False if max(r, g, b) 140: return False # 字号超过页面高度的5% if span[size] page_height * 0.05: return True return False这里的页面高度我用的是page.rect.height。如果字号超过页面高度的5%基本上可以断定是水印级别的大字。当然不同文档水印深浅不一样阈值需要按样本微调。3.4 剔除水印的前后对照与收益我专门做了一组对照实验。某批46页带水印的制度文件不做处理直接进解析和切块抽取出来的chunk里有接近20%的token是水印词。清洗前用同一套embedding和检索逻辑问“保密制度的适用范围”召回的前5个chunk里混进来3个基本只有水印的无效块。清洗后无效块几乎消失答案命中率提升非常明显。这组实验让我确认了一件事水印过滤放在解析阶段做成本最低、收益最大。如果放到检索阶段做后置过滤要么得在chunk里识别水印要么得对每个候选块做重复词统计麻烦且不彻底。至于图片型水印因为它不污染文本层对RAG的影响主要在切块后的图像处理环节普通项目里优先级可以往后放。我把文字型水印的清清除干净就能解决绝大多数实际问题。4. 端到端实战多栏和水印同时存在的PDF怎么处理4.1 流程设计与顺序选择真实业务里的PDF往往同时具备多栏和水印两个问题。我和团队最终沉淀出一套流程稳定跑了几个月输入PDF - 逐页解析 - 提取bbox和视觉属性 - 水印识别与过滤 - 行聚合/栏分区 - 按阅读顺序重组文本 - 输出结构化结果 - 进入切块和embedding写代码时特别注意顺序先过滤水印再做栏分区。如果先分区后过滤水印字会干扰x0聚类把栏边界算歪。这个顺序问题是我实测踩出来的算是第一优先级的经验。4.2 核心代码一个可运行的解析类下面是从我项目里抽出来的简化版能直接跑通双栏加文字水印的基本场景import fitz from collections import defaultdict class PDFLayoutParser: def __init__(self, pdf_path, min_watermark_ratio0.6): self.doc fitz.open(pdf_path) self.page_count len(self.doc) self.min_watermark_ratio min_watermark_ratio def _collect_spans(self, page): spans [] data page.get_text(dict) for block in data[blocks]: if lines not in block: continue for line in block[lines]: for span in line[spans]: if not span[text].strip(): continue spans.append(span) return spans def _detect_watermark_texts(self): freq defaultdict(list) for pno in range(self.page_count): page self.doc[pno] for span in self._collect_spans(page): text span[text].strip() freq[text].append((span[bbox], span[color], pno)) watermarks set() for text, items in freq.items(): if len(items) self.page_count * self.min_watermark_ratio: continue bbox0 items[0][0] pos_variance sum( abs(b[0] - bbox0[0]) abs(b[1] - bbox0[1]) for b, _, _ in items ) / len(items) if pos_variance 10: watermarks.add(text) return watermarks def _filter_watermarks(self, spans, watermark_texts, page_height): res [] for span in spans: text span[text].strip() if text in watermark_texts: continue color span[color] r (color 16) 0xFF g (color 8) 0xFF b color 0xFF if max(r, g, b) - min(r, g, b) 30 and max(r, g, b) 140: continue if span[size] page_height * 0.05: continue res.append(span) return res def parse(self): pages_text [] watermark_texts self._detect_watermark_texts() for pno in range(self.page_count): page self.doc[pno] spans self._collect_spans(page) spans self._filter_watermarks(spans, watermark_texts, page.rect.height) words [ (s[bbox][0], s[bbox][1], s[bbox][2], s[bbox][3], s[text]) for s in spans ] lines self._group_into_lines(words) ordered_lines self._reorder_for_columns(lines, page.rect.width) pages_text.append(\n.join(ordered_lines)) return pages_text def _group_into_lines(self, words, y_tolerance4.0): lines [] current_line [] current_y None for w in sorted(words, keylambda w: (w[1], w[0])): if current_y is None: current_y w[1] current_line.append(w) continue if abs(w[1] - current_y) y_tolerance: current_line.append(w) else: lines.append(current_line) current_line [w] current_y w[1] if current_line: lines.append(current_line) return lines def _reorder_for_columns(self, lines, page_width, span_ratio0.7): normal_lines [] spanning_lines [] for line in lines: x0 min(w[0] for w in line) x1 max(w[2] for w in line) if (x1 - x0) / page_width span_ratio: spanning_lines.append(line) else: normal_lines.append(line) left_lines [] right_lines [] mid page_width / 2 for line in normal_lines: center_x (min(w[0] for w in line) max(w[2] for w in line)) / 2 if center_x mid: left_lines.append(line) else: right_lines.append(line) left_lines.sort(keylambda line: min(w[1] for w in line)) right_lines.sort(keylambda line: min(w[1] for w in line)) spanning_lines.sort(keylambda line: min(w[1] for w in line)) merged [] for l, r in zip(left_lines, right_lines): merged.append( .join(w[4] for w in l)) merged.append( .join(w[4] for w in r)) if len(left_lines) len(right_lines): merged.extend( .join(w[4] for w in line) for line in left_lines[len(right_lines):]) elif len(right_lines) len(left_lines): merged.extend( .join(w[4] for w in line) for line in right_lines[len(left_lines):]) for line in spanning_lines: merged.append( .join(w[4] for w in line)) return merged这份代码在实际项目中跑了很多页双栏、水印单栏、跨栏标题混合页面都覆盖到了。需要注意生产环境还要加日志、错误重试、无文本页面的保护这里就不堆了。4.3 参数调优实测值这里给一份我实测调出来的参数表供参考参数推荐初始值作用备注y_tolerance4.0 pt行聚合容差字号大时可调到6行距紧时调到2通栏阈值页面宽度的70%识别跨栏元素三栏PDF可降到50%水印页码覆盖率60%出现页数占比短文档少于5页建议提高到80%水印位置方差10坐标波动容忍范围OCR扫描件会更大需放宽到20-30水印颜色亮度下限140RGB最大通道下限深色底纹水印要调低参数不是死的。每批新文档进入流程我都会先抽样5页跑一遍肉眼看过输出结果再批量执行。批量解析最忌讳不校验直接跑完等入库后发现问题再返工成本高得多。4.4 切块策略的配合建议bbox层面把阅读顺序修复之后下游切块策略可以更灵活。比如按“栏内自然段落边界”切块而不是机械按字符数切。做法是在重组后的文本流里利用原始block编号或段落间距信息找到自然段边界在边界处切分让每个chunk语义完整。其实对于多栏文档来说栏本身就是一个很自然的分块单元左栏一段完整论述拆成两个chunk总比把左右栏混在一起拆好得多。这套优化和bbox一样本质都是让文本结构尽可能贴近人阅读时的自然结构embedding质量才能跟着上去。5. 实测踩坑记录坐标陷阱、扫描件倾斜和水印误杀5.1 PyMuPDF与pdfplumber混用的坐标陷阱早期项目里我同时用PyMuPDF读文字、用pdfplumber做表格提取两边坐标一对发现对不上。排查折腾了很久最后发现虽然两个库都声称左上角原点但它们在处理PDF的MediaBox和CropBox时取舍不同同一页面解析出的坐标有细小出入。解决方式很简单项目中只选一种库作为坐标基准另一个库的结果只做参考或者统一换算成归一化坐标再比较。这个坑在要提取表格的RAG项目里特别容易遇到提前记住能省半天排查时间。5.2 扫描件与倾斜页面的坐标噪声扫描版PDF经OCR以后每个词的bbox本身就带噪声行聚合的y_tolerance必须放宽。更麻烦的是页面轻微倾斜时同一行文字的y0从左到右会逐渐偏移。容差设太小一行被切成两半设太大两行并成一行。我的经验是先对页面做deskew倾斜校正再提取坐标或者干脆把y_tolerance提到6到8pt配合行的x0单调性做校正。OCR文本质量差的文档多花点时间做预处理不要在bbox阶段硬扛。5.3 防止水印过滤误杀正文高频词前面说过只靠跨页重复会误伤“第一章”“甲方”这类正文高频词。我的最终方案是三重判断跨页重复数量、颜色灰度值、位置方差。正文高频词虽然重复出现但位置会跟随版面变化不会每页都在同一坐标且正文通常是纯黑色颜色通道不会同时偏亮。三重判断叠加之后误杀率在测试集里低于2%。另外还有一个经验不要用文本长度过滤水印。水印词有短有长“内部资料注意保密”这种长短语很常见长度过滤不仅没意义还可能把很多正文小字段误杀不如前面几个视觉特征可靠。5.4 后续优化方向bbox这套逻辑目前帮我处理了大量多栏报告和带水印文件但它不是终点。真正复杂的版面还有公式、表格、栏中栏、图文混排这些光靠一维坐标排序解决不了。我的下一步计划是把渲染图像和bbox文本层结合起来用轻量视觉模型先定位版面区域再用bbox做精细切分。不过说实话对大多数RAG项目来说把PDF的坐标信息吃透用规则方法已经能解决90%的版面问题不必急着上大模型。关于这一篇就分享到这里。系列前两篇讲了基础提取流程和常见文档类型的适配后面有空我会把表格识别和公式处理这两块的实战记录补齐。如果你手里也有什么奇葩PDF排版欢迎交流我也想看看这世界上还有多少种折磨解析器的花样。