news 2026/9/23 1:47:57

基于CLIP与向量检索实现跨模态图文搜索

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于CLIP与向量检索实现跨模态图文搜索

简介:面向计算机相关专业学生的跨模态检索课程设计资源,基于Flickr30k数据集实现图像与文本的双向搜索。项目提供完整Python源码,覆盖数据预处理、模型训练、评估与可视化测试界面等核心环节,适合作为课设、毕设或入门进阶的参考项目。压缩包共35个文件,其中包含22个Python脚本及6个编译后的pyc文件,用于完成数据集划分、resize、训练与评估;另有3个Markdown文档与shell脚本,便于快速了解项目结构并初始化环境;整体体积仅1.13MB,轻量易用。目前已有586人学习下载。代码均经过运行验证,答辩评审平均分96分,资源目录按预处理、训练、测试等模块划分,便于定位和二次开发,可直接使用或在此基础上扩展其他功能,是与跨模态检索相关的完整实践范例。

1. 从一个反直觉现象开始:跨模态搜索做的是向量比较,不是文本匹配

在检索框输入a man with a red umbrella walking on the beach,返回结果里没有一张图在文件名或标签中带有umbrella字样。传统关键词匹配在跨模态场景下失效,原因很简单:图片没有原生文本,人工打标签也覆盖不了这种组合型长尾。Flickr30k 数据集恰好是最适合课程设计的落地样本:31,783 张图片,每张附 5 条英文描述,规模足够小,单卡甚至 CPU 都能在十分钟内完成特征抽取。

这个题目的核心不是让程序“看懂”图片,而是把图像与文本编码进同一个向量空间。CLIP 这类双塔模型负责把两种模态对齐,向量检索负责快速召回。后文按数据清洗、CLIP 特征抽取、相似度检索、Flask 测试界面、R@K 评估五个步骤展开,每一步都给出可直接运行的 python 源码和参数含义。

2. Flickr30k 数据集清洗与 CLIP 特征抽取

2.1 认识 captions.txt:先读原始标注再做清洗

下载到的 Flickr30k 解压后,常见目录结构是flickr30k_images/存放 JPG,captions.txt存放图片描述。每一行格式固定为:图片文件名<TAB>描述文本。torchvision 里虽然有现成的Flickr30k数据类,但课程设计改过目录结构后,我建议先自己解析一次原始文件,后续去重、过滤脏数据也更可控。

from pathlib import Path ann_path = Path("data/captions.txt") pairs = [] with ann_path.open("r", encoding="utf-8") as f: for line in f: line = line.rstrip("\n") if not line: continue img_name, caption_text = line.split("\t", maxsplit=1) pairs.append((img_name, caption_text)) print("caption 总条数:", len(pairs)) print("去重图片数:", len({img for img, _ in pairs})) print("平均单词数:", sum(len(c.split()) for _, c in pairs) / len(pairs))

split("\t", maxsplit=1)是为了防止描述文本里出现制表符导致切出多列。打印统计信息能提前发现两类问题:一是明明该有 15 万条标注却少了几千,大概率是下载时文件被截断;二是平均单词数异常,说明 caption 与图片错位。

清洗规则直接决定后续检索质量。CLIP 的 tokenizer 最大上下文是 77 个 token,超过部分会被截断,所以长度过滤比想象中更重要。常用策略如下:

处理项默认策略说明
英文大小写全部转小写CLIP 词表以小写为主,保留大小写会稀释命中率
特殊符号替换为空格保留, . ? !,去掉# @ $等噪音
长度过滤保留 4~60 词少于 4 词通常语义过弱,多于 60 词基本会被截断
import re def clean_caption(text: str) -> str: text = text.lower() text = re.sub(r"[^a-z0-9\s,\.\?\!]", " ", text) text = re.sub(r"\s+", " ", text).strip() return text def caption_ok(text: str, min_len: int = 4, max_len: int = 60) -> bool: n = len(text.split()) return min_len <= n <= max_len texts = [clean_caption(cap) for _, cap in pairs if caption_ok(cap)]

