Elasticsearch と SigLIP-2 による山頂のマルチモーダル探索
SigLIP-2 埋め込みと Elasticsearch kNN ベクトル検索を使用して、テキストから画像、画像から画像へのマルチモーダル検索を実装する方法を学びます。プロジェクトの焦点: エベレスト トレッキングでアマ ダブラム山の山頂の写真を探す。
写真アルバムを意味で検索したいと思ったことはありませんか?「青いジャケットを着てベンチに座っている写真を見せてください」「エベレストの写真を見せてください」「日本酒と寿司」などのクエリを試してみてください。コーヒー(またはお好みの飲み物)を飲みながら、読み続けてください。このブログでは、マルチモーダル ハイブリッド検索アプリケーションの構築方法を紹介します。マルチモーダルとは、アプリが単語だけでなく、テキスト、画像、音声などさまざまな種類の入力を理解して検索できることを意味します。ハイブリッドとは、キーワード マッチング、kNN ベクトル検索、ジオフェンシングなどの技術を組み合わせて、より鮮明な結果を提供することを意味します。

これを実現するために、Google の SigLIP-2 を使用して画像とテキストの両方のベクトル埋め込みを生成し、Elasticsearch ベクトル データベースに保存します。クエリ時に、検索入力、テキストまたは画像を埋め込みに変換し、高速 kNN ベクトル検索を実行して結果を取得します。この設定により、効率的なテキストから画像への検索、画像から画像への検索が可能になります。Streamlit UI は、テキストベースの検索を行ってアルバムから一致する写真を検索して表示するだけでなく、アップロードされた画像から山頂を識別し、フォトアルバムでその山の他の写真を表示できるフロントエンドを提供することで、このプロジェクトを実現します。また、検索精度を向上させるために実行した手順や、実用的なヒントやコツについても説明します。さらに詳しく調べるために、 GitHub リポジトリとColab ノートブックを提供しています。
始まり
このブログ投稿は、エベレストベースキャンプトレッキングで撮ったアマダブラム山の写真を全部見せてほしいと私に頼んだ10歳の子供からインスピレーションを受けたものです。写真アルバムを精査しながら、私は他のいくつかの山頂を特定するよう求められましたが、そのうちのいくつかは名前がわかりませんでした。
それで、これは楽しいコンピューター ビジョン プロジェクトになるかもしれないというアイデアが浮かびました。私たちが達成したかったこと:
山頂の写真を名前で検索する
画像から山頂の名前を推測し、写真アルバムで似たような山頂を見つける
概念クエリを機能させる(人、川、祈りの旗など)

アマ・ダブラム山
ドリームチームを結成: SigLIP-2、Elasticsearch、Streamlit
これを機能させるには、テキスト (「Ama Dablam」) と画像 (私のアルバムの写真) の両方を、意味のある比較が可能なベクトル、つまり同じベクトル空間に変換する必要があることがすぐに明らかになりました。これを実行すると、検索は単に「最も近いものを見つける」だけになります。

