简介:面向多模态数据检索入门与进阶研究者的一份PDF文档,围绕跨模态信息检索中的视觉—语言语义鸿沟问题,系统阐述基于CLIP模型实现图像与文本双向检索的完整方法。内容覆盖数据预处理与增强、Vision Transformer图像特征提取、Text Transformer文本特征提取、数据集按8:2划分及模型训练评估流程,并给出图像检索与文本检索的具体实验设计和Recall@K评价结果。适合准备跨模态检索相关课题、数学建模竞赛或希望掌握CLIP实战思路的读者参考学习。资源包为单个PDF文件,大小4.48MB,便于直接阅读与打印;已有238人下载学习。文档既包含理论框架,也呈现了从数据清洗到最佳学习率调优的详细实验记录,可帮助读者快速理解CLIP在图像文本检索中的落地流程,并迁移至自己的研究或项目中。
1. 基于 CLIP 模型的图像文本跨模态检索:这套流程能直接跑通文本搜图和图搜文本
做多模态检索的同行应该都有同感:单模态检索只要把特征库建好,剩下就是相似度排序的事,但图像和文本之间隔着一条语义鸿沟——图像是像素级的视觉呈现,文本是符号级的抽象表达,两个特征空间根本不在一张坐标系里。这篇基于 CLIP 模型的图像文本跨模态检索项目,解决的就是“用一句话找图、用一张图找描述”的双向检索问题,核心做法是用 CLIP 的双塔结构分别抽出图像和文本特征,再在共享语义空间里算余弦相似度。项目已经把数据预处理、双编码器构建、对比训练、Recall@K 评估到最终推理的完整链路都走通了,适合正在做跨模态检索课程设计、毕设或者想快速验证 CLIP 落地效果的工程师直接参考复现。
2. 数据预处理:灰度图转 RGB 和文本清洗是检索精度的第一道分水岭
2.1 图像预处理细节:灰度图必须统一转 RGB,尺寸调整要“信封式”
项目里 50000 张图像来自附件一,数据质量并不理想,最典型的问题是混入了灰度图。灰度图只有单通道,模型预训练时输入约定是三通道 RGB,如果不处理,训练时要么直接报维度错误,要么勉强跑通但特征表达缺失了色彩信息。项目里用 Python Imaging Library 的 convert 方法统一转成 RGB,这是一个非常基础但容易被忽略的步骤。
from PIL import Image def to_rgb(img): """ 统一转换为RGB三通道图像 """ return img.convert("RGB")逻辑说明:convert("RGB") 对本身就是 RGB 的图像不会有任何颜色偏移,对灰度图(L 模式)会做通道复制,把单通道扩展成三通道。这个操作的目的是让数据分布对齐模型输入要求,否则后续 ViT 的 Patch Embedding 卷积层会因通道数不一致而报错或产生维度不匹配。灰度图不转 RGB 还有一个隐性影响:同一张图在灰度模式下提取的特征会丢掉色相、饱和度信息,模型会把黑白图和彩色图误判为不同语义,检索时召回率会明显下降。
图像尺寸调整方面,项目采用的不是简单暴力 resize,而是“信封式”调整——保持长宽比不变,把图像缩放到指定尺寸内,剩余区域用灰色填充;如果原图大于设定尺寸则居中裁剪。这种做法的好处是避免直接拉伸导致图像变形,尤其对包含文字、人脸等结构化内容的图像,变形会严重影响特征质量。
def letterbox_image(image, size=(224, 224)): """ 保持长宽比的信封式缩放,不足部分填充灰色 size: 目标尺寸,CLIP 默认图像输入为 224x224 """ from PIL import Image src_w, src_h = image.size dst_w, dst_h = size scale = min(dst_w / src_w, dst_h / src_h) new_w, new_h = int(src_w * scale), int(src_h * scale) resized = image.resize((new_w, new_h), Image.BICUBIC) canvas = Image.new("RGB", (dst_w, dst_h), (128, 128, 128)) canvas.paste(resized, ((dst_w - new_w) // 2, (dst_h - new_h) // 2)) return canvas逻辑说明:这段代码先计算缩放比例,取宽高两个方向的最小值保证缩放后不超出目标尺寸,然后用灰色画布居中粘贴。填充灰色的地方在后续特征提取时会成为“无信息区域”,模型依靠位置编码和注意力机制能感知到这些区域与主体区域的边界,比直接拉伸出畸变要安全得多。size 参数需要注意:CLIP 的 ViT-B/32 原版输入是 224x224,但如果用更大分辨率如 336x336 微调过的模型,这里就要改成对应值,否则模型会因尺寸不匹配报错或特征提取失真。
图像增强层面,项目还加入了随机旋转、翻转和色域扰动。随机旋转在小角度范围内(比如 ±10 度)对检索任务基本无害,但旋转 90 度或 180 度对某些语义(比如“朝左的猫”)会产生标签翻转问题,训练时需要谨慎。色域增强则是把 RGB 通道做随机偏移,模拟不同光照条件,这部分对提升模型泛化能力帮助不小,但也别调太狠,色相偏移过大会让“红色轿车”这类语义失效。
2.2 文本预处理细节:小写化、去标点和长度截断的一致性要求
文本这边的增强相对朴素,但对结果影响同样很大。项目对文本统一做了转小写、去标点、去空格和长度截断。文本清洗的目标是让同一语义的不同表达尽量收敛到相近的 token 序列,减少分词和编码时的噪声。
import re def clean_text(text, max_len=77): """ 文本清洗:小写化、去标点、压缩空格、截断 max_len: CLIP 文本编码器的最大 token 长度(77) """ text = text.lower() text = re.sub(r"[^\w\s]", "", text) # 去标点符号 text = re.sub(r"\s+", " ", text) # 压缩连续空白 text = text.strip() return text[:max_len]逻辑说明:CLIP 文本编码器的输入序列长度上限是 77 个 token,这个数字来自原论文的设置,不是随便拍的。超过 77 的部分会被截断,所以清洗后还需要按 token 数截断。这里有个隐藏坑:Python 字符串切片是按字符截的,但 tokenizer 切出来的 token 数和字符数不是一一对应关系,一个英文单词可能拆成两三个 token。稳妥做法是先分词再截断,但项目在数据清洗阶段按字符截断也没大问题,因为最终 tokenizer 会对超长部分做截断处理,只是语义信息损失的位置不同。
标注数据清洗时还要注意:附件一的 Excel 中图像和文本是一一对应的,清洗文本后要保证索引不丢。常见做法是把图片路径和清洗后的文本一起存成新 DataFrame,训练时按行读取。文本清洗和图像预处理最好在同一轮循环中完成,保证两个模态的数据对齐,否则前面图片已经随机翻转了,文本还对应着原来的描述,训练时模型会学到错误的对应关系。
数据划分上,项目按 8:2 把附件一拆成 40000 训练 + 10000 测试。这个划分在检索任务里有一个值得注意的点:确保按类别或语义分布做分层采样,而不是简单随机打乱后切片。否则测试集里可能集中出现某一类图像,导致 Recall@K 指标虚高或偏低,误导调参方向。项目原文没有提到分层采样,但从工程经验看,我一般会按图像所属类别做 StratifiedSplit,尤其当数据集本身类别不均衡时。
3. 双塔模型构建:ViT 图像编码器和 Text Transformer 文本编码器如何对齐到同一语义空间
3.1 ViT 图像编码器:Patch Embedding、class token 和位置编码的协作机制
CLIP 图像编码器用的是 Vision Transformer,核心思路是“把图像当句子读”——将图像切割成固定大小的 Patch,每个 Patch 线性映射成一个 Token,再加上位置编码和 class token 后送入 Transformer Encoder。这个设计和用 CNN 提取图像特征的方案有本质区别:CNN 靠卷积核的局部感受野逐层扩大语义范围,而 ViT 从一开始就让每个 Patch 可以 attend 到全图任意位置的 Patch,全局建模能力更强,但需要更大的数据量才能训出好效果。CLIP 使用 ViT 的前提正是它有海量图文对数据做对比预训练。
项目里提到用卷积层实现 Patch 切割,这一步在代码层面就是用 Conv2d 完成的。ViT-B/32 的配置是 patch_size=32,输入 224x224 图像会切成 7x7=49 个 Patch。用卷积实现 Patch Embedding 时,卷积核大小和步距都设为 32,输入通道数设为 3(RGB),输出通道数设为 768(embedding 维度)。
import torch.nn as nn class PatchEmbed(nn.Module): """ 图像到 Token 的转换:卷积核大小=patch_size,步距=patch_size in_chans=3, embed_dim=768, patch_size=32 输入: (B, 3, 224, 224) 输出: (B, 49, 768) """ def __init__(self, in_chans=3, embed_dim=768, patch_size=32): super().__init__() self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size) def forward(self, x): x = self.proj(x) # (B, 768, 7, 7) x = x.flatten(2) # (B, 768, 49) x = x.transpose(1, 2) # (B, 49, 768) return x逻辑说明:Conv2d 卷积核尺寸等于 stride 等于 patch_size,这一步同时完成了"切图"和"向量化"。flatten 保留了每个 Patch 的特征向量,transpose 把序列维度放到中间,输出形状为 (Batch, num_patches, embed_dim),和 Transformer 期望的输入格式对齐。
切割完 Patch 后,还需要在序列最前面拼接一个 class token,并叠加位置编码。class token 的作用是汇聚全局信息——它不对应任何具体 Patch,而是通过自注意力机制与其他所有 Patch 交互,最终取这个位置的输出作为整张图的语义向量。位置编码则让模型知道 Patch 之间的空间关系,ViT 用的是可学习的位置编码,而不是 Transformer 原论文里的正弦编码。
class ViTEncoder(nn.Module): def __init__(self, embed_dim=768, depth=12, num_heads=12, num_patches=49): super().__init__() self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim)) self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim)) self.blocks = nn.ModuleList([ TransformerBlock(embed_dim, num_heads) for _ in range(depth) ]) def forward(self, x): B, N, C = x.shape cls_tokens = self.cls_token.expand(B, -1, -1) x = torch.cat([cls_tokens, x], dim=1) # (B, 50, 768) x = x + self.pos_embed for block in self.blocks: x = block(x) return x[:, 0] # 只取 class token 输出逻辑说明:cls_token 初值为全零,随训练更新;pos_embed 是 1x50x768 的可学习参数,50 = 49 个 Patch + 1 个 class token。在 Transformer Encoder 之后取第 0 个位置的输出即 class token 对应的特征向量,作为整张图像的语义表示。这个向量的维度是 768,后续与文本特征算相似度用的就是这个向量。如果直接对所有 Patch 的 token 做平均池化,也能得到图像表示,但实验来看 class token 的效果通常更好,CLIP 原实现也是走 class token 路线。
3.2 Text Transformer 编码器:OpenAI 编码和 Hugging Face 编码的取舍
文本编码器方面,项目做了两套方案的对比:一套是 OpenAI 官方 CLIP 自带的文本编码器风格,另一套是预训练的 Hugging Face BERT。两者的核心差异在于分词方式、序列长度上限和 embedding 维度。
OpenAI 风格用 Byte-Pair Encoding 分词,序列长度固定为 77,embedding 维度 512,模型结构是 8 层 Transformer,每层 8 个头。这套编码器是 CLIP 预训练阶段从零训练出来的,和图像编码器天然匹配。Hugging Face BERT 则是通用预训练语言模型,embedding 维度 768,序列长度通常是 512,它更擅长理解复杂语法和上下文,但它不是为图文对比学习设计的,embedding 空间和 ViT 图像特征空间没有做过统一。
项目最终通过对比实验选择了更适合数据集风格的编码方式。从我跑类似任务的经验看,在数据量不大、文本描述是短句(比如“一只黑色的猫坐在沙发上”)的情况下,OpenAI 风格编码器更稳,因为它的 token 序列短、语义聚焦;但如果文本是长段落或者包含大量修饰性从句,BERT 这类预训练语言模型对上下文的理解优势会体现出来。判断标准不是哪个模型更先进,而是哪个编码器的输出和图像编码器做对比学习时 loss 收敛更快、验证集 Recall@K 更高。
import torch from transformers import AutoTokenizer, AutoModel def encode_text_hf(texts, model_name="bert-base-uncased"): """ 方案二:Hugging Face 预训练编码器 texts: list[str] 返回: (B, 768) 的文本特征 """ tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModel.from_pretrained(model_name) model.eval() inputs = tokenizer(texts, return_tensors="pt", padding=True, truncation=True, max_length=77) with torch.no_grad(): outputs = model(**inputs) # CLS token 的输出来作为整句表示 return outputs.last_hidden_state[:, 0, :]逻辑说明:这里取了 last_hidden_state 的第 0 个位置,也就是 BERT 的 [CLS] token,它聚合了整句的语义。max_length 设置为 77 是为了和 CLIP 文本编码器对齐,方便后续相似度计算时维度统一。注意这里用的是无梯度推理,实际训练时如果要微调文本编码器,不能加 with torch.no_grad(),否则梯度无法回传。
文本特征经过编码器之后还需要做 L2 归一化,图像特征同理。归一化的目的是把特征向量拉成单位长度,让余弦相似度的计算等价于向量点积,同时避免特征向量的模长差异干扰相似度排序。这一步在模型构建和推理阶段都要做,推理时容易忘,尤其是从磁盘加载预提取特征时,如果忘了归一化,检索结果的排序会被模长大的向量主导,这是个很隐蔽的坑。
4. 对比训练与调参:学习率范围和 Recall@K 评估是模型收敛的关键试金石
4.1 训练流程:对比预训练、分类器创建、零样本分类的三段式构建
CLIP 训练的核心不是让模型预测某个类别标签,而是让模型学会判断“哪张图和哪句描述更匹配”。项目采用对比预训练策略:在一个 batch 内,有 N 张图和 N 条文本,模型需要把正确配对的 N 对找出来,其余 N^2 - N 对都是负样本。约束方式是对图像特征和文本特征做交叉熵损失,目标是让正确配对的特征相似度远高于错误配对的相似度。
import torch.nn.functional as F def contrastive_loss(image_features, text_features, logit_scale=20.0): """ 对比损失:image_features 和 text_features 均已做 L2 归一化 对角线位置是匹配的图文对 """ logits = logit_scale * image_features @ text_features.t() labels = torch.arange(logits.shape[0]) loss_i = F.cross_entropy(logits, labels) # 给定文本找图像 loss_t = F.cross_entropy(logits.t(), labels) # 给定图像找文本 return (loss_i + loss_t) / 2逻辑说明:logits 矩阵的 (i, j) 位置表示第 i 张图和第 j 条文本的相似度。正确配对在对角线上,所以 labels 直接用 arange 生成。两个方向的交叉熵分别对应“以文本为查询找图像”和“以图像为查询找文本”,取平均作为最终 loss。logit_scale 是可学习参数,也可以固定为常数。这个参数的作用是放大相似度差异,因为归一化后的特征向量点积范围在 [-1, 1] 之间,直接做 softmax 会让所有类别概率差异太小,梯度几乎消失。
训练阶段,模型会经历“对比预训练—分类器创建—零样本分类”三个环节。对比预训练是指用大量图文对学习通用语义对齐;分类器创建是指在特定任务数据上,把图文匹配关系转化为可判别的分类边界;零样本分类则是用训练好的模型直接处理未见过的类别,不更新任何参数。项目里这三个环节是顺序执行的,但实际落地时如果只有少量标注数据,可以考虑跳过第二阶段的分类器创建,直接走零样本推理,CLIP 本身的零样本能力对检索任务通常已经足够。
batch size 和 logit_scale 的设置互相影响。batch size 越大,一个 batch 内的负样本越多,对比学习的效果越好,但对显存要求也越高。项目数据规模是 50000 张图,按 8:2 划分后训练集 40000,如果单卡 24G 显存,batch size 一般设在 64 到 128 之间。logit_scale 初始值设为 20 对应温度参数 1/20,训练中让 logit_scale 可学习,模型会自动调整到一个合适的范围。损失值方面,对比学习初期 loss 通常在 6 到 7 左右,如果 logit_scale 太小,loss 会一直降不下去。
4.2 学习率范围与 Recall@K:项目给出的两组区间分别应对图像检索和文本检索
项目实验部分给了一个重要结论:最佳学习率范围在 [10^-5, 10^-4] 和 [10^-6, 10^-4] 两个区间,前者用于图像检索任务,后者用于文本检索任务。这个差异背后的逻辑是:图像编码器 ViT 参数量远大于文本编码器,图像侧需要更大的梯度更新步长来拟合数据分布,而文本侧如果学习率过大,很容易在预训练权重附近震荡甚至发散。
实际调参时建议用学习率预热加余弦退火的组合策略:前 10% 的迭代从零线性上升到目标学习率,之后按余弦曲线衰减到接近零。CLIP 原论文训练时就是这么做的,这比全程固定学习率更容易收敛。另外,Adam 的 betas 参数建议保持默认 (0.9, 0.999),epsilon 设为 1e-8,weight decay 设为 0.2 或 0.5 效果都不错,ViT 本身对 weight decay 比较敏感,太小容易过拟合。
评估指标用的是 Recall@K,具体是 R@1、R@5 和 R@10。在图像检索任务中,模型对每张图计算它与所有文本的相似度,看正确文本是否排在前 K 位;文本检索同理。项目要求输出前五张图或前五条文本,所以 R@5 是最核心的评估指标。训练时每轮迭代完应该跑一遍验证集的 Recall@K,并保存验证集指标最好的模型权重——这一步不要偷懒只保存最后一个 epoch 的权重,因为对比学习的损失曲线和准确率并不完全同步,最后一个 epoch 不一定是最优模型。
def recall_at_k(similarity_matrix, k=5): """ similarity_matrix: (N, N) 其中对角线为匹配对 返回 R@K 指标,表示正确匹配对出现在 Top-K 的比例 """ n = similarity_matrix.shape[0] top_k_indices = similarity_matrix.topk(k, dim=1).indices correct = (top_k_indices == torch.arange(n).view(-1, 1)).sum().item() return correct / n逻辑说明:topk 在 dim=1 上取每一行相似度最大的前 k 个索引,然后和正确的配对索引比较。这里的前提是图像和文本已经按顺序一一对应,如果不是,需要显式传入匹配对索引,否则评估结果是错的。k 值对结果影响很大,R@1 在跨模态检索里通常不高,尤其是数据量大的时候,R@5 和 R@10 更能反映模型的实际检索能力。
数据划分不均匀会对 Recall@K 评估造成明显干扰。如果测试集里某类图像占比过高,模型只需对这类图像有较好表现,整体指标就会虚高;反之如果某类在训练集中出现很少,该类的 Recall@K 会很低,拖累整体分数。项目直接用 8:2 随机划分,这在类别均衡情况下没问题,但如果发现验证集指标忽高忽低,建议检查一下类别分布再做分层采样。
5. 避坑指南:CLIP 微调和检索推理中常见的五个翻车点
5.1 现象:灰度图训练时没报错,但推理检索结果混乱
灰度图转 RGB 这个步骤在训练时如果漏了,模型一般不会直接崩——PyTorch 的 DataLoader 会在读取时把单通道图复制成三通道,但复制之后三个通道是完全相同的值。问题出在验证和推理阶段:如果推理时加载的是 1 通道灰度图,而模型参数期望 3 通道输入,要么报错,要么维度对齐后特征分布和训练时不一致,检索结果基本靠猜。
原因:训练数据里灰度图被 DataLoader 隐式处理了,但推理脚本里没有做同样的 convert 操作,两边数据分布不一致。
解决:把灰度图转换逻辑写进 Dataset 的getitem里,而不是在外部预处理时做。这样训练、验证、推理三条路径共用同一份加载代码,从源头保证通道数一致。我就是这么干的,从那以后灰度图相关的坑再没踩过。
5.2 现象:损失函数一直不降,停在 6.9 附近
对比学习的初始 loss 如果一直在 6.9 左右徘徊,说明 logits 中所有类别概率都趋于均匀分布,模型几乎没学到任何图文对齐信息。
原因:logit_scale 初始值太小或太大了;也可能是学习率设置过高,模型在最优点附近反复震荡,loss 曲线呈现锯齿状不下降。
解决:先把 logit_scale 固定为常数 20,排除温度参数的影响;然后把学习率降一个数量级重新跑 10 个 epoch,观察 loss 是否打破平台期。如果还不行,检查数据加载顺序——确保每个 batch 里的图像和文本是配对的,最容易犯的错是 shuffle 时把图像和文本单独打乱了,导致模型看到的全是错误配对。这个错我犯过一次,排查半天才发现问题出在 DataLoader 里两个 Dataset 用了不同的随机种子。
5.3 现象:训练时 R@5 很高,但推理时检索效果差
训练集上 Recall@5 已经到 85% 以上,但用附件二和附件三做实际检索时,返回的结果明显不符合语义。
原因:过拟合。模型记住了训练集里图文对的“表面特征”,比如特定背景色、特定构图,而没有真正学到语义对应。CLIP 模型参数量大,40000 张图的训练集很容易让编码器在特定数据集上过拟合。
解决:减少训练轮数,使用早停策略;增大数据增强的强度,比如增加裁剪比例范围、颜色抖动的幅度;也可以冻结图像编码器的前几层,只微调最后几层和文本编码器,降低可训练参数量。实际项目中我把 ViT 前 6 层冻结后,验证集 R@5 反而提高了 2 个百分点。
5.4 现象:检索结果顺序不稳定,同一条查询每次跑结果不一样
同一个模型、同一批数据,两次推理输出的 top5 结果不同。
原因:推理脚本里模型处于 train 模式。Dropout 层在训练模式下会随机丢弃神经元,导致特征提取结果随机波动。另一个可能原因是图像增强在推理时没有关闭,随机旋转和翻转还在生效。
解决:推理前强制调用 model.eval(),并且把图像增强流程用 if 条件或独立函数隔离,只在训练分支执行。eval() 是每个 PyTorch 项目都会强调的操作,但跨模态这种带 Dropout 的 Transformer 结构影响尤其明显。从那以后我都会在推理脚本开头加一行断言,检查模型是否处于 eval 状态。
5.5 现象:文本检索结果里出现大量无语义关联的句子
用一张“海边日落”的图去检索文本,前五条结果里有两条在描述“城市夜景”。
原因:文本特征和图像特征没有在同一语义空间对齐。如果图像编码器用的是 ViT-B/32,文本编码器用的是 BERT-base,两者 embedding 维度不同(768 vs 768 但语义空间不同),直接算余弦相似度是没有意义的。另一种情况是编码器一个做了 L2 归一化,另一个没做,相似度分数被向量模长污染。
解决:要么统一用 OpenAI CLIP 官方权重,保证两个编码器是联合预训练过的;要么用对比学习在目标数据上做全量微调,强制两个特征空间对齐。然后把 L2 归一化写进特征提取函数的最末尾,对所有特征统一处理。检查维度是否对齐也很简单,打印两个特征的 shape,确认最后一个维度相同,再打印相似度矩阵的值域,正常应该在 [-1, 1] 之间。
6. 进阶用法:把训练好的 CLIP 特征工程化,复用一次推理结果支撑三个检索需求
模型训练完、R@5 达标之后,接下来就是实际使用。项目里附件二和附件三分别要求做文本搜图和图搜文本,如果每来一条查询都重新跑一遍整个编码器,速度慢不说,资源也浪费。更工程化的做法是把所有候选图像和候选文本的特征提前抽好存盘,检索时只做矩阵乘法和排序。这套思路对大规模特征库是一个可以直接复用的范式。
import numpy as np def build_index(image_feats, text_feats, save_dir="./index"): """ 预抽取全部特征并保存为 numpy 文件 image_feats: (N, D) L2 归一化后的图像特征 text_feats: (M, D) L2 归一化后的文本特征 """ np.save(f"{save_dir}/image_feats.npy", image_feats) np.save(f"{save_dir}/text_feats.npy", text_feats) def search(query_feat, index_feats, top_k=5): """ 向量化检索:query_feat 已归一化,这里只需一次矩阵乘法 """ scores = index_feats @ query_feat top_indices = np.argsort(scores)[::-1][:top_k] return top_indices, scores[top_indices]逻辑说明:检索的核心是 index_feats @ query_feat,利用 numpy 的广播机制一次性算出查询向量与全库特征的相似度。由于特征已经做过 L2 归一化,余弦相似度等价于点积,这一步不需要额外处理。argsort 降序排列得到 top-k 索引。对于 50000 条候选库,这个操作耗时在毫秒量级。
文本搜图的时候,query 是文本,需要先过文本编码器得到 query_feat,再和预存的图像特征库做矩阵乘法。图搜文本则反过来,图像特征先提取,再和预存的文本特征库做检索。因为附件二是 5000 条文本对 50000 张图,附件三是 5000 张图对 50000 条文本,两边都只需要各抽一次特征,同一份特征文件可以反复使用。
特征库里还有一类技巧值得掌握:加权融合。项目里是单模态互为查询,但如果要做“图文联合检索”——输入一张图和一段描述,找最匹配的另一张图——可以把图像特征和文本特征拼接在一起做成联合向量。CLIP 的文本和图像特征维度都是 768,拼接后是 1536 维,用于检索时召回精度通常比单模态更好,代价是索引体积翻倍。这个操作对模型结构没有任何改动,只是特征后处理。
验证检索质量有一个我常用的技巧:不只看 top-1 对不对,还要看 top-2 到 top-5 之间是否有语义相近的干扰项。如果 top-1 对了,但 top-2 到 top-5 全是完全不相关的样本,说明模型的相似度分布辨别力不足,这时候不要急着增加训练轮数,先检查是不是特征归一化出了问题,或者 logit_scale 调得过低导致分数差距太小。我后来每次训练完模型,都会把特征相似度矩阵的热力图打印出来看一眼,对角线是否明显亮于其他位置,这一步能快速判断图文对齐的质量,比单看指标数字直观得多。希望这套从数据预处理到检索落地的流程,能帮你在自己的跨模态检索任务上少走几步弯路。
本文还有配套的精品资源,点击获取