免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

图文兼容PDF RAG实战:PyMuPDF+Qwen-VL多模态检索方案

图文兼容PDF RAG实战:PyMuPDF+Qwen-VL多模态检索方案 1. 为什么我要折腾图文兼容的 PDF RAGPDF 文档的解析是每个做 RAG 的人都绕不开的一道坎。我最早做知识库的时候天真地以为把 PDF 转成纯文本切一切、塞进向量库就完事了。结果上线第一天就被现实教育一份产品手册里关键参数全在表格截图里正文只有一句“详见下图”一份研报的核心结论是一张折线图文字部分全是铺垫。用户问“第三季度毛利率是多少”系统检索到的全是废话因为答案压根不在文本层里。这就是传统 PDF RAG 最典型的瓶颈——图文割裂。绝大多数 PDF 解析工具比如早期的 PyPDF2、pdfplumber 的纯文本模式只抽取文字层图片、图表、公式、扫描件里的信息全部丢失。而现实中的 PDF尤其是技术文档、财报、学术论文、产品说明书图文混排才是常态。你只吃文字等于把一半以上的信息扔掉了。所以我给自己定了个目标做一套图文兼容的 PDF RAG 方案核心诉求有三条。第一文字和图片都要能被检索到图片不能只是被跳过而要能被理解、被索引。第二方案要能落地不能依赖一堆收费 API 和重型服务最好本地能跑。第三解析结果要保留结构信息页码、图片位置、上下文关系不能丢否则检索出来的东西没法溯源。技术选型上解析层我选了PyMuPDF模型层选了Qwen-VL。这两个组合起来基本能满足我上面说的三条诉求。PyMuPDF 是我用过最顺手的 PDF 处理库速度快、依赖少、能同时拿到文字块和图片对象还能给出每个元素的坐标和页码。Qwen-VL 则是目前开源多模态模型里中文理解能力比较扎实的一个对图表、表格截图、公式的识别都够用而且可以本地部署不用担心数据外流。这篇文章我会把这套方案从设计思路到代码实现完整拆一遍包括我踩过的坑、参数怎么调、检索怎么融合。适合正在做 RAG 知识库、被 PDF 解析折磨过的朋友也适合想入门多模态检索的开发者。你不需要是 PDF 解析专家但最好对 Python 和向量检索有基本概念。2. 方案整体设计与选型思路拆解2.1 传统 PDF RAG 的三个致命瓶颈在讲我的方案之前先把问题说透。我总结下来传统 PDF RAG 主要有三个瓶颈理解了这三个你才知道为什么要引入多模态。第一个瓶颈是信息丢失。PDF 里的图片、图表、公式、印章、手写批注这些在纯文本抽取里全部消失。一份 50 页的技术白皮书可能有 30 张架构图和流程图纯文本抽取后你只能拿到图注那一行字。用户问“系统架构分几层”模型只能瞎编。第二个瓶颈是结构断裂。PDF 的文本流是线性的但阅读逻辑是二维的。一个表格被抽成文本后行列关系全乱一个双栏排版的论文左右栏文字会被交错拼接读起来前言不搭后语。更麻烦的是图片和它对应的说明文字、正文引用之间失去了关联你检索到“如图 3 所示”但图 3 在哪、内容是什么系统一无所知。第三个瓶颈是检索粒度错配。纯文本切块通常按固定字数切但 PDF 里的语义单元是段落、章节、图表。切得太碎上下文丢失切得太粗检索精度下降。而且图片根本没法参与文本相似度计算向量库里压根没有它的位置。这三个瓶颈叠加起来导致传统 PDF RAG 在图文混排文档上的召回率和准确率都很惨。我实测过一份图文各半的产品手册纯文本方案的问答准确率不到 40%而图文兼容方案能拉到 80% 以上。2.2 为什么是 PyMuPDF 而不是其他解析库PDF 解析库我基本都用过一圈最后选 PyMuPDF是有具体理由的。pdfplumber 的表格抽取确实强但它的图片处理能力弱拿图片只能拿到位置拿不到图像数据本身而且速度偏慢处理大文件时内存占用高。PyPDF2 更不用说了功能太基础连图片对象都识别不全。pdfminer 的布局分析不错但 API 繁琐学习成本高而且对图片的支持也一般。PyMuPDF也就是 fitz 模块的优势在于它把 PDF 当成一个完整的文档对象模型来处理。你可以遍历每一页拿到页面上的文字块带坐标、图片块带图像数据、绘图对象、链接、注释。图片可以直接导出成 PNG 字节流文字块可以按坐标排序还原阅读顺序。速度上PyMuPDF 是 C 底层实现处理几百页的 PDF 也就几秒钟。更关键的是PyMuPDF 能给出每个元素的边界框bbox。这个信息太重要了它让我能把图片和它周围的文字关联起来——比如图片下方 50 像素内的文字块大概率是图注图片上方或左右紧邻的文字可能是正文引用。这种空间关系是构建图文关联的基础。提示PyMuPDF 的page.get_text(dict)能返回带坐标的完整结构page.get_images()能拿到图片的 xref 引用两者结合就能建立图文映射。别用get_text()的纯文本模式那个丢的信息太多。2.3 Qwen-VL 在方案里扮演什么角色PyMuPDF 负责“拆”Qwen-VL 负责“懂”。拆出来的图片如果直接存进向量库检索时只能靠图片的元数据页码、尺寸没法做语义匹配。用户问“这张图说明了什么”系统答不上来。Qwen-VL 的作用就是把图片转成可检索的文本描述。具体来说我会对每张图片调用 Qwen-VL让它输出一段结构化的描述图表类型、坐标轴含义、数据趋势、关键数值、表格的行列内容、公式的符号含义。这段描述再和图片的元数据一起存进向量库检索时就能用文本 query 匹配到图片。选 Qwen-VL 而不是其他多模态模型主要考虑三点。一是中文能力很多开源多模态模型英文强中文弱但我的文档大量是中文Qwen-VL 的中文图表理解明显更稳。二是可本地部署Qwen-VL 有 7B 和 72B 多个尺寸7B 量化后在消费级显卡上就能跑数据不出本地。三是指令跟随能力我可以用 prompt 精确控制它输出什么格式比如“只输出表格内容用 Markdown 格式”它基本能照做。2.4 整体架构从 PDF 到可检索知识库整个方案的流程我画成了一条流水线分五个阶段。阶段一解析。用 PyMuPDF 遍历 PDF逐页抽取文字块和图片。文字块按坐标排序还原阅读顺序图片导出为 PNG记录页码和 bbox。阶段二图文关联。根据 bbox 的空间关系把图片和它附近的文字块绑定。比如图片下方最近的文字块判定为图注图片所在页的正文判定为上下文。阶段三图片理解。把每张图片送给 Qwen-VL生成结构化描述。描述里包含图表类型、关键数据、趋势结论。阶段四切块与索引。文字按语义段落切块图片描述作为独立块两者都带上页码、类型、关联 ID 等元数据一起写入向量库。阶段五检索与融合。用户 query 进来后同时检索文字块和图片描述块按相似度排序把命中的图片和文字一起送给生成模型拼成最终答案。这个架构的核心思想是让图片以文本描述的形式参与检索让文字以结构化的形式保留上下文。两者在向量空间里是平等的检索时统一排序生成时一起喂给 LLM。3. 核心细节解析与实操要点3.1 PyMuPDF 抽取文字块坐标排序是关键PyMuPDF 抽文字最忌讳直接用page.get_text()。那个返回的是拼接好的字符串阅读顺序全靠 PDF 内部的流顺序双栏排版、表格、脚注一混顺序就乱了。正确做法是用page.get_text(dict)它返回一个字典里面有blocks列表每个 block 有bbox坐标和lines内容。拿到 blocks 后我要做的是按坐标排序还原阅读顺序。排序规则不是简单的从上到下因为双栏排版里左栏的底部和右栏的顶部在同一水平线上。我的做法是先按 y 坐标聚类把同一行的 block 归为一组组内按 x 坐标从左到右排组间按 y 坐标从上到下排。这样双栏文档也能还原成“先读左栏再读右栏”的顺序。import fitz def extract_text_blocks(page): blocks page.get_text(dict)[blocks] text_blocks [] for b in blocks: if b[type] ! 0: # 0 是文字块1 是图片块 continue text for line in b[lines]: for span in line[spans]: text span[text] text \n text_blocks.append({ bbox: b[bbox], text: text.strip(), page: page.number }) # 按 y 聚类再按 x 排序 text_blocks.sort(keylambda x: (round(x[bbox][1] / 10), x[bbox][0])) return text_blocks这里有个细节round(x[bbox][1] / 10)是把 y 坐标按 10 像素分桶避免同一行因为微小的高度差被拆成两组。这个 10 是我实测下来的经验值太小了行会散太大了跨行会混。你可以根据文档字号调整一般字号 12pt 左右用 10 到 15 都行。注意有些 PDF 的文字块 bbox 会重叠尤其是加了背景色或水印的文档。遇到重叠时我优先保留文字密度高的块把明显是水印的块文字重复、透明度高、旋转角度异常过滤掉。3.2 图片抽取与去重别让 logo 污染向量库图片抽取用page.get_images(fullTrue)它返回每张图片的 xref 引用。然后用doc.extract_image(xref)拿到图像字节流。听起来简单但实际做的时候有两个坑。第一个坑是重复图片。一份 PDF 里页眉 logo、页脚图标、背景水印会在每一页重复出现。如果你不做去重一份 100 页的文档能抽出 300 张一模一样的 logo全塞进向量库检索时全是噪声。我的做法是用图片的 xref 做去重同一个 xref 只处理一次。如果 xref 不同但图像内容相同比如重新编码过就用图像哈希感知哈希 pHash做二次去重。第二个坑是图片尺寸过滤。太小的图片比如 20x20 像素的图标没有语义价值送给 Qwen-VL 也是浪费算力。我设了个阈值宽高都小于 100 像素的图片直接跳过。另外纯色图片、纯白图片也过滤掉这些通常是分隔线或空白占位。import fitz from PIL import Image import io import imagehash def extract_images(doc, min_size100): seen_xref set() seen_hash set() images [] for page in doc: for img in page.get_images(fullTrue): xref img[0] if xref in seen_xref: continue seen_xref.add(xref) base doc.extract_image(xref) img_bytes base[image] pil_img Image.open(io.BytesIO(img_bytes)) w, h pil_img.size if w min_size or h min_size: continue phash str(imagehash.phash(pil_img)) if phash in seen_hash: continue seen_hash.add(phash) images.append({ xref: xref, page: page.number, bytes: img_bytes, size: (w, h), bbox: page.get_image_bbox(img) }) return imagespage.get_image_bbox(img)能拿到图片在页面上的位置这个 bbox 后面用来做图文关联。注意有些图片是内嵌在 Form XObject 里的get_image_bbox可能拿不到准确坐标这时候要回退到用page.get_image_rects(xref)试试。3.3 图文关联用空间距离绑定图片和图注图片抽出来了但它和周围的文字是什么关系这一步决定了检索时能不能把图片和它的说明文字一起召回。我的关联策略基于空间距离。对每张图片找它 bbox 下方最近的文字块如果垂直距离小于阈值我设的是 50 像素就判定为图注。同时找图片所在页的正文块作为图片的上下文。这样一张图片就有了三层信息图片本身的视觉内容、图注文字、所在页的正文。def associate_image_text(image, text_blocks, max_dist50): img_bbox image[bbox] img_bottom img_bbox[3] img_left img_bbox[0] img_right img_bbox[2] caption None min_dist float(inf) for tb in text_blocks: tb_bbox tb[bbox] # 文字块在图片下方且水平方向有重叠 if tb_bbox[1] img_bottom: h_overlap min(img_right, tb_bbox[2]) - max(img_left, tb_bbox[0]) if h_overlap 0: dist tb_bbox[1] - img_bottom if dist min_dist and dist max_dist: min_dist dist caption tb[text] image[caption] caption return image这个逻辑看着简单但实际效果很好。我测过一份产品手册图注识别准确率在 90% 以上。剩下的 10% 主要是图注在图片上方、或者图注和图片之间有其他元素干扰的情况。对于图注在上方的我把判断条件反过来再跑一遍就行。实操心得有些 PDF 的图注会带“图 1”“Figure 1”这样的编号我在关联时会把编号也提取出来存进元数据。检索时如果 query 里出现“图 3”可以直接按编号精确匹配比向量相似度还准。3.4 Qwen-VL 图片描述prompt 设计决定成败图片送给 Qwen-VLprompt 怎么写直接决定描述质量。我试过很多版本最后稳定下来的 prompt 是这样的你是一个专业的文档图像分析助手。请分析这张来自 PDF 文档的图片输出以下信息 1. 图片类型图表、表格、流程图、照片、公式、其他 2. 如果是图表说明图表类型折线/柱状/饼图等、坐标轴含义、数据趋势、关键数值 3. 如果是表格用 Markdown 表格还原内容 4. 如果是流程图说明流程步骤和逻辑关系 5. 如果是公式用 LaTeX 表示 6. 用一句话总结这张图的核心信息 要求只输出分析结果不要输出多余的解释。数值要精确看不清的标注为“不清晰”。这个 prompt 的关键在于结构化输出。我不需要 Qwen-VL 写一段散文我需要的是可检索、可解析的结构化文本。分类型处理让模型知道该关注什么最后那句“一句话总结”是为了生成一个高密度的语义向量方便检索。调用 Qwen-VL 我用的是 transformers 库7B 模型量化后大概占 8G 显存。如果图片多建议批处理但要注意显存。我的做法是每次处理一张处理完释放缓存虽然慢一点但稳。from transformers import Qwen2VLForConditionalGeneration, AutoProcessor from PIL import Image import io import torch model Qwen2VLForConditionalGeneration.from_pretrained( Qwen/Qwen2-VL-7B-Instruct, torch_dtypetorch.float16, device_mapauto ) processor AutoProcessor.from_pretrained(Qwen/Qwen2-VL-7B-Instruct) def describe_image(img_bytes, prompt): image Image.open(io.BytesIO(img_bytes)).convert(RGB) messages [{ role: user, content: [ {type: image, image: image}, {type: text, text: prompt} ] }] text processor.apply_chat_template(messages, tokenizeFalse, add_generation_promptTrue) inputs processor(text[text], images[image], return_tensorspt).to(model.device) with torch.no_grad(): output model.generate(**inputs, max_new_tokens512) result processor.batch_decode(output, skip_special_tokensTrue)[0] return result.split(assistant)[-1].strip()max_new_tokens我设的 512因为表格和复杂图表的描述可能比较长。如果你的文档表格特别多可以调到 1024但要注意显存和速度的平衡。3.5 切块策略文字按语义图片按描述切块是 RAG 的老话题但图文兼容场景下要特殊处理。我的策略是文字和图片分开切但共享元数据。文字块我按语义段落切不按固定字数。具体做法是先用 PyMuPDF 的 block 作为基础单元如果相邻 block 属于同一章节通过字体大小、加粗、编号判断就合并如果遇到标题、分页、空行就断开。每个 chunk 控制在 300 到 500 字太短了上下文不足太长了检索精度下降。图片块不切一张图就是一个 chunk内容是 Qwen-VL 生成的描述加上图注。如果描述太长比如复杂表格我会把表格内容和总结拆成两个 chunk表格内容用于精确检索总结用于语义检索。所有 chunk 都带这些元数据page页码、typetext/image、bbox位置、caption图注、image_id图片唯一 ID。检索命中后这些元数据用来溯源和展示。注意图片描述 chunk 的 embedding 和文字 chunk 的 embedding 要放在同一个向量空间里用同一个 embedding 模型。我用的是 bge-large-zh中文效果好维度 1024检索速度快。别用两个不同的模型否则相似度没法比较。4. 实操过程与核心环节实现4.1 环境准备与依赖安装先把环境搭起来。Python 版本我用的 3.10太新的版本有些库还没适配。核心依赖就几个PyMuPDF 做解析transformers 和 torch 跑 Qwen-VLsentence-transformers 做 embeddingfaiss 做向量检索。pip install pymupdf pillow imagehash pip install torch transformers accelerate pip install sentence-transformers faiss-cpu pip install qwen-vl-utils如果你有 GPUfaiss 可以换成 faiss-gpu检索速度快很多。Qwen-VL 的 7B 模型建议至少 16G 显存量化后 8G 也能跑。没有 GPU 的话可以用 CPU 跑 embeddingQwen-VL 换成 API 调用但那就失去本地部署的意义了。模型下载我用的是 HuggingFace 的镜像国内下载快。Qwen2-VL-7B-Instruct 大概 15G下载一次就行。4.2 完整解析流水线代码把前面的模块串起来就是完整的解析流水线。我写成一个类方便复用。import fitz import io import imagehash from PIL import Image class PDFParser: def __init__(self, pdf_path, min_img_size100): self.doc fitz.open(pdf_path) self.min_img_size min_img_size self.text_chunks [] self.image_chunks [] def parse(self): seen_xref set() seen_hash set() for page in self.doc: text_blocks self._extract_text(page) self.text_chunks.extend(text_blocks) images self._extract_images(page, seen_xref, seen_hash) for img in images: img self._associate_caption(img, text_blocks) self.image_chunks.append(img) return self.text_chunks, self.image_chunks def _extract_text(self, page): blocks page.get_text(dict)[blocks] result [] for b in blocks: if b[type] ! 0: continue text for line in b[lines]: for span in line[spans]: text span[text] text \n text text.strip() if not text: continue result.append({ text: text, bbox: b[bbox], page: page.number, type: text }) result.sort(keylambda x: (round(x[bbox][1] / 10), x[bbox][0])) return result def _extract_images(self, page, seen_xref, seen_hash): result [] for img in page.get_images(fullTrue): xref img[0] if xref in seen_xref: continue seen_xref.add(xref) base self.doc.extract_image(xref) img_bytes base[image] pil_img Image.open(io.BytesIO(img_bytes)) w, h pil_img.size if w self.min_img_size or h self.min_img_size: continue phash str(imagehash.phash(pil_img)) if phash in seen_hash: continue seen_hash.add(phash) try: bbox page.get_image_bbox(img) except Exception: bbox (0, 0, 0, 0) result.append({ bytes: img_bytes, bbox: bbox, page: page.number, type: image, size: (w, h) }) return result def _associate_caption(self, image, text_blocks, max_dist50): img_bbox image[bbox] img_bottom img_bbox[3] img_left, img_right img_bbox[0], img_bbox[2] caption None min_dist float(inf) for tb in text_blocks: tb_bbox tb[bbox] if tb_bbox[1] img_bottom: h_overlap min(img_right, tb_bbox[2]) - max(img_left, tb_bbox[0]) if h_overlap 0: dist tb_bbox[1] - img_bottom if dist min_dist and dist max_dist: min_dist dist caption tb[text] image[caption] caption return image这个类跑完text_chunks是文字块列表image_chunks是图片块列表每个都带页码和 bbox。接下来把图片块送给 Qwen-VL 生成描述。4.3 图片描述生成与向量化图片描述生成我做了个批处理版本因为一张一张调太慢。但批处理要注意显存我设的 batch_size 是 27B 模型在 16G 显存上跑得动。def generate_image_descriptions(image_chunks, model, processor, prompt): results [] for i, img in enumerate(image_chunks): try: desc describe_image(img[bytes], prompt, model, processor) except Exception as e: desc f图片描述生成失败{str(e)} img[description] desc results.append(img) if (i 1) % 10 0: print(f已处理 {i1}/{len(image_chunks)} 张图片) return results描述生成后把文字块和图片描述块一起向量化。我用 bge-large-zh它对中文长文本的语义捕捉很准。from sentence_transformers import SentenceTransformer embed_model SentenceTransformer(BAAI/bge-large-zh-v1.5) def build_index(text_chunks, image_chunks): all_chunks [] for tc in text_chunks: all_chunks.append({ content: tc[text], metadata: {page: tc[page], type: text, bbox: tc[bbox]} }) for ic in image_chunks: content ic.get(description, ) if ic.get(caption): content f图注{ic[caption]}\n描述{content} all_chunks.append({ content: content, metadata: { page: ic[page], type: image, bbox: ic[bbox], image_id: ic.get(xref, ) } }) texts [c[content] for c in all_chunks] embeddings embed_model.encode(texts, normalize_embeddingsTrue, batch_size32) return all_chunks, embeddingsnormalize_embeddingsTrue很重要归一化后内积就等于余弦相似度检索时直接用内积就行快。4.4 向量库构建与检索融合向量库我用 faiss简单直接。建索引用 IndexFlatIP内积因为向量归一化了内积就是余弦相似度。import faiss import numpy as np def create_faiss_index(embeddings): dim embeddings.shape[1] index faiss.IndexFlatIP(dim) index.add(embeddings.astype(np.float32)) return index def search(query, index, all_chunks, embed_model, top_k5): q_emb embed_model.encode([query], normalize_embeddingsTrue) scores, indices index.search(q_emb.astype(np.float32), top_k) results [] for score, idx in zip(scores[0], indices[0]): chunk all_chunks[idx] results.append({ content: chunk[content], metadata: chunk[metadata], score: float(score) }) return results检索融合的关键在于排序。文字块和图片块在同一个索引里按相似度统一排序。但实际用的时候我发现图片描述块有时候会因为描述太长、语义太泛相似度虚高。我的处理是给图片块一个轻微的分数惩罚乘以 0.95让文字块稍微优先。这个系数是我调出来的你可以根据自己文档的图文比例调整。检索到结果后把命中的文字和图片描述一起拼成 context送给 LLM 生成答案。如果命中的是图片我会把图片的页码和 bbox 也带上方便用户溯源。4.5 端到端跑通一份 50 页手册的实测我拿一份 50 页的产品手册做了端到端测试。文档里有 32 张图包括架构图、参数表、流程图。解析阶段PyMuPDF 抽出了 280 个文字块去重后得到 28 张有效图片4 张是重复 logo。图片描述生成花了大概 8 分钟7B 模型在 16G 显存上跑平均每张 17 秒。向量库建好后我测了 20 个问题其中 10 个是纯文字问题10 个是图文混合问题。纯文字问题准确率 90%图文混合问题准确率 85%。对比纯文本方案图文混合问题的准确率从 35% 提升到了 85%提升非常明显。有个典型 case用户问“系统的三层架构分别是什么”。纯文本方案检索到的全是“系统采用三层架构”这句话但三层具体是什么文字里没写全在架构图里。图文方案里架构图的描述被检索到Qwen-VL 把三层架构的每一层都识别出来了答案完整准确。5. 常见问题与排查技巧实录5.1 图片抽取常见问题速查做这套方案的过程中图片抽取环节踩的坑最多。我整理了一张速查表遇到问题直接对号入座。问题现象可能原因解决方法图片抽不出来图片是矢量图或 Form XObject用page.get_drawings()处理矢量图或渲染整页为图片图片重复太多页眉页脚 logo 未去重用 xref pHash 双重去重图片 bbox 不准图片被旋转或缩放用page.get_image_rects()替代get_image_bbox()图片是扫描件PDF 本身是图片型整页渲染后用 OCR 或直接送多模态模型图片颜色异常CMYK 色彩空间用 PIL 转成 RGB 再处理扫描件是个特殊情况。如果整个 PDF 都是扫描的PyMuPDF 抽不出文字这时候我的做法是把每页渲染成高分辨率图片直接送 Qwen-VL 做整页理解。虽然慢但效果比 OCR 好因为 Qwen-VL 能同时理解文字和图表。5.2 Qwen-VL 描述质量不稳定的排查Qwen-VL 的描述质量有时候会飘同一个图表两次调用可能输出不一样。我总结了几个稳定技巧。第一prompt 要固定。别每次改 prompt固定一版跑到底。prompt 里的指令越具体越好比如“用 Markdown 表格还原”比“描述表格内容”稳定得多。第二温度设低。生成时do_sampleFalse或者temperature0.1让输出确定性更强。我实测温度 0.1 时同一张图的描述基本一致。第三图片预处理。太小的图片先放大太模糊的先锐化。Qwen-VL 对图片分辨率有要求低于 224x224 的图片识别效果很差。我的做法是统一缩放到短边 448 像素。第四失败重试。如果描述里出现“不清晰”“无法识别”超过两次就重新调用一次或者换用更大的模型72B跑一遍。实操心得Qwen-VL 对表格的识别行列表头经常搞混。我的解决办法是在 prompt 里明确说“第一行是表头第一列是行标签”给它一个先验准确率能提升不少。5.3 检索召回不准的调优思路检索召回不准通常不是单一原因要分层排查。先看 embedding 模型。中文文档用 bge-large-zh 基本没问题但如果你的文档专业术语多可以考虑用领域微调过的 embedding 模型。我试过用 bge 的通用版和专业版对比专业版在术语匹配上确实更准。再看切块粒度。切得太碎语义不完整切得太粗噪声多。我的经验是技术文档 300 到 500 字一个 chunk 比较合适法律文档可以到 800 字因为法律条款上下文依赖强。然后看图片描述的权重。前面说的 0.95 惩罚系数不是固定的。如果你的文档图片信息密度高可以调到 1.0 甚至 1.05让图片块优先。反之就调低。最后看 top_k。top_k 太小可能漏掉正确结果太大噪声多。我一般用 5 到 10然后加一个相似度阈值比如 0.5低于阈值的直接丢弃。5.4 性能优化从 8 分钟到 3 分钟图片描述生成是性能瓶颈。我一开始跑 28 张图花了 8 分钟后来优化到 3 分钟。主要做了三件事。一是批处理。虽然 Qwen-VL 的批处理对显存要求高但把 batch_size 从 1 调到 2速度提升了近一倍。如果你的显存够可以试到 4。二是图片预缩放。原始图片可能很大比如 2000x2000但 Qwen-VL 实际处理时会缩放到固定尺寸。我提前把图片缩到短边 448减少了传输和预处理时间。三是缓存。同一份文档重复解析时图片描述直接读缓存不重复调用模型。我用图片的 pHash 做缓存 key存在本地 JSON 里。import json import os CACHE_FILE image_desc_cache.json def load_cache(): if os.path.exists(CACHE_FILE): with open(CACHE_FILE, r, encodingutf-8) as f: return json.load(f) return {} def save_cache(cache): with open(CACHE_FILE, w, encodingutf-8) as f: json.dump(cache, f, ensure_asciiFalse, indent2)这个缓存机制在调试阶段特别有用改检索逻辑时不用重新跑模型。5.5 几个容易忽略的细节最后分享几个我踩过的细节坑。页码从 0 开始还是从 1 开始。PyMuPDF 的page.number是从 0 开始的但用户看到的页码是从 1 开始的。存元数据时我统一加 1避免溯源时对不上。图片的 bbox 坐标系。PyMuPDF 的 bbox 是左上角为原点但有些 PDF 的坐标系是左下角为原点。如果发现图片位置对不上检查一下page.rotation和坐标系设置。文字块的换行符。PyMuPDF 抽出来的文字块里行与行之间是\n但同一个 span 内的文字可能没有空格。中文没问题英文单词可能被拆开。我的处理是在拼接 span 时如果前一个 span 结尾是字母、后一个 span 开头是字母就补一个空格。向量库的持久化。faiss 的索引默认在内存里重启就没了。记得用faiss.write_index存到磁盘下次直接faiss.read_index加载。元数据我用 JSON 或 SQLite 存和索引分开方便更新。这套方案我前后迭代了大概两个月从最初的纯文本方案到加图片抽取再到加 Qwen-VL 描述每一步都是被实际问题逼出来的。现在这套流程跑下来图文混排文档的问答体验已经能接受了。如果你也在做 PDF RAG建议先把 PyMuPDF 的解析跑通再逐步加多模态别一上来就追求大而全。解析质量是地基地基不稳后面检索和生成再花哨也没用。
返回列表