
最近在搞RAG知识库的文档解析被多栏PDF和水印折磨得不轻。如果你的知识库里塞了一堆学术论文、行业报告或者老版扫描件多半也遇到过这种诡异事检索出来的片段看着是连续的读起来却前言不搭后语明明原文里没有水印切出来的chunk却带着一排“内部资料仅供参考”的重复噪音。这问题的根源十有八九是你压根没看文字的bbox。bbox全称是bounding box即每个文字、图片、表格在页面上的最小外接矩形。它看起来只是四个坐标值实际上决定了你能不能在RAG里正确还原文档的阅读顺序。这篇博文是我做RAG文档解析系列的第三篇专门聊bbox的实战多栏排版怎么拆、水印PDF怎么净、页眉页脚怎么清以及把这些规则揉进解析Pipeline时踩过的坑。内容适合正在搭知识库、做文档结构化解析、或者跟PDF死磕的研发同学也适合想了解“为什么PDF解析不能只靠文本提取”的初学者。1. bbox是什么为什么RAG离不开它1.1 PDF的“无秩序”本质先搞清楚一个关键事实PDF本身不存“段落”“栏目”“阅读顺序”这些语义概念。它只记录一堆绘制指令——在某个坐标画一段文字、在另一个坐标贴一张图、在第三个坐标线条加粗仅此而已。文本内容在PDF内部是沿着内容流content stream顺序存放的但这个顺序经常和视觉阅读顺序完全无关。生成PDF的工具经常先画背景装饰、再画水印、最后画正文如果你直接按提取顺序把文字串起来得到的就是“背景文字 水印 正文”的大杂烩。所以要让RAG拿到干净的文本必须回归到几何层面拿到每段文字的bbox通过坐标判断它在页面上的真实位置再重建人类阅读时的浏览轨迹。这也是为什么说bbox是文档解析的基础设施没有它后面所有版面分析、多栏切分、表格识别、水印过滤都无从谈起。1.2 坐标体系最容易踩的坑PDF的坐标系统默认是左下角为原点x轴向右y轴向上单位是点1点1/72英寸。PyMuPDF和pdfplumber返回的bbox都遵循这个坐标系。但很多做NLP的同事习惯用PIL或者OpenCV那些图像处理库是左上角原点、y轴向下的坐标系。这就导致一个非常尴尬的场景用PyMuPDF取到bbox再用PIL可视化时如果不做y轴翻转画出来的框全部上下颠倒文字明明在左上角框却标到了左下角。我一开始就被这个坑绊过所以现在统一做法是解析阶段全程用PyMuPDF坐标系可视化时再手动翻转。判断公式很简单翻转后的y 页面高度 - 原始y。页面高度可以用page.rect.height拿到。记住这个换算后面调试能省很多时间。1.3 bbox背后的“文本图”思路现在语言模型对PDF的理解其实已经不只是把文字抽出来。业界有个说法叫“文本图”text map本质就是把文本和它的位置信息打包让模型同时看到“说了什么”和“在哪说的”。比如GPT-4o这类多模态模型读PDF时靠的就是视觉特征和文本的布局特征。即使不做多模态RAG解析时把bbox保留下来对表格转Markdown、跨栏合并、水印过滤都有决定性帮助。一句话总结RAG文档解析的核心不是“提取文字”而是“理解版面结构”而版面结构的起点就是bbox。2. 多栏排版解析实战从检测到线性化2.1 怎么判断页面是不是多栏做多栏解析的第一步不是急着切而是先判断页面到底是不是多栏。常见的学术论文、行业白皮书大多是双栏但有些页面是标题页、图表页可能是单栏居中布局。我用的方法是统计文本块x0坐标的分布再加一根分界线。具体来说用page.get_text(blocks)拿到页面上每个文本块记下它们的x0左边界。如果页面正常是单栏绝大多数文本块的x0都集中在左边界附近比如50到100点之间。如果是双栏x0会明显分成两簇一簇在60左右另一簇在页面中线附近比如300左右。这时候分界线取两簇中心点的中点或者直接看直方图的谷底。用聚类更省心。我直接用sklearn的KMeans聚类数固定2特判一下如果某一簇的样本数占比不足15%就当成单栏处理。实测下来对学术论文、行业报告、说明书这类排版规范的双栏文档准确率在95%以上。唯一要小心的是标题页和纯图片页前者文本块数量少后者几乎没有文本块走特判逻辑就行。2.2 跨栏元素怎么处理标题、表格、通栏图双栏文档里的标题、跨栏表格、通栏图片是很麻烦的存在。它们的bbox宽度接近整个页面如果硬按x0归到某一栏会把左右两栏的对齐关系彻底打乱。我的经验是先识别跨栏元素再分栏。识别逻辑很简单如果一个文本块或者图片的bbox宽度大于页面宽度的75%就认为它是跨栏元素。对跨栏元素单独提取记录它的y坐标对剩下的窄文本块按x0聚类分成左栏和右栏。全部处理完之后在生成线性文本流时按照y坐标大小把跨栏元素插入到对应位置。这样标题还在开头跨栏表格还在它原来的位置但左右栏的正文已经各归各位了。需要注意表格如果跨栏出现它的内部单元格也可能自带多栏结构不能简单当成通栏处理。我的做法是把跨栏表格单独抽出来交给后面专门的结构化通道不掺和在正文线性化里。2.3 排序与合并核心代码实现多栏线性化的核心流程是每页解析blocks判断单栏/双栏双栏先分栏再按y排序最后合并出整页线性文本。贴一段我常用的PyMuPDF代码import pymupdf def page_layout_to_linear(page): # 页面宽度和高度 W, H page.rect.width, page.rect.height blocks page.get_text(blocks) # 每个元素: x0,y0,x1,y1,text,block_no,block_type if not blocks: return # 过滤太小的块噪声块 blocks [b for b in blocks if len(b[4].strip()) 2] # 判断是否双栏统计x0聚类 text_blocks [b for b in blocks if b[6] 0] if len(text_blocks) 3: return \n.join(b[4] for b in blocks) x0s [b[0] for b in text_blocks] mid W / 2 left_count sum(1 for x in x0s if x mid) right_count len(x0s) - left_count # 双栏判断左右都有相当数量的文本块 if left_count len(x0s) * 0.3 and right_count len(x0s) * 0.3: left_col sorted([b for b in text_blocks if b[0] mid], keylambda b: b[1]) right_col sorted([b for b in text_blocks if b[0] mid], keylambda b: b[1]) full_width_blocks [b for b in text_blocks if b[2] - b[0] W * 0.75] full_width_blocks.sort(keylambda b: b[1]) # 按跨栏元素的y位置将左右栏文本分段拼接 parts [] last_y 0 for fw in full_width_blocks: fy fw[1] parts.append(join_cols(left_col, right_col, last_y, fy)) parts.append(fw[4]) last_y fy parts.append(join_cols(left_col, right_col, last_y, H)) return \n.join(p for p in parts if p.strip()) else: text_blocks.sort(keylambda b: (b[1], b[0])) return \n.join(b[4] for b in text_blocks) def join_cols(left_col, right_col, y_start, y_end): 取出左右栏在y_start到y_end之间的文本按左栏先、右栏后拼接 left_text \n.join(b[4] for b in left_col if y_start b[1] y_end) right_text \n.join(b[4] for b in right_col if y_start b[1] y_end) return left_text \n right_text这段代码的核心思想是双栏页面阅读时人眼通常先读完左栏整列再跳回右栏。所以对每一段被跨栏元素切分出来的横向区域我输出顺序是“左栏全部文字 右栏全部文字”。标题、通栏图片则按照它的y位置插入到对应的区域末尾。逻辑不复杂但对学术论文的效果立竿见影。2.4 切分策略与栏保护多栏解析完紧接着就是RAG切分。很多人在这一步犯的错误是先切成大段文本再做embedding切分导致一个chunk里同时混了左栏和右栏的文字。正确做法是在多栏线性化之后按栏为单位保护段落。也就是说切分器在整个线性文本流上运行时必须知道哪些内容属于同一栏跨栏边界处要强制断开。我习惯在解析阶段就给每段文本打一个元数据标记比如{page: 3, column: left, block_no: 5}切分器遇到column字段变化时即使文本长度没达到chunk_size也强制截断。这样做的好处是每个chunk语义完整检索出来的片段不会出现“上一句在左栏、下一句在右栏”的错乱。chunk_size的设置也要跟着栏目走。双栏论文每栏宽度大概300点一栏能放300到500个字所以chunk_size可以设置成400到600overlap设80到120。如果单栏文档chunk_size可以适当调大到800到1000。这个值不是拍脑袋定的是根据栏宽、字号、行距算出来的。3. 水印PDF的净化从检测到过滤3.1 水印的几种常见形态水印PDF大概是RAG解析里最烦的一类输入。它不像多栏那样结构规律水印的形态五花八门。我归纳了四种常见类型一是文本水印每页同一个位置反复出现同一行字比如“仅供参考”“内部资料”这种最常见也最好处理二是图片水印整页铺一个半透明的Logo图文字本身不出现但会让后续OCR或者视觉模型误读三是页眉页脚类严格说不是水印但特征类似——每页顶部或底部固定出现文档标题、页码、日期四是不可见文本水印用颜色很浅的文字铺在页面上肉眼看不到但文本提取时会被抓出来。这四类水印的共同特征是什么重复性和位置固定性。真正干净的文档不会在每一页的同一个坐标位置反复出现同一段文字。这就是检测水印的抓手。3.2 用bbox特征做水印检测既然水印是“每页同一位置反复出现的小字”那么只要统计跨页重复出现的bbox区域就能揪出来。我的做法是第一把每一页的span级别文本提取出来记录每个span的文本、字号、颜色、bbox。第二把所有span的左上角坐标映射到相对位置x0/W和y0/H。第三对跨页面聚合如果同一个相对位置在超过70%的页面都出现了文本且字号明显小于正文中位字号那么这个位置标记为水印或页眉页脚区域。下面是我用的一套核心过滤逻辑代码不长但实战里很能打from collections import defaultdict def detect_repeated_regions(pages_spans, min_ratio0.7): # pages_spans: list每个元素是list[span]span包含text,x0,y0,x1,y1,size,color pos_counter defaultdict(list) page_count len(pages_spans) for page_idx, spans in enumerate(pages_spans): for span in spans: h span[y1] - span[y0] if h 3: # 过滤杂散点 continue rel_x round((span[x0] span[x1]) / 2 / 612, 2) # 归一化中心x rel_y round((span[y0] span[y1]) / 2 / 792, 2) # 归一化中心y key (rel_x, rel_y) pos_counter[key].append((page_idx, span)) repeated_regions [] for key, items in pos_counter.items(): if len(items) page_count * min_ratio: repeated_regions.append(key) return repeated_regions这个逻辑比较简单判断相对坐标一致并且跨页重复出现。实际工程里还要再加几道保险比对各页的重复文本是否一致如果每页都是“内部资料”这四个字基本可以断定是水印比对字号与全页中位字号水印字号通常在正文的50%以下检查颜色和页面背景的对比度水印用浅灰或浅蓝很常见。3.3 Span级别的清洗与页面净化拿到重复区域列表后真正的清洗是逐span进行的不是逐块进行的。这里有个细节你绝对不能用page.get_text(blocks)去做水印过滤因为block是多个span聚合的结果一个block里可能混着正文和水印一刀切会把正文也误删。我最终用的是page.get_text(dict)它可以拿到span级别的数据包括font、size、color、bbox粒度最细。清洗逻辑按优先级做三件事第一层直接按“重复区域 文本一致”删除这是最安全的一层误删率接近0。第二层按“位置在页面上下边距 字号偏小”删除这层主要针对页眉页脚顺带处理页码。页面正文区域一般占据中间70%的纵向范围超出这个范围的固定文本基本都是页眉页脚。第三层按“颜色极浅 字号偏小”过滤这层专门收拾不可见文本水印。颜色的判断需要注意PDF里的颜色值通常是24位RGB0xFFFFFF是白色。如果一个span的颜色灰度值大于0.85同时字号又小于正文中位字号的60%那它极大概率是不可见水印。三层过滤都记录日志方便回溯。谁被删了、为什么被删、在哪一页被删全部写进JSON调试时一目了然。3.4 图片型水印和半透明水印图片型水印是另一个麻烦。文本提取阶段它完全隐形因为图片没有文本内容。但如果你做OCR或者后续把渲染后的页面交给多模态模型图片水印就会成为一个大噪声源它不仅可能被识别成一堆无意义的“文字”还会干扰模型对正文区域的注意力。处理图片水印我用的仍然是几何思路。水印图片的特征是覆盖范围大、跨页位置固定、透明度一般设置得很低。用page.get_image_info()可以拿到页面上每张图片的bbox再跨页比对位置。如果同一张图片在每一页都出现在同一个相对位置并且它的大小覆盖了页面中央大部分区域直接把它从页面上“剔除”。这里的剔除不是真的删掉图片而是在后续OCR和图像理解阶段把这个区域Mask掉不让模型和水印发生关系。半透明水印则稍微复杂一点。PDF的半透明通过ExtGState的CA和ca属性控制PyMuPDF无法在span级别直接给我们透明度值。所以项目里我对半透明水印不专门做透明度检测而是靠两招组合拳一是看颜色半透明水印通常伴随着比较浅的RGB值二是靠位置重复性只要位置特征符合重复区域就算透明度没拿到也照样过滤。我遇到的90%的水印都能靠“位置 颜色 字号 重复性”四个特征组合干掉。3.5 页眉页脚、页码一并处理页眉页脚和水印一样是RAG解析里常见的噪声源。每页顶部一行文档标题、底部一个页码看起来无伤大雅但切分成chunk后每个chunk都会带着同样的页眉页脚文字。embedding的时候这些重复内容会让不同chunk的向量产生虚假的相似度检索时非常容易误召回。我观察过一批有页眉页脚的文档检索命中率至少下降10个百分点。处理上我用一个更狠的规则页面顶部和底部各留出10%的边距在这个边距内的所有span除非是正文段落跨越了边距线这种情况极少否则全部视为页眉页脚候选。再结合和上一节相同的跨页重复性验证准确率非常高。页码就更好办了正则匹配纯数字、位于底部中心或两侧、跨页连续递增满足条件就删。因为页眉页脚、水印本质是一种“固定位置的重复文本”所以我在代码里把它们统一抽象成“repeated patterns”处理一套逻辑复用只是不同区域、不同阈值。4. 工程化落地与问题排查4.1 完整Pipeline怎么串多栏解析和水印清洗单独拿出来看都挺简单但合在一起就容易出一堆“相互作用”的问题。比如水印过滤要是把跨栏标题误删了多栏排序就会错位。所以我自己项目里的完整流程是固定的一步一步串下来每一步的输出都存成中间JSON第一步解析PDF逐页提取span级文本和bbox第二步做页面级的版面分析判断单栏/双栏、识别跨栏元素第三步多栏线性化生成整页阅读顺序文本第四步水印和页眉页脚清洗在全页文本流里做过滤第五步结构标记把标题、表格、图片这些特殊区域打上标签第六步按栏目和段落边界切分chunk第七步embedding入库。这个流水线里最关键的一个设计决定是水印过滤必须放在多栏线性化之前但可视化调试要在多栏线性化之后做。为什么因为多栏线性化会把跨页重复的页眉页脚文本移动到不同的位置水印检测的跨页位置特征就被破坏了。反过来调试的时候只看清洗前的原始版面图才能肉眼判断过滤规则是否误伤了正文。4.2 可视化调试别用眼睛直接看文本有一个经验我踩了三次坑才总结出来解析质量问题绝对不要靠“读抽取出来的文本”来验证。文本抽取后的顺序已经经过代码处理如果你看到的乱序你很难判断到底是排序逻辑的问题还是清洗逻辑的问题。正确做法是给每页画bbox调试图——把文本块的bbox直接画在原页面图像上每个块标上序号和预测的栏位。我用的是PyMuPDF自带的可视化能力渲染页面为图片然后把每个span的bbox画成矩形颜色按栏位区分左栏蓝色、右栏绿色、跨栏元素红色、水印区域灰色。这样一眼就能看出排序有没有错、水印有没有漏删、跨栏元素有没有被错误归属。调试图配合文本输出一起看效率翻倍。另外强烈建议所有关键解析结果都保存成JSON包含页码、block序号、span位置、文本、置信度。这些数据既是调试材料也是后续做解析质量评估的输入。质量评估不要只看“抽取率”还要看“版面还原率”——也就是预测的阅读顺序和实际阅读顺序的一致性。这块我目前是靠人工抽检抽检量不大但每个页面都要看到bbox图。4.3 常见问题速查表在实战里不同团队遇到的问题高度相似。我整理一个速查表可以直接对照排查现象可能的原因解决办法左右栏文字交替混在一起分栏判断失败把双栏页当成了单栏检查文本块x0分布确认是否因为跨栏元素过多导致聚类失衡调大跨栏宽度阈值同一栏内段落顺序乱序只按y0排序没考虑同一行内多个span的水平顺序排序键改成(y0, x0)组合对y0差值小于5的span按x0升序排列标题被分到某一栏里位置错乱跨栏元素识别阈值太严格如只认80%宽度降低跨栏宽度阈值到70%同时忽略图片块只对文本块计算宽度水印删了但正文出现小字缺失过滤阈值过严把注释、脚注、小字说明当水印删了增加位置黑名单校验只在重复区域且颜色浅的情况下过滤注释通常只出现一次不满足跨页重复条件每页开头多了一段重复文字页眉页脚过滤没有生效检查重复区域检测的min_ratio是否太高如果文档页数少于5页降低阈值到0.5图片水印干扰OCR结果没有做图片级Mask用get_image_info拿图片bbox跨页比对固定位置出现的图片做区域屏蔽解析耗时太长把整页渲染成图片再跑OCR能用文本层提取就绝不用OCR只在扫描件场景开OCR并用多进程并行旋转页面导致坐标错乱PDF某些页带旋转属性解析前统一处理page.rotation或者用page.rect.width和height动态适配坐标4.4 性能优化建议最后一个务实问题一批PDF几千页解析会不会很慢我的经验是如果都是文本型PDF有文本层只跑PyMuPDF提取和规则清洗单页大概在50到150毫秒之间瓶颈集中在get_text(dict)的大页面和复杂表格上。我通常用多进程按文件粒度并行一个文件一个进程避免GIL限制。内存方面逐页处理、不缓存整本书的span列表峰值内存能控制在500MB以内。如果遇到扫描件需要OCR那就完全是另一套思路了OCR单页就要1到3秒需要上一整个OCR并行集群。我在实际项目里还有一个优化细节对同一个PDF重复解析时可以把解析结果缓存成中间格式比如JSON Lines下次直接读缓存。知识库要更新时新增文件走解析旧文件直接复用缓存避免重复计算。写在最后的一些体会我做RAG文档解析算下来也有一年多了最强烈的感受是bbox这东西看着简单实际是版面理解的敲门砖。很多人做PDF解析第一步就是拿正则和规则去抽文本、抽表格忽略了文本在页面上的位置信息。结果就是结构化文档做得越多越发现纯文本提取的天花板低得可怜——翻页、分栏、页眉页脚、水印、表格嵌套任何一个稍微复杂的版面都能让规则崩溃。而一旦把视角从“提取文字”转成“理解版面”bbox就是那个打开局面的钥匙。有了坐标多栏可以线性化水印可以检测页眉页脚可以清除表格可以结构化甚至还能把版式信息喂给多模态模型做更高级的页面理解。这篇实战里给的代码和阈值都是能直接跑的但更重要的是把坐标意识和版面意识带进你的解析流程里。最后再分享一个心得任何排序和过滤规则上线前一定要跑一个几十页的样本把处理前后的bbox图放在一起人工扫一遍宁可慢一点也别让坏规则在全部文档上跑完才发现。