1. 为什么传统 PDF RAG 一到扫描件就“断片”
1.1 文本提取式的 RAG 有多脆弱
我最早做 PDF 知识库问答时,思路非常简单粗暴:用解析库把 PDF 里的文字抠出来,按段落切块,然后塞进向量库,查询时做相似度召回。这个方案跑纯文本合同、论文摘要、政府公开文件都没问题,但一旦遇到扫描版手册、带截图的培训资料、图文混排的投标文件,效果就急转直下。
问题出在三个环节。第一,很多 PDF 根本没有文本层,整页就是一张图片,传统解析库提取出来的是一堆空字符串;第二,即便有文本层,图表里的坐标轴标签、柱状图数值、流程图箭头旁的文字,这些信息往往以矢量图形或嵌入图片的形式存在,get_text根本够不到;第三,就算文本都提取出来了,如果丢掉“这段文字属于哪张图”“这张表格旁边是哪段说明”这种布局关系,检索到的文字块往往是孤立的,用户问“图里的最大值是多少”,系统只能答非所问。
这也是我后来坚持做“图文兼容”方案的原因。RAG 的核心目标是让大模型拿到足够的证据来回答问题,证据如果天生就是残缺的,后面检索和生成做得再漂亮也是空中楼阁。
1.2 图文兼容到底难在哪三个环节
我要做的并不是简单地“文本提取 + OCR”,而是一套能从 PDF 里同时抽出文字和图片,并让两者保持关联的管线。拆开来看,难点集中在三处。
第一个难点是图片定位。PDF 里的图片不是像网页那样乖乖待在一个矩形区域里的,它可能被旋转、裁剪、嵌入在某个容器对象里,还可能有多个引用指向同一份 XObject。你需要拿到每张图在页面上的物理坐标(bbox),才能知道它和周边文本的位置关系。
第二个难点是内容理解。扫描件里的表格、架构图、流程图,如果只用传统 OCR 去识别文字,遇到多行表头、跨列合并单元格、图片上叠文本框这类场景就非常容易出错。这个环节更适合交给视觉语言模型(VLM)来做——它不仅能识别文字,还能理解图表的语义结构。
第三个难点是检索链路怎么接。图片本身不能直接丢进向量库,你需要把图片的视觉特征“翻译”成文本描述,再和普通文本块统一建索引。同时,用户问一个问题,召回结果里可能既有文本块又有图片描述块,你需要有一套机制把相关图片和文字拼在一起送给模型,并且标注清楚来源页码和坐标,方便溯源。
我的最终选型是 PyMuPDF 做底层解析,Qwen-VL 做图像内容理解。PyMuPDF 负责把 PDF 拆成“文本块 + 图片块 + 坐标关系”,Qwen-VL 负责把图片变成结构化描述,两者合并后走常规的向量检索流程。下面我把完整方案的每一步细节和踩过的坑都写出来。
2. 方案总览:PyMuPDF 负责拆,Qwen-VL 负责看
2.1 在整套链路里,PyMuPDF 到底承担了什么
很多文章喜欢一上来就给架构图,我反过来,先说分工。整套 PDF RAG 链路分成四段:文档解析、图像理解、索引建立、检索问答。PyMuPDF 只负责第一段里的文本和图片抽取,Qwen-VL 负责第二段的理解,后面两段是标准 RAG 流程。
具体到我实现的代码,PyMuPDF 这块的核心任务是三个:打开 PDF,遍历每一页,对每一页执行两遍扫描。第一遍用page.get_text("dict")拿到文本块的列表,每个文本块都带坐标、字号、字体信息;第二遍用page.get_images(full=True)拿到该页引用的所有图片 XObject 列表,再通过doc.extract_image(xref)把图片二进制数据取出来。
拿到这两份数据之后,再做一次空间关系匹配。规则不复杂:遍历每个图片的 bbox,看它和哪些文本块的距离小于阈值,就把这些文本块标记为“该图片的关联说明文字”。比如一个架构图下面通常紧跟一段“系统由四层组成”的描述,这段描述就会和架构图绑定。绑定关系在后续构建索引时非常关键。
值得提醒的是,PyMuPDF 的page.get_text("dict")和page.get_text("text")返回的内容差异很大。后者是纯字符串,方便阅读但丢掉了坐标;前者是嵌套字典,保留每个 span 的原始信息,虽然解析代码多写几行,但后续做布局分析时是必须的。
2.2 为什么是 PyMuPDF,而不是 PDFPlumber 或 pdfminer.six
我在选型时其实先把 PDFPlumber 和 pdfminer.six 都试了一遍,最后才锁定 PyMuPDF。先说结论:不是别的库不好,是它们在不适合这个场景。
pdfminer.six 的文本提取精度不错,但它对图片 XObject 的处理能力很弱,要拿到“图片在页面上的坐标位置”需要绕不少弯路,而且它没有直接的 API 能把某页的图片渲染成 Pixmap,最后你得自己调底层PDFDevice接口,代码量会明显膨胀。
PDFPlumber 的page.objects里确实能拿到图片对象,page.images也能给出图片的 x0、y0、x1、y1 坐标,这点我很喜欢。但它在解析复杂页面时性能偏差,几百页的彩色手册跑下来,速度能差出三五倍。而且对于某些带旋转标记的图片,PDFPlumber 给出的坐标是未修正的,你需要额外做矩阵换算。
PyMuPDF 的优势在于它直接基于 MuPDF 解析引擎,性能和健壮性都很稳。Pixmap对象可以一步把 PDF 页面、指定区域甚至某个 XObject 渲染成像素图,这为后续喂给 Qwen-VL 提供了极大的便利。坐标系统也是完整的,文档里写的Rect对象自带旋转修正逻辑,基本不用我自己做坐标变换。
选型对比表,我直接把我当时的实测数据贴出来方便参考(测试环境为同一台机器,处理一份 260 页的图文混排 PDF):
| 库 | 文本提取耗时 | 图片坐标获取 | 图片渲染能力 | 复杂页面健壮性 |
|---|---|---|---|---|
| pdfminer.six | 32.4s | 弱,需自行解析图像对象 | 无直接渲染接口 | 一般 |
| PDFPlumber | 44.8s | 支持,坐标需处理旋转 | 无直接渲染接口 | 中上 |
| PyMuPDF | 8.1s | 完整 Rect 坐标 | 内置 Pixmap 渲染 | 好 |
2.3 Qwen-VL 在流程里的精确位置
PyMuPDF 把 PDF 拆完以后,图片流并不直接进检索。原因很简单:纯图片提不了向量,就算提了向量,模型拿到的也只是图片的嵌入表示,很难直接用于生成式问答。所以我的做法是让 Qwen-VL 把每张图片“翻译”成一段结构化的文字描述。
Qwen-VL 在这个方案里不是可选项,而是核心理解引擎。我试过用传统 OCR 工具配合规则解析来处理图表,碰到图表元素重叠、文字与背景色相近的情况基本就废了。Qwen-VL 这类视觉语言模型把“看”和“理解”合并成了一个过程,它可以描述图的类型、结构、关键数值的所在位置,还能把柱状图里最高的那根柱子对应的数值直接读出来,这在传统 OCR 流程里要写一堆启发式规则才能勉强做到。
为了控制成本,我并没有把整页 PDF 直接丢给模型,而是先用 PyMuPDF 把图片区域裁剪出来,必要时做一些放大和清理处理,再单独调用 Qwen-VL。页面里的小图标、装饰性元素会先在本地过滤掉,只有面积占比超过阈值或者正文中明确引用的图片,才会进入模型调用队列。
后面章节我会把这套流程每个环节的代码逐一拆开讲解,包括 PyMuPDF 的精准裁剪、Qwen-VL 的提示词设计、以及 RAG 链路的混合检索与重排策略。
3. PyMuPDF 实操:如何把 PDF 精准拆成文本块和图片
3.1 文本块提取与坐标信息保留
我用 PyMuPDF 提取文本块时,第一个动作是打开文档并逐页调用get_text("dict")。这个方法返回的字典里有一个blocks列表,每个 block 是一个文本块或图片块的描述。文本块的type为 0,图片块的type为 1。拿到 block 后,我还要继续往下钻一层到lines和spans,因为真正带有字体信息的对象在 span 级别。
关键参数是bbox,它表示该文本块在页面上的边界矩形,后面做图文关联全靠它。另一个关键参数是size和flags,分别表示字号和字体样式位掩码,我用它来判断某个文本块是不是标题或小字注释。判断标题这件事,在分块策略里很有用,但在这个阶段我更多的是把它作为一个可选的辅助特征保留下来,不参与主要逻辑。
import fitz doc = fitz.open("sample_doc.pdf") page = doc[0] data = page.get_text("dict") for block in data["blocks"]: if block["type"] != 0: continue # 只处理文本块 bbox = block["bbox"] text = "" for line in block["lines"]: for span in line["spans"]: text += span["text"] print(bbox, repr(text[:50]))这里有个细节值得强调:get_text("dict")返回的文本顺序是文档结构顺序,不是阅读顺序。遇到多栏排版时,可能出现左右两栏文本交错排列的情况。如果要严格按视觉阅读顺序抽取,需要靠坐标排序,我会把bbox的 x 和 y 组合排序。实际项目里,大部分业务文档都是单栏或双栏,双栏的我可以按“栏优先”规则修正,这个规则我在踩坑章节再细说。
3.2 图片区域抽取与格式转换
图片这块是 PyMuPDF 的重点戏。page.get_images(full=True)返回的是该页面引用的所有图片信息列表,每一项包含xref、width、height等元数据。拿到xref之后,用doc.extract_image(xref)就能取到图片的二进制字节和扩展名。
比较隐蔽的坑是,同一张图片可能会被多个页面引用,get_images会把每个引用都列出来,但它们的xref是同一个。你在做去重的时候要基于xref,不要基于图片二进制内容。另外一个问题是图片可能被旋转过,extract_image拿到的原始字节是旋转前的图像,直接展示会出现方向错乱。
doc = fitz.open("sample_doc.pdf") page = doc[0] for img in page.get_images(full=True): xref = img[0] base_image = doc.extract_image(xref) image_bytes = base_image["image"] ext = base_image["ext"] width, height = img[2], img[3] # 后续可写入临时文件或直接传给 PIL / Qwen-VL大部分情况下,直接拿原始图片字节就有足够信息,但有些 PDF 会把图片包在某个奇异色彩空间里,extract_image拿出来的数据在普通图片查看器里无法显示。这种情况我用 PyMuPDF 的Pixmap做一次强制渲染:
pix = fitz.Pixmap(doc, xref) if pix.colorspace and pix.colorspace.n > 3: pix = fitz.Pixmap(fitz.csRGB, pix) png_bytes = pix.tobytes("png")Pixmap是 PyMuPDF 提供的最强渲染工具。它不仅能渲染 XObject,还能按任意Rect渲染页面区域,这一点在第 4 章的图像预处理里会用到。我最终统一把图片转成 PNG 或 JPEG,再喂给 Qwen-VL。
3.3 文本块与图片的布局关系处理
拿到页面上的文本块列表和图片块列表后,我的下一步是对两者做空间匹配,目的是确定“这张图和哪段文字是描述关系”。
匹配算法我用的是一种简化的最近邻规则,逻辑如下:
- 对于每张图片,先找出所有与其
bbox存在垂直重叠或水平相邻的文本块,重叠或相邻的判定阈值是 15 个像素。 - 在候选文本块里,选取垂直距离最近的那个作为“主关联文本”。
- 图片下方或上方 100 像素以内的其他文本块,作为“辅助关联文本”一起绑定进元数据。
这个规则覆盖了绝大多数图文混排场景。比如产品手册中,一张产品渲染图旁边有“尺寸:1200×800mm”字样,这张图和这段字就会被绑定到一起。原理层面,PDF 的阅读顺序是自上而下的,图片和它的说明文字在物理位置上天然邻近,所以基于坐标的邻近关系推断是合理且高效的。
def match_text_to_image(img_bbox, text_blocks, gap=15): nearby = [] for tb in text_blocks: tb_bbox = tb["bbox"] # 中心点距离 img_cx = (img_bbox[0] + img_bbox[2]) / 2 img_cy = (img_bbox[1] + img_bbox[3]) / 2 tb_cx = (tb_bbox[0] + tb_bbox[2]) / 2 tb_cy = (tb_bbox[1] + tb_bbox[3]) / 2 if abs(img_cx - tb_cx) < gap or abs(img_cy - tb_cy) < gap * 3: nearby.append(tb) return nearby这一步产出的“图文绑定关系”后面会直接写进索引文档,问答时才能做到“图片和文字互相印证”。这是纯文本 RAG 永远做不到的点。
4. Qwen-VL 接入:从“看图说话”到“结构化摘录”
4.1 调用前的图像预处理,我做了哪些操作
Qwen-VL 虽然能直接理解图片,但实际调用前我还是做了三步预处理,否则效果和成本都会失控。
第一步是尺寸归一化。PyMuPDF 拿出来的图片原始尺寸可能极大,比如一张 3000×2000 的高清扫描图,全部塞进模型不仅浪费 token,有时反而让模型看不清局部细节。我会用 PIL 把图片最长边缩放到 1280 像素以内。
第二步是过滤小图。面积小于 32×32 像素的图片基本都是装饰性图标,直接跳过,不送进模型。这个阈值来自我对某企业内部文档库的实测统计,低于这个尺寸的图片几乎没有独立语义价值。
第三步是放大截图。有些图片中的文字特别小,整体缩放到 1280 像素后局部文字会糊掉。我的做法是用page.get_text("dict")先定位图片区域内的文本密度,如果密度很高,就把图片按 2 倍分辨率截取并送给模型。实际操作中,对扫描合同这种场景,放大一档后识别效果提升非常明显。
from PIL import Image import io def preprocess_image(img_bytes): img = Image.open(io.BytesIO(img_bytes)).convert("RGB") w, h = img.size max_side = 1280 if max(w, h) > max_side: ratio = max_side / max(w, h) img = img.resize((int(w * ratio), int(h * ratio))) return img预处理不是给模型“减负”,而是帮助模型把注意力集中到有效信息上,这点很多人会忽略。
4.2 提示词设计:我用一段固定模板同时拿到描述和结构化字段
Qwen-VL 的调用方式是标准的 chat 接口,支持多模态消息格式。我构造的消息体是content数组里同时放text和image_url两个类型的元素。如果你的代码依赖较老的接口,可能还要留意兼容性,但我用的这套messages格式在近几个版本里都稳定可用。
from openai import OpenAI client = OpenAI( api_key="your_api_key", base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", ) prompt = ( "请仔细分析这张图片,输出以下字段:\n" "1. 图片类型(表格/流程图/架构图/产品图/照片/截图/其他)\n" "2. 图片内出现的核心文字(尽量完整提取,不要遗漏)\n" "3. 图片表达的核心内容或结论(用一两句话概括)\n" "4. 如果图片中有数据或数字,请列出所有关键数值并说明其含义\n" "5. 图片与周围正文可能是什么关系(说明图/数据支撑/示意图)\n" "请用中文回答,直接给出字段内容,不要额外解释。" ) response = client.chat.completions.create( model="qwen-vl-plus", messages=[{ "role": "user", "content": [ {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}, {"type": "text", "text": prompt}, ], }], ) result = response.choices[0].message.content这个提示词的核心思路是把图片内容切成“结构化摘录”而不是“自由描述”。如果只是问“这张图里有什么”,模型会给出自然语言段落,后续做检索时相关度并不好用。固定了五个字段之后,模型每次输出的格式都比较规整,我能直接把第四项“关键数值”单独提取出来,供后续的精确匹配使用。
实测下来,qwen-vl-plus的性价比最合适,复杂业务图表的理解准确率已经达到可接受水平;如果对成本不太敏感并且文档质量参差,可以考虑升级到更强的版本,但响应延迟会成倍增加。
4.3 Qwen-VL 输出怎么进入检索链路
Qwen-VL 给出来的结构化描述是一段自然语言,我把它拼接到该图片关联的文本块之后,形成一个完整的“图文合并文档块”。举个例子,某页有一张“系统架构图”,下方紧跟着一段“系统分为接入层、服务层、数据层”的文字,那么这一条索引内容就是:
[图片描述] 图片类型:架构图 核心文字:接入层、服务层、数据层、API网关、数据库 核心内容:系统采用三层架构,用户请求经API网关进入服务层,服务层调用数据层完成数据读写。 关键数值:无 [关联文本] 系统分为接入层、服务层、数据层……(原 PDF 正文)这样处理的好处很明显:用户问“网关放在哪一层”时,无论关键词命中图片描述里的“API网关”,还是命中正文里的“接入层”,都能召回同一个文档块,模型拿到这个块后可以同时参考图文两侧的信息作答。这个“图转文再拼接”的设计,是整个方案不改变原有检索架构就能实现多模态理解的关键。
5. 检索增强链路:分块、向量化与召回策略
5.1 混合分块策略:为什么不能只按固定长度切
纯文本 RAG 里最常见的是按 200 到 500 个字符固定切块,简单省事,但在这个方案里完全行不通。原因很简单:图文合并之后的文档块大小参差不齐,图片描述可能只有 50 字,绑定文本却有三四百字,一刀切下去很容把图片和它的绑定文本切断。
我使用的分块逻辑是“语义优先、长度兜底”。先遍历 PyMuPDF 输出的原始段落,把它们按标题、段落间距、上下文切换点切成语义片段;每个片段里如果包含图片引用,就把图片描述块作为引文插进去,不单独切离。如果单个语义片段超过 600 个 token,再用滑动窗口从标题或列表项处二次切分。
这个策略有点类似很多文档库的“父子块”做法:精切出来的小块用于精确召回,包含它的大块上下纹用于给模型补充上下文。我在实践里验证过,混合分块的检索命中率明显高于纯固定长度切块,尤其是在夹杂大量图表的 PDF 上,提升幅度能到 20 到 30 个百分点。
def split_document(page_blocks, max_tokens=600): chunks = [] current = [] current_len = 0 for block in page_blocks: block_tokens = len(block["text"]) / 1.5 # 中文粗略按 1 token/1.5 字 if current_len + block_tokens > max_tokens: chunks.append("".join(current)) current = [block["text"]] current_len = block_tokens else: current.append(block["text"]) current_len += block_tokens return chunks需要注意的是,这个 token 估算很粗,真实环境里建议直接用分词器或模型 tokenizer 做精确计算,否则索引和召回结果会偏。
5.2 向量化与混合召回:字面匹配 + 语义匹配一起上
向量化这一层我选择的是通用文本表示模型,比如 BGE 系列或者商业 embedding 接口。图文合并块的文本内容经过向量化之后,和普通文本块一样进入了同一个向量空间,这是整个架构最优雅的地方——它不存在独立的“图片检索通道”,所有召回都走同一套向量索引。
但只做向量召回不够。用户的问题往往带有精确数值、型号、地址这类实词,纯语义向量在这些词上可能召回不准。我于是在向量召回之外补了一条 BM25 字面匹配路线,两条路线的结果做加权融合。举个例子,用户问“最大功率 1500W 的型号有哪些”,BM25 对“1500W”的精确匹配几乎是秒中,而向量召回能发现“高功率版本”这类同义改写和“功率等级”等上下文相关的描述,两者互补才能保证召回率。
融合之后,我还会加一步重排序。用重排模型对候选集按相关性二次打分,把前 5 到 10 条作为最终上下文。这一步的意义在于,向量相似度和检索目标之间并不是完全对齐的,重排模型直接学习“针对问题的相关程度”,能更好地区分关键证据和无关干扰项。
| 召回方式 | 优势 | 劣势 |
|---|---|---|
| 向量召回 | 理解语义、同义改写 | 对精确数值/编号不敏感 |
| BM25 | 精确词命中、速度快 | 无法处理同义词、错别字 |
| 向量+BM25融合 | 兼顾语义和精确匹配 | 需调融合权重 |
| 重排 | 从候选里挑最优证据 | 增加一次模型调用开销 |
5.3 图文映射、答案溯源与页码坐标的传递
RAG 问答系统最容易被忽略的是溯源能力。普通文本 RAG 至少还能给出“第 3 章第 2 节”,图文方案里如果召回的是图片描述块,你却不知道这张图原在哪一页、哪个位置,那用户根本没法去原文核对。
把这一步做扎实,靠的是 PyMuPDF 在解析阶段保留的坐标。我在构造索引文档的时候,给每个图文合并块都附加了一个元数据头,里面记录页码、图片 xref、图片坐标 bbox、绑定文本块 bbox。检索结果输出时,我会完整暴露这些字段,前端展示的时候可以直接把答案定位到 PDF 的具体页面上,甚至用坐标高亮对应的图片区域。
{ "chunk_id": "doc_001_p12_img03", "page": 12, "img_bbox": [120.0, 245.0, 312.0, 412.0], "text_bbox": [120.0, 420.0, 312.0, 450.0], "content": "..." }这个设计在实际用户调研里非常受欢迎。企业用户看到回答时能一键跳到原文 PDF 的对应图区,信任度完全不一样。
6. 实测效果、召回率对比与五个值得记住的坑
6.1 我搭的测试集:50 份图文混排文档和 200 道题
为了验证这个方案的工程可行性,我用从某企业文档库整理出的 50 份图文混排 PDF 建了一个评测集,涵盖产品手册、投标文件、培训 PPT 导出件和扫描合同四类,共计 200 道问答对。
评测指标分两档:第一档是“检索命中率”,看标准答案所在的块是否出现在召回的前 10 个块里;第二档是“端到端正确率”,让模型基于召回的上下文回答问题,人工判断回答是否准确、是否漏掉关键数值。对照实验则是同一批文档跑传统纯文本提取 RAG。
结果差距非常明显。纯文本方案在扫描合同和产品手册上的检索命中率只有 54% 和 61%,因为大量内容根本不在文本层里;而图文方案在全部四类文档上的检索命中率均超过 87%,端到端正确率从原来的 40% 上下提升到了 74% 左右。提升最夸张的是含大量流程图的培训文档,纯文本方案基本全军覆没,图文方案能做到 89% 的命中率。
这个结果基本说明,只要你的 PDF 里有一定比例的视觉信息,图文兼容方案就是值得投入的。
6.2 实战里踩过的坑:双栏、重复图片与加密扫描件
第一个坑是双栏文档的阅读顺序。某次索引时,我按get_text("dict")的顺序拼接文本,结果同一个段落的文字被左右两栏打乱穿插,检索出来的块语义完全不对。后来我加了 y 轴分栏判断:如果一页上左右两侧各有一列文字,就按 x 坐标分成两个序列分别拼接,问题迎刃而解。
第二个坑是重复图片的百科全书式污染。前面的章节提过,同一张 logo 可能在 50 页里反复出现,如果不做去重,向量库里会出现 50 份几乎一模一样的图片描述块,检索时它们会霸占召回列表。我的去重策略是记录每个 xref 第一出现的页码,后续页面只保存引用信息,不重复生成图片描述。
第三个坑是加密扫描件。很多客户给的 PDF 带着文档打开密码或权限密码,PyMuPDF 打开时会报错或只能读到空白页。处理方式是在解析之前先做一轮密码检测,用常见的空密码和简单弱口令尝试解锁,解锁不了的再让用户显式提供密码。这个过程要展示在日志里,不然用户只会困惑“为什么这一批文档全是空的”。
第四个坑是模型调用频率限制。几百页的 PDF 里可能提取出几十上百张有效图片,同步逐张调用 Qwen-VL 会非常慢,而且很容易触发限流。我后来改成了线程池并发调用,并把每张图片都先写入本地缓存,这样即使中途挂了,重跑一遍也不用重新花模型费。
第五个坑是幻觉数据混入。Qwen-VL 描述图片时,偶尔会给图片“脑补”出不存在的文字,比如把流程图里的说明文字误读成另一个词。这个问题无法完全避免,但可以通过提示词里加一句“如果没有看到确切文字,请输出未见对应文字”,显著减少编造概率。
6.3 部署上线阶段的工程化建议
方案跑通离线评测之后,上线前还有几个工程化细节值得分享。
一是把“PyMuPDF 解析 + Qwen-VL 描述”的离线增量任务做成独立的异步流水线。文档上传后先进入 MQ 队列,后台 worker 逐个解析和调用模型,解析结果写入 SQLite 或数据库。用户的第一个问题可能出现在几分钟后,所以要保证任务具备断点续跑能力和失败重试机制。
二是对 Qwen-VL 的输出做一次轻量级的质量过滤。我写了一个规则集,检查描述文本里是否包含图片类型、是否超过 20 个汉字、是否包含无意义字符。不符合规则的块会标记为“低置信度”,检索时降到队尾,避免劣质块污染问答质量。
三是索引数据要定期重建。PDF 可能存在版本更新,旧文档被替换后,如果索引里还残留旧版本的图片描述块,用户会搜到过时信息。我设计了一个简单的比对逻辑,用文档哈希值判断是否需要重新进流水线,更新后再删除旧索引数据。
四是给前端加一层坐标定位展示。技术上非常容易实现,PyMuPDF 渲染的页面图片,配合索引元数据里的 bbox,就能在回答下面画出一个高亮框。这一步的体验提升远超预期,推荐给所有做知识库产品的团队。
五是有条件的场景,建议把 Qwen-VL 的调用结果和人工标注做一次对齐评估,特别是在上线初期,挑选 100 到 200 张真实图片人工审核描述质量。模型的一部分“看图”错误是系统性的,比如它对篡改型柱状图的数值判断容易出错,这类问题单靠调整提示词很难根治,只能通过人工审核发现后,再针对性增加后处理规则。
这个方案的核心价值是把多模态模型塞进 RAG 而不用改变整体检索架构,用 PyMuPDF 精确拆解 PDF 布局,用 Qwen-VL 把视觉信息翻译成文本,本质上还是全部走文本检索的老路,但信息覆盖率完全不同。它解决的是我一开始提到的那个痛点:不是所有问题都藏在文本层里,很多答案就在图表里,只是以前没人去解析它们。