注意:清洗后要重新做一次去重,Flickr30k 原始标注里偶尔出现完全相同的句子对应不同图片,留着会影响评估阶段的正样本判定。

2.2 用 open_clip 双塔模型批量抽取图像与文本向量

特征抽取是整个项目的核心开销,建议一次性把所有向量算好存成.npy,运行时直接加载,而不是每次查询都重新编码。这里用open_clip加载 ViT-B/32 权重,它输出的向量维度是 512,对 15 万条文本规模足够。

import numpy as np import torch import open_clip from pathlib import Path from PIL import Image from torch.utils.data import DataLoader, Dataset class Flickr30kImages(Dataset): def __init__(self, image_dir: str, transform): self.images = sorted(Path(image_dir).glob("*.jpg")) self.transform = transform def __len__(self): return len(self.images) def __getitem__(self, idx): img = Image.open(self.images[idx]).convert("RGB") return self.transform(img), str(self.images[idx]) model, _, transform = open_clip.create_model_and_transforms( "ViT-B-32", pretrained="laion2b_s34b_b79k", ) model.eval() tokenizer = open_clip.get_tokenizer("ViT-B-32") dataset = Flickr30kImages("data/flickr30k_images", transform) loader = DataLoader(dataset, batch_size=64, num_workers=4) image_embs, image_paths = [], [] with torch.no_grad(): for batch, paths in loader: feats = model.encode_image(batch) feats = torch.nn.functional.normalize(feats, dim=-1) image_embs.append(feats.cpu()) image_paths.extend(paths) image_embeddings = torch.cat(image_embs).numpy().astype("float32") np.save("data/image_embeddings.npy", image_embeddings)

normalize(dim=-1)这一步不能省。CLIP 原始输出不是单位向量,直接做点积得到的是带尺度偏差的相似度,后面设阈值时会非常难调。统一归一化后,点积就等于余弦相似度,阈值含义变得直观。

文本向量同理,但 15 万条 caption 一次性encode_text会把显存撑爆,需要分块:

text_embs = [] batch_size = 512 for i in range(0, len(texts), batch_size): batch_tokens = tokenizer(texts[i:i + batch_size]) with torch.no_grad(): batch_emb = model.encode_text(batch_tokens) text_embs.append(batch_emb.cpu()) text_embeddings = torch.cat(text_embs).numpy().astype("float32") np.save("data/text_embeddings.npy", text_embeddings)

参数这里有几处值得按机器配置调整。batch_size=64对 8GB 显存是安全的,如果显存更小就降到 16 或 8;CPU 环境跑图像特征建议num_workers=4以上,让数据加载和模型推理重叠。ViT-B/32 在 3.1 万张图上通常几分钟能完成,换成 ViT-L/14 会明显变慢,但它 768 维的特征在做细粒度检索时更稳。

参数推荐值调整方向
图像 batch_size64显存不足时依次减半
文本 batch_size512按显存调整,OOM 就降到 256
存储精度float3215 万×512 维约 300MB,没必要用 float64
模型规模ViT-B/32追求效果可换 ViT-L/14,维度会变 768

第一次运行会从模型仓库拉取预训练权重,离线环境需要先在有网机器上把权重缓存好,再拷贝到目标机器的~/.cache目录。这个细节在课程设计答辩现场经常被问到。

3. 跨模态搜索的相似度计算:从暴力遍历到 faiss 向量索引

3.1 双向检索方向不同,gallery 也随之切换

跨模态搜索有两个方向:文本查图像、图像查文本。很多课程设计只做了一个方向,但测试界面上通常会同时展示。文本查图像时,候选库是image_embeddings.npy,每一行对应一张图;图像查文本时,候选库是text_embeddings.npy,每一行对应一条 caption。搞清楚这一点,代码反而比做单方向更简单。

