Jina v5 Omni × Agent Builder × MCP で組み立てるマルチモーダル画像検索 PoC 📦 ソースコード : GitHub リポジトリ 14 枚の写真を Elastic に登録するだけで、Kibana のチャットから 「青い椅子の写真を見せて」 と日本語で問いかけると、本当に青い椅子が写った写真が返ってくる。 しかも フロントエンドコードはゼロ行 。Python 300 行と Elastic Cloud のクリック数回で完成します。 このブログでは、その PoC をどう組み立てたか、ステップ・バイ・ステップで紹介します。 目次 1. 実装する 3 つの検索モード 完成形の応答イメージ 想定読者 所要時間の目安 2. なぜ作ったか 自前構成と Serverless で、どこに差が出るのか 3. 全体アーキテクチャ 3-1. コンポーネントの全体図 3-2. 5 つのコンポーネントの役割 4. 準備するもの アカウント (すべて無料枠で OK) ローカル環境 (macOS 想定) 画像 Phase 0 — 環境準備 0-1. プロジェクトの構成 0-2. Elastic Cloud Serverless プロジェクトを作る 0-3. ELASTIC_URL を取得する 0-4. ELASTIC_API_KEY を作る 0-5. AWS IAM ユーザーを準備 0-6. .env を作る 0-7. 環境チェック Phase 1 — S3 バケットを作る ここで押さえたいキーワード: SSE-S3 とは Phase 2 — Elastic インデックスを作る マッピング (フィールド定義) ここで押さえたいキーワード ① — dense_vector ここで押さえたいキーワード ② — alias Phase 3 — 画像を ingest する ここで押さえたいキーワード ① — embedding (埋め込み) ここで押さえたいキーワード ② — content block 形式 Python の実装 ⚠️ よくある罠 — 画像 URL を文字列として送る Phase 4 — CLI で検索を試す ここで押さえたいキーワード ① — kNN (k-Nearest Neighbors) ここで押さえたいキーワード ② — query_vector_builder query_vector_builder.lookup — 画像 → 類似画像検索 ここで押さえたいキーワード ③ — Pre-signed URL Phase 5 — Agent Builder + MCP でチャット化 ここで押さえたいキーワード ① — Agent Builder ここで押さえたいキーワード ② — MCP (Model Context Protocol) Agent Builder のツールの 4 種類 5-1. MCP server を書く 5-2. ngrok でトンネルを開く 5-3. 2 ターミナル運用 ターミナル A: ngrok を起動 ターミナル B: MCP server を起動 (コード変更ごと) 5-4. Kibana 側: エージェントを作る 5-5. Kibana 側: MCP コネクタを登録 Authorization の値の作り方 ngrok-skip-browser-warning が必要な理由 5-6. ツールをエージェントに有効化 5-7. チャットで動作確認 ハマったところ TOP 4 罠 ①. 画像 URL をテキストとして送って 3 時間溶かした 罠 ②. semantic_text フィールドに画像を入れたら大失敗 罠 ③. Kibana チャットで画像がインライン表示できない 罠 ④. ngrok を再起動したら「Failed to load tools」が再発した 運用 — 起動と停止のステップ 始めるとき コードを編集したら 仕事終わり 次のステップ 5-1. データセットに無い画像から類似画像を探す (リバース画像検索) 5-2. OCR と組み合わせて「画像内文字」検索の精度を上げる 5-3. その他 まとめ ソースコード・詳細手順 参考リンク 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.sh 0-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.clo ud 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" \ https://.ngrok-free.app/mcp .ngrok-free.app 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 に基づいています。質問・改善提案あれば気軽にどうぞ。 The post Elastic Cloud で「言葉で画像を探す」を作る first appeared on Elastic Portal .