首页 / 资讯中心 / 文章详情

PyMuPDF+Qwen-VL:打造图文兼容的PDF RAG知识库

PyMuPDF+Qwen-VL:打造图文兼容的PDF RAG知识库 ★ FEATURED ARTICLE
最近在做一个内部知识库项目被一堆扫描版老 PDF 和带大量图表的研发文档折腾得够呛。一开始按老套路走PDF 转文本 → 切片 → embedding → 丢进 RAG 管线结果用户提交“架构图左下角那个服务叫什么”这类问题时系统完全答不上来。原因不复杂纯文本管道里根本没有“图像内容”这个概念图就是一张空白。后来把方案改成 PyMuPDF 做结构化文本抽取配合 Qwen-VL 做图像语义理解才算把“图文兼容”这四个字真正落到地。这篇文章就是把那套方案的完整骨架、核心代码和踩坑过程整理出来。如果你也在做 PDF RAG、多模态知识库或者只是被“PDF 里的图表检索不到”这个问题卡住这篇可以给你省不少时间。1. PDF 的文本层和图像层它们从来就不是一回事1.1 大部分 PDF 解析工具只解决“一半的问题”PDF 文件在普通人眼里就是一个“排好版的文件”但在程序眼里它本质上是一个二维坐标系统里面散布着文本流、矢量路径、位图图像和注释对象。文本流是可以用解析器直接抽出来的字符数据位图图像则是一块块像素解析器没办法直接“读字”。很多 PDF RAG 项目翻车翻的就是这个认知误区。大家默认“解析 PDF 把文字提取出来”于是 pdfplumber、PyPDF2、Tesseract OCR 这套组合拳打完觉得万事大吉。却忽略了大量技术文档里最有价值的信息是架构图、流程图、时序图、数据表格截图和系统界面截图。你把这些图丢掉RAG 相当于把一个工程师的汇报材料抹掉了所有配图只留了文字描述。而且还有个更隐蔽的问题图旁边的文字往往只是引用比如“如下图所示”“详见左侧拓扑”真正的语义全在图里。文字提取得再干净也提不出图里的模块名称、箭头关系、端口号。所以 PDF RAG 的第一道坎不是“文本提取得干不干净”而是“图像层的信息到底要不要进来、怎么进来”。1.2 三类典型 PDF 的形态差异我做这个项目时把物料分成了三类它们的处理策略完全不同区分这个非常关键PDF 类型典型来源主要难点处理策略原生文本型论文、官方文档、导出网页版式复杂、页眉页脚噪声PyMuPDF 块级提取按坐标过滤扫描图片型老资料、传真件、扫描版书籍OCR 识别和版面还原Qwen-VL 直接看图 文本稀疏提取混合图表型系统设计文档、产品方案、周报文字图表混排图片承载关键信息文本层 图像层双通道并行第三类是最普遍的也是最容易被普通 RAG 方案做残的。比如一份系统方案 PDF 里一页上面四行字、中间一幅架构图、下面三行结论。你按固定 chunk_size 去切文本架构图的信息天然丢失甚至切出来的文本块都是不完整的“碎片”。图文混排场景下不能只按文本流来处理必须把页面当“版面”而不是“文档流”来看。1.3 为什么 OCR 不是终点很多人会说图像层信息用 OCR 抽出来不就行了吗我在项目前期也试过Tesseract PaddleOCR 都跑过一轮。OCR 能解决“扫描版里文字可检索”的问题但解决不了“图表结构理解”的问题。架构图里一个矩形框写着“订单服务”旁边一个箭头指向“支付网关”OCR 只能把“订单服务”“支付网关”这些字符识别出来它不会知道这两个实体之间存在调用关系也不会知道整张图表达的是一种服务依赖拓扑。图表的价值恰恰在结构关系上不在字符本身。这就导出了我的核心选择图像语义理解这件事交给视觉语言模型而不是传统 OCR。Qwen-VL 这类模型可以“看图说话”它能描述出“图中展示了一个订单服务与支付网关之间的调用链路左侧为客户端入口右侧为数据库层”这种描述才是 RAG 后续检索和生成真正需要的语义单元。2. 技术选型为什么是 PyMuPDF Qwen-VL 而不是其他组合2.1 PyMuPDF 在 PDF 结构化解析里的位置PyMuPDF 的底层是 MuPDF一个 C 语言写的 PDF 渲染引擎Python 绑定库叫 fitz。它和 pdfplumber、PyPDF2 最大的区别是它不仅给你文本字符还给你每个字符、每一行、每个文本块在页面上的精确坐标bbox同时还能原生抽取图片对象、渲染页面成高清像素图。这些能力放到 RAG 场景里非常关键。拿文本抽取来对比pdfplumber 也能给坐标但速度慢处理几百页的 PDF 非常吃力。PyPDF2 基本只能拿字符串坐标信息很弱。PyMuPDF 虽然 API 风格偏底层但胜在速度和坐标信息完整页面渲染能力也是天花板级别。它一个库就能覆盖“文本解析 图片抽取 页面渲染”三个需求不用再额外引 PDF 转图工具。我实际的用法是三层配合文本块用 PyMuPDF 的 dict 模式拿图片对象用 get_images 拿那些不带文本层的扫描页面直接 get_pixmap 渲染成 PNG再丢给 Qwen-VL 看图。一个库干完三类活依赖链干净部署包也好打。2.2 为什么视觉模型选 Qwen-VL视觉语言模型现在有很多选择GPT-4V、Claude、Gemini、开源的 CogVLM、MiniGPT-4 也都能跑。但我最终稳定用的是 Qwen-VL 系列理由有三点。第一是中文文档理解能力。我要处理的知识库大量是中文技术文档架构图里的标注也以中文为主。Qwen-VL 在中文 OCR 和中文图表语义理解上有明显优势英文模型在中文小字号标注上经常出错。第二是本地化部署和 API 都灵活。需要私有化部署时Qwen-VL 系列有开源权重可以基于 vLLM 或 FastAPI 起服务走 OpenAI 兼容接口。不想自己整 GPU 集群也有 DashScope 上直接可调的 API。这种“本地可跑、云端可调”的灵活性对项目落地非常重要。第三是成本结构。对知识库里的图做识别只需要生成较短的描述文本Qwen-VL 的输入输出模式非常适合这种“图像描述生成”任务不像多模态大模型的重型方案那样烧 token。2.3 和几套替代方案的横向对比这里我把实际调研过的方案拉出来对比一下给后来人省点弯路方案组合图文兼容度处理速度部署成本真实效果评价pdfplumber Tesseract OCR低OCR 只能出字中等低图表语义分析基本缺失PyMuPDF 全文转图片 GPT-4V高但整页丢给大模型慢按页算很费 token高文本精度受图片渲染影响整体性价比低PyMuPDF 抽取 PaddleOCR 文字中图文字能出图结构不懂中等中等对“图里是什么关系”回答不了PyMuPDF 文本块 Qwen-VL 图描述本方案高文字归文字图形归图形快只对图片调视觉模型中低文本检索和图形语义各走各的通道互不干扰最后一行是这套方案的核心思路“各干各的最后融合”。不要试图用一个模型把所有事都做了也不要把所有页面都渲染成图丢给视觉模型——那样文本检索的精确度会下降token 成本也会爆炸。最合理的做法是文本层用传统解析拿准图像层用视觉模型拿语义两层各建索引最后在检索阶段合并。3. 整体设计两级解析 图文联动3.1 第一级文本层块级解析整个管线的第一级是文本层解析。我没有按固定字符数切片而是采用“块级 坐标”策略。PyMuPDF 的page.get_text(dict)会把页面划分为多个 block每个 block 里有若干 lineline 里有若干 span。我以 block 为基本单元把它当成一个天然语义段。但 block 不能直接用得先过滤噪声。页眉页脚、页码、水印、目录里的“........”引导符这些 block 都会污染 embedding。我的过滤规则有几条根据页面绝对坐标过滤底部区域页码区和顶部区域页眉区过滤纯数字或纯符号的短 block过滤字体名带 “Song”“FangSong”等常规正文字体之外的特殊字体标记块比如注释斜体块。过滤之后每个 block 会保留page_no、bbox、text和block_id四个字段用于后续和图像建立空间关联。3.2 第二级图像区域解析与上下文绑定文本层跑完后轮到图像层。PyMuPDF 的page.get_images(fullTrue)能拿到页面上所有图像对象的 xref但这里有个细节get_images返回的是 PDF 内部的图像资源不代表它一定被“绘制”在页面上也可能只在资源字典里。所以必须用page.get_image_rects(xref)拿到该图像实际出现在页面上的矩形区域再用坐标判断它是否真的可见。拿到图像矩形区域后最关键的一步是把图像和它周围的文本块绑定起来。我管这个叫“上下文绑定”。具体做法是遍历该图像矩形周边指定范围内的文本块取图像上方最近的两个 block 和下方最近的一个 block拼接成一小段“引导文本”。这段引导文本会作为视觉模型的提示语上下文帮助 Qwen-VL 理解这张图在讲什么。举个例子一页文档里先是一段“系统整体部署架构如下”中间插着架构图然后是“各节点资源要求见下表”。如果直接让视觉模型描述图片它只能看到图本身。但把上下两段文字一并喂进去模型就能生成“该图展示了系统整体部署架构包含 Web 接入层、应用层、数据层三个部分”这种更贴合文档意图的描述。这个细节对检索效果的提升非常明显。3.3 统一索引结构设计两层解析完之后最终要落到同一个向量库里才能做统一检索。我用的索引结构设计如下字段说明示例id唯一标识doc_20240301_p12_img5page_no页码12block_type类型text/image_descimage_desccontent索引文本图像语义描述或文本块原文source来源文档system-design-v2.pdfref_image关联图像文件路径images/20240301/12_5.pngbbox原始坐标[36.0, 420.0, 480.0, 560.0]anchor_text图像绑定的引导文本图5 系统整体部署架构这里有个容易忽略的点图像本身的描述文本和文档文本块如果都丢进同一个向量集合检索时可能互相干扰。所以我在 embedding 前给两种类型都加上了类型前缀比如[图]开头表示图像描述[文]开头表示文本块。这能显著减少语义相似度检索时的噪声融合特别是当查询词同时和图、文相关时。4. 核心代码实现文本抽取、图片定位与视觉描述生成4.1 用 PyMuPDF 抽取文本块与图片对象先上第一段核心代码处理文本层import fitz import os import json def extract_text_blocks(pdf_path, output_dir): doc fitz.open(pdf_path) results [] for page_no, page in enumerate(doc, start1): blocks page.get_text(dict, sortTrue)[blocks] page_h page.rect.height for block in blocks: if block[type] ! 0: continue x0, y0, x1, y1 block[bbox] # 过滤页眉页脚顶部 5% 和底部 6% 区域 if y0 page_h * 0.05 or y1 page_h * 0.94: continue text .join( span[text] for line in block[lines] for span in line[spans] ).strip() if len(text) 5: continue results.append({ page_no: page_no, block_id: fp{page_no}_b{len(results)}, bbox: [x0, y0, x1, y1], text: text, }) os.makedirs(output_dir, exist_okTrue) with open(f{output_dir}/text_blocks.json, w, encodingutf-8) as f: json.dump(results, f, ensure_asciiFalse, indent2) print(ftotal text blocks: {len(results)})提取图片对象时要注意get_image_rects的使用这是定位图像实际显示区域的标准方法def extract_images(pdf_path, output_dir): doc fitz.open(pdf_path) image_records [] os.makedirs(f{output_dir}/images, exist_okTrue) for page_no, page in enumerate(doc, start1): images page.get_images(fullTrue) for img_index, img in enumerate(images): xref img[0] rects page.get_image_rects(xref) if not rects: continue for rect_index, rect in enumerate(rects): # 只保留面积大于阈值的图过滤小图标/装饰图 if rect.width * rect.height 10000: continue pix fitz.Pixmap(doc, xref) if pix.n - pix.alpha 4: pix fitz.Pixmap(fitz.csRGB, pix) img_path f{output_dir}/images/{page_no}_{img_index}_{rect_index}.png pix.save(img_path) pix None image_records.append({ page_no: page_no, xref: xref, bbox: [rect.x0, rect.y0, rect.x1, rect.y1], img_path: img_path, width: rect.width, height: rect.height, }) return image_recordsPyMuPDF 的版本不同API 差异不小我本地用的版本是 1.23.xget_image_rects在这个系列里是稳定的。如果你用的是 1.18 或更早的版本可能接口名都不一样。这块建议直接锁定版本不要随缘升级。4.2 图像与相邻文本块的上下文绑定接下来做绑定。核心逻辑是给定图像矩形找到上方最近的两个文本块和下方最近的一个文本块。这里的“上方最近”我用的是文本块 bbox 的底部也就是y1离图像y0最近。def bind_image_context(text_blocks, image_bbox, page_no, max_gap120): page_blocks [b for b in text_blocks if b[page_no] page_no] image_y0, image_y1 image_bbox[1], image_bbox[3] above, below [], [] for b in page_blocks: b_y0, b_y1 b[bbox][1], b[bbox][3] gap abs(b_y1 - image_y0) if b_y1 image_y0 else None if gap is not None and gap max_gap: above.append((gap, b[text])) elif b_y0 image_y1: gap abs(b_y0 - image_y1) if gap max_gap: below.append((gap, b[text])) above.sort(keylambda x: x[0]) below.sort(keylambda x: x[0]) context_text for _, t in above[:2]: context_text t \n for _, t in below[:1]: context_text t \n return context_text.strip()这个绑定策略我调过很多轮。只取上方两个的原因是绝大多数文档排版里图的标题和引导语都在图上方下方一般跟的是表格或备注最多取一个就够了。max_gap120是经验值单位是 PDF 坐标点A4 页面高度约 842 点120 点大概是一英寸半的距离超过这个距离的文本块大概率是上一节的内容硬绑进来反而是噪声。4.3 构造 Qwen-VL 的输入并生成图像描述绑定完成后把图、引导文本一起交给 Qwen-VL。我这边常用两种接入方式私有化部署走 OpenAI 兼容接口或者直接调 DashScope API。下面以 API 方式为例代码逻辑是一样的from dashscope import MultiModalConversation def describe_image(img_path, context_text, page_no): prompt ( 请仔细查看这张图片并结合如下上下文理解它在文档中的含义。\n 需要你输出的内容\n 1. 这张图的主要内容和类型架构图、流程图、表格、截图等\n 2. 图中涉及的关键实体、模块、名称\n 3. 实体之间的关系或核心流程\n 请用简洁的中文描述不超过150字。\n f--- 图片上下文 ---\n{context_text} ) messages [ { role: user, content: [ {image: ffile://{img_path}}, {text: prompt}, ], } ] response MultiModalConversation.call( modelqwen-vl-plus, messagesmessages, ) return response[output][choices][0][message][content][0][text]模型输出的描述统一写入索引库。注意一个细节不是所有图片都值得生成描述。我这里设了过滤条件面积小于 100×100 的小图、纯色块、logo、装饰性图片直接跳过不浪费 token 和向量库空间。4.4 向量化入库最后一步是把文本块和图像描述向量化并写入向量数据库。这个环节有两个实用经验。第一个经验是给content加类型前缀。我在 3.3 里提过的[文]、[图]前缀就是在 embedding 前拼上去的。这样查询“请描述架构图中的服务调用关系”时[图]开头的描述内容会更稳地排在前面查询“文档里对限流算法的说明”时[文]块则优先命中。第二个经验是不要重复 embedding。如果同一个文本块已经被上一版任务处理过hash(content)一致直接跳过。对几百页文档、按块级拆出来的几千个记录来说这能省掉大量重复 API 调用和本地 GPU 排队时间。伪代码如下def index_pipeline(pdf_path, embed_func, vector_store): text_blocks extract_text_blocks(pdf_path, ./tmp) images extract_images(pdf_path, ./tmp) for image_record in images: ctx bind_image_context(text_blocks, image_record[bbox], image_record[page_no]) desc describe_image(image_record[img_path], ctx, image_record[page_no]) vector_store.add( idfpage{image_record[page_no]}_img, contentf[图] {desc}, metadata{page_no: image_record[page_no], img_path: image_record[img_path]} ) for block in text_blocks: vector_store.add( idblock[block_id], contentf[文] {block[text]}, metadata{page_no: block[page_no], bbox: block[bbox]} )5. 检索阶段的图文融合策略5.1 双通道召回与加权合并索引建好只是第一步检索设计才是决定 RAG 质量的关键。我在检索阶段采用了双通道召回同一个 query 向量化后分别从文本通道和图像描述通道各取 top K再做一次基于类型权重的合并排序。具体做法是def hybrid_retrieve(query_vec, top_k6, text_weight0.55, image_weight0.45): text_hits vector_store.query(query_vec, filter{type: text}, top_ktop_k) image_hits vector_store.query(query_vec, filter{type: image}, top_ktop_k) merged [] for hit in text_hits: hit.score * text_weight hit.channel text merged.append(hit) for hit in image_hits: hit.score * image_weight hit.channel image merged.append(hit) merged.sort(keylambda x: x.score, reverseTrue) return merged[:top_k]权重怎么设我的项目里text_weight设 0.55image_weight设 0.45看起来图文的权重很接近。这是因为文档中的图像承载了大量关键信息但如果图像占比更高的物料比如截图型手册可以把图像权重调到 0.6。权重设置没有万能值建议针对你的物料分布跑 50~100 条 query做一个简单的人工标注评测再决定权重的方向。5.2 上下文组装文本块 图像描述 图像链接召回结束后需要把这些命中记录组装成给大模型的上下文。我的组装原则是文本块给原文图像描述给描述文本同时附上图像文件的路径或引用让生成环节能“看图说话”。实际发给下游大模型的内容格式大致是文档片段 1文本块原文文档片段 2图像描述关联图像文件路径或经处理的 base64这样最后回答问题时大模型不仅知道文档写了什么还知道哪里存在一张图和图的大致内容。如果生成时还需要截图证据可以直接把img_path渲染进卡片这对内部知识库问答场景特别有用——用户要的不只是一个答案最好能给出“这个架构图在哪里、原图长什么样”。5.3 跨页图表和表格类图片的特殊处理跨页图是文档解析里永远绕不开的坑。有的架构图横跨两页PyMuPDF 会把图片对象识别为两个独立矩形区域分别落在左右两页。如果不做处理视觉模型会分别描述两次得到两个残缺的描述。我的处理策略是识别相邻页面如第 5 页和第 6 页上 xref 相同、且 y 坐标范围接近的图片对将它们判定为跨页图合并成一个逻辑记录选取面积大的那一半作为主图另一半作为补充描述输入。这样 Qwen-VL 至少能看全一张图的 80% 内容而不是被切了一半。表格类图片也要注意。很多文档的表格是截图而不是文本直接走文本通道会漏掉。表格截图我单独分类让 Qwen-VL 输出结构化的“表格描述 关键行内容”比如“表 3-2 展示了各节点的 CPU 和内存规格第一行为控制节点 8C16G”。这样既保留了表格的语义又避免了把表格截图强行走 OCR 给文本兼顾了检索能力和回答的准确度。6. 实测效果与踩坑清单6.1 三类物料的检索效果对比方案跑通后我拿三批真实物料做了评测。每批 30 条 query按“精确命中率”和“回答可用率”两个指标评估。精确命中率指 top 3 里出现了定位目标回答可用率指最终生成的回答内容基本正确、无需人工大幅修改。物料类型说明精确命中率回答可用率原生文本论文40 篇 PDF纯文字为主86.7%83.3%混合架构文档系统设计文档、方案图表占比约 40%76.7%73.3%扫描版手册老产品手册无文本层整页扫描63.3%60.0%混合架构文档的效果提升最明显因为这类物料在旧方案里基本只能靠文本块命中图像通道补进来后很多和“图”相关的 query 从完全答不上变成能准确回答。扫描版手册相对最弱整页扫描图分辨率不稳定部分老图的清晰度不够Qwen-VL 的描述精度会明显下降。这属于数据质量问题模型再强也救不了模糊图。6.2 我踩过并爬出来的几个坑下面这几个坑是这次落地过程中最值得记录的每一个都花了我至少半天时间。第一个坑PyMuPDF 图像坐标在不同版本间行为不一致。旧版本里get_image_rects返回的坐标是未经过页面旋转校正的新版本则是校正后的。如果你的 PDF 页面设置了旋转属性常见于扫描件转 PDF坐标对不上图像和文本的绑定逻辑会全面错乱。解决方法是统一锁定 PyMuPDF 版本并且每次解析前先检查page.rotation非零时主动做坐标变换。第二个坑视觉模型的“幻觉描述”。Qwen-VL 在生成图像描述时偶尔会把引导文本里的内容“脑补”进画面生成图像里根本没有的文字。比如引导文本里有“Kubernetes”图里实际没这个字样模型仍可能描述“图中包含 Kubernetes 相关组件”。为了解决这个我在提示词里明确加了一句“只描述图片中实际出现的内容不要推断”同时在多轮评测中盯住这类错误。描述作为检索召回单元时少量幻觉影响不大但如果描述进了最终上下文下游大模型会把幻觉当成事实引用这个要警惕。第三个坑图像描述文本的 embedding 质量不稳定。图像描述通常比文本块短embedding 时信息密度低检索召回时容易漏掉。我的解决办法是在 Qwen-VL 输出原有描述之外再要求它输出 3~5 个与该图强相关的关键词做成keyword_text字段拼接进 content。比如描述之外增加“服务调用关系、订单服务、支付网关、消息队列”这样 query 包含具体模块名时图像描述通道命中率大幅提升。6.3 参数调节与成本控制心得参数调节方面最有体感的几个点文本块切分不要依赖固定 chunk_size按 block 切分后如果 block 太长比如超过 500 字再按句子边界二次切分避免把一张大表格的语义截断。图像渲染分辨率控制在 200~300 DPI 比较合适。太低看不清图上小字太高会让 Qwen-VL 输入图片体积过大接口响应时间明显变长。300 DPI 对 A4 页面生成约 2480×3508 像素的图对视觉模型来说足够再往上没有收益。图像描述生成是这套管线里最大的成本开销。如果物料里图表特别多建议先做去重对同一文档里高度相似的截图比如不同章节的相同架构图只生成一次描述。向量库我用的轻量方案是 Chroma纯本地零运维。数据量到了百万级以上再考虑 Milvus前期没必要为架构复杂度买单。7. 最后再分享一个运维层面的小建议这套方案上线跑了一段时间后有个体会特别深图文兼容不是“解析阶段”一次性搞完的事而是要在检索和生成两个环节都持续验证。我后来给管线加了一个可视化调试面板每次 query 能回看召回了哪些文本块、哪些图像描述、以及 Qwen-VL 生成的原始描述文本。这个面板帮我发现了大量“检索明明命中但要答错”的隐蔽问题——大多数是图像描述里出现了幻觉细节被下游大模型当成事实引用了。多模态 RAG 比纯文本 RAG 多出来的一层不确定性恰恰是图像语义描述的质量。这个环节的质检和监控建议从第一天就设计进系统里不要等项目上线后靠用户反馈来发现问题。
阅读完成 · 觉得有帮助?
咨询建站