import numpy as np def topk_search(query_vec: np.ndarray, gallery: np.ndarray, k: int = 10): scores = gallery @ query_vec top_indices = np.argpartition(scores, -k)[-k:] top_indices = top_indices[np.argsort(scores[top_indices])[::-1]] return top_indices, scores[top_indices]

argpartition只保证前 k 个元素在正确区间,不保证有序,所以后面要再套一次argsort。对 15 万条文本做一次矩阵乘在 CPU 上只需几十毫秒,这是 Flickr30k 这种小规模数据集可以直接暴力检索的原因。

文本查图像的调用方式:

query = "a man walking his dog in the park" tokens = tokenizer([query]) with torch.no_grad(): q = model.encode_text(tokens) q = torch.nn.functional.normalize(q, dim=-1).numpy()[0] idx, scores = topk_search(q, image_embeddings, k=10) for pos, score in zip(idx, scores): print(image_paths[pos], round(float(score), 4))

图像查文本则把image_embeddings换成text_embeddings,返回的是 caption 文本而非图片路径。课程设计的常见错误是只做文本查图像,然后在“以图搜文”按钮上复用同一个 gallery,导致结果永远是同一批图。方向不同,查询向量和 gallery 的对应关系都要一起换。

3.2 数据量上去后,用 faiss 保留精确召回

Flickr30k 只有 15 万条文本,暴力检索够用。如果后续换成 MS COCO 或更大规模数据集,argpartition虽然比argsort快,但仍是全量扫描,这时候切到 faiss 是标准做法。

import faiss dim = image_embeddings.shape[1] index = faiss.IndexFlatIP(dim) index.add(image_embeddings) scores, idx = index.search(q[None, :], k=10)

IndexFlatIP是暴力内积索引,精确返回 top-k,因为向量已经归一化,内积就是余弦相似度。这里有个常踩的坑:有人直接用IndexFlatL2,不归一化查询向量,返回结果和余弦版本完全不是一回事。

索引类型适用规模召回率特点使用注意
IndexFlatIP100 万以内精确召回必须先 L2 归一化
IndexIVFFlat千万级接近精确需设置 nlist、nprobe
IndexHNSWFlat千万级高召回构建慢,内存占用高

切到IndexIVFFlat时,只需在 add 前用训练集index.train()建聚类。两个关键参数是nlistnprobenlist控制分桶数量,经验值是sqrt(N),15 万条数据取 400 左右;nprobe控制查询时扫描多少个桶,调大能提升召回但变慢。课程设计答辩时,讲清楚这两个参数的权衡比单纯堆代码得分高得多。

阈值设定也很容易出错。不同模型的相似度分数分布差异很大,ViT-B/32 的高分样本经常落在 0.3 以上,但换成更强的模型可能整体偏移。建议在测试阶段打印一批正样本和负样本的分数分布,再决定score_threshold取 0.2 还是 0.3。

4. 测试界面与查询服务:用 Flask 把检索链路暴露成 API

4.1 定义 /api/search/text 与 /api/search/image

测试界面本质上是对检索服务的可视化封装。先用 Flask 把两个方向封装成 JSON API,方便前端调用,也方便后续从网页测试切到命令行测试。

import io import numpy as np import torch import open_clip from flask import Flask, request, jsonify, render_template from PIL import Image app = Flask(__name__) model = None transform = None tokenizer = None def load_model(): global model, transform, tokenizer if model is None: model, _, transform = open_clip.create_model_and_transforms( "ViT-B-32", pretrained="laion2b_s34b_b79k" ) model.eval() tokenizer = open_clip.get_tokenizer("ViT-B-32") return model, transform, tokenizer

懒加载模型的目的是避免服务启动时阻塞,但第一次请求会明显慢,前端需要做 loading 提示。接下来定义文本查询端点:

@app.route("/api/search/text", methods=["POST"]) def search_by_text(): data = request.get_json() text = data.get("text", "").strip() k = min(int(data.get("k", 10)), 50) if not text: return jsonify({"code": 400, "msg": "text is empty"}), 400 model, _, tokenizer = load_model() tokens = tokenizer([text]) with torch.no_grad(): q = model.encode_text(tokens) q = torch.nn.functional.normalize(q, dim=-1).numpy().astype("float32")[0] idx, scores = topk_search(q, image_embeddings, k=k) items = [ {"image": image_paths[i], "score": float(s)} for i, s in zip(idx, scores) ] return jsonify({"code": 0, "data": items})

限制k = min(..., 50)是为了防止用户在界面上传一个 10000 导致前端卡死。image_paths[i]需要和保存image_embeddings.npy时的顺序严格一致,所以建议在特征抽取阶段把image_paths也存成image_paths.json,而不是靠文件名二次排序。

图像查询端点接收 multipart 文件,处理逻辑几乎一样,只是编码器换成encode_image

@app.route("/api/search/image", methods=["POST"]) def search_by_image(): f = request.files.get("image") if f is None: return jsonify({"code": 400, "msg": "image is required"}), 400 img = Image.open(f.stream).convert("RGB") model, transform, _ = load_model() tensor = transform(img).unsqueeze(0) with torch.no_grad(): q = model.encode_image(tensor) q = torch.nn.functional.normalize(q, dim=-1).numpy().astype("float32")[0] idx, scores = topk_search(q, text_embeddings, k=10) items = [ {"caption": texts[i], "score": float(s)} for i, s in zip(idx, scores) ] return jsonify({"code": 0, "data": items})

注意两个端点的 gallery 不同:文本查询图像用image_embeddings,图像查询文本用text_embeddings。如果复制代码时把 gallery 也复制错,返回结果会变成“文本查文本”或“图像查图像”,分数看起来正常但语义完全错位。

4.2 前端交互:输入框、上传按钮与结果展示

前端只做一个原生 HTML 页面就够了,不引 React 这类框架,课程设计阶段反而更利于讲清楚数据流。核心是 fetch 调接口,然后把返回的图片路径或文本插入结果区。

<input id="query" placeholder="输入英文描述,例如 a dog running on grass" /> <button onclick="searchText()">检索图片</button> <div id="results"></div> <script> async function searchText() { const text = document.getElementById("query").value; const resp = await fetch("/api/search/text", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({text: text, k: 12}) }); const data = await resp.json(); if (data.code !== 0) return; document.getElementById("results").innerHTML = data.data .map(item => `<div style="display:inline-block;margin:8px;"> <img src="/static/${item.image}" width="240" /> <p>score: ${item.score.toFixed(4)}</p> </div>`) .join(""); } </script>

图片能显示的前提是 flask 能找到对应静态文件。常见做法是把flickr30k_images目录软链到static/flickr30k_images,或者在 Flask 里额外注册一个静态目录。图像上传的交互类似,换成<input type="file">FormData即可,上传后调用/api/search/image,渲染结果时显示 caption 文本。

4.3 启动服务后的三类边界情况

现象原因处理
首次请求等了几十秒模型权重懒加载前端加 loading,或启动时主动 load_model()
上传非 JPG 文件报 500PIL 打开失败Image.open套 try/except 返回 400
检索维度不一致模型换过但 npy 没重新抽检查嵌入维度,ViT-B/32 是 512,ViT-L/14 是 768

注意:app.run(debug=True)在提交作业时最好关掉,debug 模式会启动 reloader,模型权重会被重复加载,内存占用翻倍。

5. 评估调优:让 Flickr30k 跨模态搜索在课程设计里拿高分

5.1 用 R@K 量化召回质量

只靠肉眼判断几个查询结果,说服力不够。课程设计加分项是把评估脚本和检索代码放在一起,用 R@K 说明方案在 Flickr30k 上的表现。

def recall_at_k(query_embs, gallery_embs, query_labels, gallery_labels, k=10): scores = query_embs @ gallery_embs.T hits = 0 for i in range(len(scores)): top_ids = np.argsort(scores[i])[::-1][:k] hits += int(query_labels[i] in set(gallery_labels[top_ids])) return hits / len(scores)