画像の埋め込みを生成するために、多言語ビジョン言語エンコーダーを使用します。これにより、山の写真と「Ama Dablam」などのフレーズが同じベクトル空間に配置されます。
最近 Google がリリースしたSigLIP-2はここによく当てはまります。タスク固有のトレーニング (ゼロショット設定) なしで埋め込みを生成でき、ラベルのない写真や異なる名前と言語を持つピークなど、私たちのユースケースに適しています。テキストと画像のマッチングがトレーニングされているため、クエリ言語やスペルが異なっていても、トレッキング中の山の写真と短いテキストプロンプトは埋め込みとして近いものになります。
SigLIP-2 は、品質と速度のバランスが優れており、複数の入力解像度をサポートし、CPU と GPU の両方で実行されます。SigLIP-2 は、オリジナルの CLIP などの以前のモデルと比較して、屋外での写真撮影に対してより堅牢になるように設計されています。私たちのテストでは、SigLIP-2 は一貫して信頼できる結果を生成しました。また、サポートも非常に充実しており、このプロジェクトに最適な選択肢となっています。
次に、埋め込みとパワー検索を保存するためのベクトル データベースが必要です。画像埋め込みに対するコサイン kNN 検索をサポートするだけでなく、単一のクエリでジオフェンスとテキスト フィルターを適用することもサポートする必要があります。Elasticsearch はここで最適です。ベクトル (dense_vector フィールドの HNSW kNN) を非常に適切に処理し、テキスト、ベクトル、地理クエリを組み合わせたハイブリッド検索をサポートし、フィルタリングと並べ替えをすぐに使用できます。また、水平方向にも拡張できるため、数枚の写真から数千枚の写真まで簡単に拡張できます。公式のElasticsearch Python クライアントは、配管をシンプルに保ち、プロジェクトときれいに統合します。最後に、検索クエリを入力して結果を表示できる軽量のフロントエンドが必要です。簡単な Python ベースのデモには、Streamlit が最適です。ファイルのアップロード、レスポンシブな画像グリッド、並べ替えとジオフェンシングのためのドロップダウン メニューなど、必要な基本的な機能を提供します。簡単にクローンを作成してローカルで実行でき、Colab ノートブックでも動作します。
実装
Elasticsearchのインデックス設計とインデックス戦略
このプロジェクトでは、 peaks_catalogとphotosの 2 つのインデックスを使用します。
Peaks_catalogインデックス
この索引は、エベレストベースキャンプトレッキング中に見える主要な山頂のコンパクトなカタログとして機能します。このインデックス内の各ドキュメントは、エベレスト山などの単一の山頂に対応しています。各山頂ドキュメントには、名前/エイリアス、オプションの緯度経度座標、および SigLIP-2 テキストプロンプト (+ オプションの参照画像) を組み合わせて構築された単一のプロトタイプ ベクトルが保存されます。
インデックスマッピング:
分野 | タイプ | 例 | 目的/注意事項 | ベクトル/インデックス |
|---|---|---|---|---|
id | キーワード | アマ・ダブラム | 安定したスラッグ/ID | — |
名前 | テキスト + キーワードサブフィールド | ["アマ・ダブラム"、"アマダブラム"] | エイリアス/多言語名; 正確なフィルターのためのnames.raw | — |
ラトロン | ジオポイント | {"lat":27.8617,"lon":86.8614} | 緯度/経度の組み合わせによるピーク GPS 座標 (オプション) | — |
高度m | 整数 | 6812 | 標高(オプション) | — |
テキスト埋め込み | dense_vector | 768 | このピークのブレンドプロトタイプ(プロンプトとオプションで1~3枚の参照画像) | index:true、類似度:"cosine"、index_options: {type:"hnsw", m:16, ef_construction:128} |
このインデックスは主に、画像から山頂を識別するなど、画像間の検索に使用されます。このインデックスは、テキストから画像への検索結果を強化するためにも使用されます。
要約すると、 peaks_catalogは「これは何の山ですか?」という質問を焦点を絞った最近傍問題に変換し、概念的理解を画像データの複雑さから効果的に分離します。
peaks_catalog インデックスのインデックス戦略: EBC トレッキング中に見える最も目立つ山頂のリストを作成することから始めます。各山頂の地理的位置、名前、同義語、標高をyaml ファイルに保存します。次のステップは、各ピークの埋め込みを生成し、それをtext_embedフィールドに保存することです。堅牢な埋め込みを生成するために、次の手法を使用します。
以下を使用してテキスト プロトタイプを作成します。
山の名前
プロンプト アンサンブル(複数の異なるプロンプトを使用して同じ質問に答える)、例:
「ネパール、ヒマラヤ山脈の山頂{name}の自然写真」
「クンブ地域の{name}マーク的な山頂、高山の風景」
「 {name}山頂、雪、岩だらけの尾根」
オプションの反概念(SigLIP-2 に一致しないものを指示する): 「絵画、イラスト、ポスター、地図、ロゴ」の小さなベクトルを減算して、実際の写真に偏向させます。
ピークの参照画像が提供されている場合は、オプションで画像プロトタイプを作成します。
次に、テキストと画像のプロトタイプをブレンドして、最終的な埋め込みを生成します。最後に、ドキュメントはすべての必須フィールドでインデックス化されます。
def l2norm(v: np.ndarray) -> np.ndarray:
return v / (np.linalg.norm(v) + 1e-12)
def compute_blended_peak_vec(
emb: Siglip2,
names: List[str],
peak_id: str,
peaks_images_root: str,
alpha_text: float = 0.5,
max_images: int = 3,
) -> Tuple[np.ndarray, int, int, List[str]]:
"""
Build blended vector for a single peak.
Returns:
vec : np.ndarray (L2-normalized)
found_count : number of reference images discovered
used_count : number of references used (<= max_images)
used_filenames: list of filenames used (for logging)
"""
# 1) TEXT vector
tv = embed_text_blend(emb, names)
# 2) IMAGE refs: prefer folder by id; fallback to slug of the primary name
root = Path(peaks_images_root)
candidates = [root / peak_id]
if names:
candidates.append(root / slugify(names[0]))
all_refs: List[Path] = []
for c in candidates:
if c.exists() and c.is_dir():
all_refs = list_ref_images(c)
if all_refs:
break
found = len(all_refs)
used_list = all_refs[:max_images] if (max_images and found > max_images) else all_refs
used = len(used_list)
img_v = embed_image_mean(emb, used_list) if used_list else None
# 3) Blend TEXT and IMAGE vectors, clamp alpha to [0,1]
a = max(0.0, min(1.0, float(alpha_text)))
vec = l2norm(tv if img_v is None else (a * tv + (1.0 - a) * img_v)).astype("float32")
return vec, found, used, [p.name for p in used_list]peaks_catalogインデックスからのサンプル ドキュメント:

写真インデックス
このプライマリ インデックスには、アルバム内のすべての写真に関する詳細情報が保存されます。各ドキュメントは 1 枚の写真を表し、次の情報が含まれています。
フォトアルバム内の写真への相対パス。これを使用して、一致する画像を表示したり、検索 UI に画像を読み込んだりできます。
写真のGPSと時間情報。
SigLIP-2 によって生成された画像エンコーディング用の密なベクトル。
predicted_peaksピーク名でフィルタリングできます。インデックスマッピング
分野 | タイプ | 例 | 目的/注意事項 | ベクター / インデックス |
|---|---|---|---|---|
パス | キーワード | データ/画像/IMG_1234.HEIC | UI でサムネイル/フル画像を開く方法 | — |
クリップ画像 | dense_vector | 768 | SigLIP-2画像埋め込み | index:true、類似度:"cosine"、index_options: {type:"hnsw", m:16, ef_construction:128} |
予測ピーク | キーワード | ["ama-dablam","pumori"] | インデックス時の上位Kの推測(安価なUXフィルター/ファセット) | — |
GPS | ジオポイント | {"lat":27.96,"lon":86.83} | 地理フィルターを有効にする | — |
ショット時間 | date | 2023年10月18日09:41:00Z | 撮影時間: 並べ替え/フィルター | — |
写真インデックスのインデックス戦略:アルバム内の写真ごとに、次の操作を実行します。
画像メタデータから画像shot_timeとgps情報を抽出します。
SigLIP-2 画像埋め込み: 画像をモデルに渡し、ベクトルを L2 正規化します。埋め込みを
clip_imageフィールドに保存します。ピークを予測し、
predicted_peaksフィールドに保存します。これを行うには、まず前の手順で生成された写真の画像ベクトルを取得し、次にpeaks_catalogインデックスの text_embed フィールドに対して簡単な kNN 検索を実行します。上位 3 ~ 4 つのピークを保持し、残りは無視します。画像名とパスのハッシュを実行して
_idフィールドを計算します。これにより、複数回実行した後に重複が発生しなくなります。
写真のすべてのフィールドを決定したら、一括インデックスを使用して写真ドキュメントを一括でインデックスします。
def bulk_index_photos(
es: Elasticsearch,
images_root: str,
photos_index: str = "photos",
peaks_index: str = "peaks_catalog",
topk_predicted: int = 5,
batch_size: int = 200,
refresh: str = "false",
) -> None:
"""Walk a folder of images, embed + enrich, and bulk index to Elasticsearch."""
root = Path(images_root)
if not root.exists():
raise SystemExit(f"Images root not found: {images_root}")
emb = Siglip2()
batch: List[Dict[str, Any]] = []
n_indexed = 0
for p in iter_images(root):
rel = relpath_within(root, p)
_id = id_for_path(rel)
# 1) Image embedding (and reuse it for predicted_peaks)
try:
with Image.open(p) as im:
ivec = emb.image_vec(im.convert("RGB")).astype("float32")
except (UnidentifiedImageError, OSError) as e:
print(f"[skip] {rel} — cannot embed: {e}")
continue
# 2) Predict top-k peak names
try:
top_names = predict_peaks(es, ivec.tolist(), peaks_index=peaks_index, k=topk_predicted)
except Exception as e:
print(f"[warn] predict_peaks failed for {rel}: {e}")
top_names = []
# 3) EXIF enrichment (safe)
gps = get_gps_decimal(str(p))
shot = get_shot_time(str(p))
# 4) Build doc and stage for bulk
doc = {"path": rel, "clip_image": ivec.tolist(), "predicted_peaks": top_names}
if gps:
doc["gps"] = gps
if shot:
doc["shot_time"] = shot
batch.append(
{"_op_type": "index", "_index": photos_index, "_id": _id, "_source": doc}
)
# 5) Periodic flush
if len(batch) >= batch_size:
helpers.bulk(es, batch, refresh=refresh)
n_indexed += len(batch)
print(f"[photos] indexed {n_indexed} (last: {rel})")
batch.clear()
# Final flush
if batch:
helpers.bulk(es, batch, refresh=refresh)
n_indexed += len(batch)
print(f"[photos] indexed {n_indexed} total.")
print("[done] photos indexing")写真インデックスからのサンプルドキュメント:

要約すると、写真のインデックスは、アルバム内のすべての写真を格納する、高速でフィルタリング可能な kNN 対応のストアです。マッピングは意図的に最小限に抑えられており、すばやく取得し、きれいに表示し、結果を空間と時間で分割するのに十分な構造になっています。このインデックスは、両方の検索ユースケースに対応します。両方のインデックスを作成するための Python スクリプトはここにあります。
以下の Kibana マップの視覚化では、写真アルバムのドキュメントが緑のドットで表示され、 peaks_catalogインデックスの山頂が赤い三角形で表示されています。緑のドットはエベレスト ベース キャンプのトレッキング トレイルとぴったり一致しています。

検索ユースケース
名前による検索(テキストから画像へ):この機能により、ユーザーはテキスト クエリを使用して山頂の写真(さらには「祈りの旗」のような抽象的な概念)を見つけることができます。これを実現するために、テキスト入力は SigLIP-2 を使用してテキスト ベクトルに変換されます。堅牢なテキストベクトル生成のために、 インデックスでテキスト埋め込みを作成するために使用したのと同じ戦略を採用しています。つまり、テキスト入力を小さなpeaks_catalog プロンプトアンサンブル と 組み合わせ 、小さな 反概念ベクトル を減算し、 L2正規化 を適用して最終的なクエリベクトルを生成します。次に、 photos.clip_imageフィールドで kNNクエリを実行し、コサイン類似度に基づいて一致する上位のピークを取得して、最も近い画像を見つけます。オプションとして、クエリの一部として地理および日付フィルター、および/またはphotos.predicted_peaks用語フィルターを適用することで、検索結果の関連性を高めることができます (以下のクエリ例を参照)。これにより、トレッキング中に実際には見えていない、似たような山頂を除外することができます。

