CLIP与FAISS教程:构建多模态语义搜索

本教程面向有编程基础的开发者,深入讲解如何使用Google的gemini-embedding-2模型构建多模态语义搜索系统。通过构建包含误导性文件名的混合数据集,演示了文本和图像嵌入的生成、余弦相似度计算以及跨模态检索。教程强调语义搜索相比关键词搜索的优势,并提供了完整的代码实现、环境配置和可视化方法。读者将掌握多模态嵌入的核心原理、API调用技巧以及实际应用场景,为构建生产级语义搜索系统打下基础。

技术背景

传统关键词搜索依赖文件名或元数据匹配,无法理解内容语义,导致检索结果不准确。多模态语义搜索通过将文本和图像映射到共享向量空间,实现基于内容的跨模态检索,解决数据异构和命名不规范问题。gemini-embedding-2模型支持文本和图像统一嵌入,适用于数字资产管理、推荐系统、跨模态文档检索等场景。主要限制包括API成本、嵌入维度固定(如3072维)以及需要处理任务前缀以优化检索效果。

核心原理

gemini-embedding-2模型将文本和图像编码为高维向量,使得语义相似的内容在向量空间中距离更近。模型采用Transformer架构,对文本和图像分别编码后映射到同一向量空间。对于检索任务,模型依赖结构化任务前缀来区分查询和文档:查询格式为'task: search result | query: {query_text}',文档格式为'title: {filename} | text: {content}'(图像则交错文本前缀和图像字节)。余弦相似度衡量向量夹角,值越接近1表示语义越相似。由于嵌入向量已归一化,余弦相似度简化为点积。

架构分析

系统架构分为数据层、嵌入层、索引层和检索层。数据层负责加载和预处理文件,通过MIME类型区分图像和文本。嵌入层调用gemini-embedding-2 API生成向量,支持文本和图像输入。索引层将嵌入向量存储在内存列表中,便于快速计算相似度。检索层接收自然语言查询,生成查询嵌入,与所有文档嵌入计算余弦相似度,返回Top-K结果。设计决策包括使用误导性文件名验证语义搜索能力,以及通过任务前缀优化查询-文档不对称性。生产环境可扩展为使用FAISS等向量数据库实现大规模索引和检索。

完整实战教程

##### 版权所有 2026 Google LLC.

#@title Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

使用 gemini-embedding-2 进行多模态语义搜索

作者:@NaveenKumarG-dev

本笔记本演示了使用 gemini-embedding-2 模型和官方 google-genai Python SDK 进行多模态语义搜索和跨模态检索。

您将学习如何:

  1. 设置 google-genai SDK 并使用您的 API 密钥进行身份验证。
  2. 构建混合数据集,其中包含故意误导性的文件名,以突出语义搜索的强大功能。
  3. 加载不同类型的文件,并准备它们以生成嵌入。
  4. 使用 gemini-embedding-2 为文本和图像内容生成多模态嵌入。
  5. 使用余弦相似度执行语义相似性搜索。
  6. 使用自然语言查询运行跨模态检索。
  7. 可视化检索结果,并比较语义搜索与关键字搜索。

查看定价页面和模型文档以获取最新详细信息。

设置

安装 SDK

%pip install -U -q "google-genai>=1.0.0"

设置你的 API 密钥

要运行以下单元格,你的 API 密钥必须存储在名为 GEMINI_API_KEY 的 Colab 密钥中。如果你还没有 API 密钥,或者不确定如何创建 Colab 密钥,请参阅 身份验证 快速入门指南。

from google.colab import userdata
from google import genai

GEMINI_API_KEY = userdata.get('GEMINI_API_KEY')
client = genai.Client(api_key=GEMINI_API_KEY)

选择嵌入模型

你将使用 gemini-embedding-2,该模型将文本和图像映射到共享的向量空间,从而实现跨模态比较。

EMBEDDING_MODEL = "gemini-embedding-2"  # @param ["gemini-embedding-2"] {"allow-input": true, isTemplate: true}

导入库

import os
import mimetypes
import numpy as np

from PIL import Image
from typing import Any
from IPython.display import display, HTML

构建数据集

