2026年5月: Pythonによるベクトル検索の応用 〜マルチモーダルへの応用〜

寺田 学@terapyonです。2026年5月の「Python Monthly Topics」は、先月に引き続きPythonを使ったベクトル検索について紹介します。根源津はマルチモーダルへの応用を実践していきます。

本記事では、テキストと画像を横断するマルチモーダル検索の実装やベクトルの近傍検索について解説します。

動作環境

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

項目

バージョン

備考

Python

3.13.x

duckdb

1.2.x

ベクトルデータベース(VSS拡張)

transformers

4.51.x

SigLIP 2の利用

torch

2.x

CUDA 12.6対応版を使用

Pillow

11.x

画像処理

numpy

2.x

筆者はNVIDIA GPU(CUDA)環境で検証しています。CPU環境でも動作しますが、マルチモーダル検索(SigLIP 2)のセクションではGPU環境を推奨します。Apple Silicon(M4など)環境では、PyTorchがMPSバックエンドに対応しているため、device = "mps" で利用できます。

どこかに書く 内積(ドット積) は計算がシンプルで高速です。ただし、内積の結果がコサイン類似度と一致するのは、ベクトル L2正規化(長さを1に揃える) した場合に限ります。モデルやライブラリによって扱いは異なり、OpenAI Embeddings APIのように正規化済みベクトルを返すものもあれば、sentence-transformers のように normalize_embeddings=True で正規化を明示できるもの、SigLIP 2の get_text_features() / get_image_features() のように取得後に自分で正規化して使うものもあります。

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

マルチモーダルEmbeddingとは

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

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

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

SigLIP 2の紹介

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

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

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

SigLIP 2のモデルサイズは数百MB〜数GBになります。初回実行時はHugging Faceからのダウンロードに時間がかかります。また、CPU環境でも動作しますが、大量の画像を処理する場合はGPU環境を推奨します。筆者の経験では、CPU環境では1枚あたり数秒〜数十秒かかることがあり、2万枚規模の画像を扱う場合はGPUが事実上必須でした。

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

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

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

まず、画像群のEmbeddingを生成してインデックスを構築し、テキストクエリで検索するパイプラインを実装します。

SigLIP 2を用いたテキスト→画像検索パイプライン
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"
device = (
    "cuda"
    if torch.cuda.is_available()
    else "mps"
    if torch.backends.mps.is_available()
    else "cpu"
)
print(f"使用デバイス: {device}")

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

# ② 画像のEmbedding生成(バッチ処理)
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)

# ③ テキストのEmbedding生成
def encode_text(text: str) -> np.ndarray:
    """テキストをEmbeddingに変換する"""
    inputs = processor(text=[text], padding="max_length", 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"インデックス対象: {len(image_paths)} 枚")

print("画像のEmbeddingを生成中...")
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}")

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

テキスト→画像検索の実行結果の例
使用デバイス: cuda
Loading weights: 100%|██| 408/408 [00:00<00:00, 11024.35it/s]
インデックス対象: 12 枚
画像のEmbeddingを生成中...
  処理済み: 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.1266: skytree-04-27.jpg
  スコア 0.1185: sluice-gate-02.jpg

利用した写真は以下の通りです。

検索対象の画像

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

  • AutoProcessorAutoModel を使ってモデルをロードします。CUDAが利用可能ならGPUを、Apple SiliconではMPSを、それ以外ではCPUを使用します

  • ② 画像をバッチ処理でEmbeddingに変換します。バッチ処理により、1枚ずつ処理するより効率的です。L2正規化することで、内積がコサイン類似度と等価になります

  • ③ テキストも同様にEmbeddingに変換します。padding="max_length" で固定長にパディングすることが重要で、これがないと検索精度が大幅に低下します。テキストと画像が同一空間に配置されているため、同じ計算で類似度を測れます

  • ④ 検索対象の画像すべてのEmbeddingを事前に計算してインデックスとして保持します

  • ⑤ クエリのEmbeddingとインデックスの内積を計算し、スコアが高い順に並べ替えます

  • ⑥ 複数のクエリで検索を実行します。「鳥」「講演」「夜景」といった短い日本語でも、関連する画像が上位にヒットします

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

クエリが画像の場合も、同じインデックスを使って検索できます。違いは、テキストではなく画像からEmbeddingを生成する点だけです。

SigLIP 2を用いた画像→画像検索
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"
device = (
    "cuda"
    if torch.cuda.is_available()
    else "mps"
    if torch.backends.mps.is_available()
    else "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
            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_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"インデックス対象: {len(image_paths)} 枚")
print("画像のEmbeddingを生成中...")
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]

    # クエリ画像自身を除外する場合はフィルタリングを追加
    return [
        (image_paths[i], float(similarities[i]))
        for i in top_indices
        if image_paths[i] != query_path
    ]

# 画像→画像検索の実行
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=3)
for path, score in results:
    print(f"  スコア {score:.4f}: {path.name}")

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

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

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

どちらも同じモデルで作成した画像インデックス(image_embeddings)を使います。違うのは、検索クエリをテキストとしてEmbeddingするか、画像としてEmbeddingするかだけです。

