2026年4月: Pythonによるベクトル検索の基礎と実践 〜Embedding、Vector DB、マルチモーダルへの応用〜

寺田 学@terapyonです。2026年4月の「Python Monthly Topics」は、Pythonを使ったベクトル検索の基礎と実践について紹介します。

「EV」と検索しても「電気自動車」がヒットしない——キーワード検索の限界を感じたことはないでしょうか。ベクトル検索は、この「意味の近さ」を数値で表現し、より直感的な検索を実現する技術です。近年、LLMや生成AIの普及とともに、RAG(Retrieval-Augmented Generation)の中核技術として実務への導入が急速に進んでいます。

本記事では、Pythonを使ってベクトル検索パイプラインを一から構築する方法を解説します。テキストのEmbedding生成から始まり、ベクトルデータベースへの保存・検索、さらにテキストと画像を横断するマルチモーダル検索の実装まで、段階的に紹介していきます。

はじめに——ベクトル検索の概要とPythonのエコシステム

キーワード検索の限界

従来のキーワード検索(Keyword Search)は、文字列の一致を基準に結果を返します。「EV」で検索しても「電気自動車」や「テスラ」はヒットしませんし、「スマートフォン」で検索しても「携帯電話」や「スマホ」が同じ意味だとは認識できません。一方、ベクトル検索(Semantic Search)は意味の類似性に基づいて結果を返す仕組みです。両者の違いを以下の表にまとめます。

特徴

キーワード検索

ベクトル検索

マッチング

キーワードの一致

意味の類似性

得意なこと

固有名詞、型番、完全一致

概念検索、表記揺れ吸収、曖昧な検索

苦手なこと

同義語(辞書が必要)、表記揺れ

特定キーワードの厳密な検索

最近のトレンドは、両者の長所を組み合わせたハイブリッド検索です。本記事では、ベクトル検索の基礎をしっかり固めることに集中し、ハイブリッド検索については「まとめ」で次のステップとして触れます。

ベクトル検索とは何か

ベクトル検索は、テキストや画像などのデータを高次元の数値ベクトルに変換し、ベクトル空間上の距離(コサイン類似度など)で類似度を測る仕組みです。「意味が近いものはベクトルも近い」という性質を利用することで、キーワードの一致に依存しない検索が実現できます。

ベクトル検索システムを構築するには、大きく分けて以下の4つのステップが必要です。

  1. Embedding化(ベクトル化): 検索対象のドキュメント(テキスト、画像など)を、意味内容を表す数値の配列(ベクトル)に変換する

  2. ベクトルの永続化: 生成されたベクトルデータを、後で検索可能な形式で保存する

  3. クエリのEmbedding化: ユーザーが入力した検索クエリを、同じモデルを使ってベクトルに変換する

  4. ベクトルの近傍検索: クエリベクトルと距離が近い(意味が似ている)ドキュメントベクトルをデータベースから探し出す

実務での活用場面

ベクトル検索が実務で活用される代表的な場面を挙げます。

  • RAG(検索拡張生成): LLMに渡すコンテキストとして、質問に意味的に近いドキュメントを検索する

  • 類似商品検索: ECサイトで「この商品に似たもの」を提示する

  • ドキュメント検索: 社内ナレッジベースから関連する文書を探す

  • 画像検索: テキストで画像を検索したり、画像で似た画像を探したりする

Pythonの役割

Embedding生成・データ処理・評価・インフラ連携のすべての工程において、Pythonは事実上の標準言語として機能しています。Hugging Face Transformers、sentence-transformers、各種Vector DBのPythonクライアントなど、エコシステムが急速に充実しており、少ないコードで本格的なパイプラインを構築できます。

本記事では以下の技術スタックを順番に扱います。

ベクトル検索の技術スタック
テキスト/画像
    ↓
Embeddingモデル(テキスト用 / マルチモーダル用)
    ↓
ベクトル(384次元〜1024次元等)
    ↓
Vector DB(DuckDB / Qdrant / pgvector 等)
    ↓
ANN検索(HNSW等)
    ↓
検索結果

動作環境

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

項目

バージョン

備考

Python

3.13.x

sentence-transformers

3.4.x

テキストEmbedding生成

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" で利用できます。

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

uv を使う場合:

uvを使ったインストール
uv add sentence-transformers duckdb transformers torch Pillow numpy

pip を使う場合:

