多模态RAG实战:PDF图文混合文档检索问答本地跑通

  1. 背景与问题
    本文范围说明:标题里的"图文混合文档"范围很宽,本文收窄到以 PDF 为主要载体的图文混排文档(技术报告、产品手册、财报截图、课件)的检索问答,不展开视频、音频模态。技术路线以页面级视觉检索为主线,同时对比解析式路线,给出应届生可复现的最小实现。

传统 RAG 的典型链路是:文档切块 → 文本 embedding → 向量库 → Top-K 文本块塞给 LLM。这条链路对纯文本有效,但遇到图文混排文档时会出问题:

表格的合并单元格被 OCR 打成碎片,行列关系丢失;

架构图、流程图里的箭头和层次,转成文字后语义大幅衰减;

扫描件 OCR 错误率一高,后续检索和生成全被污染。

我在学习过程中先跑过"unstructured 解析 + 文本 embedding"的版本,问答 demo 能跑,但一旦问"这张图里 Q3 到 Q4 的流向是什么",系统基本答偏。后来接触到页面级视觉检索思路:不强行把页面拆成文字,而是把整页渲染成图像,用视觉语言模型编码,检索时直接召回页面图像。这条路线牺牲了一些存储和推理成本,但对图文混排场景的保真度更高。

读完本文,你能获得:① 两条路线的选型判断依据;② 一套本地可跑的 ColQwen2 + Milvus Lite 检索代码;③ 4 个常见坑的排查思路;④ 面试中被追问多模态 RAG 时的结构化回答模板。

  1. 核心概念与环境说明
    2.1 两条路线,先分清
    维度 解析式路线 页面级视觉检索
    代表工具/模型 Unstructured、MinerU + 文本 embedding ColPali / ColQwen2
    是否依赖 OCR 依赖(或版面模型) 不依赖 OCR,整页编码
    检索单位 文本 chunk / 表格 chunk 页面(page)
    对表格/图示的保真度 取决于解析工具,复杂表格易损 保留原始视觉结构
    存储成本 低(单向量为主) 高(一页数百个 patch 向量)
    GPU 依赖 解析阶段可选,embedding 阶段低 编码阶段建议 GPU
    适合场景 文本为主、表格结构规整 图示、扫描件、复杂排版密集
    ColPali / ColQwen2 的核心思路是 ColBERT 风格的 late interaction:把每一页文档当图像切 patch,每个 patch 输出一个向量,查询也输出多个 token 向量,用 MaxSim 聚合页面得分。它绕开了 OCR 的信息损失,代价是向量记录数成倍增加------一篇 17 页的 PDF 就可能产生一万两千多条 patch 记录。所以选型的关键不是"哪个更先进",而是你的文档里图示信息占比有多高,以及你是否能接受 GPU 编码成本。

2.2 示例环境

以下为本地测试环境记录,实际以你的机器为准。

OS:Ubuntu 22.04 / Windows 11(WSL2)

Python 3.10+

关键依赖:

colpali-engine(ColQwen2)

pdf2image + poppler

pymilvus(使用 Milvus Lite,本地文件模式)

torch

生成模型:示例中用 OpenAI 兼容 API 调用一个多模态模型;你也可以换成 Qwen-VL 系列本地部署。为降低复现门槛,检索部分完全本地,生成部分只给出调用占位。

显存:ColQwen2 编码建议 8GB+ 显存;纯 CPU 能跑但很慢,适合先验证流程。

  1. 核心内容
    3.1 整体链路
    本文示例采用三阶段:

text

PDF → 页面渲染为图像 → ColQwen2 多向量编码 → Milvus Lite 存储/检索

↓

用户问题 → 查询编码 → MaxSim 页面打分 → Top-K 页面图像

↓

多模态 LLM 基于页面图像生成答案

和解析式 RAG 最大的区别在检索单位是页面,而不是 chunk。这样做的好处是:表格、图示、正文在同一个视觉上下文里被一起召回,生成模型看到的是原始页面,不是被拆碎的文字。

3.2 环境安装

bash

pip install colpali-engine pymilvus pdf2image torch pillow tqdm

poppler 是 pdf2image 的系统依赖

Ubuntu / Debian

sudo apt-get install poppler-utils

macOS

brew install poppler

Windows:下载 poppler 并加入 PATH,或使用 WSL2

