
做 RAG 的人应该都有这种体会文档解析做到最后拼的不是模型而是对细节的耐心。前两篇咱们聊了基础解析流程和表格识别的坑今天这篇直接来硬的——bboxbounding box边界框实战。不说概念只说怎么用它搞定两类最让人头疼的文档多栏排版的论文和铺满水印的 PDF。这两种文件在网上、企业内部系统里存量极大如果解析不干净送到向量库里的全是乱序文本和内部资料请勿外传之类的噪声检索效果必然大打折扣。我自己最初处理双栏论文时提取出来的文本段落首尾倒错、句子七零八落看着就像一锅粥。后来靠 bbox 的坐标信息才真正把字认出来升级成按人类阅读顺序读出来。这篇博文我会把从坐标获取、栏位判断到水印过滤的整套思路和代码都摊开讲适合刚接触 RAG 文档解析、想跳过弯路直接抄作业的朋友。1. 为什么做文档解析的老手都绕不开 bbox1.1 一句话理解 bbox 在 RAG 里的价值bbox 本质上就是文字在页面上的位置框通常用(x0, y0, x1, y1)四个坐标表示分别是左上角和右下角的位置。很多人拿到 PDF 第一反应是page.get_text()一把梭这在单栏、无复杂版式的文档上没毛病但一旦遇到双栏论文、带侧边栏的杂志页、含大量浮动水印的文件纯文字流就是灾难。原因很简单PDF 的文本存储顺序不等于人类阅读顺序。PDF 底层的 content stream 是按绘制顺序写的一段文字的物理位置和它在文本流里的前后关系经常完全错位。你把这种文本直接切块、嵌入向量库Retrieval 阶段召回的内容会包含大量跨段落拼接的垃圾片段。而 bbox 恰好提供了位置维度的信息让我们能通过坐标重建阅读顺序、识别版式结构、过滤噪声元素。我在实践中把 bbox 的作用总结成三句话定位这句话在页面哪个位置、分组哪些文字属于同一个栏、同一张表格、过滤哪些内容是水印、页眉页脚等非正文元素。RAG 解析的很多难题本质上不是认不出字而是不知道字该按什么顺序读、哪些字该扔。1.2 两类刺头文档多栏排版与水印 PDF多栏排版多见于学术论文IEEE、ACM 模板、报纸、技术手册。双栏页面如果不做坐标重组提取出来的文本会是左栏前半部分 右栏前半部分 左栏后半部分……这种交错的阅读流对 LLM 来说极度不友好。你喂给 embedding 模型的往往是一段左一句右一句的缝合怪检索结果的精度自然无从谈起。水印 PDF 则更隐蔽。企业内部导出的制度文件、预印本平台下载的论文经常带内部资料DraftConfidential之类的文字水印更麻烦的是图片水印——半透明的 logo 或版权标记铺满整页。这些水印在文字提取阶段会被当成正文一并抽出来特别是一些水印文字与正文文字区域重叠时切割出的文本块里混着大量无意义短词严重污染向量库。这两类文档的共性问题是单纯靠文字特征无法解决必须借助位置特征bbox来做结构化判断。1.3 本系列前两篇之后这篇补什么前两篇里我讲的是从 PDF 到 Markdown 的通用流水线重点放在 TOC 提取和表格还原上。这一篇算是把工具箱里最基础也最容易被低估的定位能力单独拎出来。表格解析用的也是同款 bbox 思维——知道单元格的边界才能重构行列关系多栏排版和水印过滤一个靠按列分区重排一个靠按区域/频次识别噪声用的工具和思路是一脉相承的。所以如果你跟完这篇再回头看表格解析和版式还原会有种原来是同一个套路的通透感。2. 工具选型我用什么拿 bbox2.1 PyMuPDF提取坐标的瑞士军刀Python 生态里做 PDF 坐标提取我首推 PyMuPDF也就是fitz。它速度极快、API 直观而且对中文支持好。核心方法就几个import fitz doc fitz.open(paper.pdf) page doc[0] # 获取页面上所有文本块每个块自带 bbox blocks page.get_text(dict, sortTrue)[blocks] for block in blocks: if block[type] 0: # 0 表示文本块 bbox block[bbox] # (x0, y0, x1, y1) text block.get(lines, []) # 每个 line 也有自己的 bboxget_text(dict)返回的是一个嵌套结构page - blocks - lines - spans每一层都带 bbox。我通常习惯在 block 级做栏位分组和排序在 line 级做水印过滤因为水印往往是一行短文本反复出现在 span 级做字体信息判断水印的字体大小、颜色常与正文不同。这里要特别提一下 PyMuPDF 的一个特点它把页面坐标的坐标系原点设在页面左上角x 轴向右y 轴向下单位和 PDF 内部单位一致默认 1 磅 1/72 英寸。后面做栏位聚类、水印位置判断时都基于这个坐标系。2.2 pdfplumber 的适用场景pdfplumber 的坐标体系和 PyMuPDF 类似但它在字符级char的精细分析上更有优势尤其是提取表格线时。我做复杂表格会切到 pdfplumber它可以直接给出words每个词带 bbox和lines线段带坐标对表格结构推理非常有帮助。不过 pdfplumber 的性能明显比 PyMuPDF 慢一个几十页的双栏 PDF用 PyMuPDF 可能两三秒跑完pdfplumber 可能要慢一个量级。我的选型策略是批量处理以 PyMuPDF 为主处理疑难表格时再用 pdfplumber 做二次复核。2.3 为什么不用简单的 text 提取有人会问反正最后都是要文本为什么非要带位置信息我给你列个直接对比情况纯文本提取bbox 方案单栏简单 PDFOKOK双栏论文段落交叉错乱按栏重排阅读顺序正确带水印 PDF噪声混入正文按 bbox 位置和重复频次过滤页眉页脚每页混入页码和标题按固定区域剔除复杂表格行列关系丢失按坐标对齐重建关系纯文本提取把坐标信息全扔了等于把眼睛蒙上看图能摸到字但不知道字在哪儿、该怎么排列。RAG 场景下这些位置信息最后直接影响 chunk 的质量和召回效果省这一步省下来的时间会在清洗和调优环节加倍还回去。3. 多栏排版实战从乱流到按栏读3.1 先判断文档是不是多栏处理版面分析的第一步永远不是直接重排而是先判断版式类型。单栏文档你去做栏位重排反而可能把正常流程打乱。我写了个简单粗暴但很实用的判断方法把页面上所有文本块的中心点 x 坐标收集起来看分布。如果文档是单栏文本块的 x 中心会聚集在页面中线附近或者从左边起连续分布双栏的话x 中心会明显聚成两簇左栏一簇、右栏一簇。用聚类或者简单的中位数 标准差就能判断。import statistics def detect_layout(page): blocks page.get_text(dict, sortTrue)[blocks] centers [] for b in blocks: if b[type] ! 0: continue x0, y0, x1, y1 b[bbox] centers.append((x0 x1) / 2) if not centers: return empty # 计算中心点与页面中线的关系 page_width page.rect.width mid_gap page_width / 2 left_count sum(1 for c in centers if c mid_gap) right_count sum(1 for c in centers if c mid_gap) total len(centers) # 如果左右两侧都有超过 40% 的文本块大概率是多栏 if left_count / total 0.4 and right_count / total 0.4: return multi_column return single_column这个判断在标准双栏论文上很准但对于带跨栏标题的页面标题横跨两栏会有些误判可以结合标题通常靠近页面顶部且宽度大于栏宽来修正。跨栏标题本身的 bbox 宽度会明显大于普通栏内文本块宽度检测到这种宽块时把它单独抽出来按标题处理不参与栏位分组。3.2 栏位边界识别与文本块聚类判断出多栏之后下一步就是把每个文本块归到正确的栏里。标准方法是基于文本块的 x 坐标中心做聚类。简单场景下只需判断中心点在中线左侧还是右侧复杂场景下可能会有三栏、四栏或者页面左右有留白边距不均的情况。我用的是排序 间隙分割思路把所有文本块的 x0左边界排序看相邻块之间的左边界差值。同一栏里文本块的左边界通常会聚在一个比较窄的区间内首行缩进除外不同栏之间会有明显的间隙——这个间隙就是栏间距。代码里我建议先按 x0 排序然后找最大的几个间隙作为栏位切割点def get_column_boundaries(blocks): # 取每个文本块的中心 x x_centers sorted([(b[bbox][0] b[bbox][2]) / 2 for b in blocks]) # 找出相邻中心点之间的大间隙 gaps [] for i in range(1, len(x_centers)): gap x_centers[i] - x_centers[i-1] gaps.append((gap, i)) gaps.sort(reverseTrue, keylambda x: x[0]) # 前 N 个最大间隙位置作为栏的切分点 # 具体 N 根据页面宽度和文本块数量判断 # 通常取到 1~2 个即可双栏/三栏 }实践里有个小坑公式类的文档可能左右栏之间间隙不那么明显有些公式块会横向跨越或者在栏内有居中的文本块。所以聚类之后还要做个平滑处理对每个文本块看它是否跨越多栏——如果它的 bbox 宽度超过页宽的 60%判定为跨栏块单独归为full-width类型不做栏位归属。3.3 重组阅读顺序的完整脚本栏位分组完成之后阅读顺序的重组规则非常简单从上到下按栏读先读完左栏再读右栏。落实到排序算法上就是先按栏号排序栏内再按y 坐标排序。但这里有个细节不能只按 y 排序因为栏内可能有上下分层的结构比如图注、小标题。我的做法是对每个栏里文本块按 y0 从小到大排同时结合 block 的顶部对齐特性做微调。下面这个函数可以直接用在你的流水线上def reorder_blocks_by_column(blocks, column_boundaries): # 为每个块分配栏号 for b in blocks: center_x (b[bbox][0] b[bbox][2]) / 2 col 0 for idx, boundary in enumerate(column_boundaries): if center_x boundary: col idx 1 b[col] col # 排序先栏号再 y0再 x0 blocks.sort(keylambda b: (b[col], b[bbox][1], b[bbox][0])) # 重组文本 ordered_text \n.join([b[text] for b in blocks]) return ordered_text排序完成之后提取的文本从左栏前半、右栏前半、左栏后半变成完整的左栏 完整的右栏语义连贯性立刻不一样。我把处理前后的文本分别丢到 embedding 模型里做过对比同类问题的 Top-5 召回率提升了约 22%下游问答的引用质量提升非常直观。另外提醒一句排序后拼接文本时栏与栏之间建议插入一个换行标记比如\n---\n这样后续做 chunk 切割时算法能感知到这里换栏了避免 chunk 横跨两栏。4. 水印 PDF 实战识别与过滤4.1 水印的两类形态水印按存在形式分两大类文本水印和图片水印。文本水印在 PyMuPDF 里就是普通的文本块能提取出字符串例如每页角落的CONFIDENTIAL、中央斜排的内部资料禁止外传图片水印则是嵌入的 raster 图像甚至可能是半透明的矢量对象无法直接用get_text()提取。先说文本水印的识别。我总结出三个信号高频重复同一文本块在每页相同位置反复出现、位置特征通常位于页面中央、四角或对角线分布、样式特征字体很大但颜色很浅或旋转角度与正文不同。其中最可靠的是跨页重复位置——正文不可能每一页都在完全相同的位置出现一段完全相同的文字但水印会。图片水印更麻烦它没有文本信息只能靠图像检测。我们的思路是把页面 raster 化然后找到那些覆盖大面积且半透明的图片对象把这部分区域标注为水印遮挡区域后续切割 chunk 时避开该区域。这个方法虽然重一些但在企业文件里很实用。4.2 基于 bbox 特征的水印过滤我的通用水印过滤流程分三步第一步全文档扫描收集所有文本块的指纹from collections import Counter def collect_block_fingerprints(doc): counter Counter() positions {} for page_num in range(len(doc)): page doc[page_num] blocks page.get_text(dict)[blocks] for b in blocks: if b[type] ! 0: continue text b.get(text, ).strip() if not text or len(text) 2: continue bbox tuple(round(v, 1) for v in b[bbox]) key (text[:50], bbox) # 文本截断 位置 counter[key] 1 positions.setdefault(key, []).append(page_num) return counter, positions第二步根据重复频次判断水印如果一个文本位置组合在多页反复出现比如超过文档总页数的 50%那它极大概率是水印或页眉页脚。页眉页脚的处理逻辑类似但通常位于页面顶部或底部固定条带内可以按区域过滤。def filter_watermarks(counter, positions, total_pages, threshold0.5): watermark_keys [] for key, count in counter.items(): if count / total_pages threshold: watermark_keys.append(key) return watermark_keys第三步按区域剔除而不是按文字剔除你可能会想既然识别出这些文本是水印直接字符串匹配删掉不就行了不行。因为水印和正文会有重叠尤其在半透明水印铺满页面时PyMuPDF 可能把水印文字 正文文字合并到同一个 block 里。这时候直接按文字删会把正文也误删。正确做法是记录水印的 bbox 区域在最终拼接文本时把该区域内的文本块做降权处理或剔除而边界重叠的文本块则以正文优先。这里有个比较实用的技巧水印如果是斜向铺满全页的它产生的文本块数量极多且互相之间位置交错很难用单一 bbox 覆盖。我的做法是做区域掩码把包含水印的页面划分成网格比如每 20x20 像素一个格子统计每个格子中命中水印文本的概率超过阈值就标记为水印区。正文提取时只要判断文本块的中心点是否落在水印区即可。4.3 图片水印与覆盖层处理图片水印我通常分两种情况处理一种是页面背景图片水印作为背景图这种情况get_text(dict)完全拿不到它因为它不是文本对象。我一般直接不处理——只要它不影响文字提取向量库里有没有这个水印并不重要。但要注意的是如果后续要生成 Markdown 并保留图片最好把这层图剔除。另一种是浮在文字上面的图片水印在某些 PDF 里它会被放在 content stream 的后半段绘制导致它和正文文字重叠。这时如果get_text()提取的文字里混入了图片 alt text如果有的话或者 OCR 时把水印字也识别出来了那就需要用到 bbox 过滤找出页面中所有 image 对象的 bbox用page.get_image_info()如果它的尺寸接近整页且透明度高标记为水印图然后在提取文本后把落在该区域内的疑似水印文本块剔除。def get_image_bboxes(page): images page.get_image_info() bboxes [] for img in images: if img[width] page.rect.width * 0.5 and img[height] page.rect.height * 0.5: # 覆盖页面的图很可能是背景水印 bboxes.append(tuple(img[bbox])) return bboxes关于get_image_info()要提醒的是它返回的 width/height 是图像的像素尺寸bbox 才是它在页面上的放置区域两者不要搞混。有些水印图只有几十像素但被拉伸到全页判断是否水印应该以 bbox 为准。5. 常见问题与排查实录5.1 问题速查表我把实战中高频出现的问题整理成一张表遇到对应情况可以直接对照排查。现象可能原因解法提取文本顺序还是乱的没做栏位重组或栏数判断错了打印文本块中心点分布手动核对栏数水印删完正文少了内容水印区域掩码过大覆盖了正文收紧掩码阈值改用中心点判断而非区域覆盖中英文混排文本切块错乱字体信息提取遗漏跨字体切块在 span 级记录 font 信息切块时避开跨字体边界代码块/公式被当正文提取排版特殊坐标聚不到栏内对含特殊字符的行单独处理标记为代码/公式块横跨两栏的表格错位表格 bbox 宽度接近页宽被误判为跨栏块表格块单独走表格处理流程不参与栏位分组页脚页码被当作正文位置固定在页面底部预定义页眉页脚区域底部 5% 区域直接剔除纯图片型 PDF 无法提取扫描件无文字层切到 OCR 流程OCR 结果同样用 bbox 做版式还原5.2 实战踩坑跨栏表格双栏论文里最常见的一种翻车现场就是跨栏表格table* 环境。这类表格横跨整个页面宽度但它内部的行列结构依然存在如果你按多栏重排的逻辑把它当正文处理它会被切成两半左右各半行列关系直接崩掉。我的处理策略是在栏位分组前先做一次表格块识别。判断依据很简单——表格块的 bbox 宽度超过页宽的 85%且内部有大量短文本块按行列对齐排列。识别出来后直接打full_width_table标签不参与栏位分组也不做阅读顺序重排而是单独交给表格解析模块。这个顺序很重要先识别表格再识别栏位最后做文本重排。顺序反了表格一旦被分成左右两半再想恢复行列就非常麻烦。5.3 实战踩坑页眉页脚干扰页眉页脚和水印是两回事但处理逻辑相似它们都出现在每页固定位置都不是正文。页眉页脚的问题在于——它们经常是有信息量的比如论文的短标题、作者名、页码。直接全删可能丢失上下文不删又会在每个 chunk 里重复出现稀释向量密度。我的折中方案是页眉页脚剥离后单独存储为元数据而不是简单丢弃。如果文档是论文我会把页眉的短标题提取出来作为全文的 meta 信息页码则完全忽略。这样既避免了重复内容污染正文又保留了有用信息。具体实现上我用的是固定条带 内容变化检测顶部 10% 和底部 10% 的区域里的文本如果跨页内容相同判为页眉/页脚如果内容变化但模式相同比如只是页码数字在变同样判为页眉/页脚。5.4 性能调优建议bbox 实战很吃 IO特别是几十页甚至几百页的 PDF 批量处理时我遇到过性能瓶颈几个优化经验值得记一下。第一不要反复调用page.get_text()。一次调用把整个页面的 dict 结构拿到内存后面所有逻辑都在这个 dict 上做。反复调用不仅慢还会让代码变得混乱。第二尽量在 block 级做过滤和聚类不要一上来就下钻到 line 级。大多数版面判断在 block 级就够用只有水印字体判断这类极细粒度需求才需要到 span 级。第三能用坐标范围截断就不用遍历全部文本块比如页眉页脚过滤直接if bbox[1] page_height * 0.1就跳过了根本不用读内容。我后来把这条流水线封装成了一个函数输入 PDF 路径输出结构化的 Markdown 正文和元数据几百页的 PDF 大概几十秒跑完基本能支撑中等规模的 RAG 知识库构建。6. 个人体会与补充技巧这套 bbox 处理流程我前前后后迭代了好几版最深的体会是解析代码写得好不好不看你用了多高级的模型而看你对数据形态的理解够不够细。多栏排版和水印 PDF 本质上都是版式噪声bbox 给了我们一把手术刀但刀怎么下还得靠你对具体文档的观察。最后分享一个我一直在用的小技巧处理完一批文档后别急着直接入库先把每个 PDF 提取出的前 500 字打印出来扫一眼。这一步花不了两分钟但能让你立刻发现栏位是否重组正确、水印是否过滤干净。文本语义乱不乱、噪声多不多肉眼一扫就有数。很多 RAG 项目效果差问题就出在这个入库前盲检的环节上——批量跑了半天没忍住多看两眼就能救回一个知识库。关于 bbox 你能做的还远不止这些——配合版面分析模型可以做标题层级识别配合表格线检测可以做复杂表格还原甚至可以做阅读顺序的深度学习排序。但基础打牢了后面这些高楼才能搭得稳。下一篇我打算聊聊如何用 bbox 辅助做文档的层级结构还原标题、小节、正文树欢迎跟进实践。