pipを使ったインストール
pip install sentence-transformers duckdb transformers torch Pillow numpy

テキストデータのベクトル化(Embedding)手法

Embeddingとは

Embedding(エンベディング) とは、単語・文・画像などのデータを固定長の数値ベクトルに変換する処理のことです。この変換を行うのがEmbeddingモデルと呼ばれる専用の機械学習モデルです。テキストを入力として受け取り、たとえば384次元や768次元、1024次元といった固定長の数値の配列を出力します。

Embeddingモデルにはさまざまな種類があり、モデルごとに意味の捉え方が異なります。英語に強いもの、日本語を含む多言語に対応したもの、特定ドメインに特化したものなど、目的に応じた選択が必要です。また、出力するベクトルの次元数もモデルによって異なり、次元数が大きいほど意味の表現力は高くなりますが、その分ストレージや計算コストも増えます。

もう一つ注意すべきなのが入力テキストの長さ制限です。従来のBERTベースのモデルは、256〜512トークン程度を上限とするものが多く、それを超える文章は切り捨てられてしまいます。長い文書を扱う場合は、適切な長さに分割(チャンキング)してからEmbeddingする必要があります。最近のLLMベースのEmbeddingモデルでは入力長の制限が大幅に緩和されているものもありますが、モデルごとに対応が異なるため、利用前に仕様を確認しておくことが重要です。

たとえば、「EV」「乗用車」「洗濯機」という3つの単語をEmbeddingすると、「EV」と「乗用車」のベクトルは近い位置に、「EV」と「洗濯機」は離れた位置に配置されます。この「意味が近いものはベクトルも近い」という性質がベクトル検索の根幹です。

ベクトルの「近さ」を測る指標

ベクトル検索では、2つのベクトルがどれだけ「近い」かを数値で測る必要があります。代表的な指標を3つ紹介します。

指標

測るもの

値の範囲

特徴

コサイン類似度

ベクトルの向き(角度)

-1〜1

AI分野で最もよく使われる

ユークリッド距離(L2距離)

ベクトル間の直線距離

0〜∞

直感的だが、高次元では差が出にくい

内積(ドット積)

ベクトルの向きと大きさ

-∞〜∞

正規化済みベクトルではコサイン類似度と同じ結果

コサイン類似度 はベクトルの「向き」の近さを測ります。値が1に近いほど類似度が高く、0に近いほど無関係です。ベクトルの長さ(大きさ)に影響されないため、AI分野で最も好まれる指標です。

ユークリッド距離(L2距離) は点と点の直線距離で、直感的にわかりやすい指標です。ただし、数百〜数千次元の高次元空間では、どの点を選んでも距離の差がほとんどなくなる「次元の呪い」と呼ばれる現象が起きるため、直線距離よりも角度(コサイン類似度)のほうが有効に働く場面が多くなります。

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

NumPyを使った計算例を示します。

NumPyによる類似度計算の例 (similarities.py)
import numpy as np

# コサイン類似度
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

# L2距離(ユークリッド距離)
def l2_distance(a: np.ndarray, b: np.ndarray) -> float:
    return np.linalg.norm(a - b)

# 内積(正規化済みベクトル向け)
def dot_similarity(a: np.ndarray, b: np.ndarray) -> float:
    return np.dot(a, b)

sentence-transformers によるローカル実行

sentence-transformers は、テキストのEmbedding生成に特化したPythonライブラリです。Hugging Face上の多数のモデルを利用でき、ローカル環境で完結するため、プライバシーやコストの面でも安心して使えます。

以下のコード例では、軽量モデル all-MiniLM-L6-v2 を使って複数の文をEmbeddingし、正規化済みベクトルの内積で類似度を計算します。

sentence-transformersによるEmbedding生成と類似度計算 (text_encoding.py)
from sentence_transformers import SentenceTransformer
import numpy as np

# ① モデルのロード(初回はHugging Faceからダウンロードされます)
model = SentenceTransformer("all-MiniLM-L6-v2")

# ② 類似度を比較する文のリスト
sentences = [
    "An EV is a passenger car powered by a battery",
    "Electric vehicles are an environmentally friendly way to travel",
    "The weather is sunny today",
    "A washing machine is a home appliance for cleaning clothes",
]

