资讯动态

RAG翻车元凶在PDF解析:bbox一招解决多栏与水印难题

发布时间:2026/10/5 5:39:25 来源:尧图企业网站定制
做 RAG 有一段时间后你会发现真正拦住你的往往不是用什么模型、怎么调 prompt而是最不起眼的文档解析。上周我处理一批多栏排版的 PDF 时文本抽取结果直接把“引言”里的一句话接到了“相关工作”的引用上向量化之后怎么改 chunk 都没用另一批带水印的合同 PDF检索出来的片段里频繁混着“仅供内部使用”和公司名称回答质量肉眼可见地崩。这两类问题对应 RAG 文档解析里最常见的两个场景多栏排版与水印 PDF而解决它们的共同工具就是 bbox。如果你刚好在搭知识库、做检索增强生成或者已经在用本地模型跑 RAG 但总被 PDF 解析效果折磨这篇实战笔记应该能帮上忙。我不会讲太多模型层的东西重点放在解析层怎么用 bboxbounding box边界框把多栏文本按真实阅读顺序还原怎么识别并清掉水印文本对向量化的干扰以及这中间一堆容易踩坑的细节。1. 这次要解决的问题两个让 RAG 翻车的经典场景1.1 多栏排版为什么是文本抽取的“秩序杀手”绝大多数 PDF 解析库在抽取文本时默认按页面上对象的物理位置从上到下、从左到右排列。这个逻辑在单栏文档里基本正确可一旦遇到双栏甚至三栏论文、报纸、杂志排版问题就来了。我拿一个典型的 IEEE 双栏论文页举例。左栏第一行是“This paper proposes a method for...”右栏第一行是“transfer learning has shown great performance in...”。如果按默认顺序读取出来的文本是“This paper proposes a method for... transfer learning has shown great performance in...”两个完全不相干的句子被硬生生拼在一起。再往下读左栏第二行和右栏第二行又交叉整页文本变成一条左右摇摆的乱序字符串。对 RAG 来说这几乎是灾难级的。文本切块chunking是基于顺序切开的乱序文本切出来的块语义是断裂的向量化之后进检索库用户问“某某方法的应用场景”召回的可能是一段夹着右栏无关内容的片段生成阶段更麻烦模型拿到的上下文本来就乱回答自然也跟着乱。更隐蔽的是这种问题不会在单独的某一行暴雷而是让一批 chunk 的平均质量整体下降肉眼审查很难全部发现。1.2 水印是 RAG 的“隐形噪音”水印的问题则更“日常”。企业内部文档、合同、标书很多 PDF 都会盖一个“仅供内部使用”“机密”“副本”之类的文字水印有的还是多行多列铺满整页。这种水印在阅读时人眼会自动忽略但解析引擎不这么干它会把水印文本当成普通文本抽出来混进正文。后果分两层。第一层是向量化污染水印文字会作为独立文本块进入 embedding 模型生成的高维向量夹杂着大量重复、低信息量的“机密”“内部”等词这些向量会和正文向量混在一起干扰相似度计算。第二层是生成污染假设用户问“这份合同的有效期是多久”检索到的 chunk 里如果刚好被水印文本穿插模型生成的回答可能莫名其妙带上“仅供内部使用”这类词汇看起来非常业余。还有一个很少有人提的点水印会让文本长度膨胀。一份 20 页的 PDF如果每页都有 6 行水印抽出来的纯文本里会有 120 行无效内容占字符比例可能达到 10% 以上。chunk 数量变多、向量库变大、检索噪声变高成本却一点没省。1.3 bbox 才是这局游戏的“支点”解决上面两个问题核心不是换个更聪明的模型而是用 bbox 把文本的空间位置信息充分利用起来。bbox 是一组坐标用来表示页面元素占据的矩形区域。在 PDF 解析语境里一个文本行、一个字符、一张图片都有自己的 bbox。pdfplumber 里的 line 对象返回的典型结构是x0左边界坐标top上边界坐标也叫 y0x1右边界坐标bottom下边界坐标也叫 y1这组坐标放在页面坐标系里就相当于给页面上的每个文本块都贴了一个定位标签。多栏排版问题的本质是解析引擎只用了“上下”的顺序没用“左右”的聚类信息水印问题的本质是可以利用 bbox 的位置规律把水印从正文里区分出来。你只要想明白这一层后面所有逻辑都会顺起来。左边一栏文本的 x0 和右栏 x0 天然不同按 x0 聚类就能分栏水印文本的 bbox 在整页范围内重复、规律分布按位置和频次就能识别。bbox 不是数据结构它是你理解 PDF 版式的钥匙。2. 工具选型与坐标基本功2.1 pdfplumber文本层的“手术刀”这一系列实战我主要用 pdfplumber 处理文本层。选它的原因很简单它对字符级信息的暴露足够细而且字段命名直观。pdfplumber 基于 PDFMiner 开发针对每个字符char都能拿到 text、x0、x1、top、bottom、width、height、fontname、size、stroking_color、non_stroking_color 等属性。这些属性在做水印识别时特别有用因为水印文本的颜色、字号、字体往往和正文有明显差异。你可能也会问PyMuPDFfitz不是更快吗没错PyMuPDF 在速度和渲染能力上确实更占优势尤其是后面我要讲到的“把 PDF 页面渲染成图片做像素级分析”时fitz 几乎是唯一选项。我的用法是两者配合pdfplumber 负责精细的文本层分析fitz 负责渲染和视觉定位。这不是二选一而是组合拳。安装依赖就四行命令pip install pdfplumber pymupdf pillow注意版本pdfplumber 0.11 之后接口比较稳定pymupdf 建议用最新版老版本在部分 PDF 的坐标变换上有坑。2.2 先把坐标系踩明白PDF 的坐标系和常见图像坐标系有一个很关键的差异PDF 的单位是磅pt不是像素1 英寸等于 72 磅。一个 A4 页面大约是 595 x 842 磅所以你在 bbox 里看到的坐标值基本落在 0 到 900 之间而不是几百上千的像素值。如果你做坐标映射时直接拿 PDF 坐标当像素坐标后面渲染定位必然对不上。坐标系原点在页面左上角x 轴向右增大y 轴向下增大。pdfplumber 中 top 就是 y0bottom 就是 y1所以 bottom 的数值一定比 top 大千万不要像数学坐标系那样以为 y 轴向上。我在项目里见过不少同事把 top 和 bottom 弄反最后水印区域裁剪完全错位排查了一下午才发现是坐标方向的问题。还有页面旋转。部分扫描 PDF 或者用某些工具生成的 PDFpage.rotation 是非 0 值90、180、270。pdfplumber 在解析时通常已经应用了旋转但如果你想用 fitz 渲染同一页来做比对最好显式检查两边的 page.rect 是否一致。如果不一致所有 bbox 映射都会错位。2.3 准备一份能复现的实验样张动手之前先准备一份适合复现的 PDF 样张。我的建议是找一份真实的双栏论文加上一个多行多列文字水印这样两个问题能同时验证。如果没有现成文档可以用 WPS 或 Word 快速生成一个双栏文档然后在“页眉页脚/水印”里添加文字水印比如“仅供内部测试”。注意水印要设置成多行多列铺满整页那种不要只放一个居中的不然 bbox 规律不够明显复现效果打折。样张准备好之后先用 pdfplumber 打开打印第一页的信息import pdfplumber with pdfplumber.open(sample.pdf) as pdf: page pdf.pages[0] print(page.width, page.height) print(page.bbox) lines page.extract_text_lines() for line in lines[:10]: print(line[x0], line[top], line[x1], line[bottom], |, line[text][:30])这一步不是为了出结果而是为了让你亲眼看到解析库返回给我们的原始顺序就是错乱的水印文本也会混在里面。有了这个“现场证据”后面每一步处理你都知道是在解决什么。3. 多栏排版实战按 bbox 聚类还原阅读顺序3.1 先看清从 pdfplumber 拿到的原始顺序打开样例 PDF跑完上面那段代码后你会看到类似这样的输出68.0 102.0 250.0 120.0 | This paper proposes... 360.0 102.0 540.0 120.0 | transfer learning has... 68.0 125.0 245.0 140.0 | We evaluate our method... 360.0 125.0 530.0 140.0 | on three benchmark...注意看 x0第一行是 68第二行直接跳到 360第三行又回到 68。这说明 pdfplumber 的默认顺序是按“先来后到”的行对象存储顺序输出并不自动按视觉阅读顺序排序。真正阅读时人眼会先读完左栏整列再读右栏但机器给的是逐行交叉排列。再往下看如果页面顶部有标题、作者、摘要这种横跨双栏的行它们的 x0 通常很小x1 会延伸到页面右侧bbox 宽度明显大于单栏文本行。这类行就是“跨栏块”在处理排序时要特殊对待不能简单划进任何一栏。3.2 用加权列坐标给文本行分栏分栏的关键是给每一行算一个“列坐标”然后用聚类算法分成左右两组。直接取行首字符的 x0 不太稳因为有些行首有缩进缩进量可能让左栏某几行的 x0 和右栏某几行的 x0 接近。更稳的做法是按字符宽度做加权平均公式很简单column (sum(x0_i * width_i for 每个字符)) / sum(width_i for 每个字符)这个加权列坐标可以理解为“这一行在水平方向上的重心”。一个文本行如果从 x70 到 x245重心大概落在 150 附近如果从 x360 到 x530重心大概在 440。左右栏的重心天然分开聚类就很干净。我用一个简化版的一维 KMeans 来演示避免引入额外依赖。如果你愿意装 sklearn直接 KMeans(n_clusters2) 效果是一样的只是要记住输入是 2D 数组。def kmeans_1d(coords, k2, max_iter100): sorted_coords sorted(coords) centers [sorted_coords[i * len(sorted_coords) // k] for i in range(k)] for _ in range(max_iter): clusters [[] for _ in range(k)] for c in coords: idx min(range(k), keylambda i: abs(c - centers[i])) clusters[idx].append(c) new_centers [sum(cl) / len(cl) for cl in clusters] if all(abs(a - b) 0.5 for a, b in zip(centers, new_centers)): break centers new_centers return centers, clusters对整页所有非跨栏行算柱坐标跑这个函数你就能得到左右栏的聚类中心。之后给每一行打上“栏标签”左栏为 0右栏为 1。补充一个判断跨栏行的经验阈值如果一行 bbox 的宽度超过页面宽度的 60%基本可以认定是跨栏块。这个 60% 是我在多篇论文上试出来的页面宽度接近 A4 的常规双栏文档都适用但遇到三栏或非对称版式时要做微调。3.3 每栏内部再按 y 排序还原阅读流分好栏之后就是组装阅读顺序了这一步逻辑很直白取出所有跨栏块标题、作者、摘要、页眉页脚保留它们在页面上的原始相对顺序。左栏所有文本行按 top 从小到大排序。右栏所有文本行按 top 从小到大排序。按“先跨栏块再左栏再右栏”的顺序拼接。代码长这样import pdfplumber from statistics import median def restore_reading_order(pdf_path, page_index0, full_width_ratio0.6): with pdfplumber.open(pdf_path) as pdf: page pdf.pages[page_index] lines page.extract_text_lines() full_width (page.width * full_width_ratio) spans [] # 跨栏块 normal_lines [] # 普通行 for line in lines: text line[text].strip() if not text: continue if line[x1] - line[x0] full_width: spans.append((line[top], text)) else: normal_lines.append(line) # 计算加权列坐标 def col_coord(line): chars line[chars] total_w sum(c.get(width, 0) for c in chars if c.get(width)) if total_w 0: return (line[x0] line[x1]) / 2 return sum(c[x0] * c.get(width, 0) for c in chars if c.get(width)) / total_w coords [col_coord(line) for line in normal_lines] threshold median(coords) left [line for line, c in zip(normal_lines, coords) if c threshold] right [line for line, c in zip(normal_lines, coords) if c threshold] left.sort(keylambda line: line[top]) right.sort(keylambda line: line[top]) spans.sort(keylambda item: item[0]) result_lines [] for top, text in spans: result_lines.append(text) result_lines.extend(line[text] for line in left) result_lines.extend(line[text] for line in right) return \n.join(result_lines)如果你遇到三栏甚至更多栏可以把“阈值分割”替换成 KMeans把 k 设成实际栏数。核心思路不变还是先加权列坐标再按 y 轴排序。这里有个容易忽略的细节跨栏块内部的顺序也要按 top 排序尤其一页里有多个标题级别的跨栏行时比如“2. Related Work”出现在页面上方“3. Methodology”出现在页面下方如果不排序两个标题的相对位置会乱。3.4 验证数据并优化切块策略还原阅读顺序之后不要急着接 RAG先做一轮人工抽查。我习惯的做法是随机抽 5 页把还原后的文本和原 PDF 对照阅读重点检查三个位置页面首行、分栏交界处、跨栏块之后。如果发现某一行仍然错位先打印这一行的 x0、top、weighted_column看是被分到了错误的栏还是栏内排序不对。多数情况下问题出在跨栏块的判定阈值上调一下 full_width_ratio 就能解决。阅读顺序正确之后切块策略也要跟着调。我的经验是在栏与栏的交界处强制断开不要让一个 chunk 横跨左右两栏跨栏块标题、摘要单独成块。你可以把分栏坐标传到后面切块的逻辑里凡是一段文本内部包含“右栏起点 x0”突变的位置就自动切成新块。这样切出来的 chunk 语义更内聚向量检索的命中率会有非常明显的提升。4. 水印 PDF 实战识别与清洗4.1 水印的“指纹”位置 内容 颜色水印清洗比多栏复杂因为水印可能是文本对象也可能是图片还可能半透明地压在正文下面。我先说文本水印的情况这也是最常见的一种。文本水印有三个很明显的“指纹”第一内容高频重复。同一个文本字符串在单页内出现多次或跨页反复出现。比如“仅供内部使用”在一页里出现 6 次这基本就是水印。第二坐标规律分布。多行多列水印的 bbox 在 y 方向上往往等差排列在 x 方向上也是均匀铺开。第三样式和正文有差异。水印通常字号较大或颜色较淡字体也可能不同。光是“内容重复”这个特征已经能筛掉绝大多数误判。我用一个非常轻量的统计方法from collections import Counter def find_repeated_text(page, min_len4, min_count2): lines page.extract_text_lines() texts [line[text].strip() for line in lines if line[text].strip()] counter Counter(texts) repeated {t for t, n in counter.items() if n min_count and len(t) min_len} candidates [ (line[x0], line[top], line[x1], line[bottom], line[text].strip()) for line in lines if line[text].strip() in repeated ] return candidates注意这里要排除页眉页脚。页眉页脚也符合“跨页重复”但它们的坐标通常在页面顶部或底部固定位置。一个简单规则是如果某文本在同一页的不同 y 坐标上出现多次优先判为水印如果只在页面顶部或底部固定位置出现要单独标记为页眉页脚而不是水印。颜色信息也可以叠加进来。pdfplumber 的 char 对象里有 non_stroking_color 属性水印如果是灰色这个字段会返回类似 (0.5, 0.5, 0.5) 的灰度值而正文通常是纯黑或其他明确色彩。把颜色特征和位置特征放一起判断能显著减少误删。4.2 用渲染图层辅助定位水印区域文本水印还算是好处理的最麻烦的是图片水印或者半透明叠加的水印。这种水印在 pdfplumber 的文本层里根本不存在它是一张覆盖在页面上的图片或矢量对象只有渲染成图像才能看到。这种时候我会用 PyMuPDF 把页面渲染成高分辨率图片然后用 PIL 做像素级分析。import fitz from PIL import Image def render_page(pdf_path, page_index, zoom2): doc fitz.open(pdf_path) page doc[page_index] mat fitz.Matrix(zoom, zoom) pix page.get_pixmap(matrixmat, alphaFalse) pix.save(fpage_{page_index}.png) return Image.open(fpage_{page_index}.png)渲染出来之后你可以肉眼观察水印区域在页面上的大致位置也可以写一个简单的像素统计脚本把整页图片按 Y 方向投影看看哪些带状区域亮度均匀偏低或偏高再结合文本层的 bbox 数据把水印影响区域映射成一组 PDF 坐标系下的矩形。关键点在于坐标换算。假设渲染图片宽度是 1190 像素PDF 页面宽度是 595 磅缩放因子就是 2。图片中一个水印文字中心点的像素坐标 (px, py)对应 PDF 坐标就是 (px / 2, py / 2)。因为两边都是左上角原点x、y 方向一致不需要翻转这点和调色板坐标系不一样别弄反。有了水印区域的 PDF 坐标后你可以画一个简单的可视化把水印 bbox 用矩形框标注出来叠加在渲染图上肉眼检查这些框是否正好框住水印文字。这一步值得做因为后面所有清洗逻辑都依赖这些区域是否准确。4.3 三种清洗策略过滤、遮挡、重建拿到水印区域之后有三种策略可以选实际项目里我用得最多的是第一种。第一种在文本抽取层过滤。对文本水印直接在解析时把落进水印 bbox 的字符剔除。可以用 pdfplumber 的 crop 和 filter 组合def clean_page_text(page, watermark_boxes): lines page.extract_text_lines() clean_lines [] for line in lines: in_watermark any( not (line[x1] box[0] or line[x0] box[2] or line[bottom] box[1] or line[top] box[3]) for box in watermark_boxes ) if not in_watermark: clean_lines.append(line[text]) return \n.join(clean_lines)这个策略的好处是原 PDF 不被改动只是抽取出来的正文文本变干净了对 RAG 链路完全透明。缺点是对“正文和水印重叠”的情况无能为力如果水印正好盖在正文文字上方bbox 重叠区域会把正文也一起过滤掉。第二种渲染后遮挡再做 OCR。当水印覆盖正文时我会把页面渲染成图在水印区域用周围像素修复inpaint或用白色矩形遮挡然后交给 OCR 识别正文。这种方式能避免误删正文但成本高、速度慢而且 OCR 会引入新的识别误差一般只在扫描版 PDF 里用。第三种重建 PDF。用解析出的文本重排一个干净 PDF或者直接把原始 PDF 里的水印对象删除后另存。这个方案最“完美”但风险也最大原 PDF 里的字体、图片、表格结构可能被破坏重建后排版错乱。做 RAG 的话我基本不建议走这条路因为向量化只需要语义正确的文本不需要排版完全复刻。4.4 和多栏问题联动的完整抽取管线水印清洗和多栏还原往往要同时处理我建议在同一个解析函数里做减少中间文件的读写。管线顺序如下先打开页面提取所有文本行再识别水印 bbox接着把水印区域内的行标记为“跳过”然后对剩余行做加权列坐标聚类还原阅读顺序最后输出干净的正文。我之前在一个内部合同项目里跑过一套完整的联动管线有一次连续处理了几百份带水印的 PDF效果稳定在 95% 以上的水印文本被正确剔除误删正文的情况在抽样检查里没有出现。这里有个补充条件管线里我还会对每页的最后输出做一次 round-trip 验证——把过滤前后的文本做 diff凡是变化超过预期比例的页面单独标记出来人工审。这个方法也不复杂但非常实用。5. 常见问题与排查技巧实录5.1 速查表症状、原因与解决方案我把自己在实战里反复遇到的坑整理成表方便你直接对照排查。症状可能原因排查思路推荐处理抽取文本左右栏内容串行未按栏聚类直接用行存储顺序组装打印每行 x0/top观察左右交替用加权列坐标做分支每栏内按 top 排序水印文本仍然进入向量库只做了文本过滤未考虑水印与正文重叠渲染页面检查水印区域是否覆盖正文用水印 bbox 做掩码不在文本层硬过滤bbox 坐标与渲染图对不上页面旋转不一致核对 page.rotation 和渲染分辨率统一坐标系显式处理旋转角度聚类时跨栏块被扔进某一栏阈值设得太小跨栏行未独立看跨栏块宽度占页面比例将宽度超过页面 60% 的行剥离单独排序页脚页码被当成水印删除重复文本判断未排除页眉页脚检查文本出现的 y 坐标是否固定按固定位置区域单独标记归为页眉页脚旋转水印完全检测不到倾斜文本的 bbox 不遵循普通行规律渲染页面肉眼观察用图像区域匹配而非文本行统计半透明水印颜色接近正文颜色特征不明显文本层不可见比较同一页不同区域的像素差异用像素级投影标记均匀差异区域5.2 我踩过的坑和私有避坑清单先提醒一个最容易被低估的问题pdfplumber 默认输出的行顺序并不代表阅读顺序。不要依赖 extract_text_lines 的顺序来做文本组装尤其是在处理双栏或三栏文档时。前面多栏的例子已经很能说明问题。另一个坑是非嵌入字体。有些 PDF 生成工具为了压缩体积不嵌入字体只保留字体名称和字符码。pdfplumber 解析时能拿到文本内容但一旦做渲染对比系统会用本地字体替换渲染结果显示的字体样式和 PDF 原样差别很大。如果你发现渲染出来的页面和文本层坐标对不上先检查字体嵌入状态。还有水印文字被拆成多个对象的情况。很多 PDF 生成器不会把一整句水印保存成一个连续字符串而是逐字或逐词生成独立对象导致你统计重复文本时根本匹配不上。对付这种情况得把字符按 bbox 位置先归组再做句子级文本提取否则水印的“指纹”就是碎的。最后提一句颜色判断的复杂度。PDF 里的颜色可能是 RGB 三元组、灰度值、CMYK甚至可以是透明色。你拿到的 non_stroking_color 可能是 (0.2, 0.2, 0.2)也可能是 [0.2]还有可能是 None默认黑。写判断逻辑时要兼容这些类型。5.3 用 bbox 还能顺手解决哪些解析问题bbox 的用途远远不止分栏和水印。实际做解析管线时我发现很多问题都能靠位置信息解决。比如图文关联。如果知识库需要同时存文本和图片图片周围的 bbox 能帮你把 caption 和引用了这张图的正文段落关联起来。常见的规律是图片上方紧邻的文本大概率是图题图题后面的段落可能是对图片内容的解释。按 bbox 距离聚类能自动建立“正文-图片”的引用关系这对多模态 RAG 非常有用。再比如表格的识别。PDF 里的表格线在解析层返回的是线对象而单元格里的文本是 char 对象。把线和文本的 bbox 放在同一个坐标系里你可以推断每个文本落在哪个单元格范围内这样比纯文本规则提取表格要稳健得多。还有页眉页脚的去除。前面提到页眉页脚会在每页重复根据 bbox 在页面上的固定位置可以把它们和普通正文分割开。不要等到向量化之后再去降噪解析阶段就该处理掉。6. 把 bbox 玩成 RAG 基础设施多说几句和 RAG 的整体关系。很多项目一开始会在模型层花大量时间调 prompt、调 embedding但文档解析的质量反而是检索效果的上限。如果喂进来的文本是乱的、带噪音的后面再好的检索排序也补不回来。我在实际项目里的习惯是把 bbox 相关的解析逻辑沉淀成一个小工具库输入是 PDF 路径输出是结构化的页面对象每个对象都带原始 bbox、栏标签、是否属于水印区域等信息。这样不管是后续做切块、向量化还是接本地知识库比如用 Ollama 搭的离线检索环境都能基于干净的文本继续处理。这里的重点是“结构化”三个字。不要只输出纯文本而是把每个文本块的坐标和语义标签都保留下来。这样当你发现某个坑位效果不对时你能随时回到坐标层面做调试而不是面对一堆已经拼接成字符串、无法回溯的文本。另外bbox 数据本身也可以进入向量库。比如把图片的 bbox 和文本的 bbox 关联后存储的是图文位置关系检索时根据用户问题的语义找到相关文本块再通过坐标关联拉出对应的图片或表格。这比单纯把图片塞进向量库要高效得多因为图片的向量维度高、存储成本大没必要给每张图都建索引只对用户真正关心的图建索引就够了。如果你正在搭知识库我建议把解析层当成一个独立的子项目来对待不要和模型层混在一起改。先跑通文本抽取、bbox 聚类、水印清洗再接入 embedding 和检索。顺序对了后面能少走很多弯路。讲讲我自己的取舍吧。最初我也迷信大模型能容忍乱序文本直到一批多栏 PDF 进来之后检索效果直接掉了一半。后来把 bbox 聚类和水印清洗写进解析流程用本地 Ollama 跑轻量 embedding 做验证召回明显改善。现在这套思路我沿用至今先统计、再定位、最后才决定是过滤还是重建。不是每份 PDF 都需要上 OCR 上版面分析但多栏和水印这两关在 RAG 文档解析里值得优先打通。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