简介:本资源为多模态检索增强生成(Multimodal RAG)领域的中文综述文档,面向自然语言处理、计算机视觉与多媒体分析方向的研究者及进阶学习者,帮助系统理解跨模态检索、融合、增强与生成的整体技术脉络。压缩包内为1个PDF文件,约8.4MB,内容围绕多模态RAG的查询预处理、模态中心检索、相似性搜索与重排序、分数融合与注意力机制、迭代增强及思维链推理等关键环节展开,并补充了第四章内容,采用更美观的Lapis主题排版。文档还梳理了数据集、评价指标、基准测试与评估方法,讨论了跨模态对齐、噪声管理、鲁棒性增强等开放挑战及未来方向。目前已有516人学习下载,适合希望快速建立多模态RAG知识框架、查阅方法分类与训练策略的读者参考。
1. 多模态 RAG 到底在解决什么:从「Ask in Any Modality」说起
多模态 RAG 这个词最近被提得很多,但真正落地时你会发现,大部分团队卡住的不是模型能力,而是「用户用图提问、用语音提问、用表格提问,我的检索链路根本接不住」。Ask in Any Modality 这句话点出了核心矛盾:传统 RAG 的入口是文本 query,检索的是文本 chunk,而真实业务里用户的提问形态是混杂的——拍一张设备铭牌问故障码含义,截一张报表问异常原因,录一段语音问操作步骤。多模态 RAG 要解决的就是让这些异构输入都能被统一检索、统一召回、统一生成。
这篇文章面向的是已经跑通过文本 RAG、现在要接入图像/音频/表格等模态的工程师。我会把「多模态检索索引怎么建、跨模态对齐怎么做、生成阶段怎么拼上下文」这三段拆开讲,每一步给出可复现的代码和参数,最后落到几个我踩过的坑。读完你应该能判断自己的场景值不值得上多模态 RAG,以及最小可行版本怎么搭。
2. 多模态检索索引怎么建:三种主流路线与选型依据
2.1 统一嵌入空间 vs 分模态索引 vs 混合路由
建索引之前先做路线选择,这决定了后面所有代码结构。目前常见做法有三条:
统一嵌入空间:用 CLIP 类模型把图像和文本映射到同一向量空间,检索时直接用文本向量查图像向量。优点是架构简单,一个向量库搞定;缺点是细粒度文本(比如表格里的数字、设备型号)在 CLIP 空间里区分度很差,实测 top-5 召回经常把相似布局但内容不同的图排前面。
分模态索引:图像走图像嵌入模型,文本走文本嵌入模型,各自建库,检索时分别召回再融合。优点是每个模态可以用最强模型;缺点是融合排序需要额外逻辑,且跨模态查询(用图查文)需要桥接。
混合路由:先用一个轻量分类器判断 query 模态和意图,再路由到对应索引。适合 query 模态可枚举的场景,比如客服系统里用户要么打字要么传图。
我一般会这样选:如果业务 query 80% 以上是文本、图像只是补充上下文,走统一嵌入空间 + 文本索引为主;如果图像本身就是检索目标(比如以图搜图、以图搜文档),走分模态索引;如果 query 模态混杂且延迟敏感,加一层路由。
2.2 用 CLIP 建最小可跑索引的完整代码
下面这段代码用 CLIP 建一个图文统一索引,依赖transformers、faiss、Pillow。我假设你已经有一批图文对数据,图像存本地路径,文本存 jsonl。
import json import torch import faiss import numpy as np from PIL import Image from transformers import CLIPProcessor, CLIPModel # 加载 CLIP 模型,这里用 base 版本,显存不够可换 small model_name = "openai/clip-vit-base-patch32" model = CLIPModel.from_pretrained(model_name) processor = CLIPProcessor.from_pretrained(model_name) model.eval() # 如果有 GPU 就上,没有也能跑,只是慢 device = "cuda" if torch.cuda.is_available() else "cpu" model.to(device) def encode_images(image_paths, batch_size=16): """批量编码图像,返回归一化后的向量""" all_embeds = [] for i in range(0, len(image_paths), batch_size): batch = image_paths[i:i+batch_size] images = [Image.open(p).convert("RGB") for p in batch] inputs = processor(images=images, return_tensors="pt", padding=True).to(device) with torch.no_grad(): embeds = model.get_image_features(**inputs) # 归一化,方便后面用内积当余弦相似度 embeds = embeds / embeds.norm(dim=-1, keepdim=True) all_embeds.append(embeds.cpu().numpy()) return np.vstack(all_embeds) def encode_texts(texts, batch_size=32): """批量编码文本""" all_embeds = [] for i in range(0, len(texts), batch_size): batch = texts[i:i+batch_size] inputs = processor(text=batch, return_tensors="pt", padding=True, truncation=True).to(device) with torch.no_grad(): embeds = model.get_text_features(**inputs) embeds = embeds / embeds.norm(dim=-1, keepdim=True) all_embeds.append(embeds.cpu().numpy()) return np.vstack(all_embeds) # 读取数据 records = [] with open("data.jsonl", "r", encoding="utf-8") as f: for line in f: records.append(json.loads(line)) image_paths = [r["image"] for r in records] texts = [r["text"] for r in records] # 编码 img_embeds = encode_images(image_paths) txt_embeds = encode_texts(texts) # 建两个 FAISS 索引,都用内积 dim = img_embeds.shape[1] img_index = faiss.IndexFlatIP(dim) txt_index = faiss.IndexFlatIP(dim) img_index.add(img_embeds) txt_index.add(txt_embeds) # 保存索引和元数据 faiss.write_index(img_index, "img.index") faiss.write_index(txt_index, "txt.index") with open("meta.json", "w", encoding="utf-8") as f: json.dump(records, f, ensure_ascii=False)逻辑说明:CLIP 的图像编码和文本编码输出维度一致(base 版是 512),所以可以共用同一个向量空间做跨模态检索。归一化后内积等价于余弦相似度,FAISS 用IndexFlatIP即可。参数上,batch_size根据显存调,16 在 8G 显存上比较稳;truncation=True防止长文本报错,但 CLIP 文本编码器最大 77 token,超长文本会被截断,这是后面要讲的坑之一。
2.3 检索阶段的跨模态查询与融合排序
索引建好后,查询逻辑要分情况。文本查图:把 query 文本编码后查img_index。图查文:把 query 图编码后查txt_index。图查图:查img_index。下面是统一查询函数:
def search(query, modality="text", top_k=5): """modality 是 query 的模态,返回 top_k 结果""" if modality == "text": q_embed = encode_texts([query]) else: q_embed = encode_images([query]) # 同时查两个索引,取并集 img_scores, img_ids = img_index.search(q_embed, top_k) txt_scores, txt_ids = txt_index.search(q_embed, top_k) results = [] for score, idx in zip(img_scores[0], img_ids[0]): results.append({"type": "image", "score": float(score), "meta": records[idx]}) for score, idx in zip(txt_scores[0], txt_ids[0]): results.append({"type": "text", "score": float(score), "meta": records[idx]}) # 按分数排序,去重 results.sort(key=lambda x: x["score"], reverse=True) seen = set() deduped = [] for r in results: key = r["meta"].get("id") if key not in seen: seen.add(key) deduped.append(r) return deduped[:top_k]这里有个工程细节:两个索引的分数尺度可能不一致,直接混排会有偏。常见做法是分别取 top-k 后用 RRF(Reciprocal Rank Fusion)融合,而不是比原始分数。RRF 的公式是score = sum(1 / (k + rank)),k 一般取 60。如果你的场景对排序质量敏感,建议换成 RRF。
3. 跨模态对齐与上下文拼接:生成阶段怎么喂给 LLM
3.1 检索结果转成 LLM 能吃的上下文
多模态 RAG 的生成阶段和文本 RAG 最大的区别是:上下文里可能同时有图像描述、原始文本 chunk、表格片段。LLM 只能吃文本,所以图像必须转成文本描述或者用支持视觉的 LLM 直接吃图。
两条路线:
路线 A:图像先转描述再拼上下文。用 BLIP 或 GPT-4V 类模型给每张召回图生成 caption,然后把 caption 和文本 chunk 拼在一起。优点是兼容纯文本 LLM;缺点是 caption 会丢信息,尤其是图表里的数字。
路线 B:直接用多模态 LLM。把召回图直接作为 image token 喂给支持视觉的 LLM(如 LLaVA 类、Qwen-VL 类)。优点是信息无损;缺点是对 LLM 有要求,且上下文长度消耗大。
我一般会这样选:如果召回图是照片类、语义为主,走路线 A;如果召回图是图表、表格、文档截图,走路线 B,因为数字和布局信息 caption 很难描述准。
3.2 上下文拼接的模板与 token 预算控制
拼接模板直接决定生成质量。下面是一个我常用的模板结构:
def build_context(retrieved_items, max_tokens=3000): """把检索结果拼成 LLM 上下文,控制 token 预算""" context_parts = [] token_count = 0 for item in retrieved_items: if item["type"] == "text": chunk = f"[文本片段] {item['meta']['text']}" else: # 图像走 caption 路线 chunk = f"[图像描述] {item['meta'].get('caption', '无描述')}" # 粗略估算 token,中文按 1.5 字/token estimated = len(chunk) / 1.5 if token_count + estimated > max_tokens: break context_parts.append(chunk) token_count += estimated return "\n\n".join(context_parts)参数说明:max_tokens要留出生成空间,一般设为模型上下文窗口的 60%。中文 token 估算用 1.5 字/token 是经验值,实际用 tokenizer 算更准。拼接顺序建议按检索分数降序,因为 LLM 对上下文开头和结尾的信息利用更好,中间容易丢。
3.3 用多模态 LLM 直接吃图的调用示例
如果走路线 B,下面是调用多模态 LLM 的伪代码结构(以常见 API 形态为例):
def generate_with_images(query, retrieved_items): """把召回图和文本一起喂给多模态 LLM""" messages = [{"role": "user", "content": []}] # 先放文本上下文 text_context = build_context([i for i in retrieved_items if i["type"] == "text"]) messages[0]["content"].append({"type": "text", "text": f"参考信息:\n{text_context}"}) # 再放图像 for item in retrieved_items: if item["type"] == "image": messages[0]["content"].append({ "type": "image_url", "image_url": {"url": f"file://{item['meta']['image']}"} }) # 最后放问题 messages[0]["content"].append({"type": "text", "text": f"问题:{query}"}) # 调用 LLM,这里省略具体 SDK # response = llm_client.chat(messages) return messages关键点是图像放在问题之前、文本之后,这样 LLM 先读文本建立语义框架,再看图补充细节,最后回答问题。实测这个顺序比图像放最前面效果好,因为文本上下文能帮模型定位该关注图的哪个区域。
4. 避坑与排查:多模态 RAG 落地时最容易翻车的 5 个点
4.1 坑一:CLIP 文本编码器 77 token 截断导致长 query 失效
现象:用户输入一段 200 字的问题,检索结果和问题完全不相关。
原因:CLIP 文本编码器最大长度 77 token,超出部分被静默截断,实际参与编码的只有前 77 个 token。
解决:长 query 先做摘要或关键词抽取,再编码。或者换用支持长文本的嵌入模型(如 BGE 系列)做文本侧编码,图像侧仍用 CLIP,但这样就不是统一空间了,需要额外对齐。我一般会在 query 入口加一个长度检查,超过 60 个中文字符就先过一遍摘要模型。
4.2 坑二:图像分辨率被预处理吃掉,小字完全检索不到
现象:文档截图里的表格数字,用文本查图完全查不到。
原因:CLIP 预处理会把图像 resize 到 224x224,文档截图里的小字在这个分辨率下糊成一团。
解决:对文档类图像,先做区域切分,把大图切成多个小图分别编码,检索时按区域召回。或者用支持高分辨率的视觉编码器(如 336 或 448 输入的变体)。实测切图后召回率能提升 30% 以上,代价是索引体积变大。
4.3 坑三:FAISS 索引没做归一化,内积分数不可比
现象:检索分数忽大忽小,排序结果不稳定。
原因:编码输出没归一化,向量模长不一致,内积受模长影响。
解决:编码后必须做 L2 归一化,代码里那行embeds / embeds.norm(dim=-1, keepdim=True)不能省。如果已经建了索引才发现,重新编码建索引,别想着在检索时补救。
4.4 坑四:多模态 LLM 上下文超长导致生成质量断崖
现象:召回图一多,LLM 回答开始胡言乱语或者只回答最后一张图的内容。
原因:图像 token 消耗远大于文本,一张图可能占几百 token,召回 10 张图直接撑爆上下文。
解决:严格控制召回图数量,一般 3-5 张足够。如果必须多图,先用轻量模型做相关性重排,只把 top-3 喂给 LLM。另外可以在 prompt 里明确要求「综合所有参考图回答」,减少只关注最后一张的倾向。
4.5 坑五:跨模态分数融合没做归一化,文本结果永远压过图像
现象:检索结果里文本片段总是排在图像前面,即使图像更相关。
原因:文本嵌入和图像嵌入的分数分布不同,文本侧分数普遍偏高。
解决:用 RRF 融合代替分数直接比较,或者对每个模态的分数做 z-score 归一化后再融合。RRF 更稳,不依赖分数分布假设,我一般首选 RRF。
5. 进阶技巧:用重排模型把多模态召回质量再提一档
最小版本跑通后,下一步提升召回质量的关键是加重排。多模态重排和文本重排的区别在于,你需要一个能同时吃图和文本的交叉编码器。常见做法是用 CLIP 的交叉注意力层做 fine-tune,或者直接用多模态 LLM 做相关性打分。
我自己的习惯是分两步:第一步用轻量重排(比如基于 CLIP 相似度的二次过滤)把候选从 50 降到 10;第二步用多模态 LLM 对 top-10 做精排,让 LLM 输出 0-10 的相关性分数。第二步延迟高,但只跑 10 条,可接受。
下面是一个用多模态 LLM 做重排的打分 prompt 结构:
RERANK_PROMPT = """你是一个相关性打分器。给定一个查询和一条候选内容,输出 0-10 的相关性分数。 只输出数字,不要解释。 查询:{query} 候选类型:{modality} 候选内容:{content} 分数:"""参数上,温度设 0,max_tokens 设 5,避免 LLM 输出多余内容。实测这个方案在图文混合检索场景下,top-3 准确率比纯向量检索提升 15-20 个百分点。
还有一个容易被忽略的点:重排模型的输入要包含模态信息。同样一段文字,作为图像 caption 和作为独立文本 chunk,相关性判断标准不一样。在 prompt 里显式告诉模型候选类型,打分更准。
最后说一个我踩过的坑:重排阶段不要用和召回阶段同一个模型。如果召回用 CLIP,重排还用 CLIP,那只是把同样的分数算了一遍,没有新信息。重排必须引入召回阶段没有的信号,比如交叉注意力、LLM 的推理能力,否则就是白费算力。
这套方案我从最小索引到重排跑通大概花了两天,其中一天半在调坑。如果你刚开始做多模态 RAG,建议先把第 2 章的索引跑通,用真实 query 测一轮召回,再决定要不要上重排。别一上来就堆模型,先把链路跑直。希望帮到你。
本文还有配套的精品资源,点击获取