Jina v5 Omni × Agent Builder × MCP で組み立てるマルチモーダル画像検索 PoC
📦 ソースコード: GitHub リポジトリ
14 枚の写真を Elastic に登録するだけで、Kibana のチャットから 「青い椅子の写真を見せて」 と日本語で問いかけると、本当に青い椅子が写った写真が返ってくる。
しかも フロントエンドコードはゼロ行。Python 300 行と Elastic Cloud のクリック数回で完成します。
このブログでは、その PoC をどう組み立てたか、ステップ・バイ・ステップで紹介します。
- 1. 実装する 3 つの検索モード
- 2. なぜ作ったか
- 3. 全体アーキテクチャ
- 4. 準備するもの
- Phase 0 — 環境準備
- Phase 1 — S3 バケットを作る
- Phase 2 — Elastic インデックスを作る
- Phase 3 — 画像を ingest する
- Phase 4 — CLI で検索を試す
- Phase 5 — Agent Builder + MCP でチャット化
- ハマったところ TOP 4
- 運用 — 起動と停止のステップ
- 次のステップ
- まとめ
1. 実装する 3 つの検索モード
| ユーザーが入力する言葉 | 動き |
|---|---|
| 「青い椅子の写真」 | 意味的に近い画像 上位 3 件 |
| 「IMG_8133.jpeg に似た画像」 | 既存画像から視覚的に似た画像 上位 3 件 |
| 「’RIDE’ と書かれた画像」 | 画像内の文字が一致するっぽい画像 上位 3 件 |
完成形の応答イメージ

⚠️ スコア表示の読み方について — 「類似度 60.60%」は確率ではありません
応答例とスクリーンショットでは 「類似度 60.60%」のように % 付きで表示されていますが、これは見た目の都合でそうしているだけで、AI の正解確率や信頼度ではありません。
これは Elasticsearch の kNN が、選んだ similarity metric (本 PoC では cosine) から計算する ランキング用のスコアです。cosine similarity の場合、内部的には次のように正規化されます。
_score = (1 + cos(θ)) / 2 ← 範囲 [0, 1]
表示値 = _score × 100 ← 0〜100 の数字想定読者
- Python と HTTP API に慣れている開発者
- Elasticsearch の基本 (index, search) は知っていても OK / 知らなくても OK
- ベクトル検索やマルチモーダル AI に「触ってみたい」と思っている人
技術用語は登場する順に、その場で説明する形にしてある。
所要時間の目安
- 環境準備 (アカウント・キー作成) … 30〜60 分
- 実装と動作確認 … 2〜3 時間
- Agent Builder のセットアップ … 2〜3 時間
合計 半日〜1 日 で動かせます。
2. なぜ作ったか
きっかけは 3 つありました。
- 以前試していた画像検索を、今度は Jina の Omni モデルでどう作れるか見てみたかった。
- Agent Builder にこの検索ロジックを乗せられるかを知りたかった。
- 日本語の指示も理解してくれるか、本当に試したかった。
この 3 つを一気に検証できる最小構成として、本 PoC を作りました。結果としては、Jina v5 Omni + query_vector_builder + Agent Builder + MCP の組み合わせで、フロントエンドコード 0 行・Python 約 300 行 で日本語チャット画像検索が成立することを確認できました。
自前構成と Serverless で、どこに差が出るのか
この構成を自前で作ると、何が大変になるのか
ここで比較したいのは、単純な「OSS か、有料か」ではありません。もう少し正確に言うと、次の 3 つの違いです。
- 自前サーバー + 無償中心の Elastic
- 自前サーバー + Elastic の有償エディション / サブスクリプション
- Elastic Cloud Serverless
検索クエリの考え方だけを見ると、dense_vector、kNN、Inference API、query_vector_builder などを組み合わせる構成は、どの方式でも近い形にできます。
ただし、本当に差が出るのは「検索クエリを書けるか」ではありません。
差が出るのは、モデル、エージェント、UI、アップグレード、運用責任を誰が持つか です。
違いを整理すると、次のようになります。 ※ Elastic Cloud Hosted については説明を省略します
| 観点 | 自前サーバー + 無償中心の Elastic | 自前サーバー + 有償エディション / サブスクリプション | Elastic Cloud Serverless |
|---|---|---|---|
| Elasticsearch / Kibana の運用 | サーバー、OS、Docker / VM、Elasticsearch、Kibana、証明書、バックアップ、監視、アップグレードを自分で管理する | 基本的なサーバー運用は自分で行う。ただし、有償機能やサポート、Cloud Connect などを使える選択肢が増える | Elastic が基盤を管理する。ユーザーはノード数、シャード設計、クラスタアップグレードなどを強く意識せずに使える |
| Jina v5 Omni などの AI モデル利用 | 外部推論サービスを別途使う、または自分でモデルをホストする必要がある。自前ホストの場合は GPU / CPU、推論サーバー、ライブラリ、モデル更新も自分で管理する | Enterprise などの条件を満たせば、Cloud Connect 経由で Elastic Inference Service を使える選択肢がある。ただし self-managed cluster 自体の運用責任は残る | Elastic Cloud 側で用意された Elastic Inference Service を zero setup で使える。今回のような PoC では、GPU や推論サーバーを自分で用意しなくてよい |
| LLM チャット / エージェント | LangChain、LlamaIndex、自作アプリなどでエージェントランタイムを作る必要がある。UI も Streamlit / Gradio / React などで自作することになる | 有償機能により Agent Builder や EIS 連携を使える可能性があるが、構成やライセンス条件の確認が必要 | Agent Builder と Kibana のチャット UI を使える。今回の PoC では、フロントエンドコードを書かずにチャット型の画像検索を作れた |
| MCP server の接続 | MCP client 側の実装、認証、エラーハンドリング、ツール呼び出し制御を自分で作る必要がある | Agent Builder が使える構成なら、MCP 連携を Elastic 側の UI に寄せられる | Manage MCP の画面から URL と HTTP ヘッダーを設定し、MCP server のツールを Agent Builder に取り込める |
| アップグレード対応 | Elasticsearch / Kibana、推論サーバー、AI モデル、Python ライブラリ、アプリ UI を自分で追いかける必要がある。AI モデルの進化が速いほど、継続的な検証と更新が重くなる | 有償サポートや Cloud Connect によって一部の負担は減らせるが、self-managed cluster のアップグレード計画や検証は基本的に自分側に残る | Serverless では Elastic が管理する基盤やプロジェクトコンポーネントのアップグレードを担当するため、アップデート追従の負担を大きく減らせる。ただし、自分で作った MCP server、Python コード、外部アプリ、ingest コンポーネントは自分で更新する必要がある |
| 向いている使い方 | 技術検証、学習、コストを抑えた小規模 PoC | 自社インフラ要件があるが、有償機能やサポートも使いたい場合 | すばやく PoC を作り、その後も運用負荷を抑えながら AI 機能を使いたい場合 |
AI モデルの進化は非常に速いため、この差は大きいです。一度 PoC を作るだけなら、自前構成でも十分可能です。しかし、継続的に使い続ける場合は、モデル更新、互換性確認、再 embedding、検索品質の再評価、クラスタアップグレード、UI 保守などが積み重なります。
つまり、有償スタックや Elastic Cloud Serverless の価値は、主に次の 4 つにあります。
- モデルのホスト先を自分で持たなくてよいこと
- LLM チャットの土台を自分で作らなくてよいこと
- アップデート対応の負担を減らせること
- 検索サービスを Observability / Security に広げやすいこと
今回のような画像検索 PoC は、最初は「検索できるか」が中心です。しかし、社内サービスや顧客向けサービスに近づくほど、「遅くなったときに原因を追えるか」「誰が何を検索したかを確認できるか」「認証情報や画像 URL が悪用されていないか」を見る必要が出てきます。
Elastic Cloud Serverless や有償スタックの価値は、検索機能そのものだけでなく、その周辺にある運用・監視・セキュリティまで、同じ Elastic の考え方で拡張できる点にもあります。
3. 全体アーキテクチャ
3-1. コンポーネントの全体図

