跳到主要内容

CLIP + Milvus 图文检索基线:从数据建模到多模态问答接口

· 阅读需 11 分钟
XlongLab
AI 产品、技术与工程实践

多模态检索 Demo 很容易让人产生错觉:输入“红色汽车”,系统返回几张红色汽车图片,看起来就已经成功了。但真正的产品会遇到中文查询、细粒度属性、相似但错误的图片、元数据过滤、增量图片和版权边界。

本文从一个可连接自有授权图片的图文检索基线开始,再演示如何把检索结果交给 OpenAI 兼容的视觉语言模型。重点不是展示成功截图,也不公布未经执行的跑分,而是说明如何准备数据、评价系统、分析失败和逐步走向生产。

先区分三个任务

“多模态搜索”通常混合了三类不同问题:

  1. 以文搜图:输入文本,返回相关图片;
  2. 以图搜图:输入图片,返回视觉或语义相似图片;
  3. 多模态 RAG:先检索图片及其元数据,再让视觉语言模型基于证据回答。

三类任务应分别评估。一个模型可能擅长图像相似,却无法理解复杂文本属性;视觉语言模型能描述图片,也不代表检索阶段准确。

CLIP 做了什么

CLIP 使用文本编码器和图像编码器,把两种输入映射到同一向量空间。语义相关的图文向量距离更近,因此可以使用同一种相似度搜索。

它的优势是结构简单、生态成熟;局限也很明确:

  • 经典 CLIP 主要使用英文图文对训练;
  • 对数量、方位、否定和细粒度属性可能不敏感;
  • 文本长度有限;
  • 视觉上相似不等于业务上相关;
  • 不同模态仍可能存在分布差异。

因此,中文产品应同时评估中文多模态模型、翻译查询和多语言模型,而不是直接照搬英文 Demo。

数据设计比模型调用更重要

每张图片至少保存:

  • 稳定的 image_id
  • 文件 URI 或对象存储地址;
  • 标题或 caption;
  • 类别、标签、语言等元数据;
  • 来源与版权;
  • 创建和更新时间;
  • 图片向量。

如果同一图片有多个语言描述,不要用一个字符串覆盖。可以把描述作为独立记录,或者保存为数组并明确查询策略。

下面是一条示例数据:

JSON
{
"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 数据训练;上线前应分别核对代码许可证、模型卡、权重条款、训练数据边界和图片自身版权,不能因为代码可用就推断图片内容可以商用。

Shell
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"
Python
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。每行描述一张图片:

JSON
{"image_id":"catalog-00142","path":"images/catalog-00142.jpg","caption":"一双红色低帮跑鞋,白色鞋底","category":"shoes","source":"internal-demo","license":"owned"}

然后读取清单、检查空数据和缺失文件,再写入 Milvus Lite:

Python
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)
)
],
)

小数据可以使用精确搜索或默认索引。生产数据需要根据规模、召回率和延迟目标选择索引,并保存原始图片到对象存储,而不是放进向量数据库。

以文搜图

Python
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 的中文能力有限。可比较三种方案:

  • 中文直接输入;
  • 把中文翻译成英文后输入;
  • 替换为经过中文或多语言训练的图文模型。

比较时必须使用同一查询集,而不是为每个模型挑选最有利的示例。

以图搜图

查询图片使用同一个图像编码器:

Python
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": {}},
)

以图搜图通常更偏向视觉外观,以文搜图更偏向语义。产品需要决定用户想要“同款”“相似风格”还是“同一类物体”,并据此标注评测集。

加入元数据过滤

实际搜索往往还要满足类目、时间、权限或库存条件。例如:

Python
results = search_images("red running shoes", limit=8, category="shoes")

过滤字段应使用稳定枚举或标识符。把自然语言标签直接当作唯一过滤条件,会产生拼写、语言和同义词问题。

从检索升级到多模态 RAG

多模态 RAG 可以采用以下流程:

  1. 文本或图片查询生成向量;
  2. 从 Milvus 召回图片 ID、caption 和来源;
  3. 使用元数据、caption 或跨编码器重排;
  4. 读取最终图片;
  5. 把图片和问题交给视觉语言模型;
  6. 回答中返回使用过的图片 ID。

视觉语言模型只能看到最终上下文,因此检索失败时,它可能基于错误图片生成非常流畅的错误回答。检索指标和回答指标必须分开。

一个安全的提示词应要求:

Text
只根据提供的图片和元数据回答。
无法从图片确认的细节必须明确说明。
每个结论标注对应的 image_id。

下面的函数把最终图片作为 Data URL 发送给兼容 OpenAI Chat Completions 的视觉模型。请只向模型服务发送允许外传的图片,并结合具体供应商的请求大小、图片数量和数据保留规则调整:

Python
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、必须出现的事实、明确不能推断的事实,以及证据不足时的预期拒答。

例如:

JSON
{
"question": "哪双鞋是红色低帮并带白色鞋底?",
"allowed_image_ids": ["catalog-00142"],
"required_claims": ["红色", "低帮", "白色鞋底"],
"forbidden_claims": ["防水", "真皮"],
"should_abstain": false
}

至少分开记录四项:

  • 回答正确性:关键事实是否与图片和人工标注一致;
  • 证据支撑率:回答中的可验证结论有多少能在提供的图片中找到依据;
  • 图片引用准确率[图片: image_id] 是否确实来自本次上下文并支持对应结论;
  • 拒答准确率:图片不足以回答时,模型是否停止推断。

引用合法性可以先做确定性检查:

Python
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。下面的最小实现只计算至少有一个相关图片的查询;无答案查询应像文本检索一样单独评价误接受率。

Python
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 模型”就结束。它需要把图片资产、向量、元数据、生成证据和评估流程连接起来。

参考资料