做 PDF RAG 时间长了,你会发现最头疼的不是模型效果,而是 PDF 解析这一关。文字还好办,PyPDF2、pdfplumber 都能应付,但一旦文档里出现架构图、表格截图、产品实拍图,传统解析管线的信息断层就暴露出来了——检索能召回文字,却召回不了图里的结论和数据。我自己跑通的一套解法是:PyMuPDF 负责把 PDF 里的文字块和图片块按坐标拆出来,Qwen-VL 负责把图片翻译成结构化描述,两者一起进向量库,最终实现图文兼容的 PDF RAG。这套方案我从年初开始搭,在生产环境里跑了大半年,踩了不少坑,也沉淀了一些稳定可靠的做法,这篇就把完整的实战过程分享出来。
1. 传统 PDF RAG 的信息断层:图表几乎全丢
1.1 一个典型的翻车场景
我最早接的业务需求是给一批产品技术手册做内部问答机器人,手册里大量出现"系统架构图""接口时序图""参数对照表"。第一版方案很朴素:PDF 转纯文本 → 按段落切分 → embedding → 向量检索。上线后用户反馈很一致——问"这张架构图里模块之间怎么连接",系统要么答不上来,要么只回一个"图见文档第 X 页"。
问题不在 RAG 流程本身,而在输入端:PDF 里的图片在转文本阶段被直接丢弃了。知识库索引里根本没有图的内容,检索再准也白搭。
这不是个别现象。技术文档、产品手册、行业报告里,信息密度最高的往往是图:一张架构图的文字密度可能抵得上两三页正文,一张趋势图能准确表达数据走势。传统 PDF RAG 方案默认把"图"和"文"割裂,等于把文档里最有价值的一半信息拒之门外。
1.2 PDF 里的"图"其实有三种形态
在处理图片之前,要先搞清楚 PDF 里的"图"到底长什么样。我在实战中把它们分成三类:
- 内嵌位图:直接嵌入 PDF 的图片文件,常见格式是 JPEG、PNG。比如产品截图、扫描的印章、拍照的现场图。
- 矢量图形:PDF 内部用路径、填充、渐变等方式绘制的形状,比如流程图里的方框和连线、柱状图的柱子。它们不是独立的图片文件,而是一组绘图指令。
- 整页扫描件:整个页面本身就是一张大图,没有文字层。老资料、盖章文件里特别多。
这三种形态对解析工具的考验完全不同。位图还能用传统 OCR 硬扛,矢量图如果没有渲染步骤,OCR 根本无从下手;整页扫描件如果按文本库处理,读出来就是空的。
1.3 只解析文本会损失多少信息
我在处理一份 80 页的产品白皮书时做过统计:全书有 47 张图片,其中 25 张包含关键数值或结构关系,比如系统组件拓扑图、性能压测柱状图、接口字段映射表。如果只索引文本,这 25 张图对应的知识点全部丢失。
更隐蔽的是跨图文信息。文档里经常出现"如上图所示,服务端与客户端通过长连接通信",但"上图"的内容并不会出现在文本流里。检索"长连接通信"时,如果图里正好画了连接方式,纯文本索引只能召回这句话本身,无法关联图中的细节,用户还得手动翻 PDF。
所以我后来的结论是:PDF RAG 的解析层必须做到"图文分离提取 + 语义合并回填",而不是简单地把页面拉平成一串文字。这正是 PyMuPDF + Qwen-VL 这套组合要解决的核心问题。
2. 技术选型:PyMuPDF 配 Qwen-VL 的底气从哪来
2.1 PyMuPDF 和 pdfplumber、PyPDF2 的差异
选 PyMuPDF 不是因为它名气大,而是它解决了一个关键问题:它能同时拿到文本块和图片块,并且都带坐标。
我用过的几个库差异很明显:
| 库 | 文本提取 | 图片块定位 | 坐标精度 | 渲染能力 | 上手成本 |
|---|---|---|---|---|---|
| PyPDF2 | 一般 | 只能取图片对象 | 弱 | 无 | 低 |
| pdfplumber | 好 | 不擅长 | 中 | 无 | 中 |
| fitz / PyMuPDF | 极好 | 原生支持 | 高 | 强 | 中 |
PyPDF2 偏轻量,适合简单场景,但是它把文本和图片的信息拆得很碎,想重建"某段文字旁边有张图"的关系很费劲。pdfplumber 做表格和文本定位是一把好手,但图片块不是它的主攻方向,提取坐标布局时需要自己绕弯。
PyMuPDF(import 名是 fitz)最大的价值在于它是解析引擎级的工具。它有原生 API 直接返回页面里的 block 列表,每个 block 自带 bounding box 坐标,而且文本块和图片块共用一个坐标系。这意味着我可以非常自然地回答"这一页有哪些图、这些图分别挨着哪些文字"。
2.2 Qwen-VL 凭什么比 OCR 更适合做图片理解
传统 OCR 方案的第一反应是把图片转成文字,但这条路在 RAG 场景里有两个明显缺陷:
- OCR 只解决"把图里的字抠出来",不解决"这张图表达什么意思"。一张架构图 OCR 出来的可能是一堆散落的模块名和箭头,没有逻辑关系。
- OCR 对矢量图直接无能为力——矢量图没有像素层,必须先渲染成位图才能识别,而 OCR 厂商往往不提供渲染能力。
Qwen-VL 这类视觉语言模型解决的是"理解"层面的问题。它直接把图片作为输入,模型会结合图像里的文字、布局、颜色、形状给出语义化描述。我在实测中让 Qwen-VL 描述架构图,它能输出"服务层包含三个微服务,通过负载均衡连接到数据层,数据层使用主从复制"这样的完整句子,并且能准确捕捉箭头方向和数据流向。
而且 Qwen-VL 对中文场景的适配很友好,处理中文标注的架构图、中文表格时,识别效果比通用 OCR 方案更稳定。这一点在技术文档类场景里价值很大,毕竟英文模型看不懂中文图和中文界面截图是常有的事。
2.3 整体的双通道架构
整套方案的设计思路可以概括成一句话:用 PyMuPDF 做版面解析,把文档拆成"文字块"和"图片块"两条通道;文字块直接走文本处理,图片块渲染成位图后交给 Qwen-VL 生成描述;最后按坐标顺序把描述插回文字流,统一切片、统一向量化。
整个管线分成四个阶段:
- 版面解析阶段:PyMuPDF 读取每一页,产出带坐标的文本块和图片块列表。
- 图片理解阶段:图片块按 bbox 渲染成高分辨率位图,调用 Qwen-VL 生成结构化描述。
- 内容合并阶段:按版面坐标把图片描述插到对应的文本上下文中间,形成"图文混合的内容单元"。
- 索引检索阶段:对混合内容切片、embedding,配合元数据实现混合检索与重排。
这样做的好处是检索时不会出现"文字和图各查各的"。图片描述被嵌入了它们在版面中的原始位置,检索"长连接通信"时,既有邻近文本被召回,也有图片描述中的连接关系被召回,召回内容更完整。
3. 核心实现:用 PyMuPDF 拆出文字与图片的对应关系
3.1 读取页面 Block:坐标是第一生产力
先看最基础的一段代码,用 PyMuPDF 读出一个页面里所有文本块和图片块。
import fitz doc = fitz.open("product_manual.pdf") page = doc[3] # 以第 4 页为例 blocks = page.get_text("blocks") for b in blocks: x0, y0, x1, y1, text, block_no, block_type = b if block_type == 0: print(f"[TEXT] ({x0:.1f}, {y0:.1f}) - ({x1:.1f}, {y1:.1f}): {text[:50]}") elif block_type == 1: print(f"[IMAGE] ({x0:.1f}, {y0:.1f}) - ({x1:.1f}, {y1:.1f})")这里的关键是page.get_text("blocks")返回的每一行是一个 block,里面包含了完整的 bounding box 坐标。block_type == 0表示文本块,block_type == 1表示图片块。坐标单位是 PDF 的点(point),和页面坐标系一致。
有一类特殊情况要注意:部分 PDF 的图片不会出现在 block 列表里,尤其是作为背景水印的图片,或者用 PDF 绘图指令直接画在页面上的矢量图。遇到这种情况,建议再用page.get_images(full=True)查一遍页面关联的图片对象,结合坐标做交叉验证。
3.2 图片按区域裁剪和渲染:清晰度决定识别上限
拿到图片块坐标之后,下一个问题是怎么把它变成 Qwen-VL 能吃的输入。这里我踩过一个坑:直接用doc.extract_image(xref)提取原始图片二进制,分辨率往往不够。因为 PDF 页面里的图片很可能被压缩过,或者原始图很小但被拉伸铺满整个版面。
正确的思路是用 PyMuPDF 的渲染能力,把图片块的矩形区域重新渲染成位图。这样能保证输出分辨率与版面中的显示效果一致,识别准确率明显更高。
import io from PIL import Image clip = fitz.Rect(x0, y0, x1, y1) # 矩阵参数控制缩放倍数,2.0 表示放大一倍 pix = page.get_pixmap(matrix=fitz.Matrix(2.0, 2.0), clip=clip) img = Image.open(io.BytesIO(pix.tobytes("png")))这里有两个细节值得展开:
- 缩放倍数不是越大越好。我实测过 1x、2x、3x、4x 四档,大部分图表在 2x 时已经能稳定识别,3x 以上提升有限,但渲染耗时和内存占用翻倍增长。推荐默认 2x,遇到小字号密集表格再单独提升到 3x。
- 长图会被 Qwen-VL 压缩。如果图片块占比特别大,比如一张横跨半页的架构图,渲染出来的位图尺寸可能超过模型输入上限。建议把最长边限制在 1600 像素左右,超出就等比缩放,避免模型端自动压缩导致细节丢失。
3.3 按版面顺序重排:让图片描述插进正确的上下文
图片描述本身是有意义的,但它必须放在正确的上下文里才有意义。同一页里一张图可能出现在第 1 段和第 2 段之间,也可能出现在页尾作为附表。如果粗暴地把图片描述全部追加到页面末尾,检索时的相关性就会错乱。
我的做法是:把该页所有文本块和图片块按y0(顶部 y 坐标)排序,然后按顺序拼接,遇到图片块就把它的描述插入当前位置。
def page_to_content(page, img_desc_map): blocks = page.get_text("blocks") items = [] for b in blocks: x0, y0, x1, y1, text, block_no, block_type = b items.append((y0, block_type, text, (x0, y0, x1, y1))) items.sort(key=lambda x: x[0]) content_parts = [] for _, btype, text, bbox in items: if btype == 0: content_parts.append(text.strip()) elif btype == 1: desc = img_desc_map.get(bbox, "") if desc: content_parts.append(f"[图片描述] {desc}") return "\n".join(content_parts)img_desc_map的 key 是图片块的坐标元组,value 是 Qwen-VL 生成的描述。实际项目中建议用 block_no 作为 key,因为坐标存在浮点误差,直接比较可能匹配不上。
这样处理之后,一页的内容结构就从"文字、图片、文字、图片"变成了一段连贯的、图文混合的文本,语义上更接近人阅读时的顺序。
4. 图片描述生成、切片与混合检索落地
4.1 写给 Qwen-VL 的提示词:别只说"图片里有什么"
Qwen-VL 能力再强,也要看你怎么问。我第一次接入时提示词写的是"请描述这张图片",结果输出是一堆"图中有一个表格,表格里有若干行若干列"的车轱辘话,没有信息量。
调试几轮后,我总结了一套更适合文档场景的提示词模板:
你是一名技术文档分析助手。请详细分析这张图片,输出以下内容:
- 图片类型(架构图/流程图/柱状图/表格截图/产品图等);
- 图中出现的所有关键文字、数字、模块名称;
- 图中的结构关系,例如模块之间的连接、箭头方向、层级关系;
- 如果是表格,请用 Markdown 表格形式还原关键数据;
- 一句话总结这张图表达的核心信息。 注意:保留原始数据,不要自行推断或补充原图没有的信息。
实测下来,要求输出 Markdown 表格这条非常关键。直接说"描述表格",模型容易输出概括性语言,丢数据;要求表格格式后,模型会尽量保数据完整性,检索时能精确命中关键数字。
调用代码用 DashScope 兼容模式,OpenAI SDK 直接就能跑:
from openai import OpenAI import base64 client = OpenAI( api_key="你的_API_KEY", base_url="https://dashscope.aliyuncs.com/compatible-mode/v1" ) def describe_image(img: Image.Image) -> str: buf = io.BytesIO() img.save(buf, format="PNG") b64 = base64.b64encode(buf.getvalue()).decode() resp = client.chat.completions.create( model="qwen-vl-plus", messages=[{ "role": "user", "content": [ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}, {"type": "text", "text": PROMPT_TEMPLATE} ] }] ) return resp.choices[0].message.content4.2 切片与元数据:检索时能定位到具体页面
图文内容合并完成后,进入切片环节。这里有个和纯文本 RAG 不一样的要点:切片时不能简单按固定长度硬切,否则一条图片描述可能被拦腰截断,语义完整性受损。
我的策略是两级切片:
- 第一级按页面内容切。每个页面生成的内容单元作为一个大的文档块。
- 第二级在页面内按段落切。当页面内容长度超过 800 token 时,以合并后的文本块为单位切分,同时保证每个切片要么完整包含某张图片的描述,要么完全不包含。
切片时同步写入元数据,这是后续定位答案的关键:
| 元数据字段 | 说明 | 示例 |
|---|---|---|
| source_file | 原始 PDF 文件名 | product_manual.pdf |
| page_num | 页码 | 4 |
| block_type | 内容类型,text 或 image | image |
| image_bbox | 图片在页面中的坐标 | (120.5, 300.1, 400.8, 500.2) |
有了 page_num 和 image_bbox,回答生成后可以直接定位到"第几页的哪个区域",用户翻 PDF 时秒级找到依据,体验比纯文字引用好很多。
4.3 混合检索与重排的实测对比
切片完成之后,我把内容向量化存进 FAISS,同时保留一份 BM25 文本索引,用于混合检索。
from rank_bm25 import BM25Okapi import jieba # 向量检索 top 20 vec_hits = vector_storage.search(query_embedding, top_k=20) # BM25 检索 top 20 tokenized_corpus = [list(jieba.cut(doc)) for doc in all_docs] bm25 = BM25Okapi(tokenized_corpus) bm25_hits = bm25.get_top_n(list(jieba.cut(query)), all_docs, n=20) # 合并去重后进 rerank candidates = deduplicate(vec_hits + bm25_hits) reranked = reranker.rerank(query, candidates)为什么要混合检索?因为向量检索擅长语义匹配,但精确关键词——比如"MCU-48"、"TCP 端口 8080"这类编号和参数——BM25 命中率更高。我做了组对比实验,三组方案的召回效果差异明显:
- 纯向量检索:语义相关片段召回好,但含偶发参数错误。
- 纯 BM25:精确名词召回强,但语义近似表达召回差。
- 混合 + 重排:综合表现最稳,关键参数命中率明显提升,最终答案准确率高出一截。
目前我线上用的配置是向量检索取 top 20,BM25 取 top 20,合并去重后交给 reranker 取 top 5 输入给大模型。这个配置在不同文档集上效果都比较稳定。
5. 实测踩坑记录:长文档、扫描件与复杂版面
5.1 大 PDF 处理管线怎么避免内存暴涨
第一次处理 300 页 PDF 时,我直接把所有页面渲染结果都留在内存里,机器 16G 内存直接爆掉。后来做了三处调整,算是彻底解决了:
- 逐页处理、逐页释放。循环中处理完一页,立即把该页的 pixmap、图片对象、内容单元全部置空,不保留跨页引用。
- 限制 Qwen-VL 并发数。同时并发 20 个请求,API 端容易出现超时和限流;控制在 8 个并发时,速度和稳定性平衡最好。
- 渲染结果落盘缓存。图片渲染成位图后,按页码和 block_no 命名存到本地缓存目录。同一个 PDF 二次处理时直接读缓存,省掉重复渲染的时间。
缓存这点特别有用。调提示词时经常要重新生成整批图片描述,没有缓存的话,每调一次提示词就要重新跑一遍全量渲染,一次几十个 PDF 的批次能省下好几十分钟。
5.2 扫描版 PDF 的兜底策略
扫描版 PDF是这套方案里最考验兜底设计的场景。PyMuPDF 的get_text()在扫描版上返回空字符串,文本块列表直接为空,但图片块通常还在。此时我做了统一兜底:如果某页的文本块数为 0,则把整页渲染成位图,直接交给 Qwen-VL 做端到端识别。
if len(text_blocks) == 0: pix = page.get_pixmap(matrix=fitz.Matrix(2.0, 2.0)) img = Image.open(io.BytesIO(pix.tobytes("png"))) desc = describe_image(img) page_content = f"[扫描页识别] {desc}"扫描版页面的识别提示词和普通图片不同,需要额外强调"这是一份扫描文档页,请还原页面中所有段落、标题和关键数据"。因为扫描页经常有手写批注、盖章遮挡,提示词里也要加上"忽略与正文无关的手写痕迹"。
这里要注意,整页渲染会显著增加 Qwen-VL 的输入消耗,建议扫描版单独走一个处理队列,别和普通文档混在一起抢并发。
5.3 表格和复杂版面:误判与失败的真实案例
表格识别是图文兼容 RAG 里最难的关卡之一,我这里有几个真实案例可以分享。
表格误判为图片。PyMuPDF 对部分表格区域会把每一行识别成文本块,但也有些跨页大表格会被识别成图片块。如果按图片块处理,Qwen-VL 的表格还原能力虽然能兜底,但描述长度和 token 消耗都会明显上升。建议对图片块先做一次启发式判断:如果图片块宽高比接近表格常见比例,且上方或下方 50 点范围内存在表头文本,优先尝试合并相邻文本块做结构化表格抽取,抽不干净再降级给 Qwen-VL。
复杂流程图的方向误判。我遇到过一次 Qwen-VL 把流程图的箭头方向描述反了——"A 依赖 B"被识别成"B 依赖 A"。排查下来发现是该图用了曲线箭头、交叉连线多,模型单次看图确实容易混淆。缓解办法是把同一个图片块分两次送入模型,一次正向、一次反向提示"请特别关注箭头方向和依赖关系",取两次输出中信息量更完整的一次。
多图表并列。一个页面里四张小型柱状图并排,PyMuPDF 会把它们识别为多个图片块,但坐标之间挨得很近。如果每个图片块单独送 Qwen-VL,模型会因视野受限而无法理解四张图之间的对比关系。我的处理是:当检测到多个相邻且高度接近的图片块时,将它们合并为一个大区域,整体渲染后一次识别,效果比分开识别好很多。
6. 方案能力边界与适用场景判断
6.1 什么场景适合这套方案
经过半年的线上运行,我认为这套"PyMuPDF 解析 + Qwen-VL 图生成描述"的方案在以下场景里表现最好:
- 技术手册和产品说明书:架构图、接口图、参数表密集,图文混排比例高。
- 行业研究报告:数据图表、柱状图、饼图占比大,文字描述偏概括。
- 合同和投标文件:盖章扫描页、带表格附件的混合型 PDF。
- 企业内部制度文档:流程图多、带审批节点的规范,图的关系比文字本身更值钱。
这些场景的共同点是"图里藏着关键决策信息",如果只做文本 RAG,等于让模型闭着眼睛回答看图才能回答的问题。
6.2 什么时候不必硬上图文识别
反过来,我也踩过一些过度设计的坑。有些文档图占比极低,或者图片只是装饰性配图,这时引入 Qwen-VL 反而增加了处理耗时和成本。
判断标准很简单:先抽样 5 页,数一下图片块里有多少是"信息型图片"(包含数据、结构、逻辑关系),如果占比低于 10%,纯文本 RAG 就够了。
另外,这套方案不适合处理超大批量的低质量扫描件。扫描件渲染整页后,Qwen-VL 的输出质量受原始扫描清晰度影响很大,模糊扫描件的识别结果会带幻觉,比不做识别更危险。这类文档我建议优先用户外专门的扫描优化流程,而不是直接进 RAG 管线。
6.3 后续可以扩展的方向
这套方案本身是模块化的,后续扩展我留了几个口子:
- 知识图谱融合:图片描述中的"模块 A 通过协议 B 连接到模块 C"这类关系,可以进一步用抽取模型转成三元组,喂给图数据库,支撑图谱问答。当前已经有相关实践在推进,效果上能补足纯 RAG 在多跳推理上的短板。
- 分段模型选择:普通图片用 qwen-vl-plus,复杂表格和精密工程图切换到更强的 qwen-vl-max,成本与精度按线路分流。
- 增量索引:PDF 文档经常更新版本,我现在只做全量重建。做增量的话,需要把图片内容的 hash 作为内容指纹,版本更新时只处理变化页,能大幅节省带宽和算力。
方案跑到现在,我最大的感受是:RAG 的瓶颈往往不在模型侧,而在输入侧的数据工程。PyMuPDF 把页面里文字和图片的组织关系完整暴露出来,Qwen-VL 恰好补齐了"看懂图"这块拼图,两者配合,才让 PDF RAG 真正做到了图文兼容。对正在被"检索结果缺图、答案找不到依据"困扰的朋友,这套方案可以直接作为起点,按文中管线搭一遍再根据自己文档的特点调优就行。