做 RAG 知识库的人大多体会过一句话检索一时爽解析火葬场。我之前处理一份双栏排版的行业研报 PDF直接解析出来的文本居然是左栏第一段、右栏第一段、左栏第二段、右栏第二段这样穿插着来话说到一半就跳到另一栏去了。更抓狂的是页面背景里一行行浅灰色水印“内部资料 请勿外传”被当成正文抽出来喂进向量库之后检索结果里全是这类垃圾词。这篇想把 bbox边界框这个工具彻底聊透围绕两个最磨人的场景——多栏排版和水印 PDF——给出一套我实测过、能直接抄走的处理思路。如果你正在搭 RAG 知识库或者被 PDF 文本抽取乱序搞到头大这篇应该对得上你的需求。1. 为什么 PDF 解析在 RAG 链路里是最容易翻车的环节1.1 一个典型的翻车现场RAG 的完整链路看起来很简单PDF → 文本提取 → 清洗 → 分块 → embedding → 向量检索 → 把检索结果塞给大模型。很多人在 embedding 模型、chunk 大小、向量库选型上反复调参却忽略了最前面的文本提取环节。实际上文本提取的质量会像多米诺骨牌一样向下游传播——源头的文字顺序错了、杂质多了后面所有环节都是白搭。我遇到过一份典型的双栏学术论文 PDF用默认工具直接提取文本输出顺序是“摘要 → 左栏第一节上半部分 → 右栏第一节下半部分 → 左栏第二节”。这种错位拼接出来的 chunk比如设定 500 token 一个切片几乎每个 chunk 里都混着另一栏的碎片语义完全断裂。拿去检索“模型训练效率”返回的上下文里左半段讲训练右半段插进来一段实验数据大模型看到的是一段前言不搭后语的文字回答质量自然好不到哪去。这一类翻车最麻烦的地方在于它不会报错也不会让你一眼看出来。PDF 能打开、文本能抽出来、向量库能建起来一切看起来正常只有检索结果隐隐不对劲。1.2 多栏排版打破了“自上而下”的默认顺序很多人默认 PDF 的文本抽取顺序就是阅读顺序这个假设对单栏排版基本成立但对多栏排版完全不成立。单栏文档的阅读顺序等于垂直顺序按 y 坐标从上到下排序就好多栏则要走“Z 字形”——从左栏顶部读到左栏底部再回到右栏顶部继续读。问题是PDF 文件格式本身不保证内容流里的对象按视觉顺序存储。PDF 的 content stream 里是一堆绘制指令页面上的文本对象可能按照版式对象的先后排列也可能按创建时的时间顺序排列跟人类的阅读顺序没有必然关系。pdfminer、MuPDF 这类解析库虽然会做一定程度的排序但面对多栏时它们往往只会按内容流顺序输出于是左右栏就交叉了。这个问题的本质是解析器不是排版引擎它不理解“栏”这个概念。栏位是人类看版式时形成的视觉分组计算机看到的只是一个个带有坐标的文本块。所以要让机器理解阅读顺序得靠我们把这些坐标信息利用起来人为重建栏位和顺序这正是 bbox 的核心用途。1.3 水印文本在检索时会造成什么后果水印对 RAG 的伤害不像乱序那么显性但后果同样严重。我把常见水印分成三类页边角水印比如四角上的“机密”“DRAFT”平铺文字水印比如“内部资料”斜向铺满整页还有图像型水印多半出现在扫描件里跟背景融合在一起。平铺文字水印在文本层里往往会被抽取成一堆重复字符串。这些字符串如果跟着正文一起被 embedding会带来几个问题第一高频重复的“内部资料”“仅供内部使用”会成为稳定的噪声向量检索时哪怕查询词跟它们无关也可能因为向量空间里的高频密度被拉到前排第二水印文字夹在正文行之间会打断 chunk 的语义连续性一段完整的论述中间被插进几个重复词切分出来的块质量很差第三水印白白占用了 token 预算尤其是每次检索要塞给大模型的上下文长度有限浪费在这些垃圾词上很可惜。我在实际项目里还遇到过一个隐蔽问题检索评测指标看起来不错但人工看标注好的 answer 时发现模型经常回答“根据资料内部资料请勿外传……”这种莫名其妙的内容。源头就是水印词被当作了正文。所以水印过滤不是可有可无的洁癖而是 RAG 效果有没有下限的保证。2. bbox 坐标系与工具选型PyMuPDF 和 pdfplumber 怎么选2.1 bbox 到底描述的是什么bbox 全称 bounding box翻译过来就是边界框。在 PDF 解析语境下它就是一个矩形框的四元组坐标(x0, y0, x1, y1)分别表示矩形左上角和右下角的横纵坐标。你可以想象成拿荧光笔在打印纸上框住一句话这个荧光笔划出来的矩形就是那句话的 bbox。在 PyMuPDF 的get_text(dict)结构里文档从大到小分为页面page、块block、行line、span一段字体样式一致的文本、词word五层。每一层都有自己的 bbox。比如一个 block 的 bbox 框住整个段落一个 line 的 bbox 框住一行文字一个 span 的 bbox 框住一段连续文本。对 RAG 解析来说bbox 不只是用来画框的装饰它本质上给了我们每段文字在页面上的“位置元数据”。有了这个定位信息我们才能判断哪些文本属于同一栏、哪些文本位于页边距、哪些文本以异常角度重复出现。没有 bbox解析器就只能拿到一个扁平的字符串流面对复杂排版基本束手无策。2.2 两种工具获取 bbox 的代码实测我平时用得最多的两个库是 PyMuPDFfitz和 pdfplumber。先说结论批量解析、生产环境、需要同时处理渲染和多元素时我优先选 PyMuPDF做精细排版分析、表格结构还原时pdfplumber 的信息更完整但速度慢很多。PyMuPDF 取 word 级 bbox 非常简单import fitz doc fitz.open(demo.pdf) page doc[0] words page.get_text(words) for w in words[:5]: print(w)每个 word 元组长这样(x0, y0, x1, y1, word, block_no, line_no, word_no)。前四个就是 bbox 坐标后面跟着单词本身和它所在的块、行编号。如果要拿 block 和 line 级别的 bbox用get_text(dict)更顺手data page.get_text(dict) for block in data[blocks]: if block[type] ! 0: # 跳过图片块 continue for line in block[lines]: x0, y0, x1, y1 line[bbox] spans line[spans] # span 里有 text、font、size、flags、color 等属性pdfplumber 的写法也很直观import pdfplumber with pdfplumber.open(demo.pdf) as pdf: page pdf.pages[0] for word in page.extract_words(): print(word)pdfplumber 的 word 对象里包含x0、top、x1、bottom、text等字段语义比较友好。不过它在解析大文件时确实慢我处理几百页的 PDF 时体感很明显。下面是一个简略对比对比维度PyMuPDF (fitz)pdfplumber底层解析器MuPDFC 实现pdfminer.six获取 bbox 的 APIget_text(words)/dict/rawdictextract_words()/extract_text_lines()处理速度快适合批量慢适合精细分析坐标原点左上角单位 pt左上角单位 pt附加能力渲染图像、矢量绘图、文本属性表格提取、曲线分析适合场景生产环境批量解析离线分析排版结构2.3 坐标系细节和单位换算做 bbox 处理前必须搞清楚坐标系。PDF 原生坐标系的默认原点是左下角y 轴向上但 PyMuPDF 的page.get_text()系列 API 返回的坐标已经转换成了以左上角为原点、y 轴向下的形式单位是 ptpoint1/72 英寸。这样做的好处是拿到一个 bbox 后直接和页面尺寸page.rect比较就能判断位置关系。如果你需要把 bbox 画到渲染出来的图片上要注意缩放比例。比如用 2 倍分辨率渲染页面zoom 2 mat fitz.Matrix(zoom, zoom) pix page.get_pixmap(matrixmat) # 图片上的 bbox ≈ 页面坐标 bbox * zoom说实话做分栏和水印识别时我们真正关心的是文本块之间的相对位置关系单位是 pt 还是像素影响不大。真正容易踩坑的是有些 PDF 页面尺寸是动态的同一份文档不同页面可能宽度不同。所以判断栏位时最好用相对值比如“x0 占页面宽度的百分比”而不是写死一个绝对像素阈值。3. 多栏排版重建按 bbox 聚类还原阅读顺序3.1 先判断文档是不是真的多栏很多人拿到 PDF 就直接套分栏代码结果单栏文档被硬拆成两栏顺序反而乱了。我的建议是先判断后处理。判断方法不复杂——统计所有文本块左边界 x0 的分布多栏文档会在不同的 x 区域形成明显的簇中间有空白间隔单栏文档的 x0 基本聚在一处。用 PyMuPDF 快速看直方图import fitz import numpy as np doc fitz.open(paper.pdf) page doc[0] words page.get_text(words) x_starts [w[0] for w in words] hist, bin_edges np.histogram(x_starts, bins20) for i, h in enumerate(hist): print(f{bin_edges[i]:6.1f} ~ {bin_edges[i1]:6.1f}: {# * h})打印出来如果出现明显的两簇或三簇就说明页面是多栏。实操时有个细节页眉页脚、页码这类元素的位置固定会污染直方图统计前应该先根据 y 坐标把页面顶部 10% 和底部 10% 的区域排除掉。如果不想人工看直方图可以直接用 KMeans 对文本块的中心 x 坐标聚类。默认按 2 栏试发现聚类中心间距过小比如小于页面宽度的 20%就退回单栏处理。这里的核心思想是页面上文本在 x 方向上的分布天然反映了版式结构聚类只是把这个结构自动找出来。3.2 分栏、排序、再把跨栏标题插回去判断完多栏后核心任务就是把文本块按“栏 → 栏内纵向顺序”排列。我写了一个相对完整的函数核心思路是三步先按 bbox 宽度把块分成普通块和跨栏块比如标题、摘要这类横贯整页的块再用 KMeans 为普通块分栏最后按 y 坐标把跨栏块插回合适位置。import fitz import numpy as np from sklearn.cluster import KMeans def get_text_blocks(page): data page.get_text(dict) blocks [] for block in data[blocks]: if block[type] ! 0: continue x0, y0, x1, y1 block[bbox] text .join( span[text] for line in block[lines] for span in line[spans] ).strip() if text: blocks.append({bbox: [x0, y0, x1, y1], text: text}) return blocks def resolve_reading_order(page, n_columns2): blocks get_text_blocks(page) page_width page.rect.width # 1. 区分跨栏块和普通块 full_width, normal [], [] for b in blocks: width b[bbox][2] - b[bbox][0] if width page_width * 0.6: full_width.append(b) else: normal.append(b) if not normal: full_width.sort(keylambda b: b[bbox][1]) return \n.join(b[text] for b in full_width) # 2. 对普通块按中心 x 聚类分栏 xs np.array([[(b[bbox][0] b[bbox][2]) / 2] for b in normal]) km KMeans(n_clustersn_columns, n_init10, random_state0).fit(xs) labels km.labels_ centroids km.cluster_centers_.flatten() column_order np.argsort(centroids) # 从左到右的栏顺序 # 3. 栏内按 y 排序整页顺序先按栏再按行 ordered_blocks [] for col in column_order: col_blocks [normal[i] for i in range(len(normal)) if labels[i] col] col_blocks.sort(keylambda b: (b[bbox][1], b[bbox][0])) ordered_blocks.extend(col_blocks) # 4. 跨栏块标题等按 y 坐标插回 for fb in full_width: pos 0 for i, b in enumerate(ordered_blocks): if b[bbox][1] fb[bbox][1]: pos i 1 ordered_blocks.insert(pos, fb) return \n.join(b[text] for b in ordered_blocks)这里有几个关键选择需要解释。为什么用 block 而不是 word 作为排序单位因为 word 级坐标太碎一个段落里稍微有点排版变化就会把同一个段落拆散聚类稳定性差block 语义更完整一本书里一段文字通常就是一个 block排序后的拼接也更接近原文段落。为什么跨栏块要单独处理因为标题这类全宽块如果参与聚类会把它的中心 x 拉到页面中间干扰左右栏的分簇所以先踢出去最后再按 y 坐标插回去。3.3 如何验证排序结果不是“看起来合理”分栏排序做完之后千万别直接上线。多栏重建是个很容易“自我感觉良好”的活代码跑通不代表顺序正确。我的验证方法有三个层级。第一层是人工目检。随机抽 5 页把排序后的文本打印出来看前 1000 字是否读起来通顺。多栏重建成功时输出看起来就是一篇顺着读的文章失败时一眼就能看出句子断裂。第二层是量化指标。排序后的同栏相邻文本块它们的 x0 坐标应该非常接近因为同一栏的行边界基本对齐。可以用这个特征做检查import statistics def column_alignment_score(ordered_blocks): diffs [] for i in range(1, len(ordered_blocks)): prev_x0 ordered_blocks[i - 1][bbox][0] cur_x0 ordered_blocks[i][bbox][0] diffs.append(abs(prev_x0 - cur_x0)) return statistics.mean(diffs), statistics.stdev(diffs)如果平均 x0 差异很小说明栏内连续性不错如果标准差很大大概率还是有跨栏错位。第三层是做 end-to-end 的抽样比对。挑一段原文已知的内容在排序结果里搜索能完整命中多少。我之前处理一份财报时用这个方法发现第三栏被整体排到了第一栏前面原因是有个表格块宽度刚好超过 60% 阈值被当成了跨栏块。后来我把阈值从 0.6 调到 0.7问题解决。4. 水印 PDF 处理三道识别过滤器4.1 页边角水印位置过滤器页边角水印通常贴在人眼最容易注意的四个角上文本抽取时就会得到一些孤零零的、位于页面边缘的文字块。最朴素的过滤思路是看 bbox 位置如果文本块完全落在页面边距范围内就标记为水印候选。def is_margin_watermark(block, page, margin_pt30): x0, y0, x1, y1 block[bbox] pw, ph page.rect.width, page.rect.height if x0 margin_pt or (pw - x1) margin_pt: return True if y0 margin_pt or (ph - y1) margin_pt: return True return False但只靠位置会误伤页眉页脚里的真实内容。很多论文的页脚会有“本文受国家自然科学基金资助”这类信息它也出现在页面底部但删掉会影响完整性。所以位置过滤器只能作为第一道粗筛还要结合文本长度和字号。页边角水印通常文本很短、字号很大而且内容在整份文档中固定重复。比如“机密”这两个字字号比正文大两倍位置永远在页角基本可以断定是水印。4.2 平铺水印重复频率与文本指纹平铺水印是 RAG 解析里最恶心的类型它斜向铺满整页文本抽取时产生大量重复字符串。识别这类水印最有效的特征是重复频率——同样的文字如果在绝大多数页面里反复出现极大概率是水印。我写过一个简单方案按行提取文本统计全文重复次数超过总页数 60% 的行被列为水印候选。from collections import Counter def find_repeated_lines(doc): counter Counter() page_lines [] for page in doc: lines [] data page.get_text(dict) for block in data[blocks]: if block[type] ! 0: continue for line in block[lines]: line_text .join( span[text] for span in line[spans] ).strip() if line_text: lines.append(line_text) counter[line_text] 1 page_lines.append(lines) total_pages len(doc) repeat_threshold max(2, int(total_pages * 0.6)) candidates [] for text, cnt in counter.items(): if cnt repeat_threshold and len(text) 20: candidates.append((text, cnt)) return candidates这段逻辑的思路是正文内容千变万化几乎不可能整行重复出现在 60% 以上的页面里而平铺水印恰恰就是同一行文字反复出现。不过要小心“仅供内部使用”这类短句在少数几页出现的情况那可能是文档的版权声明页需要人工确认。更稳的指纹是把文本内容和字体特征绑定。水印的字体、字号、旋转角度通常是固定的而且和正文有明显差异。PyMuPDF 的rawdict结构里每个 span 都带font、size、flags、color等属性把这些属性和文本内容联合起来看误判率会大幅下降。4.3 绘制型水印get_drawings 兜底有一种情况比较麻烦水印不是文本层里的一段普通文字而是作为矢量图形被绘制在页面上。比如某些排版工具会把水印转成路径轮廓文本抽取时根本拿不到文字。这种情况属于“绘制型水印”。PyMuPDF 提供了page.get_drawings()返回页面上的矢量绘图指令包括矩形路径、填充色、透明度等信息。如果水印是低透明度的斜向矩形或文字路径可以通过颜色、透明度、覆盖面积等特征识别出来。但说实话绘制型水印的过滤已经进入图像处理范畴文本层的 bbox 方案不再适用。我的经验是优先判断页面有没有文本层如果page.get_text()能抽出正常内容就别管绘制型水印先据文本层做频率过滤如果 PDF 是扫描件根本没有文本层那就走 OCR 和图像处理路线这部分在下一章展开。4.4 怎么避免误删正文水印过滤最大的风险不是漏网而是误删。我见过有同行用黑名单方式过滤水印词把“内部”“机密”“资料”直接删掉结果正文里出现“内部员工培训制度”“机密文件清单”这类正常内容时被拦腰砍断文本不完整RAG 检索质量更差。我自己总结了三道防线。第一水印判断至少组合两个特征比如“位置在页边距 全文高频重复”同时成立才删单一特征只标记为候选不直接删除。第二过滤时保留 block 结构把文本替换成空字符串而不是把 block 整体移除这样后续排序时左右留白依然存在不会把原本不相邻的两段文字硬拼在一起。第三把候选水印清单打印出来人工过一眼几十个 PDF 也就几秒钟的事但能避免大量误删。正文里也有高频重复的案例比如教材每页右下角的“第 X 页”或者论文每页页眉的“第 X 章 标题”这些严格说都不是水印删除后对语义影响也不大。但如果把“版权声明”也删了对文档的完整性会有影响。所以我的原则是宁可少删不可多删。漏掉几个水印词RAG 只是有点噪声误删正文那是直接把有效信息弄丢了性质完全不同。5. 搭一条能上生产环境的 RAG 文档解析流水线5.1 从 PDF 到干净文本的完整步骤把前面讲的 bbox 技术点串起来一条可以用于 RAG 的解析流水线大概长这样打开 PDF逐页判断是否存在文本层调用page.get_text()如果返回为空标记为扫描页。对文本层页面用get_text(dict)抽取 block/line/span 级 bbox 和文本。水印过滤先用位置过滤器筛页边角水印再用重复频率和字体指纹筛平铺水印组合特征才删除。布局重建判断单栏还是多栏多栏则按 bbox 聚类分栏、排栏内顺序、插回跨栏块。段落合并把排序后的文本块按换行规则拼成段落去掉孤立短行。分块按语义或者固定 token 数量切 chunk尽量让块边界落在段落边界。向量化并写入向量库embedding 后连同页码、块序号等元数据一起存储。这套流程里第 2 到第 4 步是本文的主角。实际项目里我会把每一步结果都输出到一个中间 JSON 文件里包含每个 block 的文本、bbox、过滤标记、栏号、排序序号。这样下游处理出问题回溯排查最快不用重新解析 PDF。5.2 扫描件和 OCR 兜底路线扫描件没有文本层get_text()拿不到任何内容上面的文本方案全部失效只能走 OCR。OCR 工具我用 PaddleOCR 比较多的好处是它不仅返回识别文本还会返回每个词的四边形坐标。这个四边形的外接矩形本质上就是词级 bbox所以多栏排序、水印频率过滤这些逻辑完全可以复用。PaddleOCR 返回结果通常是这样的结构每个元素包含text、score和box四个角点坐标。拿到之后取最小外接矩形的四个边界即可import numpy as np def quad_to_bbox(quad): xs [p[0] for p in quad] ys [p[1] for p in quad] return [min(xs), min(ys), max(xs), max(ys)]扫描件的水印处理比文本层麻烦得多。图像水印会跟正文叠加在同一层像素里OCR 可能识别出一些浅色残字也可能直接忽略。如果水印比较轻单纯靠 OCR 文本过滤可以挡掉一部分如果水印覆盖严重得先做图像预处理比如在二值化、灰度化的基础上用形态学操作抹掉固定位置的低对比度区域。这类操作风险很高容易连带抹掉正文笔画我建议只处理那些水印颜色很浅、结构简单的案例其他情况宁可保留噪声也不要冒险损伤文字。5.3 解析质量的检查清单与调参思路生产环境里“跑通”不算完“跑对”才算达标。我给自己定了一个检查清单每次上线新解析规则都要过一遍检查项目合理标准检查方式水印残留字数占比低于全文字数的 1%统计过滤后仍含“机密”等词的比例抽样段落顺序正确率抽 10 页至少 9 页顺序自然打印排序后文本人工阅读跨栏块处理正确率标题没有被拆进左右栏检查标题后紧跟的文本是否同段chunk 打断正文比例少于 5%看分块边界是否落在段落边界OCR 兜底耗时单页不超过数秒按批处理测平均耗时参数调优方面最容易影响结果的有三个。第一个是跨栏块宽度阈值0.6遇到标题刚好占页面宽度 55%~60% 的版面时这个阈值非常尴尬建议在做完分栏后检查一下全宽块列表如果发现某个块明显是正文段落却被误判为全宽就调大阈值。第二个是 KMeans 的n_columns学术论文和研报常见 2 栏或 3 栏可以在 1 到 3 之间自动尝试选聚类中心间距最大且每个簇内文本量均衡的模型。第三个是页边距margin_pt中文文档字号通常在 9~12pt页眉页脚离边缘 30~50pt可以先设 30pt 观察候选水印再根据实际文档微调。5.4 性能和缓存策略PyMuPDF 解析单页的速度很快但get_text(dict)比get_text()慢不少因为要构建完整的层级结构。如果是上千页的文档建议按页流式处理解析完一页就把结果落盘不要一次性把整本的rawdict都攒在内存里。缓存策略上我是用 PDF 的文件名加文件大小做 hash key解析结果存成 JSON。同一个文件再次处理时先查缓存命中就直接读取。文档版本更新时PDF 的 hash 会变自动走重新解析流程不用人工干预。另一个优化点是只对需要的页面做精细解析。比如某些盘点文档前几页是封面和目录目录页大多有独立版式容易让分栏算法误判。遇到这种情况我会在配置里设定“跳过前 N 页”或者“只解析特定页码范围”省时又省心。我自己从这套 bbox 方案里最大的收获是不要试图用一个万能函数解决所有 PDF。文档解析的质量上限很大程度取决于你对版面类型的敏感度。双栏论文、三栏简报、带复杂水印的内部资料、扫描件每种都有自己的脾气。把这些常见类型各挑几份真实样本建立一个小规模测试集每次改完解析逻辑先过测试集再放大到全量基本不会再出现上线后才发现解析结果大面积崩掉的情况。
阅读完成 · 觉得有帮助?