# ③ 文をベクトルに変換(エンコード)
# normalize_embeddings=True を指定すると、単位長に正規化されたベクトルが得られる
embeddings = model.encode(sentences, normalize_embeddings=True)
print(f"ベクトルの形状: {embeddings.shape}")
# → ベクトルの形状: (4, 384)

# ④ 内積による類似度計算(正規化済みベクトルのためコサイン類似度と等価)
def dot_similarity(a: np.ndarray, b: np.ndarray) -> float:
    return np.dot(a, b)

# 基準文(インデックス0)と他の文との類似度を表示
base = embeddings[0]
print(f"\n基準文: 「{sentences[0]}」")
print("-" * 50)
for i, (sentence, emb) in enumerate(zip(sentences[1:], embeddings[1:]), 1):
    sim = dot_similarity(base, emb)
    print(f"類似度 {sim:.4f}: 「{sentence}」")

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

実行結果の例
ベクトルの形状: (4, 384)

基準文: 「An EV is a passenger car powered by a battery」
--------------------------------------------------
類似度 0.6269: 「Electric vehicles are an environmentally friendly way to travel」
類似度 -0.0633: 「The weather is sunny today」
類似度 0.0680: 「A washing machine is a home appliance for cleaning clothes」

「EV」と「Electric vehicles」の類似度が最も高く、「weather」や「washing machine」との類似度が低い結果になるはずです。キーワードが完全一致していなくても、意味的な近さが数値として表れているのがわかります。

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

  • SentenceTransformer にモデル名を渡すだけでロードできます。初回実行時はモデルがダウンロードされます(all-MiniLM-L6-v2 は約90MB)

  • ② 比較したい文をリストで用意します

  • model.encode() でリスト全体を一括でベクトル化できます。normalize_embeddings=True を指定すると、単位長に正規化されたベクトルが返ります。戻り値はNumPy配列で、形状は (文の数, ベクトルの次元数) です

  • ④ 正規化済みベクトル同士の内積を計算し、類似度を確認します

外部APIの活用例

ローカルモデルではなく、外部APIを使ってEmbeddingを生成することもできます。OpenAI Embeddings APIを使う場合は以下のようになります。 この例を試す場合は、あらかじめ uv add openai または pip install openai で追加してください。

OpenAI Embeddings APIの利用例
from openai import OpenAI

client = OpenAI()  # OPENAI_API_KEY環境変数が必要

response = client.embeddings.create(
    model="text-embedding-3-small",
    input="EVはバッテリーで駆動する乗用車です",
)
embedding = response.data[0].embedding
print(f"ベクトルの次元数: {len(embedding)}")  # → 1536

Ollamaの活用例

Ollama は、ローカル環境でLLMを動かすためのツールです。Embeddingの生成にも対応しており、データをクラウドに送らずに処理できるため、プライバシーが重要な場面で重宝します。Ollamaサーバーを起動した状態で、以下のように利用できます。 この例を試す場合は、あらかじめ uv add ollama または pip install ollama でPythonクライアントを追加してください。また、ollama serve などでローカルのOllamaサーバーを起動し、必要なモデルを ollama pull nomic-embed-text などで取得しておく必要があります。

OllamaによるEmbedding生成
import ollama

response = ollama.embed(
    model="nomic-embed-text",
    input="EVはバッテリーで駆動する乗用車です",
)
embedding = response["embeddings"][0]
print(f"ベクトルの次元数: {len(embedding)}")  # → 768

選択基準の整理

3つのアプローチの特徴を以下の表にまとめます。

項目

ローカルモデル(sentence-transformers)

外部API(OpenAI等)

Ollama

コスト

無料(計算リソースのみ)

従量課金

無料(計算リソースのみ)

プライバシー

◎(データが外部に出ない)

△(データがクラウドへ)

◎(データが外部に出ない)

セットアップ

pip installのみ

APIキーが必要

Ollamaサーバーの起動が必要

扱いやすさ

◎(Pythonから容易でライブラリだけで完結)

◎(APIなので言語を問わず扱いやすい)

○(クライアントを選ばないが別サーバー前提)

オフライン対応

精度

モデルによる

高品質

モデルによる

日本語対応

モデルによる

モデルによる

プロトタイピングや個人プロジェクトでは sentence-transformers のローカルモデルから始めるのが手軽です。日本語テキストを扱う場合は、多言語対応モデルを選ぶと精度が向上します。筆者自身は multilingual-e5-large をメインに使っています。日本語の性能が高く、ハンドリングのしやすさから気に入っています。軽量に試したい場合は multilingual-e5-base も選択肢になります。

Embeddingモデル選択のポイント

テキスト用Embeddingモデルは大きくBERTベースとLLMベースに分かれます。筆者の視点で代表的なモデルIDを以下にまとめます。

モデル

ベース

次元数

入力上限

日本語

特徴

sentence-transformers/all-MiniLM-L6-v2

BERT

384

256トークン

軽量・英語向け、学習用途に最適

intfloat/multilingual-e5-base

BERT

768

512トークン

多言語対応、バランスが良い

intfloat/multilingual-e5-large

BERT

1024

512トークン

多言語高精度、筆者のメイン

cl-nagoya/ruri-v3-310m

ModernBERT

768

8192トークン

◎◎

日本語特化、長文対応、JMTEB高水準

nomic-ai/nomic-embed-text-v1.5

BERT

768

8192トークン

英語専用、長文対応

nomic-ai/nomic-embed-text-v2-moe

LLM(MoE)

768

512トークン

多言語対応、Matryoshka対応

google/embeddinggemma-300m

LLM

768

2048トークン

小型・多言語対応、Matryoshka対応

Qwen/Qwen3-Embedding-0.6B

LLM

1024

32768トークン

長文対応、高精度

モデルは「何でも同じ」ではありません。日本語対応・入力長・次元数など、モデルによって得意不得意が大きく異なります。用途に合ったモデルを選ぶことが検索品質に直結します。

BERT系(ModernBERTを含む) は軽量でCPU環境でも運用が可能です。入力長は256〜512トークンが上限のものが多いですが、実績が豊富で安定しています。nomic-ai/nomic-embed-text-v1.5cl-nagoya/ruri-v3-310m のように長文対応のものもあります。

LLMベース は入力長の制限が大幅に緩和されており、表現力も高い傾向があります。ただしモデルサイズが大きく、実用的な速度を出すにはGPU環境が必要です。

実装面でのポイントも補足します。sentence-transformersmodel.encode() は、文のリストをまとめて渡すとバッチ処理されるため、forループで1件ずつ処理するより大幅に高速です。また、モデルによって推奨される距離指標(コサイン類似度、内積など)が異なるため、利用前に各モデルのドキュメントを確認しておきましょう。

長文を検索対象にする場合は、モデル選定と同じくらい チャンキング の設計が重要です。1文書をそのままEmbeddingすると話題が混ざって意味がぼやける一方、細かく分割しすぎると前後関係が失われます。実務では、段落や見出し単位で分割しつつ、必要に応じて少し重なりを持たせる方法がよく使われます。Embeddingモデルの入力上限だけでなく、「検索時に取り出したい情報の粒度」に合わせて分割幅を決めることが、RAGやドキュメント検索の精度に直結します。

【コラム】 モデルによって異なるプリフィックスに注意

一部のEmbeddingモデルでは、入力テキストに プリフィックス(接頭辞) を付ける必要があります。たとえば multilingual-e5 シリーズでは、検索クエリには "query: " を、検索対象のドキュメントには "passage: " を先頭に付けます。

multilingual-e5でのプリフィックス指定例
query_text = "query: EVの最新動向"
doc_text = "passage: 電気自動車市場は急速に拡大しています"

nomic-ai/nomic-embed-text-v2-moe では "search_query: ""search_document: " が必要です。検索向けのEmbeddingモデルでは、multilingual-e5Nomic 系だけでなく、RuriEmbeddingGemmaQwen のようにクエリ用・文書用のプロンプトや指示文を前提とするものもあります。こうしたプリフィックスや指示文を付け忘れると検索精度が低下するため、モデルごとのドキュメントを必ず確認しましょう。なお、all-MiniLM-L6-v2 のようにプリフィックス不要のモデルもあります。

CPU環境で大量のテキストをEmbeddingしたい場合は、ONNX Runtimeベースの FastEmbed も選択肢です。本稿では詳細は省略しますが、PyTorchより軽量に扱えるケースがあり、サーバーサイドやCI環境での運用に向いています。

ベクトルデータベースの種類と選択基準

なぜ通常のDBでは不十分か

生成したEmbeddingベクトルを保存するだけなら、通常のRDBMSやファイルでも問題ありません。しかし、「クエリベクトルに最も近いベクトルを探す」という類似度検索を行う場合、通常のRDBMSでは全件スキャンが必要になります。

