多模态大模型实战:CLIP、BLIP 与 LLaVA 的视觉语言融合
多模态大模型实战:CLIP、BLIP 与 LLaVA 的视觉语言融合
当 AI 学会"看"世界,它不再只是一个文字处理器。从 CLIP 的对比学习到 LLaVA 的视觉指令微调,多模态大模型正在重新定义人机交互的边界。本文将深入解析三大里程碑架构,并给出完整的实战代码。
一、多模态架构演进全景
1.1 从单模态到多模态的三个阶段
阶段1: 双塔模型(Dual Encoder)
┌──────────┐ ┌──────────┐
│ Image │ │ Text │
│ Encoder │ │ Encoder │
│ (ViT) │ │ (BERT) │
└────┬─────┘ └────┬─────┘
│ │
│ 对比学习对齐 │
│ (Contrastive) │
└────────┬────────┘
│
共享嵌入空间
代表: CLIP (2021), ALIGN (2021)
能力: 图文检索、零样本分类
局限: 只能做检索,不能生成描述
阶段2: 交叉注意力融合(Cross-Attention)
┌──────────┐ ┌──────────┐
│ Image │ │ Text │
│ Encoder │ │ Decoder │
│ (ViT) │ │ (LM) │
└────┬─────┘ └────┬─────┘
│ │
│ Cross-Attention│
│ (Q←Text, KV←Image)│
└────────┬───────┘
│
融合表示 → 生成
代表: BLIP-2 (2023), Flamingo (2022)
能力: 图像描述、VQA、图文生成
优势: 深度交互,理解更准确
阶段3: 投影层直连(Projection + LLM)
┌──────────┐ ┌──────────────┐
│ Image │ │ LLM │
│ Encoder │ │ (Vicuna/ │
│ (CLIP │ │ Llama) │
│ ViT) │ │ │
└────┬─────┘ └──────┬───────┘
│ │
│ MLP Projection │
│ (线性层/MLP) │
└────────┬─────────┘
│
视觉Token混入文本Token
代表: LLaVA (2023), MiniGPT-4 (2023)
优势: 架构简单、可复用LLM能力、支持指令微调1.2 核心挑战:模态对齐
不同模态的数据有完全不同的特征空间:
# 图像和文本的嵌入维度差异
# 图像: 224x224x3 像素 → ViT → 196 tokens × 768 dim
# 文本: "A cat sitting on a table" → BERT → 7 tokens × 768 dim
# 挑战1: 粒度对齐
# 一张图 = 196 个视觉 token
# "A cat" = 2 个文本 token
# 如何让这两者在语义空间对齐?
# 挑战2: 语义对齐
# 图像中的"红色"是像素值 (255, 0, 0)
# 文本中的"红色"是一个 token
# 如何让模型理解它们是同一个概念?
# 挑战3: 信息密度
# 图像包含大量冗余信息(背景、光照)
# 文本通常是高度压缩的语义
# 如何提取图像的语义精华?二、CLIP:对比学习的革命
2.1 核心思想
CLIP(Contrastive Language-Image Pre-training)的核心创新极其简洁:用 4 亿个图文对,通过对比学习让图像和文本在同一个嵌入空间中对齐。
CLIP 训练过程:
Batch (N=4 个图文对):
Image1 ←→ Text1: "a photo of a dog"
Image2 ←→ Text2: "a photo of a cat"
Image3 ←→ Text3: "a photo of a car"
Image4 ←→ Text4: "a photo of a bird"
编码:
I1, I2, I3, I4 = ImageEncoder([Image1, Image2, Image3, Image4])
T1, T2, T3, T4 = TextEncoder([Text1, Text2, Text3, Text4])
相似度矩阵 (N×N):
T1 T2 T3 T4
I1 [ 0.9 -0.1 -0.2 -0.1 ] ← I1 应该和 T1 最相似
I2 [-0.1 0.9 -0.1 -0.2 ] ← I2 应该和 T2 最相似
I3 [-0.2 -0.1 0.9 -0.1 ] ← I3 应该和 T3 最相似
I4 [-0.1 -0.2 -0.1 0.9 ] ← I4 应该和 T4 最相似
对比损失 (InfoNCE):
最大化对角线(正样本对)的相似度
最小化非对角线(负样本对)的相似度
Loss = -mean(log(diagonal / row_sum))2.2 CLIP 架构详解
import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import CLIPModel, CLIPProcessor
# 使用预训练 CLIP
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
# ---- 零样本分类 ----
from PIL import Image
image = Image.open("test.jpg") # 一张狗的照片
# 用自然语言定义类别
candidate_texts = [
"a photo of a dog",
"a photo of a cat",
"a photo of a car",
"a photo of a bird",
]
inputs = processor(
text=candidate_texts,
images=image,
return_tensors="pt",
padding=True
)
with torch.no_grad():
outputs = model(**inputs)
# 图像-文本相似度
logits_per_image = outputs.logits_per_image # [1, 4]
probs = F.softmax(logits_per_image, dim=-1)
for text, prob in zip(candidate_texts, probs[0]):
print(f"{text}: {prob:.2%}")
# a photo of a dog: 95.32%
# a photo of a cat: 2.10%
# a photo of a car: 1.50%
# a photo of a bird: 1.08%2.3 从零实现 CLIP 训练
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
class CLIPModelFromScratch(nn.Module):
"""CLIP 模型简化实现"""
def __init__(
self,
image_embed_dim: int = 768,
text_embed_dim: int = 512,
projection_dim: int = 512,
temperature: float = 0.07
):
super().__init__()
self.temperature = nn.Parameter(torch.tensor(temperature))
# 图像编码器(使用预训练 ViT)
from transformers import ViTModel
self.image_encoder = ViTModel.from_pretrained("google/vit-base-patch16-224")
self.image_projection = nn.Linear(image_embed_dim, projection_dim)
# 文本编码器(使用预训练 BERT)
from transformers import BertModel
self.text_encoder = BertModel.from_pretrained("bert-base-uncased")
self.text_projection = nn.Linear(text_embed_dim, projection_dim)
# LayerNorm
self.image_norm = nn.LayerNorm(projection_dim)
self.text_norm = nn.LayerNorm(projection_dim)
def encode_image(self, pixel_values):
"""编码图像 → 归一化的嵌入向量"""
img_features = self.image_encoder(pixel_values).last_hidden_state[:, 0] # [CLS] token
img_embeds = self.image_projection(img_features)
img_embeds = self.image_norm(img_embeds)
return F.normalize(img_embeds, dim=-1)
def encode_text(self, input_ids, attention_mask):
"""编码文本 → 归一化的嵌入向量"""
text_features = self.text_encoder(input_ids, attention_mask=attention_mask).pooler_output
text_embeds = self.text_projection(text_features)
text_embeds = self.text_norm(text_embeds)
return F.normalize(text_embeds, dim=-1)
def forward(self, pixel_values, input_ids, attention_mask):
"""前向传播:计算对比损失"""
img_embeds = self.encode_image(pixel_values) # [B, D]
text_embeds = self.encode_text(input_ids, attention_mask) # [B, D]
# 相似度矩阵 [B, B]
logit_scale = 1.0 / self.temperature.exp()
logits_per_image = logit_scale * img_embeds @ text_embeds.t()
logits_per_text = logits_per_image.t()
# 对比损失(对称 InfoNCE)
labels = torch.arange(img_embeds.size(0), device=img_embeds.device)
loss_img = F.cross_entropy(logits_per_image, labels)
loss_text = F.cross_entropy(logits_per_text, labels)
loss = (loss_img + loss_text) / 2
return loss, logits_per_image
def clip_contrastive_loss(image_embeds, text_embeds, temperature=0.07):
"""
CLIP 对比损失的核心实现
"""
# 归一化
img = F.normalize(image_embeds, dim=-1)
txt = F.normalize(text_embeds, dim=-1)
# 相似度矩阵
logits = img @ txt.t() / temperature # [B, B]
# 标签:对角线为正样本
labels = torch.arange(img.size(0), device=img.device)
# 对称损失
loss_i2t = F.cross_entropy(logits, labels) # 图像→文本
loss_t2i = F.cross_entropy(logits.t(), labels) # 文本→图像
return (loss_i2t + loss_t2i) / 22.4 CLIP 的应用场景
# 应用1: 图文检索
def image_text_retrieval(query_image, text_database, model, processor, top_k=5):
"""用图像检索最相关的文本"""
# 编码查询图像
img_inputs = processor(images=query_image, return_tensors="pt")
with torch.no_grad():
img_embed = model.get_image_features(**img_inputs)
img_embed = F.normalize(img_embed, dim=-1)
# 编码文本库
text_inputs = processor(text=text_database, return_tensors="pt", padding=True)
with torch.no_grad():
text_embeds = model.get_text_features(**text_inputs)
text_embeds = F.normalize(text_embeds, dim=-1)
# 计算相似度
similarities = (img_embed @ text_embeds.t()).squeeze(0)
top_indices = similarities.topk(top_k).indices
return [(text_database[i], similarities[i].item()) for i in top_indices]
# 应用2: 零样本图像分类(自定义类别)
def zero_shot_classify(image, class_names, model, processor):
"""用自然语言定义类别,实现零样本分类"""
# Prompt engineering: "a photo of a {class}" 比单纯 "{class}" 效果好
prompts = [f"a photo of a {name}" for name in class_names]
inputs = processor(text=prompts, images=image, return_tensors="pt", padding=True)
with torch.no_grad():
outputs = model(**inputs)
probs = F.softmax(outputs.logits_per_image, dim=-1)
results = [(class_names[i], probs[0][i].item()) for i in range(len(class_names))]
results.sort(key=lambda x: x[1], reverse=True)
return results
# 应用3: 图像聚类
def image_clustering(images, model, processor, n_clusters=5):
"""用 CLIP 嵌入进行图像聚类"""
from sklearn.cluster import KMeans
embeds = []
for img in images:
inputs = processor(images=img, return_tensors="pt")
with torch.no_grad():
embed = model.get_image_features(**inputs)
embeds.append(F.normalize(embed, dim=-1))
embeds = torch.cat(embeds).numpy()
kmeans = KMeans(n_clusters=n_clusters, random_state=42)
labels = kmeans.fit_predict(embeds)
return labels三、BLIP-2:Q-Former 的精妙设计
3.1 BLIP-2 架构总览
BLIP-2 的核心创新是 Q-Former——一个轻量级的 Transformer,用一组可学习的 Query 从冻结的图像编码器中提取与语言相关的视觉特征。
BLIP-2 架构:
┌──────────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ ┌─────────────┐ ┌──────────┐ │
│ │ 冻结的 │ │ Q-Former │ │ 冻结的 │ │
│ │ Image │───▶│ (可训练) │───▶│ LLM │ │
│ │ Encoder │ │ │ │ │ │
│ │ (ViT) │ │ 32个Query │ │ (OPT/ │ │
│ │ 冻结! │ │ × 768 dim │ │ FlanT5) │ │
│ └──────────┘ └─────────────┘ │ 冻结! │ │
│ │ └──────────┘ │
│ │ │ │
│ Cross-Attention │ │
│ (Q←Learned Queries, │ │
│ KV←Image Features) │ │
│ ▼ │
│ 文本输出 │
└──────────────────────────────────────────────────────┘
Q-Former 两阶段训练:
阶段1: 表示学习(图文对比 + 图文匹配 + 图文生成)
阶段2: 生成学习(将Q-Former输出连接到冻结LLM)3.2 Q-Former 详解
import torch
import torch.nn as nn
class QFormer(nn.Module):
"""Q-Former 简化实现"""
def __init__(
self,
num_queries: int = 32,
query_dim: int = 768,
num_heads: int = 12,
num_layers: int = 6,
image_embed_dim: int = 1024 # ViT-L 的输出维度
):
super().__init__()
# 可学习的 Query embeddings
self.query_tokens = nn.Parameter(
torch.randn(num_queries, query_dim)
)
# 自注意力层(Query 之间)
self.self_attn_layers = nn.ModuleList([
nn.MultiheadAttention(query_dim, num_heads, batch_first=True)
for _ in range(num_layers)
])
# 交叉注意力层(Query → Image Features)
self.cross_attn_layers = nn.ModuleList([
nn.MultiheadAttention(query_dim, num_heads, batch_first=True)
for _ in range(num_layers)
])
# FFN
self.ffn_layers = nn.ModuleList([
nn.Sequential(
nn.Linear(query_dim, query_dim * 4),
nn.GELU(),
nn.Linear(query_dim * 4, query_dim),
nn.LayerNorm(query_dim)
)
for _ in range(num_layers)
])
# LayerNorms
self.self_attn_norm = nn.ModuleList([
nn.LayerNorm(query_dim) for _ in range(num_layers)
])
self.cross_attn_norm = nn.ModuleList([
nn.LayerNorm(query_dim) for _ in range(num_layers)
])
def forward(self, image_features: torch.Tensor) -> torch.Tensor:
"""
Args:
image_features: [B, N_img, D_img] 图像编码器的输出
Returns:
query_output: [B, num_queries, query_dim] 提取的视觉特征
"""
B = image_features.size(0)
# 扩展 query tokens 到 batch 维度
queries = self.query_tokens.unsqueeze(0).expand(B, -1, -1) # [B, 32, 768]
for i in range(len(self.self_attn_layers)):
# 1. Self-Attention(Query 之间交互)
residual = queries
attn_out, _ = self.self_attn_layers[i](
queries, queries, queries
)
queries = self.self_attn_norm[i](residual + attn_out)
# 2. Cross-Attention(Query 关注 Image Features)
residual = queries
cross_out, _ = self.cross_attn_layers[i](
queries, image_features, image_features # Q←Query, K,V←Image
)
queries = self.cross_attn_norm[i](residual + cross_out)
# 3. FFN
queries = queries + self.ffn_layers[i](queries)
return queries # [B, 32, 768] — 32 个视觉 token
# BLIP-2 三阶段训练目标
class BLIP2TrainingObjectives:
"""BLIP-2 第一阶段的三个训练目标"""
@staticmethod
def image_text_contrastive_loss(query_out, text_embeds):
"""目标1: 图文对比学习
Query 输出(取平均)与文本嵌入对比"""
query_mean = query_out.mean(dim=1) # [B, D]
query_mean = F.normalize(query_mean, dim=-1)
text_embeds = F.normalize(text_embeds, dim=-1)
logits = query_mean @ text_embeds.t() / 0.07
labels = torch.arange(query_mean.size(0), device=query_mean.device)
loss = (F.cross_entropy(logits, labels) +
F.cross_entropy(logits.t(), labels)) / 2
return loss
@staticmethod
def image_text_matching_loss(query_out, text_embeds, labels):
"""目标2: 图文匹配(二分类)
判断图像和文本是否匹配"""
# 用 query 和 text 的交互特征做二分类
combined = torch.cat([
query_out.mean(dim=1),
text_embeds
], dim=-1)
logits = nn.Linear(combined.size(-1), 2)(combined)
return F.cross_entropy(logits, labels)
@staticmethod
def image_grounding_loss(query_out, text_tokens):
"""目标3: 图文生成
基于 Query 输出生成文本描述"""
# 用因果注意力生成文本
# 这里简化为 LM loss
return F.cross_entropy(text_tokens[:, 1:], text_tokens[:, :-1])3.3 BLIP-2 实战使用
from transformers import Blip2Processor, Blip2ForConditionalGeneration
from PIL import Image
# 加载 BLIP-2
processor = Blip2Processor.from_pretrained("Salesforce/blip2-opt-2.7b")
model = Blip2ForConditionalGeneration.from_pretrained(
"Salesforce/blip2-opt-2.7b",
torch_dtype=torch.float16,
device_map="auto"
)
image = Image.open("example.jpg")
# ---- 图像描述生成 ----
inputs = processor(images=image, return_tensors="pt").to("cuda", torch.float16)
generated_ids = model.generate(**inputs, max_new_tokens=50)
description = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
print(f"描述: {description}")
# ---- 视觉问答(VQA)----
question = "What color is the car in the image?"
inputs = processor(images=image, text=question, return_tensors="pt").to("cuda", torch.float16)
generated_ids = model.generate(**inputs, max_new_tokens=50)
answer = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
print(f"回答: {answer}")四、LLaVA:视觉指令微调的突破
4.1 架构设计
LLaVA(Large Language and Vision Assistant)的架构极其简洁——CLIP ViT + MLP 投影层 + LLM,但效果惊人。
LLaVA 架构:
┌──────────────────────────────────────────────────────┐
│ │
│ 输入图像 │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ CLIP ViT-L/14│ (冻结,预训练) │
│ │ 224×224 输入 │ │
│ │ → 256 patches │ │
│ │ × 1024 dim │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ MLP Projector│ (可训练) │
│ │ 1024 → 4096 │ (2层 MLP + GELU) │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ 视觉Token (256个) 文本Token │ │
│ │ [V1][V2]...[V256] [USER][图像是..] │ │
│ │ │ │
│ │ LLM (Vicuna/Llama) │ │
│ │ (LoRA 微调) │ │
│ └──────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 文本输出 │
└──────────────────────────────────────────────────────┘
关键设计:
1. 视觉编码器冻结(复用CLIP的视觉理解能力)
2. 投影层将视觉特征映射到LLM的嵌入空间
3. 视觉Token与文本Token拼接后送入LLM
4. LLM通过LoRA微调以理解视觉Token4.2 LLaVA 训练流程
# LLaVA 两阶段训练
# 阶段1: 预训练投影层(对齐视觉和语言空间)
# - 冻结 ViT 和 LLM
# - 只训练 MLP Projector
# - 数据: 558K 图文对(CC3M + CC12M 子集)
# - 目标: 让投影层学会将视觉特征"翻译"成LLM能理解的Token
# 阶段2: 指令微调(教会模型回答视觉问题)
# - 冻结 ViT
# - 训练 LLM(LoRA)和 Projector
# - 数据: 158K 多模态指令数据
# - 58K 对话(GPT-4生成)
# - 23K 详细描述
# - 77K 复杂推理
class LLaVAProjector(nn.Module):
"""LLaVA 视觉-语言投影层"""
def __init__(self, vision_dim: int = 1024, llm_dim: int = 4096):
super().__init__()
self.mlp = nn.Sequential(
nn.Linear(vision_dim, llm_dim),
nn.GELU(),
nn.Linear(llm_dim, llm_dim),
)
def forward(self, image_features: torch.Tensor) -> torch.Tensor:
"""
[B, num_patches, vision_dim] → [B, num_patches, llm_dim]
"""
return self.mlp(image_features)
def llava_forward(
image_features, # [B, 256, 1024] 来自 CLIP ViT
text_input_ids, # [B, seq_len] 文本 token
projector, # MLP 投影层
llm, # 语言模型
tokenizer
):
"""LLaVA 前向传播"""
# 1. 投影视觉特征
visual_embeds = projector(image_features) # [B, 256, 4096]
# 2. 获取文本嵌入
text_embeds = llm.get_input_embeddings()(text_input_ids) # [B, seq_len, 4096]
# 3. 拼接视觉和文本嵌入
# [视觉Token] + [文本Token]
combined_embeds = torch.cat([visual_embeds, text_embeds], dim=1)
# 4. 送入 LLM
outputs = llm(inputs_embeds=combined_embeds)
return outputs.logits4.3 LLaVA 实战
# 使用 LLaVA 进行推理
from transformers import LlavaForConditionalGeneration, AutoProcessor
from PIL import Image
import torch
# 加载模型
processor = AutoProcessor.from_pretrained("llava-hf/llava-1.5-7b-hf")
model = LlavaForConditionalGeneration.from_pretrained(
"llava-hf/llava-1.5-7b-hf",
torch_dtype=torch.float16,
device_map="auto",
load_in_4bit=True, # 4bit量化加载,节省显存
)
image = Image.open("meeting_room.jpg")
# 对话模式
conversation = [
{
"role": "user",
"content": [
{"type": "image"},
{"type": "text", "text": "描述这张图片中的场景,如果有问题请指出。"},
],
},
]
prompt = processor.apply_chat_template(conversation, add_generation_prompt=True)
inputs = processor(images=image, text=prompt, return_tensors="pt").to("cuda", torch.float16)
output = model.generate(**inputs, max_new_tokens=500)
response = processor.decode(output[0], skip_special_tokens=True)
print(response)4.4 自定义 LLaVA 微调
# LLaVA LoRA 微调示例
from peft import LoraConfig, get_peft_model
from transformers import TrainingArguments, Trainer
# 1. 准备 LoRA 配置
lora_config = LoraConfig(
r=64,
lora_alpha=128,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
)
# 2. 应用 LoRA
model = get_peft_model(model, lora_config)
# 3. 准备训练数据
class LLaVADataset(torch.utils.data.Dataset):
"""LLaVA 指令微调数据集"""
def __init__(self, data_list, processor):
self.data = data_list
self.processor = processor
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
item = self.data[idx]
image = Image.open(item["image_path"]).convert("RGB")
# 构造对话
conversation = [
{"role": "user", "content": [
{"type": "image"},
{"type": "text", "text": item["question"]}
]},
{"role": "assistant", "content": item["answer"]}
]
prompt = self.processor.apply_chat_template(
conversation, add_generation_prompt=False
)
inputs = self.processor(
images=image,
text=prompt,
return_tensors="pt",
padding=True
)
# 移除 batch 维度
inputs = {k: v.squeeze(0) for k, v in inputs.items()}
inputs["labels"] = inputs["input_ids"].clone()
return inputs
# 4. 训练
training_args = TrainingArguments(
output_dir="./llava-lora-finetuned",
num_train_epochs=3,
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
learning_rate=2e-5,
warmup_ratio=0.03,
logging_steps=10,
save_steps=500,
bf16=True,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_dataset,
)
trainer.train()五、多模态 RAG 应用
5.1 多模态 RAG 架构
传统 RAG:
用户问题 → 文本检索 → 文本片段 → LLM → 回答
多模态 RAG:
用户问题 ──┬──→ 文本检索 ──→ 文本片段 ──┐
│ │
└──→ 图像检索 ──→ 相关图像 ──┤
│
多模态LLM ──┤
(LLaVA/ │
GPT-4V) │
│ │
▼ │
综合回答 ───┘5.2 完整实现
import torch
import numpy as np
from PIL import Image
from transformers import (
CLIPModel, CLIPProcessor,
LlavaForConditionalGeneration, AutoProcessor as LlavaProcessor
)
import faiss
class MultimodalRAG:
"""多模态 RAG 系统"""
def __init__(self, clip_model_name="openai/clip-vit-base-patch32"):
# 检索模型(CLIP)
self.clip_model = CLIPModel.from_pretrained(clip_model_name)
self.clip_processor = CLIPProcessor.from_pretrained(clip_model_name)
# 生成模型(LLaVA)
self.llava_processor = LlavaProcessor.from_pretrained("llava-hf/llava-1.5-7b-hf")
self.llava_model = LlavaForConditionalGeneration.from_pretrained(
"llava-hf/llava-1.5-7b-hf",
torch_dtype=torch.float16,
device_map="auto",
load_in_4bit=True,
)
# 向量索引
self.text_index = None
self.image_index = None
self.text_store = []
self.image_store = []
def build_index(self, documents: list[dict]):
"""构建多模态索引
Args:
documents: [{"text": "...", "image_path": "..."}, ...]
"""
text_embeds = []
image_embeds = []
for doc in documents:
# 文本嵌入
if "text" in doc:
inputs = self.clip_processor(
text=doc["text"], return_tensors="pt", padding=True, truncation=True
)
with torch.no_grad():
embed = self.clip_model.get_text_features(**inputs)
embed = embed / embed.norm(dim=-1, keepdim=True)
text_embeds.append(embed.numpy())
self.text_store.append(doc["text"])
# 图像嵌入
if "image_path" in doc:
image = Image.open(doc["image_path"]).convert("RGB")
inputs = self.clip_processor(images=image, return_tensors="pt")
with torch.no_grad():
embed = self.clip_model.get_image_features(**inputs)
embed = embed / embed.norm(dim=-1, keepdim=True)
image_embeds.append(embed.numpy())
self.image_store.append(image)
# 构建 FAISS 索引
if text_embeds:
text_dim = text_embeds[0].shape[-1]
self.text_index = faiss.IndexFlatIP(text_dim)
self.text_index.add(np.vstack(text_embeds))
if image_embeds:
image_dim = image_embeds[0].shape[-1]
self.image_index = faiss.IndexFlatIP(image_dim)
self.image_index.add(np.vstack(image_embeds))
def retrieve(self, query: str, top_k: int = 3) -> dict:
"""多模态检索"""
# 编码查询
inputs = self.clip_processor(text=query, return_tensors="pt")
with torch.no_grad():
query_embed = self.clip_model.get_text_features(**inputs)
query_embed = (query_embed / query_embed.norm(dim=-1, keepdim=True)).numpy()
results = {"texts": [], "images": []}
# 文本检索
if self.text_index is not None:
scores, indices = self.text_index.search(query_embed, top_k)
for score, idx in zip(scores[0], indices[0]):
results["texts"].append({
"text": self.text_store[idx],
"score": float(score)
})
# 图像检索
if self.image_index is not None:
scores, indices = self.image_index.search(query_embed, top_k)
for score, idx in zip(scores[0], indices[0]):
results["images"].append({
"image": self.image_store[idx],
"score": float(score)
})
return results
def answer(self, query: str, top_k: int = 3) -> str:
"""多模态 RAG 回答"""
# 1. 检索
retrieved = self.retrieve(query, top_k=top_k)
# 2. 构造多模态 prompt
context_text = "\n".join([r["text"] for r in retrieved["texts"]])
retrieved_images = [r["image"] for r in retrieved["images"]]
# 3. 如果有图像,用 LLaVA 生成
if retrieved_images:
content = []
for img in retrieved_images[:2]: # 最多传2张图
content.append({"type": "image"})
content.append({
"type": "text",
"text": f"基于以下信息和图像,回答问题。\n\n参考资料:\n{context_text}\n\n问题: {query}"
})
conversation = [{"role": "user", "content": content}]
prompt = self.llava_processor.apply_chat_template(
conversation, add_generation_prompt=True
)
inputs = self.llava_processor(
images=retrieved_images[:2],
text=prompt,
return_tensors="pt"
).to("cuda", torch.float16)
output = self.llava_model.generate(**inputs, max_new_tokens=300)
return self.llava_processor.decode(output[0], skip_special_tokens=True)
# 4. 无图像,仅用文本
else:
return f"基于文本检索结果回答:\n{context_text}\n\n问题: {query}"
# 使用示例
rag = MultimodalRAG()
# 构建索引
documents = [
{"text": "iPhone 15 Pro 采用钛金属边框,搭载A17 Pro芯片。", "image_path": "iphone15.jpg"},
{"text": "Galaxy S24 Ultra 配备2亿像素主摄和S Pen。", "image_path": "s24.jpg"},
# ...
]
rag.build_index(documents)
# 查询
answer = rag.answer("哪款手机更适合摄影?")
print(answer)六、面试要点
Q1: CLIP 的对比学习为什么有效?
答:CLIP 用 4 亿图文对训练,核心是 InfoNCE 损失——在 batch 内,拉近匹配的图文对、推远不匹配的图文对。这有效是因为:(1) 数据量大,覆盖广泛的视觉概念;(2) 对比学习天然适合跨模态对齐——不需要精确的图文对应标注,只需要知道"这个图配这个文比配其他文更合适";(3) 双塔架构解耦了图像和文本编码器,可以分别用于零样本分类、检索等下游任务。
Q2: BLIP-2 的 Q-Former 为什么用可学习的 Query?
答:(1) 固定数量的 Query(32个)压缩了变长的图像特征(可能数百个 patch),降低了计算量;(2) 可学习的 Query 通过训练学会了"问什么问题"——即从图像中提取与语言任务最相关的视觉信息;(3) 解耦了图像编码器和 LLM——Q-Former 输出固定维度的 token,可以直接接入任何 LLM,不需要修改 LLM 架构。
Q3: LLaVA 为什么比 BLIP-2 更简单但效果不差?
答:(1) LLaVA 直接将 CLIP 视觉特征通过 MLP 投影后拼接到文本 token 前面,让 LLM 原生处理——充分利用了 LLM 已有的注意力机制来处理视觉信息;(2) LLaVA 的训练数据质量更高——用 GPT-4 基于图像生成指令数据,覆盖了对话、描述、推理等多种任务;(3) 简单架构更容易扩展——LLaVA 可以轻松换用更强的 LLM(如 Llama-3)或更强的视觉编码器(如 SigLIP)。
Q4: 多模态 RAG 和传统 RAG 的区别是什么?
答:(1) 检索维度——传统 RAG 只检索文本,多模态 RAG 同时检索文本和图像(用 CLIP 等模型编码);(2) 生成模型——传统 RAG 用纯文本 LLM,多模态 RAG 用多模态 LLM(如 LLaVA、GPT-4V);(3) 应用场景——多模态 RAG 可以处理"这张图中的产品有什么问题"这类需要视觉理解的问题;(4) 索引构建——需要同时构建文本向量索引和图像向量索引,且两者要在同一嵌入空间中。
Q5: 如何选择多模态模型?
答:看任务类型:(1) 图文检索/零样本分类 → CLIP(轻量、快速);(2) 图像描述/VQA → BLIP-2 或 LLaVA;(3) 复杂视觉推理/多图理解 → LLaVA-1.5 或 GPT-4V;(4) 需要中文支持 → Qwen-VL 或 CogVLM;(5) 端侧部署 → MobileCLIP 或 LLaVA-Phi(小模型版本)。
七、避坑指南
坑1: CLIP 零样本分类的 prompt 影响巨大
# ❌ 效果差
prompts = ["dog", "cat", "car"]
# ✅ 效果好("a photo of a {}" 模板可提升5-10%)
prompts = ["a photo of a dog", "a photo of a cat", "a photo of a car"]
# ✅✅ 效果更好(加入领域信息)
prompts = [
"a photo of a dog, a domestic animal",
"a photo of a cat, a domestic animal",
"a photo of a car, a vehicle"
]
# OpenCLIP 的 prompt ensemble 更进一步
# 对每个类别生成多个 prompt,取平均嵌入
prompts_per_class = {
"dog": [
"a photo of a dog",
"a blurry photo of a dog",
"a centered satellite photo of a dog",
"a bright photo of a dog",
]
}坑2: LLaVA 推理时图像预处理不一致
# ❌ 用自己的 resize 逻辑
image = Image.open("test.jpg")
image = image.resize((224, 224)) # 手动resize,可能和训练时不一致
# ✅ 用 processor 的预处理
image = Image.open("test.jpg")
inputs = processor(images=image, return_tensors="pt")
# processor 内部会做正确的 resize、normalize、center_crop坑3: 多模态模型的 context window 被图像 token 占满
# LLaVA-1.5 用 256 个视觉 token(224x224 输入)
# LLaVA-1.5-HD 用 576 个视觉 token(336x336 输入)
# 如果 LLM context window 是 4096,图像就占了 6%-14%
# ❌ 一次传入多张图导致超长
images = [Image.open(f"img{i}.jpg") for i in range(10)]
# 10张图 = 2560 视觉token,加上文本可能超出context window
# ✅ 限制图像数量或使用更高分辨率的单图
images = images[:2] # 最多2张坑4: BLIP-2 的 Q-Former 输出维度与 LLM 不匹配
# Q-Former 输出: [B, 32, 768]
# LLM (OPT-2.7B) 输入: [B, seq, 2560]
# 需要一个线性投影层连接
# 这个投影层在 BLIP-2 中已经内置
# 但如果你想自己搭建类似架构,别忘了加这个层坑5: 忽略视觉编码器的分辨率限制
# CLIP ViT-L/14 训练时用 224x224 分辨率
# 如果你传入 4K 图像,processor 会 resize 到 224x224
# 但这可能导致小物体信息丢失
# ✅ 对于需要细粒度理解的场景,使用高分辨率变体
# CLIP ViT-L/14@336px 或 LLaVA-1.5-HD(支持 672x672)
# 或者用切片策略:将大图切分为多个小图分别编码八、总结
多模态大模型的发展趋势:架构越来越简洁,能力越来越强。
从 CLIP 的双塔对比学习,到 BLIP-2 的 Q-Former 桥接,再到 LLaVA 的直接投影——每一次简化都伴随着训练数据和策略的升级。核心启示:
- CLIP 开创了视觉语言对齐的范式:对比学习 + 海量图文对 = 强大的零样本能力
- Q-Former 是高效的视觉信息压缩器:用 32 个可学习 Query 提取最相关的视觉特征
- LLaVA 证明了简单架构 + 好数据的力量:MLP 投影 + GPT-4 生成的指令数据 = SOTA 性能
- 多模态 RAG 是下一个应用爆发点:将检索增强从文本扩展到视觉,打开企业级应用空间