
做 RAG 的知识库解析做到第三篇终于要聊点硬核的了。前两篇我们解决了“把 PDF 变成文本”但那种按页抽取的文本扔进向量库效果往往一地鸡毛。问题出在哪很大程度不在 embedding 模型而在于你忽略了 PDF 版面的空间信息。多栏排版和水印 PDF就是两个最典型的“文字都对语义全乱”的场景。这篇文章我会围绕 bboxbounding box边界框展开手把手把多栏检测和水印过滤做出来。适合正在搭 RAG 知识库、或者被 PDF 解析折磨过的同学参考。1. 为什么 PDF 解析是 RAG 的隐形瓶颈1.1 从“能读文字”到“能读懂版面”很多人做 RAG 的第一步是找个 PDF 库把文字抽出来然后用 CharTextSplitter 或者按固定长度切块觉得万事大吉。实测过不少文档就会发现这种天真想法在普通单栏排版 PDF 上可能凑合一旦遇到学术论文、产品手册、报纸样式的多栏布局召回质量立刻崩盘。因为 PDF 本质上不是“一段文字”而是一堆带坐标的图形对象文字、线条、图片都被放在一个平面画布上。所谓“阅读顺序”是读者根据视觉位置脑补出来的而不是 PDF 文件本身天然携带的。举个最典型的例子一篇两栏论文PDF 内部内容流可能是先输出左边栏第一行再输出右边栏第一行再回到左边栏第二行。你用extract_text直接抽取得到的就是左右两栏第一行拼在一起、第二行再拼在一起。这种“左右横跳”的文本不论是做 embedding 还是给大模型读起来都像精神分裂。更麻烦的是切块后一个 chunk 里同时混入两栏不相关的内容检索时 query 可能撞上无关的那一半后面 rerank 再怎么调也拉不回来。所以 RAG 文档解析的关键突破点是从“能读到文字”升级到“能读懂版面”。版面理解的第一步就是搞清楚每个文字对象在页面上的位置也就是 bbox。我们不需要像传统 PDF 渲染那样逐像素还原只需要拿到足够精确的坐标信息就能把页面重新组织成语义上连贯的段落。这也是这篇文章想把 bbox 讲透的意义它是后续一切栏检测、水印过滤、标题提取、表格还原的地基。1.2 bbox 是什么一切版面理解的基础bbox 的全称是 bounding box翻译过来就是边界框。在 PDF 解析语境下一个文本块、一行文字、一个单词甚至一个字符都可以用一个矩形框来描述。PyMuPDF 会给你类似(x0, y0, x1, y1)的四元组分别代表左上角 x、左上角 y、右下角 x、右下角 y。这个坐标单位是 point也就是 PDF 页面坐标系里的一个“点”1 point 等于 1/72 英寸。和你在屏幕上看到的像素不一样它不会因为缩放而改变。用生活化的方式理解你在人群中找人如果只知道“张三在人群里”那只能瞎找如果告诉你“张三在东北角那一堆大概这个范围内”你视线就自动聚焦过去了。bbox 就是给解析程序指的“人堆范围”。拿到这些范围以后程序才知道先看哪个区域、跳过哪个区域、把哪些文字当成同一段落。PyMuPDF 里取 bbox 非常简单import fitz doc fitz.open(test.pdf) page doc[0] d page.get_text(dict) for block in d[blocks]: if block[type] ! 0: # 0 表示文字块跳过图片块 continue for line in block[lines]: for span in line[spans]: print(span[bbox], span[text])输出里每个 span 都有bbox、text、size、font等字段。后面所有的栏检测和水印过滤本质上都是在玩这些坐标和字段的组合。在开始实战之前你应该先养成一个习惯拿到一份 PDF先把第一页所有文本对象的 bbox 可视化画出来看看页面上的文字到底是怎么分布的。这一步看着简单实际上能帮你排查掉 80% 的坐标方向、缩放、偏移问题。2. 多栏排版让“顺序阅读”变成伪命题2.1 多栏 PDF 为什么会让 RAG 召回质量崩盘多栏排版在纸质时代是为了省纸和提升阅读效率在数字时代却成了 RAG 的噩梦。学术论文普遍两栏新闻网站导出的 PDF 常常三栏产品手册更夸张可能一页上同时有主体栏、侧边栏、图片说明栏、页脚栏。如果你只按内容流顺序抽取文本会以不可预测的次序混在一起。更糟的是跨栏拼接可能导致一句话被人为折断左边栏最后一个词和右边栏第一个词被塞进同一个 chunk语义断裂得连 reranker 都看不下去。我在一个真实项目里见过这种问题一个两栏产品手册总共有 300 页直接抽取后生成 1500 个 chunk。上游 query 问“支持的最大并发数”结果召回的 chunk 里除了并发数描述还混着下一栏的“安装注意事项”向量相似度被拉低最后大模型回答时把两段毫不相干的内容捏在一起输出完全没法用。后来我把栏检测加上同一个 PDF 重新生成 chunk检索准确率直接提升了 20 多个点。这不是 embedding 模型的差距而是输入数据本身的干净程度差距。为什么要用 bbox因为只有坐标能告诉你哪些文字在同一个视觉栏位里。人类的阅读顺序是“先读完左栏再从页首读右栏”这个经验要翻译成算法就得把页面按 x 方向切分。具体来说两栏页面左栏文字的中心 x 大致在 150 附近右栏文字的中心 x 大致在 450 附近中间存在一段没有文字的空隙。只要有办法找到这段空隙就能把所有文字正确归属到两栏。2.2 bbox 实战基于坐标的栏检测算法我先给一个简单但实用的栏检测思路不依赖现成深度学习版面模型只用坐标统计就能处理大部分规则分栏。算法分三步。第一步取出页面上的文本块block而不是单词或字符。单词层次太碎字符层次噪声太大文本块刚好保留了语义单元同时具备稳定的 x 范围。第二步将每个文本块的 x 区间(x0, x1)拿出来排序然后找相邻区间之间的 gap。如果两个文本块之间的水平间隙明显大于正文里正常的词间空格说明这里可能是一栏的结束和另一栏的开始。第三步把连续的文本块聚成若干列再根据每个文本块中心点落在哪个列内把文本重新归类。代码可以直接抄def column_split(page): blocks [b for b in page.get_text(dict)[blocks] if b[type] 0] if not blocks: return [] intervals sorted((b[bbox][0], b[bbox][2]) for b in blocks) min_gap 30 # point可调A4 页面双栏间隙一般 30-50pt columns [] cur [intervals[0][0], intervals[0][1]] for x0, x1 in intervals[1:]: if x0 - cur[1] min_gap: columns.append(cur) cur [x0, x1] else: cur[1] max(cur[1], x1) columns.append(cur) # 计算每栏中心点 col_centers [(c[0] c[1]) / 2 for c in columns] col_texts {i: [] for i in range(len(columns))} for b in blocks: center_x (b[bbox][0] b[bbox][2]) / 2 idx min(range(len(col_centers)), keylambda i: abs(center_x - col_centers[i])) # 按照 y 坐标排序文本块 col_texts[idx].append((b[bbox][1], b[bbox][3], b[bbox][0], b[bbox][2], b[lines])) result [] for i in range(len(columns)): # 栏内按 y 从上到下同一行按 x 从左到右 col_texts[i].sort(keylambda t: (round(t[0] / 5), t[2])) result.append(col_texts[i]) return result这里有几个细节值得展开。min_gap不是拍脑袋定的它取决于页面宽度和栏数。A4 页面宽度大约是 595pt常见双栏间距 30-50pt如果是三栏间距可能压缩到 20-30pt。你可以在实际项目里打印所有相邻文本块的空隙分布取一个明显的低谷值。如果文档里有跨页眉页脚这些对象的 x 区间会横跨整页必须先单独剔除否则栏检测会把整页误判成单栏。另外排序时我用了一个小技巧round(t[0] / 5)把 y 坐标按 5pt 分桶这样同一行里因为字体 ascent/descent 导致的几 pt 误差不会把顺序打乱同时又能避免栏内多个文本块因为上下位置接近造成错序。排序的第三关键字 x 保证从左向右读。经过这一步每栏的文本就已经是一段连续的、顺序正确的文字了。还有一种更极端的做法不依赖文本块而是把整页渲染成图像用投影分析找空白列。但那是杀鸡用牛刀跑一遍要几十毫秒甚至更久而且精度未必比坐标法高。坐标法直接读取 PDF 内部对象速度飞快准确率足够支撑 90% 的常见场景。3. 水印 PDF肉眼忽略机器“胡言乱语”3.1 文字水印 / 图片水印的识别思路如果说多栏排版是结构错乱水印就是内容污染。水印的存在让 RAG 解析在“文字正确”这一步就已经输了你提取出来的文本里混着大量“机密资料”“内部文件”“Do Not Copy”这些词本身没问题可怕的是它会和正文交织在一起。比如水印是斜向跨页的提取时同一行文字里可能会夹着一段水印切块后 chunk 里既有正文又有水印语义相似度被稀释。更隐蔽的是“多行多列文字水印”比如每页重复 6 次“内部资料”这种水印甚至会直接出现在正文行的中间破坏句子结构。图片水印是另一种麻烦。有些 PDF 为了视觉效果在每一页铺一个半透明的 logo 或背景图用extract_text拿不到图片里的文字但 RAG 如果后续做 OCR 或者多模态解析这些水印文字就会被 OCR 出来照样污染。还有一些扫描件水印直接烙在图像像素里除非做图像处理否则文字层过滤根本看不见。我的建议是不要试图在视觉层面“去水印”而是把水印当作解析阶段的噪声来过滤。传统去水印工具比如 PDF 编辑器里的删除、覆盖、PitStop 批量操作关心的是打印或显示效果我们需要的是干净的文本流两者目标不同。过滤水印的优势是不修改原文件也不会误伤正文的视觉内容处理速度也快。真正思路上的关键点只有一个如何判定一个文本对象是水印。3.2 基于 bbox 的水印过滤与内容恢复判定水印要从三个维度交叉验证。第一是文本重复度。水印最显著的特征就是“阴魂不散”——同一段文字在每一页同一个位置出现或者在某一页内平铺多次。我们可以维护一个计数器统计“归一化坐标 文本”组合出现的次数。所谓归一化坐标就是把 bbox 坐标除以页面宽度和高度这样不同页面大小也能比较。如果同一个文本块在 3 页以上重复出现它大概率是页眉、页脚或水印。页眉页脚可以通过 x 方向或 y 方向位置单独处理水印通常更靠近页面中部或斜向分布。第二是视觉属性。文字水印往往和正文字体、字号、颜色明显不同。比如正文字号 10pt水印字号 48pt 且旋转了 45 度或者正文字色接近纯黑水印是浅灰色RGB 里三个分量都在 0.5-0.6 附近且三者接近。这些特征在 span 字段里直接就有判断成本极低。不过要注意有的水印用了和正文同样大小、同样颜色那就要靠重复度来识别。第三是位置上下文。有些水印不是独立文本块而是被放置在某些段落中间。这时候光删 span 不够需要降到单词级把水印文本切分成单词如果某个单词的 bbox 和正文文本的 bbox 高度重叠并且这个单词明显不属于当前句子就把它删除。这个规则在斜跨页面的水印上特别有效。我给出一个实用的文本水印过滤函数from collections import Counter seen Counter() def normalize_key(text, bbox, page_w, page_h): nx round(bbox[0] / page_w, 2) ny round(bbox[1] / page_h, 2) return (text.strip()[:20], nx, ny) def is_watermark_span(span, page_w, page_h, min_repeat3): text span[text].strip() if not text: return False key normalize_key(text, span[bbox], page_w, page_h) seen[key] 1 if seen[key] min_repeat: return True # 颜色接近灰色且和正常正文对比明显 color span[color] if color and max(color) - min(color) 0.05 and sum(color) / 3 0.6: return True # 字号过大且 bbox 跨页面宽度比例较大典型的水印大字 if span[size] 30 and (span[bbox][2] - span[bbox][0]) page_w * 0.3: return True return False这段代码针对纯文字水印基本够用。如果你遇到的 PDF 水印是图片水印处理思路要换成对象层面的去重用page.get_images()拿到页面引用的图片 XObject如果同一张图片在多个页面反复出现并且它的 bbox 覆盖了正文区域那就可以在输出时把它忽略不让 OCR 或多模态模型读到。如果是“多行多列”平铺的文字水印重复度判断会在几页内迅速命中自然过滤掉。水印过滤最怕误杀。正文里正常的“注意事项”也可能在每页页脚重复但它不是水印是真实内容。这时候需要结合位置判断页脚区域y 接近页面底部的重复文本优先归为页码或页脚数据可以单独保存为 metadata只有出现在正文阅读路径上、且重复次数异常高的文本才当成水印。这个优先级关系建议你在自己的文档集上多跑几个样例再固化。4. 实操复盘双栏水印 PDF 的完整解析4.1 案例背景与预期效果这套流程不是纸上谈兵。我拿一份真实的产品手册来复盘A4 尺寸双栏排版每页左下角有“机密资料”四个字字体浅灰字号 14pt同时右下角有页码。原始 PDF 是从某设计软件导出的文字层完整不需要 OCR。目标是输出一份干净的 Markdown 或分块 JSON直接喂给后面的 RAG 切分和 embedding。先看直接用page.get_text(text)拿到的原始文本肉眼就能发现问题第一行是“密产品特色机资料”第二行是“高可靠性设计”这里“机密资料”四个字被夹在正文行中间然后左右栏内容交替出现。这种情况下不做栏检测切出来的 chunk 基本等于把两页内容搅在一起不做水印过滤向量索引里到处都是“机密资料”检索时一个不相关的 query 都可能因为“资料”两个字撞上水印 chunk。处理后的预期效果很简单双栏成为两个独立语义段栏内文本按从上到下、从左到右排列“机密资料”彻底消失页码作为 metadata 单独保留。这样才能保证下游 RAG 切块时每个 chunk 都是完整的一句话或一个段落没有跨栏拼接也没有水印噪声。4.2 解析流程串联与核心代码整个流程可以串成一条流水线打开 PDF - 遍历每一页 - 获取文本块 - span 级别水印过滤 - 通过 block 的 bbox 做栏检测 - 按栏重排文本 - 返回页面级结果。我把关键代码整合如下import fitz from collections import Counter seen Counter() def is_watermark(span, pw, ph): text span[text].strip() if not text: return True key (text[:12], round(span[bbox][0] / pw, 2), round(span[bbox][1] / ph, 2)) seen[key] 1 if seen[key] 3: return True color span.get(color) if color and max(color) - min(color) 0.05 and sum(color) / 3 0.6: return True return False def extract_clean_blocks(doc, page_idx): page doc[page_idx] pw, ph page.rect.width, page.rect.height raw_blocks [b for b in page.get_text(dict)[blocks] if b[type] 0] clean_spans [] for b in raw_blocks: for line in b[lines]: for span in line[spans]: if not is_watermark(span, pw, ph): clean_spans.append(span) # 用 span 重新构建逻辑文本块避免水印删除后出现空洞 # 这里直接用每个 span 的中心 x 做栏归属更细粒度 if not clean_spans: return xs [s[bbox][0] for s in clean_spans] x_edges sorted(set(round(x / 10) * 10 for x in xs)) # 极简单栏/双栏判定看最左与最右是否明显分离 left_xs [s for s in clean_spans if s[bbox][0] pw * 0.4] right_xs [s for s in clean_spans if s[bbox][0] pw * 0.4] if left_xs and right_xs: left_col sorted(left_xs, keylambda s: (round(s[bbox][1] / 5), s[bbox][0])) right_col sorted(right_xs, keylambda s: (round(s[bbox][1] / 5), s[bbox][0])) left_text .join(s[text] for s in left_col) right_text .join(s[text] for s in right_col) return left_text \n\n right_text else: all_ordered sorted(clean_spans, keylambda s: (round(s[bbox][1] / 5), s[bbox][0])) return .join(s[text] for s in all_ordered) doc fitz.open(manual.pdf) for i in range(len(doc)): print(f\n Page {i1} ) print(extract_clean_blocks(doc, i))这段代码相对紧凑属于“能跑但需要按文档微调”的版本。实际项目里我通常不会在left_xs/right_xs里用固定0.4阈值而是用前面 2.2 小节的栏检测函数先算出真实分栏线再把 span 按中心点归属到对应栏。这样做的好处是遇到三栏或者不对称布局时也能自适应。还有一点水印过滤发生在 span 级删除水印 span 后同一行里可能只剩下正文前半段和后半段中间留下一个应该合并的 gap。我上面的方案是用join简单拼接但这一步在实际中容易产生词与词之间多余的空白或者把本属于两个句子的片段硬拼在一起。更稳的做法是在过滤完水印后把保留下来的 span 重新按 bbox 的 y 坐标聚类成“视觉行”再按行内 x 排序。因为 span 本身带有原始行信息line对象只要删除不影响那个 line 的完整性就可以直接按原行输出如果水印把一行切断再用 y 聚类补一次。4.3 输出效果与参数微调跑完上面代码原始输出和处理后对比如下项目原始输出处理后输出第一行密产品特色机资料产品特色第二行高可靠性设计机密资料高可靠性设计左右栏关系交替拼接语义混乱左栏段落后接右栏段落水印每页多次出现完全消失页码混在末尾文本中独立保留参数微调方面最容易出问题的就是min_gap和水印重复次数。min_gap如果设太小会把正文里两个相邻文本块的正常间隔误判成栏间隙导致单栏文档被拆成两栏设太大又会让双栏文档合并成一栏。建议先用 30pt 跑一遍打印检测到的栏数量如果和实际页面对不上再按具体 PDF 调整到 20 或 50。水印重复次数也一样。有些 PDF 总共只有两三页水印每页出现一次min_repeat3会导致最后一页的水印漏网。更稳妥的方案是先跨全部页面收集统计信息然后再过滤而不是边遍历边过滤。也就是第一遍扫描所有 span 累计重复次数第二遍做过滤。这个顺序调整会让代码多一点但误判率会低很多。5. 常见问题速查与避坑实录5.1 表格、跨栏标题、图片文字夹心怎么处理水印和分栏搞定以后下一个让人头疼的通常是表格。bbox 能定位表格单元格的位置但合并单元格、跨页表格、表头重复这类问题靠简单坐标分组很难一次解决。我常用的做法是“分区处理”先用page.get_text(dict)找出页面上所有线条和文字 bbox粗略识别表格区域然后在表格区域内用pdfplumber的extract_table表格区域外继续走文本流方案。不要把整个页面都交给表格解析器否则正文和表格容易互相污染。跨栏标题也要提前处理。论文的第一行标题通常是通栏居中包含“标题、作者、摘要”它们横跨左右两栏上方。如果直接按双栏切分这些内容会被当成左栏或右栏的一部分导致栏内语义错乱。解决思路是先按 y 坐标分层越靠前的通栏区域y 小于一定阈值先单独提取再进入分栏流程。你可以通过检测某个文本块的 x0 是否接近左边距、x1 是否接近右边距来判断它是不是通栏元素。图片里的文字夹心更麻烦。如果 PDF 是文字层加图片图片中的文字 bbox 拿不到需要走 OCR如果是扫描件整页都是图片douban 上常见的“PDF OCR”工具就派得上用场。我的建议是扫描件先做 OCR输出带坐标的文本比如 PaddleOCR 的get_ocr_result然后继续用同样的 bbox 逻辑做分栏和水印过滤。OCR 的文本坐标可能和原始 PDF 坐标不同需要归一化到同一套坐标系再处理。5.2 关于坐标系的 5 个容易踩的坑这部分算是我被坑过多次后的血泪总结。第一坐标原点不一致。PDF 规范里用户空间的原点在左下角y 轴向上但很多高层库PyMuPDF、pdfplumber为了方便提供的是左上角原点的页面坐标y 向下。当你把多个库的 bbox 混在一起时一定要统一成同一个坐标系否则栏检测直接错乱。第二bbox 单位是 point不是像素。如果你把页面渲染成 150 DPI 的图像再拿坐标去比对需要乘 150/72 的缩放系数。我见过有人把文字层 bbox 直接覆盖到渲染图上结果所有框都偏了位置最后排查半天才发现是单位混了。第三页面旋转。有些 PDF 页面设置了 90 度或 270 度旋转此时拿到的 bbox 是旋转前的坐标。PyMuPDF 可以用page.derotation_matrix把坐标映射回旋转后的显示坐标系这一步必须在分栏前做否则所有位置判断都失效。第四词和 span 的 bbox 包含字体上下间距。同一行文字里不同 span 的 y0 和 y1 可能差上好几 pt如果直接按 bbox 的 y1 排序顺序会乱。我习惯把 y 坐标除以 5 取整再做桶排序容忍小范围抖动。第五页边距不同导致绝对坐标不可比。要跨页统计水印重复次数时别用原始坐标先除以页面宽高做归一化。不然一份页面大小混排的 PDF会让你的重复检测完全失效。5.3 bbox 之外RAG 知识库能存图片吗这个问句经常在 RAG 社区出现很多人以为解析完 PDF 就万事大吉。实际上就算你把多栏和水印都处理干净了下一步还是要想清楚文档里的图怎么办如果你的 RAG 知识库用的是纯文本 embedding图片里的信息不会自动进入检索你需要把图片转成文字描述或 caption。如果用的是多模态 embedding可以把图片裁剪出来存成独立向量检索时返回图片路径或 bbox前端预览时直接定位到原文对应区域。这里的 bbox 依然有用当你检测出页面上的图片块type 为 1 的 block它就带有一个 bbox你可以用这个 bbox 把图从页面上截出来保存成小图再走多模态向量化。那些夹在双栏中间的图也能通过 bbox 判断它属于左栏、右栏还是通栏从而决定把图片插回哪段文本流。另外只靠 bbox 规则解决不了所有 RAG 瓶颈。分栏和水印过滤之后还有切块策略、embedding 选型、rerank 设计、query 改写一堆问题等着你。在我个人经验里文档解析干净度对召回率的影响往往比换一个更大的 embedding 模型还要明显。先把源头治理好后面每一步都会轻松很多。最后聊一点实际体会版面解析没有“一个模型打天下”的银弹。我建议你在项目初期搭建一个可视化调试工具把每页的 bbox 画出来水印、分栏、页眉、表格分别用不同颜色标记看一遍再调规则。这样比瞎猜参数高效得多。这套 bbox 实战逻辑不止适用于多栏和水印后续你要处理页眉页脚、表格还原、图片定位的时候还能继续复用。可以先在自己的文档集上跑一遍再根据真实效果做调整。