たとえば100万件のベクトルがあり、各ベクトルが768次元だとすると、1回の検索で100万回のコサイン類似度計算が必要です。小規模なデータでは問題ありませんが、データ量が増えると現実的な応答速度を維持できなくなります。

ベクトルデータベース(Vector DB)は、この高次元ベクトルの類似度検索を効率的に行うために設計された専用のデータストアです。後述するANN(近似最近傍探索)アルゴリズムを内部で利用することで、大規模データでも高速な検索を実現します。

代表的なベクトルデータベース

ベクトルデータベースには、専用のVector DB、既存RDBMSの拡張、ローカル/組み込み向けのものなどさまざまな選択肢があります。筆者はローカルでの実験やデータ分析にはDuckDB、本番運用にはQdrantを使い分けています。

項目

DuckDB

Qdrant

Chroma

pgvector

sqlite-vss

分類

ローカル/組み込み

専用Vector DB

専用Vector DB

RDBMSの拡張

RDBMSの拡張

用途

ローカル実験・分析

中〜大規模・本番運用

プロトタイピング

既存PG環境への追加

軽量・組み込み

セットアップ

ファイルベース

Docker推奨

pip installのみ

PostgreSQL必要

pip installのみ

SQLで操作

フィルタリング

◎(SQL)

◎(SQL)

Pythonクライアント

◎(duckdb)

○(psycopg2等)

DuckDB はファイルベースの列指向データベースで、VSS(Vector Similarity Search)拡張を使うことでベクトル検索が可能になります。SQLでベクトル演算(コサイン類似度など)が扱えるのが大きな特徴で、セットアップも手軽です。筆者はローカルでのデータ分析や一時的なベクトル処理に日常的に利用しています。ただし、HNSWインデックスの永続化は執筆時点では実験的機能のため、利用時は設定と注意事項を確認してください。

Qdrant はRust製の高性能なVector DBで、本番運用を想定して設計されています。メタデータフィルタリング(「2024年以降かつカテゴリがニュース」のような絞り込み)が強力で、Pythonクライアントも直感的に使えます。筆者が本番環境でメインに使っているVector DBです。利用にはDockerでのサーバー起動が必要ですが、クラウドサービスも提供されています。

Chroma は軽量でセットアップが容易なVector DBで、プロトタイピングに向いています。

pgvector はPostgreSQLの拡張機能で、既存のPostgreSQL環境にそのまま追加できます。SQLでベクトル検索を記述でき、既存資産を活かせるのが強みです。

sqlite-vss はSQLiteにベクトル検索機能を追加する拡張で、軽量な組み込み用途に適しています。

DuckDBを使った基本的なベクトル保存・検索

DuckDBのVSS拡張を使ったベクトルの保存と検索の基本的な流れを示します。ここでは、ファイルベースのDuckDBにHNSWインデックスを永続化する前提で設定しています。

DuckDBを使ったベクトル保存・検索
import duckdb
from sentence_transformers import SentenceTransformer

# ① DuckDBの初期化とVSS拡張のロード
con = duckdb.connect("vectors.duckdb")
con.execute("INSTALL vss; LOAD vss;")
# HNSWインデックスをファイルに永続化するための実験的設定
con.execute("SET hnsw_enable_experimental_persistence = true")

# ② テーブルの作成(384次元のベクトルを格納)
con.execute("""
    CREATE TABLE IF NOT EXISTS documents (
        id INTEGER,
        content TEXT,
        embedding FLOAT[384]
    )
""")

# ③ 保存するドキュメントの準備
documents = [
    "Pythonは汎用プログラミング言語です",
    "機械学習にはPythonがよく使われます",
    "Rustはシステムプログラミング言語です",
    "ベクトル検索はAIアプリケーションの基盤技術です",
    "データベースはデータを永続化するシステムです",
]

# ④ Embeddingの生成
model = SentenceTransformer("all-MiniLM-L6-v2")
embeddings = model.encode(documents)

# ⑤ ドキュメントとEmbeddingをテーブルに追加
for i, (doc, emb) in enumerate(zip(documents, embeddings)):
    con.execute(
        "INSERT INTO documents VALUES (?, ?, ?)",
        [i, doc, emb.tolist()],
    )
print(f"登録件数: {con.execute('SELECT COUNT(*) FROM documents').fetchone()[0]}")

