ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

图文兼容PDF知识库RAG方案:PyMuPDF+Qwen-VL实践

2026/10/8 16:18:12 拓冰建站 浏览量
图文兼容PDF知识库RAG方案:PyMuPDF+Qwen-VL实践 PDF 大概是做 RAG 知识库最容易翻车的输入格式没有之一。它不像 Markdown 那样自带标题结构也不像 HTML 那样有 DOM 可循更麻烦的是正经文档几乎不可能只有纯文字——架构图、柱状图、流程图、扫描件随时混在里面。我最近在几个内部知识库项目里反复打磨了一套方案用 PyMuPDF 把 PDF 的版面解剖成文本块和图片块再让 Qwen-VL 把图片真正看懂最后把图文信号一起送进向量库参与检索实现真正意义上的图文兼容 PDF RAG。这篇文章我把从选型到落地踩过的坑完整复盘一遍非常适合正在做文档问答、知识库搭建、合同或技术手册处理的朋友。先说明一下这篇实战分享的定位。它不是一篇教科书式的原理科普也不会只丢一个PDF 转 Markdown的玩具代码。我要解决的场景是一个几十上百页的 PDF 文档里既有大段正文文字又有内嵌的架构图、数据图表、表格截图用户提问时既可能问文档第 12 页提到的阈值是多少这类纯文本问题也可能问那张系统架构图里 API 网关连接了哪几个服务这种纯图表问题更可能问结合架构图和前文描述这个系统的容错机制是怎么设计的这种图文混合推理问题。纯文本解析方案在第二、三类问题上基本束手无策而这篇方案恰好能把这些场景补齐。1. 处理带图 PDF 时纯文本 RAG 的翻车现场1.1 一个真实的检索失败案例先讲我实际遇到的一个案例。我处理过一份约 60 页的技术白皮书里面有一页是系统的整体架构图图上画着服务分层、组件名称、数据流向箭头这些内容全部以矢量图形方式嵌在 PDF 里。用常规 pdfplumber 或者 pdfminer 提取文本时这页能拿到的文字只有图下方的几行说明架构图内部的服务名一个都提取不出来。当时用这套纯文本方案上线后有同事提问这个系统的消息队列用的是 Kafka 还是 RabbitMQ这个问题其实架构图里画得清清楚楚但检索系统压根没有把图上内容索引进来最后给了一个完全错误的回答说文档中未找到相关信息。更尴尬的是用户把 PDF 翻到那一页截图发过来图上明明写着 Kafka——这种错误对知识库系统的信任度打击是致命的。这并不是个例。我后来统计过手头的几份电子版 PDF非扫描版纯文本提取平均只能拿到页面真实信息的 70% 左右剩下的 30% 全藏在图片、矢量图、嵌套表格和特殊排版里。这些藏起来的信息恰好是用户最爱问的架构图、流程图、数据图表、看板截图。1.2 三种PDF文本型、图片型、图文混合型做了这么多文档后我习惯把 PDF 分成三类来对待第一类是纯文本型。典型代表是学术论文的预印本、政府公文、纯文字合同。这类 PDF 自带文本层用 PyMuPDF 的page.get_text()就能把内容完整抽出来准确率几乎是 100%完全不需要上视觉模型。第二类是图片型也就是扫描件。整页都是一张大图文本层空空如也。这类文档必须走 OCR 或者整页视觉理解PaddleOCR、Qwen-VL 都干得动但那个成本就完全不一样了。第三类是图文混合型——这也是现实世界中最常见、也最棘手的一类。电子排版生成的技术手册、产品方案、财报、售前材料几乎全是这种有真实文本层但同时又嵌入了大量图片、图表、矢量图。这类文档不能一刀切你得先把版面拆开把文本信号和图像信号分别抽出来再重新组合成结构化的知识元。我这一整套方案的精髓就是针对第三类文档做信号分离而不是无脑转图片。1.3 为什么不能只靠渲染整页给视觉模型一条路有人可能会问现在多模态大模型这么强直接把 PDF 每一页渲染成 PNG整页丢给 Qwen-VL 让它描述不也能解决问题吗我早期真这么干过效果一言难尽。先说第一个问题整页图片会被严重压缩。一个 A4 页面渲染成 1600x2200 像素的 PNG再缩放进视觉模型的输入分辨率页面上的小字号、表格里的数字、架构图里的组件名基本糊成一片。Qwen-VL 能把页面大概主题说出来但细节完全靠猜。第二个问题是图文交叉排版时容易看漏。视觉模型对整页长图的理解并不是逐像素精读而是类似人眼的注意力扫描。当一页上有多个独立图块、图注、脚注混在一起时模型经常只盯着最大的那张图其他内容就漏掉了。第三个问题最致命整页渲染方案丢失了锚点。文本 RAG 有一个宝贵特性——检索结果可以精确到某一段文字回答时可以引用见第几页第几段。而整页视觉方案只能做到答案在这一页的图上至于在哪张图、哪个区域完全无法定位。这意味着用户无法验证答案系统也不敢保证回答的准确性。所以我的结论是不要整页渲染扔给视觉模型而是先用 PyMuPDF 把页面拆成文本块和图片块图片单独裁剪出来再喂给 Qwen-VL。这就是图文双通道的核心思路。2. 选型思路PyMuPDF 和 Qwen-VL 各自解决什么2.1 PyMuPDF 在版面解析上的独特优势先说 PyMuPDF 为什么在这个场景里不可替代。不是说 pdfplumber、pdfminer 这些库不行而是在版面解析这件事上PyMuPDF 是少数能同时提供四件事的库文本提取page.get_text(blocks)直接返回带坐标的文本块每一块的左上角/右下角坐标、文本内容、块类型都齐了。图片定位page.get_image_info()能拿到页面里每一张图片的显示位置bbox还能拿到图片对象的 xref 编号。矢量图读取page.get_drawings()能读出页面里的矢量绘图元素比如架构图里的矩形、线条、文本框轮廓。页面渲染page.get_pixmap()可以在百毫秒级把任意区域渲染成高分辨率图像这个能力用来把图片区域截出来非常方便。我做了个简单的对比表方便你判断自己的场景该选谁库文本提取图片坐标矢量图元素区域渲染综合性能pdfminer.six好但慢一般无不提供中等pdfplumber好表格提取方便能取到基础位置弱不提供中等PyMuPDF快块级带坐标完整 xrefbbox完整 drawings原生支持性能极高强表格只是参考真正让我定下 PyMuPDF 的是它的数据模型文本块、图片、矢量图都以 bbox一个简单的矩形坐标来表述这对我来说是金矿。因为后续做图文关联、caption 归属、窗口合并全都在跟坐标打交道PyMuPDF 提供的坐标统一性省了我大量数据对齐的功夫。2.2 Qwen-VL 的接入位置与模型选择逻辑选 Qwen-VL准确说是 Qwen2-VL 系列我从三个角度讲为什么。第一是私有化部署能力。知识库应用场景里文档内容往往敏感不太可能把 PDF 页面传到云端闭源模型接口去处理。Qwen-VL 是开源权重模型7B 和 72B 都有可以完全私有化部署。7B 模型在 24GB 显存上就能跑起来处理批量文档完全够用。第二是中文图表理解能力。测试过几个开源视觉模型后Qwen-VL 对中文图表的理解是最稳的。中文柱状图、折线图的坐标轴文字、图例、数据标签它基本都能读对。这跟它训练数据里中文图文语料占比有关也是我这种以中文文档为主的场景最刚需的能力。第三是输入分辨率可控。Qwen2-VL 的处理器支持我自定义最大像素数这意味着我可以把裁剪出来的大图适当放大后送进去而不是被强制压缩到固定分辨率。对读取图表里的小字来说这个特性太关键了。接入位置刚才讲了不是整页渲染后丢给它而是 PyMuPDF 先把版面拆开把每个独立的图片块裁剪成小图再让 Qwen-VL 做定向描述。就好比一个精读小组PyMuPDF 是图书管理员负责把书拆开、标出每一页的正文区域和插图区域Qwen-VL 是视觉研究员只负责盯着被裁出来的插图细看然后写下解读附注。两个人各管一段效率比一个人把整本书拍照再逐页扫描要高得多准确度也完全不在一个量级。2.3 为什么不走全页大模型 OCR 路线这条路我得专门说一下因为踩坑率太高了。很多团队一看到 PDF 里有图片第一反应是上 OCR比如 PaddleOCR。但 OCR 是针对扫描件的一套完整 OCR 流程版面分析、文字检测、识别、坐标还原跑一页的时间是 PyMuPDF 提取文本的几十倍。如果你面对的是带文本层的电子版 PDF用 OCR 去处理原本能直接读取的文字纯属杀鸡用牛刀还容易把排版信息搞丢。我的实践原则很简单能用文本层解决的绝不上 OCR只有确认某个区域没有文本层时才动用视觉模型。PyMuPDF 把版面拆开之后哪些块有文本、哪些块是纯图片一目了然这样就能精准控制视觉模型的消耗范围。一个上百页的文档可能真正需要 Qwen-VL 出马的只有二三十张图成本完全可控。3. pipeline 总览图文双通道的 RAG 架构3.1 整体流程与核心设计直接说方案的整体流程我把它拆成五个阶段1. 版面解剖PyMuPDF 解析每一页得到文本块列表和图片信息列表 2. 文本通道文本块清洗、拼接、加上 page_id 和坐标信息 3. 视觉通道图片块裁剪 → Qwen-VL 看图 → 输出结构化 JSON 描述 4. 统一入库文本块和图片描述块分别做 embedding存入向量库 5. 查询链路向量检索 图文窗口合并 多模态模型组装答案这个 pipeline 最核心的设计是双通道统一进了同一个向量库。文本块走常规文本 embedding图片块走的是 Qwen-VL 生成的文本描述再 embedding。为什么要这样设计因为最终用户的查询是文本我们要保证查询向量和图片向量的语义空间一致。如果你用 CLIP 那类独立的图像编码器做图片向量文本 embedding 和图像 embedding 根本不在一个语义空间里检索效果会很飘。我自己测过实验用户问系统支持哪几种部署方式如果图片块向量是 CLIP 生成的文本查询向量检索出来的图片块经常答非所问但换成 Qwen-VL 描述文本再送 bge-m3 embedding图片块的语义和文本查询能对齐得非常好。这个细节决定了整个方案的检索质量上限。3.2 text 通道与 vision 通道的分工两个通道的分工用一句话总结凡是能从文本层读到的交给 text 通道凡是必须看才行的交给 vision 通道。text 通道处理的是正文段落、标题、列表项、表格里的文字单元格。这些内容提取出来后我还会做小的清洗——去页眉页脚、合并断裂的行、过滤重复块——然后带上页码和坐标信息构成一个文档块。vision 通道处理的是内嵌的架构图、数据图表、产品截图、矢量流程图以及在 PyMuPDF 里检测到但没有任何文本关联的图片区域。这些块会先被裁剪成独立 PNG再交给 Qwen-VL 生成一个结构化描述描述里包括图片类型、标题、图中关键文字、核心数据、结论等字段。两个通道最后都变成统一的知识块进向量库知识块的 payload 里有一个type字段标记是text还是image。这样检索阶段才能区别对待也才能在最终回答时把图片路径作为多模态上下文传给模型。3.3 图文块的关联策略caption 归属问题这里有个一开始很容易忽略的坑图片和它的图注/上下文文字是分离的。比如一页上有一张柱状图下面是图 3-2 季度营收对比这张图的标题和结论可能就藏在这行 caption 里。如果我们把图和 caption 拆成两个独立块用户问图 3-2 展示的是什么检索命中 caption 的概率很低命中图片描述块的概率也不稳。我的解决方案是坐标窗口关联。在完成文本块和图片块的坐标提取后对每个图片块做一次邻域搜索找出同一页上垂直方向距离图片 bbox 最近的上方和下方的 1-2 个文本块把它们作为这张图的关联上下文拼进图片描述里一并入块。这样图片的语义就包含了图注的说明检索命中率能提升一大截。反过来也一样检索到正文文本块时我会把同一页上与该块 bbox 有交叠或紧邻的图片一并取出来。这样当用户的问题是结合上面这张图说说……时检索链路能自然地把图文一起呈现给最终模型。4. PyMuPDF 版面解析实践提取文本与图片坐标4.1 文本块提取与清洗先上最基本的代码用 PyMuPDF 打开文档并提取文本块import fitz # PyMuPDF 的导入名固定是 fitz doc fitz.open(sample.pdf) page doc[2] # 拿到第3页 blocks page.get_text(blocks) # 返回列表每个元素是元组 # 元组结构 (x0, y0, x1, y1, text, block_no, block_type)这里的block_type很关键0代表文本块1代表图片块。最开始我图省事直接对blocks全量处理后来发现纯图片页会被当成一个巨大的图片块导致文本通道漏掉整页内容。正确做法是先按block_type分流把文本块和图片块分开处理。文本块拿到后清洗规则我总结了几条实操经验过滤明显是页眉页脚的块用块坐标(y0)判断如果文本块靠近页面顶部或底部小于页面高度的 5% 或大于 95%且长度较短基本就是页眉页脚直接丢掉。合并同一标题断行的块PDF 排版经常把长标题断成多行PyMuPDF 有时也会把一个段落拆成多个块。我的做法是按(x0, y0)坐标排序后如果相邻两个块在垂直方向间距很小小于行高的一半且水平位置相同就合并成一条。去重有些排版会重复输出页脚文字比如第 3 页 / 共 20 页这类同一文本会在不同页重复出现入库前要做全文档级去重。清洗完的文本块我会拼上一个 doc_id 和页码格式类似clean_block { page: page_number, bbox: (x0, y0, x1, y1), text: cleaned_text, block_type: text, }4.2 图片提取的两种正确姿势图片提取是我踩坑最多的环节。PyMuPDF 提供了两种完全不同的取图方式用错一种都会出事。第一种是按 xref 抽取原始图像对象。适合 PDF 内部显式内嵌的位图图片images page.get_image_info(xrefsTrue) for img in images: xref img[xref] bbox img[bbox] if xref 0: continue pix fitz.Pixmap(doc, xref) if pix.n 3: # 处理 CMYK 等色彩空间 pix fitz.Pixmap(fitz.csRGB, pix) pix.save(fimg_{page_number}_{xref}.png)这种方式拿到的是图片的原始像素数据质量最高。但有一个前提陷阱get_image_info(xrefsTrue)只在图片是显式嵌入时才能拿到 xref。很多 PDF 里图片被裁剪、遮罩、甚至用 DCTDecode 多次引证时xref 虽然能拿到但实际显示区域和原始图像不完全一致直接存原始图会把不需要的空白区域也存进去。第二种是按 bbox 渲染页面局部区域。这种方式更通用也更能保证所见即所得zoom 2.0 # 2 倍缩放提高渲染分辨率 mat fitz.Matrix(zoom, zoom) clip fitz.Rect(bbox) # 从 get_image_info() 拿到的展示区域 pix page.get_pixmap(matrixmat, clipclip) pix.save(fclip_{page_number}_{xref or vector}.png)我实际项目里的推荐策略是优先用 bbox 渲染方式因为它是直接从页面渲染出来的和用户看到的效果完全一致不会出现原始图像被裁剪导致的变形问题。只有当你明确知道这个 PDF 里的图片质量很高、没有被裁剪时才用 xref 方式保像素原真度。这里还必须提一个装饰性小图的过滤问题。PDF 里常有一些 logo、小图标、分隔线这些图片没有信息量送进 Qwen-VL 纯属浪费。我的过滤规则很简单bbox 面积小于一页面积 2% 的图片直接跳过。4.3 坐标对齐与旋转页面处理这个坑我必须记一下因为它排查了我整整一个下午。问题表现是某些 PDF 页面提取出来的图片 bbox 和实际渲染出来的位置明显不符图片往左偏了一大截。后来才发现是页面旋转属性在作怪。PDF 里的页面可以有Rotate属性0、90、180、270有些扫描排版工具生成 PDF 时会把横版页面标成旋转 90 度。PyMuPDF 的page.get_text(blocks)返回的坐标通常是经过旋转校正后的显示坐标但get_image_info()在部分版本里返回的 bbox 是原始未校正坐标这两个坐标系混用图文关联全乱。我的处理方式比较暴力但有效在处理任何一页前先检查page.rotationif page.rotation ! 0: # 将图片 bbox 转换到旋转后的坐标系 rect fitz.Rect(bbox) transformed rect * page.rotation_matrix bbox (transformed.x0, transformed.y0, transformed.x1, transformed.y1)然后再和文本块做关联。如果不想处理坐标变换也可以用更省事的办法干脆以page.rect旋转校正后的页面矩形为标准先用page.get_pixmap()渲染整页小图人工抽查 bbox 是否合理。批量处理前我强烈建议在每个文档上先这么做一次视觉检查不然坐标错位的问题会一路传导到检索阶段。5. Qwen-VL 图像理解接入从原始图像到结构化描述5.1 环境与模型部署Qwen-VL 的接入我用的还是常规 transformers 管线关键是版本别太老。我当前跑通的依赖环境如下pip install transformers4.45.0 torch2.1.0 Pillow accelerate加载模型的代码import torch from transformers import Qwen2VLForConditionalGeneration, AutoProcessor model Qwen2VLForConditionalGeneration.from_pretrained( Qwen/Qwen2-VL-7B-Instruct, torch_dtypetorch.bfloat16, device_mapauto, trust_remote_codeTrue, ) processor AutoProcessor.from_pretrained( Qwen/Qwen2-VL-7B-Instruct, trust_remote_codeTrue )显存方面7B 模型在 bfloat16 下大概需要 16GB 显存左右24GB 的 4090 可以很舒服地跑。如果你打算部署成服务端批量处理我建议上 vLLM把 Qwen-VL 包装成 OpenAI 兼容接口这样可以用并发满跑几十张图几秒钟就能处理完。单机串行调用 transform 管线处理大批量 PDF 会慢到怀疑人生——吞吐量能差 5 倍以上。5.2 提示词设计让 VL 输出 JSON 字段给视觉模型的提示词直接决定输出质量。我一开始用的提示词是请描述这张图片的内容结果 Qwen-VL 输出一大段散文式描述关键数据散落各处很难解析入库。后来我改成了强制 JSON 输出效果立刻上了一个台阶。我实际在用的提示词模板如下这是一个从PDF中截取的图片区域。请仔细识别图中内容并严格按JSON格式输出以下字段 { image_type: chart/architecture/table/screenshot/decoration/other, title: 图片标题或图表标题没有则填空字符串, texts: [图中所有可辨认的重要文字逐条列出], data_points: [图表中的关键数值/指标如具体数字和单位没有则空数组], conclusion: 用一句话概括这张图表达的核心信息 } 注意事项 1. 如果图片是记录性内容无法概括请如实输出字段原文。 2. 如果图片是纯装饰或空白输出 {image_type: decoration}。 3. 不要添加JSON之外的任何解释。调用代码如下from PIL import Image def describe_image(image_path: str, prompt: str) - str: image Image.open(image_path).convert(RGB) messages [ {role: user, content: [ {type: image}, {type: text, text: prompt}, ]} ] text processor.apply_chat_template( messages, tokenizeFalse, add_generation_promptTrue ) inputs processor( text[text], images[image], return_tensorspt, max_pixels1280 * 28 * 28, # 控制分辨率上限 ).to(model.device) generated_ids model.generate(**inputs, max_new_tokens512) output processor.batch_decode( generated_ids[:, inputs.input_ids.shape[-1]:], skip_special_tokensTrue, )[0] return output这里max_pixels这个参数值得多说一句它控制了模型接受的最大像素数。值越小推理越快、显存占用越低但小字识别能力会下降值越大细节越好但可能在批量处理大图时爆显存。我用 7B 模型的经验值是设为 1280x28x28 左右也就是相当于约 90 万像素在细节和性能之间比较平衡。如果你的图里关键信息是特别小的标注可以临时调大。5.3 大图切片与分辨率控制处理 PDF 里的图片时我们会遇到另一个矛盾有些架构图长达一整个 page 的高度如果直接送进 Qwen-VL图像会被缩得很小组件名完全看不清。这里我实践出两个办法办法一是按网格切片。把大图按 50% 重叠切成 2x2 或 3x3 的子图每个子图单独送 Qwen-VL 描述最后把子图描述合并成对大图的整体描述。这种方法能保留图内每个区域的细节代价是会增加推理次数。如果一张架构图切成 4 块就相当于 4 次视觉推理成本要提前估算好。办法二是控制渲染 zoom 倍率。在 PyMuPDF 阶段如果原图分辨率不够我会提高clip渲染的 zoom让裁剪出来的 PNG 尺寸足够大。再用max_pixels控制送入模型的上限这样小字能尽量保持清晰。切片逻辑不复杂核心代码是from PIL import Image import os def slice_image(image_path, grid(2, 2), overlap0.1): img Image.open(image_path) w, h img.size slices [] for i in range(grid[0]): for j in range(grid[1]): left max(0, (i * w / grid[0]) - overlap * w / grid[0]) upper max(0, (j * h / grid[1]) - overlap * h / grid[1]) right min(w, ((i 1) * w / grid[0]) overlap * w / grid[0]) lower min(h, ((j 1) * h / grid[1]) overlap * h / grid[1]) crop img.crop((int(left), int(upper), int(right), int(lower))) slices.append(crop) return slices切片后的每块图用同样的提示词送 Qwen-VL得到的分段描述再按坐标拼接。实测里一张复杂架构图切成 4 块后组件名的识别完整性从不到 60% 提升到了 90% 以上。6. 向量化、入库与检索让图文块协同参与问答6.1 embedding 与向量库选择双通道的知识块最终都要向量化入库。embedding 模型我选的是 bge-m3主要原因是它支持中英文混合语义且输出 1024 维向量对短文本块和图注描述的表达能力都很稳。其他模型不是不能用但要注意如果你用的是 bge-large-zh-v1.5对英文图注支持会弱一些如果用 OpenAI 的 embedding又会引入外部 API 依赖知识库场景不太推荐。向量库我选了 Qdrant。原因很简单轻量、docker 一条命令启动、Python SDK 友好中小型项目完全够用。Milvus 我也用过但那个更适合百万级知识库的大规模场景部署运维成本明显更高项目初期没必要。6.2 入库数据结构每个知识块入库时我会存这样一组字段{ id: uuid, doc_id: doc-001, page: 3, type: text, text: 原文文本块或图片描述文本, bbox: [x0, y0, x1, y1], image_path: /data/img/doc-001_p3_xref.png, related_bbox: [[x0, y0, x1, y1], [...]] }几个字段的作用说明一下typetext还是image。检索命中后会根据类型决定如何组装进上下文。image_path只有typeimage时才有。这个字段是为了在最终问答阶段如果用的是支持多模态输入的大模型可以直接把图片路径传进去。related_bbox当前块关联的周围图文块坐标。检索到文本块时我会用它去拉出附近的图片块检索到图片块时同样用它拉出邻近文本块。入库代码示意from qdrant_client import QdrantClient from qdrant_client.models import VectorParams, Distance client QdrantClient(urlhttp://localhost:6333) client.create_collection( collection_namepdf_blocks, vectors_configVectorParams(size1024, distanceDistance.COSINE), ) payload { doc_id: doc-001, page: 3, type: image, text: image_description_text, bbox: [x0, y0, x1, y1], image_path: /data/img/doc-001_p3.png, related_bbox: [caption_bbox], } client.upsert( collection_namepdf_blocks, points[{id: block_id, vector: embedding_vector, payload: payload}], )6.3 查询链路与多模态推理组装查询阶段的流程是这样用户问题先做 embedding然后在向量库里做 top-K 检索拿到命中块之后执行一次图文窗口合并最后把合并结果交给最终生成模型我用 Qwen-VL 做最终问答推理让它能真正看到图片。查询代码示例如下query_vec embedding_model.encode(user_question) hits client.search( collection_namepdf_blocks, query_vectorquery_vec, limit20, ) # 图文窗口合并收集命中的块及其关联块 final_blocks [] hit_page_map {} for hit in hits: payload hit.payload block_key (payload[page], tuple(payload[bbox])) if block_key in hit_page_map: continue hit_page_map[block_key] True final_blocks.append(payload) # 如果命中的是图片块把同页关联文本块也拉出来 if payload[type] image: for rb in payload.get(related_bbox, []): related fetch_block_by_bbox(payload[page], rb) if related: final_blocks.append(related)为什么要把窗口合并做得这么复杂核心原因是用户的问题经常是看图说话 结合上下文的混合推理。比如这个架构图里的数据流向是怎么描述的前面提到的安全策略在图中哪里体现了如果只把检索命中的文本块丢给模型图片信息缺失如果只给图片上下文又不够。合并之后模型既能看到文本上下文也能看到相关图片回答才能覆盖全面。最终组装提示词时我会把文本块按页码排好序图片块直接作为多模态输入传给模型context_text \n.join( f[第{block[page]}页] {block[text]} for block in text_blocks ) image_inputs [Image.open(block[image_path]) for block in image_blocks]这样 Qwen-VL 就能在真正看图的基础上作答而不是靠印象造答案。7. 效果评测与踩坑记录7.1 三类问题的实测对比搭建完这套方案后我在一份约 45 页的混合型技术方案文档上做了抽样测试。文档里有架构图、系统流程图、对比表格截图、若干段正文说明基本代表了图文混合 PDF 的典型情况。我设计了三类测试问题纯文本类部署环境的硬件要求是什么答案在正文段落里图表类架构图里缓存层用了哪个组件这张对比表里方案A的并发上限是多少图文混合类结合架构图和 3.2 节的描述该系统的容错机制如何实现抽样结果如下人工判断回答是否准确仅供参考不同文档偏差会很大测试类别纯文本 RAG只提取文本层本文图文兼容方案纯文本问题88%91%图表类问题13%86%图文混合问题9%79%图表类和图文混合问题的提升幅度是很夸张的从几乎不可用变成了可以实际回答问题。纯文本问题两个方案都做得不错因为这部分本质上是常规文本检索图文兼容方案并没有因为引入图像通道而降低文本通道的质量。值得注意的是图文混合问题的 79% 还有提升空间主要失分点是最终模型在图片 多个文本块的长上下文里偶尔会漏看某些文本块的细节。这可以通过调低 top-K 或者对命中的文本块做重排序来缓解。7.2 高频踩坑与解决办法几个高频坑我按出现频率排一下每个都是真实见过的。第一个坑图注归属错误。有些排版把图注放在图片左侧而不是常见的下方。我最初的取图片上方/下方最近文本块策略在这种版面上会抓错文本把正文段落当成图注并入图片块。解决办法是对图片周围的文本块做一次方向判断取四个方向里最近的那个而不是默认上下。第二个坑矢量图无法用get_image_info()捕获。前面提过的架构图如果完全是矢量绘制get_image_info()返回的列表可能是空的。这时候必须用page.get_drawings()判断该区域是否有大量矢量元素如果有直接用 bbox 渲染区域。这个坑我是在第一版上线后才发现差点漏掉一整类架构图。第三个坑旋转页面 bbox 错位。前文说过了批量处理前务必先人工抽查一页的坐标对齐情况别等到检索错误再回头查。第四个坑Qwen-VL 对小尺寸图标几乎无解。PDF 里有些小 logo、小按钮截图面积太小送进模型后根本识别不出内容。解决办法是面积阈值过滤直接放弃这些无信息量的小图避免脏数据入库。第五个坑max_pixels设置太小导致图表数值识别错误。有一版我把 max_pixels 压到 512x512结果柱状图上的数据标签全被模型看成了一团噪点回答里的数值错得离谱。放大输入分辨率之后准确率立刻恢复。这个参数一定要结合你实际文档里的字号来调不要省显存。第六个坑批量处理时的并发资源竞争。Qwen-VL 一次性加载到显存后如果再用多线程往同一个模型实例发送图片很容易 OOM。稳妥的做法是串行推理或者用 vLLM 的并发调度而不是自己开线程。7.3 后续优化方向这套方案目前在我的内部项目里已经稳定跑了好几个文档集但它离万能还很远。我自己的下一步优化方向有两个一是引入版面分析模型做兜底对 PyMuPDF 无法定位的复杂嵌套表格做二次切分二是加一层 Rerank 模型粗排后对命中的图文块再做精排减少长上下文里无关块对问答的干扰。最后再分享一条个人体会图文兼容 RAG 这事儿最忌讳一上来就追求大而全恨不得把版面分析、视觉模型、重排序全都堆上去。理性做法是先盘一盘你的文档类型——如果 80% 的 PDF 都是纯文本型那做好文本提取就够了视觉通道按需打开如果像我一样天天面对图文混合的技术手册再上 PyMuPDFQwen-VL 这套组合也不迟。工具是死的文档类型分析才是决定方案复杂度该落在哪儿的那个关键判断。