ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

实战复盘 | 基于视觉模型的多模态 RAG 系统,我们踩过的坑与收获(项目已开源)

实战复盘 | 基于视觉模型的多模态 RAG 系统,我们踩过的坑与收获(项目已开源) 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 URLhttps://taotoken.net/apiAPI Key在控制台创建形如sk-xxxxModel ID视觉模型填qwen2.5-vl-72b-instruct这类编码模型填claude-sonnet-4-5这类如果你只是想让视觉模型跑起来验证检索结果最直接的方式是打开模型对话页面把检索到的页面截图贴进去问问题。地址是https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite。这一步能帮你快速判断“检索对不对”和“模型答得对不对”是两件事。但要做成管线就得走 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite。它和按量 API 的区别在于更适合高频调用场景具体额度以控制台为准我不在这里编造价格。Key 的创建入口在https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite接入文档在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite。建议先把文档里的请求示例跑通再往 RAG 管线里塞。3. 可复制配置ColPali 索引构建与 Milvus 落库脚本这一节是全文最硬的部分。多模态 RAG 的索引构建分三步页面渲染、ColPali 多向量编码、Milvus 落库。我踩过的坑集中在第二步和第三步的维度对齐上。先装依赖。ColPali 官方实现依赖colpali-engineMilvus 用pymilvus页面渲染用pdf2imagepip install colpali-engine0.3.0 pymilvus2.4.4 pdf2image1.17.0 pillow10.3.0 torch2.3.1pdf2image需要系统装 popplerUbuntu 下apt install poppler-utilsmacOS 下brew install poppler。这个不装渲染那步直接报PDFInfoNotInstalledError。页面渲染脚本把 PDF 每页转成 144 DPI 的 PNGfrom pdf2image import convert_from_path import os def render_pdf(pdf_path, out_dir, dpi144): os.makedirs(out_dir, exist_okTrue) pages convert_from_path(pdf_path, dpidpi) paths [] for i, page in enumerate(pages): p os.path.join(out_dir, fpage_{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_dtypetorch.bfloat16, device_mapcuda:0 ).eval() processor ColPaliProcessor.from_pretrained(model_name) def encode_images(image_paths, batch_size4): all_embeddings [] for i in range(0, len(image_paths), batch_size): batch [Image.open(p).convert(RGB) for p in image_paths[i:ibatch_size]] inputs processor(imagesbatch, return_tensorspt).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(urihttp://localhost:19530) schema client.create_schema(auto_idTrue, enable_dynamic_fieldTrue) schema.add_field(id, DataType.INT64, is_primaryTrue) schema.add_field(doc_id, DataType.VARCHAR, max_length128) schema.add_field(page_no, DataType.INT64) schema.add_field(image_path, DataType.VARCHAR, max_length512) schema.add_field(emb, DataType.FLOAT_VECTOR, dim128) index_params client.prepare_index_params() index_params.add_index(field_nameemb, index_typeFLAT, metric_typeIP) client.create_collection(visual_rag, schemaschema, index_paramsindex_params)dim128是 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_namevisual_rag, datarows)这套配置跑通后一份 50 页的规范文档索引构建大概 3 到 5 分钟4090 上。存储膨胀是必然的一页大概 1000 多个 patch每个 128 维 float32算下来一页约 500KB 向量数据。这个量级要提前规划磁盘。4. 验证请求检索评测命令与成功结果长什么样索引建完不验证等于没建。这一节给你可复制的检索评测命令以及“成功”应该看到什么。检索的核心是晚交互打分。query 编码后和每个 patch 向量算内积再按页聚合取 maxdef encode_query(query): inputs processor(text[query], return_tensorspt).to(cuda:0) with torch.no_grad(): emb model(**inputs) return emb.cpu().to(torch.float32).numpy()[0] def search(client, query, topk5): q_emb encode_query(query) results client.search( collection_namevisual_rag, data[q_emb.tolist()], anns_fieldemb, limit200, 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(), keylambda x: -x[1])[:topk] return rankedlimit200是 patch 级召回数不是页级。因为一页有上千 patch先召回 200 个 patch再按页聚合这个参数太小会漏页。我一开始设 20结果目标页的 patch 根本没进候选检索全错。跑一条真实 querypython eval.py --query 南昌航空大学科技学院招生情况 --topk 5成功输出应该类似[1] doclnzsjh_2024 page37 score28.41 imagepages/page_0037.png [2] doclnzsjh_2024 page38 score26.77 imagepages/page_0038.png [3] doclnzsjh_2024 page36 score24.12 imagepages/page_0036.png看到目标页排第一且分数明显高于后面说明检索链路通了。然后把page_0037.png喂给视觉模型import litellm resp litellm.completion( modelvision-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 fdata: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 是 128v1.3 可能不同建集合前先print(emb.shape[-1])确认。检索结果全是同一页。晚交互聚合时用了 sum 而不是 max导致 patch 多的页分数虚高。改回 max 聚合。排障时建议把检索和生成分开验证先确认search()返回的目标页对不对再确认视觉模型读图答得对不对。两个都对了管线才通。接入文档在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteKey 管理在https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite。6. 语义一致 CTA按你的下一步选入口如果你现在卡在排障或接入阶段比如 401、local proxy failed、OAuth 这类问题直接去 API Keys 页面把 Key 重新生成一遍再对照接入文档把三件套配齐。入口是https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite和https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite。如果你只是想先验证视觉模型对某张页面截图的回答质量不想写代码打开模型对话页面把page_0037.png拖进去直接问。入口是https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite。这一步能帮你快速区分“检索错了”还是“模型答错了”。如果你要把这套多模态 RAG 做成长期跑的 Agent 或编码辅助工具高频调用视觉模型看 Coding Plan。入口是https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite。具体额度以控制台为准。最后说一个我踩过的坑视觉模型选型别一步到位上 72B。先用本地 32B 把索引和检索调通确认 Top-5 命中率达标再换在线 72B 做生成。因为检索错了换多大的模型都救不回来。检索和生成解耦验证是这个项目里最省时间的习惯。
返回列表