# ⑥ HNSWインデックスの作成
con.execute("""
    CREATE INDEX IF NOT EXISTS idx_documents_embedding
    ON documents USING HNSW (embedding) WITH (metric = 'cosine')
""")

# ⑦ クエリによる類似検索
query = "AIと機械学習の関係"
query_embedding = model.encode(query).tolist()

results = con.execute("""
    SELECT content, array_cosine_distance(embedding, ?::FLOAT[384]) AS distance
    FROM documents
    ORDER BY distance ASC
    LIMIT 3
""", [query_embedding]).fetchall()

print(f"\nクエリ: 「{query}」")
print("検索結果:")
for content, distance in results:
    similarity = 1 - distance
    print(f"  類似度 {similarity:.4f}: {content}")

con.close()

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

DuckDBによる検索結果の例
登録件数: 5

クエリ: 「AIと機械学習の関係」
検索結果:
  類似度 0.7263: 機械学習にはPythonがよく使われます
  類似度 0.5154: ベクトル検索はAIアプリケーションの基盤技術です
  類似度 0.4538: Pythonは汎用プログラミング言語です

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

  • ① この例では duckdb.connect("vectors.duckdb") でファイルベースのデータベースを作成します。HNSWインデックスをファイルに永続化したいため、この構成にしています。インメモリで試すだけなら、引数なしの duckdb.connect() でも利用できます

  • 補足: hnsw_enable_experimental_persistence は、HNSWインデックスをファイルに永続化するための実験的な設定です。DuckDB公式ドキュメントでも実験的機能とされているため、クラッシュ時の復旧手順や制約は事前に確認してください

  • ② 通常のSQLでテーブルを作成します。ベクトルは FLOAT[次元数] 型で定義します

  • ③〜⑤ ドキュメントとそのEmbeddingをSQLのINSERT文で登録します

  • ⑥ HNSWインデックスを作成します。大規模データでの検索高速化に有効です

  • array_cosine_distance() 関数でコサイン距離を計算し、距離の近い順に取得します。metric = 'cosine' のHNSWインデックスもこの形で利用されます。表示時には 1 - distance で類似度に戻しています。SQLで書けるため、WHERE句での絞り込みも自在に組み合わせられます

【コラム】 SQLでベクトル検索を行うメリット

DuckDBやpgvectorのようにSQLでベクトル検索が書けると、メタデータによるフィルタリング(WHERE句)と類似度検索を1つのクエリで組み合わせられます。たとえば「2024年以降の記事で、このクエリに意味的に近いもの」といった検索が自然に記述できます。SQLに慣れている開発者にとってこのアプローチは非常に取り組みやすいと感じています。

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

マルチモーダル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対応 モデルでは、先頭側の次元だけを切り出しても検索性能を保ちやすく、ストレージ削減や検索高速化に活用しやすいという特徴があります。2026年3月に公開された TurboQuant のような新しい量子化研究も登場しており、実務ではまずPCAやPQのような定番手法から検討するのが現実的です。

実務での判断基準

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

  • データ件数が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モデルの選択やチャンキングの設計が回答品質を左右することも少なくありません。

まとめ

本記事では、Pythonを使ったベクトル検索パイプラインの構築を、以下の流れで解説しました。

  • Embedding: テキストや画像を数値ベクトルに変換する仕組みと、sentence-transformers を使った実装方法を紹介しました。ベクトルの「近さ」を測る指標(コサイン類似度、L2距離、内積)についても整理しました

  • Vector DB: DuckDB(VSS拡張)を使ったローカルでのベクトル保存・検索を実装しました。Qdrant・pgvector・Chroma・sqlite-vssなど、用途に応じた選択肢も比較しました

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

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

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

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

次のステップ

本記事で扱ったベクトル検索パイプラインは、さらに以下の方向に発展させることができます。

  • RAGパイプラインへの発展: ベクトル検索とLLMを組み合わせ、質問応答システムを構築する

  • ハイブリッド検索: キーワード検索とベクトル検索を組み合わせ、両者の長所を活かした検索システムを構築する

  • 本番環境でのVector DB運用: Qdrantのクラウドサービスや、pgvectorを使った既存PostgreSQL環境への統合

  • Embeddingモデルのファインチューニング: 特定ドメインのデータでモデルを調整し、検索精度をさらに向上させる

参考リンク