1. 为什么多栏排版和水印是 RAG 文档解析的硬骨头
做过 RAG 知识库的人都有一个共识:PDF 解析是整个链路里最脏最累的活。文本型 PDF 还好,一旦碰上多栏排版、带水印、带页眉页脚的学术论文或者扫描件,很多解析工具直接歇菜。我见过太多项目,embedding 模型选得挺好,向量库也搭得漂亮,结果检索出来的 chunk 全是乱的——两栏文字被交错拼在一起,水印文字混进了正文,页眉页脚反复出现在每个片段里。这种数据喂给大模型,回答质量能好才怪。
这篇要聊的是 bbox 实战,核心解决两个具体问题:多栏排版的阅读顺序还原和水印文字的识别与剔除。用到的工具是 PyMuPDF(也就是 fitz),配合 XY-cut 算法做版面分析。整套方案不依赖任何外部 OCR 服务,纯本地跑,适合对数据隐私有要求的场景。
适合谁看?如果你正在搭 RAG 知识库,已经过了"能跑通"的阶段,开始被解析质量卡脖子,那这篇就是写给你的。如果你还没接触过 bbox 这个概念,也不用慌,我会从最基础的坐标系统讲起,保证你能跟着复现。
先说清楚 bbox 是什么。bbox 就是 bounding box,边界框,PDF 里每一个文本块、每一行、每一个字符都有自己的坐标范围,通常用 (x0, y0, x1, y1) 表示,分别是左上角和右下角的坐标。PyMuPDF 的page.get_text("dict")能把整页内容拆成 block、line、span 三级结构,每一级都带 bbox。拿到这些坐标,我们就能做版面分析——判断哪些文字属于左栏,哪些属于右栏,哪些是水印。
为什么不用现成的 PDF 解析库?比如 pdfplumber、pdfminer 这些。它们也能拿到坐标,但在处理复杂版面时,要么速度慢,要么对旋转文字、倾斜水印的支持不够。PyMuPDF 底层是 MuPDF,C 写的,速度快,坐标信息全,还能拿到文字的旋转角度和字体信息,做水印识别时这些信息很关键。
XY-cut 算法是版面分析里的经典方法,思路很朴素:递归地在水平和垂直方向上找空白切割线,把页面切成一个个独立的区域。多栏排版的核心特征就是中间有一条垂直的空白带,XY-cut 能自动找到这条带,把左右栏分开。水印的识别则要靠字体、颜色、旋转角度、透明度这几个维度的特征组合判断。
下面我会把整个流程拆开讲,从环境准备到代码实现,再到踩坑经验,尽量做到你复制过去就能用。
2. 环境准备与 PyMuPDF 基础操作
2.1 安装与版本选择
PyMuPDF 的安装很简单:
pip install PyMuPDF但版本选择有讲究。1.23.x 之后的版本对get_text("dict")的输出结构做过调整,如果你参考的教程比较老,可能会对不上。我目前用的是 1.24.x,稳定性和功能都比较均衡。安装完可以这样验证:
import fitz print(fitz.__doc__)输出里会显示 MuPDF 的版本和 PyMuPDF 的版本。如果显示的是 1.24 以上,后面的代码基本可以直接跑。
注意:PyMuPDF 的 import 名是
fitz,不是pymupdf。虽然新版本也支持import pymupdf,但为了兼容性,建议统一用fitz。
2.2 理解 PDF 的坐标系统
PDF 的坐标原点和我们平时用的屏幕坐标不一样。原点在页面左下角,x 轴向右,y 轴向上。但 PyMuPDF 做了转换,它返回的坐标是原点在左上角,y 轴向下,这样更符合我们的直觉。这一点很重要,后面做 XY-cut 的时候,判断"上方"和"下方"都基于这个坐标系。
拿一个 A4 页面举例,宽度约 595 点(point),高度约 842 点。1 点等于 1/72 英寸。如果你拿到一个 bbox 是 (50, 100, 300, 120),意思是这个文本块从左边 50 点、顶部 100 点开始,到右边 300 点、底部 120 点结束。宽度 250 点,高度 20 点,大概是一行文字。
2.3 提取页面结构化文本
核心 API 是page.get_text("dict"),返回的结构是这样的:
{ "width": 595.0, "height": 842.0, "blocks": [ { "type": 0, # 0 表示文本块,1 表示图片 "bbox": (x0, y0, x1, y1), "lines": [ { "bbox": (x0, y0, x1, y1), "wmode": 0, # 书写模式,0 水平,1 垂直 "dir": (1, 0), # 文字方向向量 "spans": [ { "bbox": (x0, y0, x1, y1), "text": "文字内容", "font": "字体名", "size": 12.0, "flags": 0, "color": 0, "origin": (x, y), } ] } ] } ] }span 是最小单位,通常对应一段连续的同格式文字。line 是一行,block 是一个段落或一个独立的文本区域。做版面分析时,我们主要在 block 和 line 这两个层级上操作。
我习惯先把所有 span 拍平成一个列表,每个元素带上它所属的 block 索引和 line 索引,方便后续回溯:
def extract_spans(page): 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"]): for si, span in enumerate(line["spans"]): spans.append({ "block": bi, "line": li, "span": si, "bbox": span["bbox"], "text": span["text"], "font": span["font"], "size": span["size"], "color": span["color"], "dir": line["dir"], "origin": span["origin"], }) return spans这个列表就是后面所有分析的原材料。
2.4 可视化调试:把 bbox 画出来
光看坐标数字很难判断对不对,我强烈建议先把 bbox 画到页面上看一眼。PyMuPDF 支持在页面上画矩形:
def draw_bboxes(page, spans, output_path): for span in spans: rect = fitz.Rect(span["bbox"]) page.draw_rect(rect, color=(1, 0, 0), width=0.5) pix = page.get_pixmap(dpi=150) pix.save(output_path)跑一遍,打开图片,你就能直观看到每个 span 的范围。这一步在调试 XY-cut 参数时特别有用,能帮你快速定位问题。我刚开始做的时候,就是因为没可视化,调了半天参数都不知道错在哪。
3. XY-cut 算法还原多栏阅读顺序
3.1 多栏排版的本质问题
先说说多栏排版为什么会让解析结果乱掉。PyMuPDF 返回的 block 顺序,默认是按 PDF 内部的绘制顺序来的,不一定是人眼的阅读顺序。对于两栏论文,PDF 可能先画完左栏第一段,再画右栏第一段,再画左栏第二段……这样提取出来的文本就是交错的。
更麻烦的是,有些 PDF 的 block 会把左右栏的同一行合并成一个 block,因为它们在垂直方向上有重叠。这时候你按 block 提取,得到的就是"左栏文字 + 右栏文字"混在一行。
XY-cut 的思路是:不管 PDF 内部怎么组织,我只看坐标。如果页面中间有一条从上到下的空白带,把页面分成左右两部分,那这两部分就是两个独立的阅读区域,应该先读完左边再读右边。
3.2 XY-cut 的核心逻辑
XY-cut 是一个递归算法,每一步做两件事:
- 投影:把所有 bbox 投影到 x 轴和 y 轴上,得到每个位置的覆盖情况。
- 找切割线:在投影的空白区域里找一条最宽的切割线,把当前区域切成两块。
- 递归:对切出来的两块分别重复上述过程,直到无法再切。
判断用水平切还是垂直切,有个经验规则:先尝试垂直切(分栏),如果切出来的区域宽度比例合理,就采用;否则尝试水平切(分段)。实际实现时,通常是两种都试,选切割线最宽的那个方向。
投影的具体做法:假设当前区域的范围是 (x0, y0, x1, y1),把所有落在这个区域内的 bbox 拿出来,在 x 轴上做区间覆盖。比如有三个 bbox 的 x 范围分别是 [50, 300]、[320, 570]、[50, 300],那么 x 轴上的覆盖情况是:50-300 被覆盖两次,320-570 被覆盖一次,300-320 之间是空白。这条 300-320 的空白带就是候选的垂直切割线。
3.3 代码实现
先写投影函数:
def project(bboxes, axis): """把 bboxes 投影到指定轴上,返回覆盖区间列表""" intervals = [] for bbox in bboxes: if axis == "x": intervals.append((bbox[0], bbox[2])) else: intervals.append((bbox[1], bbox[3])) intervals.sort() merged = [] for start, end in intervals: if merged and start <= merged[-1][1]: merged[-1] = (merged[-1][0], max(merged[-1][1], end)) else: merged.append((start, end)) return merged再写找切割线的函数:
def find_gap(merged, min_gap=10): """在合并后的区间里找最大的空白间隙""" gaps = [] for i in range(len(merged) - 1): gap_start = merged[i][1] gap_end = merged[i + 1][0] if gap_end - gap_start >= min_gap: gaps.append((gap_start, gap_end, gap_end - gap_start)) if not gaps: return None return max(gaps, key=lambda g: g[2])然后是递归切割:
def xy_cut(bboxes, depth=0, max_depth=10): """返回切割后的区域列表,每个区域是一个 bbox 列表""" if depth >= max_depth or len(bboxes) <= 1: return [bboxes] # 尝试垂直切 x_merged = project(bboxes, "x") x_gap = find_gap(x_merged) # 尝试水平切 y_merged = project(bboxes, "y") y_gap = find_gap(y_merged) # 选择更宽的切割线 if x_gap and (not y_gap or x_gap[2] >= y_gap[2]): cut_pos = (x_gap[0] + x_gap[1]) / 2 left = [b for b in bboxes if b[2] <= cut_pos] right = [b for b in bboxes if b[0] >= cut_pos] if not left or not right: return [bboxes] return xy_cut(left, depth + 1, max_depth) + xy_cut(right, depth + 1, max_depth) elif y_gap: cut_pos = (y_gap[0] + y_gap[1]) / 2 top = [b for b in bboxes if b[3] <= cut_pos] bottom = [b for b in bboxes if b[1] >= cut_pos] if not top or not bottom: return [bboxes] return xy_cut(top, depth + 1, max_depth) + xy_cut(bottom, depth + 1, max_depth) else: return [bboxes]这段代码有几个关键参数需要调:
min_gap:最小空白宽度,默认 10 点。太小会把字间距当成切割线,太大会漏掉真正的分栏。A4 两栏论文的栏间距通常在 15-25 点之间,10 是个安全的起点。max_depth:递归深度,默认 10。防止无限递归,一般 5-6 层就够了。- 切割线位置取间隙的中点,避免偏向某一侧。
3.4 把切割结果映射回文本
XY-cut 返回的是一堆 bbox 分组,每个分组代表一个阅读区域。接下来要按阅读顺序把这些区域排好,再提取文字。
阅读顺序的排序规则:先按区域的上边界 y0 排序,y0 相近的按 x0 排序。对于两栏论文,左栏的 y0 和右栏的 y0 可能差不多,但左栏的 x0 更小,所以左栏会排在前面。这样就能保证先读左栏再读右栏。
def sort_regions(regions): """按阅读顺序排序区域""" def region_key(region): y0 = min(b[1] for b in region) x0 = min(b[0] for b in region) return (round(y0 / 10), x0) # y0 分桶,避免微小差异影响排序 return sorted(regions, key=region_key)round(y0 / 10)这个分桶操作很关键。如果两个区域的 y0 差了几个点,直接比较会导致排序不稳定。分桶后,y0 在同一个 10 点范围内的区域会被认为在同一行,再按 x0 排序。
最后把区域内的 span 按 (y0, x0) 排序,拼接文字:
def extract_ordered_text(spans, regions): ordered_regions = sort_regions(regions) result = [] for region in ordered_regions: region_spans = [s for s in spans if s["bbox"] in region] region_spans.sort(key=lambda s: (round(s["bbox"][1] / 5), s["bbox"][0])) text = "".join(s["text"] for s in region_spans) result.append(text) return "\n".join(result)3.5 实操心得:什么时候 XY-cut 会失效
XY-cut 不是万能的,我踩过几次坑:
第一,栏间距太小或者没有栏间距。有些 PDF 的两栏之间只有几个点的间隙,min_gap=10就找不到切割线了。这时候要把min_gap调小到 5 甚至 3,但代价是可能把字间距误判成切割线。我的做法是先可视化看一眼,确认栏间距的实际宽度再定参数。
第二,跨栏的图表或标题。如果页面顶部有一个横跨两栏的大标题,XY-cut 会先水平切一刀,把标题和正文分开,这是对的。但如果图表跨栏且和正文有重叠,切割就会出问题。这种情况我一般手动处理,或者用page.get_drawings()把图形区域排除掉再做 XY-cut。
第三,三栏以上的排版。XY-cut 递归处理三栏没问题,但排序规则要调整。三栏的阅读顺序是左、中、右,按 x0 排序就行。但如果中间栏的某段文字和左右栏的 y0 差异很大,分桶策略可能要调。
实操建议:XY-cut 的参数没有万能值,每换一批 PDF 都要重新调。我的习惯是先用 20 份样本跑一遍,人工检查阅读顺序,统计错误率,再决定参数。
4. 水印识别与剔除的实战方案
4.1 水印的几种常见形态
水印这东西,形态比多栏排版还杂。我见过的至少有这几类:
- 文字水印:斜着铺满页面的"机密"、"样本"、"仅供内部使用"之类。
- 图片水印:一张半透明的 logo 或图案,重复平铺。
- 页眉页脚:严格说不算水印,但同样会污染正文,处理思路类似。
- 背景色块:某些 PDF 会有浅色背景,提取文字时可能带出多余字符。
文字水印是最好处理的,因为它在get_text("dict")里就是普通的 span,只是特征和正文不一样。图片水印要靠page.get_images()或者page.get_drawings()来识别,处理起来更麻烦,这篇主要讲文字水印。
4.2 水印的特征维度
判断一个 span 是不是水印,我通常看这几个维度:
| 特征 | 正文的典型值 | 水印的典型值 |
|---|---|---|
| 字体 | 宋体、黑体、Times 等 | 常见字体但可能加粗或特殊 |
| 字号 | 10-14 点 | 通常很大,30-60 点 |
| 颜色 | 黑色 (0) 或深色 | 浅灰、浅红等非黑色 |
| 旋转角度 | 0 度(水平) | 常见 45 度或 -45 度 |
| 透明度 | 不透明 | 半透明 |
| 位置 | 在正文区域内 | 可能超出正文边界 |
| 重复性 | 不重复 | 同一页或跨页重复出现 |
单一特征都不够可靠,要组合判断。比如"仅供内部使用"这几个字,如果字号大、颜色浅、还带旋转,那基本可以确定是水印。但如果只是字号大,可能是标题,不能误杀。
4.3 旋转角度的计算
PyMuPDF 的 span 里没有直接的旋转角度,但 line 的dir字段给了方向向量。水平文字的 dir 是 (1, 0),垂直的是 (0, 1),45 度旋转的可能是 (0.707, 0.707)。
import math def get_rotation_angle(dir_vector): """从方向向量计算旋转角度(度)""" dx, dy = dir_vector angle = math.degrees(math.atan2(dy, dx)) return angle正文的 angle 通常是 0 或接近 0。水印的 angle 可能是 45、-45、90 等。判断时给个容差,比如abs(angle) > 5就认为是旋转文字。
4.4 颜色和透明度的判断
颜色在 span 里是一个整数,需要转成 RGB:
def int_to_rgb(color_int): """把 PyMuPDF 的颜色整数转成 RGB 元组""" r = (color_int >> 16) & 0xFF g = (color_int >> 8) & 0xFF b = color_int & 0xFF return (r, g, b)正文通常是黑色 (0, 0, 0) 或接近黑色。水印如果是浅灰,RGB 值会比较高,比如 (200, 200, 200)。判断时可以设个阈值,比如sum(rgb) > 400就认为是浅色。
透明度 PyMuPDF 拿不到直接的值,但可以通过颜色和背景的对比来间接判断。如果文字颜色和背景色很接近,说明透明度高。这个判断比较复杂,实际项目中我一般先用颜色阈值,不够再补其他规则。
4.5 综合判断函数
把上面的特征组合起来:
def is_watermark(span, page_width, page_height): """综合判断一个 span 是否是水印""" bbox = span["bbox"] text = span["text"].strip() if not text: return False # 特征1:旋转角度 angle = get_rotation_angle(span["dir"]) is_rotated = abs(angle) > 5 # 特征2:字号异常大 is_large = span["size"] > 25 # 特征3:颜色浅 rgb = int_to_rgb(span["color"]) is_light = sum(rgb) > 400 # 特征4:位置异常(超出正文区域) is_out_of_bounds = ( bbox[0] < 20 or bbox[2] > page_width - 20 or bbox[1] < 20 or bbox[3] > page_height - 20 ) # 特征5:文字内容匹配常见水印词 watermark_keywords = ["机密", "样本", "内部", "禁止", "复制", "草稿", "试用"] has_keyword = any(kw in text for kw in watermark_keywords) # 组合判断:旋转 + 浅色,或者大字号 + 浅色,或者关键词 + 浅色 score = 0 if is_rotated: score += 2 if is_large: score += 1 if is_light: score += 2 if is_out_of_bounds: score += 1 if has_keyword: score += 2 return score >= 4这个打分机制的好处是灵活。不同 PDF 的水印特征不一样,你可以根据实际情况调整权重和阈值。比如某批 PDF 的水印都是黑色的,那就把is_light的权重降下来,把is_rotated的权重提上去。
4.6 跨页重复检测
有些水印在每一页都出现,而且位置固定。这种可以通过跨页比对来识别:
def find_repeated_spans(pages_spans, tolerance=5): """找出在多页中重复出现的 span""" from collections import defaultdict position_map = defaultdict(list) for page_idx, spans in enumerate(pages_spans): for span in spans: # 把 bbox 量化到 tolerance 精度 key = ( round(span["bbox"][0] / tolerance), round(span["bbox"][1] / tolerance), span["text"].strip() ) position_map[key].append(page_idx) # 出现在超过一半页面上的,认为是水印 threshold = len(pages_spans) / 2 repeated = set() for key, pages in position_map.items(): if len(set(pages)) >= threshold: repeated.add(key) return repeated这个方法对固定位置的水印特别有效,比如页脚的公司名、页眉的文档标题。但要注意,如果文档本身就有重复的正文内容(比如模板化的合同),可能会误判。所以跨页检测的结果最好和单页特征判断结合使用。
4.7 剔除水印后的文本重组
识别出水印后,从 span 列表里过滤掉,再走 XY-cut 流程:
def clean_and_extract(page): spans = extract_spans(page) page_width = page.rect.width page_height = page.rect.height # 过滤水印 clean_spans = [ s for s in spans if not is_watermark(s, page_width, page_height) ] # 对剩余 span 做 XY-cut bboxes = [s["bbox"] for s in clean_spans] regions = xy_cut(bboxes) # 按阅读顺序提取文字 text = extract_ordered_text(clean_spans, regions) return text注意:过滤水印要在 XY-cut 之前做。如果先做 XY-cut,水印的 bbox 会干扰投影,导致切割线找错。我一开始就是顺序搞反了,调了半天才发现问题。
5. 完整流程串联与参数调优
5.1 端到端代码
把前面的模块串起来:
import fitz def parse_pdf(pdf_path, min_gap=10, watermark_threshold=4): doc = fitz.open(pdf_path) all_text = [] for page_num, page in enumerate(doc): spans = extract_spans(page) page_width = page.rect.width page_height = page.rect.height # 过滤水印 clean_spans = [ s for s in spans if not is_watermark(s, page_width, page_height) ] if not clean_spans: continue # XY-cut bboxes = [s["bbox"] for s in clean_spans] regions = xy_cut(bboxes, max_depth=8) # 提取文字 text = extract_ordered_text(clean_spans, regions) all_text.append(f"--- Page {page_num + 1} ---\n{text}") doc.close() return "\n\n".join(all_text)5.2 参数调优的实操方法
参数调优没有捷径,就是拿样本反复试。我的流程是:
- 准备样本集:至少 20 份 PDF,覆盖单栏、双栏、三栏、带水印、不带水印各种情况。
- 可视化对比:用
draw_bboxes把切割结果画出来,人工检查阅读顺序对不对。 - 统计错误率:定义几个指标——阅读顺序错误率、水印漏检率、正文误删率。
- 网格搜索:对
min_gap和watermark_threshold做网格搜索,找错误率最低的组合。
我实测下来,min_gap在 8-15 之间比较稳,watermark_threshold在 3-5 之间。具体值要看你的 PDF 来源。
5.3 性能优化
PyMuPDF 本身很快,但 XY-cut 的递归在页面元素多的时候会慢。优化点:
- 提前终止:如果当前区域的 span 数量少于 3 个,直接返回,不再递归。
- 缓存投影结果:同一区域的投影只算一次。
- 限制递归深度:
max_depth=6通常够用,再深收益很小。
我测过一个 200 页的论文合集,单页解析时间从最初的 80ms 优化到 25ms 左右,整体跑完不到 6 秒。
6. 常见问题与排查技巧实录
6.1 问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 左右栏文字交错 | XY-cut 没找到垂直切割线 | 可视化 bbox,看栏间距 | 调小 min_gap |
| 水印混进正文 | 水印特征不满足阈值 | 打印水印 span 的特征值 | 调整权重或阈值 |
| 正文被误删 | 正文特征像水印 | 检查被删 span 的内容 | 降低关键词权重 |
| 阅读顺序颠倒 | 排序规则不对 | 打印区域排序结果 | 调整 y0 分桶大小 |
| 解析速度慢 | 递归太深或元素太多 | 加计时日志 | 限制 max_depth,提前终止 |
| 表格内容错乱 | 表格线干扰投影 | 检查表格区域的 bbox | 排除图形区域 |
6.2 几个我踩过的坑
坑一:把标题当成水印删了。有些 PDF 的章节标题字号很大,颜色也偏浅,结果被is_watermark误判。解决办法是加一条规则:如果 span 的 y0 在页面上部 1/4 区域,且文字长度小于 30 字,降低水印分数。
坑二:XY-cut 把页眉页脚切成了独立区域。页眉页脚本身就该剔除,但 XY-cut 会把它们当成正文区域参与排序,导致阅读顺序里混入页眉页脚。我的做法是在 XY-cut 之前,先用 y 坐标过滤掉页面顶部 5% 和底部 5% 的 span。
坑三:旋转文字的方向向量不准确。有些 PDF 的旋转文字,dir字段返回的是 (1, 0),但实际是旋转的。这种情况要靠span["origin"]和 bbox 的关系来判断。如果 origin 不在 bbox 的左下角附近,说明文字可能是旋转的。
坑四:跨页水印的 bbox 有微小偏移。跨页检测时,如果 tolerance 设得太小,同一水印在不同页的 bbox 对不上。我一般设 tolerance=5,能容忍几个点的偏移。
6.3 独家避坑技巧
技巧一:先分类再解析。不要对所有 PDF 用同一套参数。我习惯先跑一遍,根据页面特征把 PDF 分成几类(单栏、双栏、带水印、扫描件),每类用不同的参数组合。分类可以用简单的规则,比如统计页面中间区域的空白宽度,大于 15 点的归为多栏。
技巧二:保留原始 span 信息。解析结果不要只存纯文本,把每个 span 的 bbox、字体、字号也存下来。后面如果发现解析错误,可以回溯到原始数据重新处理,不用重新解析 PDF。
技巧三:人工抽检。自动化再厉害,也要人工抽检。我一般随机抽 5% 的页面,人工核对阅读顺序和水印剔除效果。发现系统性问题就调参数,个别问题就手动修正。
技巧四:水印词库要维护。watermark_keywords这个列表要根据实际遇到的 PDF 不断补充。我现在的词库有 30 多个词,覆盖了大部分常见水印。
7. 与 RAG 链路的衔接
解析出来的文本怎么喂给 RAG,这里也有讲究。bbox 信息不要丢,它在 chunk 切分时能派上大用场。
传统的 chunk 切分是按字符数或按段落,但 PDF 解析出来的文本,段落边界不一定准确。有了 bbox,你可以按"阅读区域"来切 chunk——每个 XY-cut 出来的区域就是一个天然的语义单元。这样切出来的 chunk,语义完整性比按字符数切好得多。
具体做法:在extract_ordered_text里,不要把所有区域拼成一个字符串,而是每个区域单独作为一个 chunk,带上它的 bbox 和页码作为元数据。检索时,如果命中某个 chunk,可以顺带返回它的位置信息,方便溯源。
def extract_chunks(page, page_num): spans = extract_spans(page) clean_spans = [s for s in spans if not is_watermark(s, page.rect.width, page.rect.height)] bboxes = [s["bbox"] for s in clean_spans] regions = xy_cut(bboxes) ordered_regions = sort_regions(regions) chunks = [] for region in ordered_regions: region_spans = [s for s in clean_spans if s["bbox"] in region] region_spans.sort(key=lambda s: (round(s["bbox"][1] / 5), s["bbox"][0])) text = "".join(s["text"] for s in region_spans) if len(text.strip()) < 10: continue chunks.append({ "text": text, "page": page_num, "bbox": ( min(s["bbox"][0] for s in region_spans), min(s["bbox"][1] for s in region_spans), max(s["bbox"][2] for s in region_spans), max(s["bbox"][3] for s in region_spans), ), }) return chunks这样每个 chunk 都带位置信息,检索命中后可以高亮原文位置,用户体验会好很多。而且 chunk 的语义完整性高,embedding 的质量也会提升。
我在实际项目里对比过,用 bbox 区域切 chunk 和按字符数切 chunk,检索准确率能差 15-20 个百分点。这个差距在 RAG 场景里是很显著的。
最后分享一个小技巧:如果你的 PDF 里有大量表格,XY-cut 对表格的处理不太理想。可以考虑先用page.find_tables()把表格区域识别出来,单独处理,剩下的区域再走 XY-cut。PyMuPDF 1.23 之后内置了表格识别,效果还不错,值得一试。