ColQwen2 模型从 HuggingFace 拉取时,首次需要下载约 4GB 左右权重(以实际模型卡为准)。如果你的网络环境受限,可以先在可访问环境下载后拷贝到本地目录,代码中把模型路径换成本地路径。

3.3 Step 1:把 PDF 页面渲染成图像

python

from pdf2image import convert_from_path

def pdf_to_images(pdf_path: str, dpi: int = 150):

"""

将 PDF 每一页渲染为 PIL Image。

dpi=150 是本地测试用的折中值;

如果页面文字很小或图示细节多,可以提高到 200,但编码时间会增加。

"""

images = convert_from_path(pdf_path, dpi=dpi)

print(f"共渲染 {len(images)} 页")

return images

images = pdf_to_images("sample_report.pdf")

这一步不区分文本页和图片页,所有页面统一处理。对图文混排文档来说,这恰恰是页面级路线的优势:不需要先判断"哪一页是图、哪一页是文字"。

3.4 Step 2:ColQwen2 编码与入库

python

import torch

from colpali_engine.models import ColQwen2, ColQwen2Processor

from pymilvus import MilvusClient, DataType

EMBED_MODEL = "vidore/colqwen2-v1.0-merged" # 也可换成本地路径

EMBED_DIM = 128

MILVUS_URI = "./multimodal_demo.db"

COLLECTION = "doc_pages"

device = "cuda" if torch.cuda.is_available() else "cpu"

dtype = torch.bfloat16 if device == "cuda" else torch.float32

加载编码器

model = ColQwen2.from_pretrained(

EMBED_MODEL,

torch_dtype=dtype,

device_map=device,

).eval()

processor = ColQwen2Processor.from_pretrained(EMBED_MODEL)

初始化 Milvus Lite

client = MilvusClient(uri=MILVUS_URI)

if client.has_collection(COLLECTION):

client.drop_collection(COLLECTION)

schema = client.create_schema(auto_id=True, enable_dynamic_field=True)

schema.add_field("id", DataType.INT64, is_primary=True)

schema.add_field("page_id", DataType.INT64)

schema.add_field("patch_idx", DataType.INT64)

schema.add_field("vector", DataType.FLOAT_VECTOR, dim=EMBED_DIM)

client.create_collection(COLLECTION, schema=schema)

逐页编码并写入

page_embeddings = \[\]

with torch.no_grad():

for i, img in enumerate(images):

batch = img

inputs = processor.process_images(batch).to(model.device)

emb = model(**inputs)0.cpu().float().numpy()

page_embeddings.append(emb)

print(f"page {i}: {emb.shape0} patches, dim={emb.shape1}")

写入 Milvus:每个 patch 一行

for page_id, patch_vecs in enumerate(page_embeddings):

rows = [

{"page_id": page_id, "patch_idx": j, "vector": v.tolist()}

for j, v in enumerate(patch_vecs)

]

client.insert(COLLECTION, rows)

print("入库完成")

关键解释:

ColQwen2Processor.process_images 负责把 PIL 图像转成模型输入,不需要你手动 OCR 或切块。

每页输出的是多个 patch 向量,不是单个向量,所以 Milvus schema 里每一行对应一个 patch,用 page_id 聚合。

Milvus Lite 以本地文件运行,零服务配置,适合应届生做本地示例项目。

3.5 Step 3:查询编码与 MaxSim 检索

python

def retrieve(question: str, top_k: int = 3):

1. 编码查询

with torch.no_grad():

q_inputs = processor.process_queries(question).to(model.device)

q_vecs = model(**q_inputs)0.cpu().float().numpy()

复制代码
# 2. 每个 query token 向量在 Milvus 中搜 patch
page_patch_scores = {}
for qv in q_vecs:
    hits = client.search(
        COLLECTION,
        data=[qv.tolist()],
        limit=300,
        output_fields=["page_id", "patch_idx"],
        search_params={"metric_type": "IP"},
    )[0]
    for h in hits:
        pid = h["entity"]["page_id"]
        patch_idx = h["entity"]["patch_idx"]
        score = h["distance"]
        page_patch_scores.setdefault(pid, {})
        # MaxSim:同一 patch 保留最高分
        page_patch_scores[pid][patch_idx] = max(
            page_patch_scores[pid].get(patch_idx, 0), score
        )

