PyMuPDF + Qwen-VL:构建图文混排PDF的RAG检索方案
发布时间:2026/10/11 5:05:32 锦皓数字建站

1. 为什么传统 PDF RAG 一到扫描件就“断片”1.1 文本提取式的 RAG 有多脆弱我最早做 PDF 知识库问答时思路非常简单粗暴用解析库把 PDF 里的文字抠出来按段落切块然后塞进向量库查询时做相似度召回。这个方案跑纯文本合同、论文摘要、政府公开文件都没问题但一旦遇到扫描版手册、带截图的培训资料、图文混排的投标文件效果就急转直下。问题出在三个环节。第一很多 PDF 根本没有文本层整页就是一张图片传统解析库提取出来的是一堆空字符串第二即便有文本层图表里的坐标轴标签、柱状图数值、流程图箭头旁的文字这些信息往往以矢量图形或嵌入图片的形式存在get_text根本够不到第三就算文本都提取出来了如果丢掉“这段文字属于哪张图”“这张表格旁边是哪段说明”这种布局关系检索到的文字块往往是孤立的用户问“图里的最大值是多少”系统只能答非所问。这也是我后来坚持做“图文兼容”方案的原因。RAG 的核心目标是让大模型拿到足够的证据来回答问题证据如果天生就是残缺的后面检索和生成做得再漂亮也是空中楼阁。1.2 图文兼容到底难在哪三个环节我要做的并不是简单地“文本提取 OCR”而是一套能从 PDF 里同时抽出文字和图片并让两者保持关联的管线。拆开来看难点集中在三处。第一个难点是图片定位。PDF 里的图片不是像网页那样乖乖待在一个矩形区域里的它可能被旋转、裁剪、嵌入在某个容器对象里还可能有多个引用指向同一份 XObject。你需要拿到每张图在页面上的物理坐标bbox才能知道它和周边文本的位置关系。第二个难点是内容理解。扫描件里的表格、架构图、流程图如果只用传统 OCR 去识别文字遇到多行表头、跨列合并单元格、图片上叠文本框这类场景就非常容易出错。这个环节更适合交给视觉语言模型VLM来做——它不仅能识别文字还能理解图表的语义结构。第三个难点是检索链路怎么接。图片本身不能直接丢进向量库你需要把图片的视觉特征“翻译”成文本描述再和普通文本块统一建索引。同时用户问一个问题召回结果里可能既有文本块又有图片描述块你需要有一套机制把相关图片和文字拼在一起送给模型并且标注清楚来源页码和坐标方便溯源。我的最终选型是 PyMuPDF 做底层解析Qwen-VL 做图像内容理解。PyMuPDF 负责把 PDF 拆成“文本块 图片块 坐标关系”Qwen-VL 负责把图片变成结构化描述两者合并后走常规的向量检索流程。下面我把完整方案的每一步细节和踩过的坑都写出来。2. 方案总览PyMuPDF 负责拆Qwen-VL 负责看2.1 在整套链路里PyMuPDF 到底承担了什么很多文章喜欢一上来就给架构图我反过来先说分工。整套 PDF RAG 链路分成四段文档解析、图像理解、索引建立、检索问答。PyMuPDF 只负责第一段里的文本和图片抽取Qwen-VL 负责第二段的理解后面两段是标准 RAG 流程。具体到我实现的代码PyMuPDF 这块的核心任务是三个打开 PDF遍历每一页对每一页执行两遍扫描。第一遍用page.get_text(dict)拿到文本块的列表每个文本块都带坐标、字号、字体信息第二遍用page.get_images(fullTrue)拿到该页引用的所有图片 XObject 列表再通过doc.extract_image(xref)把图片二进制数据取出来。拿到这两份数据之后再做一次空间关系匹配。规则不复杂遍历每个图片的 bbox看它和哪些文本块的距离小于阈值就把这些文本块标记为“该图片的关联说明文字”。比如一个架构图下面通常紧跟一段“系统由四层组成”的描述这段描述就会和架构图绑定。绑定关系在后续构建索引时非常关键。值得提醒的是PyMuPDF 的page.get_text(dict)和page.get_text(text)返回的内容差异很大。后者是纯字符串方便阅读但丢掉了坐标前者是嵌套字典保留每个 span 的原始信息虽然解析代码多写几行但后续做布局分析时是必须的。2.2 为什么是 PyMuPDF而不是 PDFPlumber 或 pdfminer.six我在选型时其实先把 PDFPlumber 和 pdfminer.six 都试了一遍最后才锁定 PyMuPDF。先说结论不是别的库不好是它们在不适合这个场景。pdfminer.six 的文本提取精度不错但它对图片 XObject 的处理能力很弱要拿到“图片在页面上的坐标位置”需要绕不少弯路而且它没有直接的 API 能把某页的图片渲染成 Pixmap最后你得自己调底层PDFDevice接口代码量会明显膨胀。PDFPlumber 的page.objects里确实能拿到图片对象page.images也能给出图片的 x0、y0、x1、y1 坐标这点我很喜欢。但它在解析复杂页面时性能偏差几百页的彩色手册跑下来速度能差出三五倍。而且对于某些带旋转标记的图片PDFPlumber 给出的坐标是未修正的你需要额外做矩阵换算。PyMuPDF 的优势在于它直接基于 MuPDF 解析引擎性能和健壮性都很稳。Pixmap对象可以一步把 PDF 页面、指定区域甚至某个 XObject 渲染成像素图这为后续喂给 Qwen-VL 提供了极大的便利。坐标系统也是完整的文档里写的Rect对象自带旋转修正逻辑基本不用我自己做坐标变换。选型对比表我直接把我当时的实测数据贴出来方便参考测试环境为同一台机器处理一份 260 页的图文混排 PDF库文本提取耗时图片坐标获取图片渲染能力复杂页面健壮性pdfminer.six32.4s弱需自行解析图像对象无直接渲染接口一般PDFPlumber44.8s支持坐标需处理旋转无直接渲染接口中上PyMuPDF8.1s完整 Rect 坐标内置 Pixmap 渲染好2.3 Qwen-VL 在流程里的精确位置PyMuPDF 把 PDF 拆完以后图片流并不直接进检索。原因很简单纯图片提不了向量就算提了向量模型拿到的也只是图片的嵌入表示很难直接用于生成式问答。所以我的做法是让 Qwen-VL 把每张图片“翻译”成一段结构化的文字描述。Qwen-VL 在这个方案里不是可选项而是核心理解引擎。我试过用传统 OCR 工具配合规则解析来处理图表碰到图表元素重叠、文字与背景色相近的情况基本就废了。Qwen-VL 这类视觉语言模型把“看”和“理解”合并成了一个过程它可以描述图的类型、结构、关键数值的所在位置还能把柱状图里最高的那根柱子对应的数值直接读出来这在传统 OCR 流程里要写一堆启发式规则才能勉强做到。为了控制成本我并没有把整页 PDF 直接丢给模型而是先用 PyMuPDF 把图片区域裁剪出来必要时做一些放大和清理处理再单独调用 Qwen-VL。页面里的小图标、装饰性元素会先在本地过滤掉只有面积占比超过阈值或者正文中明确引用的图片才会进入模型调用队列。后面章节我会把这套流程每个环节的代码逐一拆开讲解包括 PyMuPDF 的精准裁剪、Qwen-VL 的提示词设计、以及 RAG 链路的混合检索与重排策略。3. PyMuPDF 实操如何把 PDF 精准拆成文本块和图片3.1 文本块提取与坐标信息保留我用 PyMuPDF 提取文本块时第一个动作是打开文档并逐页调用get_text(dict)。这个方法返回的字典里有一个blocks列表每个 block 是一个文本块或图片块的描述。文本块的type为 0图片块的type为 1。拿到 block 后我还要继续往下钻一层到lines和spans因为真正带有字体信息的对象在 span 级别。关键参数是bbox它表示该文本块在页面上的边界矩形后面做图文关联全靠它。另一个关键参数是size和flags分别表示字号和字体样式位掩码我用它来判断某个文本块是不是标题或小字注释。判断标题这件事在分块策略里很有用但在这个阶段我更多的是把它作为一个可选的辅助特征保留下来不参与主要逻辑。import fitz doc fitz.open(sample_doc.pdf) page doc[0] data page.get_text(dict) for block in data[blocks]: if block[type] ! 0: continue # 只处理文本块 bbox block[bbox] text for line in block[lines]: for span in line[spans]: text span[text] print(bbox, repr(text[:50]))这里有个细节值得强调get_text(dict)返回的文本顺序是文档结构顺序不是阅读顺序。遇到多栏排版时可能出现左右两栏文本交错排列的情况。如果要严格按视觉阅读顺序抽取需要靠坐标排序我会把bbox的 x 和 y 组合排序。实际项目里大部分业务文档都是单栏或双栏双栏的我可以按“栏优先”规则修正这个规则我在踩坑章节再细说。3.2 图片区域抽取与格式转换图片这块是 PyMuPDF 的重点戏。page.get_images(fullTrue)返回的是该页面引用的所有图片信息列表每一项包含xref、width、height等元数据。拿到xref之后用doc.extract_image(xref)就能取到图片的二进制字节和扩展名。比较隐蔽的坑是同一张图片可能会被多个页面引用get_images会把每个引用都列出来但它们的xref是同一个。你在做去重的时候要基于xref不要基于图片二进制内容。另外一个问题是图片可能被旋转过extract_image拿到的原始字节是旋转前的图像直接展示会出现方向错乱。doc fitz.open(sample_doc.pdf) page doc[0] for img in page.get_images(fullTrue): xref img[0] base_image doc.extract_image(xref) image_bytes base_image[image] ext base_image[ext] width, height img[2], img[3] # 后续可写入临时文件或直接传给 PIL / Qwen-VL大部分情况下直接拿原始图片字节就有足够信息但有些 PDF 会把图片包在某个奇异色彩空间里extract_image拿出来的数据在普通图片查看器里无法显示。这种情况我用 PyMuPDF 的Pixmap做一次强制渲染pix fitz.Pixmap(doc, xref) if pix.colorspace and pix.colorspace.n 3: pix fitz.Pixmap(fitz.csRGB, pix) png_bytes pix.tobytes(png)Pixmap是 PyMuPDF 提供的最强渲染工具。它不仅能渲染 XObject还能按任意Rect渲染页面区域这一点在第 4 章的图像预处理里会用到。我最终统一把图片转成 PNG 或 JPEG再喂给 Qwen-VL。3.3 文本块与图片的布局关系处理拿到页面上的文本块列表和图片块列表后我的下一步是对两者做空间匹配目的是确定“这张图和哪段文字是描述关系”。匹配算法我用的是一种简化的最近邻规则逻辑如下对于每张图片先找出所有与其bbox存在垂直重叠或水平相邻的文本块重叠或相邻的判定阈值是 15 个像素。在候选文本块里选取垂直距离最近的那个作为“主关联文本”。图片下方或上方 100 像素以内的其他文本块作为“辅助关联文本”一起绑定进元数据。这个规则覆盖了绝大多数图文混排场景。比如产品手册中一张产品渲染图旁边有“尺寸1200×800mm”字样这张图和这段字就会被绑定到一起。原理层面PDF 的阅读顺序是自上而下的图片和它的说明文字在物理位置上天然邻近所以基于坐标的邻近关系推断是合理且高效的。def match_text_to_image(img_bbox, text_blocks, gap15): nearby [] for tb in text_blocks: tb_bbox tb[bbox] # 中心点距离 img_cx (img_bbox[0] img_bbox[2]) / 2 img_cy (img_bbox[1] img_bbox[3]) / 2 tb_cx (tb_bbox[0] tb_bbox[2]) / 2 tb_cy (tb_bbox[1] tb_bbox[3]) / 2 if abs(img_cx - tb_cx) gap or abs(img_cy - tb_cy) gap * 3: nearby.append(tb) return nearby这一步产出的“图文绑定关系”后面会直接写进索引文档问答时才能做到“图片和文字互相印证”。这是纯文本 RAG 永远做不到的点。4. Qwen-VL 接入从“看图说话”到“结构化摘录”4.1 调用前的图像预处理我做了哪些操作Qwen-VL 虽然能直接理解图片但实际调用前我还是做了三步预处理否则效果和成本都会失控。第一步是尺寸归一化。PyMuPDF 拿出来的图片原始尺寸可能极大比如一张 3000×2000 的高清扫描图全部塞进模型不仅浪费 token有时反而让模型看不清局部细节。我会用 PIL 把图片最长边缩放到 1280 像素以内。第二步是过滤小图。面积小于 32×32 像素的图片基本都是装饰性图标直接跳过不送进模型。这个阈值来自我对某企业内部文档库的实测统计低于这个尺寸的图片几乎没有独立语义价值。第三步是放大截图。有些图片中的文字特别小整体缩放到 1280 像素后局部文字会糊掉。我的做法是用page.get_text(dict)先定位图片区域内的文本密度如果密度很高就把图片按 2 倍分辨率截取并送给模型。实际操作中对扫描合同这种场景放大一档后识别效果提升非常明显。from PIL import Image import io def preprocess_image(img_bytes): img Image.open(io.BytesIO(img_bytes)).convert(RGB) w, h img.size max_side 1280 if max(w, h) max_side: ratio max_side / max(w, h) img img.resize((int(w * ratio), int(h * ratio))) return img预处理不是给模型“减负”而是帮助模型把注意力集中到有效信息上这点很多人会忽略。4.2 提示词设计我用一段固定模板同时拿到描述和结构化字段Qwen-VL 的调用方式是标准的 chat 接口支持多模态消息格式。我构造的消息体是content数组里同时放text和image_url两个类型的元素。如果你的代码依赖较老的接口可能还要留意兼容性但我用的这套messages格式在近几个版本里都稳定可用。from openai import OpenAI client OpenAI( api_keyyour_api_key, base_urlhttps://dashscope.aliyuncs.com/compatible-mode/v1, ) prompt ( 请仔细分析这张图片输出以下字段\n 1. 图片类型表格/流程图/架构图/产品图/照片/截图/其他\n 2. 图片内出现的核心文字尽量完整提取不要遗漏\n 3. 图片表达的核心内容或结论用一两句话概括\n 4. 如果图片中有数据或数字请列出所有关键数值并说明其含义\n 5. 图片与周围正文可能是什么关系说明图/数据支撑/示意图\n 请用中文回答直接给出字段内容不要额外解释。 ) response client.chat.completions.create( modelqwen-vl-plus, messages[{ role: user, content: [ {type: image_url, image_url: {url: data:image/png;base64,...}}, {type: text, text: prompt}, ], }], ) result response.choices[0].message.content这个提示词的核心思路是把图片内容切成“结构化摘录”而不是“自由描述”。如果只是问“这张图里有什么”模型会给出自然语言段落后续做检索时相关度并不好用。固定了五个字段之后模型每次输出的格式都比较规整我能直接把第四项“关键数值”单独提取出来供后续的精确匹配使用。实测下来qwen-vl-plus的性价比最合适复杂业务图表的理解准确率已经达到可接受水平如果对成本不太敏感并且文档质量参差可以考虑升级到更强的版本但响应延迟会成倍增加。4.3 Qwen-VL 输出怎么进入检索链路Qwen-VL 给出来的结构化描述是一段自然语言我把它拼接到该图片关联的文本块之后形成一个完整的“图文合并文档块”。举个例子某页有一张“系统架构图”下方紧跟着一段“系统分为接入层、服务层、数据层”的文字那么这一条索引内容就是[图片描述] 图片类型架构图 核心文字接入层、服务层、数据层、API网关、数据库 核心内容系统采用三层架构用户请求经API网关进入服务层服务层调用数据层完成数据读写。 关键数值无 [关联文本] 系统分为接入层、服务层、数据层……原 PDF 正文这样处理的好处很明显用户问“网关放在哪一层”时无论关键词命中图片描述里的“API网关”还是命中正文里的“接入层”都能召回同一个文档块模型拿到这个块后可以同时参考图文两侧的信息作答。这个“图转文再拼接”的设计是整个方案不改变原有检索架构就能实现多模态理解的关键。5. 检索增强链路分块、向量化与召回策略5.1 混合分块策略为什么不能只按固定长度切纯文本 RAG 里最常见的是按 200 到 500 个字符固定切块简单省事但在这个方案里完全行不通。原因很简单图文合并之后的文档块大小参差不齐图片描述可能只有 50 字绑定文本却有三四百字一刀切下去很容把图片和它的绑定文本切断。我使用的分块逻辑是“语义优先、长度兜底”。先遍历 PyMuPDF 输出的原始段落把它们按标题、段落间距、上下文切换点切成语义片段每个片段里如果包含图片引用就把图片描述块作为引文插进去不单独切离。如果单个语义片段超过 600 个 token再用滑动窗口从标题或列表项处二次切分。这个策略有点类似很多文档库的“父子块”做法精切出来的小块用于精确召回包含它的大块上下纹用于给模型补充上下文。我在实践里验证过混合分块的检索命中率明显高于纯固定长度切块尤其是在夹杂大量图表的 PDF 上提升幅度能到 20 到 30 个百分点。def split_document(page_blocks, max_tokens600): chunks [] current [] current_len 0 for block in page_blocks: block_tokens len(block[text]) / 1.5 # 中文粗略按 1 token/1.5 字 if current_len block_tokens max_tokens: chunks.append(.join(current)) current [block[text]] current_len block_tokens else: current.append(block[text]) current_len block_tokens return chunks需要注意的是这个 token 估算很粗真实环境里建议直接用分词器或模型 tokenizer 做精确计算否则索引和召回结果会偏。5.2 向量化与混合召回字面匹配 语义匹配一起上向量化这一层我选择的是通用文本表示模型比如 BGE 系列或者商业 embedding 接口。图文合并块的文本内容经过向量化之后和普通文本块一样进入了同一个向量空间这是整个架构最优雅的地方——它不存在独立的“图片检索通道”所有召回都走同一套向量索引。但只做向量召回不够。用户的问题往往带有精确数值、型号、地址这类实词纯语义向量在这些词上可能召回不准。我于是在向量召回之外补了一条 BM25 字面匹配路线两条路线的结果做加权融合。举个例子用户问“最大功率 1500W 的型号有哪些”BM25 对“1500W”的精确匹配几乎是秒中而向量召回能发现“高功率版本”这类同义改写和“功率等级”等上下文相关的描述两者互补才能保证召回率。融合之后我还会加一步重排序。用重排模型对候选集按相关性二次打分把前 5 到 10 条作为最终上下文。这一步的意义在于向量相似度和检索目标之间并不是完全对齐的重排模型直接学习“针对问题的相关程度”能更好地区分关键证据和无关干扰项。召回方式优势劣势向量召回理解语义、同义改写对精确数值/编号不敏感BM25精确词命中、速度快无法处理同义词、错别字向量BM25融合兼顾语义和精确匹配需调融合权重重排从候选里挑最优证据增加一次模型调用开销5.3 图文映射、答案溯源与页码坐标的传递RAG 问答系统最容易被忽略的是溯源能力。普通文本 RAG 至少还能给出“第 3 章第 2 节”图文方案里如果召回的是图片描述块你却不知道这张图原在哪一页、哪个位置那用户根本没法去原文核对。把这一步做扎实靠的是 PyMuPDF 在解析阶段保留的坐标。我在构造索引文档的时候给每个图文合并块都附加了一个元数据头里面记录页码、图片 xref、图片坐标 bbox、绑定文本块 bbox。检索结果输出时我会完整暴露这些字段前端展示的时候可以直接把答案定位到 PDF 的具体页面上甚至用坐标高亮对应的图片区域。{ chunk_id: doc_001_p12_img03, page: 12, img_bbox: [120.0, 245.0, 312.0, 412.0], text_bbox: [120.0, 420.0, 312.0, 450.0], content: ... }这个设计在实际用户调研里非常受欢迎。企业用户看到回答时能一键跳到原文 PDF 的对应图区信任度完全不一样。6. 实测效果、召回率对比与五个值得记住的坑6.1 我搭的测试集50 份图文混排文档和 200 道题为了验证这个方案的工程可行性我用从某企业文档库整理出的 50 份图文混排 PDF 建了一个评测集涵盖产品手册、投标文件、培训 PPT 导出件和扫描合同四类共计 200 道问答对。评测指标分两档第一档是“检索命中率”看标准答案所在的块是否出现在召回的前 10 个块里第二档是“端到端正确率”让模型基于召回的上下文回答问题人工判断回答是否准确、是否漏掉关键数值。对照实验则是同一批文档跑传统纯文本提取 RAG。结果差距非常明显。纯文本方案在扫描合同和产品手册上的检索命中率只有 54% 和 61%因为大量内容根本不在文本层里而图文方案在全部四类文档上的检索命中率均超过 87%端到端正确率从原来的 40% 上下提升到了 74% 左右。提升最夸张的是含大量流程图的培训文档纯文本方案基本全军覆没图文方案能做到 89% 的命中率。这个结果基本说明只要你的 PDF 里有一定比例的视觉信息图文兼容方案就是值得投入的。6.2 实战里踩过的坑双栏、重复图片与加密扫描件第一个坑是双栏文档的阅读顺序。某次索引时我按get_text(dict)的顺序拼接文本结果同一个段落的文字被左右两栏打乱穿插检索出来的块语义完全不对。后来我加了 y 轴分栏判断如果一页上左右两侧各有一列文字就按 x 坐标分成两个序列分别拼接问题迎刃而解。第二个坑是重复图片的百科全书式污染。前面的章节提过同一张 logo 可能在 50 页里反复出现如果不做去重向量库里会出现 50 份几乎一模一样的图片描述块检索时它们会霸占召回列表。我的去重策略是记录每个 xref 第一出现的页码后续页面只保存引用信息不重复生成图片描述。第三个坑是加密扫描件。很多客户给的 PDF 带着文档打开密码或权限密码PyMuPDF 打开时会报错或只能读到空白页。处理方式是在解析之前先做一轮密码检测用常见的空密码和简单弱口令尝试解锁解锁不了的再让用户显式提供密码。这个过程要展示在日志里不然用户只会困惑“为什么这一批文档全是空的”。第四个坑是模型调用频率限制。几百页的 PDF 里可能提取出几十上百张有效图片同步逐张调用 Qwen-VL 会非常慢而且很容易触发限流。我后来改成了线程池并发调用并把每张图片都先写入本地缓存这样即使中途挂了重跑一遍也不用重新花模型费。第五个坑是幻觉数据混入。Qwen-VL 描述图片时偶尔会给图片“脑补”出不存在的文字比如把流程图里的说明文字误读成另一个词。这个问题无法完全避免但可以通过提示词里加一句“如果没有看到确切文字请输出未见对应文字”显著减少编造概率。6.3 部署上线阶段的工程化建议方案跑通离线评测之后上线前还有几个工程化细节值得分享。一是把“PyMuPDF 解析 Qwen-VL 描述”的离线增量任务做成独立的异步流水线。文档上传后先进入 MQ 队列后台 worker 逐个解析和调用模型解析结果写入 SQLite 或数据库。用户的第一个问题可能出现在几分钟后所以要保证任务具备断点续跑能力和失败重试机制。二是对 Qwen-VL 的输出做一次轻量级的质量过滤。我写了一个规则集检查描述文本里是否包含图片类型、是否超过 20 个汉字、是否包含无意义字符。不符合规则的块会标记为“低置信度”检索时降到队尾避免劣质块污染问答质量。三是索引数据要定期重建。PDF 可能存在版本更新旧文档被替换后如果索引里还残留旧版本的图片描述块用户会搜到过时信息。我设计了一个简单的比对逻辑用文档哈希值判断是否需要重新进流水线更新后再删除旧索引数据。四是给前端加一层坐标定位展示。技术上非常容易实现PyMuPDF 渲染的页面图片配合索引元数据里的 bbox就能在回答下面画出一个高亮框。这一步的体验提升远超预期推荐给所有做知识库产品的团队。五是有条件的场景建议把 Qwen-VL 的调用结果和人工标注做一次对齐评估特别是在上线初期挑选 100 到 200 张真实图片人工审核描述质量。模型的一部分“看图”错误是系统性的比如它对篡改型柱状图的数值判断容易出错这类问题单靠调整提示词很难根治只能通过人工审核发现后再针对性增加后处理规则。这个方案的核心价值是把多模态模型塞进 RAG 而不用改变整体检索架构用 PyMuPDF 精确拆解 PDF 布局用 Qwen-VL 把视觉信息翻译成文本本质上还是全部走文本检索的老路但信息覆盖率完全不同。它解决的是我一开始提到的那个痛点不是所有问题都藏在文本层里很多答案就在图表里只是以前没人去解析它们。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。