query_labelsgallery_labels需要按数据集组织。文本查图像方向,一张图对应 5 条 caption,正确做法是把每张图作为 query 的 label,和 5 条 caption 分别计算,再取平均;图像查文本方向则反过来。R@10 的结果不建议跨模型裸比,不同预训练权重和清洗策略都会影响绝对数值,重点看同一实验内的相对提升。

5.2 三个投入产出比最高的调优方向

调优手段成本适用场景
换更大的 CLIP 模型重新抽特征基线效果普遍偏低时
对 Flickr30k 做双塔微调需要少量标注检索概念有明显领域偏差时
二阶段重排改服务层逻辑粗排 top-50 内已有正确答案时

重排是性价比最高的方案:先用 CLIP 粗排取 top-50,再用更重的模型或同一个模型对 top-50 内的图文对重新计算相似度,效果比盲目换大模型更可控。难例挖掘的具体做法是收集 top-10 里的假正例,打印 query 与错误结果的 score,观察是颜色词混淆还是场景词混淆,再决定是否需要对训练数据进行增强。

最后给一个交付级技巧:把R@1、R@5、R@10score_threshold画成一条曲线,观察阈值从 0.2 升到 0.4 时准确率与召回的此消彼长,这份图表放进项目说明,比大段文字更能说明你理解了跨模态搜索的评估逻辑。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 1:47:00

医学影像超分辨率重建:物理约束驱动的临床落地实践

简介&#xff1a;本资源是一份面向人工智能方向高校学生、医学影像研究者及深度学习实践者的完整项目实践包&#xff0c;聚焦图像超分辨率重建技术在医学CT影像中的落地应用。内容涵盖自然图像&#xff08;DIV2K&#xff09;与医学图像&#xff08;DeepLesion CT切片&#xff0…

作者头像 李华
网站建设 2026/9/23 1:46:43

ISO 18413-2015液压元件清洁度检测:从取样到报告全流程解析

简介&#xff1a;ISO 18413:2015《液压流体动力—组件的清洁度—检验文件及与污染物提取、分析和数据报告相关的原理》标准原文&#xff08;PDF版&#xff09;&#xff0c;面向液压系统设计、制造、质量检验及维护人员&#xff0c;用于统一组件清洁度的评估、检测与报告流程。资…

作者头像 李华
网站建设 2026/9/23 1:46:41

WebGIS开发实战:跨域代理、图层控制与坐标转换全解析

简介&#xff1a;这套资源围绕ArcGIS API for JavaScript的WebGIS开发实践展开&#xff0c;定位清晰&#xff0c;面向Web GIS初学者、前端工程师及需要快速上手地图开发的读者。内容覆盖OGC的Web服务规范、REST风格服务、ArcGIS Server站点架构&#xff0c;以及页面布局、图层操…

作者头像 李华
网站建设 2026/9/23 1:44:31

RV1126 ISP调试环境搭建与核心模块调优实战指南

做图像产品的人&#xff0c;基本都绕不开 ISP 调试这道坎。瑞芯微 RV1126 这颗芯片在安防摄像头、门锁、婴儿看护、工业视觉这些方案里出镜率非常高&#xff0c;原因不外乎三点&#xff1a;算力够用、带硬核 ISP pipeline、配套 SDK 也比较成体系。但很多朋友拿到板子后第一步就…

作者头像 李华
网站建设 2026/9/23 1:44:30

ESP32驱动LCD屏实战:SPI与8080并口从接线到代码全解析

玩ESP32的人迟早会碰到一块屏幕。不管你是做温湿度计、桌面时钟、离线的仪表盘&#xff0c;还是想给蓝牙控制的小项目加一个可视化界面&#xff0c;LCD屏几乎是绕不开的组件。而网上关于ESP32驱动LCD的教程&#xff0c;要么只讲了某个屏的零散接线&#xff0c;要么一上来就扔给…

作者头像 李华