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

Elasticsearch を選んだ理由はシンプルです。
- ベクトル検索に強い:画像やテキストを CLIP で 512 次元ベクトルに変換し、それをそのまま保存して KNN 検索できる。
- スケールに強い:最初は手元の16枚の画像でも、将来的に何千・何万枚に増えても同じ仕組みで動かせる。
- 検索をカスタマイズできる:近似検索(速さ)と再ランク(精度)を組み合わせたり、メタデータで絞り込みしたりが簡単。
やりたかったこと
- 画像検索:テキストを入力すると、それに近い画像を探せる
- 日本語サポート:日本語で検索しても正しく動く
- シンプル構成:余計な仕組みは極力排除して、わかりやすくする
モデル選びでの失敗と気づき
試したモデル
最初に試したのは 多言語対応の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位はまさに池と山の写真でした。

日本語クエリ「猫とメガネ」
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位に出現。

英語クエリ「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位は海辺に立つ男性の写真。英語でも正しい結果が得られました。

日本語クエリ「動物園」
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
次の一歩
- 数万枚規模にスケールして性能を検証したい
- メタデータでフィルタリングを追加してみたい
- 「画像 → 画像検索」に拡張したい