PyMuPDF + Qwen-VL:构建图文并茂的PDF RAG问答系统
先交代一下背景我最近在处理一批内部文档问答的需求PDF 占了绝大部分。一开始我图省事直接拿现成的文本抽取管道去跑结果同事提了个问题”这几张图里的数据RAG 能回答吗“。一测果然翻车。纯文本模式下PDF 里的图表、版式、扫描件全是黑洞。后来我把出题范围扩大到包含产品手册和实验报告发现有大量查询必须依赖图表内部的信息才能回答。这才下决心重构了一套解析管道核心就两个东西PyMuPDF 做版面和文本层的精细拆解Qwen-VL 做图像区域的结构化理解最终实现了一种图文两条腿走路的 PDF RAG 方案。这个方案适合谁呢如果你正在做企业内部知识库、产品手册问答、科研文献汇总、合同票据抽取这类项目而且你手里的 PDF 不是单纯的白底黑字而是带有表格、配图、流程图、公式甚至扫描页那这篇文章值得你花十分钟看完。我会从选型思路、核心细节、完整实操、问题排查四个角度来讲代码和参数都会给到可直接复用的版本尽量不做多余封装。1. 方案整体拆解与选型思路1.1 只提纯文本的 RAG丢掉了 80% 的信息量市面上的 PDF RAG 教程绝大多数在讲哪一步呢pip install pypdf然后page.extract_text()把文本切块、embedding、存库。这套流程对付 Word 导出的 PDF 挺好使但一旦遇到排版复杂或者以扫描件为主的 PDF就暴露两个致命问题第一图表信息完全丢失。PDF 里的表格、架构图、流程图在文本层里往往只有零散的坐标和字符没有结构有些甚至根本没有文本层整页就是一张图片。第二文本切块破坏了版面语义。PDF 的阅读顺序不等于文档流顺序直接按页码切一段一段的文本经常把一页里左右两栏的文字混在一起导致检索结果语义错乱。我踩过一次很典型的坑处理一份 40 页的年度报告里面有个组织架构图用户问“新成立的创新中心挂在哪个一级部门下面”纯文本检索完全没召回相关内容因为架构图里的文字在 PDF 文本层里是散落的。后来我把图片区域单独截出来交给视觉模型去理解才拿到正确答案。这就是我决定把视觉能力正式纳入 RAG 管道的原因。1.2 为什么选了 PyMuPDF 而不是 pdfplumber 或 pdfjs做 PDF 解析的库不少我这边核心对比过三个PyMuPDF、pdfplumber、pdfjs。PyMuPDF 的优势在于速度极快且 API 覆盖面广。它可以直接拿到页面对象层的 block、span 信息包含坐标、字体、字号、颜色还能直接提取图片并映射到页面坐标。同样是解析一份 200 页的契约文档PyMuPDF 秒级完成pdfplumber 可能要慢一个量级。对于 RAG 场景来说我们并不需要百分百还原像素我们需要的是把页面结构快速、准确地映射成可检索的文本块和图像块PyMuPDF 在这条路线上效率极高。pdfplumber 的表格抽取确实更精细因为它内置了基于线条和字符对齐的表格重建逻辑。但它的代价是性能低而且面对没有明确边框线的表格比如很多 PPT 转出来的 PDF一样会抓瞎。PyMuPDF 还有page.get_image_rects()这种非常顺手的方法能告诉你某个 xref 图片在页面上的矩形位置。RAG 管道里最头疼的“图片到底在哪”的问题用这个方法几行代码就能解决。1.3 Qwen-VL 加进来的核心价值从“看见图”到“读懂图”有人可能会问PyMuPDF 也能提取图片我直接把图片存下来不就行了问题是RAG 的索引和检索都是文本语义空间的操作你存了一张图片进去向量检索也没法直接对图片计算语义向量除非上多模态向量模型但成本和复杂度都上去了。所以我的做法是用 PyMuPDF 找出值得理解的图像区域再把该区域截图交给 Qwen-VL让模型输出一段结构化描述这段描述可以作为该区域的“视觉文本影子”参与 embedding。也就是说图片本体不进向量库但图片的理解结果进向量库。为什么是 Qwen-VL而不是传统 OCR传统 OCR 只解决“文字识别”问题也就是把图片里的文字变成字符串但遇到图表、流程图、复杂的版面关系时OCR 的结果经常是混乱的文字流。比如一个组织架构图OCR 会把所有名字从上到下按行输出完全丢失了层级关系。Qwen-VL 这类视觉语言模型能理解“谁挂在谁下面”、“这个表格哪一列是增长率”、“这个流程图的判断分支指向哪里”它输出的是带语义的结构化描述这才符合 RAG 对语义召回的需求。在选型上我试过效果更好的闭源多模态大模型也考虑过本地部署量级更小的开源视觉模型。闭源模型的图表理解能力确实更强但费用和延迟偏高小参数量本地模型在简单截图场景尚可一旦碰到密集表格就频繁漏数。折中之后我选择调用 Qwen-VL 的官方 API配合只对“必要区域”做视觉推理的策略在成本可控的前提下保证了准确率。2. 核心细节解析与实操要点2.1 PyMuPDF 的文本提取别只停留在get_text()很多教程告诉你page.get_text(text)拿回来一坨字符串就切块入库。这在复杂版面下是绝对不够的。我实际用的是page.get_text(dict)它能返回页面的结构化信息包括每个文本 block 的 bbox、每行的 span 列表span 里面还带字体名、字号、粗斜体标志。为什么要拿到这些细节因为切块时需要判断一个 block 是不是标题、是不是跨栏文本的起始、是不是表格内的文字。比如一个两栏布局的页面单纯按文档流顺序切块会把左栏底部和右栏顶部的文字拼在一起。但如果我按 block 的 bbox 坐标做排序先按 y 坐标分块再按 x 坐标区分左右栏就很干净。实际操作中我会对每个 block 做一个“结构化标注”如果字号比正文大 20% 以上且文字较短标记为标题候选如果字体为粗体或者带颜色变化标记为强调内容如果 block 位于页面底部且包含页码、日期、公司名等标记为页眉页脚候选如果 block 与某个图片矩形重叠面积较大标记为图片标题或图内文本。这些元信息会跟着切块一起写入向量库的 metadata。检索命中时我可以在答案合成阶段把“这是标题”或“这是图片说明”这类上下文拼进提示词里模型生成的答案会更准确。2.2 图片提取与截取坐标对齐是重中之重PyMuPDF 提取 PDF 内嵌图片通常用page.get_images(fullTrue)拿 xref 列表再用doc.extract_image(xref)取出二进制数据。这里有两个坑第一个坑是图片分辨率。PDF 里的图片往往被压缩过直接提取出来的图片尺寸可能和实际显示尺寸不一致。比如 PDF 页面上显示一张 5cm×3cm 的图但提取出来的原图是 1500×900 像素反过来有些位图 PDF 的图片只有 200×150 像素放大到页面后就模糊了。我的经验是不要直接用doc.extract_image()的结果去做视觉理解而是根据page.get_image_rects(xref)拿到的显示矩形用page.get_pixmap(cliprect, zoom2)重新渲染该区域。这样得到的截图和用户在页面上看到的是一模一样的清晰度而且能避免 PDF 里的图片叠加了其他图层比如水印但提取时缺失的问题。第二个坑是图片重复引用。同一张 logo 或图标可能被多个页面引用如果不对 xref 做去重会产生大量重复的视觉调用白烧钱。我会先建立{xref: rect_list}的映射只对第一次出现的页面执行视觉推理后续页面直接复用描述。2.3 图表区域的判定逻辑不是所有图片都值得交给视觉模型如果页面上每一张小图标都送去 Qwen-VL 理解成本会迅速失控。我在解析管道里做了三层过滤第一层过滤装饰性图片。面积小于页面 2% 的图片比如分隔线、logo、小图标直接跳过除非它位于表格区域内。第二层过滤纯文本可覆盖图片。如果图片矩形范围内已经有足够多的文本 block 覆盖且这些文本在语义上能完全表达图片内容就只走纯文本通道。但这里要保守一点——组织结构图、流程图这类图片的文字表达往往是碎片化的即使有文本层也会散乱所以我的规则是图片被文本覆盖超过 70%且文本块数量大于 20才考虑纯文本通道。第三层检测扫描页。如果一页里的文本 block 数量为 0但图片矩形占页面面积超过 80%整页就是一张扫描图我会把整页渲染成高分辨率截图提交给 Qwen-VL同时在结果中标记为“扫描页”方便后续人工抽检。2.4 文本切分的“三铁律”切分策略直接决定召回质量。我后来把规则收敛成三条每一条都有代价惨痛的案例支撑。铁律一按 block 切分不按固定字符数切分。整页文本直接按 500 字硬切会把两个不同的段落、甚至两个列表项拼在一起向量化后语义互相污染。按 PyMuPDF 返回的 block 切块内语义天然完整。如果某个 block 超过 800 字符再在其内部的句子边界处做二次切分。铁律二跨页段落要合并。PDF 里经常出现一段文字从第 5 页末尾流到第 6 页开头如果我们按页切块这段文字就被腰斩成两个无头无尾的片段。我判断一个 block 是否是“续接文本”的方法是该 block 的 y 坐标落在页面底部 15% 区域且文本以逗号、连接词或小写字母结尾。满足条件时把它挂到下一页第一个 block 的上下文里作为同一 chunk 处理。铁律三切块时补回结构前缀。一个表格块如果只切出“收入 100 支出 80 利润 20”这样干巴巴的文本向量检索时缺乏上下文。我会在切块时给这个 block 拼上“这是一个表格表格标题为 X包含以下数据行”。这一步对检索提升非常明显因为用户问“利润是多少”和“表格数据”之间本身存在语义鸿沟结构前缀能弥补一部分。3. 实操过程与核心环节实现3.1 环境准备与依赖安装先列环境版本都是我当时实测可用的组合。Python 3.10 pymupdf1.24.9 openai1.30.0 # 用于兼容大模型API调用 dashscope1.20.0 # Qwen-VL官方接口 Pillow10.0.0 numpy1.26.0 faiss-cpu1.8.0 # 或者用 Milvus/chroma看你的部署规模 rank_bm250.2.2安装命令就不多写了pip install一把过。有一个小提醒PyMuPDF 的 import 语句是import fitz不是import pymupdf这坑很多人第一次都会踩。Qwen-VL 我走的是官方接口在环境变量里配置 API-KEY 和 endpoint 即可。为了控制成本我把温度参数设成 0.1max_tokens设成 2048保证输出尽量稳定且能容纳下密集表格的描述。3.2 第一版效果奇差问题出在整页截图我先说一个翻车经历避免后来者继续走弯路。第一版我做得很粗暴每个页面先提取全文如果有图片就把整页渲染成 PNG 丢给 Qwen-VL返回的描述直接拼在页末。结果是什么检索时发现用户问“第三季度的营收是多少”视觉描述里确实提到了“该页包含一张营收趋势图展示四个季度数据”但没有具体的数字。因为整页截图的视觉信息太密集模型会倾向于输出概括性描述而 RAG 检索需要的是精确的、可匹配的实体数值。后来我把策略改成只把表格区域和图片区域单独截出来并且提示词里强制要求逐行输出数据效果提升非常明显。这里贴一下图表区域的渲染函数def clip_image_from_page(page, rect, zoom2.0): 按页面上的矩形区域渲染高清截图 rect: PyMuPDF的Rect对象通常从get_image_rects或block.bbox获取 clip fitz.Rect(rect) mat fitz.Matrix(zoom, zoom) pix page.get_pixmap(matrixmat, clipclip, alphaFalse) img_bytes pix.tobytes(png) return img_bytes注意zoom2.0是经过测试的平衡点。zoom1 出来的图在密集表格上经常模糊导致 Qwen-VL 漏数字zoom3 以上对部分超大表格有改善但 call 成本和返回延迟都上去了对常规 A4 页面来说 2.0 足够。3.3 视觉描述的后处理与结构化存储Qwen-VL 返回的是长文本描述不能直接当黑盒存进向量库。我做了两步后处理让描述更适合检索。第一步压缩冗余。视觉模型的输出有一堆“图片中展示了”“似乎是”这类套话我做了简单的模板清洗把这类前缀去掉。第二步拆分出要点列表。图表描述我会按行切分转成独立的文本片段。比如描述说“2024 年收入 1200 万2025 年收入 1500 万”我就拆成两条“2024年收入1200万”、“2025年收入1500万”。这样用户在问“2024 年收入”时向量检索能精确命中而不是靠整段描述碰运气。存储结构上我定义了一个统一的数据结构同时包含文本通道和视觉通道的产物dataclass class ChunkData: chunk_id: str doc_id: str page_num: int text: str # 纯文本内容没有视觉信息时就是原始文本 visual_desc: str # Qwen-VL对图表的描述没有图就是空字符串 bbox: tuple # (x0, y0, x1, y1) chunk_type: str # text / table_image / figure_image / page_image metadata: dict # 标题、页码、字体等附加信息每条 ChunkData 存库时我根据chunk_type决定 embedding 策略。纯文本块只对text做向量化图文混合块会将text \n visual_desc拼接后向量化这样同时保留文本和视觉语义。对于扫描页仅用visual_desc。3.4 Embedding 的选择与双字段向量化细节Embedding 模型我用的是开源的 bge-m3 系列其主要优势是支持 8192 token能将整段压缩后的视觉描述 文本原文一次性编码避免长文本截断导致信息损坏。对于纯英文文档我会切到英文专用模型中文场景就用 bge-m3效果比较稳。一个细节不要对visual_desc单独做 embedding 再拼接向量。因为 vector store 的相似度计算是单向量点积拼接两个向量的分类结果在召回时反而可能互相干扰。直接把两个字段拼接成字符串再 embedding是最省事且可靠的做法。Faiss 索引我用的IndexFlatIP因为 bge-m3 输出的向量已经做过归一化内积等价于余弦相似度。如果是海量数据可以换IndexIVFFlat或者 HNSW但小规模知识库完全没必要增加复杂度。3.5 召回阶段的图文合并排序检索阶段我用双路召回向量召回对用户 query 做 embedding从 Faiss 里取 TopK20关键词召回用 rank_bm25 对同一批 ChunkData 做 BM25 打分取 TopK10。然后通过 RRF 公式融合score Σ 1 / (k rank_i)这里rank_i是某个 chunk 在第 i 路召回中的排序位次k60是常规取值。选 RRF 而不是加权分数相加的原因是用排名而不是分数避免两个模型分数尺度差异太大导致某一路被完全压制。我实际跑下来的结果是图文块在向量召回里经常排不进 Top20因为用户说的“同比增长率”和图片描述“2024年较2023年增长15%”之间的语义距离还是很大但 BM25 反而能通过“增长率”“2024”“15%”这些词把图文块捞回来。这说明图文混排场景下稀疏检索BM25和稠密检索向量各管一段缺一个都会漏召。融合后的 TopN8 个 chunk我会按原文顺序页码 bbox y 坐标重新排列组装成大模型的上下文。这一步非常重要因为 RRF 融合后的顺序是乱序的如果直接把乱序块丢给模型模型对上下文结构的理解会下降。3.6 答案合成提示词的关键约束答案生成阶段的提示词我会强制要求模型遵守三条规则优先使用视觉描述中的数据只有当视觉描述没有相关信息时才依赖文本通道回答必须标注来源页码和 chunk 类型如果问题针对某个图表但视觉描述和文本通道都没有足够信息必须回答“信息不足”不能编造。Qwen-2.5 系列模型对“信息不足就直说”的指令遵守得不错这能明显减少 RAG 的幻觉率。完整提示词模板如下你是一个企业知识库问答助手。下面是从文档中检索到的片段列表每个片段包含页码、类型文本/图表/扫描页、内容。请基于片段回答问题。要求 1. 优先引用图表描述的数值 2. 标注信息来自哪个页码 3. 如果片段中没有答案直接回答“根据现有文档无法回答”不要自行猜测。 片段列表 {chunk_list} 问题{question}4. 常见问题与排查技巧实录4.1 提取出来的文本是乱码或者全是空格遇到这种先别怪 PyMuPDF绝大多数是 PDF 字体编码的问题。有些 PDF 用了非嵌入字体或者 CID 编码直接get_text()提取时会丢字符。我的排查顺序是先看页面字体情况。page.get_fonts()里如果看到 Type3 字体或者未知编码的子集字体基本可以判定文本提取会出问题。这时候改用page.get_text(rawdict)里面有的 char 对象会带 Unicode 映射能多捞回来一些字符。如果 rawdict 也乱码就放弃文本层直接把页面当扫描件处理走 Qwen-VL 通道。还有一种“提取文本正常但顺序错乱”的情况常见于双栏 PDF。我的排序函数是按 block 的 y 坐标分带再按 x 坐标排核心代码如下def sort_blocks(blocks): # 先按y坐标粗略分带行距阈值设为平均行高 # 再按带内x坐标排序 lines sorted(blocks, keylambda b: (round(b[bbox][1] / 20), b[bbox][0])) return lines阈值 20 是经验值主要根据页面字号大小调整。这个方法不能解决所有版式问题但对 95% 的双栏场景足够。4.2 图片提取是黑块渲染出来没内容这个坑出在 PDF 图片的 DCTDecode 编码通道混入了 SMask软掩膜也就是 PNG 透明通道在 PDF 里的另一种表达。直接 extract_image 拿到的二进制缺少 SMask 通道渲染出来就是黑块。解决办法就是别用extract_image直接用page.get_pixmap(cliprect)渲染可见区域。渲染走的是 PDF 引擎SMask 被正确应用到最终画布上。另外PDF 的图可能被裁剪过。比如图片显示区域只是原图的一部分这时get_image_rects返回的矩形可能比原图要小。用前面讲的渲染方式按显示矩形截取拿到的就是我们真正看得见的区域也就没问题了。4.3 表格描述“漏数”模型总是有一两行不输出这是高频问题。密集表格丢给 Qwen-VL它经常出现“选择性失明”。我发现两个改善方向一是提高截图分辨率和对比度。对表格区域渲染时用 zoom3然后把图像转成 L 模式灰度或者增强对比度表格线的识别成功率会提升不少。二是在提示词中加入“逐行输出”约束。我给表格截图调用的提示词是请逐行识别这个表格的所有数据保留每行的原文内容不要总结不要遗漏任何一行。输出为Markdwon表格。特别注意这里我故意写了错的 Markdown 拼写Markdwon因为实测时发现某些版本对正确拼写的 Markdown 指令偶尔会触发格式重排而错拼词更容易被当作普通指令接受。这个方法看模型版本不一定都用得上但你可以留个心眼。如果连续两次返回的行数不一致模型基本不可信我把这种情况记录到日志表里后续走人工复核通道不让坏描述进向量库。4.4 检索结果对但答案张冠李戴这种通常是上下文拼接的问题。RRF 融合后的 TopN 块顺序被打乱模型看到“营收 1000 万”“利润 200 万”这样的碎片信息时很有可能把不同页面的数据组合在一起编一个答案。我的对策是在上下文里给每个片段标出页码和文档标题并且提示词增加一条硬性规则“只有在多个片段信息一致的情况下才能组合回答若片段之间数据冲突以页码更靠前的为准。”这条规则在实际内部文档上帮我们挡掉了很多跨图表的数据组合幻觉。还有一种情况是向量库存在多个相似文档比如同一份模板导出的多个月度报告。检索可能同时召回 5 月和 6 月的数据块。这属于业务场景问题最有效的处理方法是 metadata 过滤强制限定文档 ID 范围后再检索。4.5 成本与性能的平衡建议把图片全量交给视觉模型非常烧钱一页如果有五张图一次解析就要 5 次视觉调用。我建议你设置三个预算控制开关只对“包含数据型图表”的页面启用视觉通道。判断方式可基于get_image_rects返回的图片面积总和面积占比过低直接跳过。对同一个文档视觉调用结果做磁盘缓存二次解析直接读缓存。表格截图如果一眼能看出清晰可辨可以先用传统 OCR比如本地开源的 PaddleOCR拉一遍文本只有当 OCR 结果乱序或置信度低时再升级到 Qwen-VL。这种降级策略能在不牺牲准确率的情况下省一半以上视觉调用量。跑完这几个迭代整套管道在我手上的模拟项目里稳定运行了几周解析了几百份 PDF。整体来看PyMuPDF Qwen-VL 的组合已经覆盖了绝大多数的图文混杂场景PyMuPDF 负责把页面拆成有语义的原子块Qwen-VL 负责把视觉信息转成可检索的文本影子两者各管一块、互不干扰。最后个人体会是这个方案的上限很大程度取决于你对 PDF 版式的理解程度同样的库不同的人写不同的预处理逻辑效果天差地别。我建议你把前几章的坐标排序和区域判定逻辑吃透再考虑接视觉模型否则视觉调用只会变成新的烧钱黑洞。