做RAG文档解析做到第三期,前面聊完PDF基础提取和表格还原,今天集中火力解决两个让解析结果“惨不忍睹”的典型场景:多栏排版PDF和带水印的PDF。这俩问题在真实业务里出现频率极高——期刊论文、报纸、制度汇编、扫描版红头文件,随便一个就能把单纯的文本抽取打回原形。而破局的钥匙,就是bbox,也就是每个文字块、字符、图片在页面上的边界框坐标。
这篇文章不聊大模型怎么调参,也不聊向量库怎么选,只聊一个很多人忽略但极其关键的中间层:用bbox做“物理布局感知”的解析。看完你就能自己处理双栏论文的乱序问题,能把“机密”“仅供参考”这类文字水印自动过滤掉,还能把处理好的干净文本接进chunk和embedding流程。适合做RAG落地、文档解析工具开发、数据清洗的同学参考。
1. bbox坐标是RAG解析绕不开的“物理层”
1.1 bbox的格式与坐标系:先弄清原点在哪
bbox的全称是bounding box,本质上就是一个矩形框,用四个数字描述:左上角x坐标、左上角y坐标、右下角x坐标、右下角y坐标。在PDF解析里,最常见的是(x0, y0, x1, y1)这种表示法,但坑就在“y轴方向”。
PDF的坐标系原生是左下角为原点,y轴向上。也就是说,y值越大,位置越靠上。但人眼习惯的是屏幕坐标系,y轴向下,y值越大越靠下。pdfplumber处理得非常贴心,它在word对象里给出了top、bottom这类字段,其中top是从页面顶部往下计算的距离,数值越大越靠下。PyMuPDF则比较直白,get_text("dict")返回的bbox直接按PDF坐标系来,排序的时候你得自己做一次y轴翻转。
这个差异看着小,但真实项目中十个有八个乱序问题都出在这:按y坐标排序时方向搞反,结果正文从上到下变成了从下到上,比不排序还难看。建议统一封装一层坐标工具,把不同库的坐标全部转成“以页面左上角为原点、y轴向下”的统一形式,后续所有排序逻辑都基于这个标准坐标系,就不会反复踩坑。
提示:多栏重排、水印过滤、表格区域排除,本质上都是对bbox做集合运算。坐标统一是第一步,这一步混乱,后面全部白搭。
1.2 为什么解析结果乱序?内容流顺序不等于阅读顺序
很多初学者以为PDF里的文字顺序和视觉顺序一致,extract_text()出来的文本就是从第一行到最后一行。事实远非如此。PDF文件的内容流是一串绘制指令,文字对象按什么顺序写入内容流,完全取决于生成PDF的软件。Word导出的PDF通常还比较循规蹈矩,但LaTeX生成的论文、专业排版软件出的报纸杂志、扫描后OCR的文档,内容流顺序经常和阅读顺序对不上。
我看过一份双栏期刊PDF,直接extract_text()之后,第一行是左栏开头,第二行突然跳到了右栏开头,第三行又回到左栏第二行,整段文字像洗过的扑克牌一样交错。这就是因为PDF内部把两个栏的文字存成了两个独立的文本对象,抽取时按对象顺序返回,而不是按视觉顺序返回。
这时候bbox就是唯一的“物理坐标依据”。我们不需要理解PDF内容流怎么存,只看每个文字块被画在了页面哪个位置,然后按视觉阅读习惯重新排序。说白了,就是把“逻辑层”的乱序问题,降维成“坐标层”的排序问题。
1.3 谁需要关心bbox:不只是做OCR的同学
一提bbox,很多人觉得这是OCR算法工程师的事。其实在RAG解析链路里,但凡你遇到下面这些情况,都需要主动去拿bbox:
- 双栏或多栏PDF提取后文本乱序;
- 文字水印混进正文,导致检索命中了一堆“机密”“仅供参考”;
- 页眉页脚被切进chunk,引起语义碎片;
- 图片和表格的位置关系混乱,图文对应不上;
- 扫描件OCR结果的文字顺序错乱。
这些问题的共同特征都是:单独看文本内容无法判断,必须借助位置信息才能修复。所以bbox不应该只在底层OCR环节用,而是应该作为一个基础能力贯穿整个解析流程。后面两章我分别拿多栏和水印这两个最典型的场景做一次完整实战,你就清楚怎么用了。
2. 多栏排版PDF:用坐标把文本块“装回”正确的栏
2.1 多栏PDF的三种来源与乱序症状
多栏PDF大致来自三种情况。第一种是学术论文,很多期刊用LaTeX模板生成双栏排版,文字密度高、栏目间隙窄、还夹杂公式和图表。第二种是报纸杂志和新闻资讯类的PDF,排版自由度高,栏数可能不固定,甚至一页里既有通栏标题又有三栏正文。第三种是老旧的扫描翻印件,纯图片,OCR之后每个文字块的坐标本身就有抖动,栏边界更模糊。
不同来源的乱序症状不一样。第一种常见的现象是左右栏内容按“行”交叉:第一行左栏内容,然后是第一行右栏内容,再回第二行左栏。第二种更麻烦,可能出现“Z字型”乱序,某个栏读到一半跳到另一个栏。第三种则表现为OCR引擎已经把同一栏的文字切碎了,按y坐标排完仍然左右穿插。
但无论症状多乱,核心解决思路都是一样的:先识别页面分了几栏,再把文字块归到对应栏里,最后按栏为单位、栏内按距页面顶部距离排序。这个过程不涉及任何语义模型,纯粹靠几何计算,速度快而且可解释性强。
2.2 栏目分割:找“列间空隙”而不是找“文字边缘”
怎么判断页面分了几栏?最直观的做法是看文字块在x轴方向的分布。双栏论文的典型特征是:左栏文字集中在页面左侧一定宽度内,右栏文字集中在右侧,中间有一段明显的空白区域,这个空白就是栏间隙。
我常用的方法是提取页面所有文字的x0坐标,排序后计算相邻坐标的差值。差值超过某个阈值的,就认为这里是一个栏边界。阈值的选择很关键,设得太小会把正常字间距当成栏边界,设得太大又会漏掉窄栏。实测下来,对于A4页面、字体大小10到12pt的文档,栏间隙通常在30pt以上,而正常字间距不会超过几个pt,所以用20pt作为默认阈值比较安全。如果页面密度特别高,可以先统计一下x0差值的分布,取第二大的间隙作为候选栏边界。
这里有一个重要的细节:要区分“页边距”和“栏间隙”。页面左右两侧的空白也很大,但它们不是栏边界。所以判断时只看文字块x0坐标的差值还不够,还要结合x1坐标——右侧边缘的坐标也能反映栏的右边界。更稳妥的做法是把页面宽度分成若干个纵向条带,统计每个条带里文字的面积覆盖率,覆盖率低的条带就是栏间隙的候选区域。
2.3 阅读顺序重排完整代码
下面这段代码是我在实际项目里用的多栏重排方案,基于pdfplumber实现,做了三件事:检测栏边界、把单词归入各栏、按阅读顺序拼接。
import pdfplumber def detect_column_gaps(words, page_width, gap_threshold=20): """通过x0坐标聚类找出栏间隙""" if not words: return [] xs = sorted(w['x0'] for w in words) gaps = [] prev = xs[0] for x in xs[1:]: if x - prev > gap_threshold: gaps.append(prev) # 记录间隙左边界 prev = x return gaps def split_words_into_columns(words, gaps): """把words按栏间隙切成多个栏""" columns = [[] for _ in range(len(gaps) + 1)] gaps_with_edges = [-float('inf')] + gaps + [float('inf')] for w in words: # 取文字块中心x坐标,判断属于哪一栏 center_x = (w['x0'] + w['x1']) / 2 for i in range(len(gaps_with_edges) - 1): if gaps_with_edges[i] < center_x < gaps_with_edges[i + 1]: columns[i].append(w) break return columns def reorder_columns(page): words = page.extract_words() if not words: return "" gaps = detect_column_gaps(words, page.width) columns = split_words_into_columns(words, gaps) # 按top排序每一栏,然后按从左到右的顺序拼接 parts = [] for col in columns: col_sorted = sorted(col, key=lambda w: (w['top'], w['x0'])) line_texts = [] current_line_top = None line_words = [] for w in col_sorted: # 用top判断是否新起一行,允许小范围抖动 if current_line_top is None or abs(w['top'] - current_line_top) > 5: if line_words: line_texts.append(" ".join(w['text'] for w in sorted(line_words, key=lambda x: x['x0']))) line_words = [w] current_line_top = w['top'] else: line_words.append(w) if line_words: line_texts.append(" ".join(w['text'] for w in sorted(line_words, key=lambda x: x['x0']))) parts.append("\n".join(line_texts)) return "\n\n".join(parts) with pdfplumber.open("paper.pdf") as pdf: for page in pdf.pages: print(reorder_columns(page))代码里有个细节:同一栏内判断“是否同一行”,我用top差值小于5pt作为阈值。因为OCR或PDF渲染会有轻微坐标抖动,如果严格要求top完全相等,很多同一行的字会被拆成多行。但这句话说明一个问题:如果识别结果来自扫描件OCR且坐标抖动比较大,5pt可能不够,建议调到10到15pt,或者用聚类而不是绝对阈值。
2.4 跨栏元素的特别处理
并不是所有文字块都老老实实待在某一栏里。最常见的例外是“通栏标题”,比如论文标题、一级章节标题,它们的宽度横跨左右两栏,如果强行归入某一栏,就会导致该栏多出一段无意义的宽文本,另一栏内容错位。
处理办法是先检测“宽文本块”:如果一个文字块或文本行的x1减x0接近整页宽度(比如超过页面宽度的75%),就把它单独作为一种“通栏块”,优先级最高,直接排在所有栏内容之前,然后才从左栏到右栏拼接正文。表格也有类似情况,有些表格横跨两栏,但表格内部有自己独立的阅读顺序,不能参与栏内排序。
我在项目中维护了一个顺序规则:通栏标题优先,其次是跨栏表格(整体保留并单独处理),再次是左栏内容,最后是右栏内容。这样处理之后,绝大部分双栏论文都能恢复成和视觉一致的阅读顺序。遇到页面上有图片遮挡导致文本坐标断裂的情况,建议用来处理之后再做一次“段落合并”的后处理,把同一语义段落的行拼起来,避免chunk切到半句话。
3. 水印PDF:把干扰项按“嫌疑度”筛掉,而不是一刀切
3.1 先给水印分类:文字、平铺、图章
水印PDF大概分三类。第一类是单条文字水印,通常斜跨页面中央,内容像“机密”“内部资料”“仅供参考”,字号巨大,颜色很浅,带旋转。第二类是平铺水印,整页规则排列着同一段文字,像壁纸一样铺满,主要在政府公文和公司制度文件里见得到。第三类是图章水印,用图像或矢量图形画出一枚红色印章或者灰色logo,盖在页面的某个固定位置。
这三类水印的干扰方式不同:文字水印会和正文在文字层交织,抽取时混入大量重复文本;平铺水印会把页面文字密度抬得极高,直接影响chunk切割质量和检索相关性;图章水印通常不产生可提取的文字,但会干扰扫描件的OCR识别,让水印区域内的正文识别不准。
处理策略也要按类型区分。文字水印靠字符级过滤,平铺水印靠“重复度检测”,图章水印则要结合图像处理和区域排除。下面分开讲。
3.2 水印识别的四个特征:字号、颜色、旋转、重复
水印识别不能靠匹配文本内容,因为水印文字太多样了。我总结了一套基于bbox和样式特征的“嫌疑度打分”,对绝大多数水印都有效。
第一是字号。水印的字号通常远大于正文,普通正文10到12pt,水印常常40到60pt甚至更大。如果你发现页面里有一批字符的尺寸是周围字符的三五倍以上,这批字符大概率不是正常正文,除非它是页面大标题。第二是颜色。水印为了不遮住正文,通常会用浅灰色或者带透明度的浅色。pdfplumber的char对象里能读到non_stroking_color,如果颜色通道值都在200以上(灰白方向),说明它很可能是水印。第三是旋转。文字水印为了视觉上不占用阅读区域,常常旋转45度。PDF的matrix矩阵里可以算出旋转角,正常正文的旋转角要么是0要么是90度(竖排),出现45度这种非常规角度,优先怀疑是水印。第四是重复。平铺水印的特征最明显:同一段文字在页面多个位置反复出现,文本内容完全一致,间距均匀。统计整页text的频次,某个词条出现三次以上且分布在页面四角,基本可以断定是水印。
我把这四个特征做成打分项,每一项加一个权重,总分超过阈值的字符/文本块就标记为水印。注意,单看某一个特征很容易误杀,比如红色标题的字号也大,但不满足颜色浅这一条;正文里也可能有加粗大字,但通常不会旋转45度。多重特征联合判定的稳定性要高很多。
3.3 过滤水印的完整流程与代码
下面这段代码实现了一个基础但可用的文字水印过滤器,针对文字类水印,核心思路是逐字符判断,而不是整行删除。
import math import pdfplumber def char_suspicion_score(c): """返回字符的‘水印嫌疑度’,分数越高越可能是水印""" score = 0 # 字号特征 size = math.hypot(c['matrix'][0], c['matrix'][1]) if size >= 30: score += 2 elif size >= 20: score += 1 # 颜色特征:偏白/偏浅 color = c.get('non_stroking_color', (0,)) if isinstance(color, (int, float)): color = (color,) avg = sum(color[:3]) / len(color[:3]) if len(color) >= 3 else 0 if avg >= 0.85: # RGB通道平均非常亮 score += 2 elif avg >= 0.7: score += 1 # 旋转特征 angle = math.degrees(math.atan2(c['matrix'][1], c['matrix'][0])) angle = abs(angle % 90) if 30 < angle < 60: score += 2 return score def remove_watermarks_from_page(page, threshold=3): """ 返回过滤掉水印后的纯文本 保留所有正常文本,只剔除嫌疑度足够高的字符 """ chars = page.chars filtered_chars = [c for c in chars if char_suspicion_score(c) < threshold] # 对过滤后的字符,重新按top/行组织成文本 line_buckets = {} for c in filtered_chars: key = round(c['top'] / 5) * 5 # 5pt容差分成行 line_buckets.setdefault(key, []).append(c) lines = [] for top in sorted(line_buckets.keys()): chars_in_line = sorted(line_buckets[top], key=lambda c: c['x0']) lines.append("".join(c['text'] for c in chars_in_line)) return "\n".join(lines)这里面有两个关键点。一是用per-char判断而不是per-word判断,因为有些水印和正文共用同一个文本行对象,如果不拆到字符级,会把一整行正文跟着水印一起删掉。二是threshold值的调整逻辑:分数等于3的字符有可能只是大号标题,所以我把阈值设在3,让分数4以上的才被删除,宁可多留一点噪声,也不误删正文。
实测下来,对居中斜跨的“机密”“仅供参考”类水印,这个方案基本能滤掉。但对于那种和正文完全同色的大字号水印(比如深红色印章文字),颜色特征失效,只能靠旋转和字号。如果旋转角是0度且颜色又深,那就需要结合位置信息:水印往往在页面中央或四角,正文的主体通常在安全边距内。可以把页面划分成区域,给中心区域加权。
3.4 图像型水印与扫描件的兜底方案
文字层的PDF水印好处理,真正的硬骨头是两类:一是矢量图章水印,二是扫描件里直接混入的像素水印。前者没有可提取的文本,后者连正文都是图片,需要走OCR。
矢量图章在PDF里表现为“图像对象”或“矢量路径”。如果你用pdfplumber的page.images能拿到图像bbox,用page.rects能拿到矩形/路径对象。识别逻辑比较简单:图章位置固定(比如右上角或中央)、尺寸占页面比例较大、颜色单一。这种水印如果和正文不重叠,直接记录区域并在提取时排除即可。如果和正文重叠,麻烦一点,需要把对应区域的图像从渲染图片里“抠掉”。
扫描件水印的兜底方案是两级:先做图像预处理,再做OCR。图像预处理可以用OpenCV对水印颜色做阈值分割,比如红色印章在RGB空间里红色通道显著高于其他通道,生成一个蒙版,把蒙版区域置白或置灰,再传给OCR。这个方案不完美,置白区域如果有正文,正文也就丢了。但相比把水印文字全识别成正文混进RAG里,牺牲一点覆盖率换检索干净度,通常更划算。
注意:处理扫描件水印时,不要把图像修复想得太美。像素级水印去除本质上是信息重建,OCR识别率会受很大影响。如果文档里水印区域的正文是关键内容,建议保留原图路径,而不是强行清洗后抛弃原图。
4. 从PDF页面到RAG chunk:一条可落地的完整链路
4.1 工具链怎么选:pdfplumber vs PyMuPDF vs OCR
做这套处理,我推荐三件套配合使用。pdfplumber负责精细的字符级坐标读取和水印过滤,它的chars、words对象字段丰富,最适合做这类几何判断。PyMuPDF负责大规模高性能提取和区域编辑,比如apply_redactions清理页面区域、渲染页面成图片,以及处理特别大的PDF时性能更好。OCR引擎(PaddleOCR或Tesseract)负责扫描件兜底,输出每个识别文字块的坐标和文本。
选型的逻辑是:pdfplumber的char级字段能拿到matrix矩阵和颜色信息,这是水印识别的关键;PyMuPDF在性能上比pdfplumber快不少,但字符级信息不如pdfplumber详尽。所以我的分工是:pdfplumber做精确分析和过滤,PyMuPDF做批量处理和图像渲染,OCR只对付没有文字层的扫描件。三者各有分工,不是非此即彼。
4.2 解析管线的四段式设计
把前面两章的技巧整合起来,我建议把PDF解析管线设计成四段式:页面预处理、布局分析、内容提取、后处理。
页面预处理阶段,把PDF转成统一坐标系下的几何对象:每个字符、单词、图片、矩形都带上bbox和样式属性。布局分析阶段做三件事:检测页面栏结构、识别通栏块、标记水印区域。内容提取阶段根据布局信息重新组织阅读顺序:先通栏、再各栏按序拼接,同时把水印字符从候选集里剔除。后处理阶段做段落合并、表格结构还原、chunk边界生成。
这个设计的核心是“把布局决策和文本生成分离”。不要在提取文本时才想着判断栏边界,那样代码会乱成一团。先把所有几何信息准备好,再根据几何信息决定如何拼接,每一步都可以单独调试和review。我在项目里就是这么重构的,重构之前解析逻辑全揉在一个函数里,出问题根本没法定位;拆开之后,每个环节都能出独立的中间结果,排查效率高了一个数量级。
4.3 把处理结果接进chunk与embedding
内容提取干净后,就要进入RAG的chunk环节。chunk不能简单按固定token切,因为多栏重排后的文本虽然顺序对了,但段落边界未必保留。我一般先做一个“段落边界识别”:根据文本行之间的垂直间距聚类,间距明显大于行内间距的位置视为段落断点。然后以段落为基础单元,把过长的段落按语义子句再切,过短的相邻段落合并,最终形成500到800 token的chunk。
水印过滤对这个环节的影响特别大。我之前做过一次对比:同一批带“内部资料”水印的PDF,不过滤水印直接embedding,检索结果里经常返回一些根本不相关的内容,因为水印文字和正文的语义不匹配,却占据了chunk里的相当比例。过滤之后再embedding,同样一批召回里,和查询真正相关的文档占比明显提升。原因很简单,冗余地重复出现的水印词汇会稀释正文的有效token密度,让向量表达偏向噪声。
4.4 性能与缓存:批量处理几十万页的心得
解析几百页的PDF还好,处理几十万页的批量任务就完全不一样了。我踩过的坑主要有两个:一是每个页面都重复做全量文本提取,很多页面根本不需要栏检测,白白浪费算力;二是把中间结果存内存,批量任务一跑就OOM。
解决思路是三级缓存和“按需深入”。第一级先用轻量方式提取页面基本信息(页数、是否含图像、文本密度),判断页面是否需要复杂的布局处理。第二级对有水印嫌疑或有多栏特征的页面做详细分析,正常的单栏页面直接走标准提取。第三级把每个页面的bbox结果缓存成JSON或SQLite,下次再处理同一批PDF时直接读缓存,不用重新解析。这样既保留了解析精度,又控制了整体耗时。
5. 常见问题与排查技巧实录
5.1 表格区域干扰栏检测
多栏检测时最常遇到的意外是:页面里有一个跨栏的宽表格,表格单元格宽度很窄,一行里有好几列,这些列的间距可能比栏间隙还小,导致栏检测把表格的列当成独立栏来处理。结果是表格内容被拆得七零八落,正文排序也乱了。
排查方法很简单:做栏检测之前,先调用page.find_tables()拿到表格区域,把表格内的文字块单独排除。表格区域内部的顺序用“先按行、行内按列”的规则独立处理,处理完再作为一个整体插入到正文的对应位置。不把表格参与正文的栏检测,两边都不会乱。
5.2 旋转文本的坐标陷阱
PDF里不是所有文字都是水平方向的。竖排文字、旋转的页眉、倾斜的艺术字,它们的外接bbox在坐标系里位置很怪。比如一个竖排文字块,它的bbox可能很矮但很宽,或者很高但很窄,直接按top排序会把它们放错位置。
处理旋转文本时,不要用外接bbox的中心点去判断行归属,而应该用字符的基线和“局部坐标方向”。pdfplumber的char对象里有upright字段,可以快速判断字符是否正立。遇到非正立字符,如果不是水印,建议单独提取、单独存储,不要强行塞进正文的阅读顺序里。这样虽然损失了一点内容连续性,但至少不会破坏整页的排序。
5.3 水印误删正文:怎么降低误杀率
水印过滤最怕的就是把正文里的正常大字当成水印删了。我遇到过最典型的一个案例:文档封面有一个特大号的标题,颜色是浅金色,字号比普通正文大很多,结果被过滤器当成“浅色大字水印”给剔除了。
改进方案是加“上下文校验”:正常的大字号标题往往位于页面上部或居中,而且同一页里只有一两个;水印通常横跨页面且重复出现。所以我在打分结果出来之后,又加了一步:统计被判为水印的字符数量,如果数量很少且位置接近页面中心上部,就人工复核一下是不是标题。另外,也可以把“文本块周围是否有同一行的小字号正文”作为反向特征——水印通常独立成块,和正文没有语义关系,而标题周围会有正文围绕。
5.4 常见问题速查表
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| 双栏PDF提取后左右交错 | PDF内容流按对象存栏,顺序≠阅读顺序 | 用x0坐标检测栏间隙,栏内按top排序后从左到右拼接 |
| 提取文本里出现大量重复的“机密”字样 | 文字水印混入正文 | 用字号、颜色、旋转角、重复度四个特征逐字符过滤 |
| 表格内容被多栏检测拆散 | 单元格窄列被误判为栏边界 | 先调用find_tables()排除表格区域,再处理正文栏 |
| 竖排/旋转文字位置错乱 | 非正立字符的bbox排序规则不对 | 用upright字段区分,旋转字符单独提取不参与正文排序 |
| 彩色大标题被水印过滤器误删 | 颜色浅、字号大这两个特征同时命中 | 增加标题上下文校验:位置、数量、周围是否有正文 |
| 扫描件水印导致OCR结果混入大量噪声 | 图像型水印被OCR当成正文 | 用OpenCV做颜色蒙版置白后再OCR,必要时牺牲覆盖率 |
| 批量解析几万页PDF后内存溢出 | 中间结果全部堆在内存 | 用SQLite/JSON做二级缓存,页面级按需处理 |
补充一个性能相关的经验:pdfplumber的extract_words()在小文件上性能没问题,但处理上千页的大文件会明显变慢。批量任务建议优先用PyMuPDF做粗提取,只有遇到特定页面需要精细判断时才调pdfplumber。两种库的坐标转换要做到稳定,建议在初始化阶段就统一成自己的坐标系,别把转换逻辑散落在各处。
我个人在实际操作中最深的一个体会是:RAG文档解析这一层,真正决定最终效果下限的,往往不是模型,而是干净程度和顺序正确性。顺序乱了,再强的embedding也救不回来;噪声多了,再好的召回算法也会被带偏。所以每次解析之前,我会先打印几页中间产物,肉眼看一眼顺序和水印过滤前后的文本对比,确认无问题再上全量任务。这个习惯帮我避掉了很多莫名其妙的线上问题。
最后再分享一个小技巧:如果你手头有一批PDF,不确定哪些页有复杂布局,可以先跑一个“布局健康度扫描”,统计每页的栏数、水印嫌疑字符数、表格区域占比,把这些数字输出成一个报告。用报告页面做人工抽检,比逐页翻PDF高效得多。这也是我目前做文档解析批量交付前的标准动作,建议你试试。