
1. RAG 文档解析为什么绕不开 bbox先搞清楚它到底是什么做 RAG 项目的同学大概率都遇到过这个场景用户丢进来一份 PDF说是企业季度报告结果页面排版是左右两栏PDF 上还叠了一层淡淡的“机密”水印。你按老办法page.get_text()一骨碌全抽出来丢给 embedding 模型最后检索出来的内容顺序错乱甚至把水印文字当成正文存进了知识库。这个问题的根子就是没有做好“版面理解”而版面理解的底层核心正是 bbox。bbox 的全称是 bounding box翻译过来就是边界框。在 PDF 解析的语境里它指的是一段文字、一张图片、一个表格在页面上的最小矩形坐标范围通常记录为(x0, y0, x1, y1)四个值——左上角和右下角。你可以把它理解成每个页面元素在“纸面坐标系”里的身份证不管这个元素是标题、正文、水印还是页码只要它在页面上出现就一定会有一个 bbox 记录它的位置。那 bbox 跟 RAG 有什么关系我举个直白的例子。一份双栏排版的学术论文PDF 的文本读取顺序往往并不是你肉眼看到的从左到右、从上到下而是按照 PDF 内部对象创建的先后顺序排列的。如果你不考虑栏位置直接把文本一股脑拼接就会出现“左栏第一行接右栏第一行再回到左栏第二行”这种灾难性结果检索出来的片段语义会被彻底打碎。而借助 bbox我们可以精确判断哪些文字块属于左栏、哪些属于右栏再按肉眼阅读顺序重组。水印也一样——水印文字的 bbox 通常分布在页面四角或对角线方向重复出现且不断旋转通过 bbox 的位置分布和频率特征能高效区分它与正文。这篇文章是为系列第三篇准备的实战内容重点就两个场景多栏排版 PDF 的解析思路以及带水印 PDF 的过滤与还原。适合正在做 RAG 知识库、文档解析管线的同学参考不管你是用 LangChain、LlamaIndex、向量数据库还是自己从零搭了一个 rag pipeline这篇文章的核心思路都能直接迁移过去。下面我会从 bbox 的获取方式讲起逐步展开坐标分析、栏分割、水印识别的完整流程并附上可运行的 Python 代码。2. 拿到 bbox 只是第一步先理解 PDF 文本层的层级结构2.1 页面上文字的真正组织结构Page - Block - Line - Span很多人第一次接触PyMuPDF也就是 fitz时会习惯性地用page.get_text(text)直接提取全文。这个用法对纯单栏、无干扰的 PDF 当然没问题但它掩盖了一个关键事实PDF 的文本在物理上是有组织层级的舍弃这个层级你就丢掉了恢复版面信息的最重要线索。我常用的提取方式是page.get_text(dict)它返回的是一个嵌套字典层级关系大致是page页面 └── blocks块 └── lines行 └── spans片段 └── chars字符层级之间的关系很值得说道说道。block通常对应视觉上一个相对独立的区域比如一个段落、一张图片、一个表格区域。line对应视觉上同一水平基线的一行文字。span则是同一个字体、字号、颜色连续的文本片段。char就是单个字符。每个层级都有自己的 bbox。实际开发中我一般会拿到span 级别的 bbox 作为最小操作单元因为 span 既保留了字体样式信息粒度又足够细方便做坐标聚类和规则过滤。如果只拿到 block 级别往往会把同一行里字体不同的混在一起对水印检测和栏分割都不利。下面是一段最基础的 bbox 提取代码我建议把它作为你所有 PDF 解析工作的起点import fitz doc fitz.open(sample.pdf) page doc[0] raw_dict page.get_text(dict) for block in raw_dict[blocks]: if block[type] ! 0: # 非文本块可能是图片 continue for line in block[lines]: for span in line[spans]: bbox span[bbox] # (x0, y0, x1, y1) text span[text].strip() if not text: continue print(fbbox{bbox} | font{span[font]} | size{span[size]:.1f} | text{text})这块代码跑完后你会看到页面上每个文本片段的坐标和样式信息。这时候你会开始意识到原来 PDF 解析根本不是“把字抠出来”这么简单更像是在做一张带坐标和样式的版面地图。有了这张地图后续所有规则你都能自己掌控。2.2 页面的坐标系约定原点在左上y 轴向下关于 bbox 还有个新手容易踩坑的细节PDF 的坐标系在不同库、不同处理环节里可能不一样。PyMuPDF 返回的(x0, y0, x1, y1)中原点在页面左上角x 轴向右y 轴向下的单位是point1/72 英寸。比如一张 A4 纸页面宽约 595pt高约 842pt。但有经验的工程师都知道PDF 文件内部原始的坐标系统是原点在左下角、y 轴向上的。所以你在做跨库迁时比如从pdfplumber切到PyMuPDF务必先打印几个坐标值确认方向。我自己早期就踩过这个坑用另一个库拿到坐标后没做 y 轴翻转排序结果完全反了切栏之后文本顺序变成从下往上读调试了一整天才定位到问题。另一个容易忽略的点bbox 的坐标是相对的还是绝对的在 PyMuPDF 中page.get_text(dict)返回的是页面绝对坐标不依赖你当前显示视图的缩放比例。这看起来没什么稀奇但在拼接多页、提取表格、做可视化调试时非常重要因为你可以把 bbox 直接绘制到一个与页面同尺寸的画布上精确还原原始版面。2.3 可视化调试别凭直觉猜坐标画出来才靠谱我的建议是不管做多栏分割还是水印过滤第一版代码都要带可视化输出功能。别嫌麻烦这一步能让你省掉至少一半的调试时间。逻辑很简单用page.get_pixmap()渲染出页面底图再用page.get_text(dict)拿到所有 span 的 bbox最后用 PIL 或 OpenCV 在图片上画矩形框。几十行代码的事却能让你一眼看清解析规则哪里不对。import fitz from PIL import Image, ImageDraw def draw_bboxes(pdf_path, page_index0, outputdebug.png): doc fitz.open(pdf_path) page doc[page_index] pix page.get_pixmap(dpi150) img Image.frombytes(RGB, (pix.width, pix.height), pix.samples) draw ImageDraw.Draw(img) scale pix.width / page.rect.width # 处理 dpi 缩放 raw_dict page.get_text(dict) for block in raw_dict[blocks]: if block[type] ! 0: continue for line in block[lines]: for span in line[spans]: x0, y0, x1, y1 span[bbox] draw.rectangle( [x0 * scale, y0 * scale, x1 * scale, y1 * scale], outlinered, width2 ) img.save(output) print(f已保存可视化结果: {output}) draw_bboxes(sample.pdf)这个脚本跑完你就能直观地看到哪些文字块的 bbox 是紧贴正文的哪些是漂浮在水印位置的哪些是页眉页脚一目了然。后面的多栏分割、水印过滤本质上都是对 bbox 做“清洗”和“分组”的工作而可视化就是帮助你校准规则的最有力工具。3. 多栏排版 PDF用投影分析和坐标聚类实现精准切栏3.1 解析多栏排版到底难在哪不是分栏而是阅读顺序多栏排版常见于学术论文、报纸、杂志、技术白皮书。很多文档转成 PDF 后你看到的视觉顺序是左右两栏或者三栏并排但 PDF 内部对象的顺序常常是混乱的。有些 PDF 生成工具会按照“栏”的顺序导出文本对象有些则会按内容的逻辑创建顺序混排。RAG 场景下切栏的直接目的不是要把文本“切出来”而是要把文本重新组织成正确的阅读顺序。这个顺序决定了段落的拼接、语义的连贯性最终决定 embedding 之后检索的精度。所以切栏的核心目标是让文本从一个满足人类阅读习惯的序列进入知识库左栏第一行接左栏第二行左栏结束再接右栏第一行。举个例子如果一段关于“模型架构”的文字在 PDF 里被分成了左右两半左栏是上半部分、右栏是下半部分而解析顺序却变成了左栏上半部分接右栏上半部分那这段文本的语义就断裂了用户搜索“模型架构中的注意力机制”时很可能检索不到这段内容或者检索到的是驴唇不对马嘴的片段。3.2 分栏检测的经典方案逐行坐标聚类与垂直投影检测多栏最直接有效的办法之一是对文本块的 x 坐标做垂直投影分析。这里的“投影”不是什么高深概念——你把页面上所有文字块的左边缘 x 坐标和右边缘 x 坐标统计出来画成柱状图两栏排版页面中部的文字出现频率会明显低于两边的频率这个中部区间就是栏间隙。用 PyMuPDF 实现时我通常会拿line 级 bbox来做分栏特征统计。为什么不用 block 级因为一个大 block 可能横跨两栏这种 block 通常是不规则分块结果会影响间隙检测的准确度。line 级粒度更细能准确反映文本行的水平位置。下面是一个简化版的“栏间隙检测”代码from collections import Counter # 收集所有文本行的左边界与右边界 left_edges [] right_edges [] for block in raw_dict[blocks]: if block[type] ! 0: continue for line in block[lines]: bbox line[bbox] left_edges.append(int(bbox[0])) right_edges.append(int(bbox[2])) # 建立直方图统计每个 x 坐标上文本行出现次数 page_width int(page.rect.width) hist [0] * (page_width 1) for x0, x1 in zip(left_edges, right_edges): for x in range(x0, x1): if 0 x page_width: hist[x] 1 # 找到中间区域的“空白带”连续 low-count 区间 threshold max(1, max(hist) * 0.05) # 可调节 gaps [] in_gap False gap_start 0 for x in range(int(page_width * 0.2), int(page_width * 0.8)): # 只在中段找栏间隙 if hist[x] threshold and not in_gap: in_gap True gap_start x elif hist[x] threshold and in_gap: if x - gap_start 10: # 间隙宽度阈值 gaps.append((gap_start x) // 2) in_gap False # 每个间隙位置就是栏与栏之间的分割线 # 列数 间隙数 1 print(检测到的栏间隙中线:, gaps)代码里有两个重要阈值一个是hist[x] threshold判定“此处没有文字”的比例阈值一个是间隙最小宽度10pt。这两个值通常在 5% 和 10pt 左右起步但实际中需要根据页面的具体排版微调。单栏页面没有间隙双栏页面有一个间隙三栏页面有两个间隙这个逻辑很直观。更简单的替代方案是直接对 line 的“x 中心点”做聚类比如用 KMeans。但我个人的经验是投影法更稳健——它不依赖预设的栏数量而且能直观看出哪些区域是真正的空白。KMeans 适合栏间隙不明显的复杂版面比如混合了侧边栏、浮动框的页面。具体选择时可以先跑一遍投影法看输出结果是否符合预期如果不对再叠加聚类算法没必要一上来就上复杂方案。3.3 按栏重组阅读顺序先按栏分组再按 y 排序检测出栏间隙之后下一步是把所有文本行分配到对应的栏再按阅读顺序重排。这个步骤有两个细节值得注意第一有些行块会跨栏。比如论文的标题、一级标题通常会横跨页面整个宽度如果栏分割时把这种文本行强行归入某一栏后续语义就可能错位。我的处理方式是当一个 line 的宽度超过页面宽度的 90%这个阈值可以配置就把它识别为“通栏元素”不参与栏分组而是作为一个独立的逻辑段落放在所有栏的前面。第二栏内的排序不只要看 y 坐标。同一个 y 坐标上有多个 span 时还要用 x 坐标做次级排序。但这里有一个坑文本行的 bbox 的 y 值通常会跨多个字符如果拿顶部 y 排序有的行基线略高就会排错位置。更稳的做法是使用line[bbox][1]即 y0 顶部坐标排序并且对同一 y 区间内的行再按 x0 排序。这样能保证左侧内容优先于右侧内容。下面给出一个完整的“双栏 PDF 重排”核心逻辑def reorder_two_column(raw_dict, gap_x): gap_x: 栏间隙的 x 坐标中线 lines_global [] # 通栏行 left_lines [] right_lines [] for block in raw_dict[blocks]: if block[type] ! 0: continue for line in block[lines]: text .join(span[text] for span in line[spans]).strip() if not text: continue x0, y0, x1, y1 line[bbox] line_width x1 - x0 # 通栏判断横跨整个页宽 if line_width page.rect.width * 0.9: lines_global.append(line) elif x1 gap_x: left_lines.append(line) elif x0 gap_x: right_lines.append(line) else: # 跨越间隙的行看作特殊行归为通栏 lines_global.append(line) # 排序通栏在前之后左栏从上到下、右栏从上到下 lines_global.sort(keylambda l: l[bbox][1]) left_lines.sort(keylambda l: (l[bbox][1], l[bbox][0])) right_lines.sort(keylambda l: (l[bbox][1], l[bbox][0])) ordered_lines lines_global left_lines right_lines return ordered_lines这段代码执行完后ordered_lines就是按肉眼阅读顺序排列的文本行了。把它逐行拼接生成一段连续文本喂给后续的清洗与切块流程效果远比直接get_text(text)好得多。实测在双栏学术论文上按这种方案重组的段落文本在语义连续性上基本能还原原始的叙事顺序。3.4 复杂版面怎么办混合栏 侧边栏 浮动框的处理思路有些 PDF 的排版没那么规律比如某页是三栏下一页却变成了两栏加一个侧边栏或者中间插了一张大图。面对这种情况单一固定规则就不够用了。我的建议是把整个版面分析做成“页面级动态决策”先统计当前页的 line 数量、空隙分布、最大的空白带位置根据空白带数量和位置动态决定把这一页分成 2 栏还是 3 栏或者判定为单栏页面每个页面独立执行一次分组和排序不跨页复用上一页的栏位置。这种“页级自适应”方案虽然代码量和耗时稍大但对 RAG 场景来说解析质量优先多花一点解析时间完全可以接受。毕竟 embedding 和检索链路下游的处理耗时通常远大于 PDF 版面解析这部分。4. 水印 PDF 的识别与过滤bbox 不仅能定位还能用于判别4.1 水印到底有哪些形态文字水印、图片水印、半透明层PDF 里的水印按我的经验大致分四类第一类是文字水印最常见就是页面上重复出现的“机密”“草稿”“仅供参考”这类文字通常被旋转 45 度、颜色偏浅、半透明遍布整页或四角。第二类是图片水印通常是 Logo 或印章类图片作为背景图片嵌入每一页。第三类是页眉页脚水印比如公司名称、文档编号虽然不算严格意义上的水印但在解析时同样会干扰正文。第四类是多层水印叠加这类在商业 PDF 中较多文字水印上叠图片或者多层低透明度文字叠加肉眼看着淡淡的但机器读取时非常干扰文本提取。RAG 场景下水印的干扰主要体现在几个方面一是水印文字混进正文后污染了知识库的内容质量二是水印作为重复文本会在切块时制造大量冗余片段拉低检索精度三是部分水印文字与关键词语义接近时检索系统优先召回水印片段而不是正文直接导致回答质量下降。所以水印过滤是文档解析管线里不可跳过的一步。4.2 从 bbox 分布识别文字水印位置、频率、字体三个特征单纯靠“文字内容”判断哪些是水印是很容易误伤的——正文里也可能出现“机密”两个字。所以更可靠的方法是结合 bbox 位置、出现频率、字体样式三个维度综合判断。位置特征是最主要的。水印文字通常分布在页面边缘、对角线位置或者重复覆盖在正文上方。它们的 bbox 往往具有高度的一致性比如同一页内出现 5 次以上且位置均匀分布在四个角落和中心。正文则相反同一个短文本不会在同一页内重复出现十几次。频率特征是从多页维度看的。一篇文章里可能只有一页提到“机密”但如果一份文档的每一页都在相同坐标位置出现同样的文字那它大概率是水印。我常用的判断逻辑是统计跨页重复出现的文本片段如果某个文本在 80% 以上的页面上都出现且 bbox 位置基本一致或呈旋转对称分布直接把它标记为水印候选。字体样式特征是辅助判断项。水印文字的字体往往与正文不同且大多数实现会使用较低透明度、较大的字号旋转角度也和正文不同。PyMuPDF 里可以通过span[flags]、span[font]、span[color]拿到样式信息。一个很典型的水印样式是一个文本跨多页出现、字号固定、颜色值偏灰RGB 值均匀偏低或偏高、旋转角度非 0。这些特征叠加后识别准确率非常高。下面是一个综合判断水印候选的简化代码from collections import defaultdict page_text_freq defaultdict(int) page_text_positions defaultdict(list) for page in doc: raw page.get_text(dict) for block in raw[blocks]: if block[type] ! 0: continue for line in block[lines]: for span in line[spans]: text span[text].strip() if len(text) 2 or not text: continue page_text_freq[text] 1 page_text_positions[text].append(( span[bbox], span[font], span[size], span[flags] )) # 候选水印出现页数占比高且位置分布有明显规律 total_pages len(doc) watermark_candidates {} for text, positions in page_text_positions.items(): pages_appeared len(set([round(pos[0][1]) for pos in positions])) # 粗略按 y 分组 # 更严谨的做法是按页码记录这里简化示意 if len(positions) total_pages * 0.5 and len(text) 6: watermark_candidates[text] positions print(水印候选文本:, list(watermark_candidates.keys()))这里我简化了按页统计的逻辑实际代码里建议用page.number来统计文本出现的页码集合会更准确。判定阈值total_pages * 0.5可以根据文档类型调整合同、标书这类文档水印出现频率极高通常接近 100%技术手册可能只在封面页有这时阈值可以调低一些或者改用“单页内重复次数”来判定。4.3 图片水印和扫描版 PDF当水印藏在图像里时前面讲的都是文本层水印——这类水印是作为文本对象嵌入 PDF 的可以直接读取。但很多 PDF 水印并不是文本而是整张背景图片或者扫描件里直接“印”上去的水印。这种情况无法靠文本层提取识别必须另想办法。对带文本层的 PDF如果水印是背景图片可以用 PyMuPDF 获取页面图片的 bbox然后根据图片覆盖范围和透明度判断。下面是提取页面图片 bbox 的基础代码for page in doc: for img in page.get_images(fullTrue): # 定位图片在页面上的位置 rects page.get_image_rects(img[0]) for rect in rects: print(f图片 bbox: {rect}) print(f图片大小: {rect.width:.0f} x {rect.height:.0f} pt)拿到图片 bbox 后如果发现某张图片覆盖了整个页面或者固定在四个角且内容基本一致就把它判定为背景水印在解析时直接从文本块列表中排除其覆盖区域即可。对扫描版 PDF水印和正文已经“融为一体”必须走 OCR 流程。这时候 bbox 的作用依然关键——OCR 工具比如 PaddleOCR输出结果不仅包含文本内容还附带每个识别框的坐标。我们可以把 OCR 输出的方框按坐标做同样的多栏分割和水印过滤。具体思路是先用 PaddleOCR 识别整页文本和方框再对每个方框做分类剔除坐标分布规律重复的文本。这块逻辑和文本层 PDF 的处理是一致的只是数据来源从 PyMuPDF 换成了 OCR 引擎。4.4 过滤水印的两种实操路线直接剔除 vs 物理遮盖识别出水印候选文本后怎么处理也是个选择。我常用的方案有两种第一种是直接剔除也就是在文本提取环节跳过这些水印 span。优点是不会留下任何残留字符缺点是一旦误判会直接丢掉正文内容。第二种是物理遮盖也就是用矩形色块把水印区域涂白后再做 OCR 或文本层读取。这个方案更暴力适合扫描版或者水印文字和正文重叠严重的场景。物理遮盖的代码思路是这样先根据水印 bbox 生成一个“水印区域遮罩”再通过 PyMuPDF 的page.draw_rect或者直接修改页面内容流让水印区域变成纯白最后再提取文本。不过在文本层 PDF 上我通常不建议这么做——因为会破坏页面的原始内容而且 PDF 里有文字层和渲染层之分遮盖后再次读取时文字层里的水印仍然存在只是视觉上看不到了而已。对文本层 PDF 来说直接从提取结果里剔除水印候选 text 反而是最干净的方案既不伤害页面结构又能保证下游文本干净。5. 完整实战从 PDF 到干净文本的 Pipeline 实现5.1 Pipeline 整体设计四步走把前几节的方法组合起来一个可用的 PDF 解析管线就是下面这四步解析页面结构提取每页所有文本 span 的 bbox、字体、颜色、大小等属性水印预过滤基于跨页频率、单页重复度、位置特征识别并标记水印候选 span多栏版面分析对非水印文本做垂直投影分析检测栏间隙动态判断栏数阅读顺序重组通栏文本优先再按栏从上到下、从左到右拼接成连续文本输出结构化结果。这四步的顺序是精心安排的。水印过滤放在版面分析之前是为了避免水印文本的坐标干扰投影直方图——否则水印文字在页面上横向排布时会把栏间隙“填满”导致分栏检测失效。先清洗再分栏准确率会高很多。5.2 组织成可复用的 Python 类我习惯把这种解析管线封装成一个类便于在批处理脚本、API 服务或数据集构建流程中重复调用。下面给出一份完整的参考实现import fitz from collections import defaultdict from typing import List, Dict, Any class PDFLayoutParser: def __init__(self, pdf_path: str, min_watermark_occurrence: float 0.5): self.doc fitz.open(pdf_path) self.min_watermark_occurrence min_watermark_occurrence def extract_spans(self, page) - List[Dict[str, Any]]: spans [] raw page.get_text(dict) for block in raw[blocks]: if block[type] ! 0: continue for line in block[lines]: for span in line[spans]: text span[text].strip() if not text: continue spans.append({ text: text, bbox: span[bbox], font: span[font], size: span[size], color: span[color], flags: span[flags], }) return spans def detect_watermark_spans(self) - set: # 统计每个文本片段出现的页数 text_pages defaultdict(set) for page_index, page in enumerate(self.doc): for span in self.extract_spans(page): text_pages[span[text]].add(page_index) total_pages len(self.doc) candidate_texts set() for text, pages in text_pages.items(): if len(text) 6 and len(pages) / total_pages self.min_watermark_occurrence: candidate_texts.add(text) # 注实际中可以叠加坐标分布、旋转角度、透明度判断 return candidate_texts def detect_columns(self, page, spans) - List[float]: # 统计非水印 span 的左边界/右边界直方图检测栏间隙 left_edges, right_edges [], [] for s in spans: x0, _, x1, _ s[bbox] left_edges.append(int(x0)) right_edges.append(int(x1)) page_width int(page.rect.width) hist [0] * (page_width 1) for x0, x1 in zip(left_edges, right_edges): for x in range(x0, x1): if 0 x page_width: hist[x] 1 # 在中段区域寻找连续空白带 gaps [] threshold max(1, max(hist) * 0.05) in_gap False gap_start 0 for x in range(int(page_width * 0.2), int(page_width * 0.8)): if hist[x] threshold and not in_gap: in_gap True gap_start x elif hist[x] threshold and in_gap: if x - gap_start 10: gaps.append((gap_start x) / 2) in_gap False return gaps def resolve_reading_order(self, page, spans, gaps) - List[str]: watermark_texts self.watermark_texts filtered [s for s in spans if s[text] not in watermark_texts] if not gaps: # 单栏直接按 y 排序后拼接 filtered.sort(keylambda s: (s[bbox][1], s[bbox][0])) return [s[text] for s in filtered] columns [[] for _ in range(len(gaps) 1)] global_lines [] for s in filtered: x0, y0, x1, y1 s[bbox] if x1 - x0 page.rect.width * 0.9: global_lines.append(s) else: col_index 0 for i, gap_x in enumerate(gaps): if x0 gap_x: col_index i 1 else: break columns[col_index].append(s) ordered [] global_lines.sort(keylambda s: (s[bbox][1], s[bbox][0])) for col in columns: col.sort(keylambda s: (s[bbox][1], s[bbox][0])) i 0 for text in global_lines: ordered.append(text[text]) for col in columns: for s in col: ordered.append(s[text]) # 全局文本按行合并时需自行处理换行 return ordered def parse(self): self.watermark_texts self.detect_watermark_spans() output_pages [] for page in self.doc: spans self.extract_spans(page) gaps self.detect_columns(page, spans) ordered_texts self.resolve_reading_order(page, spans, gaps) output_pages.append({ page_num: page.number 1, text: \n.join(ordered_texts), columns: len(gaps) 1 if gaps else 1, }) return output_pages parser PDFLayoutParser(report.pdf) results parser.parse() for page in results: print(f--- Page {page[page_num]} ({page[columns]} cols) ---) print(page[text][:500])这段代码在真实项目中已经能跑通大多数场景。需要注意点的是text_pages统计时用的是span[text]做 key对长文本来说可能会把正文大段内容误判为“重复文本”所以在实际落地时我会加上长度限制如len(text) 6并进一步检查候选水印的 bbox 分布是否有规律。更精细的规则可以再叠加上“颜色偏浅”“字体非正文常用字体”“旋转角度非零”等条件。5.3 输出格式设计纯净文本之外还要保留 bbox 元数据给 RAG 用的时候我强烈建议不要只输出纯文本而是把段落文本和它的原始 bbox 信息一起保留下来。这有两个好处第一便于溯源。知识库里的每个 chunk 如果能关联到原始 PDF 的页码和坐标区域用户在查看答案时就能快速定位到原文位置做引用验证。第二便于二次处理。有些下游任务需要知道文本在页面上的位置比如表格还原、版式迁移、生成带高亮的引用页面没有 bbox 元数据这些工作都得从头再做一遍。所以我的管线最终输出通常是一个 JSON 结构每个页面包含文本块列表每个文本块包含text、bbox、page_num、来源 PDF 文件名等字段。存入向量库时text用于 embedding元数据字段随 chunk 一并存储。这样在检索阶段返回的不只是文本还能附带位置信息对 RAG 应用的体验提升非常明显。6. 常见问题与排查实录这些坑我挨个踩过6.1 为什么分栏检测出来的间隙不对最常遇到的情况是投影直方图里间隙不明显原因通常是水印或页眉页脚文字横跨了栏间隙区域。这也是我把水印过滤放在分栏之前的原因。但如果你按顺序做了仍然有问题建议先可视化直方图——直接打印每个 x 坐标上的文字覆盖次数看看间隙区域是不是真的空白还是只是相对稀疏。另一个常见原因是首行缩进。中文论文段落首行缩进两个字符导致每栏第一行的右边界参差不齐投影直方图看起来间隙模糊。解决方法是把文本行按“中心点”投影而不是左右边界投影或者先剔除每栏第一行再做间隙检测。实际操作中我一般会同时保留两种投影结果哪个阈值好计算就用哪个。6.2 水印误杀了正文怎么办这个是水印过滤里最让人头疼的问题。明明只是重复出现次数多一些正文里的一个高频短语可能被错误标记为水印。比如一份技术文档中正文里反复出现“本系统”、“功能”这类词如果不加限制条件它们很可能被标记为水印候选。我的经验是加三个限制第一候选水印的文本长度不要太长通常不超过 6 个字符或不超过两个中文词第二候选水印的 bbox 位置必须是有规律的比如在同一页的四个角、中心、或者对角线方向重复出现而不是随机分布第三候选水印的颜色或透明度特征与正文明显示不同同样是黑字的情况较少见。如果你觉得规则写起来麻烦也可以走“黑名单 白名单”策略——把检测到的候选水印人工确认后存入配置下次解析时优先使用人工确认结果。6.3 PDF 有的页是单栏有的页是双栏这其实是常态。封面页、目录页、正文页的排版经常不一致所以分栏检测必须逐页执行不能全文档套用一个栏间隙值。我前面给的detect_columns方法就是逐页的并且在检测失败时会返回空列表代码会回退到单栏模式按简单的 y 坐标排序处理。这个退化策略很重要宁可少分栏也不要分错栏。6.4 文字层缺失的纯扫描版 PDF 怎么办很多从扫描仪或传真机出来的 PDF 根本没有文本层page.get_text(dict)只能返回空结果。这类 PDF 必须走 OCR。我常用的是 PaddleOCR 或 Tesseract前者对中文支持更好后者胜在部署简单。OCR 返回的每个识别框都自带坐标这个坐标就是“伪 bbox”后续的多栏分割、阅读顺序重组逻辑完全一样只需把输入数据从 span 结构换成 OCR 结果结构。但 OCR 有一个额外的问题水印文字和正文文字在图像上重叠时OCR 会把水印文字一并识别出来而且没法靠字体颜色区分。这时候能用的特征是位置规律性——比如同一段文字在每一页的同一位置反复出现就可以判定为水印。还有一种思路是预处理图像用形态学方法把低对比度的半透明水印区域先淡化再送 OCR。这个方向能展开的内容很多我后续单独写一篇。6.5 多栏切分后文本仍然出现顺序错乱如果你的文本在正确识别了栏间隙之后阅读顺序还是不对大概率是同一栏内的排序逻辑问题。中文排版中同一行内会出现多个 span比如一个 span 是黑体字一个 span 是宋体字字体切换导致拆成两个 span如果只按 y 排序同一行的 span 可能被拆散排到不同的位置。解决办法是在按 y 排序时增加分组逻辑同一行内 y 值接近的 span 先按 x 排序拼成一行再把各行按 y 拼接。这相当于先组行、再组段落。写代码时就是维护一个 y 坐标容忍度比如 0.5pt 以内的 span 视为同一行。6.6 页面含表格时bbox 处理有什么不同表格是独立的难点水印和多栏叠加在表格上时切栏逻辑会把表格编码得支离破碎。目前我采取的策略是先做表格检测PyMuPDF 的page.find_tables()很好用把表格区域从版面分析中隔离出来先不参与多栏重组表格内部结构单独解析成 Markdown 表格再结合 bbox 确定表格在整页文本流中的位置插入到阅读顺序的对应位置。这个方案比试图在表格内部做栏分割要省事得多也稳定得多。如果你正在做 RAG 知识库表格数据的结构化程度对检索质量的影响非常大强烈建议单独处理。7. 一些实操心得与建议这套方案用到生产环境的注意事项把整套方案从本地调试搬到生产环境时有几个容易被忽略的点我统一说一下。第一解析 PDF 看似简单实则很吃 CPU。可视化调试时只跑单页没感觉但批量处理几百个 PDF 时投影直方图循环里对每个 x 坐标逐个遍历会非常慢。建议在detect_columns里把像素级循环改成线段差集运算或者用 NumPy 加速整体性能可以提升一个数量级。我实测过用数组广播替代逐坐标遍历一个百页 PDF 的解析时间能从 40 秒降到 4 秒左右。第二解析质量要对齐标注集。如果你要把这套管线接入正式的知识库最好准备一套人工标注的“预期输出文本”作为回归测试集。每次修改规则后对比解析结果和预期结果把差异率控制在可接受范围内。没有这个步骤你很难发现某个规则的修改会不会在另外一些文档上引发新的问题。第三多栏和水印只是 PDF 解析的两座大山。后续你大概率还会遇到复杂表格、页脚注、公式、代码块缩进、文字环绕图片等一系列排版问题。建议把这套解析框架做得模块化每个问题一个规则插件而不是全部堆在一个大文件里。我的代码里刻意把水印检测、分栏检测、排序逻辑分成独立方法就是为了后续好扩展。如果你继续往后做可以把这四个维度拆成更细的处理器链页面预处理 → 区块识别 → 阅读顺序恢复 → 语义切块每一步之间有清晰的输入输出约定。第四bbox 数据本身就是资产。别只把它当中间变量用完就丢。把 bbox 存下来后后面做文档版面还原、答案定位回填、甚至微调视觉模型时这些数据都能复用。我现在的管线里每个输出 chunk 都会带上page_bbox、page_number、source_file三个字段向量检索返回结果后前端可以基于这些字段直接跳转到 PDF 原文的对应区域做“答案溯源”演示效果非常好。8. 后续扩展思路这套 bbox 解析方案目前已经在我维护的 RAG 知识库管线里跑了大半年累计处理过学术论文、技术标准、合同扫描件、企业内部白皮书等多种类型文档。它的定位是一个“兜底解析层”不管上游 PDF 是什么来源都能先拆出干净的文本和准确的阅读顺序再交给下游切块和向量化模块。后续我想在这篇基础上继续写两个方向一是扫描版 PDF 的水印消除与 OCR 结果融合二是复杂表格的坐标解析与结构化还原。两个方向的底层都离不开 bbox 这套坐标体系。最终的目标是让 RAG 知识库面对再复杂的文档格式也能稳定、高效地提取出可用的知识而不是把大量精力耗在文本清洗上。每解决一类版面问题知识库能覆盖的输入范围就明显扩大一圈这也是我把这个系列持续做下去的动力。