- 背景与问题
本文范围说明:标题里的"图文混合文档"范围很宽,本文收窄到以 PDF 为主要载体的图文混排文档(技术报告、产品手册、财报截图、课件)的检索问答,不展开视频、音频模态。技术路线以页面级视觉检索为主线,同时对比解析式路线,给出应届生可复现的最小实现。
传统 RAG 的典型链路是:文档切块 → 文本 embedding → 向量库 → Top-K 文本块塞给 LLM。这条链路对纯文本有效,但遇到图文混排文档时会出问题:
表格的合并单元格被 OCR 打成碎片,行列关系丢失;
架构图、流程图里的箭头和层次,转成文字后语义大幅衰减;
扫描件 OCR 错误率一高,后续检索和生成全被污染。
我在学习过程中先跑过"unstructured 解析 + 文本 embedding"的版本,问答 demo 能跑,但一旦问"这张图里 Q3 到 Q4 的流向是什么",系统基本答偏。后来接触到页面级视觉检索思路:不强行把页面拆成文字,而是把整页渲染成图像,用视觉语言模型编码,检索时直接召回页面图像。这条路线牺牲了一些存储和推理成本,但对图文混排场景的保真度更高。
读完本文,你能获得:① 两条路线的选型判断依据;② 一套本地可跑的 ColQwen2 + Milvus Lite 检索代码;③ 4 个常见坑的排查思路;④ 面试中被追问多模态 RAG 时的结构化回答模板。
- 核心概念与环境说明
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 能跑但很慢,适合先验证流程。
- 核心内容
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: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 或视觉编码解决的是"找得到",生成阶段解决的是"看得懂",两者不能混为一谈。
- 优化建议与适用边界
5.1 可优化的方向
混合召回:页面级向量召回 + BM25 关键词召回,用 RRF 融合。对"型号、年份、数值"类查询,关键词召回能补上视觉检索的短板。
多模态 rerank:粗排后用 Qwen3-VL-Reranker 这类交叉注意力模型对候选页面精排,进一步提升 Top-K 精度。
解析 + 页面双路并存:纯文本页走解析式 chunk,图示/表格页走页面级索引,查询时按问题类型路由。这是更接近生产级的设计,但对个人项目来说复杂度较高。
图像预处理:对扫描件先做去噪、纠偏;dpi 根据文档版式调整。页面级路线对图像质量敏感,低质量扫描会直接影响 patch 语义。
5.2 适用边界
不适合:纯文本为主、对延迟和存储极敏感的场景。页面级路线的向量记录数量远高于文本 chunk,全量索引成本需要提前评估。
不擅长:需要精确匹配数字、型号、专有名词的查询,建议叠关键词检索。
生成模型依赖:检索端只能保证"页面被召回",最终答案质量取决于多模态 LLM 能否正确阅读表格和图示。不要用检索指标代替端到端回答质量的评估。
- 求职与面试关联
"多模态 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 占位,这些不丢人,夸大才减分。