资讯动态

图文兼容的PDF知识库RAG:PyMuPDF+Qwen-VL让图表信息可检索

发布时间:2026/10/14 14:36:53 来源:尧图企业网站定制
做PDF知识库问答的时候你有没有遇到过这种情况用户的问题答案明明就在文档里可检索系统就是答不上来。我最近在一个产品手册问答项目里踩了个大坑——手册第14页画了一张散热孔位置示意图图里清清楚楚标着Type-C接口、LED灯条和散热孔的相对位置。用户上来就问“充电口旁边是什么接口”基于PyMuPDF纯文本抽取加向量检索的RAG方案最后返回了一个完全不相关的结果。排查到源头才发现那天解析PDF时只调了get_text拿文字图片里的信息根本没进候选集。后面我把解析层改成两步走PyMuPDF负责抽文本块和图片位置信息再用Qwen-VL对每一张图片做结构化描述把图里的内容翻译回文本统一进入RAG链路。改动之后这类图文问题的检索命中率从五成左右提到了接近九成。这篇文章就把整套图文兼容的PDF RAG方案完整记录下来包括解析层怎么改造、切片怎么组织、检索怎么拼上下文以及一路上踩到的坑。适合正在搭知识库尤其是手头PDF图表密集、图片扫描件多的读者参考。1. 传统 PDF RAG 丢失信息的根源一次实际问答翻车的复盘先回到那次翻车的现场。用户问“充电口旁边是什么接口”系统返回的是手册末尾“常见问题FAQ”里的一段内容跟充电口、散热孔完全不沾边。我第一时间以为是向量化参数或者检索TopK设置有问题结果把解析产物调出来一看第14页的切片里只有两句话“散热孔设计说明”和“注意事项详见后文”。真正承载答案的配图压根就没有进入文本流水线。1.1 那天到底发生了什么这页PDF的真实结构是页面顶部一个标题标题下方一段简介简介右侧嵌着一张大尺寸的产品底部示意图。示意图里用引线标注了Type-C接口、LED灯条、扬声器开孔和散热孔的位置。PyMuPDF用默认的get_text(text)模式抽取时只会把文字部分拼出来图片块整体被跳过。所以这段文字进了向量库图里的标注和位置关系全部被丢掉了。传统RAG方案有个隐含假设PDF里的所有信息都能被文本抽取拿到。这个假设对纯文本文档成立但一遇到图文混排的手册、说明书、白皮书就顶不住了。PDF本身是一个排版容器图片、表格、扫描件和文字是并列存在的信息形态只抽文本等于把一张图里的内容当成不存在。1.2 PDF 里其实藏着四类不同的信息我后来把PDF里的信息粗略分成四类每一类在纯文本管线里的命运完全不一样信息类型解析难度纯文本管线的命运正文文本块低正常进入分块和嵌入能被检索图片照片、示意图、截图高整个被跳过信息直接丢失表格中高结构和行列关系丢失数字可能错位扫描页、手写批注很高变成乱码或空白彻底无法使用图片里的文字、相对位置、箭头指向对文本嵌入模型来说就是一堆不可见的像素。想让RAG回答“图里有什么”必须先把图片翻译成文字再把文字送进向量索引。1.3 信息损耗到底发生在RAG链路的哪一环RAG的完整链路一般是解析→分块→嵌入→检索→生成。很多人花大量时间在后三段调参数、换模型但我那次翻车让我意识到最常见的瓶颈其实出在解析和分块这两步。解析决定了哪些信息能进入后面的流程分块决定了这些信息以什么粒度组织后面模型再强也找不回一开始就被丢掉的内容。打个比方一座图书馆如果把所有藏书里的插图页全部撕掉再编目读者来查插图资料查不到是必然结果跟检索系统有多先进没关系。图文兼容方案的核心理念就是让尽可能多的文档信息进入索引而不只是让对话模型变得更聪明。2. PyMuPDF 文本抽取拿到的不只是字还有版面和约束选PyMuPDF不是因为它名字好听而是因为它能同时给出“文字内容”和“版面坐标”。后者才是后面做图文对齐的关键。如果只用pdfplumber或者OCR路线坐标信息拿得不够全做图文拼接时会很被动。2.1 为什么偏偏是 PyMuPDF我对比过几款主流PDF解析工具实际体验差距挺明显工具解析速度结构化程度图片提取表格处理PyMuPDF快块/行/字符级带坐标强弱pdfplumber中慢单词级表格较好中强pdfminer.six慢版式报告弱弱pypdf快信息粗糙弱弱传统OCR方案慢依赖OCR引擎中弱PyMuPDF底层是C语言写的MuPDF库打开几十MB的大文档基本是秒级。它的get_text(dict)模式会把一页拆成多个块每个块自带bbox坐标文本块、图片块一视同仁。这样我就能像拼图一样把文本和图片的位置关系重新还原出来。2.2 抽文本的同时把图片“登记在册”我一开始只用了page.get_text()后来改成遍历get_text(dict)返回的块列表一步同时拿到文本和图片信息。核心代码长这样import fitz doc fitz.open(产品手册.pdf) for page_index, page in enumerate(doc): page_dict page.get_text(dict) text_blocks [] image_blocks [] for block in page_dict.get(blocks, []): bbox block.get(bbox) # (x0, y0, x1, y1) if block[type] 0: # 文本块 lines block.get(lines, []) text .join( span.get(text, ) for line in lines for span in line.get(spans, []) ) text_blocks.append({bbox: bbox, text: text}) elif block[type] 1: # 图片块 image_blocks.append({bbox: bbox, xref: block.get(number)}) # 接下来可以按需要进一步处理这里有几个细节值得注意。bbox是块在页面上的矩形坐标单位是PDF点不同PDF创建工具生成的坐标基准可能不一样但同一文档内是一致的。文本块内部会包含多行lines每行又有多个spans拼接时要按这个嵌套顺序遍历直接取外层文本经常会漏字。如果想进一步拿到图片的二进制内容可以用page.extract_image(xref)它会返回图片的扩展名、字节流等信息想确认图片在页面上的真实显示区域则用page.get_image_rects(xref)。我踩过的坑是get_images()返回的图片顺序不一定是页面显示顺序这在后面会专门讲。2.3 表格在 PyMuPDF 眼里是散落的文字PyMuPDF很强大但你指望它直接把表格识别成二维结构它会让你失望。在它眼里表格就是一组有规律的文本块和线条没有“这个区域是一张表格”这种语义。简单的表格还能靠文本块x坐标对齐去重建复杂一点的合并单元格、跨行表头基本只能走视觉模型路线。所以我对PyMuPDF的定位是“版面基础设施”它负责给出文本块、图片块、坐标、图片资源让后面的图层能按版面关系做匹配。真正需要语义理解的部分比如图到底讲了什么、表格结构是什么交给视觉语言模型去补位。3. Qwen-VL“看图说话”把像素翻译成可检索的文本图片不能直接进向量模型这没什么好说的。视觉语言模型的意义就在于看图之后输出自然语言描述相当于先把像素翻译成文本再让这条文本参与向量化和检索。我这里选的是Qwen-VL系列主要看重它对中文标注和常见图表形态的理解比较稳。3.1 为什么不是传统 OCR传统OCR擅长把印刷字符变成字符串但对流程图里的箭头关系、组织结构图的上下级、图表标题和坐标轴含意OCR基本无能为力。它给你的是一堆带坐标的文字盒而不是一段连贯的描述。Qwen-VL这类模型不仅能识字还能理解版面布局和语义关系。我给你举个实际例子。一张散热孔示意图OCR输出的可能是“TYPE-C”“LED”“散热孔”三个词加上坐标盒Qwen-VL则能直接输出“Type-C接口位于产品左侧LED灯条位于其上方散热孔集中分布在产品底部”。后者才是RAG检索真正需要的语义单元。用户问“充电口旁边是什么”命中的是这个描述里的“Type-C位于产品左侧”而不是孤零零一个词。对于扫描版PDF也可以把整页渲染成图片直接交给Qwen-VL一次性完成OCR和版面理解。这个处理方式在文档质量一般的扫描件上效果往往比传统OCR更自然。3.2 好 prompt 是图片描述质量的关键同样是Qwen-VL不同prompt出来的结果天差地别。我给不同图片类型设计了不同模板这里直接分享流程/结构图把这张图转换为文字说明按模块和连接关系描述保留所有标注文字不要遗漏箭头方向。表格截图把图片中的表格完整转成markdown表格逐行输出数字必须保留不要合并单元格语义不要省略任何列。组织结构图按树状结构输出上下级关系上级用一级列表下级用缩进表示标注职务名称。扫描文本页把图片中的正文内容尽量完整转写出来保持段落和标题顺序不要自行总结。这些prompt的共同点是明确输出格式、要求保留细节、限制模型自由发挥。图片描述不是要让模型写读后感而是要尽可能忠实还原内容因为描述质量直接决定后面检索能不能命中。描述漏掉一个关键数字用户问到这个数字时就彻底没戏。3.3 调用流程与批量处理Qwen-VL可以通过OpenAI兼容接口调用处理流程就是把图片转成base64连同prompt一起发给模型。代码框架大概这样import base64 from openai import OpenAI client OpenAI( api_key你的API密钥, base_urlhttps://dashscope.aliyuncs.com/compatible-mode/v1, ) def describe_image(image_path, prompt): with open(image_path, rb) as f: b64 base64.b64encode(f.read()).decode(utf-8) resp client.chat.completions.create( modelqwen-vl-max, messages[ {role: system, content: 你是文档解析助手输出结构化中文描述。}, {role: user, content: [ {type: text, text: prompt}, {type: image_url, image_url: {url: fdata:image/png;base64,{b64}}}, ]}, ], ) return resp.choices[0].message.content批量处理时有几个省钱省事的技巧先把图片压缩到长边不超过1024像素去掉四周纯白边再传给模型。图片太大既浪费token还容易超时。如果一张图特别长比如电脑截屏式的长表格我会按三分之一重叠窗口切成几块逐块描述再拼接。批量阶段建议加一层缓存按图片哈希值存储描述结果同一张图重复出现时直接读缓存不再调用模型。这个优化能省下不少调用费用。3.4 解析结果落盘成统一结构图片描述生成之后我会和文本块信息一起落盘成JSON方便下一步切片对齐。结构大致是{ page_id: 14, text_blocks: [ {bbox: [72.0, 120.0, 300.0, 160.0], text: 散热孔设计说明}, {bbox: [72.0, 170.0, 520.0, 240.0], text: 产品底部配有... } ], image_blocks: [ {bbox: [380.0, 170.0, 540.0, 400.0], image_path: pages/page_14_img_0.png, caption: Type-C 接口位于产品左侧LED 灯条位于其上方散热孔集中分布在底部} ] }落盘是个好习惯它能支持断点续跑。一次解析长文档时中途网络断了重新跑也不需要从头再调用视觉模型只要检查哪些page已经生成结果就行。这个JSON结构也是下一章切片对齐的输入结构稳定了后面怎么折腾都好说。4. 图文切片怎么组织坐标对齐与上下文拼接是核心素材都备齐了但现在就全部塞进向量库还不行。怎么切片、怎么把图片描述和正文绑定在一起直接决定检索质量。这一步是整个方案里最花时间的地方也是提升最明显的地方。4.1 为什么切片是分水岭整页直接当一个切片塞进去检索噪声太大一个页里七八个主题互相干扰。反过来把每个文本块和每个图片描述都切得碎碎的独立入库上下文又不够。图片描述如果单独成为一个切片它和正文完全脱离检索召回后生成模型不知道这是哪个产品、哪个页面、回答哪段内容很容易答得前言不搭后语。所以切片要做的是“有组织的绑定”让图片跟着它说明的正文一起走。用户问“充电口旁边是什么”命中的切片应该同时包含产品上下文和图片描述这样生成模型才有足够信息组织出一个有来源、有依据的回答。4.2 一个简单有效的“图随文走”对齐策略我的基线策略是对每个图片块在它左侧或上方寻找最近的文本块把这个文本块和图片描述合并成一个复合片段。用曼哈顿距离做近邻匹配代码长这样def find_related_text_block(image_bbox, text_blocks): ix (image_bbox[0] image_bbox[2]) / 2 iy (image_bbox[1] image_bbox[3]) / 2 best, best_dist None, float(inf) for tb in text_blocks: x (tb[bbox][0] tb[bbox][2]) / 2 y (tb[bbox][1] tb[bbox][3]) / 2 # 只考虑图片左侧或上方的文本块 if tb[bbox][2] image_bbox[0] or tb[bbox][3] image_bbox[1]: dist abs(ix - x) abs(iy - y) if dist best_dist: best_dist, best dist, tb return best这个启发性规则其实是有前提的大多数排版里配图要么在正文段落下方要么在右/左侧“左上方最近的文字”大概率就是配图的说明文字。它不完美遇到双栏排版就会错位但作为基线策略完全够用。双栏的坑在第6章会说怎么优化。4.3 切片聚合策略与元数据保留文本块太短直接入库会导致向量表示不准确。我实践里的做法是同一页面内按阅读顺序把相邻的短文本块拼接成500到800字一段图片描述跟着关联到的文本块走如果关联文本块很短就把图片描述并入附近的聚合段每个切片保留page_id、block_ids、图片说明摘要检索命中后能还原到具体页面和块。元数据保留这件事很重要。它不参与向量化但每次检索结果返回后我需要靠它拼引用来源、跳转到原PDF页面。没有元数据的RAG检索结果就像没有页码的引用等于白查。4.4 表格切片的处理表格经过Qwen-VL转成markdown后本质上就是文本。我会把它当作一个独立的文本块放到表格bbox对应页面位置参与同一套聚合逻辑。这么做的好处是用户问“报价单第三行是什么型号”时检索链路能直接命中表格切片而不是命中表格附近的其他正文。表格切片在向量化时可以单独加上表头信息让模型更清楚这是一份结构化数据。5. 检索与生成链路混合检索 重排 带引用答案切片组织好以后后面就是标准RAG流程了。但这里有几个容易被忽视的细节值得认真做。5.1 嵌入模型和向量库怎么选图片描述已经翻译成文字直接交给文本嵌入模型就行不需要额外接图片向量模型。中文场景我用的是bge-m3这一类的模型对中文长文本的支持比较成熟检索稳定性也不错。向量库方面单机验证用FAISS完全够跑通了再考虑迁到Milvus或者pgvector。我建议先别一上来就上重型组件FAISS搞个小脚本验证效果比搭一套分布式更符合这个阶段的需求。5.2 混合检索为什么必要纯向量检索对语义相近的问题效果好但查准确型号、人名、编号这类场景容易翻车。原因是向量匹配天然偏向语义相似而忽略精确字符串匹配。我实际项目里是向量检索和BM25关键词检索同时跑取两种结果合并再用重排模型统一排序。这个组合对“查具体数字”和“查某个型号的产品”这类问题提升很大。重排模型不一定要很大bge-reranker系列中等规模的就能明显改善排序质量。5.3 检索结果怎么拼进 Prompttop_k切片命中后组织Prompt的方式也有讲究。我通常把每个命中切片转成这样的结构再拼进系统提示词{ text: 散热孔分布在产品底部Type-C 接口位于产品左侧LED 灯条位于接口上方。, source: 产品手册.pdf, page_id: 14, caption: 散热孔示意图说明 }Prompt里我会明确告诉模型请根据参考资料回答涉及插图内容以插图说明为准回答必要时标注页码。这样能减少模型脱离材料自由发挥的概率。图文方案里图片描述是生成答案的一手素材不能把“插图说明”当成辅助信息丢到Prompt末尾。5.4 用“三类问题”评估图文方案的效果搭建完方案之后一定要做评估否则改了什么都看不出来。我建议把测试集分成三类纯正文问题、图表说明问题、表格数据问题。我这次项目里的对比结果大致是问题类型纯文本RAG命中率图文RAG命中率纯正文问题72%78%图表说明问题12%86%表格数据问题41%83%这是个人项目体感数据不是正经benchmark但趋势很能说明问题纯文本方案在图表类问题上几乎是瘫痪的图文兼容方案把最大的一块短板补上了。如果你的文档里图表占比更高提升幅度还会更夸张。6. 实测效果与踩坑清单解析顺序、图片压缩、双栏版面这些细节方案跑通之后我整理了几个被问得最多、也最容易踩的坑。这里直接按日志形式分享能省大家不少时间。6.1 一版实测结果先说结论图文兼容方案在图表问题上的检索命中率提高非常明显从原来的“基本答不上来”变成了“大概率命中”。表格问题仍然受视觉模型识别精度影响复杂表格偶尔会漏数字需要人工校验流程兜底。总体来看这套方案值得为图表密集的PDF知识库投入。6.2 坑一图片抽取顺序和显示顺序不是一回事PyMuPDF的get_images()返回的是PDF内部资源表顺序不是页面显示顺序。我第一次处理一个多图页面时完全按资源顺序把图片描述拼到文本后面结果明明该配第3段的图描述跑到了第1段旁边回答错得离谱。后来统一改成按图片bbox坐标排序再配合get_image_rects()校准真实显示区域这个问题才解决。记住PDF解析世界里坐标比顺序可靠特别是面对来自不同生成工具、不同排版的PDF时。6.3 坑二大图直接喂给视觉模型会超时或漏细节手册首页的横幅图动辄几千像素宽直接base64传给Qwen-VL接口大概率超时模型还会漏掉角落里的细小文字。我的处理方式是三条统一压缩长边到1024像素裁剪四周纯白边长图按三分之一重叠窗口切块逐块描述后拼接。用PIL处理就几行代码的事但收益立竿见影。图片描述最大的敌人不是模型不够聪明而是输入信息量过大导致的注意力分散。6.4 坑三双栏PDF的图会拼到另一栏文字上学术论文和产品规格书经常是双栏排版。不分栏直接按最近邻匹配图片左侧的文本其实是另一栏的内容拼出来的切片内容驴唇不对马嘴。我的优化方法是先取一页所有文本块的x中心坐标做简单聚类如果聚成左右两簇且中心距离超过阈值就判定为双栏然后在栏内各自匹配图片。单栏文档继续用全局最近邻。这个规则不复杂但对检索质量的提升非常直接。6.5 坑四Qwen-VL 读密集小表格会漏数字表格截图字一多模型偶尔会偷懒跳过某些数字。我的应对方式把表格区域放大到160%重新传一次prompt里明确写“逐行输出完整保留所有数字不要省略任何单元格”表格特别大时按行切成几段分别识别再合并。输出后我会抽查数字行数与原表格行数是否一致对不上就重新识别。这部分虽然麻烦但表格类问题的检索能不能用完全取决于这一步的完整度。6.6 算力与成本怎么省前期用API起步最省心M系列芯片的Mac可以直接跑客户端调用不需要本地部署GPU服务。如果纯本地部署需要准备一台显存够用的机器成本比API高不少。我建议前期API验证效果批量阶段加缓存等处理量稳定下来了再评估要不要换本地部署。大批量解析时把缓存命中率做到60%以上成本能省下相当可观的一部分。7. 写在最后解析层多花的时间检索层会加倍还你整套项目跑下来我体会最深的一点是RAG项目最花时间的地方往往不在用什么模型、调什么参数而在解析层和切片层。你愿意在文档解析上多花半天搞清楚每一类信息以什么形态存在于PDF里后面的检索和生成会省下很多纠错时间。我见过太多项目前期对解析草草了事上线后不停调重排、换模型来弥补信息缺失事倍功半。最后分享一个小技巧拿一批真实问题和目标答案页码做成验收集每次改解析管线、改切片策略后都跑一遍。这个小验收集比我调Embedding参数带来的收益直观得多也最能暴露解析层有没有引入新问题。如果你的文档里有大量扫描件还可以在此基础上加入版面分析步骤把页面切成标题、正文、图表区域后再走管线效果会更上一层楼。图文兼容的方案听起来复杂核心其实就是一句话让PDF里所有信息都以文本形态进入索引。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