先说一个让我印象特别深的翻车场景。某次要给一份行业调研报告做知识库问答报告正文里的文字部分我都顺利索引进去了结果同事问了一个非常正常的问题“最近一年这个品类的线上渗透率变化趋势是什么”系统检索了半天返回的是产品介绍页里一段毫不相干的描述。后来我把这份PDF翻到第24页才恍然大悟——问题答案就安安静静躺在一张折线图里普通RAG从头到尾就没“看见”过这张图。这种问题在真实场景里太常见了。现在的PDF早已不是纯文本文件而是图文混排的复合文档甚至很多是扫描件。市面上大多数RAG方案默认只用文本抽取层但PDF里的图表、流程图、截图、数据可视化内容一旦占比变高检索质量就会断崖式下跌。这也是我想写这篇分享的原因我基于PyMuPDF和Qwen-VL搭了一套“图文兼容”的PDF RAG流水线把PDF里肉眼能看到的东西全部转换成可检索的文本中间态再进入向量库。整套方案不需要高端GPU集群一台普通开发机就能跑通适合个人知识库、企业内部文档问答、合同审阅辅助这类场景。这篇文章我会把完整实现拆开讲包括文档拆解、视觉模型补全、混合检索设计、问答链路以及实测中遇到的坑和教训。目标只有一个让你看完之后能照着思路自己搭一遍。1. 为什么绝大多数 PDF RAG 方案都卡在“图文断裂”上1.1 普通RAG处理PDF的三个“看不见”我最早用的方案和大多数人一样拿到PDF先调某个文本抽取库把内容导出来然后按固定长度切成chunk embedding后扔进向量库。这套流程在纯文本PDF上没什么问题但一遇到复杂排版就露馅。第一个“看不见”是表格。PDF本身不存储表格的语义结构存的是线和文字的坐标。文本抽取器按阅读顺序输出内容时表格里的数据会被拆得七零八落上一行还在一列下一行跑去了另一列字段名和数值完全对不上。第二个“看不见”是扫描件。这类PDF本质是整页图片没有文字层抽出来就是空字符串。第三个“看不见”最致命——图表里的信息完全丢失。饼图、柱状图、流程图里蕴含的结论在文字层里根本不存在RAG再强也检索不到不存在的文本。1.2 为什么不能只用OCR硬扛有些朋友可能会说遇到图片用OCR不就行了我最初也这么想用了一轮传统OCR之后发现远远不够。OCR能把图片里的文字识别成文本但识别不出“这张柱状图里哪根柱子最高”“这条折线呈现上升趋势”这类视觉语义。换句话说OCR只能把“图里的字”变成文字却无法把“图表达的意思”变成文字。举个例子一张趋势图横轴是月份、纵轴是销售额OCR能读出一堆数字标签但不会告诉你“全年销售额波动上升6月出现峰值”。刚好这类结论性描述才是问答场景里最常被问到的。所以我把方案升级成了“版面解析 视觉语言模型描述生成”的组合先拆出PDF里的版面元素再用视觉模型把图形元素“翻译”成结构化的文字描述。1.3 整体流水线的设计思路整套RAG流水线分成四个阶段拆解阶段用 PyMuPDFfitz解析PDF输出页面中的文本块、图像块、位置坐标识别表格所在区域。补全阶段对提取出的每个图像区域调用 Qwen-VL生成摘要描述和结构化信息凑成一段“图文的文字替身”。入库阶段把文字块和图像描述块统一切片、向量化存储页面号、坐标、类型等元数据。问答阶段用户提问后先做混合检索召回相关的文本块和图像描述块再做重排把上下文喂给大模型生成带引用来源的答案。这四个阶段里最核心的设计决策是不追求把所有内容强行变成纯文本而是保留坐标和结构信息让图像内容以描述文本的形式和原文锚定在一起。这样既解决了“看不到图”的问题也不会丢失版面结构。2. 文档拆解PyMuPDF 如何把版面变成可利用的结构化数据2.1 PyMuPDF 对比其它解析库赢在“快”和“全”做PDF解析绕不开三个库pdfplumber、pdfminer.six、PyMuPDF。pdfplumber的表格抽取能力很强适合处理规则表格但速度偏慢pdfminer.six胜在纯Python便于定制但API比较底层PyMuPDFimport名是fitz的最大优点是把文本、图像、矢量图形统一在一个对象模型里解析速度很快而且能拿到所有元素的坐标框。我的选择逻辑不复杂RAG场景不要求像素级还原版面但要求“快速拿到每页有哪些文字、哪些图、各在什么位置”。PyMuPDF正好是这个定位。它对PDF里嵌入图片的处理尤其方便直接可以拿到原始xref编号、图像尺寸和像素数据做一些过滤和导出操作非常顺手。2.2 用 fitz 提取页面中的文本块和图像块PyMuPDF里最核心的不传之秘是page.get_text(dict)。这个接口返回一个字典里面包含当前页的blocks列表每个block都有type字段0代表文本块1代表图片块2代表矢量图形。文本块内部再往下拆有lines、spans可以拿到每一行文字和每个span的字体、字号、颜色、坐标。我的拆解逻辑大致是这样import fitz doc fitz.open(some_report.pdf) page doc[page_idx] page_dict page.get_text(dict) text_blocks [] image_blocks [] for block in page_dict[blocks]: if block[type] 0: # 文本块合并行内容 lines [] for line in block[lines]: text .join([span[text] for span in line[spans]]) lines.append(text) text_blocks.append({ text: \n.join(lines), bbox: block[bbox], page: page_idx }) elif block[type] 1: # 图片块记录坐标和xref image_blocks.append({ bbox: block[bbox], xref: block[xref], page: page_idx })注意一个细节很多文本工具会把PDF按阅读顺序输出但排过版的双栏 PDF 不一定满足这个假设。PyMuPDF返回的block顺序通常是自上而下、自左而右的物理顺序对复杂版面不一定完全正确。我在切分chunk时并不会把所有block简单按顺序拼接而是保留坐标、计算每个block之间的空间邻近度把相邻且同属一个小节的block归并到一个组里尽量保住语义边界。2.3 把坐标变成可检索的元数据很多RAG方案会忽略坐标信息但我认为坐标是PDF RAG里最被低估的资产。它可以支持很多东西回答完成后可以定位到页面某个区域、判断两个block是否属于同一栏、过滤页眉页脚、恢复表格的阅读顺序。所以我在设计文档解析结果时给每个block建立了一个统一的元数据结构字段说明示例page_idx页码24bbox页面上的坐标框 [x0, y0, x1, y1][72.0, 120.5, 300.0, 240.0]block_typetext / image / table / header / footerimageparent_title所属章节标题就近查找市场趋势分析img_path如果block是图片导出后的本地路径pages/p24_01.pngchunk_ref最终对应的检索块IDchunk_1024有了这套元数据后续生成答案时可以溯源到“某页某区域”并且能把图像描述块和它附近的文本块合并成同一个chunk的上下文。这个设计在第五节的引用溯源里发挥了很大作用。2.4 表格区域的粗识别与处理策略严格来说表格识别是另一个大坑。PyMuPDF没有直接输出表格语义的能力但我们可以用线条检测做一个“粗识别”遍历页面的page.get_drawings()拿到所有矢量线段如果一段区域内横线和竖线数量都超过阈值就标记为一个表格区。这一步不是要还原表格结构而是要识别出“这里有一张表格需要特殊处理”。标记出表格区之后我直接把这个区域连同坐标截成图片交给视觉模型描述而不是试图用文本层去还原表格内容。为什么因为很多PDF表格的文本层顺序是乱的逐行还原需要复杂的NLP推断而视觉模型可以直接“看图”把表格的标题、表头、关键数据行描述出来准确度和成本都更可控。扫一眼表格图片然后输出结构化摘要比把每个单元格拼成Markdown表格再清洗要省力得多。3. 视觉补全让 Qwen-VL 把图形“翻译”成文字3.1 为什么视觉模型输出的是“事实描述”而不是“理解结论”在把图片送入 Qwen-VL 之前我先想清楚了它的角色定位它不是一个问答引擎而是一个离线图文转换器。我给它输入的是一张图表图片要求它输出的是对图片内容的事实性描述和结构化信息不掺杂主观推断。这样做的好处是把“看图的步骤”从问答链路里的实时调用变成了离线批量处理问答时直接检索描述文本即可成本和延迟都可控。实际操作中Qwen-VL能基于图像输入生成一段长短可控的文本。这里的关键不是模型能不能看懂图而是怎么让它输出“适合检索”的文本。我见过很多方案直接把图片的Alt文本或OCR文本作为替代效果都一般因为这两类文本不含视觉语义。Qwen-VL的优势在于可以显式地描述图形元素之间的关系。3.2 Prompt 设计摘要、数据片段、图表类型我的Prompt经过了几轮迭代最终稳定成这个结构这是一个PDF页面中截取出来的图像区域。请分析这张图的内容并输出JSON格式包含以下字段 1. chart_type: 图像类型柱状图/折线图/饼图/流程图/表格截图/照片/其它 2. title: 如果图像中有标题提取标题文字 3. summary: 用3到5句话描述图中最重要的信息包括趋势、对比关系、关键结论 4. data_snippets: 提取最多10条关键数值/实体以名称: 值形式输出 5. text_content: 图中包含的所有文字尽可能完整提取 只输出JSON不要输出任何解释。我提供一个真实的prompt场景一张“2023-2024年分季度营收对比柱状图”Qwen-VL可能会输出{ chart_type: 柱状图, title: 分季度营收对比, summary: 2024年各季度营收均高于2023年同期。2024年Q3营收最高约为85亿元同比增长约21%。Q2增速最为明显同比增幅超过30%。整体呈现逐季抬升趋势。, data_snippets: [ 2023Q1: 52亿元, 2023Q2: 55亿元, 2023Q3: 70亿元, 2024Q1: 64亿元, 2024Q3: 85亿元 ], text_content: 分季度营收对比 单位亿元 2023年 2024年 季度 }这段输出相比OCR多了两个关键价值一是把“趋势”变成了明确的文字表述二是把数值抽取成可以精确匹配的片段。未来用户问“哪个季度营收最高”时系统可以直接命中“2024Q3”而不是只能匹配到一组离散数字。3.3 防止模型“胡言乱语”的解析容错用大模型做批量处理最先遇到的一定是输出格式问题。我实测Qwen-VL有时会给出带Markdown代码块围栏的输出有时会把JSON包裹在多余说明文字里偶尔还会漏掉字段。所以我写了一个专门做JSON解析的容错函数核心思路是先剥离json围栏再截取第一个{到最后一个}之间的片段最后用json.loads解析任何一层失败就调用一次修正接口让它重输出。这个容错逻辑看起来简单但实际帮了大忙。300多页的PDF跑下来Qwen-VL的首轮可用率大约在92%左右加上容错重试之后能到99%以上。不过我不能把可靠性完全寄托在“重试”上所以还会在解析成功后做一次字段完整性校验发现缺失字段就用固定的兜底文本比如 “chart_type: unknown”补齐。3.4 大图切分与降采样Qwen-VL虽然支持高分辨率输入但超大图直接送进去会带来两个问题一是超出模型限制时的处理不稳定二是生成描述时容易忽略局部细节。我的处理办法是对图片做降采样把最长边限制在标准尺寸附近同时保留原始宽高比。对于内容特别密集的图比如复杂的系统架构图我会先按网格切分成多个子图每个子图单独描述最后合并成一个整体描述文本。切分时有一个必须注意的顺序问题必须按“从上到下、从左到右”的顺序编号并且在每个子图的描述文本开头注明“本图是整体图的左上部分”这样拼合后的描述文本才能保持空间逻辑的一致性。第一次做切分时我疏忽了顺序导致描述文本像一个打乱的拼图检索出的内容牛头不对马嘴后来加上方位标注才稳定下来。4. 混合入库文字块和图像描述块如何共存于一个向量库4.1 文本切片策略先按语义边界切再补长度限制文档解析完成之后手里有两大类内容一是文字块二是图像描述块。切片策略上我选择了“语义优先、长度兜底”的方式。先按标题层级归并段落把同一标题下、空间上相邻的blocks合并成一个语义单元如果合并后长度超过设定的chunk大小比如1200字再从段落边界中间拆开如果多个blocks本身很小就尽量拼到接近chunk大小避免出现大量几十字的短碎片。图像描述块的处理略有不同。单张图的描述文本通常也就200到500字如果直接作为独立chunk入库检索时能命中但上下文太单薄。我的做法是查找图像坐标附近最近的文本块把两者拼接为一个复合chunk。这样既保留了图像的语义描述又带上了图像在原文中的上下文。复合chunk的元数据里会标记source_type为“textimage”。4.2 Embedding模型选择与向量库选型方案里最核心的检索单元是文本块Embedding模型我用的是中文场景表现稳定的常见模型。实现过程中我发现一个问题有些Embedding模型对“查询语句”和“文档片段”建议使用不同的前置提示词instruction如果在向量化阶段忘记加提示词检索准确率会掉好几个点。所以我把向量化逻辑封装成了一个入口自动根据内容类型判断是否拼接instruction。向量库我在这套方案里用的是本地文件式组件它可以零依赖跑起来几百个chunk级别的项目完全够用。如果哪天数据量到了百万级可以平滑迁移到服务化组件。这里不建议一上来就上重组件个人项目的核心诉求是快速迭代能把全流程跑通最重要。4.3 双层召回先命中的是“视觉替身”再去拉原文检索阶段我做了两层设计。第一层是常规的向量相似度检索对用户查询做向量化然后在库里找出Top K个最相似的chunk。但纯向量召回存在一个问题用户问“2024年哪个季度营收最高”query和图像描述文本在字面上可能相似度不高向量召回未必排得靠前。所以我加了第二层——关键词倒排检索。我把图像描述块里的data_snippets和summary抽出来单独建立关键词索引用BM25算法做稀疏召回。然后通过RRFReciprocal Rank Fusion把向量召回和稀疏召回的结果做融合排序。这一套混合检索逻辑做下来模型本质上是在“先靠关键词粗定位再靠向量语义精排序”召回稳定性比纯向量检索明显提升。4.4 图像块与文字块重叠时的去重这个坑是我实际跑完才知道的。PyMuPDF解析出的图像块和文本块有时会重叠原因在于有些图表是由图片和说明文字共同组成的说明文字以文本块形式出现但图片的bbox包含了底部说明文字区域。如果不处理同一个图表信息会同时出现在“文本块A”和“图像描述块B”里检索时重复召回上下文膨胀一倍。我的去重规则是计算文本块与图像块的bbox交集面积占文本块面积的比例如果超过50%就把该文本块标记为“图像附属文本”不再作为独立chunk只整体拼到图像描述块里。这个阈值经过实测效果不错既能去重又不至于误杀同页相邻的独立段落。5. 问答链路重排、引用溯源与模型角色分工5.1 把 Top K 变成真正的“上下文”检索出Top K个chunk之后能不能直接塞给大模型我的经验是不能。因为向量召回是按相似度排序但部分chunk彼此重复或相互矛盾所以我在生成阶段前先做一次轻量重排把融合后的Top K列表按“是否包含用户query中的实体词”“是否来自用户指定页码”“chunk类型与实际问题的匹配度”做加权调整。这一步不引入大模型所以开销极低。重排完成后我按顺序拼接chunk内容并在每个chunk前面加上来源标记例如[来源第24页 图片区域描述]。这样大模型在生成答案时能看到来源信息回答的引用自然更加精准。5.2 引用溯源为什么答案要带上“第几页的哪块区域”在RAG场景里“答案依据来源”和“答案本身正确”几乎同等重要。特别是处理合同、报告、合规文档时用户不仅要知道结论还要能快速翻到原始出处。因为我保留了每个chunk的page_idx和bbox所以拼接提示词时能直接附带坐标信息。生成答案时我会要求模型在引用事实时附带对应的来源页码和区域描述。后处理阶段再把模型输出的来源标记解析成统一格式最终展示为类似“第24页 左上区域柱状图”的效果。这个功能让整个问答系统在可用性上了一个台阶同事反馈说“至少我知道它说的是哪张图”。5.3 把视觉模型从“实时问答”里解放出来很多类似方案会把Qwen-VL放在问答阶段做实时多模态推理用户问一个问题就触发一次图片识别成本高、延迟长。而我这套方案把Qwen-VL定位在离线预处理阶段问答时只用文本模型。这样做有三点好处成本可控批量处理的请求量固定不会随着用户问答次数线性增长。延迟稳定问答链路不需要传图片响应速度更快。检索一致所有图片在入库前都已经固化为描述文本后续无论谁提问检索对象都是同一套索引。如果遇到“看完图片直接问图片里的细节”这类强视觉问题离线描述可能满足不了我的建议是做一个带开关的增强模式当重排后发现相关性仍然偏低再临时调用一次视觉模型把原图重新送入并基于问题进行针对性抽取。这个开关按需开启能让系统的灵活性和成本取得平衡。6. 实测数据与踩坑清单6.1 在模拟项目上的效果对比我在一个350页左右的模拟项目文档集上做了对比测评。这个文档集包括扫描页、图表页、混合排版页在线问答里随机抽取了80个问题包含数据查询、趋势分析、条款定位三种类型。评测标准是“回答正确且能溯源到正确页面”记为通过。结果如下方案检索召回率回答通过率平均响应时间纯文本RAG61.3%52.5%2.8s文本RAG OCR68.8%60.0%3.1s本方案PyMuPDF Qwen-VL91.3%85.0%3.4s可以看到方案的主要收益在召回率上提升了30个百分点。这部分几乎全部来自图表描述块的贡献因为纯文本和OCR此前根本没机会“看到”那些图表。回答通过率略低于召回率因为部分召回片段足够相关但拼接后上下文信息不全模型只能答对一半。这个差距可以通过精调切片策略来弥补。6.2 实打实踩过的坑和应对方案我在搭建过程中把能踩的坑几乎踩了一遍列出几个印象最深的JSON解析失败率高Qwen-VL偶发输出多余文字解决方法是做一个“剥离围栏截取JSON片段失败重试”的三段式容错。页眉页脚混入正文PDF每个页面顶部和底部的页码、文档标题会成为高重复噪音。我在拆解时按坐标过滤凡是紧贴页面上边缘或下边缘且每个页面重复出现的文本块直接丢弃。图片导出重复同一张图片在多个页面被引用是可能发生的导出到本地时不能只按页码命名必须用xref编号加页码做唯一键。长文档的页码偏移直接用PDF物理页码下标从0开始容易和文档内部印刷页码混淆。我在元数据里同时保存物理页码和印刷页码如果PDF有文本层可以搜索书签或页码数字来对齐问答展示时优先用印刷页码。请求并发限流批量离线调用视觉模型时如果一口气开50个并发很容易触发限流。我的做法是限制并发数为4到6每批次间隔一点时间总量反而更快。6.3 可以继续扩展的空间这套流水线跑通后还能往几个方向继续深入。表格区域可以进一步用代码块表示法Markdown表格替换图片描述让包含公式和大量数值的表格检索更加精确。重排阶段可以引入一个跨编码器重排对检索出的chunk做二次语义打分准确率还能再提升几个点。如果未来有“以图搜图”的需求还可以把图像描述块的向量与视觉特征向量拼接形成真正的多模态索引。最后还有一点个人的体会方案的技术难度并不在模型和库本身而在版面结构的细节处理。PDF的复杂性永远比预想的多但只要把“元素坐标”和“视觉语义”这两样东西用好大部分问题都有解。希望这篇分享能给你一些启发少走几步弯路。