ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyMuPDF+Qwen-VL:图文混排PDF知识库RAG方案

PyMuPDF+Qwen-VL:图文混排PDF知识库RAG方案

1. 为什么单靠文本解析做不好 PDF RAG

做过 PDF 知识库的同行大概率都踩过同一个坑:一份产品手册或者研报,正文提取出来干干净净,但里面几十张流程图、架构图、参数对比表全部丢失。用户问“这个模块的输入输出关系是什么”,检索回来的全是“如图 3-1 所示”这种废话,模型自然答不出东西。这就是纯文本 RAG 在真实 PDF 场景下最典型的失效模式。

PDF 这种格式天生就是为“排版呈现”服务的,不是为“内容提取”服务的。它内部是一堆带坐标的绘制指令,文字、图片、矢量图形混在一起,没有语义层级。PyMuPDF(也就是 fitz)之所以在解析圈子里口碑稳,是因为它直接读 PDF 的对象树,能拿到每个文字块的精确坐标、字号、字体,也能把内嵌图片按原始分辨率抠出来。相比一些先转 HTML 再解析的方案,PyMuPDF 少了一层转换损耗,对中文 PDF 的兼容性也更实在——很多国产排版工具导出的 PDF 字体子集很刁钻,PyMuPDF 基本都能正确解码。

但光有 PyMuPDF 还不够。它解决的是“把内容拆出来”,解决不了“图片里的信息怎么进知识库”。传统做法是接一个 OCR,把图里的文字识别成文本再入库。这个思路对付扫描件还行,对付图表就废了——柱状图的趋势、流程图的箭头方向、表格的行列对应关系,OCR 出来的是一堆散字,语义全丢了。Qwen-VL 这类视觉语言模型的价值就在这里:它能把一张图当成一个整体去理解,输出结构化的描述,甚至能回答关于这张图的细节问题。

所以这套方案的核心思路很直接:PyMuPDF 负责精准拆解版面,把文本块和图片块分离;Qwen-VL 负责把图片块转成可检索的语义描述;两路内容统一进向量库,检索时图文混排召回。它适合谁?适合手里有一批图文混排 PDF、想搭本地或半本地知识库的开发者,也适合做企业文档问答、研报分析、技术手册检索这类场景的团队。下面我把整套方案的选型逻辑、关键细节和实操过程拆开讲。

2. 方案整体设计与技术选型拆解

2.1 为什么是 PyMuPDF 而不是 pdfplumber 或 pdfminer

解析库的选择直接决定后面返工量。我先后用过 pdfminer、pdfplumber 和 PyMuPDF,说下真实体感。

pdfminer 是最底层的,控制粒度最细,但 API 反人类,提取一个带坐标的文字块要写一堆布局分析参数,调试成本高。pdfplumber 在 pdfminer 上包了一层,表格提取确实好用,但它的图片提取能力偏弱,很多内嵌图拿不到原始字节,只能拿到渲染后的区域截图,分辨率损失明显。PyMuPDF 的优势在于“全能”:page.get_text("dict")能一次性拿到文字块、行、span 的完整坐标和字体信息,page.get_images(full=True)能列出页面所有图片的 xref,再用doc.extract_image(xref)直接导出原始图片字节。一套 API 把文本和图片都覆盖了,省心。

还有一个容易被忽略的点:阅读顺序还原。PDF 里文字块的物理顺序不等于阅读顺序,双栏排版尤其明显。PyMuPDF 的get_text("blocks")返回的块带(x0, y0, x1, y1)坐标,我们可以按 y 坐标做主排序、x 坐标做次排序,把双栏内容正确串起来。这个逻辑用 pdfplumber 也能做,但 PyMuPDF 的坐标精度和稳定性实测更好。

2.2 Qwen-VL 在链路里到底承担什么角色

很多人第一反应是“图片描述用 OCR 不就行了”。这里要区分两类图片:一类是纯文字截图(比如代码片段、扫描的合同页),OCR 确实够用;另一类是信息图(流程图、架构图、数据图表、界面截图),OCR 只能拿到零散文字,拿不到关系。

Qwen-VL 的定位是把图片转成“可被检索的自然语言”。我给它的提示词通常是这样的结构:先说明图片类型,再要求它输出“图中包含哪些元素、元素之间的关系、关键数值或标签、整体表达的含义”。这样输出的描述既包含关键词(方便向量召回),又包含关系(方便生成阶段理解)。举个实际例子,一张系统架构图,Qwen-VL 会输出类似“该图展示三层架构,最上层是接入层包含网关和负载均衡,中间是服务层包含用户服务和订单服务,底层是存储层包含 MySQL 和 Redis,箭头表示调用方向”这样的描述。这段文字进向量库后,用户问“订单服务依赖哪些存储”,召回概率比纯 OCR 高一个量级。

