资讯详情

资讯详情

图像检索系统实战:从CLIP特征提取到FAISS与Milvus向量索引落地

简介图像检索系统是一套面向计算机视觉与多媒体检索学习者的完整源码实践项目适合具备一定编程基础、希望深入理解以图搜图原理的高校学生与开发者。资源围绕图像预处理、特征提取、相似度计算、索引构建、检索策略与性能评估六大核心环节展开覆盖颜色直方图、SIFT、SURF、ORB等经典特征以及欧氏距离、余弦相似度等度量方法帮助读者从零搭建可运行的检索流程。压缩包共48个文件约1.18MB以31个bmp测试图像、5个h头文件、3个cpp源文件为主另含可执行文件、工程配置与说明文档目录结构清晰便于对照源码逐模块调试。目前已有1201人学习下载读者可通过修改特征提取与索引策略直观比较查全率、精确率与检索耗时进而设计更高效准确的图像检索方案。1. 图像检索系统从「以图搜图」到可复现的工程落地电商后台每天新增几十万张商品图设计团队想找一张「白底、俯拍、带木质纹理的保温杯」参考图靠文件名搜索根本找不到。这就是图像检索系统要解决的核心问题用一张图或一段文字描述从海量图库里把语义或视觉上最接近的结果排到前面。它和传统关键词搜索最大的区别在于检索的「query」是向量而不是字符串匹配靠的是嵌入空间里的距离度量而不是倒排索引里的词项重合。这套系统适合三类人一是手里有几千到几百万张图、想加搜索入口的开发者二是做内容审核、版权比对、相似商品去重的工程团队三是想理解向量检索底层机制、准备自己搭一套最小可用系统的学习者。下面我会按「特征怎么提、索引怎么建、服务怎么跑、坑怎么避」的顺序把一条能落地的路径讲清楚参数和命令都给到能直接抄的程度。2. 特征提取与模型选型为什么你的检索结果总是不对2.1 全局特征和局部特征的分工图像检索的特征大致分两派。全局特征把整张图压成一个定长向量常见的是 CNN 骨干网络最后一层池化输出或者 CLIP 这类图文对齐模型输出的 512 维向量。它的优点是快、存储小、适合粗排缺点是背景干扰大主体占比小的图容易被背景带偏。局部特征则是从图中检测关键点再描述比如 SIFT、SuperPoint适合做精细匹配和重排但一张图可能产生几百上千个描述子存储和计算成本高一个量级。我一般会采用「全局粗排 局部重排」的两段式结构。粗排用 CLIP 或一个在业务数据上微调过的 ResNet 变体把候选从百万级压到几百级重排再用局部特征或更强的交叉编码器精算。这样既控制了延迟又保住了精度。如果图库规模在十万以内、延迟要求不苛刻直接上全局特征单段检索也够用别一上来就堆复杂结构。2.2 用 CLIP 提取图像嵌入的最小代码下面这段代码用常见的开源 CLIP 实现提取图像向量并做 L2 归一化。归一化很关键归一化之后内积等价于余弦相似度后续索引可以直接用内积检索。import torch import clip from PIL import Image import numpy as np # 加载模型ViT-B/32 在速度和精度之间比较平衡 device cuda if torch.cuda.is_available() else cpu model, preprocess clip.load(ViT-B/32, devicedevice) model.eval() def extract_embedding(image_path): # 预处理缩放、中心裁剪、归一化尺寸由模型自带 preprocess 决定 image preprocess(Image.open(image_path).convert(RGB)).unsqueeze(0).to(device) with torch.no_grad(): # encode_image 输出 512 维向量ViT-B/32 feat model.encode_image(image) # L2 归一化让内积等于余弦相似度 feat feat / feat.norm(dim-1, keepdimTrue) return feat.cpu().numpy().astype(float32) vec extract_embedding(sample.jpg) print(vec.shape) # (1, 512)逻辑说明preprocess里包含了 resize 到 224、中心裁剪和 ImageNet 均值方差归一化不要自己另写一套否则和训练分布不一致会掉点。encode_image输出的是未归一化的特征必须手动做 L2 归一化。参数上ViT-B/32 输出 512 维ViT-L/14 输出 768 维但显存和延迟翻几倍图库小于五十万时 B/32 通常够用。如果业务图是商品白底图建议在自有数据上做一次线性探测或 LoRA 微调top-10 命中率往往能涨十几个点。2.3 批量提取与落盘的组织方式单张提取太慢实际要批量跑。我的习惯是把图库路径写成一个清单文件用 DataLoader 多进程加载每批 64 或 128 张提取完直接写进一个内存映射文件或向量数据库。下面是一个批量提取并保存为 npy 的骨架from torch.utils.data import Dataset, DataLoader import os class ImageList(Dataset): def __init__(self, paths): self.paths paths def __len__(self): return len(self.paths) def __getitem__(self, idx): img preprocess(Image.open(self.paths[idx]).convert(RGB)) return img, idx def batch_extract(paths, batch_size128): ds ImageList(paths) # num_workers 按 CPU 核数设通常 8 或 16 dl DataLoader(ds, batch_sizebatch_size, num_workers8, shuffleFalse) all_feats [] for imgs, idxs in dl: imgs imgs.to(device) with torch.no_grad(): feats model.encode_image(imgs) feats feats / feats.norm(dim-1, keepdimTrue) all_feats.append(feats.cpu().numpy().astype(float32)) return np.concatenate(all_feats, axis0) paths [os.path.join(images, f) for f in os.listdir(images)] feats batch_extract(paths) np.save(embeddings.npy, feats) # 形状 (N, 512)参数说明batch_size受显存限制512 维向量下 128 一般安全num_workers设成 CPU 物理核数设太大反而因进程切换变慢。落盘用 npy 适合几十万级超过百万建议直接写 FAISS 或 Milvus避免每次启动都重新加载全量向量。3. 向量索引与相似度检索把百万级图库压进毫秒响应3.1 暴力检索、IVF 和 HNSW 的取舍向量检索的索引结构直接决定延迟和召回。暴力检索Flat是精确的逐个算内积百万级向量单次查询大概几百毫秒到一秒适合做小库或作为召回基准。IVF 先把向量聚成若干簇查询时只扫最近的 nprobe 个簇速度提升明显但有召回损失。HNSW 是基于图的近似检索查询快、召回高但建索引慢、内存占用大。我的经验是图库十万以内直接 Flat别折腾十万到五百万用 IVF把 nprobe 调到 16 到 64 之间平衡五百万以上或者对延迟极敏感用 HNSW。下面用 FAISS 演示这三种索引的构建和查询。import faiss import numpy as np feats np.load(embeddings.npy) # (N, 512), 已归一化 d feats.shape[1] # 方案一Flat精确检索 index_flat faiss.IndexFlatIP(d) # IP 即内积配合归一化等于余弦 index_flat.add(feats) # 方案二IVF先聚类再检索 nlist 1024 # 簇数一般取 sqrt(N) 到 4*sqrt(N) quantizer faiss.IndexFlatIP(d) index_ivf faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_INNER_PRODUCT) index_ivf.train(feats) # IVF 必须先训练 index_ivf.add(feats) index_ivf.nprobe 32 # 查询时扫描的簇数越大越准越慢 # 方案三HNSW图索引 index_hnsw faiss.IndexHNSWFlat(d, 32) # 32 是每个节点的连接数 M index_hnsw.add(feats) query feats[0:1] D, I index_ivf.search(query, k10) # 返回距离和索引 print(I)逻辑说明IndexFlatIP用内积度量前提是向量已归一化否则内积不等于余弦。IVF 的train步骤不能省它用样本做 k-means 聚类训练样本建议至少nlist * 39条。nprobe是召回和延迟的旋钮从 1 调到 nlist 会逐渐逼近精确结果。HNSW 的第二个参数 M 控制图的稠密度M 越大召回越高、内存越大32 是常用起点。参数上nlist取4*sqrt(N)在多数场景召回更好但训练和查询都更慢需要实测。3.2 用 Milvus 搭一个可持久化的检索服务FAISS 是库不是服务进程重启索引就没了。要长期跑常见做法是上 Milvus 或 Qdrant 这类向量数据库。下面用 Milvus 的 Python 客户端建集合、插入、检索展示最小闭环。from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType connections.connect(hostlocalhost, port19530) fields [ FieldSchema(nameid, dtypeDataType.INT64, is_primaryTrue), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dim512), ] schema CollectionSchema(fields, descriptionimage retrieval) col Collection(nameimages, schemaschema) # 建 IVF 索引metric 用 IP col.create_index( field_nameembedding, index_params{index_type: IVF_FLAT, metric_type: IP, params: {nlist: 1024}}, ) col.load() # 插入embeddings 是 (N, 512) 的 list col.insert([list(range(len(feats))), feats.tolist()]) col.flush() # 检索 res col.search( data[feats[0].tolist()], anns_fieldembedding, param{metric_type: IP, params: {nprobe: 32}}, limit10, ) print(res[0].ids)逻辑说明Milvus 把索引和原始向量分开管理create_index后必须load才能查。metric_type选 IP 同样要求向量归一化。nprobe在查询时传入可以按请求动态调整比如对精度要求高的请求调大。参数上dim必须和嵌入维度严格一致写错会在插入时报错。如果图库持续增长建议按时间或业务分区建多个 collection避免单集合过大导致索引重建耗时。3.3 检索结果的重排与去重粗排返回的 top-k 里经常有近乎重复的图比如同一商品的不同角度。直接展示体验很差。常见做法是对候选做一次相似度阈值去重如果两张图的向量余弦大于 0.98只保留一张。更精细的可以用局部特征做几何验证确认是不是同一主体的不同视角。def dedup(results, feats, threshold0.98): kept [] for idx in results: dup False for k in kept: sim float(np.dot(feats[idx], feats[k])) if sim threshold: dup True break if not dup: kept.append(idx) return kept阈值 0.98 是经验值白底商品图可以放到 0.99场景图建议降到 0.95。这个去重是 O(k^2)k 只有几十时开销可忽略。如果候选上千得换更高效的结构。4. 服务化与性能调优从脚本到能扛并发的接口4.1 用 FastAPI 包一层检索接口脚本跑通只是第一步要给别人用就得有 HTTP 接口。下面用 FastAPI 写一个接收图片、返回 top-k 结果的接口把模型和索引在启动时加载一次避免每次请求都重新加载。from fastapi import FastAPI, UploadFile from PIL import Image import io, numpy as np, faiss, torch, clip app FastAPI() device cuda if torch.cuda.is_available() else cpu model, preprocess clip.load(ViT-B/32, devicedevice) model.eval() index faiss.read_index(index_ivf.faiss) # 启动时加载一次 index.nprobe 32 app.post(/search) async def search(file: UploadFile, k: int 10): img Image.open(io.BytesIO(await file.read())).convert(RGB) tensor preprocess(img).unsqueeze(0).to(device) with torch.no_grad(): feat model.encode_image(tensor) feat feat / feat.norm(dim-1, keepdimTrue) D, I index.search(feat.cpu().numpy().astype(float32), k) return {ids: I[0].tolist(), scores: D[0].tolist()}逻辑说明模型和索引放在模块级FastAPI 启动时执行一次请求里只做前向和检索。k作为查询参数暴露出去默认 10。参数上如果并发高GPU 前向会成为瓶颈可以用 TorchScript 或 ONNX 导出加速或者把嵌入提取单独拆成一个服务检索服务只负责向量查询。注意index.nprobe是全局的多请求共享如果不同请求要不同精度得用锁或每请求克隆索引。4.2 延迟拆解与瓶颈定位一次检索的延迟由三部分组成图片解码和预处理、模型前向、向量检索。实测中ViT-B/32 在单张 GPU 上前向约 5 到 10 毫秒预处理 2 到 3 毫秒IVF 检索百万级 nprobe32 约 3 到 8 毫秒。如果总延迟超过 50 毫秒先看是不是每次请求都重新加载了模型这是最常见的翻车点。定位方法很简单在接口里打时间戳分段记录。如果前向占大头考虑换更小的模型或做量化如果检索占大头降 nprobe 或换 HNSW如果预处理占大头检查是不是原图太大可以在客户端先压缩再上传。我一般会在压测时用 100 并发跑看 P99 而不是平均值平均值好看但 P99 爆掉的情况太常见了。4.3 索引更新与增量插入图库不是静态的每天有新图进来。FAISS 的 IVF 索引支持add增量插入但新向量不会自动参与聚类插多了召回会下降。常见做法是攒到一定量比如总量的 10%就重建一次索引。重建时用新全量数据重新 train 和 add期间用旧索引对外服务重建完原子替换。Milvus 这类数据库对增量插入更友好但也要注意 segment 合并和索引重建的时机。如果业务允许秒级延迟可以先用 Flat 索引接增量后台异步重建 IVF。参数上重建频率取决于新增速度和召回容忍度日增 1% 以内可以一周重建一次。5. 避坑与排查那些让检索结果集体跑偏的细节5.1 现象查询图和库图明明一样却排到几十名开外原因通常是预处理不一致。库图提取时用了中心裁剪查询图却直接 resize主体位置偏移导致嵌入差异大。解决把预处理逻辑封装成一个函数库和查询共用同一份代码别在两处各写一遍。另外确认库图提取时的模型版本和查询时一致换过模型必须全量重提。5.2 现象top-10 结果全是同一张图的变体原因是没有做去重或者去重阈值设太高。解决在返回前加去重逻辑阈值按业务调。如果是同一商品多角度可以按商品 ID 聚合每个商品只返回最相似的一张。注意去重要在重排之后做否则可能把真正相关的不同商品误删。5.3 现象IVF 检索召回率明显低于 Flat原因可能是 nprobe 太小或者训练样本不足导致聚类质量差。解决先把 nprobe 调到 nlist 的 10% 到 20% 观察召回变化如果还是低检查 train 用的样本量是否够至少 nlist*39。另一个常见原因是向量没归一化就用了 IP 度量内积被模长主导检索结果完全乱套。5.4 现象服务跑一段时间后内存持续上涨原因通常是请求里创建的 tensor 没释放或者索引被反复加载。解决确认模型和索引只在启动时加载请求内的中间变量用with torch.no_grad()包住避免计算图累积。如果用 GPU定期检查显存必要时在低峰期重启服务。Milvus 场景下还要注意 collection 的 load 状态重复 load 会占额外内存。5.5 现象中文或特定领域查询效果差如果系统支持文搜图CLIP 原版对中文和垂直领域词汇对齐较弱。解决用中文 CLIP 变体或者在业务数据上做图文对微调。没有标注数据时可以用大模型生成一批描述再过滤但要注意生成数据的噪声。参数上微调学习率设小一点1e-5 量级否则容易把预训练能力冲掉。6. 进阶技巧用查询扩展和混合检索再榨几个点做到上面几步系统已经能用了。但如果想再提升召回尤其是短查询或模糊查询场景可以试试查询扩展。思路很简单把一张查询图做几种轻微变换水平翻转、小角度旋转、亮度微调每种变换各提一个向量检索时把多路结果融合。融合方式可以用分数加权也可以取并集后重排。def multi_query_search(img, index, k10, weightsNone): variants [img, img.transpose(Image.FLIP_LEFT_RIGHT)] all_scores {} for i, v in enumerate(variants): tensor preprocess(v).unsqueeze(0).to(device) with torch.no_grad(): feat model.encode_image(tensor) feat feat / feat.norm(dim-1, keepdimTrue) D, I index.search(feat.cpu().numpy().astype(float32), k * 2) w weights[i] if weights else 1.0 for score, idx in zip(D[0], I[0]): all_scores[idx] max(all_scores.get(idx, -1), score * w) ranked sorted(all_scores.items(), keylambda x: -x[1])[:k] return [i for i, _ in ranked]逻辑说明翻转图单独提一个向量和原图结果按索引合并同一张图取最高分。权重可以给原图更高比如 1.0 和 0.8。这个技巧对主体不对称的图效果有限但对整体布局敏感的查询有帮助。代价是检索次数翻倍延迟增加适合离线重排或对延迟不敏感的接口。另一个方向是混合检索把向量相似度和关键词过滤结合。比如用户搜「红色连衣裙」先用文本检索或属性分类筛出红色和连衣裙类目再在这个子集里做向量检索。这样既利用了向量的语义能力又用结构化条件缩小了范围精度和速度都能受益。实现上Milvus 支持标量字段过滤可以在 search 时带 expr 参数FAISS 则需要自己维护子索引或先过滤再检索。验证这套系统好不好别只看几个 case。我一般会构造一个几百条的评测集每条包含查询图和人工标注的相关图 ID算 Recall10 和 mAP。上线前跑一遍上线后每周抽样跑一遍观察指标有没有漂移。指标掉了先查数据分布是不是变了再查模型和索引有没有被误改。这个习惯帮我省过好几次「以为没事结果线上翻车」的后悔药。最后说个我踩过的坑早期为了省事把嵌入提取和检索塞在一个进程里结果 GPU 前向一慢整个检索接口全堵住。后来拆成两个服务提取服务专注算力检索服务专注索引各自扩容稳定性好了很多。系统设计上别让慢操作拖死快操作这是分布式里最朴素的道理但真到写代码时很容易忘。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →