做 RAG 的人迟早会撞上同一堵墙模型答得头头是道但引用的原文页码对不上或者干脆把表格里的数字读串行。我最初以为是向量检索的锅换了嵌入模型、调了 chunk size、加了 rerank折腾一圈才发现问题出在最上游——PDF 解析这一层就已经把内容搞坏了。后来在几个项目里反复踩坑逐渐固定下来一套用 pdf-inspector 做前置检查的流程它不负责解析但负责告诉你这份 PDF 到底能不能被好好解析。这篇就聊聊为什么 RAG 流水线里值得单独插一个 PDF 体检环节以及具体怎么落地。1. RAG 效果差很多时候是 PDF 这一层就烂了1.1 一个被低估的事实检索质量的上限由解析质量决定大部分人做 RAG 的路径是这样的拿到一批 PDF直接丢进 LangChain 或者 LlamaIndex 的 PDFLoader切完 chunk 塞进向量库然后开始调检索参数。这个流程跑得通但跑得好不好几乎完全取决于 PDFLoader 背后那个解析器有没有把内容正确提取出来。问题在于PDF 这个格式从设计之初就不是为了被机器读取而生的。它更像是一张打印出来的纸的数字化快照里面记录的是在坐标 (x, y) 处画一个字形这种绘制指令而不是这里有一段标题、下面跟着一个三列表格这样的语义结构。所以从 PDF 到文本本质上是一个逆向工程的过程而逆向工程的质量参差不齐。我见过太多这样的情况一份财报 PDF解析出来数字全对但表格的行列关系丢了原本营业收入 1200 万变成了营业收入和1200 万分散在两个不相邻的 chunk 里。检索的时候用户问营业收入多少召回的 chunk 里只有营业收入四个字模型只能瞎编。这时候你去调 top_k、调相似度阈值全是白费力气因为源头的数据就是残缺的。1.2 解析失败的几种典型形态把 PDF 解析的坑归归类大致有这么几种每一种对 RAG 的伤害方式都不一样。第一种是扫描件无文本层。这类 PDF 本质上是图片直接提取文本会得到空字符串或者一堆乱码。如果不做检测就丢进流水线向量库里会多出一批空 chunk检索时偶尔被召回模型拿到空上下文只能自由发挥。第二种是多栏排版串行。学术论文、杂志、报纸这类双栏甚至三栏排版的 PDF解析器如果按坐标从上到下、从左到右简单排序会把左栏第一行和右栏第一行拼在一起读起来完全不通。这种错误很隐蔽因为每个字都对只是顺序错了。第三种是表格结构塌陷。这是最要命的一种。表格里的数字一旦和表头失去对应关系RAG 就彻底失去了处理结构化数据的能力。用户问第三季度环比增长多少模型看到的是一堆没有归属的数字。第四种是公式和特殊符号乱码。数学公式、化学式、特殊单位符号解析出来经常变成问号或者错位的字符。对于技术文档类的 RAG这几乎是致命的。第五种是页眉页脚和页码污染。每页都重复出现的页眉、页脚、水印文字会被切进每一个 chunk稀释掉真正有用的内容还会干扰相似度计算。1.3 pdf-inspector 的定位不解析只体检pdf-inspector 这个名字容易让人误会以为它是个 PDF 解析库。实际上它的角色更像是一个体检报告生成器——它不负责把 PDF 变成 Markdown而是负责告诉你这份 PDF 有哪些潜在问题适不适合直接进 RAG 流水线。这个定位很关键。因为解析器有很多选择PyMuPDF、pdfplumber、unstructured、Marker、MinerU 各有各的强项但你在选解析器之前得先知道这份 PDF 的病情。是扫描件就得走 OCR 路线是复杂表格就得用带版面分析的解析器是干净的单栏文本用最轻量的提取就够了。pdf-inspector 帮你做这个判断。它的输出通常包括每页的文本层字符数、是否检测到图片主导、字体信息、页面尺寸、文本块的空间分布、可能的栏数估计等等。这些指标单独看没什么但组合起来就能勾勒出一份 PDF 的可解析性画像。2. pdf-inspector 到底检查了哪些指标2.1 文本层密度判断是不是扫描件的第一道关最基础也最有用的一个指标是每页的文本层字符数。一份正常的电子版 PDF一页 A4 纸的正文大概在 1500 到 3000 字符之间。如果某页字符数低于 50基本可以判定这页是图片需要走 OCR。但这里有个坑不能只看总字符数要看有效字符数。有些 PDF 的文本层里塞满了不可见的控制字符或者空格总字符数看着不少实际有效内容寥寥。我一般会同时看两个数原始字符数和去掉空白后的字符数两者差距过大就说明文本层有问题。还有一个更隐蔽的情况部分页面是扫描件部分页面是电子版。这种混合型 PDF 在现实里非常常见比如一份合同前几页是打印后扫描的后面几页是电子签章生成的。如果只做整份文档级别的判断就会漏掉那些扫描页。所以体检必须做到页级别。import fitz # PyMuPDF def page_text_density(pdf_path): doc fitz.open(pdf_path) report [] for i, page in enumerate(doc): raw page.get_text(text) stripped .join(raw.split()) report.append({ page: i 1, raw_chars: len(raw), effective_chars: len(stripped), image_count: len(page.get_images()), }) return report这段代码就是最朴素的密度检查。跑一遍把 effective_chars 低于阈值的页标出来心里就有数了。2.2 版面结构栏数、文本块分布与阅读顺序文本层密度过关不代表解析就没问题。接下来要看的是版面结构。pdf-inspector 这类工具通常会提取页面上所有文本块的边界框bounding box然后根据这些框的横坐标分布来估计栏数。判断逻辑大概是这样的把所有文本块的左边界 x 坐标收集起来做聚类。如果明显分成两簇且两簇之间有明显间隔那大概率是双栏。三簇就是三栏。单栏的话所有文本块的左边界应该集中在一个很窄的范围内。这个判断对 RAG 的意义在于它决定了你该用哪种解析策略。单栏文档用简单的按块排序就够了双栏文档必须用带栏检测的解析器否则阅读顺序会错乱。除了栏数文本块的空间分布还能暴露另一个问题页面上有没有大面积的空白区域。如果一页里文本块只占了上半部分下半部分是空白那可能是分页导致的段落截断切 chunk 的时候要注意别把跨页的段落切散。2.3 字体与编码乱码和公式问题的预警字体信息是很多人忽略的一个维度但它对 RAG 影响很大。pdf-inspector 会报告每页用到的字体列表以及是否存在未嵌入字体。未嵌入字体是个大麻烦。PDF 里如果引用了系统字体但没有把字体文件嵌进去在别的环境打开时就会用替代字体渲染文本层提取出来的字符可能就错了。更糟的是某些特殊编码的字体提取出来是一堆看起来像乱码的字符但视觉上显示正常。这种 PDF 直接进 RAG检索出来的全是垃圾。公式问题也能从字体信息里看出端倪。数学公式通常用专门的数学字体比如 Latin Modern Math、Cambria Math如果一份技术文档里这类字体占比很高那就要预期解析出来的公式会很难看需要额外的公式识别处理。2.4 图片与矢量图表格和图表的存在信号页面上的图片数量和矢量图数量是判断内容复杂度的重要信号。纯文本页面图片数为零处理起来最简单。如果一页里有大量矢量图形那很可能是表格或者图表——因为很多 PDF 生成工具会把表格渲染成矢量线条而不是真正的表格结构。这一点特别值得强调PDF 里的表格往往不是表格。它可能只是一堆画出来的线和定位好的文字在 PDF 的内部结构里根本没有这是一个三行四列的表格这样的语义信息。所以解析器要重建表格结构靠的是几何分析而不是读取某个表格对象。这就是为什么表格解析这么难也是为什么体检时要特别关注矢量图密集的页面。3. 把体检结果翻译成解析策略3.1 干净单栏文档轻量提取就够了如果体检报告显示文本层密度正常、单栏、字体全部嵌入、图片很少那这份 PDF 就是健康的。这种情况下没必要上重型武器用 PyMuPDF 的get_text(text)或者 pdfplumber 的基础提取就够了速度快、资源占用低。我一般会写一个简单的路由函数根据体检结果决定走哪条解析路径def choose_parser(inspection): if inspection[is_scanned]: return ocr_pipeline if inspection[column_count] 2: return layout_aware_parser if inspection[table_density] 0.3: return table_aware_parser return simple_text_extractor这个路由逻辑看起来简单但能省下大量算力。我之前的项目里一批 5000 份 PDF 里其实有 70% 是干净的单栏文档如果全部走重型版面分析成本会翻好几倍。3.2 扫描件与混合文档OCR 的触发条件扫描件的处理是另一条完全不同的路径。体检报告里如果某页的文本层字符数极低但图片面积占比很高那这页就要走 OCR。这里有个经验不要整份文档一起判断要按页判断。混合型 PDF 太常见了整份文档级别的判断会误伤。我通常的做法是给每一页打一个标签——text、scanned、mixed然后分别处理最后再按页码顺序合并。OCR 本身也有讲究。现在主流的方案是 PaddleOCR、Tesseract、以及一些云端 OCR 服务。中文文档我一般优先用 PaddleOCR识别率和版面还原都比较好。但 OCR 出来的文本没有字体信息也没有原始的空间结构所以表格重建会更难需要额外的版面分析模型配合。提示OCR 是整条流水线里最慢也最贵的一环能不用就不用。体检的价值之一就是帮你精确识别出哪些页真的需要 OCR避免全量跑。3.3 表格密集文档什么时候必须上版面分析如果体检报告显示矢量图密集、文本块分布呈现明显的网格状那这份文档大概率表格很多。这时候简单的文本提取就不够了必须上带版面分析的解析器。版面分析模型比如 LayoutLM 系列、或者一些开源的文档版面检测模型能识别出页面上的表格区域、标题区域、正文区域然后分别处理。表格区域可以单独走表格识别正文区域走普通文本提取最后再按位置关系拼回去。这条路线的代价是慢。版面分析模型推理一次要几百毫秒到几秒不等一份几十页的 PDF 可能要跑几分钟。所以体检的另一个价值就是帮你判断这份文档值不值得上版面分析——如果表格占比很低那用简单方法加人工校对可能更划算。3.4 公式与技术文档特殊处理的分支技术文档、学术论文这类含大量公式的 PDF需要单独的分支。普通文本提取会把公式变成一堆错乱的符号检索时完全没法用。处理公式有两条路一是用专门的公式识别模型比如 pix2tex 这类把公式区域截图后识别成 LaTeX二是干脆保留公式为图片在 RAG 里用多模态模型处理。前者适合公式需要被检索的场景后者适合公式只需要被展示的场景。体检报告里的字体信息能帮你判断公式的密度。如果数学字体占比超过某个阈值就该考虑启用公式处理分支了。4. 一套可复现的 PDF 体检落地流程4.1 环境准备与依赖选择落地这套流程核心依赖其实不多。PyMuPDF也就是 fitz是主力它读取 PDF 内部结构的速度快、API 也够用。pdfplumber 作为补充它在表格线检测上比 PyMuPDF 更细。如果要跑版面分析再额外装对应的模型库。pip install pymupdf pdfplumber pillow我不建议一上来就装一堆重型依赖。先用 PyMuPDF 把基础指标跑出来看看报告长什么样再决定要不要加码。很多项目其实基础指标就够了。4.2 逐页体检脚本的完整实现下面这份脚本是我在多个项目里迭代出来的核心是把前面说的几类指标一次性采集齐输出成结构化的 JSON方便后续路由。import fitz import json def inspect_pdf(pdf_path, char_threshold50, image_area_ratio0.5): doc fitz.open(pdf_path) pages [] for i, page in enumerate(doc): rect page.rect page_area rect.width * rect.height text page.get_text(text) effective .join(text.split()) # 图片面积占比 image_area 0 for img in page.get_images(fullTrue): try: bbox page.get_image_bbox(img) image_area bbox.width * bbox.height except Exception: pass img_ratio image_area / page_area if page_area else 0 # 文本块左边界用于估计栏数 blocks page.get_text(blocks) left_edges sorted(b[0] for b in blocks if b[4].strip()) # 字体信息 fonts set() for b in page.get_text(dict)[blocks]: for line in b.get(lines, []): for span in line.get(spans, []): fonts.add(span[font]) pages.append({ page: i 1, effective_chars: len(effective), image_area_ratio: round(img_ratio, 3), block_count: len(blocks), left_edges: left_edges[:20], fonts: list(fonts), is_likely_scanned: len(effective) char_threshold and img_ratio image_area_ratio, }) doc.close() return {file: pdf_path, page_count: len(pages), pages: pages}跑完之后你会得到一份逐页的报告。重点看is_likely_scanned为真的页以及left_edges分布明显分簇的页。4.3 栏数估计的简化算法栏数估计不需要太复杂。我的做法是把左边界做一维聚类然后看簇的数量和簇之间的间隔。def estimate_columns(left_edges, gap_threshold50): if not left_edges: return 1 edges sorted(left_edges) clusters [[edges[0]]] for e in edges[1:]: if e - clusters[-1][-1] gap_threshold: clusters.append([e]) else: clusters[-1].append(e) # 过滤掉样本太少的簇避免噪声 significant [c for c in clusters if len(c) 3] return max(1, len(significant))gap_threshold这个参数需要根据页面宽度调整。A4 纸双栏排版两栏之间的间隔通常在 30 到 80 点之间取 50 是个比较稳的默认值。如果页面特别宽或者特别窄要相应调整。4.4 体检报告的解读与路由决策拿到报告后怎么决策我总结了一个简单的判断表体检信号含义建议解析策略有效字符数低 图片占比高扫描页OCR 流水线左边界明显分簇多栏排版版面感知解析器矢量图密集 文本块呈网格表格多表格识别 版面分析数学字体占比高公式多公式识别分支字体未嵌入编码风险先做字符校验必要时 OCR全部指标正常健康文档轻量文本提取这张表不是绝对的实际项目里经常遇到多种问题叠加的文档。这时候优先级是扫描 多栏 表格 公式。因为扫描件不解决后面全是空谈多栏不解决阅读顺序全乱表格和公式是锦上添花。5. 体检之后解析、切分与检索的衔接5.1 解析结果的质量校验解析完不等于万事大吉还得回头校验一遍。我习惯在解析后做一次反向体检把解析出来的 Markdown 或纯文本和原始 PDF 的体检报告对照。具体看几个点解析出来的总字符数和体检报告里的有效字符数总和差距应该在合理范围内比如 80% 到 120%。如果解析结果字符数远低于预期说明有内容丢失。如果远高于预期可能是页眉页脚被重复提取了。表格的话可以抽查几个表格看行列数对不对。这个没法完全自动化但可以写一些启发式规则比如检测解析结果里有没有明显不合理的空单元格比例。5.2 针对不同解析质量的切分策略解析质量直接影响切分策略。对于解析质量高的文档可以用比较激进的切分比如按语义段落切chunk 大一点也没关系。对于解析质量一般的文档切分要保守chunk 小一点重叠多一点尽量保证每个 chunk 内部是自洽的。表格尤其要注意。表格绝对不能被切散。一个表格要么整个进一个 chunk要么按行切但每行都带上表头。我见过太多项目把表格切得七零八落检索出来的全是孤立的数字。def split_with_table_protection(blocks, max_chars800): chunks [] current for block in blocks: if block[type] table: if current: chunks.append(current) current chunks.append(block[content]) # 表格整体成块 else: if len(current) len(block[content]) max_chars: chunks.append(current) current block[content] else: current \n block[content] if current: chunks.append(current) return chunks5.3 元数据注入让检索能定位到页码体检报告里的页码信息在切分时要保留下来注入到每个 chunk 的元数据里。这样检索出来之后能告诉用户这段话来自第 12 页体验会好很多也方便人工核对。更进一步可以把体检时发现的文档类型、是否有表格、是否有公式这些信息也作为元数据。检索时可以根据查询类型做过滤比如用户问的是数据问题就优先检索带表格的 chunk。5.4 一个容易忽略的点体检本身也要缓存体检是纯计算同一份 PDF 反复体检是浪费。我一般会把体检报告按文件哈希缓存起来文件没变就不重复跑。对于大批量文档处理这个缓存能省下可观的時間。import hashlib, os, json def cached_inspect(pdf_path, cache_dir./inspect_cache): os.makedirs(cache_dir, exist_okTrue) with open(pdf_path, rb) as f: file_hash hashlib.md5(f.read()).hexdigest() cache_file os.path.join(cache_dir, f{file_hash}.json) if os.path.exists(cache_file): with open(cache_file) as f: return json.load(f) report inspect_pdf(pdf_path) with open(cache_file, w) as f: json.dump(report, f, ensure_asciiFalse) return report6. 几个真实项目里踩出来的经验6.1 别信解析成功的返回值很多解析库在遇到问题时不会报错而是返回一个空字符串或者部分内容。你以为解析成功了实际上拿到的是残缺数据。所以体检和解析之后的质量校验都不能省。我现在的习惯是任何解析结果进向量库之前都要过一遍字符数校验低于阈值的直接打回人工检查。6.2 阈值要按文档类型调没有万能值前面提到的字符数阈值 50、图片占比 0.5、栏间距 50这些都是默认值不是万能值。财务报表的表格页和文学小说的正文页判断标准完全不同。我的做法是先用默认值跑一批人工看几十份报告根据实际情况调整阈值然后再批量跑。6.3 混合型 PDF 是常态按页处理是底线我经手的项目里纯扫描件和纯电子版各占一部分但混合型文档的比例高得惊人。一份几十页的 PDF中间夹几页扫描件太常见了。所以任何整份文档一个判断的逻辑都是不可靠的必须做到页级别。6.4 体检报告本身也是调试利器当 RAG 效果不好的时候先别急着调检索参数回头看看体检报告。如果报告显示这份文档本来就一堆扫描页和复杂表格那检索效果差是必然的问题不在检索层。这个排查思路帮我省下了大量无谓的调参时间。6.5 公式和表格能保留结构就别转纯文本如果下游模型支持多模态公式和表格其实可以考虑保留为图片让模型直接看图。这样能避免解析过程中的信息损失。当然这取决于你的 RAG 架构纯文本检索的流水线还是得老老实实做结构化解析。7. 关于 pdf-inspector 这类工具的一点个人看法pdf-inspector 本身不是什么黑科技它做的事情拆开看都很朴素——读文本层、数图片、看字体、估栏数。但它的价值在于把这些检查前置了让你在投入大量算力做解析之前先对文档有个基本判断。我在实际项目里的体会是RAG 的很多问题其实不在模型而在数据准备这一层。大家愿意花时间调 prompt、换模型、加 rerank却不太愿意花时间看看原始 PDF 到底长什么样。而恰恰是这一层的问题最难通过下游手段补救。最后分享一个小技巧如果你手头有一批 PDF 要处理先别急着写解析代码花半小时写个体检脚本跑一遍把报告看一遍。你会发现很多之前想不通的检索问题答案其实早就写在 PDF 的结构里了。这个前置投入回报率高得超出预期。