3-2. 5 つのコンポーネントの役割
| コンポーネント | 役割 (一言で) |
|---|---|
| Elasticsearch | 画像の数値ベクトル (1024 次元) を保存し、近いベクトルを高速検索 |
| EIS (Jina v5 Omni) | 画像とテキストを 同じベクトル空間 に変換する AI モデル。Elastic 内部にホスト済み |
| S3 (AWS) | 画像本体の保管庫。private に保ち、表示用には pre-signed URL を都度生成 |
| MCP server (Python) | チャットから呼ばれる検索ロジック本体。Elastic に kNN を投げ、S3 の URL を作って返す |
| ngrok | ローカルの MCP server にインターネットから (Elastic Cloud から) 到達できるようにする一時トンネル |
データの流れ図 (ingest / search のシーケンス図) はリポジトリの README.md に詳しく載せています。気になる方はそちらをどうぞ。
4. 準備するもの
アカウント (すべて無料枠で OK)
- AWS アカウント (S3 用)
- Elastic Cloud アカウント — https://cloud.elastic.co
- ngrok アカウント — https://dashboard.ngrok.com
ローカル環境 (macOS 想定)
- Python 3.11 以上
- AWS CLI … brew install awscli
- ngrok … brew install ngrok
画像
- jpg / jpeg / png 形式で 10〜20 枚程度。スマホで撮った写真でも、フリー素材でも OK です。
Phase 0 — 環境準備
0-1. プロジェクトの構成
test-jina-image/
├── .env / .env.example # 認証情報
├── setup/ # 一度だけ走らせる初期化スクリプト
│ ├── check_prerequisites.sh
│ ├── create_s3_bucket.py
│ └── create_elastic_index.py
├── ingest/
│ └── upload_and_index.py # 画像アップロード + 埋め込み生成
├── tools/
│ └── search_tools.py # 検索ロジック本体
└── agent/
├── system_prompt.md
├── mcp_server.py
├── start_ngrok.sh
└── start_mcp.sh0-2. Elastic Cloud Serverless プロジェクトを作る
- https://cloud.elastic.co を開く
- 「Create project」 → タイプは Elasticsearch を選ぶ
- リージョン: Tokyo (ap-northeast-1)
- 名前: image-search-poc
- 2〜3 分待つ
0-3. ELASTIC_URL を取得する
ここでよくある落とし穴があります。Cloud Console には「Endpoint」と書かれた欄が見つからないことが多いです。
最短の手順: Kibana のブラウザ URL の .kb. を .es. に書き換えるだけ。
Kibana の URL: https://image-search-poc-xxxxxx.kb.ap-northeast-1.aws.elastic.cloud/app/...
↑ ここを変える
ELASTIC_URL: https://image-search-poc-xxxxxx.es.ap-northeast-1.aws.elastic.cloud
0-4. ELASTIC_API_KEY を作る
- Kibana → Stack Management → API keys → Create API key
- 名前: image-search-poc、権限はデフォルト(PoC では簡略化のため)
- 表示された Encoded の値を必ずコピー (この画面でしか見られません)
0-5. AWS IAM ユーザーを準備
詳しい手順は長いので、GitHub リポジトリの README に分けて書いてあります。要点だけ書くと:
- 専用の IAM ユーザー elastic-poc-user を作る (Console ログインなし)
- アクセスキー (Access Key ID + Secret) を発行する
- バケット名で Resource ARN を絞ったインラインポリシーを付ける (s3:CreateBucket, s3:PutObject, s3:GetObject, など最小権限のみ)
⚠️ 絶対にやらないこと: root アカウントのアクセスキーを使わない。AdministratorAccess ポリシーを付けない。”Resource”: “*” も避ける。
0-6. .env を作る
プロジェクトルートに .env ファイルを作り、次のように埋めます。
ELASTIC_URL=https://image-search-poc-xxxxxx.es.ap-northeast-1.aws.elastic.cloud
ELASTIC_API_KEY=<Encoded 値>
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=ap-northeast-1
S3_BUCKET_NAME=image-search-poc-yourname-20260524
S3_UPLOAD_PREFIX=poc-uploads
INDEX_NAME=image-search-pocそして自分以外読めないように権限を絞ります。→ chmod 600 .env
0-7. 環境チェック
bash setup/check_prerequisites.shこのスクリプトは Python・pip・AWS CLI を確認し、venv を作り、必要なライブラリ (elasticsearch, boto3, python-dotenv, requests) をインストールし、最後に .env の中身が揃っているかを確認します。
Phase 1 — S3 バケットを作る
./venv/bin/python setup/create_s3_bucket.pyこのスクリプトが裏でやることは 4 つ。
- バケットを東京リージョンに作成
- Block Public Access を 4/4 すべて有効化
- SSE-S3 暗号化 を有効化
- バージョニング を有効化
実行後、AWS Console でバケットを開いて、上記 3 つの設定がすべて緑色になっているか確認します。
ここで押さえたいキーワード: SSE-S3 とは
「暗号化」と聞くと TLS / HTTPS と混同しがちですが、SSE-S3 はディスクに書き込む時の暗号化 (encryption at rest) です。

