Elasticsearch × CLIP × GPTで画像検索システムを作ってみた

BLOG

キャプションやタグがなくても、テキストで近い画像を検索できる。そんな仕組みを、ElasticsearchとAIを組み合わせ試作しました。画像資産の整理や業務効率化に役立ちます。

Elasticsearch を選んだ理由はシンプルです。

  • ベクトル検索に強い:画像やテキストを CLIP で 512 次元ベクトルに変換し、それをそのまま保存して KNN 検索できる。
  • スケールに強い:最初は手元の16枚の画像でも、将来的に何千・何万枚に増えても同じ仕組みで動かせる。
  • 検索をカスタマイズできる:近似検索(速さ)と再ランク(精度)を組み合わせたり、メタデータで絞り込みしたりが簡単。

※動作環境

  • Elasticsearch: 8.19.0
  • Python: 3.11
  • 実装コードとデータセット⇨

やりたかったこと

  • 画像検索:テキストを入力すると、それに近い画像を探せる
  • 日本語サポート:日本語で検索しても正しく動く
  • シンプル構成:余計な仕組みは極力排除して、わかりやすくする

モデル選びでの失敗と気づき

試したモデル

最初に試したのは 多言語対応のCLIPモデル(clip-ViT-B-32-multilingual-v1) でした。
CLIP は「画像」と「テキスト」を同じベクトル空間に変換するモデルで、これにより「テキストで画像を探す」「画像に合う説明文を探す」が自然にできます。

ざっくり仕組みを説明すると:

  • 画像エンコーダ:画像を入力して、512次元ほどの数列(=埋め込み)に変換
  • テキストエンコーダ:文章も同じ次元の埋め込みに変換
  • 同じ空間で距離を測る:両者が近いほど「意味が似ている」とみなす

問題点

たとえば「ヒマワリ」と「時計」を検索しても、ほぼ同じ結果が返ってきてしまい、
埋め込みの類似度も 0.95〜0.99 と高すぎて区別がつきません。


改善と課題

精度が低すぎたため、別のモデルを試すことにしました。そこで改めて OpenAIオリジナルのCLIP (
clip-vit-base-patch32) を試してみたところ、こちらであれば納得できる結果が得られそうでした。

  • 物体やシーンを正しく区別してくれる
  • 安定して動作する
  • 日本語は7割程度は理解できない(課題!

日本語翻訳の工夫

次の課題は 日本語クエリをどう扱うか でした。
CLIPだけで日本語検索を行うと、正しい結果が返る場合もあれば、外れてしまう場合もありました。これではプロダクション環境で使うには不安定です。

そこで、いくつか方法を試しました。

  • 多言語CLIPをそのまま使う → うまくいかない
  • 手作りの和英辞書を追加 → 手間がかかりすぎる
  • Elasticsearchに翻訳パイプラインを組み込む → 作り込みすぎて保守が大変
  • 最終解決策:LLMを使って日本語クエリを翻訳させる!

結果的に、この最後の方法が最もシンプルで、しかも精度が良かった。さらに、 gpt-3.5-turbo の利用コストは非常に低く、実運用でも十分現実的です。

動かしてみる

インデックス作成

画像を data/images/ に入れて次を実行:

python smart_clip_search.py index

CLIP が画像をベクトルに変換し、Elasticsearch に保存してくれます。

実際のログはこんな感じ↓

(.venv) /path/to/data/code % python smart_clip_search.py index
🔄 Starting image indexing process...
This will look at each image and convert it to AI-readable format
Loaded CLIP model: openai/clip-vit-base-patch32
Created index: images-openai-only
Found 0 already indexed images
Found 16 image files
📝 Processing 16 new images
✅ Indexed 1/16: 10-unsplash.jpg
✅ Indexed 2/16: 3-unsplash.jpg
✅ Indexed 3/16: 4-unsplash.jpg
✅ Indexed 4/16: 16-unsplash.jpg
✅ Indexed 5/16: 11-unsplash.jpg
✅ Indexed 6/16: 5-unsplash.jpg
✅ Indexed 7/16: 2-unsplash.jpg
✅ Indexed 8/16: 8-unsplash.jpg
✅ Indexed 9/16: 9-unsplash.jpg
✅ Indexed 10/16: 13-unsplash.jpg
✅ Indexed 11/16: 14-unsplash.jpg
✅ Indexed 12/16: 7-unsplash.jpg
✅ Indexed 13/16: 15-unsplash.jpg
✅ Indexed 14/16: 12-unsplash.jpg
✅ Indexed 15/16: 6-unsplash.jpg
✅ Indexed 16/16: 1-unsplash.jpg
🎉 Indexing complete! Processed 16 new images

dev toolで確認すると… GET images-openai-only/_count

{
  "count": 16,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  }
}

