2026年5月: マルチモーダル検索とANN 〜ベクトル検索の応用編〜

寺田 学@terapyonです。2026年5月の「Python Monthly Topics」は、先月号に引き続きPythonを使ったベクトル検索について紹介します。今月はマルチモーダルへの応用を中心に取り上げます。

先月号では、テキストのEmbedding生成と、ベクトルデータベース(DuckDBのVSS拡張)への保存・検索までの基礎を解説しました。本記事ではその応用編として、テキストと画像を横断する マルチモーダル検索 の実装を中心に紹介します。具体的には、テキストから画像を検索する「テキスト→画像検索」と、画像から類似画像を検索する「画像→画像検索」を、Googleが公開しているSigLIP 2モデルを使って実装していきます。

さらに後半では、大規模なベクトルデータを扱う際に欠かせない 近似最近傍探索(ANN: Approximate Nearest Neighbor) についても取り上げます。先月号の「代表的なベクトルデータベース」で紹介したDuckDB VSSの例で登場した HNSW インデックスを例に、近似最近傍探索の仕組みを解説します。

動作環境

本記事のコードは以下の環境で動作確認しています。

項目

バージョン

備考

Python

3.13.13

transformers

5.7.0

SigLIP 2の利用

torch

2.11.0

CUDA 12.6対応版を使用

Pillow

12.2.0

画像処理

NumPy

2.4.4

筆者はNVIDIA GPU(CUDA)環境で検証しています。GPU・CPU・MPSの使い分けや想定される処理時間については、後述の【コラム】「モデルサイズとGPU環境について」を参照してください。

パッケージのインストール

uv を使う場合:

uvを使ったインストール
uv add transformers torch pillow numpy

pip を使う場合:

pipを使ったインストール
pip install transformers torch pillow numpy

画像とテキストを統合するマルチモーダル検索の構成

ここでは、テキストと画像を同一のベクトル空間に配置するマルチモーダルEmbeddingを使って、テキスト→画像検索と画像→画像検索の両方を実装する方法を紹介します。

マルチモーダルEmbeddingとは

先月号で紹介したEmbeddingはテキストのみを扱っていました。マルチモーダルEmbedding は、テキストと画像(さらには音声や動画)を 同一のベクトル空間 に配置する技術です。

ベクトルが同一空間に配置されることで、「テキストで画像を検索する」「画像で似た画像を検索する」といった検索が可能になります。たとえば「赤いスポーツカーが走っている」というテキストクエリで、その内容に合致する画像を検索できます。

この仕組みの先駆けとなったのが、OpenAIが2021年に発表した CLIP(Contrastive Language-Image Pre-training)です。テキストエンコーダと画像エンコーダを別々に持ちながら、両者の出力が同一のベクトル空間に収まるよう学習されています(Two-Towerモデルとも呼ばれます)。CLIPの登場以降、同様のアプローチを採用したモデルが各社・研究コミュニティから相次いで公開されています。

このTwo-Towerモデルの考え方を図1にまとめます。

flowchart LR
    text["テキスト<br/>「赤いスポーツカー」"] --> text_enc["テキストエンコーダ"]
    image["画像"] --> image_enc["画像エンコーダ"]
    text_enc --> space["同一ベクトル空間<br/>(近いものを類似と判定)"]
    image_enc --> space

図1 Two-Towerモデルの概念

図1のように、テキストと画像はそれぞれ別のエンコーダでベクトル化されますが、出力が同じ空間に収まるため、テキストと画像を横断した類似度計算が可能になります。

従来の方法では、画像をテキストから検索するために、画像を説明するテキストを用意したり、メタデータを付ける必要がありました。マルチモーダルEmbeddingを使うと、画像そのものをベクトル化してテキストと同一空間に配置できるため、テキストクエリで直接画像を検索できるようになります。さらに、画像→画像検索も同じ空間で行えるため、類似画像の検索も可能になります。

SigLIP 2の紹介

SigLIP 2(Sigmoid Loss for Language Image Pre-training 2)は、CLIP型のアプローチを発展させた、Googleのマルチモーダルモデルです。筆者も自身のプロジェクトで検証しましたが、前モデルと比較して画像のニュアンスを捉える能力に向上が見られ、多言語での検索精度にも期待が持てます。特に日本語テキストでの画像検索にも有効で、実用的に試しやすいモデルです。