选 Qwen-VL 而不是其他视觉模型,主要考虑三点:中文场景下对图表文字识别准、开源可本地部署(数据敏感场景刚需)、有不同参数量版本可以按算力取舍。7B 版本在单张 24G 显存的卡上跑量化推理基本够用,对中小规模知识库完全能扛。

2.3 图文混排的存储与召回设计

这里有个关键决策:图片描述和正文文本是存一个集合还是两个集合。我的做法是存同一个向量集合,但用 metadata 区分类型。每条记录带这几个字段:content(文本或图片描述)、type(text/image)、page(页码)、bbox(坐标)、image_path(图片落盘路径)。检索时统一召回,生成阶段如果命中 image 类型,就把对应图片路径一起喂给多模态模型,或者把图片描述拼进上下文。

为什么不分开存?因为分开存会导致召回割裂。用户的问题往往同时涉及正文和图表,比如“第三章讲的调度算法,配图里的队列是怎么画的”,如果两个集合分开检索,很难保证两路结果的相关性对齐。统一集合 + metadata 过滤,逻辑简单,召回也稳。

3. 核心细节解析与实操要点

3.1 版面拆解:文本块和图片块怎么分离

PyMuPDF 拆页面的核心代码逻辑是这样的:先用page.get_text("dict")拿到所有文字块,每个块里有lines,每行有spans,span 里带text、font、size、bbox。同时用page.get_images(full=True)拿到图片列表,每个图片有 xref 和它在页面上的显示区域(通过page.get_image_rects(xref)获取)。

这里有个坑要重点说:图片和文字块可能重叠。比如一张图上面盖了一层文字标注,或者文字块和图片的 bbox 有交集。如果不处理,会导致同一块内容既被当文字提取又被当图片描述,重复入库。我的处理方式是:先收集所有图片的 bbox,然后遍历文字块,如果某个文字块的 bbox 有超过 60% 的面积落在图片区域内,就判定它是图片的一部分,跳过不单独入库。这个阈值 60% 是实测调出来的,太低会误杀正常文字,太高会漏掉图内标注。

另一个细节是小图过滤。PDF 里经常有装饰性小图标、分隔线、页眉 logo,这些图没必要送 Qwen-VL,浪费算力还污染知识库。我的过滤规则是:宽或高小于 80 像素的图直接丢弃;宽高比极端(比如超过 10:1)的细长条也丢弃,多半是分隔线。这个规则不是绝对的,如果你的文档里全是小图标承载信息(比如仪表盘截图),阈值要相应调低。

3.2 阅读顺序还原的排序算法

双栏 PDF 是阅读顺序的重灾区。PyMuPDF 返回的块顺序是按 PDF 内部对象顺序来的,跟人眼阅读顺序经常对不上。我的排序逻辑分三步:

  1. 先按块的y0坐标聚类,把 y 坐标接近(差值小于行高的一半)的块归为同一“行带”。
  2. 每个行带内部按x0排序,从左到右。
  3. 行带之间按y0从上到下。

这样双栏内容会被正确串成“左栏第一行、右栏第一行、左栏第二行……”的顺序。但有个例外:跨栏的大标题或通栏表格,它的宽度会横跨两栏,这种块要单独识别出来,作为分节边界。判断依据是块的宽度超过页面宽度的 70%。识别到通栏块后,把它前面的内容归为上一节,后面的归为下一节,避免跨栏元素打乱栏内顺序。

提示:排序算法没有万能解,不同排版工具的 PDF 差异很大。建议先拿几份代表性文档跑一遍,把排序结果打印出来人工核对,再针对性调参数。这一步偷懒,后面检索质量会一直别扭。

3.3 图片描述的提示词工程

Qwen-VL 的输出质量高度依赖提示词。我试过几版,最终稳定下来的提示词结构是这样的:

你是一个文档理解助手。请分析这张来自 PDF 第 {page} 页的图片,按以下要求输出: 1. 判断图片类型(流程图/架构图/数据图表/界面截图/照片/其他) 2. 描述图中包含的主要元素及其文字标签 3. 说明元素之间的关系(如流程走向、层级结构、数据对比) 4. 如果图中有数值或关键结论,请准确摘录 5. 用一段连贯的中文描述整体含义,控制在 200 字以内

