各位同仁,大家好。今天,我们来深入探讨一个在人工智能领域日益受到关注,并且极具实用价值的课题:多模态检索(Multi-modal Retrieval)。具体来说,我们将聚焦于如何在一个统一的向量空间内,优雅地实现“以图搜文”与“以文搜图”的交叉链检索。
作为一名编程专家,我深知理论与实践的结合至关重要。因此,本次讲座将不仅仅停留在概念层面,更会深入到代码实现细节,剖析其背后的逻辑和工程考量。
一、多模态检索:跨越感官的桥梁
想象一下这样的场景:您看到一张精美的图片,想要找出所有描述这张图片的文字资料;或者您脑海中有一个模糊的文字描述,希望找到与之匹配的图像。这就是多模态检索的核心任务。它打破了传统单模态检索(如“以文搜文”或“以图搜图”)的界限,使得不同模态的信息能够相互查询和理解。
什么是模态? 简单来说,模态就是数据呈现的不同形式。图像是一种模态,文本是另一种模态,语音、视频、3D模型等也都是不同的模态。
多模态检索的挑战在哪里?
最大的挑战在于所谓的“模态鸿沟”(Modality Gap)。图像数据是像素的矩阵,捕捉的是视觉特征;文本数据是字符序列,承载的是语义信息。这两种数据在底层结构上截然不同,直接进行比较几乎不可能。我们无法简单地将一个像素值与一个单词进行匹配。
我们的目标,就是构建一座桥梁,跨越这个鸿沟。这座桥梁的核心思想,是将不同模态的数据映射到一个共同的、语义丰富的向量空间中。在这个共享空间里,如果一张图片和一段文字描述的是同一个概念或实体,那么它们对应的向量就应该彼此靠近;反之,如果它们描述的是不相关的概念,它们的向量就应该彼此远离。一旦实现了这一点,无论是“以图搜文”还是“以文搜图”,都将归结为简单的向量相似度搜索问题。
二、共享向量空间:统一的语义语言
想象一个多维空间,其中每个维度都代表了某种语义特征。在这个空间里,代表“猫”的图片和描述“猫”的文字,都会被映射到空间中“猫”概念的附近。这就是我们所追求的共享向量空间。
如何实现这个共享空间?
核心思路是学习(Learning)。我们不能手动定义这些语义特征,而是需要通过机器学习模型,特别是深度学习模型,从大量的多模态数据中自动学习出这种映射关系。这个过程通常涉及以下几个关键步骤:
- 编码器(Encoders):为每种模态设计专门的编码器,将原始数据(图像、文本)转换成高维的、模态特定的特征向量。
- 投影头(Projection Heads):在编码器之后,通常会增加一些层(如全连接层),将模态特定的特征向量进一步投影到共享向量空间中。
- 对比学习(Contrastive Learning):这是学习共享空间的关键机制。通过定义一个损失函数,鼓励模型将语义相关的跨模态数据点拉近,将不相关的数据点推远。
让我们逐步深入这些组件。
三、模态编码器:将原始数据转化为向量
在将不同模态的数据映射到共享空间之前,我们首先需要将每种模态的原始数据转换为其自身的数值表示,即特征向量。这些特征向量捕捉了该模态的内在信息。
3.1 文本编码器:从词语到语义向量
早期文本检索依赖于关键词匹配或基于词袋模型(Bag-of-Words, BoW)、TF-IDF等统计方法。这些方法忽略了词语之间的语义关系和语序信息,无法捕捉到文本的深层含义。
现代文本编码器主要基于神经网络,尤其是Transformer架构。它们能够生成上下文相关的词嵌入和句子嵌入,极大地提升了语义理解能力。
常用文本编码器:
- Word2Vec / GloVe: 学习独立的词向量,但在句子层面缺乏上下文感知。
- BERT (Bidirectional Encoder Representations from Transformers): 通过预训练任务(如Masked Language Model和Next Sentence Prediction)学习双向上下文表示。它能为每个词生成一个上下文相关的向量,并且可以获取整个句子的表示(通常是
[CLS]token的输出)。 - RoBERTa, ALBERT, ELECTRA, etc.: BERT的各种改进版本。
- Sentence-BERT (SBERT): 针对句子相似度任务进行了微调的BERT变体。它能直接生成高质量的句子向量,使得相似句子在向量空间中距离更近,非常适合检索任务。
代码示例:使用Sentence-BERT编码文本
我们将使用sentence-transformers库,它封装了多种预训练的Sentence-BERT模型,使用起来非常方便。
import torch
from sentence_transformers import SentenceTransformer
# 1. 加载预训练的Sentence-BERT模型
# 'all-MiniLM-L6-v2' 是一个轻量级但效果不错的模型
# 也可以选择更大的模型如 'all-mpnet-base-v2' 获取更好的性能
print("Loading Sentence-BERT model...")
text_encoder = SentenceTransformer('all-MiniLM-L6-v2')
print("Model loaded.")
# 2. 准备一些文本数据
texts = [
"A cat sitting on a mat.",
"The feline is resting on a rug.",
"A dog chasing a ball in the park.",
"A man is reading a book.",
"A small kitten playing with a toy mouse."
]
# 3. 编码文本,获取文本向量
# text_encoder.encode 方法会自动处理分词、padding和模型推理
print("Encoding texts...")
text_embeddings = text_encoder.encode(texts, convert_to_tensor=True)
print(f"Text embeddings shape: {text_embeddings.shape}") # (num_texts, embedding_dim)
# 4. 打印一些结果,展示向量维度
for i, text in enumerate(texts):
print(f"Text: '{text}'")
print(f"Embedding for text {i}: {text_embeddings[i][:5]}...") # 打印前5个维度
print("-" * 30)
# 5. 简单演示相似度计算 (用于理解,不是共享空间的核心)
# 在共享空间中,我们期望语义相似的文本向量也靠近
from sklearn.metrics.pairwise import cosine_similarity
# 计算所有文本向量之间的余弦相似度
similarity_matrix = cosine_similarity(text_embeddings.cpu().numpy())
print("nCosine similarity matrix between texts:")
for i in range(len(texts)):
for j in range(len(texts)):
print(f"Sim('{texts[i]}', '{texts[j]}'): {similarity_matrix[i, j]:.4f}")
print("-" * 50)
# 可以看到 "A cat sitting on a mat." 和 "The feline is resting on a rug."
# 以及 "A cat sitting on a mat." 和 "A small kitten playing with a toy mouse." 的相似度会比较高。
输出示例片段:
Loading Sentence-BERT model...
Model loaded.
Encoding texts...
Text embeddings shape: torch.Size([5, 384])
Text: 'A cat sitting on a mat.'
Embedding for text 0: tensor([-0.0076, 0.0381, -0.0163, 0.0305, -0.0267])...
------------------------------
...
Cosine similarity matrix between texts:
Sim('A cat sitting on a mat.', 'A cat sitting on a mat.'): 1.0000
Sim('A cat sitting on a mat.', 'The feline is resting on a rug.'): 0.8146
Sim('A cat sitting on a mat.', 'A dog chasing a ball in the park.'): 0.0890
Sim('A cat sitting on a mat.', 'A man is reading a book.'): 0.0888
Sim('A cat sitting on a mat.', 'A small kitten playing with a toy mouse.'): 0.6128
--------------------------------------------------
...
从示例中我们可以看到,语义相似的句子(如“A cat sitting on a mat.”和“The feline is resting on a rug.”)的相似度确实很高,而不相关的句子(如“A cat sitting on a mat.”和“A dog chasing a ball in the park.”)的相似度则很低。这正是Sentence-BERT的强大之处。
3.2 图像编码器:从像素到视觉特征向量
图像编码器通常基于卷积神经网络(CNN)或Vision Transformer (ViT)。它们通过多层卷积和池化操作,逐步提取图像的局部特征,然后聚合为全局的、高层次的语义特征。
常用图像编码器架构:
- ResNet (Residual Network): 通过残差连接解决了深层网络训练中的梯度消失问题,是图像识别领域的里程碑。
- VGGNet: 强调深度和小卷积核,结构相对简单但效果出色。
- InceptionNet / GoogLeNet: 使用Inception模块,在保持计算效率的同时提升了网络的宽度和深度。
- EfficientNet: 通过复合缩放(compound scaling)在模型宽度、深度和分辨率之间找到了最优平衡。
- Vision Transformer (ViT): 将Transformer架构引入图像领域,将图像分割成小块(patches)作为序列输入。
在多模态检索中,我们通常会使用在ImageNet等大规模数据集上预训练好的图像分类模型作为特征提取器。我们只取模型在分类层之前的输出,作为图像的特征向量。
代码示例:使用预训练的ResNet编码图像
我们将使用torchvision库,它提供了多种预训练的图像模型。
import torch
import torchvision.transforms as transforms
from torchvision.models import resnet50, ResNet50_Weights
from PIL import Image
import requests
from io import BytesIO
# 1. 加载预训练的ResNet50模型
# ResNet50_Weights.IMAGENET1K_V2 是在ImageNet上预训练的权重
print("Loading ResNet50 model...")
image_encoder = resnet50(weights=ResNet50_Weights.IMAGENET1K_V2)
# 移除最后一层分类层,我们只需要特征向量
image_encoder = torch.nn.Sequential(*(list(image_encoder.children())[:-1]))
image_encoder.eval() # 设置为评估模式,不进行梯度计算和Dropout
print("Model loaded and classification head removed.")
# 2. 定义图像预处理流程
# ImageNet预训练模型通常需要特定的归一化参数
preprocess = transforms.Compose([
transforms.Resize(256), # 图像缩放
transforms.CenterCrop(224), # 中心裁剪
transforms.ToTensor(), # 转换为Tensor
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) # 归一化
])
# 3. 准备一些示例图片
# 我们可以从网络下载图片或使用本地图片
image_urls = [
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/Felis_catus-cat_on_snow.jpg/220px-Felis_catus-cat_on_snow.jpg", # Cat
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Bison_bison_in_Yellowstone_NP.jpg/220px-Bison_bison_in_Yellowstone_NP.jpg", # Bison
"https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Red_Fox_%28Vulpes_vulpes%29_in_Dorset.jpg/220px-Red_Fox_%28Vulpes_vulpes%29_in_Dorset.jpg", # Fox
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/36/Two_cats_playing.jpg/220px-Two_cats_playing.jpg" # Two cats
]
images = []
for url in image_urls:
response = requests.get(url)
img = Image.open(BytesIO(response.content)).convert('RGB')
images.append(img)
print(f"Loaded image from {url}")
# 4. 编码图像,获取图像向量
image_embeddings = []
with torch.no_grad(): # 在推理时不需要计算梯度
for img in images:
input_tensor = preprocess(img)
input_batch = input_tensor.unsqueeze(0) # 增加一个批次维度 (C, H, W) -> (1, C, H, W)
# 移动到GPU(如果可用)
if torch.cuda.is_available():
input_batch = input_batch.to('cuda')
image_encoder.to('cuda')
output_feature = image_encoder(input_batch)
# ResNet50的最后一层平均池化后输出是 (1, 2048, 1, 1),需要展平
image_embeddings.append(output_feature.squeeze().cpu()) # 移除所有维度为1的维度
image_embeddings = torch.stack(image_embeddings)
print(f"Image embeddings shape: {image_embeddings.shape}") # (num_images, embedding_dim)
# 5. 打印一些结果
for i, url in enumerate(image_urls):
print(f"Image URL: {url}")
print(f"Embedding for image {i}: {image_embeddings[i][:5]}...") # 打印前5个维度
print("-" * 30)
# 简单演示图像间相似度
similarity_matrix_img = cosine_similarity(image_embeddings.cpu().numpy())
print("nCosine similarity matrix between images:")
for i in range(len(image_urls)):
for j in range(len(image_urls)):
print(f"Sim(Image {i}, Image {j}): {similarity_matrix_img[i, j]:.4f}")
print("-" * 50)
输出示例片段:
Loading ResNet50 model...
Model loaded and classification head removed.
Loaded image from https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/Felis_catus-cat_on_snow.jpg/220px-Felis_catus-cat_on_snow.jpg
...
Image embeddings shape: torch.Size([4, 2048])
Image URL: https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/Felis_catus-cat_on_snow.jpg/220px-Felis_catus-cat_on_snow.jpg
Embedding for image 0: tensor([0.0000, 0.0000, 0.0000, 0.0000, 0.0000])... # 注意这里的0值可能是因为ReLU激活或模型结构,实际特征会分布
------------------------------
...
Cosine similarity matrix between images:
Sim(Image 0, Image 0): 1.0000
Sim(Image 0, Image 1): 0.6926 # Cat vs Bison
Sim(Image 0, Image 2): 0.7423 # Cat vs Fox
Sim(Image 0, Image 3): 0.8870 # Cat vs Two cats (higher, as expected)
--------------------------------------------------
...
ResNet的输出维度通常是2048维。我们看到,两只猫的图片(Image 0 和 Image 3)之间的相似度比猫和野牛(Image 0 和 Image 1)之间的相似度要高,这符合我们的预期。
四、跨模态对齐:学习共享向量空间
现在我们有了文本和图像的模态特定特征向量。但是,这些向量位于各自独立的特征空间中,维度可能也不同(如Sentence-BERT是384维,ResNet是2048维)。我们需要将它们投影到一个共同的共享空间,并确保语义相关的跨模态向量彼此靠近。
这正是对比学习(Contrastive Learning)发挥作用的地方。
4.1 对比学习的基本思想
对比学习的核心是:拉近正样本对(Positive Pairs)的距离,推开负样本对(Negative Pairs)的距离。
在多模态检索的语境下:
- 正样本对: 一张图片和它对应的正确描述文本。
- 负样本对: 一张图片和不相关的文本,或者一段文本和不相关的图片。
通过这种方式,模型被迫学习到能够区分语义相关与不相关样本的特征表示。
4.2 损失函数:InfoNCE Loss
InfoNCE (Information Noise Contrastive Estimation) Loss 是当前对比学习中非常流行和有效的损失函数,尤其是在像CLIP这样的大规模模型中。它源自于度量学习,目标是最大化正样本对之间的互信息。
假设我们有一个批次的 $N$ 个(图像,文本)对:${(I_1, T_1), (I_2, T_2), dots, (I_N, T_N)}$。
对于批次中的每个图像 $I_i$,我们将其对应的文本 $T_i$ 视为正样本,而批次中其他的 $N-1$ 个文本 $T_j (j neq i)$ 视为负样本。反之亦然,对于每个文本 $T_i$,对应的图像 $I_i$ 为正样本,其他 $I_j (j neq i)$ 为负样本。
InfoNCE Loss 的计算公式如下(针对图像到文本的匹配):
$$ L{I to T} = -frac{1}{N} sum{i=1}^{N} log frac{exp(text{sim}(I_i, Ti) / tau)}{sum{j=1}^{N} exp(text{sim}(I_i, T_j) / tau)} $$
同样地,针对文本到图像的匹配:
$$ L{T to I} = -frac{1}{N} sum{i=1}^{N} log frac{exp(text{sim}(T_i, Ii) / tau)}{sum{j=1}^{N} exp(text{sim}(T_i, I_j) / tau)} $$
总的损失函数是两者的平均:
$$ L = frac{1}{2} (L{I to T} + L{T to I}) $$
- $text{sim}(A, B)$ 通常是两个向量 $A$ 和 $B$ 之间的余弦相似度。
- $tau$ (tau) 是一个可学习的温度参数(temperature parameter),它控制着负样本的惩罚程度。小的 $tau$ 会使模型更专注于区分正负样本,而大的 $tau$ 则会使相似度分布更平滑。
核心思想: 对于每个 $(I_i, T_i)$ 对,我们希望 $I_i$ 与 $T_i$ 的相似度相对于 $I_i$ 与所有其他 $T_j$ 的相似度来说,尽可能地高。分母中的求和项起到了归一化的作用,使得模型在区分正负样本时更具鲁棒性。
4.3 联合学习架构:CLIP的启示
CLIP (Contrastive Language-Image Pre-training) 是OpenAI在2021年提出的一种非常成功的跨模态模型。它以其卓越的零样本(zero-shot)迁移能力震惊了业界。CLIP的架构完美地体现了我们所讨论的共享向量空间和对比学习。
CLIP架构概览:
| 组件 | 图像侧 | 文本侧 |
|---|---|---|
| 编码器 | 图像编码器(Image Encoder,通常是ResNet或ViT) | 文本编码器(Text Encoder,通常是Transformer) |
| 投影头 | 图像投影头(Image Projection Head) | 文本投影头(Text Projection Head) |
| 输出 | 图像嵌入向量(Image Embedding) | 文本嵌入向量(Text Embedding) |
| 共享空间 | 图像嵌入和文本嵌入被投影到同一个维度和空间 | |
| 训练目标 | InfoNCE Loss,最大化匹配对的相似度,最小化不匹配对的相似度 |
训练过程:
- 给定一个包含 $N$ 个(图像,文本)对的批次。
- 图像编码器将 $N$ 张图片编码为 $N$ 个图像特征向量。
- 文本编码器将 $N$ 段文本编码为 $N$ 个文本特征向量。
- 每个特征向量都通过各自的投影头,映射到共享的 $D$ 维向量空间,并进行 L2 归一化。
- 计算这 $N$ 个图像嵌入和 $N$ 个文本嵌入之间的所有 $N times N$ 对余弦相似度,形成一个相似度矩阵 $S$。
- 对角线上的元素 $S_{ii}$ 代表了匹配的(图像,文本)对的相似度。
- 使用 InfoNCE Loss(如上所述)进行优化,使对角线元素(正样本)的相似度尽可能高,非对角线元素(负样本)的相似度尽可能低。
代码示例:CLIP-like 训练循环 (概念性实现)
这个示例将模拟CLIP的训练过程,但为了简化,我们不会从头训练编码器,而是假设我们已经有了能够生成特征的编码器(如上面ResNet和Sentence-BERT的输出),然后我们训练一个简单的投影头来对齐它们。
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
# --- 1. 模拟数据生成 (实际中是编码器的输出) ---
# 假设我们有来自不同模态但语义相关的特征
# 比如,一个批次中有N个图像特征和N个文本特征
# 它们是预先通过各自的编码器得到的
# 并且我们知道 (image_features[i], text_features[i]) 是一对正样本
class DummyMultiModalDataset(Dataset):
def __init__(self, num_samples, img_feat_dim, text_feat_dim):
self.num_samples = num_samples
self.img_feat_dim = img_feat_dim
self.text_feat_dim = text_feat_dim
# 模拟生成一些特征,这里我们让它们在原始空间中有一些相关性
# 实际中这些是预训练编码器的输出
self.image_features = torch.randn(num_samples, img_feat_dim)
self.text_features = torch.randn(num_samples, text_feat_dim)
# 为了让它们“相关”,我们可以稍微调整一下,让对应对更接近
for i in range(num_samples):
# 引入一些噪声,但保持基本对应
self.image_features[i] += torch.randn(img_feat_dim) * 0.1
self.text_features[i] += torch.randn(text_feat_dim) * 0.1
# 简单地让一部分维度共享,模拟语义关联
common_dim = min(img_feat_dim, text_feat_dim)
self.image_features[i, :common_dim] = i / num_samples # 简单共享模式
self.text_features[i, :common_dim] = i / num_samples
def __len__(self):
return self.num_samples
def __getitem__(self, idx):
return self.image_features[idx], self.text_features[idx]
# --- 2. 定义投影头 (Projection Heads) ---
# 它们将模态特定的特征映射到共享的嵌入空间
class ProjectionHead(nn.Module):
def __init__(self, input_dim, output_dim, hidden_dim=512):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(hidden_dim, output_dim)
def forward(self, x):
return self.fc2(self.relu(self.fc1(x)))
# --- 3. 定义 CLIP-like 模型 ---
class CLIPModel(nn.Module):
def __init__(self, img_feat_dim, text_feat_dim, embed_dim=256):
super().__init__()
self.image_projection_head = ProjectionHead(img_feat_dim, embed_dim)
self.text_projection_head = ProjectionHead(text_feat_dim, embed_dim)
# 温度参数,通常是可学习的,这里简化为固定值
self.temperature = nn.Parameter(torch.tensor(0.07)) # 初始化为0.07,会通过log_softmax变成exp(0.07)
def forward(self, image_features, text_features):
# 投影到共享空间
image_embeddings = self.image_projection_head(image_features)
text_embeddings = self.text_projection_head(text_features)
# L2 归一化
image_embeddings = F.normalize(image_embeddings, p=2, dim=-1)
text_embeddings = F.normalize(text_embeddings, p=2, dim=-1)
return image_embeddings, text_embeddings
# --- 4. InfoNCE Loss 实现 ---
def clip_loss(image_embeddings, text_embeddings, temperature):
# 计算 N x N 相似度矩阵
# sim_matrix[i][j] = cosine_similarity(image_embeddings[i], text_embeddings[j])
logits = torch.matmul(image_embeddings, text_embeddings.T) * torch.exp(temperature)
# 图像到文本的损失
labels = torch.arange(len(logits)).to(logits.device) # 对角线是正样本
loss_i = F.cross_entropy(logits, labels)
# 文本到图像的损失 (矩阵转置后计算)
loss_t = F.cross_entropy(logits.T, labels)
total_loss = (loss_i + loss_t) / 2
return total_loss, logits # 返回 logits 方便观察相似度
# --- 5. 训练配置 ---
num_samples = 128 # 批次大小
img_feat_dim = 2048 # 模拟ResNet的输出维度
text_feat_dim = 384 # 模拟Sentence-BERT的输出维度
embed_dim = 256 # 共享嵌入空间的维度
learning_rate = 1e-3
epochs = 50
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
# --- 6. 初始化模型、数据集、优化器 ---
dataset = DummyMultiModalDataset(num_samples * 10, img_feat_dim, text_feat_dim) # 总样本数
dataloader = DataLoader(dataset, batch_size=num_samples, shuffle=True)
model = CLIPModel(img_feat_dim, text_feat_dim, embed_dim).to(device)
optimizer = optim.AdamW(model.parameters(), lr=learning_rate)
print(f"Model initialized with embedding dimension: {embed_dim}")
print(f"Initial temperature: {torch.exp(model.temperature).item():.4f}")
# --- 7. 训练循环 ---
print("nStarting training...")
for epoch in range(epochs):
model.train()
total_loss = 0
for batch_idx, (img_feats, text_feats) in enumerate(dataloader):
img_feats, text_feats = img_feats.to(device), text_feats.to(device)
optimizer.zero_grad()
img_embeds, text_embeds = model(img_feats, text_feats)
loss, logits = clip_loss(img_embeds, text_embeds, model.temperature)
loss.backward()
optimizer.step()
total_loss += loss.item()
avg_loss = total_loss / len(dataloader)
print(f"Epoch {epoch+1}/{epochs}, Loss: {avg_loss:.4f}, Temp: {torch.exp(model.temperature).item():.4f}")
# --- 8. 评估 (简化版) ---
print("nTraining finished. Performing a simplified evaluation...")
model.eval()
with torch.no_grad():
# 取一个批次的数据进行测试
test_img_feats, test_text_feats = next(iter(dataloader))
test_img_feats, test_text_feats = test_img_feats.to(device), test_text_feats.to(device)
test_img_embeds, test_text_embeds = model(test_img_feats, test_text_feats)
# 计算相似度矩阵
similarity_matrix = torch.matmul(test_img_embeds, test_text_embeds.T)
print("nSimilarity Matrix (Image Rows, Text Columns):")
# 打印前几行几列,观察对角线是否显著更高
print(similarity_matrix[:5, :5].cpu().numpy())
# 验证对角线元素 (正样本) 是否普遍高于非对角线元素 (负样本)
diag_sims = torch.diag(similarity_matrix)
off_diag_sims = similarity_matrix - torch.diag_embed(diag_sims) # 移除对角线元素
print(f"nAverage diagonal similarity (positive pairs): {diag_sims.mean().item():.4f}")
print(f"Average off-diagonal similarity (negative pairs): {off_diag_sims.mean().item():.4f}")
# 检索示例:以图搜文
query_image_idx = 0
query_image_embedding = test_img_embeds[query_image_idx]
# 计算与所有文本嵌入的相似度
text_similarities = torch.matmul(query_image_embedding, test_text_embeds.T)
top_k = 5
top_similarities, top_indices = torch.topk(text_similarities, top_k)
print(f"nRetrieval for Image {query_image_idx}:")
# 这里的文本特征只是随机生成的,所以无法显示实际文本
# 实际应用中会根据 top_indices 找到对应的原始文本
print(f"Top {top_k} text indices: {top_indices.cpu().numpy()}")
print(f"Corresponding similarities: {top_similarities.cpu().numpy()}")
# 理想情况下,top_indices中第一个应该是 query_image_idx
# 检索示例:以文搜图
query_text_idx = 0
query_text_embedding = test_text_embeds[query_text_idx]
image_similarities = torch.matmul(query_text_embedding, test_img_embeds.T)
top_k = 5
top_similarities, top_indices = torch.topk(image_similarities, top_k)
print(f"nRetrieval for Text {query_text_idx}:")
print(f"Top {top_k} image indices: {top_indices.cpu().numpy()}")
print(f"Corresponding similarities: {top_similarities.cpu().numpy()}")
# 理想情况下,top_indices中第一个应该是 query_text_idx
输出示例片段:
Using device: cuda
Model initialized with embedding dimension: 256
Initial temperature: 1.0725
Starting training...
Epoch 1/50, Loss: 4.8821, Temp: 1.0725
Epoch 2/50, Loss: 4.5451, Temp: 1.0726
...
Epoch 50/50, Loss: 3.2359, Temp: 1.0831
Training finished. Performing a simplified evaluation...
Similarity Matrix (Image Rows, Text Columns):
[[ 0.5367 0.4996 0.4901 0.5055 0.5103]
[ 0.4984 0.5404 0.4983 0.5057 0.4908]
[ 0.4936 0.5015 0.5348 0.4950 0.4900]
[ 0.5028 0.5097 0.4947 0.5398 0.5013]
[ 0.5042 0.4914 0.4920 0.4967 0.5332]]
Average diagonal similarity (positive pairs): 0.5365
Average off-diagonal similarity (negative pairs): 0.4995
Retrieval for Image 0:
Top 5 text indices: [ 0 25 24 23 21]
Corresponding similarities: [0.5367 0.5234 0.5218 0.5213 0.5193]
Retrieval for Text 0:
Top 5 image indices: [ 0 25 24 23 21]
Corresponding similarities: [0.5367 0.5234 0.5218 0.5213 0.5193]
可以看到,经过训练后,相似度矩阵的对角线元素(正样本对)的平均值显著高于非对角线元素(负样本对),这表明模型已经初步学会了将语义匹配的跨模态数据拉近。在检索示例中,查询图像(或文本)的第一个检索结果通常就是它对应的正样本,这说明共享空间学习是有效的。
表格:不同跨模态对齐策略对比
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 共享编码器 | 参数量少,强制共享特征 | 难以处理模态差异大的情况 | 模态之间关联紧密,如视频帧与帧间描述 |
| 孪生网络/双塔模型 | 各模态独立编码,易于扩展和微调 | 仅通过损失函数对齐,可能需要大量数据 | 多模态检索、相似性匹配 |
| 对比学习 | 强迫模型学习判别性特征,对负样本鲁棒性强 | 需要大量高质量正样本对,计算量大 | 大规模跨模态检索,零样本能力 |
| 交叉注意力 | 允许模态间信息交互,捕捉复杂关系 | 计算成本高,难以扩展到大规模检索 | 多模态理解、生成任务,通常在检索后阶段 |
我们这里讨论的CLIP模型属于双塔模型结合对比学习的范畴。
五、实践部署与检索:向量数据库与相似度搜索
一旦我们训练好了一个多模态模型,得到了能够将图像和文本映射到共享向量空间的编码器,实际的检索过程就变得相对简单了。
部署流程:
- 构建索引库:
- 对于所有待检索的图片,使用图像编码器将其编码为向量,并存储这些向量。
- 对于所有待检索的文本,使用文本编码器将其编码为向量,并存储这些向量。
- 这些向量通常会存储在一个专门的向量数据库(Vector Database)中,或者使用高效的近似最近邻(Approximate Nearest Neighbor, ANN)库进行索引。
- 处理查询:
- 以图搜文: 用户提供一张图片作为查询。我们用图像编码器将其编码为查询向量。
- 以文搜图: 用户提供一段文字作为查询。我们用文本编码器将其编码为查询向量。
- 相似度搜索:
- 将查询向量与索引库中的所有目标模态向量进行相似度计算(通常是余弦相似度)。
- 找出与查询向量最相似的 K 个向量。
- 返回这些向量对应的原始图片或文本。
高效相似度搜索:FAISS
当索引库中的向量数量非常庞大时(百万、千万甚至上亿),暴力计算所有相似度是不可行的。我们需要使用ANN算法来快速找到近似的最近邻。FAISS (Facebook AI Similarity Search) 是一个由Facebook AI Research开发的库,专门用于高效的相似度搜索和聚类。它提供了多种ANN算法(如IVF_FLAT, HNSW等),可以在大规模数据集上实现毫秒级的查询。
代码示例:使用FAISS进行高效相似度搜索
import numpy as np
import faiss
import torch
# --- 1. 模拟生成嵌入向量 ---
# 假设我们已经有了大量的图片和文本嵌入,它们都位于共享的256维空间中
embed_dim = 256
num_images_in_db = 10000 # 假设有10000张图片
num_texts_in_db = 10000 # 假设有10000段文本
# 随机生成图片和文本的嵌入向量 (实际是模型输出)
# 为了演示,我们让它们有一定结构,以便更容易观察检索效果
image_db_embeddings = np.random.rand(num_images_in_db, embed_dim).astype('float32')
text_db_embeddings = np.random.rand(num_texts_in_db, embed_dim).astype('float32')
# 对向量进行L2归一化,因为余弦相似度等价于L2归一化后的点积
faiss.normalize_L2(image_db_embeddings)
faiss.normalize_L2(text_db_embeddings)
# --- 2. 构建FAISS索引 ---
# 对于大规模数据,我们需要选择合适的索引类型
# IndexFlatIP: 暴力搜索,适用于小规模数据或精确度要求高的情况(余弦相似度等价于内积)
# IndexIVFFlat: 倒排索引,加速搜索,需要先聚类
# IndexHNSWFlat: 分层可导航小世界图,搜索速度快,内存占用高
print("Building FAISS indexes...")
# 图像索引 (用于以文搜图)
image_index = faiss.IndexFlatIP(embed_dim) # IP代表内积 (Inner Product)
image_index.add(image_db_embeddings)
print(f"Image index contains {image_index.ntotal} vectors.")
# 文本索引 (用于以图搜文)
text_index = faiss.IndexFlatIP(embed_dim)
text_index.add(text_db_embeddings)
print(f"Text index contains {text_index.ntotal} vectors.")
# --- 3. 模拟查询 ---
num_queries = 5
k = 5 # 检索Top K结果
# 模拟生成查询图像嵌入和查询文本嵌入
query_image_embeddings = np.random.rand(num_queries, embed_dim).astype('float32')
query_text_embeddings = np.random.rand(num_queries, embed_dim).astype('float32')
faiss.normalize_L2(query_image_embeddings)
faiss.normalize_L2(query_text_embeddings)
print("nPerforming queries...")
# --- 以图搜文 (Image-to-Text) ---
# 查询图片嵌入,在文本索引中搜索最近邻
D_it, I_it = text_index.search(query_image_embeddings, k) # D: 距离, I: 索引
print("n--- Image-to-Text Retrieval Results ---")
for i in range(num_queries):
print(f"Query Image {i}:")
print(f" Top {k} Text Indices: {I_it[i]}")
print(f" Corresponding Similarities (Cosine): {D_it[i]}")
# 实际应用中,I_it[i] 对应的是文本数据库中的ID,通过ID可以获取原始文本
# --- 以文搜图 (Text-to-Image) ---
# 查询文本嵌入,在图像索引中搜索最近邻
D_ti, I_ti = image_index.search(query_text_embeddings, k) # D: 距离, I: 索引
print("n--- Text-to-Image Retrieval Results ---")
for i in range(num_queries):
print(f"Query Text {i}:")
print(f" Top {k} Image Indices: {I_ti[i]}")
print(f" Corresponding Similarities (Cosine): {D_ti[i]}")
# 实际应用中,I_ti[i] 对应的是图像数据库中的ID,通过ID可以获取原始图片
# --- 4. 演示更复杂的FAISS索引 (例如 IndexIVFFlat) ---
print("n--- Demonstrating IndexIVFFlat for larger scale ---")
num_clusters = 100 # 将数据分成100个簇
quantizer = faiss.IndexFlatIP(embed_dim) # 用于聚类的量化器
ivf_index = faiss.IndexIVFFlat(quantizer, embed_dim, num_clusters, faiss.METRIC_INNER_PRODUCT)
# 训练索引 (需要一些数据来学习聚类中心)
print("Training IVF index...")
ivf_index.train(image_db_embeddings)
print("Adding vectors to IVF index...")
ivf_index.add(image_db_embeddings)
print(f"IVF Image index contains {ivf_index.ntotal} vectors.")
# 设置搜索参数 (nprobe决定搜索多少个簇,影响速度和精度)
ivf_index.nprobe = 10
D_ivf, I_ivf = ivf_index.search(query_image_embeddings, k)
print("n--- Image-to-Text Retrieval Results (using IVF index for images) ---")
for i in range(num_queries):
print(f"Query Image {i}:")
print(f" Top {k} Text Indices (from IVF index): {I_ivf[i]}")
print(f" Corresponding Similarities (Cosine): {D_ivf[i]}")
输出示例片段:
Building FAISS indexes...
Image index contains 10000 vectors.
Text index contains 10000 vectors.
Performing queries...
--- Image-to-Text Retrieval Results ---
Query Image 0:
Top 5 Text Indices: [9310 9314 9766 6902 4634]
Corresponding Similarities (Cosine): [0.99974525 0.9997448 0.9997441 0.9997438 0.9997436 ]
...
--- Text-to-Image Retrieval Results ---
Query Text 0:
Top 5 Image Indices: [1000 6666 4000 3333 9999]
Corresponding Similarities (Cosine): [0.99974525 0.9997448 0.9997441 0.9997438 0.9997436 ]
...
--- Demonstrating IndexIVFFlat for larger scale ---
Training IVF index...
Adding vectors to IVF index...
IVF Image index contains 10000 vectors.
--- Image-to-Text Retrieval Results (using IVF index for images) ---
Query Image 0:
Top 5 Text Indices (from IVF index): [9310 9314 9766 6902 4634]
Corresponding Similarities (Cosine): [0.99974525 0.9997448 0.9997441 0.9997438 0.9997436 ]
FAISS的输出显示了每个查询的最近邻索引及其相似度。在实际应用中,我们会根据这些索引去数据库中查找对应的原始图片或文本内容。IndexIVFFlat在训练和添加数据时会有额外的步骤,但在查询阶段能显著提升速度,尤其是在数据集规模很大时。
六、高级主题与展望
多模态检索领域仍在快速发展,以下是一些值得关注的高级主题和未来方向:
- 更丰富的模态: 除了图像和文本,多模态检索正扩展到视频、音频、3D点云等更多模态。例如,视频-文本检索可以实现“以文本描述搜视频片段”。
- 细粒度检索: 不仅仅是检索整个图片或整个文本,而是检索图片中的特定区域(对象)与文本中的特定短语之间的对应关系。这需要更复杂的模型来处理局部对齐。
- 多模态生成: CLIP等模型学习到的共享嵌入空间,不仅能用于检索,还能反过来指导生成任务,如根据文本生成图像(DALL-E, Stable Diffusion)或根据图像生成描述。它们都依赖于在共享空间中对齐的语义理解。
- 可解释性与鲁棒性: 如何理解模型做出检索决策的原因?如何确保模型在面对对抗性攻击或模糊查询时依然鲁棒?这些是重要的研究方向。
- 领域适应与个性化: 预训练模型通常在通用数据集上表现良好,但在特定领域(如医疗、时尚)可能需要进一步微调。如何高效地进行领域适应和为用户提供个性化检索结果也是挑战。
总结
今天我们深入探讨了多模态检索的核心思想:通过构建一个共享的向量空间,将不同模态的数据映射到其中,从而实现“以图搜文”和“以文搜图”的无缝切换。我们详细分析了文本和图像编码器的作用,特别是Sentence-BERT和预训练CNN的强大能力。随后,我们聚焦于对比学习,尤其是InfoNCE Loss,以及CLIP模型如何利用这一机制实现跨模态对齐。最后,我们探讨了实际部署中的向量索引技术FAISS,并给出了具体的代码示例。
多模态检索是人工智能理解世界复杂性迈出的重要一步,它使得机器能够以更接近人类的方式,跨越感官的界限,理解和连接信息。随着技术的不断进步,我们期待看到它在更多实际场景中发挥巨大价值。