確かに16枚、すべて保存されているのがわかります。

一つのドキュメントを例に… GET images-openai-only/_doc/loHWwpgBBkwHewC3XB1a

{
  "_index": "images-openai-only",
  "_id": "loHWwpgBBkwHewC3XB1a",
  "_version": 1,
  "_seq_no": 0,
  "_primary_term": 1,
  "found": true,
  "_source": {
    "image_embedding": [
	0.005093493033200502,
      0.004333182703703642,
      -0.02418620139360428,
      -0.03343995288014412,
...
],
    "image_name": "10-unsplash.jpg",
    "image_path": "/path/to/data/images/10-unsplash.jpg"
  }
}

データセットの写真

コードの準備

ここから、コードの説明をします。

1. 基本設定と依存ライブラリ

最初に必要なライブラリを読み込みます。

  • torch / transformers → CLIPモデルを動かすため
  • PIL → 画像を読み込むため
  • Elasticsearch クライアント → 検索用データベースと接続するため
import os, time, torch, numpy as np
from pathlib import Path
from PIL import Image
from transformers import CLIPModel, CLIPProcessor
from project_image_search.elastic_client import get_client

IMAGE_ROOT = Path("/path/to/data/images")
INDEX_NAME = "images-openai-only"

.envファイルの最小構成例

ELASTICSEARCH_URL=https://localhost:9200
ELASTICSEARCH_USERNAME=elastic
ELASTICSEARCH_PASSWORD=your_password
ELASTICSEARCH_CA_CERT=/path/to/ca.crt
OPENAI_API_KEY=your_openai_api_key_here

2. CLIPのロード

ここで OpenAI が公開している CLIP を読み込みます。

GPU があれば GPU で動かし、なければ CPU で実行します。

def load_clip_model():
    model_name = "openai/clip-vit-base-patch32"
    model = CLIPModel.from_pretrained(model_name)
    processor = CLIPProcessor.from_pretrained(model_name, use_fast=True)
    device = "cuda" if torch.cuda.is_available() else "cpu"
    model.to(device)
    return model, processor, device

3. 日本語→英語の翻訳

CLIPは英語が得意なので、日本語クエリは GPT で翻訳してから検索。

def translate_with_openai(query: str):
    # 日本語が含まれていたら OpenAI に翻訳を依頼

4. ESのインデックス設計

Elasticsearch に「画像データを保存する箱」を作ります。保存するのは以下の3つ:

  • 画像の埋め込みをdense_vectorとして(512次元ベクトル)
  • ファイル名
  • ファイルパス
def create_index():
    mapping = {
        "mappings": {
            "properties": {
                "image_embedding": {"type": "dense_vector", "dims": 512, "similarity": "cosine"},
                "image_name": {"type": "keyword"},
                "image_path": {"type": "keyword"}
            }
        }
    }
    es.indices.create(index=INDEX_NAME, body=mapping)

5. 既存の画像をチェック

def get_existing_images(es):
    res = es.search(index=INDEX_NAME, body={"_source": ["image_path"], "query": {"match_all": {}}})
    existing_paths = {hit["_source"]["image_path"] for hit in res["hits"]["hits"]}
    return existing_paths

既に保存されている画像を重複インデックスしない工夫。

6. 画像をインデックス

フォルダ内の画像を読み込み、1枚ずつ CLIP で数値化(埋め込み生成) します。その数値を Elasticsearch に保存することで、後から検索できるようにします。

def index_images():
    image = Image.open(img_path).convert("RGB")
    inputs = processor(images=image, return_tensors="pt").to(device)
    image_features = model.get_image_features(**inputs)
    image_features = image_features / image_features.norm(dim=-1, keepdim=True)

7. 画像検索

検索プロセスは大きく5ステップです:

  • 日本語なら英語に翻訳
  • テキストを CLIP に渡し、埋め込み(512次元ベクトル)に変換
  • Elasticsearch で「近い画像」を高速に検索(KNN検索)
  • 上位候補を取り出して、再ランク
  • 結果をスコア付きで返す
def search_images(query: str, k: int = 5):
    translated_query = translate_with_openai(query)
    inputs = processor(text=translated_query, return_tensors="pt").to(device)
    text_features = model.get_text_features(**inputs)
    text_features = text_features / text_features.norm(dim=-1, keepdim=True)

なぜ“正規化”が必須?

CLIPの出力は512次元ベクトルですが、ベクトルには「方向」と「長さ」があります。
検索で本当に比べたいのは「意味の近さ=方向」なのに、正規化をしないと「長さの違い」に結果が引っ張られてしまいます。

