1. 从 OCR 到视觉向量:多模态 RAG 到底解决了什么麻烦
多模态 RAG 是一套把 PDF、扫描件、PPT 这类版式复杂的文档,直接以页面图像为单位做向量化、检索、再交给视觉模型生成答案的管线。它和传统文本 RAG 最大的区别在于:不再强依赖 OCR 把文档“翻译”成纯文本,而是让视觉模型直接“看”页面。适合谁?适合手里有一堆表格、图纸、规范、招生计划这类 OCR 一解析就散架的文档,又想让问答系统能准确引用原文的开发者。
我最初做这个项目时,走的是最常规的文本 RAG 路线:PyMuPDF 抽文本、正则切 chunk、BGE 做 embedding、Milvus 存向量。结果一上真实文档就翻车。比如一份招生计划 PDF,表格里“院校代码 / 专业 / 计划数”三列,OCR 出来变成一列竖排文字,检索时 query“南昌航空大学科技学院招生情况”命中的 chunk 里数字和学校名完全错位。再比如公路桥梁抗震规范,大量跨页表格和公式,文本抽取后语义直接断裂。
这就是多模态 RAG 要解决的核心痛点:版式信息本身就是语义的一部分。ColPali 这类视觉检索模型的做法是,把每一页文档渲染成图像,切成 patch,用视觉语言模型生成多向量嵌入,检索时用 ColBERT 的晚交互机制逐块匹配。省掉了 OCR 和版面分析,页面上的表格线、标题层级、图注位置全都被编码进向量里。
我实测下来,同一批难啃文档,文本 RAG 的 Top-5 命中率大概在 55% 左右,换成 ColPali 视觉向量后,检索阶段准确率能到 90% 上下。这个提升不是调参调出来的,是范式差异带来的。当然代价也很明显:存储膨胀、视觉模型幻觉、人工干预困难,这些后面会逐个拆。
这一节先把问题定义清楚:你要复现或二次开发的多模态 RAG,本质是“页面图像 → 多向量索引 → 晚交互检索 → 视觉模型生成”四段链路。任何一段配置错了,最终答案都会崩。下面从环境准备开始,一步步把可复制的配置交给你。
2. TaoToken 前置:把视觉模型 API 接进 LiteLLM 的正确姿势
多模态 RAG 的生成段需要一个能读图的视觉模型。本地跑 Qwen2.5-VL 32B 需要 48G 显存,不是每个人都有 4090 48G。更现实的做法是本地小模型做开发调试,线上用兼容 OpenAI 协议的视觉模型 API 做生成。这里我用 TaoToken 作为统一接入层,原因是它同时提供 OpenAI 兼容接口和 Claude Code 这类编码工具的接入能力,省得为每个模型写一套适配。
先明确三件套,这是后面所有配置的基础:
| 配置项 | 值 |
|---|---|
| Base URL | https://taotoken.net/api |
| API Key | 在控制台创建,形如sk-xxxx |
| Model ID | 视觉模型填qwen2.5-vl-72b-instruct这类,编码模型填claude-sonnet-4-5这类 |
如果你只是想让视觉模型跑起来验证检索结果,最直接的方式是打开模型对话页面,把检索到的页面截图贴进去问问题。地址是https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。这一步能帮你快速判断“检索对不对”和“模型答得对不对”是两件事。
但要做成管线,就得走 API。LiteLLM 的配置里,把 TaoToken 当成一个 OpenAI 兼容 provider 即可。下面是我项目里litellm_config.yaml的实际片段:
model_list: - model_name: vision-qwen litellm_params: model: openai/qwen2.5-vl-72b-instruct api_base: https://taotoken.net/api api_key: os.environ/TAOTOKEN_API_KEY - model_name: vision-local litellm_params: model: ollama/qwen2.5-vl:32b api_base: http://localhost:11434注意model字段前缀openai/是 LiteLLM 的 provider 标识,不是指 OpenAI 官方。api_base填 TaoToken 的 API 地址,不要带 UTM 参数,保持干净。API Key 走环境变量,别硬编码进仓库。
如果你用 Claude Code 做二次开发,接入方式略有不同。Claude Code 读的是~/.claude/settings.json,在里面配env段:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-你的key", "ANTHROPIC_MODEL": "claude-sonnet-4-5" } }这里三件套同样齐全:Base URL、Key、Model ID。配完后在终端跑claude能正常对话,说明接入层通了。这一步别跳过,很多后面“检索到了但生成报错”的问题,根源其实是 API 层没通。
如果你要长期跑编码和 Agent 任务,可以考虑 Coding Plan,地址是https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。它和按量 API 的区别在于更适合高频调用场景,具体额度以控制台为准,我不在这里编造价格。
Key 的创建入口在https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite,接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。建议先把文档里的请求示例跑通,再往 RAG 管线里塞。
3. 可复制配置:ColPali 索引构建与 Milvus 落库脚本
这一节是全文最硬的部分。多模态 RAG 的索引构建分三步:页面渲染、ColPali 多向量编码、Milvus 落库。我踩过的坑集中在第二步和第三步的维度对齐上。
先装依赖。ColPali 官方实现依赖colpali-engine,Milvus 用pymilvus,页面渲染用pdf2image:
pip install colpali-engine==0.3.0 pymilvus==2.4.4 pdf2image==1.17.0 pillow==10.3.0 torch==2.3.1pdf2image需要系统装 poppler,Ubuntu 下apt install poppler-utils,macOS 下brew install poppler。这个不装,渲染那步直接报PDFInfoNotInstalledError。
页面渲染脚本,把 PDF 每页转成 144 DPI 的 PNG:
from pdf2image import convert_from_path import os def render_pdf(pdf_path, out_dir, dpi=144): os.makedirs(out_dir, exist_ok=True) pages = convert_from_path(pdf_path, dpi=dpi) paths = [] for i, page in enumerate(pages): p = os.path.join(out_dir, f"page_{i:04d}.png") page.save(p, "PNG") paths.append(p) return pathsDPI 别调太高,144 是检索精度和显存占用的平衡点。我试过 200 DPI,单页 patch 数暴涨,编码时间翻倍,检索准确率只涨了不到 2 个点,不划算。
ColPali 编码,核心是拿到多向量输出:
import torch from colpali_engine.models import ColPali, ColPaliProcessor from PIL import Image model_name = "vidore/colpali-v1.2" model = ColPali.from_pretrained( model_name, torch_dtype=torch.bfloat16, device_map="cuda:0" ).eval() processor = ColPaliProcessor.from_pretrained(model_name) def encode_images(image_paths, batch_size=4): all_embeddings = [] for i in range(0, len(image_paths), batch_size): batch = [Image.open(p).convert("RGB") for p in image_paths[i:i+batch_size]] inputs = processor(images=batch, return_tensors="pt").to("cuda:0") with torch.no_grad(): emb = model(**inputs) all_embeddings.extend(emb.cpu().to(torch.float32).numpy()) return all_embeddings注意emb的形状是[batch, num_patches, dim],不是[batch, dim]。这是多向量和普通 embedding 的本质区别。我第一次落库时按[batch, dim]存,检索时维度对不上,Milvus 直接抛DimensionNotMatch。
Milvus 建集合,这里要开多向量支持:
from pymilvus import MilvusClient, DataType client = MilvusClient(uri="http://localhost:19530") schema = client.create_schema(auto_id=True, enable_dynamic_field=True) schema.add_field("id", DataType.INT64, is_primary=True) schema.add_field("doc_id", DataType.VARCHAR, max_length=128) schema.add_field("page_no", DataType.INT64) schema.add_field("image_path", DataType.VARCHAR, max_length=512) schema.add_field("emb", DataType.FLOAT_VECTOR, dim=128) index_params = client.prepare_index_params() index_params.add_index(field_name="emb", index_type="FLAT", metric_type="IP") client.create_collection("visual_rag", schema=schema, index_params=index_params)dim=128是 ColPali v1.2 的 patch 维度,别写错。索引类型用FLAT保证召回,数据量上百万后再换IVF_FLAT。
落库时每个 patch 存一行,doc_id和page_no用来回溯原图:
def insert_page(client, doc_id, page_no, image_path, page_emb): rows = [] for patch_vec in page_emb: rows.append({ "doc_id": doc_id, "page_no": page_no, "image_path": image_path, "emb": patch_vec.tolist() }) client.insert(collection_name="visual_rag", data=rows)这套配置跑通后,一份 50 页的规范文档,索引构建大概 3 到 5 分钟(4090 上)。存储膨胀是必然的,一页大概 1000 多个 patch,每个 128 维 float32,算下来一页约 500KB 向量数据。这个量级要提前规划磁盘。
4. 验证请求:检索评测命令与成功结果长什么样
索引建完不验证,等于没建。这一节给你可复制的检索评测命令,以及“成功”应该看到什么。
检索的核心是晚交互打分。query 编码后,和每个 patch 向量算内积,再按页聚合取 max:
def encode_query(query): inputs = processor(text=[query], return_tensors="pt").to("cuda:0") with torch.no_grad(): emb = model(**inputs) return emb.cpu().to(torch.float32).numpy()[0] def search(client, query, topk=5): q_emb = encode_query(query) results = client.search( collection_name="visual_rag", data=[q_emb.tolist()], anns_field="emb", limit=200, output_fields=["doc_id", "page_no", "image_path"] ) page_scores = {} for hit in results[0]: key = (hit["entity"]["doc_id"], hit["entity"]["page_no"]) page_scores[key] = max(page_scores.get(key, 0), hit["distance"]) ranked = sorted(page_scores.items(), key=lambda x: -x[1])[:topk] return rankedlimit=200是 patch 级召回数,不是页级。因为一页有上千 patch,先召回 200 个 patch,再按页聚合,这个参数太小会漏页。我一开始设 20,结果目标页的 patch 根本没进候选,检索全错。
跑一条真实 query:
python eval.py --query "南昌航空大学科技学院招生情况" --topk 5成功输出应该类似:
[1] doc=lnzsjh_2024 page=37 score=28.41 image=pages/page_0037.png [2] doc=lnzsjh_2024 page=38 score=26.77 image=pages/page_0038.png [3] doc=lnzsjh_2024 page=36 score=24.12 image=pages/page_0036.png看到目标页排第一,且分数明显高于后面,说明检索链路通了。然后把page_0037.png喂给视觉模型:
import litellm resp = litellm.completion( model="vision-qwen", messages=[{ "role": "user", "content": [ {"type": "text", "text": "南昌航空大学科技学院招生情况?"}, {"type": "image_url", "image_url": {"url": "file://pages/page_0037.png"}} ] }] ) print(resp.choices[0].message.content)如果模型返回的院校代码、专业、计划数和原表一致,整条管线就算跑通了。我实测时,招生计划这类表格文档,72B 在线模型基本能无损还原;本地 32B 模型偶尔会把相邻行的数字串行,这就是后面要说的幻觉问题。
评测命令建议做成批量:
python eval.py --queries queries.jsonl --topk 5 --report recall.jsonqueries.jsonl每行一个{"query": "...", "gold_page": "page_0037.png"},脚本自动算 Top-1 / Top-5 命中率。我项目里 30 条难例,Top-5 命中 27 条,剩下 3 条全是跨页表格,这是视觉检索的固有短板。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
这一节按真实报错来。你在复现时大概率会撞上下面几个,我逐个给定位思路。
401 Unauthorized。出现在调视觉模型 API 时。先确认TAOTOKEN_API_KEY环境变量真的被读到了,echo $TAOTOKEN_API_KEY看有没有值。再确认api_base是https://taotoken.net/api,末尾不要多斜杠,也不要带任何查询参数。LiteLLM 里如果model写成openai/qwen2.5-vl-72b-instruct但api_base没配,它会默认打 OpenAI 官方,必然 401。三件套缺一不可。
local proxy failed。这个报错通常出现在你本地起了代理类工具,或者环境变量里残留了HTTP_PROXY/HTTPS_PROXY。先unset HTTP_PROXY HTTPS_PROXY ALL_PROXY,再重跑。另外 Ollama 本地模型如果api_base写成http://127.0.0.1:11434但服务没起,也会报连接失败,curl http://localhost:11434/api/tags验证一下。
reading choices 相关报错,比如KeyError: 'choices'或list index out of range。这是 LiteLLM 返回结构和你代码预期不一致。先打印原始resp看结构,大概率是模型返回了错误对象而不是正常 completion。常见原因是图片路径用了file://但 LiteLLM 不认,改成 base64 编码传:
import base64 with open("pages/page_0037.png", "rb") as f: b64 = base64.b64encode(f.read()).decode() image_url = f"data:image/png;base64,{b64}"OAuth 相关报错。如果你用 Claude Code 接入,报 OAuth 失败,检查~/.claude/settings.json里是不是同时配了ANTHROPIC_API_KEY和 OAuth 登录态,两者冲突。清掉 OAuth 缓存,只保留 API Key 方式。三件套ANTHROPIC_BASE_URL、ANTHROPIC_API_KEY、ANTHROPIC_MODEL必须同时存在且拼写正确。
DimensionNotMatch。Milvus 落库时dim和实际向量维度不一致。ColPali v1.2 是 128,v1.3 可能不同,建集合前先print(emb.shape[-1])确认。
检索结果全是同一页。晚交互聚合时用了 sum 而不是 max,导致 patch 多的页分数虚高。改回 max 聚合。
排障时建议把检索和生成分开验证:先确认search()返回的目标页对不对,再确认视觉模型读图答得对不对。两个都对了,管线才通。接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite,Key 管理在https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。
6. 语义一致 CTA:按你的下一步选入口
如果你现在卡在排障或接入阶段,比如 401、local proxy failed、OAuth 这类问题,直接去 API Keys 页面把 Key 重新生成一遍,再对照接入文档把三件套配齐。入口是https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite和https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。
如果你只是想先验证视觉模型对某张页面截图的回答质量,不想写代码,打开模型对话页面,把page_0037.png拖进去直接问。入口是https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。这一步能帮你快速区分“检索错了”还是“模型答错了”。
如果你要把这套多模态 RAG 做成长期跑的 Agent 或编码辅助工具,高频调用视觉模型,看 Coding Plan。入口是https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。具体额度以控制台为准。
最后说一个我踩过的坑:视觉模型选型别一步到位上 72B。先用本地 32B 把索引和检索调通,确认 Top-5 命中率达标,再换在线 72B 做生成。因为检索错了,换多大的模型都救不回来。检索和生成解耦验证,是这个项目里最省时间的习惯。