Elastic Cloud で「言葉で画像を探す」を作る

Tech Blog banner showing a laptop with a colorful explosion bursting from the screen, with 'Tech Blog' and Japanese text overlaid. BLOG

Jina v5 Omni × Agent Builder × MCP で組み立てるマルチモーダル画像検索 PoC

📦 ソースコード: GitHub リポジトリ


14 枚の写真を Elastic に登録するだけで、Kibana のチャットから 「青い椅子の写真を見せて」 と日本語で問いかけると、本当に青い椅子が写った写真が返ってくる。

しかも フロントエンドコードはゼロ行。Python 300 行と Elastic Cloud のクリック数回で完成します。

このブログでは、その PoC をどう組み立てたか、ステップ・バイ・ステップで紹介します。

  1. 1. 実装する 3 つの検索モード
    1. 完成形の応答イメージ
    2. 想定読者
    3. 所要時間の目安
  2. 2. なぜ作ったか
    1. 自前構成と Serverless で、どこに差が出るのか
  3. 3. 全体アーキテクチャ
    1. 3-1. コンポーネントの全体図
    2. 3-2. 5 つのコンポーネントの役割
  4. 4. 準備するもの
    1. アカウント (すべて無料枠で OK)
    2. ローカル環境 (macOS 想定)
    3. 画像
  5. Phase 0 — 環境準備
    1. 0-1. プロジェクトの構成
    2. 0-2. Elastic Cloud Serverless プロジェクトを作る
    3. 0-3. ELASTIC_URL を取得する
    4. 0-4. ELASTIC_API_KEY を作る
    5. 0-5. AWS IAM ユーザーを準備
    6. 0-6. .env を作る
    7. 0-7. 環境チェック
  6. Phase 1 — S3 バケットを作る
    1. ここで押さえたいキーワード: SSE-S3 とは
  7. Phase 2 — Elastic インデックスを作る
    1. マッピング (フィールド定義)
    2. ここで押さえたいキーワード ① — dense_vector
    3. ここで押さえたいキーワード ② — alias
  8. Phase 3 — 画像を ingest する
    1. ここで押さえたいキーワード ① — embedding (埋め込み)
    2. ここで押さえたいキーワード ② — content block 形式
    3. Python の実装
    4. ⚠️ よくある罠 — 画像 URL を文字列として送る
  9. Phase 4 — CLI で検索を試す
    1. ここで押さえたいキーワード ① — kNN (k-Nearest Neighbors)
    2. ここで押さえたいキーワード ② — query_vector_builder
    3. query_vector_builder.lookup — 画像 → 類似画像検索
    4. ここで押さえたいキーワード ③ — Pre-signed URL
  10. Phase 5 — Agent Builder + MCP でチャット化
    1. ここで押さえたいキーワード ① — Agent Builder
    2. ここで押さえたいキーワード ② — MCP (Model Context Protocol)
    3. Agent Builder のツールの 4 種類
    4. 5-1. MCP server を書く
    5. 5-2. ngrok でトンネルを開く
    6. 5-3. 2 ターミナル運用
      1. ターミナル A: ngrok を起動
      2. ターミナル B: MCP server を起動 (コード変更ごと)
    7. 5-4. Kibana 側: エージェントを作る
    8. 5-5. Kibana 側: MCP コネクタを登録
      1. Authorization の値の作り方
      2. ngrok-skip-browser-warning が必要な理由
    9. 5-6. ツールをエージェントに有効化
    10. 5-7. チャットで動作確認
  11. ハマったところ TOP 4
    1. 罠 ①. 画像 URL をテキストとして送って 3 時間溶かした
    2. 罠 ②. semantic_text フィールドに画像を入れたら大失敗
    3. 罠 ③. Kibana チャットで画像がインライン表示できない
    4. 罠 ④. ngrok を再起動したら「Failed to load tools」が再発した
  12. 運用 — 起動と停止のステップ
    1. 始めるとき
    2. コードを編集したら
    3. 仕事終わり
  13. 次のステップ
    1. 5-1. データセットに無い画像から類似画像を探す (リバース画像検索)
    2. 5-2. OCR と組み合わせて「画像内文字」検索の精度を上げる
    3. 5-3. その他
  14. まとめ
    1. ソースコード・詳細手順
    2. 参考リンク

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 つありました。

  1. 以前試していた画像検索を、今度は Jina の Omni モデルでどう作れるか見てみたかった。
  2. Agent Builder にこの検索ロジックを乗せられるかを知りたかった。
  3. 日本語の指示も理解してくれるか、本当に試したかった。

