做 PDF RAG 的都知道,真正的难点从来不是“把文字抽出来”,而是版面里那一堆图表、扫描页、带排版的表格——一遇到它们,传统解析方案直接“沉默”。我最近在一个知识库项目里,把 PyMuPDF 和 Qwen-VL 组合起来,文本走 PyMuPDF 的文本层,图表和复杂版面走 Qwen-VL 视觉模型转述,再统一向量化进 RAG,算是把“图文兼容”这件事完整跑通了。
这篇不是堆概念,是把从选型、解析、视觉描述到检索的完整链路拆开讲,包括我用到的提示词模板、切块策略、缓存设计和踩过的坑。如果你正在做 PDF 知识库、文档问答助手,或者只是想把一堆图文混排的 PDF 变成可检索的资产,这套方案可以直接抄作业。
1. 为什么偏偏是 PyMuPDF + Qwen-VL:方案选型的底层逻辑
1.1 传统 PDF RAG 丢掉的到底是什么
大部分 PDF RAG 教程都默认了一件事:PDF 里的信息就是文字。于是解析流程通常是 read_pdf -> extract_text -> split -> embed -> index。这个链路跑纯文本报告、论文正文确实没问题,可一旦遇到产品手册、实验报告、技术白皮书、扫描合同,问题就来了。
图里的信息是文字抽取拿不到的。比如一份设备说明书里的流程图,PDF 文字层里只有“见图3”,图3的内容全靠读者看图才知道;一份实验报告里的曲线图,曲线趋势、峰值结果全在像素里,文字层只有“结果如图2所示”。还有扫描件,根本不存在可抽取的文字层,传统抽取直接返回空字符串。
就算文字层存在,版面信息也可能丢。两栏论文、表格跨页、页眉页脚混入正文、标题和正文被暴力切分,这些都会导致最终进入向量库的“知识”是支离破碎的。我试过把一篇双栏论文按文本流切块,出来的 chunk 经常是左栏半行+右栏半行拼接,检索时模型根本没法用。
所以“图文兼容”不是加分项,而是文档问答能不能落地的基础条件。真正的方案应该是:文字走文本层,图表走视觉层,最后都变成“文本化的知识”统一入库。
1.2 为什么用 PyMuPDF,而不是 pdfplumber / pdfminer / OCR 全家桶
选 PyMuPDF 之前我把主流的 PDF 解析工具都过了一遍,这里直接说对比结论。
| 工具 | 强项 | 弱项 | 适合场景 |
|---|---|---|---|
| PyMuPDF | 速度快、API 全面,能同时拿文本、图片、矢量图形,自带高质量渲染 | 表格结构推断不如专用工具 | 图文混合文档的通用解析 |
| pdfplumber | 表格线检测、字符级坐标精确 | 速度慢,大文档容易卡顿 | 精确表格抽取、版面坐标分析 |
| pdfminer.six | 纯文本流解析稳定 | 无渲染能力,接口较底层 | 只需要干净文本流的场景 |
| Tesseract / PaddleOCR | 扫描件文字识别 | 需要图像预处理,图表语义拿不到 | OCR 专用,配合视觉模型使用 |
PyMuPDF 的核心优势在于它底层是 MuPDF 渲染引擎,不仅能把文字抽出来,还能知道文字在页面哪个位置,能把页面里的图片按坐标抠出来,还能把任意区域渲染成高分辨率 PNG。这意味着我可以自己掌控“图文分离”的粒度:文本块在哪、图片在哪、表格框在哪,全部都能定位。
而对于表格,pdfplumber 确实在规则线表格上更好用,但它在处理无框线表格、复杂合并单元格时同样吃力。既然我已经让 Qwen-VL 看图了,表格问题交给了视觉模型,文本层只需要拿到基础的坐标信息就够了。这样两个工具各干各擅长的事,不会重复造轮子。
1.3 Qwen-VL 在图文档里的不可替代性
视觉部分我一开始试过纯 OCR,但很快发现不够。OCR 能告诉我图里有哪些字,但不知道这些字之间的关系。
举个例子,一张折线图上有三组数据曲线,OCR 只能给出散落的文字“销售额”“2023”“2024”“增长率”,但“2024 年销售额超过 2023 年,增长率在 Q3 达到峰值”这个语义结论,OCR 给不出来。而这个结论才是知识库里真正有价值的东西。
Qwen-VL 这类视觉语言模型解决的就是这件事。它把图片当输入,直接输出自然语言描述。相比传统 OCR,它能理解图表的阅读顺序,能识别坐标轴、图例、趋势,还能把表格内容转成 Markdown 结构。Qwen-VL 另一个优势是中文支持好,毕竟是国内模型,处理中文文档里的图、表、产品截图、印章扫描件,准确率明显比国外的闭源视觉模型稳。
部署上它也很灵活。可以用阿里云 DashScope API,也可以本地用 vLLM 部署开源权重。我这次用的就是本地部署的 Qwen2.5-VL-7B,一张 4090 24G 显存就能跑起来,完全不需要为了一个文档解析项目去开昂贵的外部服务。
2. 图文分离的解析管线:PyMuPDF 的使用细节
2.1 环境准备与依赖安装
解析管线的核心依赖只有两个:pymupdf 和 qwen-vl 的推理客户端。安装很简单,但有一个坑要先说。
pip install pymupdf python-dotenvPyMuPDF 的 Python 包名叫 pymupdf,但导入的时候用的是 fitz,这个历史原因经常让新手懵。你在代码里import fitz就行了,不需要额外装 PyMuPDF 之外的 fitz 包,网上有些旧教程会让你pip install fitz,那个包是另一个东西,装了你反而会撞包。
版本上我建议用 1.24 以上的版本,新版本对页面字典结构的字段名更稳定,API 文档也齐全。Python 环境建议 3.10+,后续接视觉模型和向量库的时候依赖兼容性会省很多事。
2.2 文本块抽取与多栏版面还原
PyMuPDF 获取文本最常用的接口有三个:page.get_text("text")拿纯文本,page.get_text("words")拿单词级坐标,page.get_text("blocks")拿文本块。我管线里用的是 blocks 模式,原因很简单:文本块已经带了 bbox 矩形坐标,我可以基于坐标做版面分析,而不是拿到一堆无序单词自己拼句子。
import fitz doc = fitz.open("manual.pdf") for page_no, page in enumerate(doc): blocks = page.get_text("blocks", sort=True) for bbox, text, block_no, block_type in blocks: # bbox: (x0, y0, x1, y1) # block_type: 0 文本块,1 图片块 if block_type == 0 and len(text.strip()) > 10: print(page_no, bbox, text.strip()[:80])sort=True参数按从上到下、从左到右排序,单栏文档够用。但两栏论文用这个排序也会乱,因为 PyMuPDF 的排序逻辑是全局 Y 坐标优先,不是按栏位划分。我后来加了一个简单的栏位检测:统计单页所有文本块的 x0 坐标,如果发现坐标集中在左侧 0~330 和右侧 340~680 两簇,就说明是双栏版面,然后分别对每一栏的块按 Y 坐标重排。
页眉页脚过滤也是必须的。判断逻辑不复杂:页眉在每页的同一 y 区间,通常 x0 接近页面中心;页脚同理。把这些位置的文本块直接丢弃,避免“第 3 页”“机密文件”这类噪音进知识库。
2.3 图片、表格区域的定位与裁剪
PyMuPDF 拿图片的接口是page.get_images(full=True),拿到的是嵌入 PDF 的资源信息,比如 xref 编号、宽高。但页面上的图片可能在哪个位置,以及可能被旋转、裁剪,这个接口没有直接给。所以我拿图片时用的是 blocks 里的类型判断:block_type 为 1 的块就是图片区域,它的 bbox 就是图在当前页面的显示位置。
拿到定位之后,把图片区域按原比例渲染出来,用于后续喂给视觉模型。这里有个关键细节:不要用doc.extract_image(xref)直接导出嵌入的原图,因为 PDF 里嵌入的图片分辨率往往低于它在页面上显示需要的分辨率,直接导出经常得到一张模糊的缩略图。正确做法是用page.get_pixmap(clip=bbox, dpi=150)裁剪渲染。
for page_no, page in enumerate(doc): for _, bbox, _, block_no, block_type in page.get_text("blocks"): if block_type != 1: continue clip = fitz.Rect(bbox) pix = page.get_pixmap(clip=clip, dpi=150) # 渲染该区域 img_path = f"assets/page{page_no+1}_block{block_no}.png" pix.save(img_path) print("saved", img_path, pix.width, pix.height)dpi 的选择我做了几组实验。纯文字截图 dpi 150 就够,文字清晰且体积小;带小字号数据标签的图表,建议 dpi 200;如果页面本身就是扫描件,需要 OCR 级清晰度,dpi 300 起步。dpi 每翻一倍,像素面积是四倍,所以别盲目上高 dpi,大部分场景 150~200 是性价比最高的区间。
2.4 整页降级策略:什么时候把整页扔给模型
图表区域裁剪处理是理想情况,现实中经常遇到一页里有十几个小图、无边框表格、图文混排的复杂版面。这时候再逐块裁剪就容易漏。
我用的降级策略是:按页统计文本量,如果一页的文字少于一个阈值(比如 200 字符),或者图片区域占页面总面积超过 40%,就把整页渲染成一张大图交给 Qwen-VL 做整体语义化。
这个策略背后是对文档结构的判断:文本量少但图多,说明是图示页、流程图页、海报页;文本量正常但图占比高,说明是图文对照页。整体语义化一次能拿到“这一页在讲什么”的完整上下文,比拆成很多小图分别描述更连贯,因为模型能同时看到图和对应的标注文字。代价是单次请求的 token 消耗大,但在我实际测试中,对复杂版面整页描述的效果远好于小图拼凑。
3. 用 Qwen-VL 做视觉语义化:提示词与结构化输出
3.1 部署方式与模型选型
我本地用的是Qwen/Qwen2.5-VL-7B-Instruct,配合 vLLM 部署。显存占用大约 16~18GB,单张 4090、3090 或者国产 24G 显存卡都能跑。如果只有 12G 显存,可以换 4B 模型,但描述复杂图表的细节会差一些,尤其是表格转 Markdown 的准确率下降明显。
启动命令很简单,但有一个细节必须注意:vLLM 部署视觉模型时,需要指定--limit-mm-per-prompt中的 image 数量上限,否则默认只允许一张图。
vllm serve Qwen/Qwen2.5-VL-7B-Instruct \ --dtype bfloat16 \ --max-model-len 32768 \ --limit-mm-per-prompt image=2 \ --port 8000这里image=2表示单次请求最多带两张图。我用图片+文本混合输入时,一张请求里放原图、表格裁剪图各一张刚好够。如果你希望模型同时参考“整页上下文图”和“某个局部放大图”,这个参数是必须调整的。
3.2 图表描述提示词模板
视觉模型的输出质量,一半取决于模型本身,另一半取决于提示词写得多细。我测试过“请描述这张图”这种开放式提示词,输出经常泛泛而谈,抓不住关键数据。后来我把提示词改成了带角色的结构化模板,效果明显提升。
CHART_DESCRIBE_PROMPT = """你是一位专业的文档图表解析助手。请仔细观察输入的图片,按以下 JSON 结构输出内容: { "type": "figure|table|flowchart|screenshot|other", "caption": "图表的标题或一句话内容概括", "chart_type": "折线图|柱状图|饼图|流程图|结构图|表格|截图|其他", "key_finding": "图表表达的核心结论,尽量引用图上的具体数据", "details": ["补充细节1", "补充细节2"], "ocr_texts": ["图中出现的所有文字,按阅读顺序列出"] } 要求: 1. 图表中的每一个图例、坐标轴标签、数据点数值都要尽量出现在 ocr_texts 中 2. key_finding 必须基于图上真实可见的内容,禁止编造图上不存在的数据 3. 如果图中含有文字,请完整保留,不要省略 4. 只输出 JSON,不要输出解释性内容 """这个模板的核心是“强迫”模型把图上的文字全部列出来,同时把语义结论单独存成 key_finding。检索的时候,我既可以用 ocr_texts 做精确召回,也可以把 key_finding 作为一个高价值的语义 chunk,这样图文信息进 RAG 之后具备两层检索能力。
3.3 表格转 Markdown 的实现细节
表格是 PDF 解析里最头疼的部分。无框线表格、单元格合并、跨页表格,传统规则解析基本抓瞎。Qwen-VL 转 Markdown 是我试过的最通用方案,虽然偶尔有字符错位,但胜在处理无框线表格时完全不需要额外规则。
提示词我单独设计了一个版本,重点强调行数和列数必须保留。
TABLE_TO_MARKDOWN_PROMPT = """请将图片中的表格内容完整转换为 Markdown 格式: 1. 第一行为表头,使用 | 分隔,表头下方必须包含 |---| 分隔行 2. 保留所有单元格内容,即使是空单元格也要保留空位 3. 合并单元格中的文字放在合并区域的第一行,并用 (跨行合并) 标注 4. 数字保留原始精度,不要四舍五入 5. 只输出 Markdown 表格,不要额外解释 """温度参数务必设置为 0,并固定采样种子,否则同一个表格前后两次转换可能给出完全不同的行列结构。我实际踩过这个坑:默认温度下,同一张复杂表格两次推理,一次输出 3 列,另一次输出 5 列,因为模型“猜”了表格结构。温度归零后这个问题基本消失。
转换后的 Markdown 表格我不要直接存一个长字符串,而是拆成“表头+每一行”的文本块。这样检索“2023 年营收”这种问题时,命中的是具体行而不是整张表,相关性分数高很多。
3.4 批量请求调度与失败降级
一个 PDF 几十上百个图表,逐个调用模型,即使是本地部署也要跑很久。我写了个简单的任务队列:所有识别出的图片区域先入库,然后并发批量请求。本地 vLLM 可以设置--max-num-seqs来控制并发,我用 4 并发,单张 4090 处理一张图大约 2~3 秒,一个 30 张图的手册图片部分跑完大约两分钟。
失败降级很关键。模型偶发超时、返回格式非法、JSON 解析失败,这些不能直接让整个流程崩溃。我的处理是:解析失败自动重试一次;重试仍失败则记录日志,把这张图标记为 unprocessed,后续用备用方案处理,比如导出原图 + 基础 OCR 文字,至少保证图里的文字能被检索到,不会成为知识盲区。
4. 混合入库与检索优化:让图文内容都能被“搜到”
4.1 切块策略:文本、图片描述、表格各走各的逻辑
很多 RAG 方案从头到尾用一种切块器,这对图文混合文档来说太粗糙了。我的经验是按内容类型分别切块。
纯文本用语义切块。借助文档结构,我用 PyMuPDF 识别标题文字块(字体大小、加粗、序号模式),把标题作为 chunk 的起点,按滑动窗口切,窗口大小 512 字符,重叠 64 字符。这样每个 chunk 自带章节上下文,而不是从页面中间生硬截断。
图片描述生成一个独立 chunk,同时我会在 chunk 文本的开头拼接“图片来源:第 X 页,上下文标题:xxx”。这个前缀在检索时非常有用,当用户问“图 3 里的结构”,命中的 chunk 明确知道自己是图 3,并且知道它属于哪个章节。
表格转 Markdown 后按表格边界切块,不跨表切。一个表格无论多长,尽量当作一个完整语义单元,只做行分组。因为表格被切成两半后,表头和行、列关系会断开,语义损失比任何其它类型都严重。
4.2 向量与元数据设计
向量化的选型我用了 BGE-M3,它可以同时生成 dense、sparse、colbert 三种向量,能直接支撑混合检索。如果接外部 API,用 text-embedding-v3 也行,但元数据设计才是这套方案能不能在业务中落地的关键。
每个 chunk 的 metadata 我会存这些字段:
| 字段 | 示例 | 作用 |
|---|---|---|
| source_file | manual_2024.pdf | 溯源 |
| page_number | 12 | 精确定位 |
| block_type | text / figure / table | 检索过滤 |
| section_title | 第三章 安装步骤 | 上下文关联 |
| bbox | [150.0, 420.0, 540.0, 680.0] | 定位页面区域 |
| figure_index | fig3 | 图片编号 |
block_type 这个字段尤其重要。用户问“这个产品支持哪些接口”,你希望只检索文本块;用户问“看下架构图”,你希望优先检索 figure chunk。没有类型过滤,这两种需求混在一起,相关性必然被稀释。
存储我用的 Milvus,因为它原生支持多向量字段和标量过滤,用 Qdrant 也完全可以,只是过滤表达式语法不同。向量库的索引参数,HNSW 的 M 值设 16、efConstruction 设 256,召回质量与内存占用平衡得不错。
4.3 混合检索与重排落地
图文混合文档的检索单靠向量是不够的。图片描述偏向语义,但用户问题里的关键词往往和 OCR 文本字面相关,比如用户问“RAG 瓶颈”,向量召回可能因为语义空间不一致而漏掉“检索增强生成的局限”这类表述。我做了两路召回再加 RRF 融合。
第一路是向量检索,取 top 30;第二路是 BM25 关键词检索,拿 top 30;两路结果用 RRF 公式融合,再交一个 reranker 重排。Reranker 我用的是 bge-reranker-v2-m3,它对长文本相关性判断比我之前用的 cross-encoder 更稳,尤其适合表格 Markdown 这种格式化文本。
scores = {} for rank, doc_id in enumerate(vec_hits): scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (60 + rank) for rank, doc_id in enumerate(bm25_hits): scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (60 + rank) # 按融合分排序后再过 rerankerRRF 里的常数 60 是经验值,太大会压扁排序差异,太小又会让高排名文档权重过重。60 是我调出来最稳的档位,你如果召回集更大可以适当调到 80。
4.4 查询改写与图文路由
实际问答时,用户不会说“请检索图 3 的内容”,而是问“这台设备的散热结构是什么样的”。如果这个问题的答案主要在结构图里,那么纯文本检索很可能会漏掉图片描述 chunk。我给问答前端加了一个轻量级的查询改写:让 LLM 判断用户问题是否可能涉及视觉内容,如果是,就在向量检索时把 block_type 过滤放宽,同时提高 figure chunk 的权重。
这个路由其实成本很低,一个简单的分类提示词就能实现,但效果非常明显。它把“知识库有没有存图里的信息”变成了“知识库如何更聪明地把图里的信息找出来”,是整个链路从能搜到变成搜得准的关键一步。
5. 端到端实操:从 PDF 到可查询知识库
5.1 主流程代码骨架
下面是整个流程的主干代码,删掉了业务包装,保留了核心逻辑。实际项目中我把每个阶段写成独立函数,方便断点重跑。
import fitz import json import requests from openai import OpenAI def parse_pdf(pdf_path): doc = fitz.open(pdf_path) items = [] # (text_chunks, image_blocks, metadata) for page_no, page in enumerate(doc): blocks = page.get_text("blocks", sort=True) text_chunks = [] image_blocks = [] for bbox, text, block_no, block_type in blocks: meta = { "page_number": page_no + 1, "bbox": list(bbox), "source_file": pdf_path, } if block_type == 0 and len(text.strip()) >= 20: text_chunks.append({ "type": "text", "text": text.strip(), "meta": meta, }) elif block_type == 1: image_blocks.append({ "type": "image", "bbox": bbox, "block_no": block_no, "meta": meta, }) # 图片区域渲染并交给 Qwen-VL for img in image_blocks: pix = page.get_pixmap(clip=fitz.Rect(img["bbox"]), dpi=180) img_path = f"cache/page{page_no+1}_b{img['block_no']}.png" pix.save(img_path) img["local_path"] = img_path items.append({"text_chunks": text_chunks, "images": image_blocks}) return items def describe_image(client, img_path, prompt): # 用 OpenAI 兼容协议调用本地 vLLM base64_img = encode_image(img_path) response = client.chat.completions.create( model="Qwen/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}, ], }], temperature=0, max_tokens=1024, ) return response.choices[0].message.content # 流转:解析 -> 视觉描述 -> 切块 -> embedding -> 入库 parsed = parse_pdf("sample.pdf") client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY") for page in parsed: for img in page["images"]: description = describe_image(client, img["local_path"], CHART_DESCRIBE_PROMPT) img["description"] = description for chunk in page["text_chunks"]: emb = embed(chunk["text"]) insert_to_vector_db(emb, chunk["text"], chunk["meta"])整个流程不复杂,但中间的缓存和错误处理不能省,否则调一次模型就要重新解析一遍 PDF,成本完全不可控。
5.2 实测效果与性能数据
我用一套 120 页的产品技术手册做过一次完整压测,里面包含约 40 张结构图、15 张表格、若干扫描页。纯 PyMuPDF 解析加切块这部分两三秒就完成了,主要耗时在视觉模型环节。7B 模型单卡 4090,40 张图大约两分半钟,1280 个文本块加图片描述 chunk,embedding 入库用了不到一分钟。
从效果看,纯文本 RAG 对比图文兼容 RAG 差距最明显的是两类问题:一类是关于“结构、流程、架构”的问题,图文方案命中率从不到 40% 提升到接近 85%;另一类是关于“某张图里某个参数”的问题,纯文本方案基本答不上来,图文方案能定位到具体图片并给出数值。当然,纯文本 PDF 场景两者差距不大,这也是我后面建议你不要过度设计的原因。
5.3 中间产物缓存与断点续跑
图片文件和视觉模型输出我全部落盘缓存。图片命名规则是page{页码}_b{块序号}.png,视觉输出命名为同名.json文件。下一次处理同一个 PDF 时,先检查缓存,命中就直接加载 JSON,不再调用模型。
这个习惯帮我省了大量重复开销。调提示词模板时,我不需要重新解析整个 PDF,而是只对缓存里的十几张代表性图片重新请求;调 embedding 模型时,我甚至可以只重算文本 chunk,图片描述部分完全跳过。断点续跑在这种多阶段管线里不是优化项,而是必需品,尤其是你换了视觉模型想对比效果的时候。
6. 常见问题与踩坑实录
6.1 表格内容被文本块截断
PyMuPDF 的文本块有时会把一个表格的行拆分成多个块,尤其是单元格里有跨行文本时。我一开始直接按 blocks 切,表格行被拆得七零八落,检索结果惨不忍睹。
后来加了一个合并规则:如果两个相邻文本块的 bbox 在水平方向重叠且垂直方向紧挨着,就把它们合并成一个大块。合并后再做一次表格识别,表格相关的块统一走表格转 Markdown 通道。另外,遇到带明显表格线框的页面,我会主动把整页渲染成图片,直接让 Qwen-VL 输出 Markdown,绕过 PyMuPDF 的块拆分问题。
6.2 模型幻觉:描述了图上不存在的内容
视觉模型也有幻觉,而且比纯文本模型更容易被忽略,因为它输出的是“描述”,你很难逐字核实。我遇到最典型的情况是模型把折线图的一个波峰描述成“2023 年 Q4 达到历史最高”,但图上那个位置根本没有标注数据点。
对策有两个。第一,在提示词里反复强调“只描述图中明确可见的内容,禁止推断不存在的数据点”。第二,在应用侧加一层验证:如果用户问的重要数值命中在图片描述里,系统会附上图片来源页码和 bbox,让用户自己回看原图。RAG 系统的职责是提供高可信的候选,而不是替用户做最终事实判断,这个边界要守住。
6.3 两栏 PDF 文本顺序错乱
这个我前面提过,但值得单独说一次。PyMuPDF 的sort=True在双栏 PDF 上不能直接信任。我实现的栏位检测逻辑是:先收集所有文本块 x0,统计分布,x0 小于页面宽度 40% 的块归为左栏,其余归为右栏;然后各栏内部按 y0 排序。
注意边界情况:有些版面是三栏或者带有侧边栏摘要,这时候分栏检测要放宽到按 x0 聚类。简单的 KMeans 聚类,类数由 x0 的分布决定,比写死两栏更通用。处理完栏位顺序后,文本块再进入切块器,这个顺序问题会显著降低检索时的语义混乱。
6.4 显存与内存优化
本地部署视觉模型时显存不够是最常见的劝退点。7B 模型在 vLLM 下大约占用 16~18G,如果你还要跑 embedding 模型和 reranker,显存就直接爆了。
我的做法是把 embedding 和 reranker 全部放到 CPU 上跑。BGE-M3 在 CPU 上虽然慢,但它只在入库和重排时用,不是高并发路径;Qwen-VL 独占 GPU,整体系统稳定性反而更好。如果你的 PDF 特别多,建议把图片解析做成常驻 Worker,GPU 模型一次加载后在内存里常驻,避免反复加载权重导致显存反复抖动。
6.5 什么时候不该用这套方案
图文兼容方案也有它不值得上的场景。我自己判断的准则是:先抽样看 PDF 的文本层质量。如果文档是纯电子生成的、无图或只有装饰性图片、版面是简单单栏,传统文本 RAG 已经够用,上视觉模型纯粹是浪费计算资源。
还有一类建议不要上视觉模型:扫描件质量极差、分辨率低、还有大量手写批注的档案。Qwen-VL 在这种图上的表现会断崖式下跌,误描述的概率很高。这类文档优先做高分辨率扫描 + 预处理增强,或者直接走纯 OCR 加人工复核,而不是硬塞给视觉模型。
| 问题 | 可能原因 | 解决建议 |
|---|---|---|
| 文本顺序乱 | 双栏/多栏版面 | 分栏检测后按栏排序 |
| 表格识别乱 | 无框线表格 | 整页渲染给 Qwen-VL 转 Markdown |
| 模型输出非 JSON | 提示词约束不足 | 温度置 0,加“只输出 JSON”约束,解析失败重试 |
| 显存不足 | 多模型同时占 GPU | embedding/reranker 放 CPU,图片解析常驻 Worker |
| 重复处理消耗大 | 无缓存 | 图片和模型输出全部落盘,断点续跑 |
| 图文信息检索不到 | 未加类型路由 | block_type 过滤 + 查询改写放宽 figure 召回 |
最后说一点我自己的体会。这套 PyMuPDF + Qwen-VL 的方案,解决的是“夹生 PDF”的问题,也就是文本层和视觉信息混在一起、只见其一就会瘸腿的文档。它的设计思想其实很简单:尊重文档的原始结构,文字给文字处理,图给图处理,再用统一的向量空间把它们捏合在一起。而我做这个项目最深的感受是,这类方案的技术难点从来不在单个模型的效果,而在工程链路的稳健度——缓存、并发、降级、元数据设计,每一项都比提示词本身更影响最终体验。后续如果要在生产环境继续演进,我会优先做两件事:一是接入版面分析模型,把更细的标题层级和阅读顺序交给模型自动识别;二是针对图片描述 chunk 做定期的“反哺校准”,用人工反馈修正视觉模型的高频错词。一步一步来,这套轮子能跑很久。