Hugging Face上で google/siglip2-base-patch16-224 等のモデルIDで公開されており、transformers ライブラリから利用できます。

先月号ではテキストのEmbedding生成に sentence-transformers を使いましたが、本記事では transformerstorch を直接使う構成にしています。sentence-transformers でもCLIP系のマルチモーダルモデルは扱えますが、transformers を直接呼び出すと、テキスト用 (get_text_features()) と画像用 (get_image_features()) のメソッドを明示的に使い分けられ、バッチ処理やデバイス転送、正規化処理も自分で制御できるため、自由度が高くなります。コードの記述量は若干増えますが、マルチモーダル検索の動作原理が見えやすい実装方式です。

その自由度の代償として、SigLIP 2の get_text_features() / get_image_features()sentence-transformersnormalize_embeddings=True のような正規化オプションを持ちません。そのため、コサイン類似度を内積で計算するには、取得後に自分でL2正規化(長さを1に揃える)する必要があります。後述のコード例でもこの手順を行っています。

【コラム】 モデルサイズとGPU環境について

SigLIP 2のモデルサイズは数百MB〜数GBになります。初回実行時はHugging Faceからのダウンロードに時間がかかる点に注意してください。

実行環境としては、NVIDIA GPU(CUDA)Apple Silicon(MPS)CPU の3種類が利用できます。本記事のコード例では、PyTorchの torch.cuda.is_available()torch.backends.mps.is_available() を使って自動判定し、CUDAが使える環境ではGPUを、Apple SiliconではMPS(Metal Performance Shaders)バックエンドを、それ以外ではCPUを使うようにしています。

CPU環境でも動作はしますが、大量の画像を処理する場合はGPU環境を推奨します。筆者の経験では、CPU環境では画像1枚あたり0.5秒〜数秒かかることがあり、2万枚規模の画像を扱う場合はGPU環境があると現実的でした(1〜1.5時間程度)。Apple Silicon搭載MacではMPSバックエンドが使え、CPUよりも大幅に高速になります。筆者の環境で少量のEmbeddingテストをしたところ、CUDA対応のGPUとApple Siliconでは大きな差はありませんでした。

テキスト→画像検索パイプラインの実装

これから実装するパイプラインの全体像を図2に示します。考え方としては、検索対象の画像をあらかじめEmbedding化して画像ベクトル群を作り、検索時はクエリ(テキストでも画像でも)を同じ空間に変換して類似度を計算します。ただし、今回のコード例は少量の画像で動作を理解するための最小構成です。先月号のDuckDBの例のようにベクトルをDBへ保存したり、インデックスを永続化したりはせず、スクリプトを実行するたびに画像ベクトル群をメモリ上に作り直します。

flowchart TB
    subgraph embedding["ベクトル化"]
        direction LR
        images["画像群"] --> ie["画像エンコーダ"] --> vec_index["画像ベクトル群"]
    end

    subgraph search["検索"]
        direction LR
        query["テキスト or 画像クエリ"] --> enc["対応するエンコーダ"] --> qvec["クエリベクトル"]
        qvec --> sim["類似度計算"] --> topk["類似画像"]
    end

    vec_index -.-> sim
    embedding ~~~ search

図2 マルチモーダル検索パイプラインの全体像

図2のように、検索対象は画像ベクトル群として保持し、クエリ側だけをテキスト用・画像用のエンコーダで切り替えることで、テキスト→画像検索と画像→画像検索の両方を同じベクトル群で実現できます。実運用で画像数が増える場合は、この画像ベクトル群をファイルやVector DBに保存し、毎回再計算しない構成にするのが一般的です。

以下のディレクトリ構成を前提としてます。

ディレクトリ構成
project/
├── images/          # 検索対象の画像ファイル(JPEG/PNG)
│   ├── photo001.jpg
│   ├── photo002.jpg
│   └── ...
└── search_pipeline.py

まず、画像群のベクトルを生成してメモリ上にベクトル群を保持し、テキストクエリで検索するパイプラインを実装します。