この 3 つを一気に検証できる最小構成として、本 PoC を作りました。結果としては、Jina v5 Omni + query_vector_builder + Agent Builder + MCP の組み合わせで、フロントエンドコード 0 行・Python 約 300 行 で日本語チャット画像検索が成立することを確認できました。

自前構成と Serverless で、どこに差が出るのか

この構成を自前で作ると、何が大変になるのか

ここで比較したいのは、単純な「OSS か、有料か」ではありません。もう少し正確に言うと、次の 3 つの違いです。

  1. 自前サーバー + 無償中心の Elastic
  2. 自前サーバー + Elastic の有償エディション / サブスクリプション
  3. 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 つにあります。

  1. モデルのホスト先を自分で持たなくてよいこと
  2. LLM チャットの土台を自分で作らなくてよいこと
  3. アップデート対応の負担を減らせること
  4. 検索サービスを 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)

ローカル環境 (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 プロジェクトを作る

  1. https://cloud.elastic.co を開く
  2. 「Create project」 → タイプは Elasticsearch を選ぶ
  3. リージョン: Tokyo (ap-northeast-1)
  4. 名前: image-search-poc
  5. 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 を作る

  1. Kibana → Stack ManagementAPI keysCreate API key
  2. 名前: image-search-poc、権限はデフォルト(PoC では簡略化のため)
  3. 表示された 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 つ。

  1. バケットを東京リージョンに作成
  2. Block Public Access を 4/4 すべて有効化
  3. SSE-S3 暗号化 を有効化
  4. バージョニング を有効化

実行後、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 つのことをします。

  1. 実体インデックス image-search-poc-v1 を作る
  2. 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 つのことをします。

  1. ローカルファイルを S3 にアップロード (poc-uploads/<filename> として)
  2. 同じファイルを base64 にエンコード し、data:image/jpeg;base64,… という data URI 形式の文字列を作る
  3. Elastic の _inference API を 公式の content block 形式 で呼ぶ
  4. 返ってきた 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 ステップ:

  1. ユーザーの質問を受け取る
  2. 質問の内容を見て、どのツールを呼ぶか LLM が決定
  3. ツールを呼んで結果を受け取る
  4. 結果を整形してユーザーに返す

ここで押さえたいキーワード ② — 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 searchLLM が自然言語からその場で 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 側のコネクタ設定を直す必要がありました。

解決策は 役割ごとにターミナルを分ける こと。

ターミナル動かすもの再起動頻度
Angrok だけ1 日 1 回
BMCP 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 側: エージェントを作る

  1. Kibana 左メニュー → AgentsNew Agent
  2. Agent ID: image-search-agent
  3. Custom Instructions に agent/system_prompt.md の中身をコピペ
  4. Elastic capabilities: OFF (本 PoC では Kibana ビルトインの機能は使わない)
  5. Visibility: Private
  6. Save

system_prompt.md には、「ユーザーが何を入力したらどのツールを呼ぶか」「結果をどう整形するか」を細かく書いてあります。Kibana の CSP の関係で 画像は インライン埋め込みではなくクリッカブルリンクで出す という指示もここで入れています (詳細は後述のハマったところ参照)。

5-5. Kibana 側: MCP コネクタを登録

  1. Kibana → Tools libraryManage MCPAdd a new MCP server
  2. 次の値を入れる:
項目意味
Connector nameImage Search PoC表示名 (日本語 OK)
Connector IDimage-search-poc一意の識別子 (半角英数字とハイフンのみ)
Server URLhttps://<NGROK_URL>.ngrok-free.app/mcpngrok の URL + /mcp suffix
  1. 「Additional settings」を展開し、Add header を 2 回クリックして次の 2 行を追加:
KeyValue意味
AuthorizationBasic bXl1c2Vy ***GFzc***mQyMDI2ngrok の basic auth を通すため
ngrok-skip-browser-warningtruengrok の警告ページをスキップするため

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. ツールをエージェントに有効化

  1. Save 後、ドロップダウンで Image Search PoC を選ぶ
  2. 3 つのツールが表示される (search_images_by_text 他 2 つ)
  3. すべてチェック → Namespace に image_search を入れて → Import tools
  4. Agents → image-search-agent → Tools タブで image_search.search_images_by_* を 3 つ有効化
  5. (推奨) デフォルトのビルトインツール 6 つは無効化
  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 ![](url) で画像を埋めようとすると、チャットでは壊れた画像アイコンになる。新しいタブで開けば見られる。

原因: 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
  • 401 Unauthorized → 認証情報が違う (この罠 ④)
  • 404 Not Found → URL の末尾が /mcp になっていない
  • HTML が返る → ngrok-skip-browser-warning ヘッダー漏れ
  • 接続拒否 → MCP server が動いていない

運用 — 起動と停止のステップ

PoC が動くようになったら、次に気になるのは「どう起動するの? どう止めるの?」です。

📌 起動コマンド・出力例の詳細は §5-3 「2 ターミナル運用」 を参照。ここでは時間軸の流れと、Kibana 側で必要な操作に絞って整理します。

始めるとき

  1. ターミナル A で start_ngrok.sh を起動 (つけっぱなし) → 表示された ngrok URL をメモ
    • NGROK_AUTH を固定値にしておくと、Kibana のヘッダーを毎回触らなくて済みます (罠 ④ 参照)
  2. ターミナル B で start_mcp.sh を起動
  3. Kibana → Tools libraryManage MCP → 既存の Image Search PoC コネクタを開く:
    • Server URL を新しい ngrok URL に書き換え (free 版はセッションごとに変わるため)
    • Authorization ヘッダーは NGROK_AUTH を固定値にしている限り 書き換え不要
    • Save
  4. Kibana のチャット画面を開いて使う 🎉

コードを編集したら

Python の検索ロジックを直したり、MCP ツールを追加したいときは ターミナル B だけ Ctrl+C → start_mcp.sh を再実行。ngrok URL は変わらないので Kibana 側は何も触らなくて OK。再起動の間、ngrok は一瞬 502 Bad Gateway を返しますが、再起動が終われば自動で復活します。

仕事終わり

  1. ターミナル B で Ctrl+C → MCP server 停止
  2. ターミナル 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 に保存

まとめ

このブログでは:

  1. マルチモーダル画像検索 を Elastic Cloud Serverless + Jina v5 Omni で実装
  2. query_vector_builder で Python のコードをシンプルに保ちつつ kNN を実行
  3. Agent Builder + MCP server で Kibana チャットからカスタム関数を呼べるように
  4. ngrok の 2 ターミナル運用 で日々の開発をスムーズに

PoC のコード総量は Python で 300 行強、構築時間は 2〜3 営業日でした。「ベクトル検索やマルチモーダルをやってみたい」という案件があれば、まずこの組み合わせを試すのが最短ルートだと思います。

ソースコード・詳細手順

  • GitHub リポジトリ: test-jina-image
  • 完全な手順書: リポジトリの README.md
  • 設計の背景・経緯: リポジトリの WALKTHROUGH.md — 何にハマって何を学んだかを時系列で記録

参考リンク


この記事は Elastic Cloud Serverless 9.5 / Jina v5 Omni small (2026-05-11 GA) / FastMCP 3.3.x / ngrok 3.x を使用した PoC に基づいています。質問・改善提案あれば気軽にどうぞ。