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 PyMuPDF:PDF 层最顺手的瑞士军刀
PyMuPDF 在 Python 生态里处理 PDF 属于"能文能武"的类型。它内部绑定 MuPDF 渲染引擎,既能做文本抽取,又能把页面渲染成高清图片,还能解析页面里的图片对象、文本框位置、绘图命令。我最看重的是它同时给了"结构化信息"和"视觉化能力"两把钥匙:既能拿到每个文本块的坐标(bbox),又能按坐标把任意区域渲染成图像。这两点组合起来,就是图文双通道的基础——先拿到版面结构,再针对特定区域做视觉理解。
对比其他方案:pdfplumber 擅长文本和表格抽取,但渲染能力弱;pdf2image 只能渲染不能抽文本,还得依赖系统安装 poppler;PyPDF2 更偏纯文本提取,对图像和复杂版面支持有限。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 "dashscope>=1.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(key=lambda 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(full=True): 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, dpi=180, zoom=None): """按坐标渲染页面指定区域为 PNG 图像""" if zoom is None: zoom = dpi / 72 mat = fitz.Matrix(zoom, zoom) clip = fitz.Rect(bbox) pix = page.get_pixmap(matrix=mat, clip=clip) output_path = f"images_cache/page_{page.number+1}_{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_type="image"): prompt = IMAGE_PROMPT if prompt_type == "image" else TABLE_PROMPT messages = [ {"role": "user", "content": [ {"image": f"file://{image_path}"}, {"text": prompt} ]} ] response = MultiModalConversation.call( model="qwen-vl-plus", # 或者 qwen2.5-vl-72b-instruct 等 messages=messages, result_format="message" ) content = response.output.choices[0].message.content return content本地部署的话,用 OpenAI 兼容接口:
from openai import OpenAI client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY") 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( model="Qwen2.5-VL-7B-Instruct", messages=[{ "role": "user", "content": [ {"type": "image_url", "image_url": {"url": f"data: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(model=EMBED_MODEL, input=texts) 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_k=8): # 1. 向量召回 query_emb = embed_texts([query]) vector_hits = vector_store.search(query_emb, top_k=20) # 2. 关键词召回(配合 BM25) bm25_hits = bm25_index.search(query, top_k=20) # 3. 合并去重 combined = merge_and_deduplicate(vector_hits, bm25_hits) # 4. 重排序 reranked = rerank(query, combined, top_k=8) 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")配合flags=fitz.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,并且手里正好有一批排版贼难搞的文档,建议直接照这个思路搭一版试试,比在纯文本解析上死磕要快得多。