PDF RAG 这词儿这两年都快被说烂了但真上手做过的都知道大部分开源方案拿扫描件或者图文混排的 PDF 一测就露馅。文本抽取乱、表格错位、图表信息全丢最后检索出来的东西根本没法用。我自己在折腾企业合同、产品手册、技术文档这批资料时被这个事儿卡了挺久后来把 PyMuPDF 和 Qwen-VL 组合起来走了一条文本通道 视觉通道双管齐下的路子终于把图文兼容的 PDF RAG 跑通了。这篇就把完整方案、核心代码和踩过的坑都摊开讲给想自己搭 RAG 知识库的朋友一个可参考的落地样板。这套方案解决的核心问题是PDF 里既有可复制的文字层又有图片、图表、扫描页、复杂表格单靠文本解析必然丢信息单靠多模态大模型整页识别又慢又贵。PyMuPDF 负责把 PDF 的版面结构拆出来文本块直接抽图片和表格区域渲染成高清图像再交给 Qwen-VL 做视觉理解和结构化转写最后统一走向量化检索。适合正在做知识库、文档问答、企业资料检索并且手里有一批排版混乱的 PDF 需要处理的同学参考。1. 为什么 PDF RAG 不能只靠文本解析1.1 真实场景里 PDF 文档到底是什么很多人以为 PDF 就是一个文档但在程序眼里 PDF 是页面、内容流、字体、图像对象的堆叠。一份 PDF 里可能同时存在好几类内容真正的文字层可以用文本抽取拿到、嵌入图片产品截图、流程图、照片、矢量图形画出来的图表、框线、扫描页面整页就是一张大图还有嵌套表格。麻烦的是这些内容经常混排在同一页甚至文字层顺序和视觉顺序完全不一致。我拿一份真实的产品手册做过测试一份 80 页的 PDF纯文本抽取能拿到的有效文本大约只占版面内容的 60%剩下 40% 的信息藏在图片标注、流程图和表格里。这些视觉信息对检索问答来说恰恰是关键——用户问这个模块的接线方式答案通常就在那张接线图里光有文字描述根本答不准。这就决定了只靠文本解析的 RAG 方案在面对真实文档时上限很低。1.2 纯文本方案的三个典型翻车现场第一个翻车现场是乱序。很多 PDF 生成工具尤其从网页或设计软件导出的会把文字块按内部顺序写入内容流而不是按视觉顺序。用常规解析库抽出来文字是跳跃的第一段可能出现在第三段后面。直接拿去分块做 embedding语义完全乱套检索出来的片段前言不搭后语。第二个翻车现场是表格错位。PDF 里的表格本质是坐标画的线框加文字解析库靠猜测来判断行列关系一旦遇到合并单元格、跨页表格或者线框缺失结果就是行列错乱。我见过最离谱的情况是 8 列的数据表被解析成 3 列数值和表头对不上检索出来就是个错误答案。第三个翻车现场是纯图片页。扫描合同、拍照存档、某些网盘导出文件整页没有文字层。文本解析返回空零信息。你说用 OCR 吧通用 OCR 对公式、表格结构、图文混排的版面理解也有限。这三个问题叠加在一起基本宣告了下载一个解析库 切块 embedding 向量库这条简单路线在真实 PDF 场景下不可行。2. 技术选型PyMuPDF Qwen-VL 的分工逻辑2.1 PyMuPDFPDF 层最顺手的瑞士军刀PyMuPDF 在 Python 生态里处理 PDF 属于能文能武的类型。它内部绑定 MuPDF 渲染引擎既能做文本抽取又能把页面渲染成高清图片还能解析页面里的图片对象、文本框位置、绘图命令。我最看重的是它同时给了结构化信息和视觉化能力两把钥匙既能拿到每个文本块的坐标bbox又能按坐标把任意区域渲染成图像。这两点组合起来就是图文双通道的基础——先拿到版面结构再针对特定区域做视觉理解。对比其他方案pdfplumber 擅长文本和表格抽取但渲染能力弱pdf2image 只能渲染不能抽文本还得依赖系统安装 popplerPyPDF2 更偏纯文本提取对图像和复杂版面支持有限。PyMuPDF 是少有的抽取 渲染两头都硬的库而且底层的 MuPDF 解析引擎对中文排版、嵌入字体的兼容性在开源方案里排得上号。2.2 Qwen-VL补上看懂版面这块拼图光有图片还不够得让模型看懂图片里是什么。Qwen-VL 是阿里通义实验室出的多模态大模型系列支持图片输入并输出文本描述、结构化抽取结果和版面理解。我选择它有几点考虑。一是中文场景能力强。我们处理的大多是中文文档通用开源视觉模型对中文版面、中英混排、表格表头的理解经常掉链子Qwen-VL 系列在这个维度上实测更稳。二是结构化输出可控。可以通过提示词让它输出 JSON直接拿到表格的二维结构不用自己去猜行列。三是部署灵活。可以用 API 服务也可以本地用 vLLM 部署开源权重数据不出内网的话就走本地部署这个对处理敏感文档非常重要。2.3 为什么不直接整页喂给多模态大模型有人会问既然 Qwen-VL 能看图直接把整页 PDF 渲染成图片喂给它不就行了我在早期方案里确实这么试过效果能用但问题也很现实。首先是成本问题。一份 80 页的文档每页调一次大模型视觉接口跑一轮就是 80 次请求迭代几次提示词或者调整解析逻辑成本成倍往上翻。其次是精度问题。整页输入时模型对长文本的注意力会被稀释页面上某个小表格或小字注释容易被忽略输出结果不稳定。再者是排版还原问题。整页识别出来的内容往往丢失了页面布局信息哪段文字属于哪个表、哪个注释对应哪张图很难精确还原。所以正确做法是先用 PyMuPDF 做版面结构拆解把大问题拆成小问题只对文本抽取覆盖不了的区域调用视觉模型。这样既省钱效果也更好。3. 环境准备与基础配置3.1 安装依赖与版本注意事项基础环境我建议 Python 3.10 以上3.11 也完全没问题。核心依赖就三个pip install pymupdf pip install dashscope1.18.0 pip install qwen-agent # 如果需要用 Agent 方式调用PyMuPDF 的 import 名是fitz这是个历史遗留命名早期版本基于老库命名后面一直沿用很多新手在这里卡一下。版本上建议直接用最新版pip install pymupdf默认装的就是带 MuPDF 引擎的完整版不要装成fitz的旧仓库。如果 Qwen-VL 走 API 方式需要配置DASHSCOPE_API_KEY环境变量如果有私有化需求可以基于 vLLM 部署 Qwen-VL 开源模型比如 Qwen2.5-VL-7B然后用 OpenAI 兼容接口调用代码里把 base_url 指向本地服务即可。两种方式在下面的实现代码里都能切换。3.2 目录结构与数据准备我的项目目录大概是这样的pdf_rag/ ├── docs/ # 原始 PDF 存放目录 ├── parsed/ # 解析结果文本块、图片描述、表格结构 ├── index/ # 向量索引存储 ├── images_cache/ # 渲染出来的图片缓存 ├── main.py # 主流程 ├── parser_engine.py # 解析引擎PyMuPDF Qwen-VL ├── retriever.py # 检索链路 └── config.py # 配置项准备测试数据时我强烈建议选三类各一一份纯文字 PDF比如论文、一份扫描件比如合同照片合订本、一份图文混排的手册。这三类基本覆盖了真实场景的绝大多数情况也方便后面验证方案的边界在哪里。4. 核心实现图文双通道解析4.1 文本通道用 PyMuPDF 抽取文本块并保留顺序文本通道的目标是把 PDF 里真正有文字层的部分干净抽出来并且尽量保持视觉顺序。PyMuPDF 对页面执行page.get_text(dict)能拿到结构化结果里面按 block、line、span 三层组织每个 block 有 bbox 坐标和文字内容。基于坐标排序是关键步骤——不能信文档内部顺序要按(y, x)排序先按行再按列这样多栏排版也能大致还原从左到右、从上到下的阅读顺序。import fitz def extract_text_blocks(page): 抽取页面的文本块按视觉顺序排序 blocks page.get_text(dict)[blocks] text_blocks [] for block in blocks: if block[type] ! 0: # 0 是文本块1 是图片块 continue bbox block[bbox] text for line in block[lines]: for span in line[spans]: text span[text] text \n if text.strip(): text_blocks.append({ bbox: bbox, text: text.strip(), page: page.number 1 }) # 按 (y, x) 排序保持阅读顺序 text_blocks.sort(keylambda b: (round(b[bbox][1] / 10), b[bbox][0])) return text_blocks排序时我把 y 坐标除以 10 再取整是为了允许同一行内有轻微高度波动避免因为 1~2 个像素的差异导致同一行的块被拆成不同行。这个细节在实际跑多栏文档时很管用。对于纯文本页面这些块直接按顺序拼接再做切块就行。但注意一个坑PyMuPDF 抽取的文本可能包含连字符断行英文单词被拆成 comput- 和 er需要在拼接时做去连字符处理。中文文档不存在这个问题但中文 PDF 可能因为字体嵌入问题出现字符错位这点后面在避坑部分细讲。4.2 图片检测与渲染找出非文本区域文本通道处理完剩下两类内容要交给视觉通道一类是页面上的独立图片对象type 为 1 的 block另一类是整个页面没有文字层的情况扫描页。第一步是把图片区域找出来。def find_image_regions(page): 找出页面里的图片对象及其坐标 images [] for img in page.get_images(fullTrue): xref img[0] try: rect page.get_image_rects(xref) for r in rect: images.append({ bbox: (r.x0, r.y0, r.x1, r.y1), xref: xref, page: page.number 1 }) except Exception: continue return images这里返回的是图像对象在页面上的显示区域。拿到坐标之后再用 PyMuPDF 把指定区域渲染成高清图像。渲染分辨率是关键参数建议至少 150 DPI复杂表格和图片标注建议 200 DPI。DPI 太低小字会被模型看漏太高图片文件过大API 请求慢还费 token。我用的是 180 DPI。def render_region(page, bbox, dpi180, zoomNone): 按坐标渲染页面指定区域为 PNG 图像 if zoom is None: zoom dpi / 72 mat fitz.Matrix(zoom, zoom) clip fitz.Rect(bbox) pix page.get_pixmap(matrixmat, clipclip) output_path fimages_cache/page_{page.number1}_{int(bbox[0])}_{int(bbox[1])}.png pix.save(output_path) return output_pathzoom dpi / 72是 DPI 和缩放倍率的换算关系PDF 的默认坐标系是 72 点/英寸所以 180 DPI 对应的缩放倍数就是 2.5 倍。这个换算逻辑记住后面微调分辨率时就不用查文档了。扫描页的判断逻辑也很简单如果一页get_text(dict)返回的文本块字数低于阈值比如 50 字并且页面里存在覆盖整页的大图片对象就判定为扫描页整页渲染交给视觉通道。4.3 视觉通道Qwen-VL 理解与转写视觉通道的核心是构造 Qwen-VL 的调用把渲染好的图片传过去用提示词让它输出结构化内容。根据内容类型我分了两类提示词。一类是图片理解针对流程图、产品图、结构示意图目标是把图片里的核心信息文字化。提示词大概是请仔细分析这张图片完成以下任务 1. 用简洁的语言描述图片内容是什么类型的图展示了什么 2. 提取图中出现的所有文字标注按阅读顺序输出 3. 如果图中包含流程或结构关系用文字描述清楚 输出格式JSON包含 description、texts、relations 三个字段。另一类是表格转写针对表格区域目标是把表格还原成 Markdown 或 JSON 二维结构。提示词是图片中是表格区域。请完整识别表格内容保留表头和行列结构输出为 Markdown 表格形式。注意单元格跨行跨列要尽量还原不要遗漏任何行列。调用代码以 DashScope API 为例import dashscope from dashscope import MultiModalConversation def qwen_vl_understand(image_path, prompt_typeimage): prompt IMAGE_PROMPT if prompt_type image else TABLE_PROMPT messages [ {role: user, content: [ {image: ffile://{image_path}}, {text: prompt} ]} ] response MultiModalConversation.call( modelqwen-vl-plus, # 或者 qwen2.5-vl-72b-instruct 等 messagesmessages, result_formatmessage ) content response.output.choices[0].message.content return content本地部署的话用 OpenAI 兼容接口from openai import OpenAI client OpenAI(base_urlhttp://localhost:8000/v1, api_keyEMPTY) def qwen_vl_local(image_path, prompt): with open(image_path, rb) as f: base64_img base64.b64encode(f.read()).decode() response client.chat.completions.create( modelQwen2.5-VL-7B-Instruct, messages[{ role: user, content: [ {type: image_url, image_url: {url: fdata:image/png;base64,{base64_img}}}, {type: text, text: prompt} ] }] ) return response.choices[0].message.content实测下来API 方式的 qwen-vl-plus 在复杂表格上的稳定度不错但如果文档里涉及敏感数据务必走本地部署。本地 7B 模型的速度大概在单卡 A100 上每秒 20~40 token解析一页图片约 3~8 秒可以接受。4.4 表格专项处理表格是 PDF RAG 里最容易被骂垃圾的部分值得单独拎出来讲。我的处理策略是双保险先用 PyMuPDF 的find_tables尝试抽取结构化表格同时把表格区域渲染成图片交给 Qwen-VL 转写。最后对比两边的结果以置信度更高的为准。def extract_tables(page): 先用 PyMuPDF 尝试抽取表格 tables page.find_tables() results [] for table in tables: data table.extract() results.append({ bbox: table.bbox, data: data }) return resultsfind_tables对有线框的规则表格识别率很高但遇到无线框表格或者复杂合并单元格就容易翻车。所以流程上我建议凡是find_tables抽取结果的列数一致性低于 90%比如有的行列数明显比别的行少就放弃 PyMuPDF 的结果改用图像渲染 Qwen-VL 转写。另外跨页表格建议在分块阶段合并处理把同一表格在上一页和下一页的部分合并成一个块不然检索时会被拦腰截断。表格转写完之后我会把它存成两份一份是 Markdown 文本方便后续 embedding一份是 JSON 结构方便做表格精确查询。这属于既要又要的思路Markdown 保证检索召回的便利性JSON 保证结构查询的准确性。4.5 向量化与索引构建文本通道抽出来的文本块、视觉通道生成的文字化描述、表格转写的 Markdown汇聚成一个统一的文档单元列表。每个单元需要维护元信息来源 PDF 文件名、页码、bbox 坐标、内容类型text/image/table、创建时间。这些元信息在检索后处理时特别有用比如可以做答案溯源——用户问这个数据来自哪里直接把坐标映射回 PDF 页面高亮出来。from openai import OpenAI EMBED_MODEL text-embedding-v3 # 或用本地 BGE 系列 def embed_texts(texts): client OpenAI() response client.embeddings.create(modelEMBED_MODEL, inputtexts) return [item.embedding for item in response.data]向量库我比较常用的是 Milvus 或 Elasticsearch小规模场景直接用 Chroma 或 LanceDB 也能顶住。关键不是选哪个库而是分块策略。对图文双通道方案分块要遵循语义完整性优先原则文本块按段落聚合图片描述单独成块表格单独成块不要机械地按固定字符数切。我的做法是文本块不足 300 字的往下一个块合并超过 800 字的按段落边界切开重叠 80 字左右保证跨块语义不过度割裂。5. 检索链路与效果调优5.1 混合检索与重排序建好索引之后检索质量直接决定 RAG 好不好用。纯向量检索有两个毛病一是对精确数字、专有名词召回不如关键词检索二是召回结果 Top 5 里经常混着语义相近但不太相关的片段。所以我用的是向量召回 关键词召回 重排序三段式。def retrieve(query, top_k8): # 1. 向量召回 query_emb embed_texts([query]) vector_hits vector_store.search(query_emb, top_k20) # 2. 关键词召回配合 BM25 bm25_hits bm25_index.search(query, top_k20) # 3. 合并去重 combined merge_and_deduplicate(vector_hits, bm25_hits) # 4. 重排序 reranked rerank(query, combined, top_k8) return reranked重排序是效果提升最大的环节。我用一个轻量级的 cross-encoder 模型比如 bge-reranker-base或者直接让 Qwen 对候选片段打分。实测下来重排序能让最终 Top 5 的相关片段纯度提升 20~30 个百分点这个钱绝对不能省。5.2 参数调优chunk 大小、重叠与阈值Chunk 大小对图文混合内容没有绝对最优值但有几个参考原则。纯文本块建议 300~500 字太短则上下文割裂太长则 embedding 平均化导致召回不准。图片描述块天然比较小不用硬凑长度检索时适当补充相邻文本块的上下文即可。表格块建议保持在 200 行以内超大表格拆开存储但保留表A_分片1这种关联标记。阈值设置的逻辑也要说清楚。很多同学用相似度硬阈值低于 0.7 就丢结果这在多模态混排场景下很容易误杀。因为图片描述和表格转写的文本和用户查询的语义相似度天然偏低比如用户问结构图里标注的最大扭矩是多少和图片描述文本的向量距离会比较远。我的做法是不用硬阈值而是用Top-K 重排序分数相对比例来决定是否采纳——只看候选之间的相对优劣不因绝对分数错过低相似度但确实相关的图文块。5.3 效果评测怎么算能用一套 RAG 方案落地前强烈建议先建一个评测集哪怕只有 30~50 条问答对。评测集要覆盖三类典型问题能从正文里找到答案的文字类问题、必须看图才能答出来的图片类问题、需要查表才能回答的表格类问题。每条标注标准答案和来源页码。评测指标我用命中率和答案完整度两个维度。命中率是最简单的——检索 Top 5 里有没有包含答案来源的片段。答案完整度是人工看的生成答案是否涵盖了标准答案的所有要点。实测这套方案在标准评测集上的命中率能做到 80% 以上比纯文本方案高出 15~20 个百分点。这个数字不夸张主要提升来自图片通道和表格专项处理把之前完全丢失的信息捞回来了。6. 常见问题与避坑清单6.1 中文乱码与字体问题PyMuPDF 抽取中文 PDF 时最常见的两个坑一是嵌入式字体导致抽取的文字是乱码或空白二是自定义字形映射导致 空格 丢失。前者通常是字体子集化问题解决办法是优先用page.get_text(text)配合flagsfitz.TEXTFLAGS_DICT在极端情况下用 OCR 兜底后者表现是中文之间莫名出现空格搜索匹配困难需要做正则合并。我的兜底路由是文本抽取结果里如果非 ASCII 字符占比低于某个阈值比如中文文档里汉字比例低于 30%判定为文本层不可用整页转视觉通道。这个判断要在流程早期做避免一堆乱码文本混进向量库污染整个索引质量。6.2 大文档超时与成本控制处理 500 页以上的大 PDF 时最容易遇到两个问题API 请求超时和费用飙升。视觉通道每页图片的请求如果走公网 API平均耗时 3~10 秒整本书跑下来要一个多小时还容易被限流。我的经验是加两层控制进程内做并发限制比如同时最多 4 个图片请求加结果缓存图片内容的 md5 作为 key解析过的结果直接复用。成本控制上建议在解析前先用 PyMuPDF 快速扫描一页统计图片区域面积比例。如果整个文档都是纯文本页直接跳过视觉通道只有包含图片、表格、扫描页的页面才调用视觉模型。这个前置判断能把 API 调用量砍掉一半以上。6.3 坐标定位与版面还原图片区域渲染和检索结果回源定位依赖的都是坐标系统。这里有个容易犯迷糊的点PyMuPDF 的 bbox 坐标是页面坐标单位是点1/72 英寸渲染图片时用的matrix缩放不会改变页面坐标只改变渲染分辨率。所以回源定位时要把检索到的图文块的 bbox 直接映射回 PDF 页面调用page.add_highlight_annot(rect)就能在原文上画出高亮框这个能力做答案溯源和前端展示非常好用。6.4 一些实操心得最后说几个我自己反复踩过才总结出来的经验。第一视觉模型的输出一定要做后处理校验。Qwen-VL 偶尔会幻觉内容特别是表格里的数字因此转写完成后建议用正则校验常见模式日期、ID 编号、金额异常数据标记出来人工复核。第二图片缓存一定要做好。同一个 PDF 反复测试提示词时渲染图片是完全一样的用内容 hash 做缓存能省大量时间。第三提示词不要频繁改动。视觉模型对提示词比较敏感但每次改动都会导致输出格式变化影响下游解析。建议确定一批稳定提示词后固化后续调整只改后处理逻辑。第四对于混合文档把图文关联也存进去。一张产品图旁边的文字说明这两个块在坐标上是相邻的我会给它们打上同一个 group_id检索时命中图片块可以把相邻文本块一起返回生成答案时上下文更完整。这个方案目前在我这边已经跑了快半年解析了几千份 PDF覆盖了产品手册、行业报告、技术合同和内部培训材料。图文兼容的这条路本质上不是把每一个环节都做到完美而是让文本解析和视觉理解各司其职该用文本的用文本该看图的时候看图最后用工程手段把两者粘合起来。如果你也在做 PDF RAG并且手里正好有一批排版贼难搞的文档建议直接照这个思路搭一版试试比在纯文本解析上死磕要快得多。
阅读完成 · 觉得有帮助?