そのため、全てのベクトルを 長さ1に揃える(正規化) ことで、大きさの違いを無視して「意味の近さ」だけを比べられるようにする必要があります。

コマンドラインから実行

コマンドラインから2つの使い方ができます:

  • index → 画像を読み込んで保存
  • search <query> → テキストで検索
python smart_clip_search.py index

python smart_clip_search.py search "ひまわり"

検索例と結果

いくつか例を見てみましょう。


日本語クエリ「池と山」
python smart_clip_search.py search "池と山"  
🔍 Searching for images matching: '池と山'
Loaded CLIP model: openai/clip-vit-base-patch32
Translated '池と山' -> 'Lake and mountain'

Search Query: 池と山
Translated to: Lake and mountain
  1. 6-unsplash.jpg (similarity: 0.228)
  2. 3-unsplash.jpg (similarity: 0.205)
  3. 2-unsplash.jpg (similarity: 0.190)
  4. 7-unsplash.jpg (similarity: 0.187)
  5. 10-unsplash.jpg (similarity: 0.177)
Low confidence - may not contain '池と山'

   Total Search Time: 4.904s

結果 →1位はまさに池と山の写真でした。

6-unsplash.jpg
日本語クエリ「猫とメガネ」
python smart_clip_search.py search "猫とメガネ"
🔍 Searching for images matching: '猫とメガネ'
Loaded CLIP model: openai/clip-vit-base-patch32
Translated '猫とメガネ' -> 'Cat and glasses'

Search Query: 猫とメガネ
Translated to: Cat and glasses
  1. 13-unsplash.jpg (similarity: 0.294)
  2. 9-unsplash.jpg (similarity: 0.253)
  3. 11-unsplash.jpg (similarity: 0.169)
  4. 7-unsplash.jpg (similarity: 0.141)
  5. 10-unsplash.jpg (similarity: 0.133)
Medium confidence match

   Total Search Time: 4.277s

結果 →サングラスをかけた猫の写真が1位に出現。

13-unsplash.jpg
英語クエリ「man and sea」
python smart_clip_search.py search "man and sea"
🔍 Searching for images matching: 'man and sea'
Loaded CLIP model: openai/clip-vit-base-patch32

Search Query: man and sea
  1. 1-unsplash.jpg (similarity: 0.260)
  2. 7-unsplash.jpg (similarity: 0.235)
  3. 10-unsplash.jpg (similarity: 0.224)
  4. 11-unsplash.jpg (similarity: 0.200)
  5. 2-unsplash.jpg (similarity: 0.197)
Medium confidence match

   Total Search Time: 3.058s

結果 → 1位は海辺に立つ男性の写真。英語でも正しい結果が得られました。

1-unsplash.jpg
日本語クエリ「動物園」
python smart_clip_search.py search "動物園"     
🔍 Searching for images matching: '動物園'
Loaded CLIP model: openai/clip-vit-base-patch32
Translated '動物園' -> 'Zoo'

Search Query: 動物園
Translated to: Zoo
  1. 15-unsplash.jpg (similarity: 0.260)
  2. 13-unsplash.jpg (similarity: 0.205)
  3. 5-unsplash.jpg (similarity: 0.199)
  4. 3-unsplash.jpg (similarity: 0.194)
  5. 10-unsplash.jpg (similarity: 0.156)
Medium confidence match

   Total Search Time: 3.822s

結果 → お見事!1位は確かに動物園の写真でした。

まとめ

今回使った画像データセットは、ファイル名が「1-unsplash.jpg」「13-unsplash.jpg」のように中身が全くわからないもので、写真の説明文も一切ありませんでした。
つまり「テキスト情報ゼロ、画像の中身だけ」という条件です。

それでも CLIP が画像を意味ベクトルに変換し、Elasticsearch が高速に検索してくれたおかげで、

  • 「猫とメガネ」→ サングラス猫の写真
  • 「動物園」→ 動物園の写真
  • 「ひまわり」→ ひまわり畑の写真

と、まさに欲しい画像を返してくれました。「キャプションなし・タグなし・名前からは何もわからない画像」を検索できるのは、実務的にも大きなメリットだと思います。


🛠️ トラブルと学び

  • APIキーを入れ忘れると「No OpenAI API key found」と怒られる
  • “slow image processor”警告が出るけど、これは無害
  • インデックスが壊れた時は再作成すればOK

次の一歩

  • 数万枚規模にスケールして性能を検証したい
  • メタデータでフィルタリングを追加してみたい
  • 「画像 → 画像検索」に拡張したい