CLIP + Milvus 图文检索基线:从数据建模到多模态问答接口
多模态检索 Demo 很容易让人产生错觉:输入“红色汽车”,系统返回几张红色汽车图片,看起来就已经成功了。但真正的产品会遇到中文查询、细粒度属性、相似但错误的图片、元数据过滤、增量图片和版权边界。
本文从一个可连接自有授权图片的图文检索基线开始,再演示如何把检索结果交给 OpenAI 兼容的视觉语言模型。重点不是展示成功截图,也不公布未经执行的跑分,而是说明如何准备数据、评价系统、分析失败和逐步走向生产。
先区分三个任务
“多模态搜索”通常混合了三类不同问题:
- 以文搜图:输入文本,返回相关图片;
- 以图搜图:输入图片,返回视觉或语义相似图片;
- 多模态 RAG:先检索图片及其元数据,再让视觉语言模型基于证据回答。
三类任务应分别评估。一个模型可能擅长图像相似,却无法理解复杂文本属性;视觉语言模型能描述图片,也不代表检索阶段准确。
CLIP 做了什么
CLIP 使用文本编码器和图像编码器,把两种输入映射到同一向量空间。语义相关的图文向量距离更近,因此可以使用同一种相似度搜索。
它的优势是结构简单、生态成熟;局限也很明确:
- 经典 CLIP 主要使用英文图文对训练;
- 对数量、方位、否定和细粒度属性可能不敏感;
- 文本长度有限;
- 视觉上相似不等于业务上相关;
- 不同模态仍可能存在分布差异。
因此,中文产品应同时评估中文多模态模型、翻译查询和多语言模型,而不是直接照搬英文 Demo。
数据设计比模型调用更重要
每张图片至少保存:
- 稳定的
image_id; - 文件 URI 或对象存储地址;
- 标题或 caption;
- 类别、标签、语言等元数据;
- 来源与版权;
- 创建和更新时间;
- 图片向量。
如果同一图片有多个语言描述,不要用一个字符串覆盖。可以把描述作为独立记录,或者保存为数组并明确查询策略。
下面是一条示例数据:
{
"image_id": "catalog-00142",
"uri": "s3://demo/catalog-00142.jpg",
"caption": "一双红色低帮跑鞋,白色鞋底",
"category": "shoes",
"language": "zh-CN",
"source": "demo-catalog",
"license": "internal-demo"
}
使用 OpenCLIP 生成图文向量
下面使用 open_clip_torch 构建一个英文基线。模型名和预训练权重应在运行时锁定,中文应用必须另外评估。以下示例要求 Python 3.10 或更高版本。
OpenCLIP 代码、具体预训练权重和训练数据不是同一个授权对象。示例权重 laion2b_s34b_b79k 来自 LAION 数据训练;上线前应分别核对代码许可证、模型卡、权重条款、训练数据边界和图片自身版权,不能因为代码可用就推断图片内容可以商用。
python3 -m venv .venv
source .venv/bin/activate
python -m pip install \
"open_clip_torch>=2.30,<3" \
"torch>=2.4,<3" \
"pillow>=10,<12" \
"pymilvus[milvus_lite]>=2.5,<3" \
"openai>=1.60,<3"
from pathlib import Path
import open_clip
import torch
from PIL import Image
device = "cuda" if torch.cuda.is_available() else "cpu"
model, _, preprocess = open_clip.create_model_and_transforms(
"ViT-B-32",
pretrained="laion2b_s34b_b79k",
)
tokenizer = open_clip.get_tokenizer("ViT-B-32")
model = model.to(device).eval()
def preprocess_image(path: Path) -> torch.Tensor:
with Image.open(path) as image:
return preprocess(image.convert("RGB"))
@torch.inference_mode()
def encode_images(
paths: list[Path],
batch_size: int = 32,
) -> list[list[float]]:
encoded = []
for start in range(0, len(paths), batch_size):
batch_paths = paths[start : start + batch_size]
batch = torch.stack(
[preprocess_image(path) for path in batch_paths]
).to(device)
vectors = model.encode_image(batch)
vectors = vectors / vectors.norm(dim=-1, keepdim=True)
encoded.extend(vectors.cpu().tolist())
return encoded
@torch.inference_mode()
def encode_texts(texts: list[str]) -> list[list[float]]:
tokens = tokenizer(texts).to(device)
vectors = model.encode_text(tokens)
vectors = vectors / vectors.norm(dim=-1, keepdim=True)
return vectors.cpu().tolist()
归一化后可以使用余弦相似度。不要让文本向量使用一种归一化方式、图片向量使用另一种方式。
用清单连接图片与元数据
文章不附带来源不明的示例图片。请准备自己的授权图片,并创建 images/manifest.jsonl。每行描述一张图片:
{"image_id":"catalog-00142","path":"images/catalog-00142.jpg","caption":"一双红色低帮跑鞋,白色鞋底","category":"shoes","source":"internal-demo","license":"owned"}
然后读取清单、检查空数据和缺失文件,再写入 Milvus Lite:
import json
from pymilvus import MilvusClient
client = MilvusClient(uri="./multimodal.db")
collection = "catalog_images"
manifest_path = Path("images/manifest.jsonl")
if not manifest_path.exists():
raise FileNotFoundError("请先创建 images/manifest.jsonl")
records = [
json.loads(line)
for line in manifest_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
if not records:
raise ValueError("manifest 为空,至少需要一张授权图片")
sample_paths = [Path(record["path"]) for record in records]
missing_paths = [str(path) for path in sample_paths if not path.is_file()]
if missing_paths:
raise FileNotFoundError(f"manifest 中的图片不存在:{missing_paths}")
vectors = encode_images(sample_paths)
dimension = len(vectors[0])
if client.has_collection(collection):
client.drop_collection(collection)
client.create_collection(
collection_name=collection,
dimension=dimension,
metric_type="COSINE",
consistency_level="Strong",
)
client.insert(
collection_name=collection,
data=[
{
"id": index,
"vector": vector,
"image_id": record["image_id"],
"uri": str(path),
"caption": record["caption"],
"category": record["category"],
"source": record["source"],
"license": record["license"],
}
for index, (record, path, vector) in enumerate(
zip(records, sample_paths, vectors)
)
],
)
小数据可以使用精确搜索或默认索引。生产数据需要根据规模、召回率和延迟目标选择索引,并保存原始图片到对象存储,而不是放进向量数据库。
以文搜图
import json
def search_images(query: str, limit: int = 8, category: str | None = None):
query_vector = encode_texts([query])[0]
filter_expression = (
f"category == {json.dumps(category, ensure_ascii=False)}"
if category
else ""
)
results = client.search(
collection_name=collection,
data=[query_vector],
filter=filter_expression,
limit=limit,
output_fields=["image_id", "uri", "caption", "category", "source"],
search_params={"metric_type": "COSINE", "params": {}},
)[0]
return [
{
"score": hit["distance"],
**hit["entity"],
}
for hit in results
]
经典 CLIP 的中文能力有限。可比较三种方案:
- 中文直接输入;
- 把中文翻译成英文后输入;
- 替换为经过中文或多语言训练的图文模型。
比较时必须使用同一查询集,而不是为每个模型挑选最有利的示例。
以图搜图
查询图片使用同一个图像编码器:
query_vector = encode_images([Path("query.jpg")])[0]
results = client.search(
collection_name=collection,
data=[query_vector],
limit=8,
output_fields=["image_id", "uri", "category"],
search_params={"metric_type": "COSINE", "params": {}},
)
以图搜图通常更偏向视觉外观,以文搜图更偏向语义。产品需要决定用户想要“同款”“相似风格”还是“同一类物体”,并据此标注评测集。
加入元数据过滤
实际搜索往往还要满足类目、时间、权限或库存条件。例如:
results = search_images("red running shoes", limit=8, category="shoes")
过滤字段应使用稳定枚举或标识符。把自然语言标签直接当作唯一过滤条件,会产生拼写、语言和同义词问题。
从检索升级到多模态 RAG
多模态 RAG 可以采用以下流程:
- 文本或图片查询生成向量;
- 从 Milvus 召回图片 ID、caption 和来源;
- 使用元数据、caption 或跨编码器重排;
- 读取最终图片;
- 把图片和问题交给视觉语言模型;
- 回答中返回使用过的图片 ID。
视觉语言模型只能看到最终上下文,因此检索失败时,它可能基于错误图片生成非常流畅的错误回答。检索指标和回答指标必须分开。
一个安全的提示词应要求:
只根据提供的图片和元数据回答。
无法从图片确认的细节必须明确说明。
每个结论标注对应的 image_id。
下面的函数把最终图片作为 Data URL 发送给兼容 OpenAI Chat Completions 的视觉模型。请只向模型服务发送允许外传的图片,并结合具体供应商的请求大小、图片数量和数据保留规则调整:
import base64
import mimetypes
import os
from openai import OpenAI
vision_client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("OPENAI_BASE_URL"),
)
def image_data_url(path: Path) -> str:
mime_type = mimetypes.guess_type(path.name)[0] or "image/jpeg"
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{mime_type};base64,{encoded}"
def answer_with_images(query: str, hits: list[dict]) -> dict:
if not hits:
return {"answer": "没有检索到可用图片。", "image_ids": []}
content = [
{
"type": "text",
"text": (
"只根据随后提供的图片和元数据回答。无法确认的细节要明确说明,"
"每个结论标注 [图片: image_id]。\n\n问题:" + query
),
}
]
for hit in hits[:4]:
content.append(
{
"type": "text",
"text": f'图片 ID:{hit["image_id"]};描述:{hit["caption"]}',
}
)
content.append(
{
"type": "image_url",
"image_url": {"url": image_data_url(Path(hit["uri"]))},
}
)
response = vision_client.chat.completions.create(
model=os.environ["VISION_MODEL"],
temperature=0,
messages=[{"role": "user", "content": content}],
)
return {
"answer": response.choices[0].message.content or "",
"image_ids": [hit["image_id"] for hit in hits[:4]],
}
这只是接口层参考实现,不是视觉模型质量证明。调用前还应限制图片大小、验证 MIME 类型、处理超时,并检查回答引用的图片 ID 是否来自实际上下文。
怎样单独评价回答质量
检索 Recall 合格,不代表视觉模型回答正确。为多模态问答准备一组独立测试案例,每条至少包含:问题、允许使用的图片 ID、必须出现的事实、明确不能推断的事实,以及证据不足时的预期拒答。
例如:
{
"question": "哪双鞋是红色低帮并带白色鞋底?",
"allowed_image_ids": ["catalog-00142"],
"required_claims": ["红色", "低帮", "白色鞋底"],
"forbidden_claims": ["防水", "真皮"],
"should_abstain": false
}
至少分开记录四项:
- 回答正确性:关键事实是否与图片和人工标注一致;
- 证据支撑率:回答中的可验证结论有多少能在提供的图片中找到依据;
- 图片引用准确率:
[图片: image_id]是否确实来自本次上下文并支持对应结论; - 拒答准确率:图片不足以回答时,模型是否停止推断。
引用合法性可以先做确定性检查:
import re
def validate_image_citations(answer: str, allowed_image_ids: set[str]) -> dict:
cited = set(re.findall(r"\[图片:\s*([^\]]+)\]", answer))
return {
"cited_image_ids": sorted(cited),
"unknown_image_ids": sorted(cited - allowed_image_ids),
"has_valid_citation": bool(cited & allowed_image_ids),
}
但“引用存在”不等于“引用支持结论”。回答正确性、证据支撑和禁止推断项应由固定规则、人工标注或与生成模型隔离的评审流程打分,并定期抽样复核。最终报告应同时列出检索指标和回答指标,不能把两者合成一个看似完整的总分。
怎样评价多模态检索
至少准备三类评测:
文本找图
为每张图片准备一条正确描述和多条难负例。例如正确描述是“红色低帮跑鞋,白色鞋底”,难负例可以只改变颜色、鞋帮或鞋底。
记录 Recall@1、Recall@5 和 nDCG@10。下面的最小实现只计算至少有一个相关图片的查询;无答案查询应像文本检索一样单独评价误接受率。
def recall_at_k(retrieved_ids: list[str], relevant_ids: set[str], k: int) -> float:
if not relevant_ids:
raise ValueError("Recall@K 不适用于没有相关图片的查询")
return len(set(retrieved_ids[:k]) & relevant_ids) / len(relevant_ids)
def evaluate_text_to_image(test_cases: list[dict], k: int = 5) -> dict:
scores = []
failures = []
for case in test_cases:
hits = search_images(case["query"], limit=k)
retrieved_ids = [hit["image_id"] for hit in hits]
score = recall_at_k(retrieved_ids, set(case["relevant_ids"]), k)
scores.append(score)
if score < 1:
failures.append({**case, "retrieved_ids": retrieved_ids})
return {
f"recall_at_{k}": sum(scores) / len(scores),
"failures": failures,
}
图片找图
根据产品目标标注“同款”“同风格”或“同类别”。三个标签不能混成一个相关性定义。
错误案例
至少把错误分成:
- 数量错误;
- 颜色或材质错误;
- 文字与 Logo 识别错误;
- 细粒度类别错误;
- 中文表达错误;
- 数据标注或图片本身歧义。
平均分告诉你系统好不好,错误类型决定下一步应该换模型、加 OCR、改元数据还是增加重排。
从 Demo 到产品还缺什么
- 图片来源和版权管理;
- 对象存储与 CDN;
- 重复图片和近重复检测;
- 增量更新与删除;
- 内容审核;
- 模型版本和向量重建;
- 多语言查询;
- 用户反馈与在线评估;
- 查询缓存和成本控制。
多模态系统不是“换一个 Embedding 模型”就结束。它需要把图片资产、向量、元数据、生成证据和评估流程连接起来。