SigLIP 2を用いたテキスト→画像検索パイプライン (search_pipeline.py)
from pathlib import Path

import numpy as np
import torch
from PIL import Image
from transformers import AutoProcessor, AutoModel


# ① モデルとプロセッサのロード
MODEL_ID = "google/siglip2-base-patch16-224"
if torch.cuda.is_available():
    device = "cuda"
elif torch.backends.mps.is_available():
    device = "mps"
else:
    device = "cpu"
print(f"使用デバイス: {device}")

processor = AutoProcessor.from_pretrained(MODEL_ID)
model = AutoModel.from_pretrained(MODEL_ID).to(device)
model.eval()


# ② 画像のベクトル生成(バッチ処理)
def encode_images(image_paths: list[Path], batch_size: int = 8) -> np.ndarray:
    """画像リストをバッチ処理でEmbeddingに変換する"""
    all_embeddings = []
    for i in range(0, len(image_paths), batch_size):
        batch_paths = image_paths[i : i + batch_size]
        images = [Image.open(p).convert("RGB") for p in batch_paths]
        inputs = processor(images=images, return_tensors="pt").to(device)
        with torch.no_grad():
            image_features = model.get_image_features(**inputs).pooler_output
            # L2正規化(コサイン類似度計算のため)
            image_features = image_features / image_features.norm(dim=-1, keepdim=True)
        all_embeddings.append(image_features.cpu().numpy())
        print(
            f"  処理済み: {min(i + batch_size, len(image_paths))}/{len(image_paths)} 枚"
        )
    return np.vstack(all_embeddings)


# ③ テキストのベクトル生成
def encode_text(text: str) -> np.ndarray:
    """テキストをEmbeddingに変換する"""
    inputs = processor(
        text=[text],
        padding="max_length",
        max_length=64,
        truncation=True,
        return_tensors="pt",
    ).to(device)
    with torch.no_grad():
        text_features = model.get_text_features(**inputs).pooler_output
        text_features = text_features / text_features.norm(dim=-1, keepdim=True)
    return text_features.cpu().numpy()


# ④ ベクトル群の構築
image_dir = Path("images")
image_paths = sorted(list(image_dir.glob("*.jpg")) + list(image_dir.glob("*.png")))
print(f"Embedding対象: {len(image_paths)} 枚")
print("画像のベクトルを生成中...")
image_embeddings = encode_images(image_paths)
print(f"ベクトル群の構築完了: shape={image_embeddings.shape}")


# ⑤ テキストクエリによる検索
def search_by_text(query: str, top_k: int = 5) -> list[tuple[Path, float]]:
    """テキストクエリで類似画像を検索する"""
    query_embedding = encode_text(query)
    # コサイン類似度の計算(正規化済みベクトルの内積)
    similarities = (image_embeddings @ query_embedding.T).squeeze()
    # 上位k件のベクトルを取得
    top_indices = np.argsort(similarities)[::-1][:top_k]
    return [(image_paths[i], float(similarities[i])) for i in top_indices]


# ⑥ 検索の実行
queries = ["鳥", "講演", "海外のイベントで大舞台に立ってスピーチしている", "朝日が昇る空"]
for query in queries:
    print(f"\nクエリ: 「{query}」")
    print("検索結果:")
    results = search_by_text(query, top_k=2)
    for path, score in results:
        print(f"  スコア {score:.4f}: {path.name}")

検索対象として用意した画像は以下の12枚です。検索対象の画像ダウンロードからダウンロードして手元で確認する事ができます。

検索対象の画像

実行結果は以下のようになります。

テキスト→画像検索の実行結果の例
使用デバイス: cuda
Loading weights: 100%|██| 408/408 [00:00<00:00, 11024.35it/s]
Embedding対象: 12 枚
画像のベクトルを生成中...
  処理済み: 8/12 枚
  処理済み: 12/12 枚
ベクトル群の構築完了: shape=(12, 768)

クエリ: 「鳥」
検索結果:
  スコア 0.0957: waterside-park-flock.jpg
  スコア 0.0826: waterside-park-ducks.jpg