# 3. 页面得分 = 该页所有命中 patch 分数之和
page_scores = {
    pid: sum(ps.values()) for pid, ps in page_patch_scores.items()
}
ranked = sorted(page_scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
return ranked

question = "图中 Q3 到 Q4 的用户增长趋势是什么?"

results = retrieve(question)

print(results)

预期现象:你会看到类似 (3, 148.2), (7, 121.5), (1, 96.8) 的输出,数字是页面聚合得分,数字越大表示该页与查询的视觉语义越相关。具体分数以你的文档和模型版本为准,这里不编造固定的准确率数字。

拿到的 page_id 对应之前渲染的 imagespage_id,可以直接把这几页图像送入多模态 LLM 生成答案。生成部分的 prompt 大致如下(伪代码级,按你使用的模型 API 调整):

python

简化示例:把 Top-K 页面图像和问题一起交给多模态模型

实际调用时需按你的 API 做 base64 编码或文件上传

prompt = f"""仅根据以下页面图像回答问题。如果图像中没有足够信息,请直接说"页面中未体现"。

问题:{question}

"""

response = multimodal_llm(prompt, images=images\[pid for pid, _ in results])

3.6 值得收藏:两条路线速查清单

如果你的情况是... 优先尝试

文档以纯文本为主,只有少量截图 解析式路线(Unstructured / MinerU)+ 文本 embedding

文档中表格、架构图、扫描页占比高 页面级视觉检索(ColPali / ColQwen2)

本地无 GPU,或只有 CPU 环境 解析式路线更容易起步

能接受编码阶段 GPU 成本,追求图示保真 页面级路线

需要像素级引用、bbox 溯源 RAGFlow DeepDoc 等带版面定位的引擎,或解析式 + 坐标保留

想做混合召回(BM25 + 向量 + rerank) 解析式路线更容易叠加;页面级路线需要额外设计 rerank 层

  1. 常见问题与排错

坑 1:pdf2image 报错 Unable to get page count

现象:运行渲染代码时抛出异常,提示 poppler 相关。

原因:pdf2image 本身不解析 PDF,它调用系统 poppler。

解决:按 3.2 安装 poppler-utils,Windows 需要下载 poppler 并把 bin 目录加入 PATH。验证命令:pdftoppm -v。

坑 2:CPU 编码极慢,以为代码写错了

现象:一页编码几十秒甚至几分钟,GPU 上只要几秒。

原因:ColQwen2 是视觉语言模型,CPU 推理本身就慢,不是代码 bug。

解决:本地验证流程可先用 2--3 页 PDF 跑通链路,再换 GPU 环境做全量索引。不要把 CPU 耗时当作模型效果问题。

坑 3:Milvus 检索返回的页面很散,Top-1 经常不对

现象:问"表格里的某个数字",召回页面却是文字页。

原因:MaxSim 聚合对"查询 token 数量多、页面 patch 数量多"的场景敏感;limit 设得太小会导致候选 patch 不足。

解决:适当提高单次 search 的 limit(示例中为 300),并检查 dpi 是否过低导致 patch 看不清细节。另外,页面级路线并不天然擅长精确数字匹配,涉及型号、年份、金额的查询可以叠加 BM25 或关键词过滤作为补充召回。

坑 4:把"检索到页面"当成"答案正确"

现象:检索 Top-3 里确实有正确页,但 LLM 回答仍然不对。

原因:检索和生成是两个环节,页面里有信息不等于生成模型读对了表格结构或图示关系。

解决:分开评估。先人工看 Top-K 页面是否命中,再检查生成 prompt 是否要求"仅依据页面图像"。面试中这一点也常被追问:OCR 或视觉编码解决的是"找得到",生成阶段解决的是"看得懂",两者不能混为一谈。

  1. 优化建议与适用边界
    5.1 可优化的方向
    混合召回:页面级向量召回 + BM25 关键词召回,用 RRF 融合。对"型号、年份、数值"类查询,关键词召回能补上视觉检索的短板。

多模态 rerank:粗排后用 Qwen3-VL-Reranker 这类交叉注意力模型对候选页面精排,进一步提升 Top-K 精度。

解析 + 页面双路并存:纯文本页走解析式 chunk,图示/表格页走页面级索引,查询时按问题类型路由。这是更接近生产级的设计,但对个人项目来说复杂度较高。

图像预处理:对扫描件先做去噪、纠偏;dpi 根据文档版式调整。页面级路线对图像质量敏感,低质量扫描会直接影响 patch 语义。

5.2 适用边界

不适合:纯文本为主、对延迟和存储极敏感的场景。页面级路线的向量记录数量远高于文本 chunk,全量索引成本需要提前评估。

不擅长:需要精确匹配数字、型号、专有名词的查询,建议叠关键词检索。

生成模型依赖:检索端只能保证"页面被召回",最终答案质量取决于多模态 LLM 能否正确阅读表格和图示。不要用检索指标代替端到端回答质量的评估。

  1. 求职与面试关联
    "多模态 RAG 和传统 RAG 的核心区别是什么?"
    回答思路:不是"加了一个图像分支",而是检索单位、编码方式、信息保真度三个层面都变了。传统 RAG 检索文本 chunk;多模态 RAG 可以把页面当图像编码,保留表格和图示的视觉结构。

"OCR 已经能把图片文字抽出来,为什么还需要多模态检索?"

回答思路:OCR 解决"识别文字",不解决"理解关系"。流程图里的箭头、表格的合并单元格、架构图的层次,OCR 文本流很难完整保留。页面级视觉检索让模型看到原始版面,而不是被拆碎的文字。

"ColPali / ColQwen2 的 late interaction 是什么?"

回答思路:每个页面输出多个 patch 向量,查询输出多个 token 向量,用 MaxSim 对每个查询 token 取最相似 patch,再聚合页面得分。好处是保留细粒度视觉特征,代价是存储和计算成本高。

"解析式路线和页面级路线怎么选?"

回答思路:看文档构成和资源约束。纯文本为主、无 GPU → 解析式;图示/扫描件密集、有 GPU → 页面级。生产环境可以考虑混合。

"多模态 RAG 的评估怎么做?"

回答思路:分开评估检索和生成。检索看页面命中率 / Recall@K;生成看 faithfulness 和 answer relevance。RAGAS 已提供多模态相关指标,但注意文本评估指标不能完全覆盖"图像描述是否忠于原图"的盲区。

"你这个示例项目的局限是什么?"

回答思路(诚实版):只在本地小规模 PDF 上验证了链路,没有做大规模性能压测,没有接入生产级鉴权和并发,生成部分用的是 API 占位。适合展示对链路的理解,不适合宣称"生产可用"。

总结

图文混合文档的检索问答,先判断文档里图示/表格/扫描信息占比,再选解析式还是页面级路线;不要默认"上多模态模型就是对的"。

页面级视觉检索的核心是 patch 多向量 + MaxSim,代码链路不长,但存储成本是文本 chunk 的很多倍,本地示例要控制页数。

复现顺序建议:先 2 页 PDF 跑通渲染 → 编码 → 入库 → 检索,再加页数和生成模型,不要在环境没跑通时就调参。

面试中最容易拉开差距的不是"我用了 ColQwen2",而是你能说清 OCR 和视觉编码各自解决什么问题、局限在哪、评估怎么分开做。

示例项目要诚实标注边界:本地验证、小规模、API 占位,这些不丢人,夸大才减分。

相关推荐
ctlover2 小时前
langchain 中间件
中间件·langchain·1024程序员节
正弦波_Wave4 小时前
【Orcad原理图打印Pdf】
笔记·pdf
liudachu4 小时前
Agent 开发常见名词说明与类比
python·langchain
开开心心就好5 小时前
电脑小说阅读器,支持朗读和自定义皮肤
服务器·开发语言·javascript·算法·pdf·c#·音视频
万化冥合7 小时前
怎么批量把 Word 转 PDF?
python·pdf·c#·word
染指11107 小时前
157.Agent-LangGraph-工具(实现ReAct Agent模式)
开发语言·人工智能·python·langchain·agents
开开心心就好7 小时前
证件照排版打印工具,免费可自定义尺寸
服务器·开发语言·算法·随机森林·pdf·音视频·散列表
深蓝冰河21 小时前
全脑记忆法创始人的2套快速记忆法pdf 书下载地址
pdf
Maiko Star1 天前
*【HM-LC】LangChain MCP详解:核心概念、外部服务连接与自定义服务
langchain·mcp