解析 ‘Multi-modal Retrieval’:如何在同一向量空间内实现‘以图搜文’与‘以文搜图’的交叉链?

各位同仁,大家好。今天,我们来深入探讨一个在人工智能领域日益受到关注,并且极具实用价值的课题:多模态检索(Multi-modal Retrieval)。具体来说,我们将聚焦于如何在一个统一的向量空间内,优雅地实现“以图搜文”与“以文搜图”的交叉链检索。

作为一名编程专家,我深知理论与实践的结合至关重要。因此,本次讲座将不仅仅停留在概念层面,更会深入到代码实现细节,剖析其背后的逻辑和工程考量。

一、多模态检索:跨越感官的桥梁

想象一下这样的场景:您看到一张精美的图片,想要找出所有描述这张图片的文字资料;或者您脑海中有一个模糊的文字描述,希望找到与之匹配的图像。这就是多模态检索的核心任务。它打破了传统单模态检索(如“以文搜文”或“以图搜图”)的界限,使得不同模态的信息能够相互查询和理解。

什么是模态? 简单来说,模态就是数据呈现的不同形式。图像是一种模态,文本是另一种模态,语音、视频、3D模型等也都是不同的模态。

多模态检索的挑战在哪里?

最大的挑战在于所谓的“模态鸿沟”(Modality Gap)。图像数据是像素的矩阵,捕捉的是视觉特征;文本数据是字符序列,承载的是语义信息。这两种数据在底层结构上截然不同,直接进行比较几乎不可能。我们无法简单地将一个像素值与一个单词进行匹配。

我们的目标,就是构建一座桥梁,跨越这个鸿沟。这座桥梁的核心思想,是将不同模态的数据映射到一个共同的、语义丰富的向量空间中。在这个共享空间里,如果一张图片和一段文字描述的是同一个概念或实体,那么它们对应的向量就应该彼此靠近;反之,如果它们描述的是不相关的概念,它们的向量就应该彼此远离。一旦实现了这一点,无论是“以图搜文”还是“以文搜图”,都将归结为简单的向量相似度搜索问题。

二、共享向量空间:统一的语义语言

想象一个多维空间,其中每个维度都代表了某种语义特征。在这个空间里,代表“猫”的图片和描述“猫”的文字,都会被映射到空间中“猫”概念的附近。这就是我们所追求的共享向量空间。

如何实现这个共享空间?

核心思路是学习(Learning)。我们不能手动定义这些语义特征,而是需要通过机器学习模型,特别是深度学习模型,从大量的多模态数据中自动学习出这种映射关系。这个过程通常涉及以下几个关键步骤:

  1. 编码器(Encoders):为每种模态设计专门的编码器,将原始数据(图像、文本)转换成高维的、模态特定的特征向量。
  2. 投影头(Projection Heads):在编码器之后,通常会增加一些层(如全连接层),将模态特定的特征向量进一步投影到共享向量空间中。
  3. 对比学习(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,最大化匹配对的相似度,最小化不匹配对的相似度

训练过程:

  1. 给定一个包含 $N$ 个(图像,文本)对的批次。
  2. 图像编码器将 $N$ 张图片编码为 $N$ 个图像特征向量。
  3. 文本编码器将 $N$ 段文本编码为 $N$ 个文本特征向量。
  4. 每个特征向量都通过各自的投影头,映射到共享的 $D$ 维向量空间,并进行 L2 归一化。
  5. 计算这 $N$ 个图像嵌入和 $N$ 个文本嵌入之间的所有 $N times N$ 对余弦相似度,形成一个相似度矩阵 $S$。
  6. 对角线上的元素 $S_{ii}$ 代表了匹配的(图像,文本)对的相似度。
  7. 使用 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模型属于双塔模型结合对比学习的范畴。

五、实践部署与检索:向量数据库与相似度搜索

一旦我们训练好了一个多模态模型,得到了能够将图像和文本映射到共享向量空间的编码器,实际的检索过程就变得相对简单了。

部署流程:

  1. 构建索引库:
    • 对于所有待检索的图片,使用图像编码器将其编码为向量,并存储这些向量。
    • 对于所有待检索的文本,使用文本编码器将其编码为向量,并存储这些向量。
    • 这些向量通常会存储在一个专门的向量数据库(Vector Database)中,或者使用高效的近似最近邻(Approximate Nearest Neighbor, ANN)库进行索引。
  2. 处理查询:
    • 以图搜文: 用户提供一张图片作为查询。我们用图像编码器将其编码为查询向量。
    • 以文搜图: 用户提供一段文字作为查询。我们用文本编码器将其编码为查询向量。
  3. 相似度搜索:
    • 将查询向量与索引库中的所有目标模态向量进行相似度计算(通常是余弦相似度)。
    • 找出与查询向量最相似的 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在训练和添加数据时会有额外的步骤,但在查询阶段能显著提升速度,尤其是在数据集规模很大时。

六、高级主题与展望

多模态检索领域仍在快速发展,以下是一些值得关注的高级主题和未来方向:

  1. 更丰富的模态: 除了图像和文本,多模态检索正扩展到视频、音频、3D点云等更多模态。例如,视频-文本检索可以实现“以文本描述搜视频片段”。
  2. 细粒度检索: 不仅仅是检索整个图片或整个文本,而是检索图片中的特定区域(对象)与文本中的特定短语之间的对应关系。这需要更复杂的模型来处理局部对齐。
  3. 多模态生成: CLIP等模型学习到的共享嵌入空间,不仅能用于检索,还能反过来指导生成任务,如根据文本生成图像(DALL-E, Stable Diffusion)或根据图像生成描述。它们都依赖于在共享空间中对齐的语义理解。
  4. 可解释性与鲁棒性: 如何理解模型做出检索决策的原因?如何确保模型在面对对抗性攻击或模糊查询时依然鲁棒?这些是重要的研究方向。
  5. 领域适应与个性化: 预训练模型通常在通用数据集上表现良好,但在特定领域(如医疗、时尚)可能需要进一步微调。如何高效地进行领域适应和为用户提供个性化检索结果也是挑战。

总结

今天我们深入探讨了多模态检索的核心思想:通过构建一个共享的向量空间,将不同模态的数据映射到其中,从而实现“以图搜文”和“以文搜图”的无缝切换。我们详细分析了文本和图像编码器的作用,特别是Sentence-BERT和预训练CNN的强大能力。随后,我们聚焦于对比学习,尤其是InfoNCE Loss,以及CLIP模型如何利用这一机制实现跨模态对齐。最后,我们探讨了实际部署中的向量索引技术FAISS,并给出了具体的代码示例。

多模态检索是人工智能理解世界复杂性迈出的重要一步,它使得机器能够以更接近人类的方式,跨越感官的界限,理解和连接信息。随着技术的不断进步,我们期待看到它在更多实际场景中发挥巨大价值。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注