RAG文档解析进阶:用bbox与XY-cut解决多栏排版和水印难题

发布时间:2026/10/6 10:23:14
RAG文档解析进阶:用bbox与XY-cut解决多栏排版和水印难题 1. 为什么多栏排版和水印是 RAG 文档解析的“隐形杀手”做过 RAG 知识库的人都有一个共同的体感文本型 PDF 的解析前 80% 的工作量花在“把字读出来”后 20% 的工作量却要花在“把字读对”。而恰恰是这后 20%决定了整个检索链路的上限。你可能会用 PyMuPDF 的page.get_text()一行代码就把整页文字抽出来看着输出结果挺像那么回事但一旦把这段文本丢进向量库检索出来的答案就开始“串味”——问的是左栏的内容召回的却是右栏的句子问的是正文命中的却是满屏重复的水印文字。这不是模型的问题是解析阶段就已经把语义结构破坏了。多栏排版和水印 PDF 是两类最典型的“看起来能解析、实际上解析废掉”的场景。多栏排版的问题在于阅读顺序人眼知道先读左栏再读右栏但 PDF 内部的文本块存储顺序是按“绘制顺序”来的很多时候是左栏第一行、右栏第一行、左栏第二行、右栏第二行这样交错排列。你直接按默认顺序拼接得到的文本在语义上是断裂的。水印的问题则更隐蔽水印文字往往以极浅的颜色、旋转的角度、超大的字号铺满整页get_text()默认会把它们和正文一起抽出来导致每一段 chunk 里都混着“内部资料 请勿外传”这类噪声检索时这些高频重复词会严重干扰相似度计算。这一篇要解决的就是这两个问题。核心工具是PyMuPDFfitz提供的bboxbounding box边界框能力配合XY-cut版面分析算法把“按绘制顺序读字”升级为“按人类阅读顺序读字”同时用 bbox 的几何特征把水印从正文里剥离出去。关键词里的 RAG、bbox、PDF、XY-cut、PyMuPDF 这五个词基本就是本篇的全部技术骨架。适合已经跑通过基础 PDF 解析、但发现检索质量上不去的 RAG 开发者也适合任何需要从复杂版式 PDF 里提取干净文本的人。先说清楚一个前提bbox 不是万能的。它解决的是“文本块位置已知但顺序和归属需要重新判断”的问题。如果 PDF 本身是扫描件、文字是图片那 bbox 拿到的只是图片区域得先走 OCR。本篇讨论的是文本型 PDF也就是文字可以被直接抽取出来的那种。这个边界要先划清楚不然方向就错了。2. bbox 到底给了我们什么从 get_text 到 get_text(dict) 的认知升级2.1 默认 get_text 的文本顺序陷阱大部分人第一次用 PyMuPDF 都是这样写的import fitz doc fitz.open(multi_column.pdf) for page in doc: text page.get_text() print(text)输出看起来是一整段文字似乎没问题。但你把这段文字和原 PDF 对照着看就会发现顺序是乱的。原因在于get_text()默认走的是“文本流”模式它按照 PDF 内容流里文本绘制指令的先后顺序输出。而 PDF 生成工具尤其是 LaTeX、InDesign、部分 Word 导出在绘制多栏内容时为了优化渲染经常是逐行跨栏绘制的。也就是说PDF 内部根本不存在“左栏”这个概念它只知道“我在坐标 (x1,y1) 画了这几个字又在 (x2,y2) 画了那几个字”。这里有个很反直觉的点PDF 不是为“阅读”设计的格式它是为“打印”设计的格式。它记录的是每个字形画在纸上的哪个位置而不是这些字之间的语义关系。所以指望get_text()直接给你正确的阅读顺序本身就是一种误解。理解这一点后面的所有操作逻辑就顺了。2.2 get_text(dict) 返回的结构长什么样要拿到 bbox得换一个调用方式page doc[0] blocks page.get_text(dict)[blocks]返回的blocks是一个列表每个元素是一个字典关键字段包括type0 表示文本块1 表示图像块bbox一个四元组(x0, y0, x1, y1)表示这个块的左上角和右下角坐标lines文本块内部的行列表每行有bbox和spansspans行内的文本片段每个 span 有text、font、size、color、bbox等属性这个层级结构是block → line → span。对于多栏分析我们主要操作到block 和 line 这一层对于水印识别span 层的size和color才是关键。for block in blocks: if block[type] ! 0: continue x0, y0, x1, y1 block[bbox] print(fBlock bbox: ({x0:.1f}, {y0:.1f}, {x1:.1f}, {y1:.1f})) for line in block[lines]: line_text .join(span[text] for span in line[spans]) print(f Line: {line_text[:40]})跑一遍这段代码你会看到每个文本块的坐标和内容。多栏 PDF 的典型特征是同一栏内的文本块 x 坐标范围高度重叠不同栏的文本块 x 范围明显分离。这就是 XY-cut 算法的切入点。2.3 坐标系与页面尺寸别在原点问题上翻车PyMuPDF 的坐标系原点在页面左上角x 向右增大y 向下增大。这和数学坐标系原点在左下是反的和 PDF 内部的原生坐标系原点在左下也是反的。PyMuPDF 帮你做了翻转所以你拿到的y0是块顶部、y1是块底部y0 y1恒成立。页面尺寸用page.rect拿print(page.rect.width, page.rect.height)A4 纸在 72 dpi 下大约是 595 × 842。这个数值在后续做相对阈值判断时很有用比如“水印字号超过页面高度的 1/10”这种规则就得用页面尺寸做归一化不然换个纸张大小规则就失效了。提示有些 PDF 存在/Rotate属性页面被旋转了 90 度或 270 度。这种情况下page.rect返回的是旋转后的尺寸但 block 的 bbox 可能还是旋转前的坐标系。稳妥做法是先检查page.rotation必要时用page.set_rotation(0)统一到未旋转状态再解析。3. XY-cut 算法拆解让机器学会“先读左栏再读右栏”3.1 XY-cut 的核心思想递归地找空白切割线XY-cut 是版面分析里的经典算法思路朴素但有效一页文档里栏与栏之间、段落与段落之间总有大片空白。找到这些空白沿着空白把页面切开切到不能再切剩下的就是一个个语义单元。具体分两个方向X-cut垂直切割在水平方向上找一条竖直的空白带把页面分成左右两部分。多栏排版主要靠这个。Y-cut水平切割在竖直方向上找一条水平的空白带把页面分成上下两部分。区分页眉、正文、页脚主要靠这个。算法是递归的先尝试 X-cut切完对每一块再尝试 Y-cut交替进行直到找不到满足条件的切割线为止。最后得到的叶子节点就是按阅读顺序排列的文本块。3.2 用 bbox 实现一个可用的 XY-cut理论说完上代码。核心是维护一个“待切割区域”的列表每次切割把区域一分为二直到所有区域都无法再切。def xy_cut(bboxes, page_width, page_height, min_gap10): bboxes: list of (x0, y0, x1, y1) 返回按阅读顺序排列的 bbox 索引列表 def find_x_cut(regions): # regions: list of (idx, bbox) if len(regions) 1: return None sorted_r sorted(regions, keylambda r: r[1][0]) # 找相邻块之间的最大水平间隙 best_gap 0 best_pos None for i in range(len(sorted_r) - 1): gap sorted_r[i 1][1][0] - sorted_r[i][1][2] if gap best_gap: best_gap gap best_pos i if best_gap min_gap: return best_pos return None def find_y_cut(regions): if len(regions) 1: return None sorted_r sorted(regions, keylambda r: r[1][1]) best_gap 0 best_pos None for i in range(len(sorted_r) - 1): gap sorted_r[i 1][1][1] - sorted_r[i][1][3] if gap best_gap: best_gap gap best_pos i if best_gap min_gap: return best_pos return None def recursive_cut(regions, depth0): if len(regions) 1 or depth 20: return regions # 先尝试 X-cut pos find_x_cut(regions) if pos is not None: sorted_r sorted(regions, keylambda r: r[1][0]) left sorted_r[:pos 1] right sorted_r[pos 1:] return recursive_cut(left, depth 1) recursive_cut(right, depth 1) # 再尝试 Y-cut pos find_y_cut(regions) if pos is not None: sorted_r sorted(regions, keylambda r: r[1][1]) top sorted_r[:pos 1] bottom sorted_r[pos 1:] return recursive_cut(top, depth 1) recursive_cut(bottom, depth 1) # 都切不动了按 y 再按 x 排序返回 return sorted(regions, keylambda r: (round(r[1][1] / 5), r[1][0])) indexed list(enumerate(bboxes)) result recursive_cut(indexed) return [idx for idx, _ in result]这段代码有几个工程上的取舍需要说明。min_gap设成 10 是个经验值对应大约 10 个 PDF 单位约 3.5mm。太小了会把同一段落内的行间距误判成栏间距太大了两栏之间如果间距不够就切不开。实际项目里我一般会把它设成页面宽度的 1.5% 到 2%这样不同纸张尺寸都能自适应。depth 20是防止极端情况下递归爆栈。正常一页文档切个五六层就到头了20 层足够覆盖绝大多数版式。3.3 切割后的排序为什么不能只靠坐标XY-cut 切完之后叶子节点的顺序就是阅读顺序。但这里有个细节递归过程中左右、上下的拼接顺序决定了最终顺序。上面代码里 X-cut 是left rightY-cut 是top bottom这符合从左到右、从上到下的阅读习惯。但如果是阿拉伯语、希伯来语这种从右到左的语言X-cut 就得反过来拼。中文和英文都是左到右所以默认逻辑没问题。还有一个坑页眉页脚会干扰切割。页眉通常横跨整个页面宽度Y-cut 在它和正文之间能找到一条水平空白这没问题。但如果页眉和正文之间没有明显空白页眉就会被并进正文块导致每个 chunk 开头都带着页眉文字。解决办法是在 XY-cut 之前先用 bbox 的 y 坐标把页面顶部 5% 和底部 5% 的区域单独拎出来判断是不是页眉页脚是的话直接丢弃或单独存储。def is_header_footer(bbox, page_height, ratio0.05): y0, y1 bbox[1], bbox[3] return y1 page_height * ratio or y0 page_height * (1 - ratio)这个ratio不是固定的学术论文页眉页脚窄可以设 0.04杂志排版页眉页脚宽可能要到 0.08。我一般会先跑一遍统计看页面顶部和底部区域的文本块分布再定这个值。4. 水印剥离用 bbox 的几何与样式特征做“减法”4.1 水印的三种典型形态与识别特征水印不是一种东西它至少有三种常见形态识别策略完全不同水印类型典型特征识别依据文字水印浅灰色、大字号、旋转角度span 的 color 接近白色、size 异常大图片水印半透明 PNG 铺满页面block type1bbox 覆盖面积大重复文字水印同一文字在页面多处重复出现文本内容在页面内高频重复第一种最常见也最好处理。第二种需要走图像块判断如果水印是图片且和正文重叠剥离起来比较麻烦通常只能靠 OCR 重新识别正文区域。第三种是“制度与轮回”这类文档里常见的“内部资料”字样每隔几行就出现一次靠内容重复度就能识别。4.2 基于 span 属性的文字水印过滤先看最典型的文字水印。它的 span 属性有几个明显异常def is_watermark_span(span, page_width, page_height): text span[text].strip() if not text: return False size span[size] color span[color] # 整数0xRRGGBB # 字号异常大 if size page_height * 0.08: return True # 颜色极浅接近白色 r (color 16) 0xFF g (color 8) 0xFF b color 0xFF if r 230 and g 230 and b 230: return True return Falsesize page_height * 0.08这个阈值怎么来的A4 纸高度 8428% 就是 67pt正常正文 10-12pt标题 18-24pt超过 67pt 的基本只有水印和装饰性大字。这个阈值我调过很多次0.08 是个比较稳的起点遇到特殊版式再微调。颜色判断用 RGB 三个通道都大于 230对应的是接近白色的浅灰。但要注意有些 PDF 的水印颜色不是纯灰而是带一点蓝或红这时候单靠 RGB 阈值会漏。更稳的做法是算亮度luminance 0.299 * r 0.587 * g 0.114 * b if luminance 220: return True亮度公式是 ITU-R BT.601 标准人眼对绿色最敏感所以绿色权重最高。用亮度判断比单纯 RGB 阈值更符合视觉感知。4.3 旋转水印的 bbox 特征旋转水印有个很明显的 bbox 特征它的 bbox 宽高比和文字实际内容严重不符。一行水平文字“内部资料”bbox 是宽扁的但旋转 45 度之后bbox 会变成一个接近正方形的区域因为旋转后的文字在 x 和 y 方向都占据了空间。def is_rotated_watermark(span, page_width): bbox span[bbox] w bbox[2] - bbox[0] h bbox[3] - bbox[1] text_len len(span[text].strip()) if text_len 0: return False # 正常水平文字宽高比应该远大于 1 aspect w / max(h, 1) # 如果宽高比接近 1 但文字很长很可能是旋转的 if aspect 2 and text_len 4: return True return False这个判断不是 100% 准因为短文字比如两个字的 bbox 本来就接近正方形。所以要和字号、颜色判断组合使用单一特征容易误杀。4.4 过滤后的文本重组保持 block 结构水印过滤不能简单地把 span 删掉就完事因为一个 block 里可能既有正文 span 又有水印 span。正确做法是在 span 层面过滤然后重新组装 line 和 blockdef clean_block(block, page_width, page_height): if block[type] ! 0: return None new_lines [] for line in block[lines]: clean_spans [ span for span in line[spans] if not is_watermark_span(span, page_width, page_height) and not is_rotated_watermark(span, page_width) ] if clean_spans: new_lines.append({bbox: line[bbox], spans: clean_spans}) if not new_lines: return None return {bbox: block[bbox], lines: new_lines}这样处理完block 的 bbox 保持不变因为位置信息还有用但内部的 span 已经干净了。后续 XY-cut 用的是 block 的 bbox不受影响。注意有些水印是作为独立 block 存在的整个 block 都是水印。这种情况下clean_block返回 None直接丢弃即可。判断依据是这个 block 内所有 span 都被判定为水印。5. 把 bbox 和 XY-cut 串成完整流水线5.1 完整处理流程与代码骨架前面几节是零件这一节把它们组装成一条能跑的流水线。整体流程是打开 PDF逐页处理提取get_text(dict)的 blocks对每个 block 做水印过滤得到干净 block收集所有干净 block 的 bbox跑 XY-cut得到阅读顺序按顺序拼接文本输出import fitz def parse_pdf(pdf_path, min_gap_ratio0.015): doc fitz.open(pdf_path) all_pages_text [] for page in doc: page_width page.rect.width page_height page.rect.height min_gap page_width * min_gap_ratio blocks page.get_text(dict)[blocks] clean_blocks [] for block in blocks: cleaned clean_block(block, page_width, page_height) if cleaned: clean_blocks.append(cleaned) if not clean_blocks: all_pages_text.append() continue bboxes [b[bbox] for b in clean_blocks] order xy_cut(bboxes, page_width, page_height, min_gap) page_text_parts [] for idx in order: block clean_blocks[idx] block_text [] for line in block[lines]: line_text .join(span[text] for span in line[spans]) block_text.append(line_text) page_text_parts.append(\n.join(block_text)) all_pages_text.append(\n\n.join(page_text_parts)) return all_pages_text这段代码跑通之后多栏 PDF 的输出顺序就对了。但实际项目里还有几个细节要处理下面逐个说。5.2 跨页文本块的合并问题XY-cut 是页内算法它不知道上一页和下一页的关系。但很多文档的段落是跨页的比如一个段落从第 3 页底部延续到第 4 页顶部。如果按页切分后直接拼接这个段落就被硬生生截断了chunk 的语义完整性受损。解决办法是在页与页之间做一次“续接判断”看上一页最后一个 block 的末尾和下一页第一个 block 的开头是否满足“上一页末尾没有句号、下一页开头是小写字母或非标题格式”这类条件。满足的话把两个 block 合并。def should_merge(prev_text, next_text): if not prev_text or not next_text: return False prev_stripped prev_text.rstrip() next_stripped next_text.lstrip() # 上一页末尾没有句末标点 if prev_stripped and prev_stripped[-1] in 。.!?: return False # 下一页开头不是标题格式标题通常短且无标点 if len(next_stripped) 20 and next_stripped[-1] not in 。.!?: return False return True这个判断是启发式的不可能 100% 准但能覆盖大部分情况。误判的代价是偶尔把两个不相关的段落合并比截断段落的代价小。5.3 表格区域的特殊处理表格是 XY-cut 的另一个挑战。表格内部的单元格之间也有空白XY-cut 会把它们切成一个个独立的小块然后按坐标排序。如果表格是规则的排序结果可能碰巧是对的如果表格有合并单元格排序就乱了。我的做法是先用 bbox 识别出表格区域把整个表格作为一个整体 block 处理不参与 XY-cut。识别表格可以用 PyMuPDF 的page.find_tables()它返回的表格对象有 bbox把这个 bbox 范围内的所有小 block 合并成一个表格内部用table.extract()单独提取。tables page.find_tables() table_bboxes [t.bbox for t in tables] def in_any_table(bbox, table_bboxes): for tb in table_bboxes: if (bbox[0] tb[0] and bbox[1] tb[1] and bbox[2] tb[2] and bbox[3] tb[3]): return True return False在 XY-cut 之前把落在表格 bbox 内的 block 标记出来XY-cut 只处理非表格 block表格 block 按 bbox 的 y 坐标插入到最终顺序里。这样表格内容不会被切碎检索时表格的语义完整性也保住了。5.4 输出格式与 chunk 切分的衔接解析出来的文本最终要切成 chunk 进向量库。这里有个衔接问题XY-cut 输出的 block 顺序是对的但 block 大小不一。有的 block 是一整段有的 block 只有一行标题。直接按 block 切 chunk会导致 chunk 长度波动很大。我的做法是XY-cut 输出后先按 block 顺序拼接成完整文本再用语义分块策略切 chunk。具体是标题 block 作为新 chunk 的起点正文 block 累积到一定长度比如 500 字后切一刀。这样每个 chunk 都有明确的主题边界检索时命中率更高。def blocks_to_chunks(blocks_text, max_chunk_size500): chunks [] current [] current_len 0 for text in blocks_text: is_heading len(text) 30 and not text.endswith(。) if is_heading and current: chunks.append(\n.join(current)) current [text] current_len len(text) else: current.append(text) current_len len(text) if current_len max_chunk_size: chunks.append(\n.join(current)) current [] current_len 0 if current: chunks.append(\n.join(current)) return chunksmax_chunk_size500是个经验值对应大约 300-400 个 token。太小了语义不完整太大了检索精度下降。实际项目里我会根据 embedding 模型的最大输入长度反推这个值留 20% 余量。6. 实测中遇到的坑与调优经验6.1 双栏学术论文的 min_gap 调参学术论文是双栏排版的典型。我拿一篇 IEEE 格式的论文实测页面宽度 595两栏之间的间距大约是 20pt。按min_gap_ratio0.015算min_gap 8.9pt小于 20pt能切开。但问题是论文里有些公式和图表是跨栏的它们的 bbox 横跨两栏XY-cut 遇到这种块就切不动了。解决办法是先把跨栏块识别出来单独处理。跨栏块的 bbox 宽度接近页面宽度而单栏块宽度大约是页面宽度的一半。用这个特征区分def is_spanning_block(bbox, page_width): w bbox[2] - bbox[0] return w page_width * 0.7跨栏块通常是标题、大图、宽表格它们的位置本身就决定了阅读顺序——它们把页面分成上下两部分上半部分先读下半部分后读。所以处理策略是先用跨栏块把页面做 Y-cut然后在每个 Y 区间内对单栏块做 X-cut。6.2 水印颜色判断的误杀问题前面用亮度 220 判断水印实测中发现会误杀一种情况正文里的浅色引用文字。有些排版会把引用块用浅灰色显示亮度也在 220 以上结果被当成水印删了。改进方案是加一个面积判断水印通常覆盖面积大引用块面积小。用 span 的 bbox 面积和页面面积比def is_large_span(span, page_width, page_height): bbox span[bbox] area (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) page_area page_width * page_height return area page_area * 0.05只有亮度高且面积大的 span 才判定为水印。这样浅色引用块因为面积小不会被误杀。5% 这个阈值也是调出来的水印一般占页面 10% 以上引用块通常不到 3%。6.3 旋转水印的 bbox 计算偏差PyMuPDF 返回的 span bbox 是轴对齐的也就是说一个旋转 45 度的文字它的 bbox 是包住这个旋转文字的最小水平矩形。这导致 bbox 面积比文字实际面积大很多用面积判断会误判。更准的做法是用span[dir]字段它返回文字的方向向量。水平文字是(1, 0)旋转文字的方向向量不是这个。直接判断方向向量def is_rotated(span): dir_vec span.get(dir, (1, 0)) return abs(dir_vec[0] - 1) 0.01 or abs(dir_vec[1]) 0.01这个判断比 bbox 宽高比准得多因为它是从 PDF 内部的方向信息直接读的不受 bbox 轴对齐的影响。实测下来dir字段在绝大多数 PDF 里都可用只有极少数老 PDF 可能缺失缺失时回退到宽高比判断。6.4 性能优化大文档的解析速度一篇 300 页的 PDF如果每页都跑完整的 XY-cut 和水印过滤解析时间可能到几十秒。优化点有几个第一水印判断结果缓存。同一篇文档的水印样式通常一致第一页识别出的水印特征字号、颜色、方向可以缓存下来后续页面直接用特征匹配不用重新计算。第二XY-cut 的 min_gap 预计算。不要每页都重新算 min_gap用第一页的页面宽度算一次就行后续页面复用。第三并行处理。PyMuPDF 的页面对象不是线程安全的但可以按页拆分到多个进程。用multiprocessing.Pool把页面分块处理最后合并结果。实测 300 页文档从 40 秒降到 12 秒左右。from multiprocessing import Pool def process_page(args): pdf_path, page_num args doc fitz.open(pdf_path) page doc[page_num] # ... 处理逻辑 return page_text with Pool(8) as pool: results pool.map(process_page, [(pdf_path, i) for i in range(len(doc))])注意每个进程要独立打开 PDF不能共享 doc 对象。这是 PyMuPDF 的进程安全限制踩过一次坑共享 doc 会导致随机崩溃。7. 解析质量怎么验证几个可落地的检查手段7.1 阅读顺序的人工抽检方法XY-cut 跑完怎么知道顺序对不对最直接的方法是把解析结果和原 PDF 对照着读。但 300 页文档不可能全读抽检策略是随机抽 10 页把解析文本按行打印和 PDF 截图并排看。重点看三个地方栏与栏的交界处、页面底部到下一页顶部的过渡、表格前后。我一般会写个小脚本把解析结果按 block 输出每个 block 前面标上 bbox 坐标这样对照时能快速定位for idx in order: block clean_blocks[idx] print(f[{block[bbox]}]) for line in block[lines]: print(.join(s[text] for s in line[spans])) print(---)7.2 水印残留的自动化检测水印有没有删干净可以用高频词统计来自动检测。水印文字的特点是重复率高如果解析结果里某个短语出现频率异常高比如每页都出现好几次大概率是水印残留。from collections import Counter def detect_watermark_residue(all_text, threshold0.5): # 按页统计 pages all_text phrase_counter Counter() for page_text in pages: # 简单按标点切分 phrases [p.strip() for p in page_text.replace(\n, 。).split(。) if p.strip()] for p in phrases: if len(p) 20: phrase_counter[p] 1 total_pages len(pages) suspects [ (phrase, count) for phrase, count in phrase_counter.items() if count total_pages * threshold ] return suspectsthreshold0.5表示超过一半的页面都出现这个短语基本可以断定是水印或页眉页脚。这个方法帮我抓到过好几次漏网的水印尤其是那种颜色不浅、字号不大的“隐形水印”。7.3 检索效果的端到端验证解析质量最终要落到检索效果上。我的验证方法是准备 20 个问题每个问题的答案明确在某一栏的某一段里看检索能不能命中正确的 chunk。如果多栏顺序错了问题问左栏内容检索会命中右栏的相似段落答案就偏了。这个验证不用等整个 RAG 系统搭完解析完直接做关键词检索就行。把每个 chunk 存成一个列表用 BM25 或简单的 TF-IDF 检索看 top-3 结果里有没有正确答案所在的 chunk。有说明解析顺序对没有回去查 XY-cut 的参数。8. 几个容易被忽略的边界情况8.1 竖排文字 PDF中文古籍、部分日文文档是竖排的文字从上到下、从右到左排列。XY-cut 的默认逻辑左到右、上到下完全不适用。竖排文档的 bbox 特征是文字块的宽度小、高度大因为一行字是竖着排的。处理竖排要改两个地方X-cut 的拼接顺序改成right leftY-cut 改成bottom top。同时 min_gap 的判断方向也要换竖排的栏间距是水平方向的但栏内行间距是垂直方向的得用不同的阈值。def is_vertical_text(blocks): # 统计 block 的宽高比竖排文字宽高比普遍小于 1 ratios [] for b in blocks: w b[bbox][2] - b[bbox][0] h b[bbox][3] - b[bbox][1] if h 0: ratios.append(w / h) if not ratios: return False avg_ratio sum(ratios) / len(ratios) return avg_ratio 0.5这个判断放在解析开始前自动检测文档方向然后切换 XY-cut 的拼接逻辑。8.2 图文混排的阅读顺序图文混排的文档图片和文字交错。XY-cut 只处理文本块图片块被忽略了。但图片的位置会影响阅读顺序——如果图片在左栏中间右栏的文字应该先读还是后读我的处理是把图片块也纳入 XY-cut但不输出文本只占位。这样图片的位置会参与切割保证文字块的相对顺序正确。图片块在最终输出时替换成一个占位符比如[IMAGE]后续如果需要图片描述可以单独处理。def block_to_text(block): if block[type] 1: return [IMAGE] # ... 文本块处理8.3 加密 PDF 的解析限制有些 PDF 设置了权限密码禁止复制文字。PyMuPDF 打开这种 PDF 时get_text()会返回空字符串。判断方法是doc fitz.open(pdf_path) if doc.is_encrypted: # 尝试空密码 if not doc.authenticate(): print(PDF 受密码保护无法解析)如果空密码能解开就正常解析解不开只能走 OCR 路线。这个边界要在流水线入口就判断不要等到解析到一半才发现全是空文本。9. 写在最后解析是 RAG 的地基值得多花时间RAG 系统里embedding 模型、向量库、检索策略这些环节大家都愿意花时间调但文档解析往往被当成“一次性工作”跑通就不管了。实际做下来解析阶段丢掉的语义信息后面任何环节都补不回来。多栏顺序错了chunk 的语义就是碎的水印没删干净检索的相似度就是被污染的。这两个问题不解决后面调 embedding 模型、调 top-k都是在错误的地基上盖楼。bbox 和 XY-cut 这套方案代码量不大核心逻辑加起来不到 200 行但解决的是最影响检索质量的两个问题。我自己的项目里加上这套解析逻辑之后检索命中率从 60% 出头提到了 85% 以上尤其是多栏文档的问答准确率提升明显。最后分享一个我踩过的坑不要试图用一套参数适配所有 PDF。学术论文、杂志、技术手册、扫描件的版式差异极大min_gap、水印阈值、页眉页脚比例这些参数最好做成可配置的按文档类型预设几套。我现在的做法是维护一个config.yaml每种文档类型一套参数解析前先自动判断文档类型靠页面尺寸、栏数、字体分布这些特征再加载对应配置。这样比一套参数硬扛所有场景稳得多。