这个提示词的关键在于强制结构化输出。如果不加约束,模型容易输出“这是一张图片,包含一些文字和图形”这种废话。加上类型判断和关系说明的要求后,输出信息密度明显提升。另外,把页码传进去也有用,模型偶尔会结合页码上下文给出更合理的描述。

还有个实操技巧:批量推理时控制并发。Qwen-VL 单张图推理在 7B 量化版本上大概 2-4 秒,如果一份 PDF 有 200 张图,串行要十几分钟。用线程池开到 4-8 并发能压到两三分钟,但要注意显存占用,并发太高会 OOM。我的经验是 24G 卡开 4 并发比较稳,再高就要看图片分辨率了。

4. 完整实操流程与关键环节实现

4.1 环境准备与依赖安装

先把环境搭起来。Python 建议 3.10 以上,PyMuPDF 和向量库对版本有一定要求。

pip install pymupdf pip install transformers accelerate pip install sentence-transformers pip install chromadb pip install pillow

Qwen-VL 的推理我走的是 transformers 路线,如果你用 vLLM 或其它推理框架也可以,接口层换一下就行。向量库这里用 ChromaDB 做演示,它本地部署简单,适合中小规模;如果数据量上到百万级,建议换 Milvus 或 Qdrant。

4.2 PDF 解析与内容抽取的完整代码

下面这段是核心解析逻辑,我把它拆成函数方便复用。

import fitz import os from PIL import Image import io def extract_pdf_content(pdf_path, image_output_dir, min_size=80): doc = fitz.open(pdf_path) results = [] os.makedirs(image_output_dir, exist_ok=True) for page_num, page in enumerate(doc): page_dict = page.get_text("dict") # 收集图片区域 image_rects = [] images = page.get_images(full=True) for img_index, img in enumerate(images): xref = img[0] rects = page.get_image_rects(xref) for rect in rects: image_rects.append((xref, rect, img_index)) # 提取文字块,过滤落在图片区域内的 text_blocks = [] for block in page_dict["blocks"]: if block.get("type") != 0: continue bbox = block["bbox"] if is_inside_image(bbox, image_rects): continue text = "".join( span["text"] for line in block["lines"] for span in line["spans"] ).strip() if text: text_blocks.append({"text": text, "bbox": bbox}) # 阅读顺序排序 text_blocks = sort_blocks(text_blocks, page.rect.width) # 导出图片 for xref, rect, img_index in image_rects: width = rect.x1 - rect.x0 height = rect.y1 - rect.y0 if width < min_size or height < min_size: continue if max(width, height) / max(min(width, height), 1) > 10: continue pix = fitz.Pixmap(doc, xref) if pix.n - pix.alpha >= 4: pix = fitz.Pixmap(fitz.csRGB, pix) img_path = os.path.join( image_output_dir, f"p{page_num}_x{xref}.png" ) pix.save(img_path) results.append({ "type": "image", "page": page_num, "bbox": list(rect), "image_path": img_path }) for tb in text_blocks: results.append({ "type": "text", "page": page_num, "bbox": tb["bbox"], "content": tb["text"] }) doc.close() return results

is_inside_image和sort_blocks两个辅助函数按前面讲的逻辑实现即可。这里要强调一个细节:fitz.Pixmap(doc, xref)拿到的可能是 CMYK 或带 alpha 的图,直接存 PNG 会出问题,所以要先判断色彩空间再转换。这个坑我踩过,导出的图全是反色或者透明背景,排查了半天。

4.3 图片描述生成与向量入库

解析完拿到结果列表后,图片部分送 Qwen-VL,文本部分直接切块。切块策略上,我建议按语义段落切,不要按固定字数硬切。PyMuPDF 提取的每个 block 本身就是一个语义单元,直接以 block 为单位入库,长度超过 512 token 的再按句子边界二次切分。这样比固定 500 字滑窗的召回质量高不少。

from transformers import AutoModelForCausalLM, AutoTokenizer from PIL import Image def describe_image(image_path, page_num, model, tokenizer): image = Image.open(image_path).convert("RGB") prompt = f"你是一个文档理解助手。请分析这张来自 PDF 第 {page_num} 页的图片..." # 按 Qwen-VL 的对话模板构造输入 messages = [{ "role": "user", "content": [ {"type": "image", "image": image}, {"type": "text", "text": prompt} ] }] text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) # 后续调用 model.generate 生成描述 # 具体推理代码按所用模型版本调整 return generated_text

入库时把文本和图片描述统一编码成向量。嵌入模型我一般用bge-large-zh或m3e-base,中文语义检索效果稳。ChromaDB 的写入逻辑很简单,每条记录带上 metadata 即可。

4.4 检索与生成阶段的图文混排