为了突出语义搜索的强大功能,你将创建一个带有故意误导性文件名的小型本地数据集。例如,一张狗的图片保存为 cat_image.png,而一篇关于海洋的文本文件命名为 space_facts.txt。

传统的关键词搜索“猫”会因为文件名而找到 cat_image.png。然而,语义搜索分析的是内容本身——因此,查询“狗”会正确检索到 cat_image.png,因为其内容是狗。

下面的图片来源于 维基共享资源,并根据知识共享(CC-BY-SA 或类似)许可协议授权。有关确切的许可细节,请参阅各个维基共享资源文件页面。文本文档为原创内容。

import requests

DATA_DIR = "multimodal_data"
os.makedirs(DATA_DIR, exist_ok=True)

# Publicly accessible, stable image URLs (Wikimedia Commons, CC0/public domain)
DATASET = [
    {
        "filename": "cat_image.png",       # Misleading: actually a dog
        "content_type": "image",
        "true_label": "dog",
        "image_url": "https://upload.wikimedia.org/wikipedia/commons/2/26/YellowLabradorLooking_new.jpg",
    },
    {
        "filename": "dog_picture.jpg",      # Misleading: actually a cat
        "content_type": "image",
        "true_label": "cat",
        "image_url": "https://upload.wikimedia.org/wikipedia/commons/1/11/Fluffy_Cat.jpg",
    },
    {
        "filename": "ocean_view.jpg",       # Misleading: actually a mountain
        "content_type": "image",
        "true_label": "mountain",
        "image_url": "https://upload.wikimedia.org/wikipedia/commons/e/e7/Everest_North_Face_toward_Base_Camp_Tibet_Luca_Galuzzi_2006.jpg",
    },
    {
        "filename": "space_facts.txt",      # Misleading: actually about the ocean
        "content_type": "text",
        "true_label": "ocean",
        "text_content": (
            "The ocean covers more than 70% of the Earth's surface. "
            "It is home to a vast diversity of marine life, from microscopic "
            "plankton to giant whales. The deepest point, the Mariana Trench, "
            "plunges nearly 11 kilometres below the surface."
        ),
    },
    {
        "filename": "mountain_climbing.txt", # Misleading: actually about stars
        "content_type": "text",
        "true_label": "stars",
        "text_content": (
            "The night sky is filled with billions of stars, planets, and galaxies. "
            "On a clear night, away from city lights, you can observe the Milky Way "
            "arching across the sky. Constellations like Orion and the Big Dipper "
            "have guided navigators for centuries."
        ),
    },
    {
        "filename": "bird_song.txt",        # Misleading: actually about a car
        "content_type": "text",
        "true_label": "car",
        "text_content": (
            "The sleek red sports car accelerated down the coastal road, "
            "its powerful engine a symphony of finely tuned machinery. "
            "It hugged every curve effortlessly, a blur of speed against the deep blue sea."
        ),
    },
]


def download_image(url: str, filename: str) -> None:
    """Downloads an image from a URL and saves it to DATA_DIR."""
    dest = os.path.join(DATA_DIR, filename)
    # Wikimedia Commons requires a User-Agent header to prevent 403 blocks
    headers = {
        "User-Agent": "GeminiAPICookbook/1.0 (https://github.com/google-gemini/cookbook)"
    }
    response = requests.get(url, headers=headers, timeout=30)
    response.raise_for_status()
    with open(dest, "wb") as f:
        f.write(response.content)
    print(f"Downloaded  {filename}")


# Create the dataset files
for item in DATASET:
    file_path = os.path.join(DATA_DIR, item["filename"])
    if item["content_type"] == "image":
        if not os.path.exists(file_path):
            download_image(item["image_url"], item["filename"])
        else:
            print(f"Already exists: {item['filename']}")
    else:
        if not os.path.exists(file_path):
            with open(file_path, "w", encoding="utf-8") as f:
                f.write(item["text_content"])
            print(f"Created       {item['filename']}")
        else:
            print(f"Already exists: {item['filename']}")

print(f"\nDataset ready in '{DATA_DIR}/'")

加载文件

加载每个文件并检测其 MIME 类型。图像以 PIL.Image 对象打开;文本文件则作为纯字符串读取。

# @title Helper — guess_mime_type and load_file_content

def guess_mime_type(file_path: str) -> str:
    """Guess the MIME type of a file with a robust fallback mapping."""
    mime_type, _ = mimetypes.guess_type(file_path)
    if not mime_type:
        ext = os.path.splitext(file_path)[1].lower()
        if ext in [".png", ".webp", ".bmp", ".gif"]:
            mime_type = f"image/{ext[1:]}"
        elif ext in [".jpg", ".jpeg"]:
            mime_type = "image/jpeg"
        elif ext in [".txt", ".md", ".py", ".json", ".csv"]:
            mime_type = "text/plain"
        else:
            mime_type = "application/octet-stream"
    return mime_type


def load_file_content(file_path: str) -> str | Image.Image:
    """Loads file content based on its MIME type.

    Args:
        file_path: Path to the file.

    Returns:
        A str for text files, or a PIL.Image.Image for image files.

    Raises:
        FileNotFoundError: If the file does not exist.
        ValueError: If the MIME type is not supported.
    """
    if not os.path.exists(file_path):
        raise FileNotFoundError(f"File not found: {file_path}")

    mime_type = guess_mime_type(file_path)

    if mime_type.startswith("image"):
        return Image.open(file_path).convert("RGB")
    elif mime_type.startswith("text"):
        with open(file_path, "r", encoding="utf-8") as f:
            return f.read()
    else:
        raise ValueError(
            f"Unsupported MIME type '{mime_type}' for file: {file_path}"
        )
loaded_files = []

print(f"Loading files from '{DATA_DIR}/'...")
for root, _, filenames in os.walk(DATA_DIR):
    for filename in sorted(filenames):
        file_path = os.path.join(root, filename)
        mime_type = guess_mime_type(file_path)
        try:
            content = load_file_content(file_path)
            loaded_files.append(
                {
                    "filename": filename,
                    "path": file_path,
                    "mime_type": mime_type,
                    "content": content,
                }
            )
            print(f"  Loaded {filename} ({mime_type})")
        except (FileNotFoundError, ValueError) as e:
            print(f"  Skipped {filename}: {e}")

print(f"\nTotal files loaded: {len(loaded_files)}")

生成嵌入

使用 client.models.embed_content 为每个文件生成向量。

gemini-embedding-2 模型是多模态的。对于检索相关的用例,模型依赖输入中的结构化任务前缀来弥合查询和文档之间的不对称性。

格式化输入的最佳实践:

# @title Helper — generate_embedding

from google.genai import types as genai_types


def generate_embedding(
    content: str | bytes,
    mime_type: str | None = None,
    is_query: bool = False,
    title: str | None = None,
) -> list[float]:
    """Generates an embedding vector for text or image content.

    Args:
        content: A text string or raw bytes (for images).
        mime_type: The MIME type of the content (required if content is bytes).
        is_query: True if generating embedding for a search query.
        title: The document title/filename (used for documents).

    Returns:
        A list of floats representing the embedding vector.

    Note:
        For real applications, consider using a library like numpy to handle vector arithmetic.
    """
    if is_query:
        # Format the query according to gemini-embedding-2 best practices
        input_content = f"task: search result | query: {content}"
    else:
        doc_title = title if title else "none"
        if isinstance(content, bytes):
            if not mime_type:
                raise ValueError("mime_type is required for binary content (bytes)")
            # Interleave document title prefix with the image part
            input_content = [
                f"title: {doc_title} | text: ",
                genai_types.Part.from_bytes(
                    data=content,
                    mime_type=mime_type,
                )
            ]
        else:
            # Format text document with title and content
            input_content = f"title: {doc_title} | text: {content}"

    response = client.models.embed_content(
        model=EMBEDDING_MODEL,
        contents=input_content,
    )
    return response.embeddings[0].values
# Build the indexed corpus by generating an embedding for each file.
indexed_corpus = []

print("Generating embeddings...")
for item in loaded_files:
    try:
        if item["mime_type"].startswith("image"):
            # Read binary file bytes for image embeddings
            with open(item["path"], "rb") as f:
                embed_data = f.read()
            mime_type = item["mime_type"]
        else:
            # Use plain string for text embeddings
            embed_data = item["content"]
            mime_type = None

        embedding = generate_embedding(
            embed_data,
            mime_type=mime_type,
            is_query=False,
            title=item["filename"],
        )
        indexed_corpus.append(
            {
                "filename": item["filename"],
                "path": item["path"],
                "mime_type": item["mime_type"],
                "content_type": "image" if item["mime_type"].startswith("image") else "text",
                "content": item["content"],
                "embedding": embedding,
            }
        )
        print(f"  Embedded {item['filename']}")
    except Exception as e:  # pylint: disable=broad-except
        print(f"  Error embedding {item['filename']}: {e}")

print(f"\nCorpus size: {len(indexed_corpus)} items")

搜索语料库

定义相似度函数和搜索辅助函数。余弦相似度衡量两个向量之间的夹角——值越接近 1.0 表示语义相似度越高。

# @title Helper — cosine_similarity and search_similar_content


def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
    """Returns the cosine similarity between two embedding vectors."""
    # Since gemini-embedding-2 vectors are normalized unit vectors,
    # cosine similarity simplifies to the dot product.
    return float(np.dot(vec1, vec2))


def search_similar_content(
    query_text: str,
    corpus: list[dict[str, Any]],
    top_k: int = 3,
) -> list[dict[str, Any]]:
    """Performs semantic search against the indexed corpus.

    Args:
        query_text: The natural language query.
        corpus: The list of embedded items.
        top_k: Number of top results to return.

    Returns:
        A list of the top_k most similar items, sorted by score descending.
    """
    # Embed the query with the is_query=True flag
    query_embedding = generate_embedding(query_text, is_query=True)

    scored = [
        {
            **{k: v for k, v in item.items() if k != "embedding"},
            "score": cosine_similarity(query_embedding, item["embedding"]),
        }
        for item in corpus
    ]
    scored.sort(key=lambda x: x["score"], reverse=True)
    return scored[:top_k]

运行跨模态检索

对语料库运行几个文本查询。注意,结果在很大程度上按语义内容排序。虽然文档标题仍会被模型考虑在内,但语义对齐往往胜过具有误导性的文件名。

queries = [
    "a dog running in a field",
    "a fluffy cat resting",
    "beautiful mountain scenery",
    "a starry night sky",
    "the vast ocean and marine life",
    "a fast sports car",
]

retrieval_results: dict[str, list[dict[str, Any]]] = {}

for query in queries:
    print(f"\nQuery: '{query}'")
    results = search_similar_content(query, indexed_corpus, top_k=3)
    retrieval_results[query] = results
    for rank, result in enumerate(results, start=1):
        print(
            f"  {rank}. {result['filename']} "
            f"(type={result['content_type']}, score={result['score']:.4f})"
        )

可视化结果

显示每个查询检索到的项目。图像以内联方式显示;文本结果则显示其内容的片段。

# @title Helper — display_results

import base64
import io


def pil_to_data_uri(img: Image.Image, fmt: str = "JPEG") -> str:
    """Converts a PIL image to a base64 data URI for inline HTML display."""
    buf = io.BytesIO()
    img.save(buf, format=fmt)
    encoded = base64.b64encode(buf.getvalue()).decode("utf-8")
    return f"data:image/{fmt.lower()};base64,{encoded}"


def display_results(
    query: str,
    results: list[dict[str, Any]],
) -> None:
    """Renders retrieval results as an HTML card grid."""
    cards = ""
    for rank, result in enumerate(results, start=1):
        if result["content_type"] == "image":
            data_uri = pil_to_data_uri(result["content"])
            media_html = (
                f"<img src='{data_uri}' "
                "style='max-width:100%;height:180px;object-fit:cover;"
                "border-radius:4px;'>"
            )
        else:
            snippet = result["content"][:200]
            if len(result["content"]) > 200:
                snippet += "..."
            media_html = (
                "<div style='background:#f5f5f5;color:#333;padding:8px;"
                "border-radius:4px;font-size:0.85em;max-height:160px;"
                f"overflow-y:auto;'><code>{snippet}</code></div>"
            )

        cards += f"""
        <div style='border:1px solid #ddd;border-radius:8px;padding:12px;
                    width:280px;box-shadow:0 1px 4px rgba(0,0,0,.1);'>
          <b>#{rank} &mdash; score: {result['score']:.4f}</b><br>
          <small><code>{result['filename']}</code> ({result['content_type']})</small>
          <div style='margin-top:8px;'>{media_html}</div>
        </div>"""

    html = f"""
    <h3 style='margin-bottom:8px;'>Query: &ldquo;{query}&rdquo;</h3>
    <div style='display:flex;flex-wrap:wrap;gap:16px;margin-bottom:24px;'>
      {cards}
    </div>"""
    display(HTML(html))
for query, results in retrieval_results.items():
    display_results(query, results)

关键词搜索与语义搜索

下表对比了当你搜索同一术语时,每种方法返回的结果。

def keyword_search(query: str, corpus: list[dict[str, Any]]) -> list[str]:
    """Returns filenames that contain the query string (case-insensitive)."""
    query_lower = query.lower()
    return [
        item["filename"]
        for item in corpus
        if query_lower in item["filename"].lower()
    ]


# Compare keyword vs. semantic search for a few representative terms.
comparison_queries = [
    ("a fluffy cat resting", "cat"),
    ("a dog running in a field", "dog"),
    ("beautiful mountain scenery", "mountain"),
    ("the vast ocean and marine life", "ocean"),
    ("a starry night sky", "space"),
    ("a fast sports car", "bird")
]

header = f"{'Descriptive Query':<32}  {'Keyword matches':<22}  {'Top semantic match (score)'}"
print(header)
print("-" * len(header))

for desc_q, kw_term in comparison_queries:
    kw_hits = keyword_search(kw_term, indexed_corpus) or ["(none)"]
    sem_results = search_similar_content(desc_q, indexed_corpus, top_k=1)
    sem_top = f"{sem_results[0]['filename']} ({sem_results[0]['score']:.4f})" if sem_results else "(none)"
    print(f"{desc_q:<32}  {', '.join(kw_hits):<22}  {sem_top}")

总结

在本笔记本中,你:

核心见解是 gemini-embedding-2 将文本和图像映射到共享向量空间,因此像“狗”这样的文本查询可以浮现出保存为 cat_image.png 的狗的图像,这完全基于内容含义而非文件名。

实际应用

后续步骤

有用的 API 参考

相关示例

常见问题

常见错误包括:1) API密钥未设置或无效,导致认证失败,需检查GEMINI_API_KEY环境变量。2) 图像下载失败,因缺少User-Agent头被服务器拒绝,需添加请求头。3) MIME类型识别错误,导致文件加载失败,需完善guess_mime_type函数。4) 嵌入生成超时或配额限制,需增加重试机制和错误处理。5) 内存不足,当语料库过大时,建议使用向量数据库。排查方法:打印错误信息,检查文件路径和权限,验证API响应格式。生产优化:使用批处理嵌入请求,缓存嵌入结果,采用FAISS或Annoy索引加速检索。

企业应用扩展

本教程可扩展为:1) 集成FAISS实现大规模向量索引,支持百万级数据检索。2) 添加重排序机制,使用交叉编码器提升精度。3) 支持视频和音频嵌入,扩展多模态能力。4) 构建REST API服务,封装检索逻辑。5) 实现增量索引更新,支持动态数据。6) 结合用户反馈进行模型微调,优化特定领域效果。7) 使用Docker容器化部署,便于扩展和维护。

总结

本教程面向有编程基础的开发者,深入讲解如何使用Google的gemini-embedding-2模型构建多模态语义搜索系统。通过构建包含误导性文件名的混合数据集,演示了文本和图像嵌入的生成、余弦相似度计算以及跨模态检索。教程强调语义搜索相比关键词搜索的优势,并提供了完整的代码实现、环境配置和可视化方法。读者将掌握多模态嵌入的核心原理、API调用技巧以及实际应用场景,为构建生产级语义搜索系统打下基础。

内容来源与验证

继续系统学习

站内还有更多完整实战教程

加入会员,解锁全部教程、完整代码与持续更新内容。

加入会员
查看全部RAG 与知识库教程