1. 为什么纯文本的 PDF RAG 不够用
先从一个实际场景说起。我最近在给某个内部团队搭知识库问答时,遇到了一个非常典型的问题:文档目录看起来内容齐全,但把 PDF 转成纯文本丢进向量库之后,用户无论怎么问都得不到满意答案。排查之后才发现,这份文档里有将近三分之一的页面包含重要配图,包括业务流程图、权限关系图、系统架构图,甚至还有不少“纯截图式”的操作教学页。传统方式只提取文字,最终拿到的是零散的按钮名和段落标题,真正表达“异常情况走 A 分支还是 B 分支”的信息,全都在图像里。
这就是图文兼容 PDF RAG 要解决的痛点。单纯的文本 RAG 在面对这类文档时,检索到的上下文本身就是残缺的,后面的生成环节再强也无济于事。常见的补全路线大概有三种,我整理了一张对比表:
| 方案 | 能识别图 | 能理解图表语义 | 实现成本 | 典型问题 |
|---|---|---|---|---|
| 纯文本提取 | 否 | 否 | 低 | 图区信息完全丢失 |
| OCR 文字识别 | 部分 | 弱 | 中 | 只能拿到文字,无法理解布局与逻辑 |
| 视觉语言模型 | 是 | 较强 | 中高 | 需要设计调用链路与成本控制 |
OCR 只能解决“图里的文字”,但解决不了“这张图想表达什么逻辑”。比如一张包含“判断条件 + 多个处理分支”的流程图,OCR 能提取出几个判断词和节点文案,却无法整理出节点之间的先后关系和分支走向。而视觉语言模型可以直接把整张图作为输入,输出结构化的描述,这就让下游检索和问答质量有了质的提升。所以我最终选了 PyMuPDF 负责解析图纸,Qwen-VL 负责读图理解,并围绕它们搭了一整套索引和检索链路。
这套方案适合谁?如果你的 PDF 里有不少图表、架构图、截图,或者你正在做企业内部文档问答、产品说明书问答、论文阅读助手这类项目,那么这一篇实战内容可以直接参考。下面我会从方案架构、代码实现、检索链路搭建、问题排查几个维度完整展开。
2. 方案架构与工具选型
2.1 整体流程是怎样设计的
整套方案可以拆成四个阶段:解析、理解、索引、检索。先用 PyMuPDF 把 PDF 页面拆成“文本块”和“图像区域”两类元素;然后对图像区域调用 Qwen-VL 生成结构化描述;接着把纯文本块和图像描述块统一做向量化,写入向量库;最后在问答时检索 TopK 相关块,拼装成上下文交给大模型生成回答。
这里有个关键设计点:图像块并不是“原图直接进向量库”,而是用 Qwen-VL 的描述文本代替。原因很简单,向量检索模型擅长处理文本语义,跨模态检索虽然也可以做,但工程链路复杂很多。我们只需要把图像“翻译”成高质量的语义描述文本,就能复用一套成熟且稳定的文本检索体系。检索结果出来之后,如果命中图像描述块,再在最终展示时把原图路径一并带到前端,用户仍然能看到原始截图。
2.2 为什么是 PyMuPDF + Qwen-VL
PyMuPDF 在内地开发者圈子里可能更常见的名字是 fitz。我选它而不是 pdfplumber 或 pdfminer,主要看中三点:
- 原生支持把页面按 block 输出,每个 block 自带坐标、类型、内容,处理起来非常顺手。
- 内置渲染能力,可以按指定矩形区域直接输出图片,做裁剪非常方便。
- 解析速度快,几千页的大文档也不会慢到让人抓狂。
Qwen-VL 这边,我看重的是它对中文文档和常见图表的理解能力。比起通用 API 或者自建 OCR 管线,它能直接响应结构化指令。比如我对一张架构图调用 Qwen-VL,它不仅能告诉我图里有“网关”“服务层”“数据库”,还能描述层与层之间的调用关系,这正是 RAG 后续做语义检索最需要的东西。而且它的开放接口兼容 OpenAI 的调用风格,接代码成本很低。
2.3 环境准备与依赖
我的环境是 Python 3.10,Windows 和 Linux 都跑过,依赖如下:
pip install pymupdf pip install openai pip install faiss-cpu pip install requests pip install Pillow如果你对向量检索有更高的并发需求,可以把 faiss-cpu 换成 qdrant-client,后面我会单独讲差异。OpenAI 库在这里只是作为调用兼容接口的客户端工具,不是使用外部服务的含义,你完全可以换成 requests 直接 POST,示例代码我也会给出两种写法。
3. 用 PyMuPDF 把 PDF 变成“块 + 图”
3.1 先看页面结构,而不是直接抽字符串
很多人用 PyMuPDF 第一步就是page.get_text(),这当然能拿到全文,但拿不到排版结构。想要做图文兼容,第一步应该用get_text("dict")拿到页面上的块结构。
import fitz doc = fitz.open("demo.pdf") page = doc[0] blocks = page.get_text("dict", sort=False)["blocks"] for block in blocks: print(block["type"], block["bbox"])block 的 type 只有两种:0 代表文本块,1 代表图像块。这里有个很容易踩的坑:很多 PDF 里的“示意图”并不是真正的位图,而是由矢量绘图指令绘制的,这种图在 block 列表里可能根本不会出现 type=1 的块。所以光靠 block 分类还是不够,还需要配合页面绘图对象检测。
看是否包含矢量绘图对象,可以使用page.get_drawings(),如果返回结果比较多,说明页面上存在大量矢量图形。典型场景是数据报表和架构图,这时的处理方案是先渲染整页,再根据坐标区域把目标区域裁剪为位图。
3.2 图像块的裁剪与原图提取
对于真正的位图块,PyMuPDF 提供了两种思路。第一种是直接从 PDF 里面把嵌入的原始图片提取出来:
page = doc[page_no] image_list = page.get_images(full=True) for img in image_list: xref = img[0] pix = fitz.Pixmap(doc, xref) if pix.n - pix.alpha > 3: # 转 RGB pix = fitz.Pixmap(fitz.csRGB, pix) pix.save(f"img_{xref}.png")但这种方法有个问题:提取出来的原图尺寸和它在页面上显示的大小不一定一致,而且如果同一个 xref 被多个页面引用,你还需要额外记录它出现的位置。更稳妥的做法是直接按显示区域裁剪渲染:
clip_rect = fitz.Rect(block["bbox"]) pix = page.get_pixmap(matrix=fitz.Matrix(2, 2), clip=clip_rect) pix.save("region.png")这里我用了Matrix(2, 2),也就是把裁剪区域以 2 倍分辨率渲染出来,主要目的是让图片中的小字号文字更清晰,后续给视觉模型理解时成功率会高很多。如果是复杂的整页截图,我甚至会用到 3 倍分辨率,但同时要考虑接口对图片尺寸的限制,后面会讲到。
3.3 文本块的合并策略
文本块如果直接按 block 切,粒度往往太碎。比如一页里有三个小段落,每个段落可能被切成七八个 block。我的做法是给每个文本块记录 bbox 坐标,然后按“行”粒度做合并,保证一个语义段落尽量完整。
page_dict = page.get_text("dict") blocks = page_dict["blocks"] chunks = [] for b in blocks: if b["type"] != 0: continue text = "" for line in b["lines"]: for span in line["spans"]: text += span["text"] if len(text.strip()) < 5: continue # 过短内容直接跳过 chunks.append({ "page": page_no, "bbox": b["bbox"], "text": text.strip() })注意文本长度这个阈值,我试过很多次,5 个字符以下的块大概率是页码、页眉、装饰性符号,直接过滤掉可以减少向量库里的大量噪声。如果过滤太粗暴导致表格文字丢失,可以在进入向量库前再补一层规则,而不是在这里放开阈值。
3.4 页面分割时要记录关键元数据
无论文本块还是图像区域,最终入库的时候都要带上页码和 bbox。这一步看似多此一举,实际检索阶段的价值巨大。当检索结果命中某块时,我可以轻松定位它在 PDF 中的位置,让前端展示原文。更重要的是,用户问“第二页那张流程图里的异常条件是什么”时,页码和 bbox 都能成为辅助排序的因子。我习惯把所有块记录成统一结构:
{ "id": "fitz_0021_03", "page": 21, "bbox": [110.0, 72.0, 540.0, 380.0], "type": "image", "image_path": "cache/0021_03.png", "text": "Qwen-VL 生成的描述文本" }这个结构贯穿整个流程,后续所有模块都围绕它工作。
4. 让 Qwen-VL 帮你“读图”
4.1 调用准备与提示词设计
图像区域准备完成后,接下来就是调用视觉语言模型。我用的是 Qwen-VL 的开放接口,兼容 OpenAI 风格,直接构造 messages 传入图片的 base64 内容即可。
import base64 import requests with open("region.png", "rb") as f: b64_img = base64.b64encode(f.read()).decode() prompt = """ 你是一位文档结构分析专家。请仔细查看这张图片,然后输出一个 JSON 对象,包含以下字段: - title: 图片的标题或主题,如果图中没有标题就根据内容概括 - type: 内容类型,取值只能是 flowchart / architecture / table / screenshot / illustration / photo 之一 - description: 用 3 到 5 句话描述图片的核心内容,包括图中出现的对象和它们之间的关系 - keywords: 适合用于检索的关键词数组,建议 5 到 10 个 - text_content: 图片中出现的所有文字,按原样保留,如果没有则返回空字符串 请以严格 JSON 格式输出,不要夹带任何其他说明。 """ resp = requests.post( "https://your-qwen-vl-endpoint/v1/chat/completions", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={ "model": "qwen-vl-max", "messages": [{ "role": "user", "content": [ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_img}"}}, {"type": "text", "text": prompt} ] }] } ) ans = resp.json()["choices"][0]["message"]["content"]提示词设计是整个流程里最值得反复调优的地方。最开始我用的是“请描述这张图片的内容”这种宽泛指令,结果输出五花八门,有的给一大段散文,有的只给一两个词。后来改成“结构化 JSON 输出”并且在 type 字段里明确枚举值,输出质量立刻稳定了很多。我的经验是:让视觉模型做分类题比做描述题容易得多,所以必须把限定条件写清楚。
4.2 将描述结果入库
调用完成后,我会把 JSON 解析出来,并把它拼接成一个更连贯的文本块:
import json data = json.loads(ans) visual_text = ( f"图片标题:{data['title']}\n" f"内容类型:{data['type']}\n" f"内容描述:{data['description']}\n" f"图中文字:{data['text_content']}\n" f"检索关键词:{','.join(data['keywords'])}" )要注意一点:如果 Qwen-VL 返回的 JSON 解析失败,不要直接抛弃,这是那种在“检索不到但明明图里就有答案”的情况下最可惜的一种浪费。我的做法是先正则提取大括号部分,再做 JSON 解析。如果仍然失败,就把原始返回内容降级当作描述文本存起来,至少不会比完全丢失更差。
4.3 成本控制与并发优化
Qwen-VL 按调用次数计费,所以无脑对每一张图都调用会很心疼。我的控制策略有几条:
- 只对面积足够大的图像区域调用。如果某个图像块的宽度或高度小于 100 像素,大概率是 logo、图标,不值得花一次调用。
- 先做一轮“标题重复检测”。同一份 PDF 里经常会出现重复的架构图或截图,用图像哈希简单比对,命中缓存就不再调用。
- 对已经处理过的 PDF 建立缓存目录,缓存 key 用
pdf文件名 + 页码 + bbox拼接,第二次解析直接读取缓存。 - 并发控制在 4 到 8 个线程,避免触发接口限流。
这些优化做完之后,我那个 300 页带大量截图的项目,真正发生视觉调用的图像只有不到 200 张,整体成本完全可控。
5. 构建检索链路并跑通问答
5.1 向量化与索引选择
文本块和视觉描述块准备好之后,需要统一向量化。嵌入模型我用了 bge-m3,它对中文长文本的支持一直很稳定。如果团队里有人习惯用接口调用,也可以换成 text-embedding-v3,只要保证入库和查询用的是同一个模型就行。
向量库的选择上,我的建议很简单:单机小项目用 FAISS,多服务并发场景用 Qdrant。FAISS 不需要额外部署,适合文章里这种几百上千块的规模;Qdrant 的优势是有过滤能力,可以按page、doc_id过滤,适合后面接入多文档场景时做权限隔离。
FAISS 建库的代码非常简单:
import faiss import numpy as np embeddings = [embedding_func(chunk["text"]) for chunk in all_chunks] dim = len(embeddings[0]) index = faiss.IndexFlatIP(dim) # 用内积做相似度,配合归一化等价于余弦相似度 emb_arr = np.array(embeddings).astype("float32") faiss.normalize_L2(emb_arr) index.add(emb_arr)记住一个细节:用内积之前一定要对向量做 L2 归一化,不然检索结果会被向量长度误导,表现很不稳定。
5.2 检索后如何组装上下文
检索阶段,我把查询向量送入 FAISS 拿到 TopK,然后根据块类型拼接最终上下文。这里的关键点是:图像块的描述文本通常比普通文本块短,如果只用向量相似度排序,可能整页都被文本块占据,图像块被挤下去。我的做法是设置一个“图像块保障比例”,比如 TopK=12 时,确保至少 3 个来自图像类型。这个比例在实测中对提升图表类问答的正确率特别明显。
组装上下文时,我还会把同一页的内容尽量放在一起,减少大模型在阅读时来回跳页的负担:
context_lines = [] for chunk in selected_chunks: source_line = f"[第{chunk['page']}页] {chunk['text']}" context_lines.append(source_line) context = "\n\n".join(context_lines)最后交给生成模型时,我会明确告诉它“如果图片描述信息不足,请直接承认不知道,不要强行猜测”。这一步非常有用,能大幅降低幻觉比例。很多人只关注检索的召回率,却忽略了生成阶段的约束提示,实际上生成提示词写得好不好,对最终答案的可用性影响极大。
5.3 效果对比:纯文本 RAG 与图文 RAG 差异有多大
用我那个包含大量截图和架构图的文档做对比,我抽取了 30 个问题,覆盖“图中某个流程的判断条件是什么”“架构图里哪一层负责鉴权”“某张截图中弹窗提示的完整文案是什么”等类型。纯文本 RAG 的正确率只有 33% 左右,大部分问题是因为根本没检索到相关内容;加入 Qwen-VL 描述之后,正确率提升到 83%。差距最大的恰恰是那些“答案完全藏在图里”的问题,纯文本方案连一点上下文都提供不了,图文方案却能给出准确的流程分支和弹窗文案。
6. 高频问题与排查心得
6.1 PyMuPDF 解析相关
- 图像区域裁剪出来是黑块或空白:这通常是渲染矩阵与页面旋转没对上。解决办法是不要直接用手写的 bbox 裁剪,而是通过
get_image_rects(xref)获取官方显示区域,再按这个 rect 渲染。 - 明明页面上有图,但
get_images()只返回很少的结果:说明大部分外形是矢量绘图,这时候应该走get_drawings()或者直接整页渲染后裁剪。 - 提取出来的文本顺序错乱:多栏文档经常出现这种情况,解决办法是开启
sort=True,让块按阅读顺序排列,但仍然要复核,PyMuPDF 的排序并非完全智能,极端情况建议配合page.find_tables()做表格区域识别。
6.2 Qwen-VL 调用相关
- 接口返回图片过大错误:部分图片渲染到 3 倍分辨率后可能超过接口限制。我的做法是在调用前用 Pillow 统一将长边压到 2048 像素,并转成 JPEG 压缩质量 85,基本不影响理解效果。
- 返回内容不是合法 JSON:尽管提示词里写了“严格 JSON”,偶发情况还是会出现。不要慌,先用正则提取 JSON 片段,再 fallback 到原始文本。
- 需要对同一张图多次调用:这通常是因为提示词写得太泛,模型描述不到位。与其重试,不如把提示词改得更具体,把输出字段分别列出来,准确率会明显提升。
6.3 检索结果不对怎么排查
如果问答效果差,先别急着怀疑大模型,大概率是检索环节出了问题。我习惯按照下面的顺序排查:
- 确认问题里的关键词是否真的命中某个块。如果连关键词都对不上,先优化块切分粒度。
- 确认图像描述文本是否包含足够多的业务词汇。我之前发现 Qwen-VL 描述架构图时,用了大量“模块”“服务”这类泛词,真实组件名反而没写全。后来在提示词里加了“尽可能保留图中出现的专业名词和英文缩写”,效果立刻改善。
- 确认 TopK 数量是否合适。太多会引入噪声,太少会漏掉关键信息。我的经验是一般取 8 到 16 之间,具体看块的平均长度。
7. 我在实际落地中最后的几点体会
这套方案核心其实不复杂:PyMuPDF 负责把 PDF 拆成结构化的块,Qwen-VL 负责替图像“开口说话”,然后统一走文本检索。但想把它从 demo 变成真正稳定可用的知识库问答系统,我最大的体会有三点。
第一,图像质量是天花板。如果 PDF 里的截图本身分辨率极低,或者文字严重发虚,再强的视觉模型也没办法。遇到这种文档,能补原图就补原图,不能补就在裁剪渲染时用更高的 DPI。
第二,缓存一定要先做。第一次跑全量文档可能耗时很久,但有了缓存之后,后续任何调参和重试都变得很快。我经常反复调整提示词,如果没有缓存,每次重新调用 API 的耐心早就耗尽了。
第三,别迷信单一模型。Qwen-VL 在中文文档理解上确实给了我很大帮助,但架构图、表格、手绘图、照片之间差异很大,不同视觉模型各有擅长。做生产系统时,最好把图像类型的判断前置,让不同类型走不同模型或不同提示词模板,整体效果会再上一个台阶。
最后留一个小技巧给想继续折腾的读者:视觉描述文本生成之后,除了进检索库,它还可以作为离线摘要直接生成文档目录或知识地图。我用这个思路给那份 300 页的项目文档自动生成了一份基于图片内容的导读,效果意外地好用。如果你已经在做图文 PDF RAG,不妨往这个方向再扩展一层。