检索阶段没什么特别的,就是标准的向量相似度召回 top-k。关键在生成阶段怎么把图文混排的上下文组织好。我的做法是:召回结果按页码和 bbox 排序,还原成文档原始顺序,然后拼成上下文。如果命中图片,就在对应位置插入图片描述,并标注[图片: 路径]。如果最终生成用的是多模态模型,可以把图片直接传进去;如果用的是纯文本模型,就只传描述。

这里有个提升召回率的小技巧:给图片描述加一个前缀,比如“图片内容:”。这样在向量空间里,图片描述和正文文本会有一定的区分度,检索时如果用户问题明显指向图表,可以加权召回 image 类型。实测这个前缀对召回准确率有 5% 左右的提升,成本几乎为零。

5. 常见问题与排查技巧实录

5.1 解析阶段的高频问题

问题现象可能原因排查与解决
文字提取乱码字体子集缺 ToUnicode 映射用page.get_text("text")对比,若仍乱码考虑 OCR 兜底
图片导出全黑/透明色彩空间未转换判断pix.n - pix.alpha,CMYK 转 RGB 再存
双栏顺序错乱排序算法未处理通栏识别宽度超页面 70% 的块作为分节边界
图片重复入库同一 xref 在页面多次引用用 xref 去重,或按 bbox 去重
小图标污染知识库未做尺寸过滤加最小尺寸和宽高比过滤规则

5.2 图片描述质量差的排查思路

Qwen-VL 输出废话或者描述不准,通常三个原因。一是图片本身分辨率太低,模型看不清,这种情况要在导出时提高 DPI,PyMuPDF 默认导出的是原始分辨率,如果原图就糊,那没救,只能靠 OCR 补。二是提示词太宽松,模型偷懒,按前面给的结构化提示词改。三是图片类型超出模型能力,比如特别复杂的工程图纸,这种建议降级为 OCR + 人工标注。

注意:图片描述生成是个耗时环节,建议做成异步任务队列,解析和描述分离。解析很快,描述慢,分离后可以先把文本部分入库提供基础检索,图片描述陆续补充,用户体验更好。

5.3 检索效果不达预期的调优方向

如果搭完发现召回不准,按这个顺序排查:先看切块是否合理,block 级别切块通常比固定长度好;再看嵌入模型是否匹配语种,中文文档用中文模型;然后看图片描述是否进了库,很多人漏了这步;最后看 top-k 和相似度阈值,k 太小漏召回,太大引入噪声。我一般从 k=5 起步,根据实际效果调到 8-10。

还有个容易被忽略的点:查询改写。用户的问题往往口语化,和文档书面语有 gap。加一层查询改写,把口语问题转成书面检索词,召回率能明显提升。这一步可以用小模型做,成本很低。

6. 几个我踩过的坑和实测经验

第一个坑是页码从 0 还是 1 开始。PyMuPDF 的enumerate从 0 开始,但用户看到的页码从 1 开始,入库时如果不加 1,检索结果引用页码会全部错位。这个 bug 很隐蔽,因为检索本身没问题,只是引用页码对不上,排查时容易忽略。

第二个坑是大 PDF 的内存占用。一份 500 页的 PDF 全量解析,如果把所有图片都加载到内存再处理,很容易爆。我的做法是解析和图片处理分离,解析阶段只记录图片路径和元数据,图片描述阶段再逐张读取,处理完立即释放。这样内存占用能压到原来的三分之一。

第三个经验是增量更新。知识库不是一次性的,文档会更新。我的做法是用文件哈希做版本标记,解析时对比哈希,只处理变化的页面。图片描述也做缓存,同一张图(按内容哈希判断)不重复调模型。这个优化在文档频繁更新的场景下能省大量算力。

第四个经验关于评估。搭完知识库一定要做评估,不能凭感觉。我一般准备 30-50 个真实问题,人工标注标准答案,然后跑一遍看召回率和答案准确率。没有评估,调优就是盲人摸象。评估集要覆盖纯文本问题、纯图表问题、图文混合问题三类,这样才能暴露链路的短板。

这套方案我前后迭代了三四版,从最初纯文本 RAG 被图表问题打脸,到加上 OCR 还是不够,最后引入 Qwen-VL 做图片语义化,才算真正把图文混排 PDF 的知识库做扎实。核心体会就一句话:PDF 里的图不是附属品,它和正文一样承载信息,必须用能理解语义的方式处理,而不是当成一堆像素或者一堆散字。PyMuPDF 负责把版面拆干净,Qwen-VL 负责把图读懂,两者配合,图文兼容的 RAG 才立得住。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进