クエリ: 「講演」
検索結果:
  スコア 0.1046: europython-talk-02.jpg
  スコア 0.0941: europython-talk-01.jpg

クエリ: 「海外のイベントで大舞台に立ってスピーチしている」
検索結果:
  スコア 0.1006: europython-talk-01.jpg
  スコア 0.0975: europython-keynote-02.jpg

クエリ: 「朝日が昇る空」
検索結果:
  スコア 0.0704: skytree-04-27.jpg
  スコア 0.0650: sluice-gate-02.jpg

スコアの絶対値が0.1前後と小さく見えますが、これはSigLIP 2のようなCLIP系モデルがテキストと画像を比較するときに現れる特性で、精度が低いわけではありません。検索結果の 相対的な順位 で評価するのがポイントです(詳しくは後述の「マルチモーダル検索の実装上のポイント」を参照してください)。 画像群に存在しないクエリ「朝日が昇る空」では、スコアがさらに小さくなっていますが、検索結果は「空の写真」が上位に来ており、クエリの内容をある程度捉えていることがわかります。

各ステップのポイントを説明します。

  • AutoProcessorAutoModel を使ってモデルをロードします。デバイスは前述のコラムの方針で自動判定しています。google/siglip2-base-patch16-224 は標準サイズのSigLIP 2で、出力ベクトルは768次元です。モデル名末尾の 224 はモデル内部の処理解像度(224×224)を示しますが、AutoProcessor が任意サイズの画像をこの解像度に自動でリサイズ・正規化してくれるため、入力画像のサイズを事前に揃える必要はありません。用途に応じて siglip2-large-patch16-256 などより大きなモデルも選択できます(なお、SigLIP 2にはアスペクト比を保ったまま可変解像度で扱える NaFlex 版 siglip2-base-patch16-naflex もあります)

  • ② 画像をバッチ処理でベクトルに変換します。1枚ずつ処理するよりGPUへのデータ転送やモデル呼び出しのオーバーヘッドが減るため効率的です

  • ③ テキストも同様にEmbeddingに変換します。SigLIP 2では padding="max_length"max_length=64 を指定し、学習時の前処理に合わせて固定長にパディングします。これがないと検索精度が低下することがあります。テキストと画像が同一空間に配置されているため、同じ計算で類似度を測れます

  • ④ この例では、検索対象の画像すべてのベクトルをスクリプト実行時に計算し、メモリ上のベクトル群として保持します

  • ⑤ クエリのベクトルとベクトル群の内積を計算し、スコアが高い順に並べ替えます

  • ⑥ 複数のクエリで検索を実行します。4つのクエリは、画像の内容を表す単語(「鳥」「講演」)から、より具体的な説明文(「海外のイベントで大舞台に立ってスピーチしている」)まで幅広く用意しています。

画像→画像検索パイプラインの実装

クエリが画像の場合も、同じベクトル群(image_embeddings)を使って検索できます。違いは、検索クエリをテキストではなく画像からEmbeddingする点だけです。

前節のテキスト→画像検索のコードに、以下の差分を加えるだけで実装できます。

SigLIP 2を用いた画像→画像検索(差分のみ) (image_search.py)
# 以下は前節「テキスト→画像検索」と同じため省略
# - import文 / MODEL_ID / device 判定
# - processor / model のロード
# - encode_images() 関数

# ① クエリ画像のベクトルを生成(テキスト用の encode_text の代わりに使う)
def encode_query_image(image_path: Path) -> np.ndarray:
    """クエリ画像をEmbeddingに変換する"""
    image = Image.open(image_path).convert("RGB")
    inputs = processor(images=[image], return_tensors="pt").to(device)
    with torch.no_grad():
        image_features = model.get_image_features(**inputs).pooler_output
        image_features = image_features / image_features.norm(dim=-1, keepdim=True)
    return image_features.cpu().numpy()


image_dir = Path("images")
image_paths = sorted(list(image_dir.glob("*.jpg")) + list(image_dir.glob("*.png")))
print(f"Embedding対象: {len(image_paths)} 枚")
print("画像のベクトルを生成中...")
image_embeddings = encode_images(image_paths)
print(f"ベクトル群の構築完了: shape={image_embeddings.shape}")


