去年双11,我们团队负责的电商搜索系统出了个大问题。用户搜"红色连衣裙"时,返回的结果里混了大量黑色T恤——因为商品标题里写了"红色"但图片是黑的。更离谱的是,搜"带帽子的羽绒服"时,系统只匹配标题关键词,完全无视图片里有没有帽子。
CTO在周会上拍桌子:"你们搞的搜索,连图片都看不懂?"
这不是个例。根据我们统计,2023年Q3搜索转化率下降了12%,其中37%的bad case都跟图文不匹配有关。传统方案依赖文本关键词匹配,图片信息完全浪费了。
我们决定上多模态模型。但选哪个?怎么落地?踩了哪些坑?这篇文章全盘托出。
我们的核心需求三个:
这三个任务覆盖了电商搜索、商品管理、客服问答等场景。我们评估了三个主流方案:CLIP、BLIP-2、LLaVA。
先上结论:
| 模型 | 参数量 | 推理速度(单卡A100) | 图文检索 | 图像描述 | 视觉问答 | 部署难度 |
|---|---|---|---|---|---|---|
| CLIP ViT-L/14 | 428M | 15ms/张 | ★★★★★ | ★ | ★ | 低 |
| BLIP-2 (OPT-2.7B) | 3.7B | 120ms/张 | ★★★★ | ★★★★★ | ★★★★ | 中 |
| LLaVA-1.5 (7B) | 7B | 350ms/张 | ★★★ | ★★★★★ | ★★★★★ | 高 |
CLIP是双塔结构,文本和图像分别编码,适合检索。BLIP-2用Q-Former桥接视觉和语言,能生成描述。LLaVA直接端到端训练,视觉问答最强。
我们最终选了CLIP + BLIP-2的组合方案:CLIP做检索,BLIP-2做生成。为什么不用LLaVA?后面避坑部分会说。
硬件:NVIDIA A100 80GB × 1,CUDA 12.1,PyTorch 2.1.0
软件:Python 3.10,transformers 4.36.2,accelerate 0.25.0
# 安装依赖
pip install torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.36.2 accelerate==0.25.0 pillow requests tqdm
pip install open_clip_torch==2.23.0 # CLIP的优化实现
我们使用open_clip的ViT-L/14模型,比官方CLIP快30%。
# clip_retrieval.py
import torch
import open_clip
from PIL import Image
import numpy as np
from typing import List
class CLIPRetrieval:
def __init__(self, model_name: str = "ViT-L-14", pretrained: str = "openai"):
self.device = "cuda" if torch.cuda.is_available() else "cpu"
self.model, _, self.preprocess = open_clip.create_model_and_transforms(
model_name, pretrained=pretrained
)
self.model = self.model.to(self.device)
self.model.eval()
self.tokenizer = open_clip.get_tokenizer(model_name)
# 预热模型
dummy_text = self.tokenizer(["test"])
dummy_img = torch.randn(1, 3, 224, 224).to(self.device)
with torch.no_grad():
self.model.encode_text(dummy_text)
self.model.encode_image(dummy_img)
print(f"CLIP模型加载完成,设备:{self.device}")
def encode_images(self, image_paths: List[str]) -> np.ndarray:
"""批量编码图片,返回归一化的特征向量"""
images = []
for path in image_paths:
img = Image.open(path).convert("RGB")
images.append(self.preprocess(img).unsqueeze(0))
image_tensor = torch.cat(images).to(self.device)
with torch.no_grad():
features = self.model.encode_image(image_tensor)
features = features / features.norm(dim=-1, keepdim=True)
return features.cpu().numpy()
def encode_texts(self, texts: List[str]) -> np.ndarray:
"""批量编码文本"""
text_tokens = self.tokenizer(texts).to(self.device)
with torch.no_grad():
features = self.model.encode_text(text_tokens)
features = features / features.norm(dim=-1, keepdim=True)
return features.cpu().numpy()
def search(self, query: str, image_features: np.ndarray, top_k: int = 10):
"""文本检索图片,返回相似度排序后的索引"""
query_feat = self.encode_texts([query])
similarities = np.dot(image_features, query_feat.T).flatten()
top_indices = np.argsort(similarities)[::-1][:top_k]
return top_indices, similarities[top_indices]
# 使用示例
if __name__ == "__main__":
retriever = CLIPRetrieval()
# 假设有1000张商品图
image_paths = [f"images/{i}.jpg" for i in range(1000)]
image_features = retriever.encode_images(image_paths)
query = "红色连衣裙 带帽子"
indices, scores = retriever.search(query, image_features, top_k=5)
print(f"查询:{query}")
for idx, score in zip(indices, scores):
print(f" 图片{idx}: 相似度 {score:.4f}")
BLIP-2使用Q-Former架构,我们加载预训练好的OPT-2.7B版本。
# blip2_caption.py
import torch
from transformers import Blip2Processor, Blip2ForConditionalGeneration
from PIL import Image
import time
class BLIP2Caption:
def __init__(self, model_name: str = "Salesforce/blip2-opt-2.7b"):
self.device = "cuda" if torch.cuda.is_available() else "cpu"
self.processor = Blip2Processor.from_pretrained(model_name)
self.model = Blip2ForConditionalGeneration.from_pretrained(
model_name, torch_dtype=torch.float16
).to(self.device)
self.model.eval()
print(f"BLIP-2模型加载完成,设备:{self.device}")
def generate_caption(self, image_path: str, max_length: int = 50) -> str:
"""生成图像描述"""
image = Image.open(image_path).convert("RGB")
inputs = self.processor(images=image, return_tensors="pt").to(self.device)
with torch.no_grad():
generated_ids = self.model.generate(
**inputs,
max_length=max_length,
num_beams=5,
temperature=0.7,
do_sample=True
)
caption = self.processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
return caption
def visual_qa(self, image_path: str, question: str) -> str:
"""视觉问答"""
image = Image.open(image_path).convert("RGB")
inputs = self.processor(
images=image, text=question, return_tensors="pt"
).to(self.device)
with torch.no_grad():
generated_ids = self.model.generate(
**inputs,
max_length=30,
num_beams=3
)
answer = self.processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
return answer
# 使用示例
if __name__ == "__main__":
captioner = BLIP2Caption()
# 图像描述
start = time.time()
caption = captioner.generate_caption("test_image.jpg")
elapsed = time.time() - start
print(f"描述:{caption} (耗时:{elapsed:.2f}s)")
# 视觉问答
answer = captioner.visual_qa("test_image.jpg", "这件衣服是什么颜色?")
print(f"回答:{answer}")
用FastAPI封装成RESTful服务,支持批量查询。
# api_server.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import numpy as np
import uvicorn
from clip_retrieval import CLIPRetrieval
from blip2_caption import BLIP2Caption
import time
from typing import List
app = FastAPI(title="多模态检索服务")
# 全局加载模型
retriever = CLIPRetrieval()
captioner = BLIP2Caption()
# 假设预计算好的图片特征
image_features = np.load("image_features.npy")
image_paths = [f"images/{i}.jpg" for i in range(image_features.shape[0])]
class SearchRequest(BaseModel):
query: str
top_k: int = 10
class SearchResponse(BaseModel):
results: List[dict]
latency_ms: float
class CaptionRequest(BaseModel):
image_path: str
class CaptionResponse(BaseModel):
caption: str
latency_ms: float
@app.post("/search", response_model=SearchResponse)
async def search_images(request: SearchRequest):
"""图文检索接口"""
start = time.time()
indices, scores = retriever.search(request.query, image_features, request.top_k)
results = [
{"image_path": image_paths[idx], "score": float(score)}
for idx, score in zip(indices, scores)
]
latency = (time.time() - start) * 1000
return SearchResponse(results=results, latency_ms=latency)
@app.post("/caption", response_model=CaptionResponse)
async def generate_caption(request: CaptionRequest):
"""图像描述接口"""
start = time.time()
caption = captioner.generate_caption(request.image_path)
latency = (time.time() - start) * 1000
return CaptionResponse(caption=caption, latency_ms=latency)
@app.post("/vqa")
async def visual_qa(image_path: str, question: str):
"""视觉问答接口"""
start = time.time()
answer = captioner.visual_qa(image_path, question)
latency = (time.time() - start) * 1000
return {"answer": answer, "latency_ms": latency}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
生产环境需要预计算所有图片特征,用GPU批量处理。
# precompute_features.py
import os
import numpy as np
from clip_retrieval import CLIPRetrieval
from tqdm import tqdm
import glob
def precompute_all_features(image_dir: str, output_path: str):
"""预计算所有图片特征并保存"""
retriever = CLIPRetrieval()
# 获取所有图片路径
image_paths = glob.glob(os.path.join(image_dir, "*.jpg"))
image_paths += glob.glob(os.path.join(image_dir, "*.png"))
print(f"找到 {len(image_paths)} 张图片")
# 分批处理,避免OOM
batch_size = 64
all_features = []
for i in tqdm(range(0, len(image_paths), batch_size)):
batch_paths = image_paths[i:i+batch_size]
batch_features = retriever.encode_images(batch_paths)
all_features.append(batch_features)
# 合并并保存
all_features = np.concatenate(all_features, axis=0)
np.save(output_path, all_features)
np.save(output_path.replace(".npy", "_paths.npy"), image_paths)
print(f"特征已保存到 {output_path},形状:{all_features.shape}")
if __name__ == "__main__":
precompute_all_features("images/", "image_features.npy")
用locust做压力测试,模拟真实并发。
# locustfile.py
from locust import HttpUser, task, between
import random
import json
class MultimodalUser(HttpUser):
wait_time = between(0.1, 0.5) # 模拟用户思考时间
queries = [
"红色连衣裙",
"黑色运动鞋",
"带帽子的羽绒服",
"白色T恤 圆领",
"蓝色牛仔裤 修身"
]
@task(3)
def search_test(self):
"""图文检索压测,权重3"""
query = random.choice(self.queries)
payload = {"query": query, "top_k": 10}
with self.client.post("/search", json=payload, catch_response=True) as response:
if response.status_code == 200:
data = response.json()
if data["latency_ms"] > 500: # 超过500ms告警
response.failure(f"延迟过高:{data['latency_ms']}ms")
else:
response.failure(f"状态码:{response.status_code}")
@task(1)
def caption_test(self):
"""图像描述压测,权重1"""
image_path = "test_image.jpg"
payload = {"image_path": image_path}
with self.client.post("/caption", json=payload, catch_response=True) as response:
if response.status_code == 200:
data = response.json()
if data["latency_ms"] > 2000: # 超过2s告警
response.failure(f"延迟过高:{data['latency_ms']}ms")
else:
response.failure(f"状态码:{response.status_code}")
我们在内部测试集上做了评估,测试集包含5000张商品图,1000个查询。
| 指标 | 传统文本搜索 | CLIP检索 | 提升 |
|---|---|---|---|
| Recall@10 | 0.42 | 0.87 | +107% |
| mAP@10 | 0.35 | 0.79 | +126% |
| 平均延迟(单查询) | 8ms | 15ms | +7ms |
图像描述质量评估(人工打分1-5分):
| 模型 | 平均分 | 准确率 | 流畅度 |
|---|---|---|---|
| BLIP-2 OPT-2.7B | 4.2 | 89% | 4.5 |
| BLIP-2 FlanT5-XL | 4.0 | 85% | 4.3 |
| LLaVA-1.5 7B | 4.5 | 92% | 4.7 |
压测结果(100并发,持续5分钟):
| 接口 | QPS | P50延迟 | P99延迟 | 错误率 |
|---|---|---|---|---|
| /search | 850 | 18ms | 45ms | 0.02% |
| /caption | 120 | 135ms | 280ms | 0.1% |
| /vqa | 95 | 160ms | 320ms | 0.15% |
上线后,搜索转化率从3.2%提升到4.8%,bad case减少62%。
以下是我们实际踩过的坑,每个都花了至少一周时间解决。
CLIP的预训练数据主要是英文,直接用于中文检索效果很差。我们测试了"红色连衣裙"这个查询,英文版CLIP的Recall@10只有0.31。
解决方案:使用中文CLIP变体,比如Chinese-CLIP(https://github.com/OFA-Sys/Chinese-CLIP)。我们换用chinese-clip-vit-large-patch14-336px后,Recall@10提升到0.85。
默认生成的描述像"这是一张图片,上面有一件红色的连衣裙,裙子是长袖的,领口是圆领设计...",用户根本不想看。
解决方案:在generate时设置repetition_penalty=1.2,并限制max_length=30。同时用prompt工程:"简短描述这张商品图,不超过20个字"。
# 优化后的生成参数
generated_ids = self.model.generate(
**inputs,
max_length=30,
num_beams=3,
repetition_penalty=1.2,
temperature=0.6,
do_sample=False # 确定性生成
)
LLaVA-1.5 7B在视觉问答上确实最强,但有两个致命问题:
我们试过量化到4bit,速度提升到200ms,但准确率掉了5%。最终决定只在离线场景用LLaVA做数据标注,线上用BLIP-2。
第一次跑precompute_features.py时,10000张图直接OOM了。因为CLIP的encode_images一次性加载了所有图片到显存。
解决方案:分批处理,batch_size=64。同时用torch.no_grad()和float16精度。
# 显存优化:使用float16和梯度关闭
self.model = self.model.to(self.device).half()
with torch.no_grad():
features = self.model.encode_image(image_tensor.half())
CLIP和BLIP-2对图片的预处理不同。CLIP用224x224,BLIP-2用364x364。如果混用,特征会偏移。
解决方案:各自使用对应的processor,不要手动resize。
BLIP-2模型加载需要30秒,导致服务启动慢。K8s健康检查总是失败。
解决方案:使用模型预热,在启动脚本中先跑一次推理。同时设置readiness probe延迟到60秒。
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: multimodal-api
spec:
replicas: 3
template:
spec:
containers:
- name: api
image: multimodal-api:latest
ports:
- containerPort: 8000
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60 # 给模型加载留时间
periodSeconds: 10
resources:
limits:
nvidia.com/gpu: 1
memory: "32Gi"
requests:
memory: "16Gi"
多模态模型不是银弹。CLIP适合检索,BLIP-2适合生成,LLaVA适合离线分析。选型要看业务场景和延迟要求。
我们这套方案上线跑了4个月,日均处理50万次检索,2万次描述生成。搜索转化率提升50%,客服人工成本降低30%。
代码都在GitHub上:https://github.com/your-company/multimodal-search
专注技术分享与实战