首页 / 资讯中心 / 文章详情

用bbox解决RAG文档解析:多栏排版重建与水印过滤实战

用bbox解决RAG文档解析:多栏排版重建与水印过滤实战 ★ FEATURED ARTICLE
做 RAG 知识库这段时间最折磨我的不是选哪个 embedding 模型也不是向量库调参而是文档解析。上周处理一批客户内部 PDF排版正规、双栏、带水印结果进知识库后检索出来的段落全是“魂穿”状态——上一句还在左栏中段下一句直接跳到右栏顶部语义被切得七零八碎后面还孤零零地跟着重复出现的CONFIDENTIAL 水印文本。我盯着输出结果愣了好久最后靠 bbox 一块块核查才把阅读顺序捋顺。这篇是这个系列的第三篇专门聊 bbox 在两类老大难 PDF 上的实战多栏排版重建和水印文本过滤。文中所有代码都用 PyMuPDF 跑通原理讲透坑也一并写了。正在搞 RAG 文档解析、知识库清洗或者被复杂 PDF 折磨的朋友这篇应该能帮你省下不少排查时间。1. 为什么“阅读顺序”决定 RAG 质量从一次信息错乱说起1.1 文档解析在 RAG 管道里的真实地位很多人做 RAG 时把心思都放在 embedding 模型和 rerank 上效果不好就换模型试参数。但踩过坑的都明白文档解析才是真正的地基。如果喂进去的文本顺序是乱的embedding 再强也只能在乱序片段里捞相似度捞出来的内容断章取义都是轻的严重时直接误导问答。我举个具体例子。一篇双栏论文的正常句子是“本研究提出了一种基于规则的方法实验表明该方法在三个数据集上均优于基线”如果解析顺序错乱chunk 可能被切成了“本研究提出了一种基于实验表明该方法在三个数据集上均优于基线规则的方法”。看着还能读但语义已经变形了。更糟的是如果水印文本混进正文chunk 里时不时冒出几行“Internal Use Only”检索阶段没什么生成阶段模型被这些噪声一搅回答质量立刻下降。所以文档解析在 RAG 管道里的地位相当于做饭前的洗菜切菜。菜没洗干净、切得乱七八糟后面厨艺再好也白搭。而 PDF 恰恰是所有文档格式里最“不讲武德”的一种——它存储内容时根本不管你视觉上的阅读顺序。1.2 一个让人抓狂的反面案例我最初天真地以为用page.get_text(text)直接读 PDF顺序总该差不多吧实际跑了一下双栏论文输出长这样import fitz doc fitz.open(double_column.pdf) page doc[0] text page.get_text(text) print(text[:600])输出结果是左栏几行、右栏几行、再跳回左栏完全随机。原因很简单PDF 内部是按照“对象写入顺序”存储文本的而写入顺序取决于生成工具的实现。有些工具幸运地接近视觉顺序有些工具按块写入写到哪算哪。遇到多栏排版流式抽取基本必乱。这个问题的本质在于PDF 里只有“物理坐标”没有“逻辑阅读顺序”。视觉上的阅读顺序是人类根据版面结构推断出来的。计算机想还原这个顺序唯一的办法就是拿坐标做版面重建。而坐标就是 bbox。1.3 bbox每个文本块的“地理坐标”bbox 全称 bounding box就是包围盒。在文档解析场景里它是每个文本块、每行、每个 span 在页面上的矩形区域通常用四个值表示(x0, y0, x1, y1)其中(x0, y0)是左上角坐标(x1, y1)是右下角坐标单位通常是点pt1 点约等于 1/72 英寸。有了这四个值我就知道这段文字在页面上的确切位置靠左还是靠右、靠上还是靠下、宽度多少、是否跨栏。这里必须提醒一个容易踩的坑坐标系的差异。PDF 原生坐标系原点在页面左下角y 轴向上增大但 PyMuPDF 暴露给用户的bbox默认是呈现坐标系原点在页面左上角y 轴向下增大。也就是说y 值越小越靠上。刚开始我拿原生 MediaBox 的坐标跟 PyMuPDF 的 bbox 混用结果排序时 y 轴方向反了整页文本上下颠倒。后来统一只用 PyMuPDF 的 bbox再也没出过这种低级问题。提示拿到 bbox 后第一件事就是确认坐标系。打印一页里第一行文本的 y0再和页面上肉眼看到的位置对比一下确认 y 方向是向下增大还是向上增大再写排序逻辑。2. 拿 bbox 的三种姿势选对工具少踩坑2.1 PyMuPDF / pdfplumber / pdfminer.six 对比市面上读取 PDF 文本和坐标的库不少我实际用下来主力是 PyMuPDFfitz偶尔用 pdfplumber 补表格。三个库的差别还是挺明显的。库优点缺点适合场景PyMuPDF (fitz)速度快、API 返回完整 dict 结构、bbox/字体/颜色/方向全都有部分字体信息需要自己解析文档不够全大批量解析、需要坐标和字体特征的 RAG 管道pdfplumber表格抽取能力强、API 清晰、交互式排查方便速度慢、大 PDF 内存占用高表格密集的 PDF、精细调试pdfminer.six信息最底层、最完整代码冗长、上手成本高、性能一般研究 PDF 底层结构、特殊需求我的选型逻辑很简单RAG 解析是批量任务速度优先同时我又需要字体名、字号、颜色、文本方向这些特征来做水印判断。PyMuPDF 在这几项上平衡得最好。pdfminer 虽然底层信息更全但开发效率太低pdfplumber 的表格能力虽然强但纯文本坐标提取并不比 PyMuPDF 更有优势。所以下面统一用 PyMuPDF 讲。2.2 深入 PyMuPDF 的 dict 结构PyMuPDF 的get_text(dict)返回的是一个嵌套结构层级是page - blocks - lines - spans每个block有type字段type0 是文本块type1 是图片块。每个line里有bbox、dir文本方向向量、wmode每个span里有bbox、font、size、flags、color、text等字段。import fitz doc fitz.open(sample.pdf) page doc[0] data page.get_text(dict) for block in data[blocks]: if block[type] ! 0: continue print(block bbox:, block[bbox]) for line in block[lines]: print( line bbox:, line[bbox]) for span in line[spans]: print( span text:, repr(span[text])) print( span bbox:, span[bbox]) print( font:, span[font], size:, span[size]) print( color:, span[color], flags:, span[flags])这里有几个字段值得细看。color是一个整数不是 RGB 元组。想拿到 RGB需要自己移位转换def hex_to_rgb(color_int): return ((color_int 16) 255, (color_int 8) 255, color_int 255)flags是字体标志位bit 0 表示上标bit 1 表示斜体bit 4 表示粗体。但更简单的方式是直接看font字符串PyMuPDF 的字体名里一般会带 Bold、Italic 等关键词。dir字段表示文本方向向量正常横排是(1.0, 0.0)旋转 90 度是(0.0, 1.0)之类。对角线水印的dir不是(1,0)这就是识别水印的好线索。注意get_text(dict)和get_text(rawdict)有区别。dict 已经做了文本合并rawdict 保留更底层的字符信息但结构更复杂。做版面重建用 dict 就够了别一上来就上 rawdict徒增工作量。2.3 封装一个自己的 bbox 提取函数有了原始数据我习惯先封装一个统一的 span 数据结构把后面要反复用到的字段都放到 dataclass 里。这样多栏排序、水印过滤的时候不用到处翻字典代码可读性能好不少。from dataclasses import dataclass, field dataclass class TextSpan: text: str bbox: tuple # (x0, y0, x1, y1) font: str size: float 0.0 color: int 0 flags: int 0 dir: tuple (1.0, 0.0) block_no: int -1 line_no: int -1 column: int -1 # 多栏排序时用 is_full_width: bool False # 跨栏元素标记 def extract_spans(page: fitz.Page) - list[TextSpan]: spans [] data page.get_text(dict) for bi, block in enumerate(data[blocks]): if block[type] ! 0: continue for li, line in enumerate(block[lines]): line_dir line.get(dir, (1.0, 0.0)) for span in line[spans]: spans.append(TextSpan( textspan[text], bboxtuple(round(v, 1) for v in span[bbox]), fontspan[font], sizeround(span[size], 1), colorspan[color], flagsspan[flags], dirline_dir, block_nobi, line_noli, )) return spans把dir放在 span 上是因为后续水印方向判断是按行看的但处理时统一按 span 更方便。封装完以后后面的所有逻辑都基于这个TextSpan列表操作不用再关心 PyMuPDF 的原始结构了。3. 多栏排版重建从坐标到正确顺序的三步走3.1 先诊断页面是不是多栏x 坐标聚类多栏重建的第一步不是排序而是先判断当前页面到底是不是多栏。单栏页面直接按 y 排序就完事强行套多栏逻辑反而会弄乱。判断方法很朴素把页面上所有非空文本的 x0左上角 x 坐标收集起来排个序看相邻两个值之间有没有明显的“断层”。单栏页面的 x0 基本集中在一个区间内双栏则会有两个聚集中心三栏会有三个。def detect_column_count(spans, gap_threshold20.0): x0s sorted(s.bbox[0] for s in spans if s.text.strip()) if len(x0s) 2: return 1 gaps [] for a, b in zip(x0s, x0s[1:]): if b - a gap_threshold: gaps.append((a, b)) # 如果断层数量为 0视为单栏有 N 个断层则栏数为 N1 return len(gaps) 1gap_threshold取 20pt 是基于经验正文两列之间的空白通常明显大于字间距和缩进。但这个方法有个漏洞——同一栏内若有很宽的右缩进或者某行恰好缩进很大x0 也会产生断层导致误判为多栏。更稳健的做法是看 x 中心点cx (x0 x1) / 2的分布对 cx 做一维聚类。双栏 PDF 的文本中心会清晰聚成两簇单栏就只有一簇。聚类可以用 sklearn 的 KMeans也可以手写简单的阈值法。在大部分正式排版 PDF 上上面的断层检测已经够用聚类适合处理排版不规范的文档。3.2 按栏分组用栏间距缺口切分而不是简单中线判断出是多栏以后面临的问题就是每个 span 到底属于哪一栏最简单粗暴的思路是拿页面宽度除以 2 做中线x0 小于中线归左栏大于中线归右栏。这个方法看着合理实际一用就翻车。双栏论文的栏宽往往不相等左侧栏有页边距、右侧栏有缩进中线切下去会把左栏右边缘的内容和右栏左边距的内容混淆。三栏更是直接失效。正确的姿势是找到“栏间距缺口”用缺口来切分。def find_column_gaps(spans, gap_threshold20.0): lines_x [] for s in spans: if not s.text.strip(): continue mid_x (s.bbox[0] s.bbox[2]) / 2 lines_x.append((s.bbox[0], s.bbox[2], mid_x)) x0s sorted(s.bbox[0] for s in spans if s.text.strip()) gaps [] for a, b in zip(x0s, x0s[1:]): if b - a gap_threshold: gaps.append((a, b)) return gaps def assign_column(spans, gaps): def col_index(x0): col 0 for gap_start, gap_end in gaps: if x0 gap_end: col 1 else: break return col for s in spans: s.column col_index(s.bbox[0])这个方案的逻辑是找到所有文本块 x0 之间的显著空白带每个空白带就是栏与栏之间的分界按 x0 落在哪个区间来确定栏号。这样即使左右栏宽度不同也能正确切分。核心参数还是那个 20pt 的缺口阈值。这里要特别提醒文本块的 bbox 宽度如果特别大比如横跨了两个栏那它大概率不是正文而是跨栏标题、跨栏表格或者图片。这种元素如果强行分到某一栏后续排序就错了。所以assign_column之前先做一次跨栏元素标记。def mark_full_width_spans(spans, page_width, ratio0.8): for s in spans: width s.bbox[2] - s.bbox[0] if width page_width * ratio: s.is_full_width Trueratio 取 0.8意思是某个 span 宽度超过版面宽度 80% 就视为跨栏。论文里的标题、图表经常触发这个条件正文文本块很少会宽到这个程度。3.3 栏内排序与最终拼接y 轴优先、x 轴次之栏号分配好以后排序规则就清晰了先按栏号从小到大也就是从左到右同一栏内按 y0 从小到大也就是从上到下同一行内按 x0 从左到右注意 y 轴是向下增大的y0 小靠上先读别搞反。def reorder_spans(spans): normal [s for s in spans if not s.is_full_width and s.text.strip()] normal.sort(keylambda s: (s.column, s.bbox[1], s.bbox[0])) return normal排序只是第一步之后还要把同属于一行的 span 合并成行文本否则每个 span 单独换行输出跟碎片一样。合并行的依据是 y0 的接近程度两个 span 的 y0 差距小于 2pt就认为它们在同一行行内按 x0 排序然后拼接。def spans_to_lines(spans, y_tol2.0): lines [] current_line [] current_y None for s in spans: if current_y is None or abs(s.bbox[1] - current_y) y_tol: current_line.append(s) current_y s.bbox[1] if current_y is None else ( current_y s.bbox[1]) / 2 else: current_line.sort(keylambda s: s.bbox[0]) lines.append(.join(s.text for s in current_line)) current_line [s] current_y s.bbox[1] if current_line: current_line.sort(keylambda s: s.bbox[0]) lines.append(.join(s.text for s in current_line)) return lines处理完整页的spans_to_lines输出后把 lines 按顺序 join 换行符就是一页重建后的文本了。3.4 跨栏元素的处理跨栏标题、跨栏表格和图片是排版重建里最容易踩坑的部分。处理原则我建议这样跨栏元素按全局 y0 插入到它应该出现的位置而不是放在所有栏内容之后。具体操作是先把普通栏内文本按栏号和 y 排序好再把is_full_widthTrue的 span 按照它的 y0 值插入到对应行的位置。举个例子一个跨栏标题“3. Method”y0 在这一页的 80pt 处而左栏第一行正文的 y0 是 100pt那么标题就应该在正文之前输出。这样做的理由是视觉阅读习惯标题在上方先读。如果是跨栏的表格情况更复杂。我目前的策略是把表格区域用 pdfplumber 单独提取成结构化数据然后在文本流里表格的 y 位置插入一段表格 Markdown 文本同时把表格范围内的原始 span 从正文流里剔除。这样做的好处是表格内容不会被切成碎片混在正文里RAG 检索时表格也能作为独立语义块召回。4. 水印文本的识别与过滤不只是“去水印”4.1 水印文本的生物特征为什么要单独聊水印因为带水印的 PDF 在 RAG 场景里太常见了。内部资料、项目文档、扫描报告几乎都喜欢加个水印。水印本身不会让 PDF 读不出来但它的文字会混进文本流污染 chunk。我处理的目标很明确把水印文本从解析结果中剔除让知识库内容干净而不是把水印当噪音留在里面反复污染 embedding。水印文本在 bbox 特征上和正文差异明显常见的可识别特征有四条重复性同一段文本在一页里出现多次。平铺水印三五次地重复对角线水印也会多次出现。颜色浅Word 之类的工具生成的水印默认是浅灰色RGB 往往在 200 以上正文则通常是纯黑或深灰。方向不同对角线水印的文字方向不是水平向量PyMuPDF 的dir字段能直接看出来。横跨版面有些水印文本块宽度接近整个页面宽度而正文文本块宽度通常只占一栏。但单独拿任何一条去过滤都会误杀。正文也会重复页眉、页脚、章节名正文也可以是浅色有些模板正文就是灰色文字正文里也可能出现旋转的文本某些设计型 PDF。所以水印过滤的核心思路是“组合特征”不是单条件判断。4.2 三种过滤策略重复去重、方向识别、跨栏平铺识别策略一重复去重。以页为单位统计所有非空 span 的文本内容删除出现次数超过阈值且位置分散的文本。from collections import Counter def filter_repeated_text(spans, repeat_threshold3, min_y_span50): text_counts Counter(s.text.strip() for s in spans if s.text.strip()) repeated_keys [ t for t, cnt in text_counts.items() if cnt repeat_threshold ] remove_set set() for t in repeated_keys: same_spans [s for s in spans if s.text.strip() t] y_positions [s.bbox[1] for s in same_spans] if max(y_positions) - min(y_positions) min_y_span: for s in same_spans: remove_set.add(id(s)) return [s for s in spans if id(s) not in remove_set]这里min_y_span50很关键。页眉页脚也会重复但它的 y 坐标很集中——顶部或底部固定位置。水印则分散在页面不同高度y 值跨度大。用 y 跨度过滤掉页眉页脚的干扰是这套逻辑的精髓。策略二方向识别。检查 span 的dir字段是不是水平向量如果明显倾斜且内容重复出现判定为水印。def is_diagonal(dir_vector, tol0.2): x, y dir_vector if abs(x) tol or abs(y) tol: return False # 横排或竖排不是对角线 return True旋转 45 度的水印dir大约是(0.707, 0.707)横排正文是(1.0, 0.0)。方向识别可以单独标记候选再结合重复性过滤。单独用方向识别会误伤部分设计型正文字所以我只把它作为打分项之一。策略三跨栏平铺识别。检查 span 宽度是否超过版面宽度的一定比例且内容长度较短、重复度高。跨栏标题虽然也宽但它通常不重复而且文字是正文的一部分。def filter_wide_repeated(spans, page_width, width_ratio0.8): remove_set set() for s in spans: if not s.text.strip(): continue width s.bbox[2] - s.bbox[0] if width page_width * width_ratio: text s.text.strip() same_text [x for x in spans if x.text.strip() text] if len(text) 20 and len(same_text) 2: remove_set.add(id(s)) return [s for s in spans if id(s) not in remove_set]单看这个条件还不够我实际使用中是把所有候选特征汇总成一个打分函数超过阈值才删。这样误杀率能压得很低。4.3 翻车预警那些会被误杀的正常文本水印过滤做得太激进比不做还糟。正文里的正常文本被删了RAG 直接丢知识这比水印污染严重得多。我踩过的误杀场景有这么几类。第一类是页眉页脚。页眉页脚和水印一样是重复出现的直接用重复次数过滤必误杀。所以我加了 y 坐标跨度判断页眉页脚高度集中y 跨度通常在 10pt 以内不会触发min_y_span50的条件。第二类是表格内重复的列标题。某些统计报表的每一行前面都有相同的行业分类字段这类文本在同一列反复出现y 跨度可能很大如果内容恰好短很容易被误杀。我的处理方法是先抽取表格区域把表格内的 span 单独隔离不参与页级水印过滤。第三类是颜色特征的干扰。很多 PDF 工具导出的正文颜色根本不是纯黑有些是索引色PyMuPDF 读出来是 0x000000但实际渲染是深灰。也有些正文本身就是灰色文字排版。所以颜色只能作为辅助打分项不能单独当过滤条件。我见过同学写过滤条件if color (128, 128, 128)然后误杀了一整页灰色正文这是典型反面案例。提示水印过滤的安全边界是“解析干净”不是“帮你盗版去水印”。我这里的场景是把水印文字从 RAG 管道里剔除掉让大家检索到的内容干净合理这才是文档解析该做的事。5. 把多栏和水印一起解决的完整管线5.1 管线全貌刚才分别讲了多栏重建和水印过滤实际处理时这两个问题经常同时出现——一份双栏带水印的 PDF 才是完整战场。所以我把流程串成一条管线执行顺序固定解析页面所有 span拿到 bbox、字体、颜色、方向先做水印过滤把污染文本剔除标记跨栏元素检测栏间距缺口给 span 分配栏号排序并合并成行输出 Markdown 文本顺序为什么这么定关键在第二步和第三步的顺序必须先把水印文本删了再做跨栏标记。因为水印文本的 bbox 往往跨度很大比如平铺水印横跨整个页面会干扰跨栏元素检测。先删水印后面栏间距检测和全宽判断才干净。5.2 完整代码实现下面是一个完整的处理函数输入是 PyMuPDF 的 page 对象输出是该页重建后的文本。def rebuild_page_text(page): page_width page.rect.width spans extract_spans(page) # step 1: 过滤水印 spans filter_repeated_text(spans) spans filter_wide_repeated(spans, page_width) spans [s for s in spans if not is_diagonal_watermark(s)] if not spans: return # step 2: 标记跨栏元素 mark_full_width_spans(spans, page_width, ratio0.8) # step 3: 检测多栏并分配栏号 gaps find_column_gaps(spans) assign_column(spans, gaps) # step 4: 排序 normal [s for s in spans if not s.is_full_width and s.text.strip()] normal.sort(keylambda s: (s.column, s.bbox[1], s.bbox[0])) full_width [s for s in spans if s.is_full_width and s.text.strip()] full_width.sort(keylambda s: s.bbox[1]) # step 5: 跨栏元素按 y 坐标插入 all_spans [] normal_idx 0 for fw in full_width: while (normal_idx len(normal) and normal[normal_idx].bbox[1] fw.bbox[1]): all_spans.append(normal[normal_idx]) normal_idx 1 all_spans.append(fw) all_spans.extend(normal[normal_idx:]) # step 6: 合并成行 lines spans_to_lines(all_spans) return \n.join(lines)is_diagonal_watermark函数就是把方向判断和重复性判断组合起来完整版本在下面def is_diagonal_watermark(span, spansNone, tol0.2): if not span.text.strip(): return False x, y span.dir if abs(x) tol or abs(y) tol: return False # 水平或垂直不是对角线水印 if spans is not None: same_text [s for s in spans if s.text.strip() span.text.strip()] if len(same_text) 2: return False # 只出现一次的不算 return True我实际用的时候还会把颜色特征加进去比如保留所有is_light_color(span.color)的候选然后要求候选同时满足“重复 方向倾斜 浅色”三个条件中的至少两个才判定为水印。具体的打分函数根据你自己的文档集调整核心思路是“多条件交叉验证”别单凭一个特征就下结论。5.3 参数怎么调从真实 PDF 里测出来的经验值这套流程跑通后参数调优才是最耗时间的。下面是我从 ACM 双栏模板、IEEE 模板、企业内部资料三类 PDF 里实测标定出的经验值。参数建议值说明gap_threshold20pt栏间距缺口阈值正文字号 10pt 时20pt 大约是两倍字宽能避开普通缩进full_width_ratio0.8宽度超过页宽 80% 视为跨栏元素repeat_threshold3 次/页同一文本出现 3 次以上进入候选min_y_span50pt重复文本 y 坐标跨度至少 50pt才能排除页眉页脚merge_line_y_tol2pt同一行 span 的 y0 差距小于 2pt 时合并为一行color_light_threshold180RGB 三个通道均大于 180 视为浅色这些数值不是拍脑袋定的。正文字号通常 9~12pt两栏之间的空白最小也有 15~20pt所以 gap_threshold 取 20pt 能在缩进和多栏之间找到平衡。min_y_span50是因为页眉页脚的 y 坐标偏差一般不超过 10pt正文段落在页面的分布跨度至少几十 pt水印的分布更分散。但每个领域的 PDF 差异很大换一批文档之前强烈建议先抽 10 页跑一下打印出每个 span 的 bbox 和判断结果肉眼过一遍再固化参数。6. 实测与翻车复盘三份特殊 PDF 的完整排查6.1 案例一三栏论文遇到跨栏表格有一篇三栏论文前两页重建效果很好第三页突然出现一个跨两栏的大型表格。按栏排序后表格单元格里的文本被拆散到了两个栏顺序完全混乱。排查时我先把这一页的所有 span 和 bbox 打印出来发现表格内的文本 x0 分布横跨栏 1 和栏 2栏号分配混乱。最终处理是检测表格区域用 pdfplumber 的表格提取把表格 bbox 内的 span 全部从正文流中剔除表格单独提取为 Markdown 表再按表格区域左上角的 y0 插入到正文流。这样表格作为一个整体语义块存在正文排序也恢复正常。这个案例给我的教训是版面重建别死磕纯坐标遇到结构化元素表格、图片、公式就独立提取再按锚点位置插回文本流比硬排序可靠得多。6.2 案例二平铺式浅色水印混入正文一份内部资料 PDF每页正中平铺着几行浅灰色“Internal Use Only”水印。刚开始过滤时我发现 PyMuPDF 读出来的水印 color 并不是浅灰色——有些 span 读出来是 0x000000。原因部分 PDF 生成器把水印文字的着色方式设置成了索引色读取时归一化出来的 color 不可靠。单靠颜色判断直接翻车。后来我把判断逻辑改成“重复次数 y 位置分散 内容固定”三个条件同时成立。水印跨页、跨版平铺位置分散内容完全一致正文文本几乎不会在同一页出现 5 次以上且内容完全相同。这个组合条件跑下来误杀为 0水印清理干净。这个案例里我看到最有价值的点就是别迷信单一特征尤其是颜色这种容易被 PDF 生成器“变魔术”的字段。6.3 案例三双栏 PDF 里栏间距忽大忽小一份从 HTML 直接打印成 PDF 的资料排版是左右两栏但左栏窄右栏宽栏间距不稳定中间还夹着插图。一开始我用“页面宽度/2”的中线切分栏位左栏部分内容被分到右栏排序全乱。排查后发现这份 PDF 的栏间距从 20pt 到 60pt 不等单纯一个阈值无法稳定切分。最后改用 x0 聚类方案把所有非空文本块的 x0 按从小到大排序计算相邻值之间的间隙再用聚类算法把间隙分成“栏间空白”和“栏内缩进”两类。经过这个调整栏分配稳定下来。这个案例的教训很直白排版不规范的时候几何规则中线不如统计规律聚类可靠。7. 解析质量怎么验收和一点私货7.1 量化验收的三个指标代码写完不等于效果好。整理完一套解析流程建议用三个指标做量化验收。顺序正确率。随机抽 20 页人工标注每页前 20 个语义单元的阅读顺序再和程序输出对比统计顺序一致的单元占比。我自己的标准是双栏 PDF 顺序正确率不低于 95%低于这个值说明栏检测或排序还有问题。水印残留率和正文误杀率是成对看的。水印残留率正文里仍包含水印关键词的 span 数 / 总 span 数正文误杀率被过滤掉的正常正文 span 数 / 总正文 span 数。一个理想状态是两者都趋近于 0实际取舍上我宁可水印残留率稍微高一点比如 2%也绝不让正文误杀率超过 0.5%。正文被删是硬伤水印残留最多让 embedding 里混一点噪声。这三个指标加起来基本能反映解析管道对结构化文档的处理能力。跑完指标后再拿真实问答去测试 RAG 效果做最后一层验证。7.2 一些个人实战心得最后说点掏心窝子的话。PDF 解析没有银弹PyMuPDF 给的 bbox 只是基础原料真正值钱的是你用坐标做了什么决策。动手写规则之前先抽样打印 5~10 页的 bbox 和当前输出顺序肉眼过一遍再定参数。盲写规则必翻车我踩过的坑基本都是因为偷懒跳过了这一步。另一个心得是“宁可少过滤也不要多删”。水印残留最多污染一点 embedding 质量正文被删则直接丢知识。所以过滤条件从严打分从宽拿不准的文本就保留。还有一个积累的方法可以分享你可以把常见 PDF 生成器的“脾气”收集起来。某些排版工具生成的多栏 PDF文本流写得接近视觉顺序有些工具的文本流纯随机。积累了这些特征之后同一个来源的 PDF 可以直接套用已经调好的参数效率翻倍。RAG 知识库质量这件事没有捷径就是一遍遍跟文档死磕出来的。这套 bbox 处理流程算是我当前能给出的最靠谱的一套死磕方法。
阅读完成 · 觉得有帮助?
咨询建站