# ② クエリ画像でベクトル群を検索する
def search_by_image(query_path: Path, top_k: int = 5) -> list[tuple[Path, float]]:
    """クエリ画像で類似画像を検索する"""
    query_embedding = encode_query_image(query_path)
    similarities = (image_embeddings @ query_embedding.T).squeeze()
    top_indices = np.argsort(similarities)[::-1][: top_k + 1]
    # クエリ画像自身を除外する
    return [
        (image_paths[i], float(similarities[i]))
        for i in top_indices
        if image_paths[i] != query_path
    ][:top_k]


# ③ 画像→画像検索の実行
query_image_path = Path("images/europython-keynote-01.jpg")
print(f"\nクエリ画像: {query_image_path.name}")
print("類似画像:")
results = search_by_image(query_image_path, top_k=2)
for path, score in results:
    print(f"  スコア {score:.4f}: {path.name}")

実行結果は以下のようになります。

画像→画像検索の実行結果の例
Embedding対象: 12 枚
画像のベクトルを生成中...
  処理済み: 8/12 枚
  処理済み: 12/12 枚
ベクトル群の構築完了: shape=(12, 768)

クエリ画像: europython-keynote-01.jpg
類似画像:
  スコア 0.8428: europython-talk-01.jpg
  スコア 0.7557: europython-panel.jpg

各ステップのポイントを説明します。

  • ① テキスト用の encode_text() 関数の代わりに、processor(images=[image], ...) でクエリ画像をベクトルに変換します。encode_images() 関数の単一画像版とも言えます

  • ② 類似度計算とランキングはテキスト→画像検索と同じです。クエリ画像自身が結果に混ざるとノイズになるため、フィルタで除外しています

  • ③ 同じ image_embeddings ベクトル群に対して、クエリだけを画像に差し替えて検索しています

マルチモーダル検索の実装上のポイント

  • モダリティギャップに注意: SigLIP 2やCLIPのようなTwo-Towerモデル(テキストと画像でエンコーダが分かれているモデル)では、テキストと画像のベクトル分布が同一空間内で分離する傾向があります。本記事でも、テキスト→画像検索ではスコアが0.1前後画像→画像検索では0.7〜0.8 と、検索方式によって値域が大きく異なります。これは「モダリティギャップ」と呼ばれる現象で、スコアの絶対値だけでは検索精度の低下とは判断できません。検索結果はスコアの絶対値ではなく、同じ検索方式内での 相対的な順位 で評価してください。

  • 人物検索には不向き: SigLIP 2やCLIPは画像全体の「雰囲気」をベクトル化するため、「同一人物を探したい」用途には向きません。筆者の実験でも、特定の人物の写真で類似画像検索すると別人がヒットすることがありました。人物検索が主目的なら、InsightFace に含まれる ArcFace のような顔認識モデルを別途組み合わせるのが実務的です。

近似最近傍探索(ANN)の仕組みとベクトルの軽量化

ここからは、大規模なベクトルデータを扱う際の課題と解決策について紹介します。前節のコード例は少量の画像で動作を理解するための最小構成でしたが、実務では数万件から数百万件のベクトルを扱うことも珍しくありません。そうした大規模データに対して、 厳密な最近傍探索(kNN) では応答速度が現実的でなくなるため、 近似最近傍探索(ANN)ベクトルの軽量化 といったアプローチが必要になります。

kNNの限界

本記事のテキスト→画像検索のSigLIPコード例では、NumPyでクエリベクトルと全画像ベクトルとの類似度計算を行っています。これは kNN(k-Nearest Neighbor) 、つまり厳密な最近傍探索です。

データ件数が少ない場合は問題ありませんが、10万件・100万件のベクトルを扱う場合、1回の検索で全件との類似度計算が必要になり、応答速度が現実的でなくなります。768次元のベクトルが100万件あれば、1回の検索で7億6800万回の乗算が必要です。さらに、CPU上で全件を処理する場合や、ベクトルをDB・ストレージから読み出しながらスキャンする場合は、データ取得のコストも加わるため、数秒〜数十秒かかることもあります。

ANNとは

