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(fullTrue)能列出页面所有图片的 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文本或图片描述、typetext/image、page页码、bbox坐标、image_path图片落盘路径。检索时统一召回生成阶段如果命中 image 类型就把对应图片路径一起喂给多模态模型或者把图片描述拼进上下文。为什么不分开存因为分开存会导致召回割裂。用户的问题往往同时涉及正文和图表比如“第三章讲的调度算法配图里的队列是怎么画的”如果两个集合分开检索很难保证两路结果的相关性对齐。统一集合 metadata 过滤逻辑简单召回也稳。3. 核心细节解析与实操要点3.1 版面拆解文本块和图片块怎么分离PyMuPDF 拆页面的核心代码逻辑是这样的先用page.get_text(dict)拿到所有文字块每个块里有lines每行有spansspan 里带text、font、size、bbox。同时用page.get_images(fullTrue)拿到图片列表每个图片有 xref 和它在页面上的显示区域通过page.get_image_rects(xref)获取。这里有个坑要重点说图片和文字块可能重叠。比如一张图上面盖了一层文字标注或者文字块和图片的 bbox 有交集。如果不处理会导致同一块内容既被当文字提取又被当图片描述重复入库。我的处理方式是先收集所有图片的 bbox然后遍历文字块如果某个文字块的 bbox 有超过 60% 的面积落在图片区域内就判定它是图片的一部分跳过不单独入库。这个阈值 60% 是实测调出来的太低会误杀正常文字太高会漏掉图内标注。另一个细节是小图过滤。PDF 里经常有装饰性小图标、分隔线、页眉 logo这些图没必要送 Qwen-VL浪费算力还污染知识库。我的过滤规则是宽或高小于 80 像素的图直接丢弃宽高比极端比如超过 10:1的细长条也丢弃多半是分隔线。这个规则不是绝对的如果你的文档里全是小图标承载信息比如仪表盘截图阈值要相应调低。3.2 阅读顺序还原的排序算法双栏 PDF 是阅读顺序的重灾区。PyMuPDF 返回的块顺序是按 PDF 内部对象顺序来的跟人眼阅读顺序经常对不上。我的排序逻辑分三步先按块的y0坐标聚类把 y 坐标接近差值小于行高的一半的块归为同一“行带”。每个行带内部按x0排序从左到右。行带之间按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 pillowQwen-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_size80): doc fitz.open(pdf_path) results [] os.makedirs(image_output_dir, exist_okTrue) for page_num, page in enumerate(doc): page_dict page.get_text(dict) # 收集图片区域 image_rects [] images page.get_images(fullTrue) 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, fp{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 resultsis_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, tokenizeFalse, add_generation_promptTrue ) # 后续调用 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.alphaCMYK 转 RGB 再存双栏顺序错乱排序算法未处理通栏识别宽度超页面 70% 的块作为分节边界图片重复入库同一 xref 在页面多次引用用 xref 去重或按 bbox 去重小图标污染知识库未做尺寸过滤加最小尺寸和宽高比过滤规则5.2 图片描述质量差的排查思路Qwen-VL 输出废话或者描述不准通常三个原因。一是图片本身分辨率太低模型看不清这种情况要在导出时提高 DPIPyMuPDF 默认导出的是原始分辨率如果原图就糊那没救只能靠 OCR 补。二是提示词太宽松模型偷懒按前面给的结构化提示词改。三是图片类型超出模型能力比如特别复杂的工程图纸这种建议降级为 OCR 人工标注。注意图片描述生成是个耗时环节建议做成异步任务队列解析和描述分离。解析很快描述慢分离后可以先把文本部分入库提供基础检索图片描述陆续补充用户体验更好。5.3 检索效果不达预期的调优方向如果搭完发现召回不准按这个顺序排查先看切块是否合理block 级别切块通常比固定长度好再看嵌入模型是否匹配语种中文文档用中文模型然后看图片描述是否进了库很多人漏了这步最后看 top-k 和相似度阈值k 太小漏召回太大引入噪声。我一般从 k5 起步根据实际效果调到 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 才立得住。 SEO 优化官网定制响应式建站教育培训建站