ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RAG文档解析实战:基于bbox的XY-cut多栏排版与水印过滤

RAG文档解析实战:基于bbox的XY-cut多栏排版与水印过滤 1. 为什么多栏排版和水印是 RAG 文档解析的硬骨头做过 RAG 知识库的人都有一个共同体会PDF 解析是整个链路里最脏最累的活。文本层提取本身不难PyMuPDF 几行代码就能把字抠出来但抠出来的顺序对不对、段落有没有被拦腰截断、页眉页脚和水印有没有混进正文这些才真正决定后面检索和生成的质量。我见过太多项目向量库建得漂漂亮亮召回率一测就崩回头一查切片里全是“内部资料 请勿外传”和左右栏交错拼接的乱码。这篇要聊的是 bbox 实战也就是基于坐标框的版面分析。核心场景有两个多栏排版和水印 PDF。多栏的痛点是阅读顺序人眼知道先读左栏再读右栏但机器默认按 y 坐标从上往下扫结果就是把两栏的文字按行交错拼在一起语义直接碎掉。水印的痛点是干扰水印通常是大字号、浅颜色、旋转角度、覆盖全页如果按普通文本块处理它会反复出现在每个切片里污染检索结果。为什么用 bbox 而不是纯文本流因为 PDF 里的文字本质上是带坐标的绘制指令page.get_text(dict)拿到的每个 span 都带bbox也就是(x0, y0, x1, y1)。有了坐标我们就能做版面聚类、栏位切分、水印识别。XY-cut 是这里面最经典的算法思路是先按 x 方向找投影空白切竖栏再按 y 方向切段落递归下去直到切不动为止。PyMuPDF 负责取 bboxXY-cut 负责组织顺序两者配合基本能覆盖 80% 的学术论文、报告、说明书类 PDF。适合谁来参考如果你正在搭 RAG 知识库、做文档问答、搞合同或论文解析并且已经被多栏和水印折磨过这篇就是给你写的。下面我会把选型逻辑、bbox 结构、XY-cut 实现、水印过滤、参数调优、踩坑记录全部摊开讲代码可以直接抄。2. 整体方案设计与技术选型思路2.1 为什么是 PyMuPDF 而不是 pdfplumber 或 pdfminer选型这件事我踩过不少坑。pdfminer 是最底层的信息全但慢API 反人类做个 bbox 提取要写一堆 LTPage 遍历。pdfplumber 基于 pdfminer封装友好extract_words()直接给 bbox调试方便但大文件性能一般而且它对旋转文字和复杂字体的处理偶尔会丢字。PyMuPDF 底层是 MuPDFC 实现速度快一个量级get_text(dict)返回的结构清晰span 级别带字体、字号、颜色、bbox做水印识别时颜色信息特别关键。我实测过一个 300 页的双栏学术 PDFpdfplumber 提取加版面分析大概 40 秒PyMuPDF 只要 6 秒左右。RAG 场景往往要批量处理成千上万个文档这个差距会被放大到不可接受。所以主力用 PyMuPDF遇到它解析异常的个别页面再用 pdfplumber 兜底这是我目前的稳定组合。2.2 bbox 坐标系与 PDF 的“坑爹”原点这里必须先讲清楚坐标系否则后面所有计算都是错的。PDF 的原点在左下角y 轴向上为正。但 PyMuPDF 为了符合阅读习惯默认把原点转到了左上角y 轴向下为正。也就是说page.get_text(dict)里的 bboxy0是上边y1是下边y0 y1。这个转换很贴心但如果你同时用page.get_pixmap()渲染图片图片坐标又是左上原点两者能对上这点比 pdfplumber 省心。一个 span 的 bbox 长这样(x0, y0, x1, y1)分别对应左、上、右、下。宽度是x1 - x0高度是y1 - y0。做栏位切分时我们关心 x 方向的投影做段落合并时关心 y 方向的间距。记住一个经验值正常正文行高在 10 到 14 之间行间距小于行高的 0.6 倍通常属于同一段大于 1.5 倍往往是段落分隔。2.3 XY-cut 的核心思想与适用边界XY-cut 本质是递归投影切分。先统计所有文本块在 x 轴上的覆盖情况找到那些“没有任何文本经过”的竖直空白带用它们把页面切成若干竖条这就是分栏。然后在每个竖条内部再统计 y 轴的空白带切成横条也就是段落。递归进行直到切出的区域里只剩一个文本块或者空白带宽度小于阈值。它的优势是无需训练、可解释、速度快对规整的多栏排版效果极好。边界也很明确遇到不规则图文混排、文字绕图、斜排文字时会失效。所以我的策略是 XY-cut 打底配合 bbox 重叠检测做修正而不是指望它包打天下。水印处理则是另一条线靠颜色、字号、旋转角度、覆盖范围四个特征联合判断不依赖 XY-cut。3. 核心细节解析与实操要点3.1 用 get_text(dict) 拿到结构化 bbox先看基础提取代码这是所有后续处理的输入。import fitz # PyMuPDF doc fitz.open(sample.pdf) page doc[0] data page.get_text(dict) for block in data[blocks]: if block[type] ! 0: # 0 是文本块1 是图片块 continue for line in block[lines]: for span in line[spans]: print(span[bbox], span[text], span[size], span[color])这里有几个关键点。blocks里混着文本块和图片块用type区分图片块没有lines。span是最小单位通常一个 span 内字体字号颜色一致。color是整数需要转成 RGB 看比如0是黑色16711680是红色。水印识别时这个字段是命门。注意get_text(dict)默认按 PDF 内部绘制顺序返回不保证阅读顺序。多栏 PDF 里经常出现右栏的块排在左栏前面所以千万别直接按返回顺序拼接文本一定要自己做排序。3.2 文本块归一化与无效块过滤原始 block 粒度太粗一个 block 可能横跨两栏直接用它做 XY-cut 会出错。我的做法是把所有 span 提升为独立的“文本单元”每个单元带 bbox、文本、字号、颜色、是否旋转。然后做一轮过滤去掉空白 span、去掉纯页码通常是单数字且位于页面顶部或底部边缘、去掉页眉页脚y 坐标在页面上下 5% 区域内且字号偏小。def normalize_spans(page): units [] h page.rect.height w page.rect.width for block in page.get_text(dict)[blocks]: if block[type] ! 0: continue for line in block[lines]: for span in line[spans]: text span[text].strip() if not text: continue x0, y0, x1, y1 span[bbox] # 过滤页眉页脚 if y1 h * 0.05 or y0 h * 0.95: continue units.append({ bbox: (x0, y0, x1, y1), text: text, size: span[size], color: span[color], dir: line[dir], # 方向向量判断旋转 }) return unitsline[dir]是个二元组正常水平文字是(1, 0)旋转文字会变。水印经常带旋转这个字段能帮上忙。3.3 水印的四个识别特征水印不是靠单一特征能认出来的我总结了一套组合拳实测准确率很高。第一是颜色。水印为了不遮挡正文通常是浅灰、浅蓝、浅红RGB 值偏高且饱和度低。正文黑色是(0,0,0)把 color 转成 RGB 后如果三个通道都大于 180基本可以怀疑。第二是字号。水印往往比正文大很多正文 10 到 12水印可能 30 到 60。但要注意有些小水印字号也小所以字号只作为辅助。第三是旋转角度。从dir算角度math.atan2(dir[1], dir[0])非零角度高度可疑。第四是覆盖范围。水印通常横跨页面大部分宽度或者重复出现多次。统计同一文本在页面内出现的次数出现 3 次以上且内容相同的大概率是水印。import math def is_watermark(unit, page_w, page_h): x0, y0, x1, y1 unit[bbox] r (unit[color] 16) 255 g (unit[color] 8) 255 b unit[color] 255 light r 180 and g 180 and b 180 big unit[size] 25 angle math.degrees(math.atan2(unit[dir][1], unit[dir][0])) rotated abs(angle) 5 wide (x1 - x0) page_w * 0.5 score sum([light, big, rotated, wide]) return score 2用打分制而不是一刀切是因为不同 PDF 的水印特征不一样两票通过比较稳。你可以根据自己文档的特点调整阈值。4. 实操过程与核心环节实现4.1 XY-cut 递归切分完整实现下面是我实际在用的 XY-cut做了工程化封装。核心是投影函数和递归切分。def projection(units, axis): axis0 投影到 x 轴axis1 投影到 y 轴 intervals [] for u in units: x0, y0, x1, y1 u[bbox] if axis 0: intervals.append((x0, x1)) else: intervals.append((y0, y1)) return intervals def find_gaps(intervals, min_gap): 在区间集合里找空白带 if not intervals: return [] intervals.sort() gaps [] cur_end intervals[0][1] for start, end in intervals[1:]: if start - cur_end min_gap: gaps.append((cur_end, start)) cur_end max(cur_end, end) return gaps def xy_cut(units, page_w, page_h, depth0): if len(units) 1 or depth 10: return units # 先切 x分栏 x_intervals projection(units, 0) x_gaps find_gaps(x_intervals, min_gappage_w * 0.03) if x_gaps: # 用第一个足够宽的空白带切 cut x_gaps[0] left [u for u in units if u[bbox][2] cut[0] 1] right [u for u in units if u[bbox][0] cut[1] - 1] if left and right: return xy_cut(left, page_w, page_h, depth1) \ xy_cut(right, page_w, page_h, depth1) # 再切 y分段 y_intervals projection(units, 1) y_gaps find_gaps(y_intervals, min_gap8) if y_gaps: cut y_gaps[0] top [u for u in units if u[bbox][3] cut[0] 1] bottom [u for u in units if u[bbox][1] cut[1] - 1] if top and bottom: return xy_cut(top, page_w, page_h, depth1) \ xy_cut(bottom, page_w, page_h, depth1) return units这里min_gap是灵魂参数。x 方向我用页面宽度的 3%A4 纸约 595 宽也就是 18 点左右能区分大多数双栏间距。y 方向用 8 点对应行间距的阈值。切分顺序先 x 后 y是因为分栏优先级高于分段先确定栏位再在栏内分段逻辑更干净。4.2 切分后的阅读顺序重建XY-cut 返回的是切分后的单元列表但顺序还需要按“先左栏后右栏、栏内从上到下”重排。我的做法是给每个单元算一个排序键先按栏位x 中心落在哪个区间再按 y0。def rebuild_order(units, page_w): # 简单两栏假设多栏可扩展 mid page_w / 2 def sort_key(u): x0, y0, x1, y1 u[bbox] cx (x0 x1) / 2 col 0 if cx mid else 1 return (col, y0) return sorted(units, keysort_key)如果是三栏或更复杂可以把 XY-cut 切出的栏位区间记下来按区间左边界排序。我一般会在切分时把栏位信息带出来而不是事后猜。4.3 水印过滤与正文合并水印识别出来后直接从 units 里剔除再走 XY-cut。这里有个顺序问题先滤水印再切分因为水印横跨全页会破坏投影空白带导致 XY-cut 找不到栏间空隙。我一开始就是先切分后滤水印结果双栏文档死活切不开排查半天才发现是水印把中间空白填满了。def parse_page(page): units normalize_spans(page) page_w, page_h page.rect.width, page.rect.height # 先滤水印 units [u for u in units if not is_watermark(u, page_w, page_h)] # 再 XY-cut ordered xy_cut(units, page_w, page_h) ordered rebuild_order(ordered, page_w) # 合并成段落文本 text \n.join(u[text] for u in ordered) return text合并时我建议按 y 间距判断是否换行还是换段。同一行内多个 span 直接拼接行间距小于 1.5 倍行高视为同段否则加换行。这样出来的文本段落完整切片时语义不碎。4.4 参数调优的实测记录我拿三类文档做了对比测试双栏学术论文、带水印的合同、单栏技术手册。关键参数是 x 方向 min_gap 和 y 方向 min_gap。文档类型x_min_gapy_min_gap切分准确率双栏论文页面宽 3%895%带水印合同页面宽 3%1092%单栏手册页面宽 5%698%单栏文档 x 方向其实不需要切但设太小会误切比如表格里的列所以单栏我调到 5%。y 方向合同用 10 是因为合同行距大用 8 会把本该分开的条款合并。这些值不是固定的建议你先拿 20 页样本跑一遍看切分结果再定。5. 常见问题与排查技巧实录5.1 切分结果错乱的排查思路最常见的症状是“文字顺序还是乱的”。排查顺序我固定为三步。第一步打印所有 unit 的 bbox看有没有异常大的块横跨页面如果有说明 span 归一化没做好可能某个 block 没拆开。第二步检查水印是否滤干净把 is_watermark 的判断结果打出来看有没有漏网。第三步看 XY-cut 的 gaps 是否为空如果为空说明投影没有空白带可能是文字块之间重叠了需要放宽 min_gap 或者先做块合并。5.2 水印漏检与误杀漏检通常是水印颜色不够浅或者字号不够大。这时候把打分阈值从 2 降到 1但会带来误杀风险。误杀正文的典型是浅色标题被当成水印解决办法是加一条“文本长度”判断水印往往短且重复正文标题通常较长且唯一。我一般会统计全文档的文本频次出现次数超过页面数 30% 的短文本才纳入水印候选。5.3 旋转文字与竖排文本有些 PDF 的页边有竖排文字dir不是(1,0)。这类文字如果混进正文会很难看。我的处理是角度绝对值大于 30 度的直接丢弃或单独存到 metadata不参与正文拼接。竖排中文在 dir 上表现为(0,1)或(0,-1)同样处理。5.4 性能优化大文档批量处理批量处理时不要每页都重新 open。用fitz.open打开一次遍历页面。另外get_text(dict)比get_text(text)慢但信息全值得。如果文档超过 1000 页建议分块处理并加进度日志避免内存堆积。我实测 1000 页双栏 PDF全流程约 90 秒内存稳定在 500MB 以内。问题现象可能原因解决方向文字顺序交错水印未滤或 span 未拆先滤水印检查 block 拆分栏位切不开水印填满空白带调整滤水印顺序段落被截断y_min_gap 过大调小 y 方向阈值正文被误删水印阈值过松提高打分阈值或加长度判断处理速度慢逐页重复打开复用 doc 对象批量遍历6. 我在实际项目里的几点体会bbox 实战这件事工具和算法都是现成的真正花时间的是调参和边界处理。我最大的体会是不要追求一步到位的通用方案。不同来源的 PDF 差异极大学术论文、扫描件、合同、说明书各有各的排版习惯。我的做法是先做一套默认参数然后针对每个文档来源建一个小的参数配置跑样本验证后再批量。这样虽然前期麻烦但后期召回率稳定得多。另一个体会是水印过滤宁可保守一点。漏掉几个水印最多是切片里多几句噪声误杀正文那是直接丢信息检索时怎么都找不回来。所以我的阈值一直偏严宁可放过也不误杀。还有个小技巧把滤掉的水印文本单独存一份日志出问题时能快速定位是哪个规则误伤了。最后说个扩展方向。XY-cut 对规整排版够用但如果你的文档里有大量表格和图文混排可以考虑引入版面检测模型做辅助把 bbox 喂给模型判断区域类型再决定切分策略。这条路我还在试目前看对复杂报表类 PDF 提升明显但成本和复杂度也上去了是否值得取决于你的文档构成。
返回列表