近似最近傍探索(Approximate Nearest Neighbor: ANN)は、厳密な最近傍ではなく「近似的に近い」ものを高速に見つける手法です。精度をわずかに犠牲にする代わりに、検索速度を劇的に向上させます。

代表的なANNアルゴリズムを2つ紹介します。

HNSW(Hierarchical Navigable Small World)はグラフベースの手法です。ベクトルをグラフのノードとして管理し、階層的な構造で近傍を効率的に探索します。QdrantChroma、DuckDBでも採用されており、高精度と高速性を両立しています。HNSWの探索イメージを図3に示します。

flowchart LR
    entry["入口ノード"] ==> n1

    subgraph idx["ネットワーク型インデックス"]
        n1((●)) --- n2((●))
        n1 --- n3((●))
        n1 --- n4((●))
        n2 --- n3
        n2 --- n5((●))
        n3 --- n4
        n3 --- n5
        n3 --- n6((●))
        n4 --- n6
        n4 --- n7((●))
        n5 --- n6
        n5 --- n8((●))
        n6 --- n7
        n6 --- n8
        n7 --- n8
    end

    n1 ==>|近い方へ| n3
    n3 ==>|近い方へ| n5
    n5 ==>|近い方へ| n6

    Q[/"クエリ Q"/] -.->|目標| n6

図3 HNSWによるネットワーク型インデックスの探索イメージ

図3のように、HNSWはベクトル同士をエッジでつないだネットワーク型のインデックスを作っておき、入口ノードから「クエリに近い方の隣接ノード」へ順番に辿っていきます。全件比較を避けながら、クエリ近傍のノードに到達できるのが特徴です(実際は複数階層のグラフを使ってさらに高速化していますが、本記事では概念を優先して単層で示しています)。

IVF(Inverted File Index)はクラスタリングベースの手法です。ベクトルをあらかじめクラスタリングし、クエリ時には近いクラスタのみを探索します。大規模データに適しています。

先月号のDuckDBのコード例ですでにANNを使っています。CREATE INDEX ... USING HNSW でHNSWインデックスを作成し、array_cosine_distance(...)ORDER BY ... LIMIT と組み合わせることで、HNSWインデックスを利用した近似検索になります。QdrantやChromaなどのVector DBも内部でHNSWを採用しており、利用者が意識せずともANNの恩恵を受けられる設計になっています。

ただし、ANNインデックスの構築時にはCPU負荷とメモリ消費が大きくなる点に注意が必要です。HNSWの場合、各ベクトルを近傍候補と接続するグラフを構築するため、データ件数が増えるほど構築時間とメモリ使用量が増大します。検索は高速ですが、インデックスの構築や更新にはそれなりのリソースが必要になることを見込んでおきましょう。

筆者自身も、業務プロジェクトの中でデータの規模やベクトルの特性に合わせて、ANNインデックスの選定・パラメータ調整やベクトル軽量化の組み合わせを実践しています。たとえばHNSWでは Mef_constructionef_search などのパラメータを各Vector DBのドキュメントに従って調整することで、精度と速度のバランスを取ることができます。後述の「実務での判断ポイント」も、そうした現場で得られた手応えをベースにまとめたものです。

ベクトルの軽量化アプローチ

大規模なベクトルデータを扱う場合、ストレージとメモリの効率化も重要です。代表的なアプローチは以下の3つです。

  • 次元削減: PCA(主成分分析)などで次元数を圧縮する(例: 768次元 → 256次元でメモリ約1/3)

  • 量子化(Quantization): 数値精度を下げる(例: float32 → int8 でメモリ約1/4)。さらに、ベクトルを部分ベクトルに分割して圧縮する Product Quantization(PQ) のような手法もある

  • Matryoshka対応モデル: 先頭側の次元だけを切り出しても精度を保ちやすいモデル(先月号でも紹介した google/embeddinggemma-300m 等)。後から次元数を調整できるのが利点

いずれの手法も精度とトレードオフがあるため、後述の評価指標で実測しながら採用判断するのが確実です。

実務での判断ポイント

精度とパフォーマンスのトレードオフを考える際の目安をまとめます。