物理的にディスクが盗まれても中身が読めない、というのが SSE-S3 の役割です。アクセス権限は IAM の仕事、通信の盗聴は TLS の仕事と分かれています。
Phase 2 — Elastic インデックスを作る
./venv/bin/python setup/create_elastic_index.pyこのスクリプトは 2 つのことをします。
- 実体インデックス image-search-poc-v1 を作る
- alias image-search-poc を -v1 に紐付ける
マッピング (フィールド定義)
{
"image_key": { "type": "keyword" },
"name": { "type": "text" },
"description": { "type": "text" }, // ← 今回は未使用、将来のキャプション保存用に予約
"image_embedding": {
"type": "dense_vector",
"dims": 1024,
"index": true,
"similarity": "cosine",
"index_options": { "type": "int8_hnsw" }
}
}ここで押さえたいキーワード ① — dense_vector
まずはシンプルに考えると、dense_vector とは「数値の配列を 1 件分のドキュメントに保存できるフィールド型」です。
今回は 1024 個の数値 (= 1024 次元のベクトル) を 1 枚の画像につき 1 つ保存します。Jina v5 Omni のモデルが出力する次元数が 1024 なので、それに合わせています。
“image_embedding“: [0.12, -0.34, 0.55, ..., 0.08] # 1024 個の数値
similarity: "cosine"… 「似ている度合い」をベクトルの向きで測る。Jina 推奨index_options.type: "int8_hnsw"… 1 次元あたり 32bit → 8bit に量子化してメモリを 約 1/4 に。精度はほぼ変わらず、検索も速い
12 枚だと量子化の効果は実感しにくいですが、画像が 100 万件レベルになると検索コスト (latency / メモリ) にはっきり効いてきます。
ここで押さえたいキーワード ② — alias
Elasticsearch のインデックスには 「マッピングは一度作ったら基本変えられない」 という制約があります。次元数を変えたい、フィールドの型を変えたい、というときは新しいインデックスを作り直すしかありません。
そのとき困るのが「アプリのコードに image-search-poc-v1 というインデックス名を直接書いてしまっていると、毎回アプリも修正する必要がある」点です。
そこで alias (エイリアス) を使います。
一言でいうと、alias は「インデックスのあだ名」です。アプリ側は alias 名で読み書きを行い、Elasticsearch 側で裏の実体を差し替えられます。Twitter のユーザー名 (@handle) を変えずに中身のアカウント ID を移転するイメージです。
将来 Jina v6 が出て次元数が変わったときも、新しいインデックスを作って alias を切り替えるだけで、アプリ側のコードは触らなくて済みます。
Phase 3 — 画像を ingest する
./venv/bin/python ingest/upload_and_index.py /path/to/poc-imagesこのスクリプトが各画像に対して 4 つのことをします。
- ローカルファイルを S3 にアップロード (poc-uploads/<filename> として)
- 同じファイルを base64 にエンコード し、data:image/jpeg;base64,… という data URI 形式の文字列を作る
- Elastic の _inference API を 公式の content block 形式 で呼ぶ
- 返ってきた 1024 次元ベクトルを image_embedding フィールドに入れて index
ここで押さえたいキーワード ① — embedding (埋め込み)
まずはシンプルに考えると、embedding とは「テキストや画像を、意味を保ったまま高次元の数値ベクトルに変換すること」です。
近い意味のものは数学的に近いベクトルになります。
- “青い椅子” → [0.12, -0.34, 0.55, …, 0.08]
- “red chair” → [0.13, -0.32, 0.56, …, 0.07] ← 上とほぼ同じ
- “taxi” → [-0.45, 0.71, -0.02, …, 0.31] ← 全然違う
<青い椅子の写真> → [0.10, -0.31, 0.58, …, 0.05] ← “青い椅子” のテキストに近い
これがマルチモーダルモデルの肝です。「テキストの青い椅子」と「青い椅子の写真」が 同じベクトル空間で近い場所 に置かれるので、テキストで画像を引っ張れます。
ここで押さえたいキーワード ② — content block 形式
content block とは、API に「これは何の入力か (テキスト? 画像? 音声?)」を明示的に伝えるための構造化データのことです。HTTP の Content-Type: image/jpeg ヘッダーと同じ感覚で、「これから渡すデータの種類はこれだよ」と先に宣言します。
画像の embedding を取りたいときは、次の JSON 構造で送ります。
POST _inference/.jina-embeddings-v5-omni-small
{
"input": [
{
"content": {
"type": "image", // ① これは画像
"format": "base64", // ② base64 でエンコード済み
"value": "data:image/jpeg;base64,<RAW_BASE64>" // ③ 実データ
}
}
]
}守るべきポイントは 3 つ:
- content は 単一オブジェクト (配列ではない)
- value には data:image/jpeg;base64, という data URI プレフィックスを付ける
- 入力は base64 された画像データ本体 (S3 の URL ではない)
Python の実装
ingest/upload_and_index.py から抜粋。
import base64
b64 = base64.b64encode(image_path.read_bytes()).decode("ascii")
data_uri = f"data:{mime};base64,{b64}"
response = es.inference.inference(
inference_id=".jina-embeddings-v5-omni-small",
input=[
{
"content": {
"type": "image",
"format": "base64",
"value": data_uri,
}
}
],
)# レスポンスの key は “embeddings” (複数形)
# ただし Elastic バージョンによっては “text_embedding” や “embedding” のこともあるので、
embedding = None
for key in ("embeddings", "text_embedding", "embedding"): # 複数の key を順に試すフォールバックを書いておくと安全
if key in response and len(response[key]) > 0:
first = response[key][0]
embedding = first["embedding"] if isinstance(first, dict) else first
break
es.index(
index="image-search-poc", # alias 経由で書く
id=image_key,
document={
"image_key": image_key,
"name": image_path.stem,
"image_embedding": embedding,
},
refresh="wait_for",
)⚠️ よくある罠 — 画像 URL を文字列として送る
ここが 一番の落とし穴 です。「_inference の入力に画像 URL の文字列を渡せば、Elastic が裏でダウンロードして画像として処理してくれるのでは?」と思いがちですが、実際は次のように動きます。
# ❌ これは「URL の文字列」を embedding するだけ — 画像は読まれない
es.inference.inference(
inference_id=".jina-embeddings-v5-omni-small",
input=["https://my-bucket.s3.amazonaws.com/poc-uploads/IMG_8133.jpeg?..."]
)その結果、https, amazonaws, poc-uploads のような URL の単語が embedding に反映され、どの画像も似たようなベクトル になります。検索結果はランダムに見えるくらい滅茶苦茶になります。
詳しい失敗体験は「ハマったところ TOP 3」で書きます。
Phase 4 — CLI で検索を試す
./venv/bin/python tools/search_tools.py text "青い椅子"
./venv/bin/python tools/search_tools.py filename "IMG_8133.jpeg"
./venv/bin/python tools/search_tools.py text_in_image "RIDE"このスクリプトは後で MCP server から呼ばれるロジック本体でもあります。CLI で正しい結果が返ってくれば、ロジックは OK ということです。
ここで押さえたいキーワード ① — kNN (k-Nearest Neighbors)
まずはシンプルに考えると、kNN とは「ベクトル空間でクエリベクトルに最も近い k 個を見つける」アルゴリズムです。
「青い椅子」というクエリのベクトルから、保存されている画像ベクトルの中で 最も近い 3 つ を返してください、というのが kNN 検索の中身です。
クエリ "青い椅子" → ベクトル化 → [0.12, -0.34, ...]
↓
12 枚の画像ベクトルの中で
最も近い 3 つを返すElasticsearch の dense_vector フィールドはこれをネイティブにサポートしています。
ここで押さえたいキーワード ② — query_vector_builder
普通に書くと、「青い椅子」というクエリで検索するには 2 ステップが必要です。
- A. テキスト → ベクトルに変換 (_inference を呼ぶ)
- B. そのベクトルで kNN 検索 (_search を呼ぶ)
つまり Python から見ると 2 ラウンドトリップ。
ところが Elastic の _search には query_vector_builder という仕組みがあって、ベクトル化を _searchリクエストの中で Elastic 側に肩代わりさせられます。
result = es.search(
index="image-search-poc", # alias 経由
query={
"knn": {
"field": "image_embedding",
"k": 3,
"num_candidates": 50,
"query_vector_builder": {
"embedding": {
"inference_id": ".jina-embeddings-v5-omni-small",
"input": {"type": "text", "value": "青い椅子"}
}
}
}
},
size=3,
source=["image_key", "name"],
)クライアントから Elasticsearch へのリクエスト回数を 2 回から 1 回に減らせます。実際の latency は inference endpoint の応答時間や検索対象件数に依存しますが、クライアントコードと通信設計はかなりシンプルになります。
query_vector_builder.lookup — 画像 → 類似画像検索
「IMG_8133.jpeg に似た画像」のように、既にインデックスにある画像のベクトル をクエリとして使いたいときは lookup を使います。
{
"knn": {
"field": "image_embedding",
"k": 4,
"query_vector_builder": {
"lookup": {
"index": "image-search-poc",
"id": "poc-uploads/IMG_8133.jpeg",
"path": "image_embedding"
}
}
}
}「インデックスから別 doc の embedding を取り出して、それを query vector に使え」という意味です。「画像 → 類似画像」検索が 1 リクエストで完結します。
ここで押さえたいキーワード ③ — Pre-signed URL
S3 のプライベートオブジェクトに、期限付き でアクセスできる URL です。AWS のシークレットキーで署名されており、有効期限を秒数で指定できます。
本 PoC では 10 分有効 の pre-signed URL をチャット表示用に生成します (tools/search_tools.py の ExpiresIn=600)。
url = s3.generate_presigned_url(
"get_object",
Params={
"Bucket": S3_BUCKET_NAME,
"Key": "poc-uploads/IMG_8133.jpeg",
"ResponseContentType": "image/jpeg",
},
ExpiresIn=600, # 10 分
)生成された URL の例 (boto3 デフォルトの AWS Signature v4 形式):
?response-content-type=image%2Fjpeg
&X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=AKIA.../20260528/ap-northeast-1/s3/aws4_request
&X-Amz-Date=20260528T120000Z
&X-Amz-Expires=600
&X-Amz-SignedHeaders=host
&X-Amz-Signature=...X-Amz-Signature は秘密鍵で計算されています。URL を改ざんすると署名が合わなくなり、S3 は 403 を返します。
Phase 5 — Agent Builder + MCP でチャット化
ここからは Kibana の UI 操作と、ローカルの MCP server 起動の組み合わせです。
ここで押さえたいキーワード ① — Agent Builder
Kibana の中で LLM チャットエージェントを作る機能です。「カスタム指示 (system prompt)」と「ツール」を組み合わせて、エージェントが何をするかを決めます。
エージェントの仕事は 4 ステップ:
- ユーザーの質問を受け取る
- 質問の内容を見て、どのツールを呼ぶか LLM が決定
- ツールを呼んで結果を受け取る
- 結果を整形してユーザーに返す
ここで押さえたいキーワード ② — MCP (Model Context Protocol)
LLM エージェントが外部のツールを呼ぶ ための、Anthropic が提唱したオープンプロトコルです。Agent Builder もネイティブにサポートしています。
MCP server を立てると、その server に登録された関数が Agent Builder のツールとして見えるようになります。Python で書いた検索ロジックを、そのままチャットの中で呼べるという仕組みです。
Agent Builder のツールの 4 種類
Agent Builder では、エージェントが呼べるツールを 4 つのタイプで作れます。
| Type | できること | 適している用途 |
|---|---|---|
| ES|QL | 固定の ES|QL クエリにユーザー入力をパラメータとして渡す | 集計・フィルタ・テキスト検索 |
| Index search | LLM が自然言語からその場で ES|QL を生成 | 構造化データの探索的検索 |
| Workflow | 複数ステップの workflow を呼ぶ | 入力 → 処理 A → 処理 B のような連鎖 |
| MCP | 外部の MCP server を呼ぶ。Python など任意言語のロジック | カスタム検索ロジック、外部 API 連携 |
query_vector_builder を含む DSL クエリは ES|QL では表現できないため、本 PoC では MCP を選びました。
5-1. MCP server を書く
# agent/mcp_server.py
from fastmcp import FastMCP
from tools.search_tools import (
search_by_text, search_by_filename, search_by_text_in_image,
)
mcp = FastMCP("image-search-poc")
@mcp.tool()
def search_images_by_text(query: str) -> list:
"""画像を自然言語で意味検索する"""
return search_by_text(query)
@mcp.tool()
def search_images_by_filename(filename: str) -> list:
"""既存ファイル名から類似画像を検索する"""
return search_by_filename(filename)
@mcp.tool()
def search_images_by_visible_text(visible_text: str) -> list:
"""画像内の文字で検索する"""
return search_by_text_in_image(visible_text)
mcp.run(
transport="streamable-http",
host="127.0.0.1", # ← LAN からの直接アクセスを遮断 (セキュリティ)
port=8080,
)@mcp.tool() というデコレータを付けるだけで関数が MCP ツールとして公開されます。これが FastMCP の便利なところです。
5-2. ngrok でトンネルを開く
Elastic Cloud (インターネット側) は、ローカル開発機の localhost:8080 を直接見ることはできません。localhost は文字通り「自分の中だけのアドレス」だからです。
そこで ngrok を使います。ngrok は「インターネット側の公開 URL と、ローカルで動くサーバーをつなぐトンネル」を作るサービスです。