筆者がPyCon JPの2万枚超のイベント写真で実験した際には、テキスト検索で「大規模ステージ」や「パネルディスカッション」といった日本語キーワードで関連画像が高い精度でヒットしました。また、画像で検索すると、構図や服装を含めた「雰囲気」が似た写真が出てくるのも面白い発見でした。

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

  • Two-Towerモデルの注意点: CLIPやSigLIP 2のようなマルチモーダルモデルでは、テキストと画像のベクトル分布に差異がある場合があります。テキスト同士の類似度計算とは異なる挙動を示すことがあるため、実際のデータで動作を確認しておくことをおすすめします

  • バッチ処理の重要性: 画像を1枚ずつ処理するとオーバーヘッドが大きくなります。batch_size を調整してGPUメモリに収まる範囲でバッチ処理を行うと効率的です

  • 人物検索の限界: 筆者の実験では、特定の人物の写真で類似画像検索しても別人が表示されることがありました。これは顔認識ではなく画像全体のベクトル化を行っているためで、想定される挙動ですが、利用時には注意が必要です

SigLIP 2やCLIPは画像全体の「雰囲気」をベクトル化するため、「同一人物を探したい」用途には向きません。人物検索が主目的なら、InsightFace に含まれる ArcFace のような顔認識モデルを別途組み合わせるのが実務的です。

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

k-NNの限界

NumPyでの実装では、クエリベクトルとデータベース内のすべてのベクトルとの類似度計算を行う想定です。これはk-NN(k-Nearest Neighbor)、つまり厳密な最近傍探索です。

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

ANNとは

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

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

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

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の場合、すべてのベクトル間の近傍関係をグラフとして構築するため、データ件数が増えるほど構築時間とメモリ使用量が増大します。検索は高速ですが、インデックスの構築や更新にはそれなりのリソースが必要になることを見込んでおきましょう。

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

大規模なベクトルデータを扱う場合、ストレージとメモリの効率化も重要です。代表的な軽量化アプローチを紹介します。

次元削減 は、PCA(主成分分析)などを使ってベクトルの次元数を圧縮する手法です。768次元のベクトルを256次元に削減することで、メモリ使用量を約1/3に抑えられます。ただし、情報の一部が失われるため精度とのトレードオフがあります。

量子化(Quantization) は、ベクトルの数値精度を下げてメモリを削減する手法です。float32(32ビット浮動小数点)からint8(8ビット整数)に変換することで、メモリ使用量を約1/4に削減できます。Product Quantization(PQ)はこの手法の代表的な実装です。

軽量化手法も活発に進化しており、google/embeddinggemma-300m のような Matryoshka対応 モデルでは、先頭側の次元だけを切り出しても検索性能を保ちやすく、ストレージ削減や検索高速化に活用しやすいという特徴があります。

実務での判断基準

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

  • データ件数が1万件以下: ベクトルをファイル管理してメモリ内で処理が可能

  • データ件数が10万件以下: 全件スキャン(k-NN)でも十分な速度が出る場合が多い

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

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

また、QdrantなどのモダンなVector DBは メタデータフィルタリング に対応しており、「2024年以降かつカテゴリがニュース」のような絞り込みをベクトル検索と組み合わせることで、検索精度と速度を両立できます。

検索の良し悪しを判断するには、体感だけでなく 評価指標で 確認することも重要です。よく使われるのが Recall@k で、「上位k件の中に正解文書が含まれている割合」を見ます。もう1つの代表例が MRR(Mean Reciprocal Rank)で、最初の正解が何位に現れたかを評価します。たとえばFAQ検索のように「最初の1件が当たっていればよい」場面ではMRRが効きやすく、関連文書を複数拾いたいRAGではRecall@kが見やすい指標になります。モデル変更やチャンキング変更の効果は、こうした指標で比較すると判断しやすくなります。

ANN導入時の判断ポイント

  • ANNは「近似」であることを理解した上で使いましょう。Recall@kを計測して、許容できる精度かどうかを確認することをおすすめします

  • 多くのVector DBではHNSWのパラメータを調整できるようになっています。精度が足りない・速度を上げたいといった場面では、各DBのドキュメントを参照してチューニングを検討してみてください

本記事のベクトル検索パイプラインは、そのまま RAG(検索拡張生成) の検索部分に応用できます。実務ではLLMそのものより、Embeddingモデルの選択やチャンキングの設計が回答品質を左右することも少なくありません。

まとめ

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

  • マルチモーダル検索: SigLIP 2を使ってテキストと画像を同一ベクトル空間に配置し、テキスト→画像・画像→画像の検索パイプラインを実装しました

  • ANN・軽量化: HNSWインデックスによる高速化の仕組みと、量子化(TurboQuant等)・次元削減などの軽量化アプローチを紹介しました

ベクトル検索はまだまだ進化の途中ですが、道具を選んで組み合わせることで、個人の開発環境でも十分に強力な検索システムを作ることができるようになっています。Embeddingモデルの進化、量子化手法の発展、マルチモーダル対応の拡充など、この分野は変化が非常に速いです。

筆者自身も最近、ベクトル検索の技術に強い関心を持ち、自身のプロジェクトでテキスト検索や画像検索の仕組みを構築してきました。PyCon JPの2万枚を超えるイベント写真を使った画像検索の実験では、「大規模ステージ」や「パネルディスカッション」といった日本語のキーワードで関連画像が高い精度でヒットし、ベクトル検索の可能性を実感しています。この領域の可能性に大きな期待を持っており、引き続き実験と検証を重ねていきたいと考えています。

参考リンク