データ規模による選択

  • データ件数が1000件以下: .npy 等の配列ファイルで十分なことが多い

  • データ件数が1万件以下: メモリ上のkNN(全件スキャン)で検証し、応答速度に問題が出るかを確認する

  • データ件数が1万件超〜100万件: HNSWなどのANNインデックスを導入する

  • データ件数が100万件以上: IVFやProduct Quantizationを組み合わせた大規模向けの構成を検討する

メタデータフィルタリングとの組み合わせ

ベクトル検索は「ベクトル空間内での類似度」を測るため、ベクトル単体では撮影日時や場所、タグなどの メタデータ を考慮した絞り込みを表現できません。ベクトル検索の前にメタデータで対象を絞り込む事前フィルタリングか、ベクトル検索の後にメタデータで結果を絞り込む事後フィルタリングが必要になります。

どちらの方式も一長一短です。ANNインデックスを使う場合は、アルゴリズムや実装によっては事前フィルタリングが難しいこともあります。たとえば単純なHNSWインデックスで検索対象を先に大きく減らすと、グラフの接続関係を十分に辿れず、検索精度が低下する可能性があります。そうした場合は、ベクトル検索の後にメタデータで絞り込む事後フィルタリングの方が現実的です。

QdrantなどのモダンなVector DBは メタデータフィルタリング に対応しており、フィルタ条件を考慮したインデックスやクエリプランナによって、「2024年以降かつカテゴリがニュース」のような絞り込みをベクトル検索と組み合わせられます。フィルタの効き方はDBや設定によって異なるため、実データで検索精度と速度を確認することが重要です。

評価指標で実測する

ANNの良し悪しを判断するには、体感だけでなく 評価指標 で確認することも重要です。

ANNは「近似」のため、厳密なkNNと比べて結果が変わることがあります。許容できる精度かは 再現率 (Recall@k) などの指標で実測して評価できます。

代表的なものを2つ紹介します。

  • 再現率 (Recall@k): 正解集合のうち、検索結果の上位k件に含まれていた割合。関連文書を複数拾いたいRAGなどに向く

  • 平均逆順位 (MRR: Mean Reciprocal Rank): 最初の正解が何位に現れたかの逆数の平均。FAQ検索のように「最初の1件が当たっていればよい」場面に向く

まとめ

本記事では、ベクトル検索の応用として、テキストと画像を横断するマルチモーダル検索の実装例と、近似最近傍探索(ANN)やベクトルの軽量化アプローチについて解説しました。

  • マルチモーダル検索: SigLIP 2を使ってテキストと画像を同一ベクトル空間に配置し、テキスト→画像・画像→画像の検索パイプラインを実装しました。検索方式によってスコアの値域が大きく異なる「モダリティギャップ」など、Two-Towerモデル特有の挙動も確認しました

  • ANN・軽量化: kNNの限界からHNSWによる近似最近傍探索の仕組み、量子化・次元削減・Matryoshkaなどの軽量化アプローチ、Recall@k などの評価指標を含む実務上の判断ポイントまで体系的に整理しました

筆者自身、PyCon JPの2万枚を超えるイベント写真を使った画像検索の実験を行い、「大規模ステージ」や「パネルディスカッション」といった日本語キーワードで関連画像が上位にヒットすることを確認しています。マルチモーダル検索は研究フェーズを越え、個人の開発環境でも実用的な検索システムを構築できる段階に来ています。

次のステップ

本記事で扱ったマルチモーダル検索パイプラインは、以下の方向に発展させることができます。

  • マルチモーダルRAG: テキスト・画像の検索結果をLLMのコンテキストとして渡し、画像内容を含めた質問応答を実現する

  • 顔認識との組み合わせ: SigLIP 2で全体の雰囲気を、ArcFace(InsightFace)で同一人物の判定を行うハイブリッド構成

  • メタデータフィルタとの組み合わせ: Qdrant等のVector DBで、撮影日時・場所・タグなどでベクトル検索を絞り込む

  • 大規模運用でのANNチューニング: HNSWの Mef_constructionef_search などのパラメータ調整による Recall@k と検索速度の最適化

  • ドメイン特化ファインチューニング: 特定の業界・用途のデータでSigLIP 2を追加学習し、検索精度を向上させる

参考リンク