ジオフィルターを使用した Elasticsearch クエリ:
POST photos/_search
{
"knn": {
"field": "clip_image",
"query_vector": [ ... ],
"k": 60,
"num_candidates": 2000
},
"query": {
"bool": {
"filter": [
{ "geo_bounding_box": { "gps": { "top_left": "...", "bottom_right": "..." } } }
]
}
},
"_source": ["path","predicted_peaks","gps","shot_time"]
}
Response (first two documents):
{
"hits": {
"total": {
"value": 56,
"relation": "eq"
},
"max_score": 0.5779596,
"hits": [
{
"_index": "photos",
"_id": "d01da3a1141981486c3493f6053c79e92a788463",
"_score": 0.5779596,
"_source": {
"path": "IMG_2738.HEIC",
"predicted_peaks": [
"Pumori",
"Kyajo Ri",
"Khumbila",
"Nangkartshang",
"Kongde Ri"
],
"gps": {
"lat": 27.97116388888889,
"lon": 86.82331111111111
},
"shot_time": "2023-11-03T08:07:13"
}
},
{
"_index": "photos",
"_id": "c79d251f07adc5efaedc53561110a7fd78e23914",
"_score": 0.5766071,
"_source": {
"path": "IMG_2761.HEIC",
"predicted_peaks": [
"Kyajo Ri",
"Makalu",
"Baruntse",
"Cho Oyu",
"Khumbila"
],
"gps": {
"lat": 27.975558333333332,
"lon": 86.82515
},
"shot_time": "2023-11-03T08:51:08"
}
}
}画像による検索 (画像間):この機能を使用すると、写真内の山を識別し、写真アルバム内で同じ山の他の画像を見つけることができます。画像がアップロードされると、SigLIP-2 画像エンコーダーによって処理され、画像ベクトルが生成されます。次に、 peaks_catalog.text_embedフィールドでkNN 検索を実行し、最も一致するピーク名を特定します。次に、これらの一致するピーク名からテキスト ベクトルが生成され、写真インデックスに対して別のkNN 検索が実行され、対応する写真が検索されます。

Elasticsearchクエリ:
ステップ1: 一致するピーク名を見つける
GET peaks_catalog/_search
{
"knn": {
"field": "text_embed",
"query_vector": [...image-vector... ],
"k": 3,
"num_candidates": 500
},
"_source": [
"id",
"names",
"latlon",
"text_embed"
]
}
Response (first two documents):
{
"took": 2,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 3,
"relation": "eq"
},
"max_score": 0.58039916,
"hits": [
{
"_index": "peaks_catalog",
"_id": "pumori",
"_score": 0.58039916,
"_source": {
"id": "pumori",
"names": [
"Pumori",
"Pumo Ri"
],
"latlon": {
"lat": 28.01472,
"lon": 86.82806
},
"text_embed": [
... embeddings...
]
}
},
{
"_index": "peaks_catalog",
"_id": "kyajo-ri",
"_score": 0.57942784,
"_source": {
"id": "kyajo-ri",
"names": [
"Kyajo Ri",
"Kyazo Ri"
],
"latlon": {
"lat": 27.909167,
"lon": 86.673611
},
"text_embed": [
... embeddings...
]
}
}
]
}
}ステップ 2: photosインデックスで検索を実行し、一致する画像を見つけます (テキストから画像への検索ユース ケースに示されているのと同じクエリ)。
POST photos/_search
{
"knn": {
"field": "clip_image",
"query_vector": [ ...image-vector... ],
"k": 30,
"num_candidates": 2000
},
"_source": [
"path",
"gps",
"shot_time",
"predicted_peaks",
"clip_image"
],
"query": {
"bool": {
"filter": [
{
"term": {
"predicted_peaks": "Pumori"
}
}
]
}
}
}
Response (first two documents):
{
"hits": {
"total": {
"value": 56,
"relation": "eq"
},
"max_score": 0.5779596,
"hits": [
{
"_index": "photos",
"_id": "d01da3a1141981486c3493f6053c79e92a788463",
"_score": 0.5779596,
"_source": {
"path": "IMG_2738.HEIC",
"predicted_peaks": [
"Pumori",
"Kyajo Ri",
"Khumbila",
"Nangkartshang",
"Kongde Ri"
],
"gps": {
"lat": 27.97116388888889,
"lon": 86.82331111111111
},
"shot_time": "2023-11-03T08:07:13"
}
},
{
"_index": "photos",
"_id": "c79d251f07adc5efaedc53561110a7fd78e23914",
"_score": 0.5766071,
"_source": {
"path": "IMG_2761.HEIC",
"predicted_peaks": [
"Kyajo Ri",
"Makalu",
"Baruntse",
"Cho Oyu",
"Khumbila"
],
"gps": {
"lat": 27.975558333333332,
"lon": 86.82515
},
"shot_time": "2023-11-03T08:51:08"
}
}
}流線型のUI
すべてをまとめるために、両方の検索ユースケースを実行できるシンプルな Streamlit UI を作成しました。左側のレールには、チェックボックスとミニマップ/ジオフィルターが付いた、スクロール可能なピークのリスト( photos.predicted_peaksから集約)が表示されます。上部には、名前による検索ボックスと写真アップロードからの識別ボタンがあります。中央のペインには、kNN スコア、予測ピーク バッジ、キャプチャ時間を表示するレスポンシブなサムネイル グリッドがあります。各画像には、フル解像度のプレビューを表示するための画像表示ボタンが含まれています。
画像をアップロードして検索:ピークを予測し、写真アルバムから一致するピークを見つけます。

テキスト検索: アルバム内の一致するピークをテキストから検索します

まとめ
アマ・ダブラムの 写真 だけ見せてもらえませんか ? というところから始まりました。小規模で実用的なマルチモーダル検索システムになりました。私たちは生のトレッキング写真を撮影し、それをSigLIP-2 埋め込みに変換し、 Elasticsearchを使用してベクトルに対して高速kNN を実行し、さらに単純な地理/時間フィルターを使用して意味によって適切な画像を浮かび上がらせました。途中で、私たちは 2 つのインデックス、つまり混合プロトタイプの小さなpeaks_catalog (識別用) と、画像ベクトルと EXIF のスケーラブルなphotosインデックス (検索用) で関心を分離しました。実用的かつ再現性があり、拡張も簡単です。
調整したい場合は、いくつかの設定を試すことができます。
クエリ時間の設定:
k(返す近隣の数) とnum_candidates(最終スコアリングの前に検索する範囲)。これらの設定については、こちらのブログで説明されています。インデックス時間の設定:
m(グラフの接続性) およびef_construction(ビルド時間の精度とメモリ)。クエリの場合は、ef_searchも試してください。値が大きいほど、通常は、レイテンシを多少トレードオフして、リコール率が向上します。これらの設定の詳細については、このブログを参照してください。
今後、マルチモーダルおよび多言語検索用のネイティブモデル/リランカーがElasticエコシステムにまもなく導入される予定です。これにより、画像/テキスト検索とハイブリッドランキングがさらに強化されるはずです。ir.elastic.co+1
これを自分で試してみたい場合は:
GitHub リポジトリ: https://github.com/navneet83/multimodal-mountain-peak-search
Colab クイックスタート: https://github.com/navneet83/multimodal-mountain-peak-search/blob/main/notebooks/multimodal_mountain_peak_search.ipynb
これで私たちの旅は終わり、帰る時間になりました。これが役に立つことを願っています。これを壊した場合(または改善した場合)、何を変更したかをお聞かせください。