ここで大事なのは、ローカル側は何のポートも外部に開けないという点。ngrok エージェントがローカルから ngrok server に接続 (outbound) しているので、インターネット → ローカルの方向はその既存トンネルの中を逆流するように流れます。
5-3. 2 ターミナル運用
最初は ngrok と MCP server を 1 つのスクリプトで両方起動していました。しかし MCP server のコードを直して再起動するたびに ngrok の URL も変わってしまい(有料アカウントが違う)、その都度 Kibana 側のコネクタ設定を直す必要がありました。
解決策は 役割ごとにターミナルを分ける こと。
| ターミナル | 動かすもの | 再起動頻度 |
|---|---|---|
| A | ngrok だけ | 1 日 1 回 |
| B | MCP server だけ | 必要に応じて何度でも |
ngrok を立てっぱなしにすれば URL が変わらないので、Kibana 側の設定はそのまま使い続けられます。
ターミナル A: ngrok を起動
NGROK_AUTH="myuser:mypassword2026" bash agent/start_ngrok.sh引数の意味:
- NGROK_AUTH=”myuser:mypassword2026″ … Basic 認証の「ユーザー名:パスワード」を環境変数で渡す
- パスワードは 8 文字以上 が必須 (ngrok の制約)
- 省略するとスクリプトがランダムな 16 文字のパスワードを生成する
起動後の出力例:
🔐 Basic auth: myuser:mypassword2026
💾 認証情報は /tmp/mcp_demo_auth.txt に保存 (chmod 600)
Forwarding https://abc123-xyz.ngrok-free.app -> http://localhost:8080
ここで https://abc123-xyz.ngrok-free.app という URL をメモ します。後で Kibana の MCP コネクタ設定に貼り付けます。
ターミナル B: MCP server を起動 (コード変更ごと)
bash agent/start_mcp.sh起動後の出力例:
📡 MCP server starting on http://127.0.0.1:8080/mcp
INFO: Uvicorn running on http://127.0.0.1:8080 (Press CTRL+C to quit)
これで http://127.0.0.1:8080/mcp で MCP server が待ち受け、ngrok 経由で https://abc123-xyz.ngrok-free.app/mcp から到達できるようになります。
5-4. Kibana 側: エージェントを作る
- Kibana 左メニュー → Agents → New Agent
- Agent ID: image-search-agent
- Custom Instructions に agent/system_prompt.md の中身をコピペ
- Elastic capabilities: OFF (本 PoC では Kibana ビルトインの機能は使わない)
- Visibility: Private
- Save
system_prompt.md には、「ユーザーが何を入力したらどのツールを呼ぶか」「結果をどう整形するか」を細かく書いてあります。Kibana の CSP の関係で 画像は インライン埋め込みではなくクリッカブルリンクで出す という指示もここで入れています (詳細は後述のハマったところ参照)。
5-5. Kibana 側: MCP コネクタを登録
- Kibana → Tools library → Manage MCP → Add a new MCP server
- 次の値を入れる:
| 項目 | 値 | 意味 |
|---|---|---|
| Connector name | Image Search PoC | 表示名 (日本語 OK) |
| Connector ID | image-search-poc | 一意の識別子 (半角英数字とハイフンのみ) |
| Server URL | https://<NGROK_URL>.ngrok-free.app/mcp | ngrok の URL + /mcp suffix |
- 「Additional settings」を展開し、Add header を 2 回クリックして次の 2 行を追加:
| Key | Value | 意味 |
|---|---|---|
| Authorization | Basic bXl1c2Vy ***GFzc***mQyMDI2 | ngrok の basic auth を通すため |
| ngrok-skip-browser-warning | true | ngrok の警告ページをスキップするため |
Authorization の値の作り方
HTTP の Basic 認証は 「ユーザー名:パスワード」を base64 でエンコードした文字列 をヘッダーに入れます。
printf 'myuser:mypassword2026' | base64
# 出力: bXl1c2VyOm15cGFzc3dvcmQyMDI2注意: echo ではなく printf を使うこと。echo は末尾に改行を付けるので、計算結果が変わります。
完成形:
Authorization: Basic bXl1c2Vy***cGFzc***mQyMDI2
Basic の後に 半角スペース 1 つ が必要です。
ngrok-skip-browser-warning が必要な理由
ngrok の無料版は abuse 防止のため、ブラウザっぽい User-Agent からのリクエストに対して HTML の警告ページ (“You are about to visit:”) を返すことがあります。Elastic の MCP クライアントがこれを受け取ると、JSON ではなく HTML が来てしまい「Failed to load tools」エラーになります。
このヘッダーを付けると、ngrok は警告ページをスキップして直接 MCP server のレスポンスを返してくれます。値は何でも OK (true, 1, yes など、空でなければ通る)。
5-6. ツールをエージェントに有効化
- Save 後、ドロップダウンで Image Search PoC を選ぶ
- 3 つのツールが表示される (search_images_by_text 他 2 つ)
- すべてチェック → Namespace に image_search を入れて → Import tools
- Agents → image-search-agent → Tools タブで image_search.search_images_by_* を 3 つ有効化
- (推奨) デフォルトのビルトインツール 6 つは無効化
- Save
5-7. チャットで動作確認
エージェント画面の右上「Save and chat」を押します。
👤 青い椅子の写真を見せて
期待される応答:
- 3 件の結果カード
- 各カードに 🖼 画像を新しいタブで開く → というクリッカブルリンク
- 類似度スコア + マッチ理由
リンクをクリックすると別タブで画像が開きます。
ハマったところ TOP 4
「正しいやり方」だけを上で書きましたが、実際にはいくつもハマりました。記憶に残った 4 つを共有します。
罠 ①. 画像 URL をテキストとして送って 3 時間溶かした
症状: 異なる画像同士の embedding が cosine 0.94 (ほぼ同じ)、検索結果がランダム。「sea」で検索すると椅子と PC の写真が出てくる。
原因: S3 の pre-signed URL を _inference に 文字列として 渡していた。
# ❌ 悪い例
es.inference.inference(
inference_id=".jina-embeddings-v5-omni-small",
input=["https://my-bucket.s3.amazonaws.com/poc-uploads/IMG_8133.jpeg?..."]
)Elastic はこの URL を テキスト として embedding 化していました。Jina v5 omni の画像エンコーダ経路には入っておらず、URL の単語 (https, amazon, poc-uploads, …) が embedding に反映されていました。結果、どの画像も似たベクトルになります。
対策: 必ず base64 + content block 形式 で送る (Phase 3 のコード例参照)。
罠 ②. semantic_text フィールドに画像を入れたら大失敗
「semantic_text フィールドに base64 を入れれば自動で embedding できるはず」と仮説検証しました。しかし semantic_text は テキスト用のフィールド で、内部で base64 文字列を チャンク分割 してしまいます。
結果として、すべての画像が JPEG のヘッダーバイト列の共通性で似たベクトルになり、検索精度が壊滅しました。
学び: 今回のように画像そのものを multimodal embedding として扱う場合は、semantic_text に base64 文字列を入れるのではなく、embedding API で画像 embedding を作り、dense_vector に保存する構成が分かりやすく安全でした。
罠 ③. Kibana チャットで画像がインライン表示できない
症状: Markdown  で画像を埋めようとすると、チャットでは壊れた画像アイコンになる。新しいタブで開けば見られる。
原因: Kibana の Content Security Policy (CSP) が、外部ドメイン (s3.amazonaws.com) からの <img> をブロック。Elastic Cloud Serverless では CSP のカスタマイズが許可されていません。
対策: System Prompt で 「インライン埋め込みではなくクリッカブルリンク [label](url) を出す」 と指示する。本 PoC の agent/system_prompt.md はその指示込みになっています。
罠 ④. ngrok を再起動したら「Failed to load tools」が再発した
これは翌日に作業を再開したときによく起こります。
症状: 前日まで普通に動いていたのに、ngrok を一度落として再起動した直後、Kibana の Bulk import で 「Failed to load tools from the selected MCP server」 が表示される。MCP server も ngrok もログ上は正常。
原因: agent/start_ngrok.sh は環境変数 NGROK_AUTH が無いと 毎回ランダムな 16 文字のパスワード を生成します。前日に登録した Kibana の Authorization ヘッダーは 昨日のパスワード の base64 のままなので、ngrok 側で 401 Unauthorized → Elastic は HTML のエラーページを受け取り MCP プロトコルとして解析失敗、というカスケード。
対策: 2 つあります。お好みでどうぞ。
(a) パスワードを固定する ⭐ (おすすめ)
ngrok 起動時に毎回同じ NGROK_AUTH を渡せば、Kibana 側のヘッダーは触らなくて済みます。
NGROK_AUTH="myuser:mypassword2026" bash agent/start_ngrok.sh(b) 起動のたびに base64 を再計算する
ランダムパスワードのままにする場合、起動後にスクリプトが生成した認証情報を確認して、新しい base64 を Kibana に貼り直します。
cat /tmp/mcp_demo_auth.txt # 今回のパスワードを確認
printf "$(cat /tmp/mcp_demo_auth.txt)" | base64 # base64 を計算出力を Basic <新しい base64> に直して Kibana の Authorization ヘッダーを更新 → Save。
診断のコツ: 「Failed to load tools」が出たら、ターミナルから直接 curl で叩いてみると原因が切り分けられます。
curl -i -u "$(cat /tmp/mcp_demo_auth.txt)" \
-H "ngrok-skip-browser-warning: true" \- 401 Unauthorized → 認証情報が違う (この罠 ④)
- 404 Not Found → URL の末尾が /mcp になっていない
- HTML が返る → ngrok-skip-browser-warning ヘッダー漏れ
- 接続拒否 → MCP server が動いていない
運用 — 起動と停止のステップ
PoC が動くようになったら、次に気になるのは「どう起動するの? どう止めるの?」です。
📌 起動コマンド・出力例の詳細は §5-3 「2 ターミナル運用」 を参照。ここでは時間軸の流れと、Kibana 側で必要な操作に絞って整理します。
始めるとき
- ターミナル A で start_ngrok.sh を起動 (つけっぱなし) → 表示された ngrok URL をメモ
- NGROK_AUTH を固定値にしておくと、Kibana のヘッダーを毎回触らなくて済みます (罠 ④ 参照)
- ターミナル B で start_mcp.sh を起動
- Kibana → Tools library → Manage MCP → 既存の Image Search PoC コネクタを開く:
- Server URL を新しい ngrok URL に書き換え (free 版はセッションごとに変わるため)
- Authorization ヘッダーは NGROK_AUTH を固定値にしている限り 書き換え不要
- Save
- Kibana のチャット画面を開いて使う 🎉
コードを編集したら
Python の検索ロジックを直したり、MCP ツールを追加したいときは ターミナル B だけ Ctrl+C → start_mcp.sh を再実行。ngrok URL は変わらないので Kibana 側は何も触らなくて OK。再起動の間、ngrok は一瞬 502 Bad Gateway を返しますが、再起動が終われば自動で復活します。
仕事終わり
- ターミナル B で Ctrl+C → MCP server 停止
- ターミナル A で Ctrl+C → ngrok トンネル停止
- スクリプトのクリーンアップ処理で /tmp/mcp_demo_auth.txt も自動削除されます
💡 ngrok の有料プランに切り替えれば 固定の URL が使えるため、URL の付け替え作業自体が不要になります。チーム運用や本番に近い構成では Cloudflare Tunnel + Access への移行も選択肢です。
次のステップ
PoC が動いたら、次のことを試すと面白いです。
5-1. データセットに無い画像から類似画像を探す (リバース画像検索)
現状の search_by_filename は「既にインデックスにある画像のファイル名」からしか動きません。次のステップとして、「ユーザーが今撮った写真」をその場でアップロードして、データセット内の似た画像を返す フローが作れます。
新しい MCP ツール search_by_uploaded_image(image_base64: str) を追加し、内部で _inference を呼んでベクトル化 → kNN するだけです。
5-2. OCR と組み合わせて「画像内文字」検索の精度を上げる
現状の search_by_text_in_image は Jina のマルチモーダル能力に依存しています。「RIDE」というプロンプトを Jina に投げて視覚的に近い画像を探しているだけで、実際に画像の文字を読んでいるわけではありません。
OCR (AWS Textract / Tesseract) を ingest 時に走らせて extracted_text フィールドに保存し、RRF (Reciprocal Rank Fusion) で BM25 と kNN を統合すると、「本当に RIDE と書かれた画像」を確実に上位に出せます。
5-3. その他
- Cloudflare Tunnel + Access に移行 — チームで共有するなら、ngrok 個人運用から脱却
- AWS Lambda にデプロイ — Mac を起動しなくても 24/7 動く構成へ
- 画像のキャプション自動生成 — GPT-4V / Claude Vision でキャプションを description に保存
まとめ
このブログでは:
- マルチモーダル画像検索 を Elastic Cloud Serverless + Jina v5 Omni で実装
- query_vector_builder で Python のコードをシンプルに保ちつつ kNN を実行
- Agent Builder + MCP server で Kibana チャットからカスタム関数を呼べるように
- ngrok の 2 ターミナル運用 で日々の開発をスムーズに
PoC のコード総量は Python で 300 行強、構築時間は 2〜3 営業日でした。「ベクトル検索やマルチモーダルをやってみたい」という案件があれば、まずこの組み合わせを試すのが最短ルートだと思います。
ソースコード・詳細手順
- GitHub リポジトリ: test-jina-image
- 完全な手順書: リポジトリの README.md
- 設計の背景・経緯: リポジトリの WALKTHROUGH.md — 何にハマって何を学んだかを時系列で記録
参考リンク
- Elastic Search Labs — jina-embeddings-v5-omni for text, images, video, audio
- Elastic Docs — Inference embedding API
- Elastic Docs — Agent Builder Tools
- Model Context Protocol (MCP) 仕様
- FastMCP (Python MCP server framework)
- Elasticsearch × CLIP × GPTで画像検索システムを作ってみた
この記事は Elastic Cloud Serverless 9.5 / Jina v5 Omni small (2026-05-11 GA) / FastMCP 3.3.x / ngrok 3.x を使用した PoC に基づいています。質問・改善提案あれば気軽にどうぞ。


