
做RAG的都知道文档解析最磨人的不是把PDF里的文字抽出来而是抽出来之后怎么用。尤其是那种排版复杂的PDF双栏排版、页眉页脚、表格插图、半透明水印每一步都可能把召回率拉低一截。处理这些问题的核心钥匙就是bbox——边界框bounding box。前两篇我们聊了文本提取和清洗这篇专门讲bbox在实战里的用法重点解决两类恶心场景多栏排版和水印PDF。先说清楚bbox能干什么。在PDF解析里每个文本块、图片、表格都有自己占据的页面矩形区域这个矩形就是bbox。拿到它你才能判断哪些文字属于同一栏、哪些文字被水印盖住了、哪些内容是需要过滤的噪声。很多同学提取文本直接上pdfplumber的extract_text()结果遇到双栏文章左右两栏串在一起语句完全没法看。换句话讲bbox不是锦上添花的花活是版面分析的地基。这篇内容不绕弯子直接上PyMuPDFfitz做演示因为它的page.get_text(dict)能让我们拿到每个span的bbox、字体、颜色信息非常全。适合正在搭RAG链路、对召回质量有要求、需要处理真实业务PDF的工程师。看完之后你应该能自己实现一套多栏切分和水印过滤的逻辑。1. 从需求倒推为什么RAG解析绕不开bbox1.1 纯文本提取的盲区如果你只是调用page.get_text(text)PyMuPDF会把页面文本按内部顺序拼接。很多PDF的文本顺序是文件生成时写入的顺序并不等于人类阅读顺序。双栏PDF里左栏的文字块可能和右栏的文字块按列顺序交错存储。直接提取的结果读起来就是左栏第一段右栏第一段左栏第二段右栏第二段……这在向量检索里非常致命。因为Embedding模型是按连续文本块编码的文本块里混杂两栏内容语义相关性被严重稀释召回出来的答案自然像是从两篇文章里各摘了一句拼出来的。1.2 bbox是版面分析的“坐标语言”处理这个问题第一步就是把文本从无序集合变成有序结构。每个span的bbox给了你四个数x0, y0, x1, y1。有了坐标就能判断哪些span的垂直投影重叠属于同一栏栏与栏之间的空白间隙在哪哪些span位于页眉页脚需要剔除哪些span颜色浅、字号大可能是一整页水印。所以bbox解决的本质上是空间关系推断。我习惯把解析流程拆成三步提取带坐标的文本单元 → 用坐标做版面分组 → 用分组结果重建阅读顺序。这样每一步都可以单独验证不用在写完一大坨代码后拿一个case死磕。1.3 解析脚本的通用框架先做一个最小的提取函数拿到每个文本块的坐标和内容。这是后续所有逻辑的起点import fitz def extract_bbox_spans(page: fitz.Page): 提取页面中所有span的bbox和文本信息 返回列表每个元素为 dict包含: - text: 文本内容 - bbox: (x0, y0, x1, y1) - size: 字号 - color: 颜色值 - font: 字体名 spans [] data page.get_text(dict, flagsfitz.TEXTFLAGS_TEXT) for block in data[blocks]: if block[type] ! 0: # 0是文本块1是图片块 continue for line in block[lines]: for span in line[spans]: text span[text].strip() if not text: continue spans.append({ text: text, bbox: tuple(span[bbox]), size: span[size], color: span[color], font: span[font], }) return spans注意这里用了TEXTFLAGS_TEXT它会过滤掉隐藏文本和不可见文本但不会过滤水印。水印往往是可见文本过滤逻辑得咱们自己写后面第三部分细说。2. 多栏排版PDF的解析实战2.1 多栏识别的核心思路判断一个页面是单栏还是双栏不需要上深度学习模型纯靠坐标投影就行。我采用的方法叫垂直投影空白检测。做法是把页面所有文本span的bbox在水平方向做投影统计每一列像素宽度上有没有文字覆盖。在双栏排版中栏与栏之间会有一条明显的垂直空白带。连续空白的宽度如果超过阈值比如页面宽度的5%~10%就认为这里是栏间隙。这个思路源自OCR里的版面分割实现起来很简单而且比用CNN快得多。对于RAG场景解析速度很重要我见过不少团队在PDF预处理上耗时几秒一页最后链路延迟根本扛不住。纯坐标法的好处是毫秒级完成准确率在日常办公文档上能到90%以上。具体实现分三步计算页面宽度page.rect.width初始化一个数组proj_width int(page.rect.width)默认值0遍历所有span把x0到x1区间内的投影值加1找到投影值为0的最长连续区间如果它的宽度超过阈值就作为栏间隙。def detect_column_gap(page_width, spans, gap_ratio0.05): # 初始化像素投影数组 proj [0] * int(page_width) for span in spans: x0, y0, x1, y1 span[bbox] x0 max(0, int(x0)) x1 min(int(page_width), int(x1)) for x in range(x0, x1): proj[x] 1 # 找出连续为0的最长区间 max_gap_start -1 max_gap_len 0 cur_start -1 cur_len 0 for x, val in enumerate(proj): if val 0: if cur_start -1: cur_start x cur_len 1 else: cur_len 1 if cur_len max_gap_len: max_gap_len cur_len max_gap_start cur_start else: cur_start -1 cur_len 0 if max_gap_len page_width * gap_ratio: return max_gap_start, max_gap_start max_gap_len return None这个函数返回栏间隙的左右边界。如果返回None就当单栏处理。这里有一个细节栏间隙的阈值不能设得太小否则会被行内空格干扰也不能设得太大否则遇到三栏版面或者左侧有窄边距的情况会漏检。我自己的实践值是gap_ratio0.04~0.06根据文档类型微调。2.2 用bbox做分栏后的排序检测到栏间隙后需要把每个span归入左栏或右栏。规则很简单看span的中心点x坐标落在间隙的哪一侧。def split_columns(spans, gap): if gap is None: return {single: spans} gap_left, gap_right gap left_col, right_col [], [] for span in spans: x_center (span[bbox][0] span[bbox][2]) / 2 if x_center gap_left: left_col.append(span) elif x_center gap_right: right_col.append(span) return {left: left_col, right: right_col}排序时先按栏分再在栏内按y坐标排序。这样双栏PDF的阅读顺序就变成了左栏从上到下然后右栏从上到下。对于三栏排版同样的逻辑可以扩展检测多个间隙然后按间隙切出多个区域。不过很多双栏PDF并不是标准的两栏到底。有的页面是标题跨栏正文分栏。标题横跨整页中心点在间隙中央会被上面这个逻辑丢掉。处理这个情况我一般先识别哪些span的宽度接近页面宽度比如宽度大于页面宽度的80%把它们当作标题单独保留。然后剩下的span再做分栏。最后把标题放在整页文本的最前面。2.3 阅读顺序还原的完整流程把上面这些组合起来就是一个完整的多栏解析流程提取所有span识别标题等通栏元素从普通span中分离检测栏间隙分栏并按y坐标排序按“标题 → 左栏 → 右栏”拼接文本。完整代码就不贴了核心就是上面几个函数串起来。这种做法的优势是每一步都可以通过可视化bbox验证对错。我习惯把每个span的bbox画出来存成图片def draw_bboxes(page, spans, output_path): page_pix page.get_pixmap(dpi150) pix page_pix # 画框用fitz的shape功能 shape page.new_shape() for span in spans: rect fitz.Rect(span[bbox]) shape.draw_rect(rect) shape.finish(color(1, 0, 0), fill_opacity0.1) shape.commit() page.get_pixmap(dpi150).save(output_path)画出来之后你会很直观地看到错误在哪。比如有的span被错误划到右栏是因为间隙检测偏了有的span被遗漏是宽度阈值设大了。可视化永远是调试版面解析的第一工具。3. 水印PDF的过滤处理3.1 水印文字的典型特征水印分为几类文字水印、图片水印、矢量水印。文字水印又分两种单行大字水印覆盖页面中间以及多行多列重复的小字水印。RAG解析中水印文字混进正文会污染Embedding向量导致检索到一堆重复的“机密”“内部资料”“样例”等垃圾内容。拿page.get_text(dict)拿到的span水印文字有非常明显的特征颜色浅水印通常用低透明度的灰色或浅色比如color值接近0x808080字号偏大或偏小单行大水印字号显著大于正文多行小水印字号又比正文小位置分布单行水印横跨页面中部多行水印则均匀分布在页面各个区域字体可能异常很多水印使用固定的字体甚至用斜体、旋转效果。但只靠颜色判断不靠谱有些文档正文也是灰色。只靠位置判断也不靠谱。我采用的策略是多特征投票每个特征给一个分数总分超过阈值才判定为水印。3.2 基于bbox样式的过滤策略先定义一个特征打分函数def is_watermark_span(span, page_width, page_height, page_rect): text span[text] bbox span[bbox] size span[size] color span[color] score 0 # 特征1: 颜色灰度且较浅 r (color 16) 0xFF g (color 8) 0xFF b color 0xFF if abs(r - g) 10 and abs(g - b) 10: # 灰色 if r 120: score 1 if r 180: score 1 # 特征2: 字号与正文差异大 # 这里假设正文size在9~12之间, 可以按实际情况调整 if size 7 or size 20: score 1 # 特征3: span宽度接近页面宽度, 且高度较小, 像横贯的水印条 span_width bbox[2] - bbox[0] if span_width page_rect.width * 0.8: score 1 # 特征4: 位于页面几何中心附近 span_cx (bbox[0] bbox[2]) / 2 span_cy (bbox[1] bbox[3]) / 2 if abs(span_cx - page_rect.width / 2) page_rect.width * 0.1: if abs(span_cy - page_rect.height / 2) page_rect.height * 0.1: score 2 # 特征5: 内容含有常见水印关键词 watermark_keywords [机密, 内部资料, 样例, draft, confidential, watermark, preview] if any(kw in text.lower() for kw in watermark_keywords): score 2 return score 3这个函数是启发式的不是万能药。但好处是灵活你可以根据自己语料的情况调整特征权重。我遇到过一种PDF水印是“本文档仅供参考”斜着排列颜色浅、字号小特征1、2、4命中足够判定了。3.3 多行多列文字水印的批量处理多行多列水印是最烦人的因为它的位置和正文大量重叠。这种水印常见于企业内部的制度文档、合同模板。比如A4纸上从左上到右下铺满三行四列“公司机密”字样。直接用文本内容过滤不行因为会误杀正文里的“公司机密”四个字。我的做法是先聚类再过滤。第一步把所有span的bbox拿出来做聚类。水印span的特征是它们均匀分布尺寸接近内容重复度高。我一般按字号和颜色聚类把size接近、color接近的span归为同一个簇。然后看每个簇的span是不是在页面上形成了规则网格。统计簇内span的x坐标间隔和y坐标间隔。如果间隔近似等距且簇内span数量超过一定值基本可以判定这是水印层。def is_repeated_layout(spans, page_rect): if len(spans) 4: return False sizes set(s[size] for s in spans) if len(sizes) 2: return False xs sorted((s[bbox][0] s[bbox][2]) / 2 for s in spans) ys sorted((s[bbox][1] s[bbox][3]) / 2 for s in spans) # 检查x间隔和y间隔的方差, 间隔稳定才认为是网格 def gap_stable(arr): if len(arr) 2: return False gaps [arr[i1] - arr[i] for i in range(len(arr) - 1) if arr[i1] - arr[i] 10] if not gaps: return False mean_gap sum(gaps) / len(gaps) variance sum((g - mean_gap) ** 2 for g in gaps) / len(gaps) return variance mean_gap * 0.5 return gap_stable(xs) and gap_stable(ys)这个函数是简化版真实场景里水印不一定是完全等距的所以我会把方差阈值放宽一点。聚类的另一个好处是可以批量拿到所有重复span然后一次性过滤不用每个span逐个判断。3.4 图片水印和矢量水印的补充处理文字水印搞定了图片水印是另一个坑。比如有些PDF用一张浅色的logo图做水印铺满整个页面。这种图在get_text里是看不到的只能从图片块里找。判断图片水印的特征主要有两个图片覆盖面积大、透明度高。PyMuPDF里可以通过page.get_images()拿到图片引用然后用page.get_image_rects()拿到图片在页面上的位置。如果图片rect覆盖了页面大部分区域且文档的Page.get_text(dict)里对应的图片块没有内容很可能是水印背景图。对于这种情况RAG解析时可以直接忽略该图片块不让它的文本通常没有或者视觉信息进入后续处理。如果有OCR识别的环节需要把水印图片从OCR的输入中排除。矢量水印更隐蔽通常是用PDF绘图指令画出来的。这种水印没有文本内容也没有图片资源只能通过渲染成位图后做像素分析。但RAG场景很少需要处理这级复杂度我一般把矢量水印当作背景忽略。4. 工程落地解析结果如何喂给切分器4.1 输出结构化表示多栏切分和水印过滤做完后解析结果不应该是一长串字符串而应该是带bbox的块列表。我建议用JSON格式保存[ { page: 1, block_type: text, bbox: [72.0, 100.0, 320.0, 180.0], text: 左边第一段内容..., order: 0 }, { page: 1, block_type: text, bbox: [360.0, 100.0, 600.0, 180.0], text: 右边第一段内容..., order: 1 } ]这个结构的好处是后续不管是按字符长度切块还是按版面语义切块都有据可依。比如你可以基于bbox判断两个文本块是否属于同一个段落如果它们的y坐标有重叠且间距很小就合并成一个句子。4.2 与向量化检索的协同在RAG链路里文本切分直接影响召回效果。我的经验是解析结果里不要只留纯文本要保留一个字段叫source_bbox。这样向量命中一段文本后能快速定位到原文位置方便做引用溯源和答案验证。切分时应该以“版面块”为最小单位而不是无脑按500字符切。因为Embedding模型对语义完整块编码更好。多栏PDF解析完之后每一栏的每个段落都是完整的语义单元按段落边界切分召回效果比按字符硬切好不少。我见过一个项目用简单的RecursiveCharacterTextSplitter切分双栏PDF文本结果很多chunk里一半是左栏内容一半是右栏内容top-1答案完全没法看。后来改成按栏内段落切分检索精度直接提升了20多个百分点。4.3 性能与缓存优化bbox解析属于CPU密集和内存密集操作尤其是一次性解析几百页的PDF。性能优化有三个方向减少无关操作不需要做OCR的时候别开OCRget_text(dict)的速度比OCR快两个数量级批量处理多进程PyMuPDF的GIL限制比较明显多线程拿不到加速效果要用multiprocessing池把页列表分发到子进程重复解析缓存同一份PDF在迭代开发时会被反复解析建议把解析结果存成pickle或Parquet避免重复计算。下面是一个多进程示例的骨架from multiprocessing import Pool def parse_page(args): pdf_path, page_index args doc fitz.open(pdf_path) page doc[page_index] spans extract_bbox_spans(page) gap detect_column_gap(page.rect.width, spans) cols split_columns(spans, gap) filtered filter_watermarks(cols) doc.close() return page_index, filtered with Pool(4) as pool: results pool.map(parse_page, [(pdf_path, i) for i in range(page_count)])注意fitz.open在子进程内部打开不要传document对象给子进程那个对象不是进程安全的。这个细节很容易踩坑。5. 常见问题与排查实录5.1 多栏检测漏检或误检排障的时候先看可视化结果。我之前遇到一个case双栏PDF的左栏很窄右栏很宽栏间隙只有不到20像素。默认的gap_ratio0.05根本检测不到。后来我把间隙检测阈值下调到0.02才勉强识别出来。还有个case是字体带旋转效果get_text(dict)里span的bbox是旋转后的包围盒用中心点判栏没问题但投影检测时会把旋转字体的横向占用算进去导致间隙不明显。这时候可以先过滤掉字号明显异常的span再做投影。5.2 水印过滤误杀正文水印过滤最大的风险是误杀正文。尤其是关键词匹配“机密”出现在正文里很正常。我的经验是关键词特征只能作为加分项不能作为一票否决项。必须结合位置、颜色、字号综合判断。曾经处理过一份合同正文里有一句话“本协议为机密文件”颜色是灰色的正好命中所有水印特征被当成水印滤掉了。后来我在规则里加了一个判断如果span的bbox和上下行span的bbox垂直方向紧密相邻说明它在段落流中则降低水印得分。因为水印span通常和正文span在y方向不会形成连续的行流。5.3 扫描版PDF怎么处理这篇文章主要处理文本型PDF。如果PDF是扫描件get_text(dict)拿不到文本必须先OCR。OCR返回的结果通常也是带bbox的比如PaddleOCR的rec_boxes所以多栏检测和水印过滤的逻辑同样适用只要把输入从span列表换成OCR结果列表。唯一要注意的是OCR结果的坐标系和PDF坐标系可能有差别。如果用了OCR所有bbox要以PDF页面坐标为基准校准。否则后续对齐原文位置时会像坐标错位一样乱成一团。5.4 常见问题速查表问题现象可能原因排查建议双栏文本被拼接栏间隙检测失败可视化投影数组调整gap_ratio标题丢失标题宽度被当作通栏但分栏逻辑没加判断检查通栏识别逻辑水印没滤掉文本颜色不是纯灰特征分数不够增加重复网格检测或渲染后像素判断正文被误杀规则过于激进加入行流上下文判断解析慢单线程处理大量页面改多进程缓存中间结果bbox坐标异常旋转span或字体信息干扰过滤非水平文本span5.5 几个实在的避坑心得想再强调几个经验性的东西。第一不要一上来就追求完美解析。RAG链路里解析质量的收益是边际递减的。先处理掉影响最大的多栏和水印其他边缘情况可以后续慢慢补。很多团队花几周调解析最后发现召回率提升不到3%不如把精力放在Embedding和检索策略上。第二规则本身就是一种可解释的模型。我见过很多团队遇到版面问题就想到训练一个版面分析模型结果标注成本巨大效果还不如简单的坐标规则。用bbox做解析最多几百行代码就能覆盖80%的常见场景而且是完全可控的。第三善用可视化调试。写版面分析代码不要只看输出文本一定要把bbox画出来。看一页图比你打一百行日志更有用。我每次开发都留一个debug_draw开关正式运行时关掉排查时打开。第四把过滤逻辑做成可配置的。不同类型文档的水印特征差异很大写死规则换一个语料就可能失灵。我在项目里把特征权重、关键词列表、阈值都放到一个配置类里调参不需要改代码。结尾说实话bbox实战这部分内容一开始看起来像是“小儿科”只要有坐标就行。但真正做起来你会发现坑全在细节栏间隙怎么算水印怎么不误杀坐标怎么对齐进程怎么用。这篇文章里写的每一个函数都是我实际在RAG项目里跑过、调过、暴力测试过的。如果你也在处理类似的问题可以直接拿这些代码改一改。最后分享一个我自己一直在用的心得解析结果一定要和检索效果绑定验证。别只看文本提取得干净不干净要看最终检索出来的答案对不对。有一次我把一份双栏PDF解析得特别完美每个槽都分得清清楚楚结果检索精度反而下降了。原因是按版面切块后每个块太短语义不够完整。后来加了段落合并逻辑同样版面精度才恢复正常。所以工具是死的指标是活的评估反馈才是最终的裁判。