Elastic Inference Service と Jina Embeddings v5 Omni で音声を検索する

Laptop with a burst of colorful shapes and particles emerging from the screen, overlaid with 'Tech Blog' and Japanese text about Elastic Inference Service and Jina Embeddings v5 Omni. BLOG

― マルチモーダル embedding の可能性と限界 ―

Elasticsearch のベクトル検索といえば、これまではテキストや画像が中心でした。 しかし最近は、テキスト・画像・動画・音声を同じ埋め込み空間で扱える「マルチモーダル embedding」が現実的な選択肢になってきています。

本記事は、Elastic Inference Service (以下 EIS) で利用できる .jina-embeddings-v5-omni-small を使い、音声ファイルを Elasticsearch に保存して kNN 検索でどこまで使えるかを検証した PoC のレポートです。

結論を先に書くと、次のとおりです。

  • 音声を embedding 化して Elasticsearch に保存し、kNN で検索する 基本パイプラインは問題なく動いた
  • 音声 → 音声(audio-to-audio) の類似検索は期待どおりに機能した
  • 一方で テキスト → 音声(text-to-audio) の意味検索は、今回の条件では十分な精度が出なかった

技術ブログとして、うまくいった部分だけでなく「なぜうまくいかなかったか」も合わせて共有します。同じような検証を計画している方の参考になれば幸いです。


マルチモーダル embedding と Jina v5 Omni

embedding とは

embedding とは、データを数値ベクトルに変換したものです。たとえば「ログインできない」と「アカウントに入れない」は意味が近いので、embedding にすると近い位置のベクトルになります。

ログインできない        → [0.12, -0.03, 0.45, …]

アカウントに入れない    → [0.11, -0.04, 0.46, …]   ← 近い

今日の天気は晴れです    → [0.92,  0.31, -0.20, …]  ← 遠い

これまでは、テキストはテキスト用モデル、画像は画像用モデルと、モダリティごとに別の embedding 空間を使うのが一般的でした。マルチモーダル embedding は、テキストも画像も音声も「同じ空間」に埋め込みます。同じ意味を持つテキストと音声が、空間上で近い位置に置かれることが理想です。

Jina Embeddings v5 Omni

今回使ったのは Jina AI のマルチモーダル embedding モデルです。Elastic Search Labs でも、テキスト・画像・動画・音声を 1 つの Elasticsearch インデックスに保存して横断的に検索できるモデルとして紹介されています。

EIS では preconfigured な inference endpoint として以下が利用できます。

  • .jina-embeddings-v5-omni-small(出力次元: 1024)

利用可能なモデルは、Dev Tools で GET _inference を実行することで確認できます。

ここで重要なのは、今回使用するモデルは ChatGPT のような 回答を生成する LLM ではなく、ベクトル変換のための embedding モデル だという点です。ここを混同すると後段の評価がブレるので、最初に押さえておきます。


なぜ音声を検索可能にしたいか

ビジネス的な動機は明確です。音声データの「中で何が話されているか」で検索したいというニーズは、現場にたくさんあります。

  • コールセンター音声から「ログインできない」と話している通話を見つけたい
  • 問い合わせ音声を内容で分類して FAQ を改善したい
  • 障害発生時に「画面が固まる」「決済できない」といった声が急増していないか確認したい
  • 顧客との会話音声から、契約・解約に関する文脈を後から探したい
  • 社内セミナーや会議音声から、必要な情報を探したい
  • 医療の現場で、音声カルテから何かを検索したい

これらは現在、文字起こし(transcript)してから text search で実現するのが普通です。マルチモーダル embedding が一定の精度で動くなら、文字起こしを介さずに 音声そのもの を検索対象に加えられる可能性があります。

また、音声データには機密情報が含まれることが多く、外部 API には投げにくいケースが少なくありません。Elastic 内で inference・indexing・検索・アクセス制御を統合できれば、データを外に出さずに音声検索基盤を構築できる点も大きなメリットです。


システム構成

今回の構成はシンプルです。

音声ファイル(.wav)

       │ Base64 エンコード

       ▼

Elastic Inference Service (.jina-embeddings-v5-omni-small)

       │ 1024 次元 embedding

       ▼

Elasticsearch (dense_vector field)

       │ kNN 検索

       ▼

類似音声 / 類似テキスト

クライアント側(今回はローカル Mac の Python スクリプト)では、音声ファイルを Base64 化して EIS に送るだけです。embedding 生成自体は EIS 側で実行されるため、GPU やモデル管理をローカルに持つ必要はありません。

インデックスの mapping

embedding を保存するインデックスの mapping は次のとおりです。

PUT audio-poc-jina-eis-v1
{
  "mappings": {
    "properties": {
      "audio_id": {
        "type": "keyword"
      },
      "file_name": {
        "type": "keyword"
      },
      "expected_topic": {
        "type": "keyword"
      },
      "audio_url": {
        "type": "keyword"
      },
      "embedding": {
        "type": "dense_vector",
        "dims": 1024,
        "index": true,
        "similarity": "cosine"
      },
      "created_at": {
        "type": "date"
      },
      "embedding_method": {
        "type": "keyword"
      }
    }
  }
}

設計のポイントは以下です。

  • dense_vector の dims: 1024 は Jina v5 Omni small の出力次元に合わせる
  • index: true を指定して kNN 検索の対象にする
  • 類似度関数は cosine を選択。ベクトルの長さよりも方向で比較するため、テキスト/音声 embedding では一般的な選択

検証データ

検証用に、短い日本語の問い合わせ音声を5つ用意しました。

ファイル 想定トピック話している内容
1.wavログインできない昨日から何度もログインしようとしていますが、正しいメールアドレスとパスワードを入力してもアカウントに入れません。
2.wavパスワード再設定パスワードを忘れてしまったので再設定メールを送ったのですが、メールが届かず手続きが進められません。
3.wavクレジットカード決済エラークレジットカードで支払いをしようとすると毎回エラーが出て、注文を完了できない状態です。
4.wavサービス画面の
フリーズ
サービスの画面が途中で固まってしまい、急ぎの作業ができないのでとても困っています。
5.wav契約プランと
請求金額の確認
契約内容について確認したいことがあるので、現在のプランと次回の請求金額を教えてください。

すべて 同じ話者・同じ録音条件 で作成しています。この条件が、後段で検索結果を解釈するうえで重要になります。

音声を embedding 化して保存する

本記事のコードは GitHub で公開しています: https://github.com/SIOS-Technology-Inc/elastic-blogs/tree/main/2026-05-15-test-jina-audio

ここは PoC の中核なので、少し丁寧にやったことの流れを追います。

Step 1: 音声を Base64 に変換する

.wav は バイナリファイル です。HTTP の JSON ボディには通常バイナリをそのまま載せられないので、まず「テキスト」に変換する必要があります。そのために使うのが Base64 です。Base64 は、任意のバイナリデータを ASCII 文字列で表現する方式で、画像や音声を API に渡すときの定番テクニックです。

def audio_to_base64(file_path: Path) -> str:
    with open(file_path, "rb") as f:
        return base64.b64encode(f.read()).decode("utf-8")

1.wav を変換すると、こんな長い文字列になります。

UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAA...

これで、音声を JSON に乗せられる形になりました。

Step 2: EIS に送って embedding を生成してもらう

次に、Base64 化した音声を Elasticsearch の inference API に送ります。EIS で公開されている embedding モデルは、以下の統一エンドポイントで呼び出します。

POST /_inference/embedding/{INFERENCE_ID}

{INFERENCE_ID} には、今回は .jina-embeddings-v5-omni-small を指定します。リクエストボディは1行だけです。

{
  "input": ["data:audio/wav;base64,UklGRiQAAABXQVZF..."]
}

ここでひとつ重要なのが、Base64 文字列の前についている data:audio/wav;base64, という接頭辞です。これは データ URI と呼ばれる形式で、「この入力は wav 音声データだよ」とモデルに伝える役割を持ちます。同じエンドポイントはテキストも受け付けるので、入力がテキストなのか音声なのかをこの接頭辞で判別しています。

Python で書くとこうなります。

def get_audio_embedding(file_path: Path) -> list:
    audio_b64 = audio_to_base64(file_path)
    # data URI プレフィックスで「これは音声」だとモデルに伝える
    audio_input = f"data:audio/wav;base64,{audio_b64}"

    response = es.perform_request(
        "POST",
        f"/_inference/embedding/{INFERENCE_ID}",
        headers={
            "Accept":       "application/json",
            "Content-Type": "application/json",
        },
        body={"input": [audio_input]},
    )
    return response["embeddings"][0]["embedding"]

実装で唯一ハマったポイントは、Acceptと Content-Typeの両方を指定する必要があった ことです。片方しか指定しないと互換バージョンと衝突して 400 エラーになることがありました。気づくまで少し時間を使ったので、同じことを試す方は注意してください。

Step 3: 返ってきた embedding を受け取る

EIS が返してくるレスポンスは次のような形です。

{
  "embeddings": [
    {
      "embedding": [0.012, -0.028, 0.103, -0.057, /* ...計1024個の数値... */]
    }
  ]
}

embeddings[0].embedding を取り出すと、長さ 1024 の float の配列 が手に入ります。これがその音声の「特徴を数値化したもの」です。人間には意味の分からない数値の羅列ですが、Elasticsearch にとってはこの 1024 個の数字が「似ているか」を判定する材料になります。

Step 4: メタデータと一緒に Elasticsearch に保存する

最後に、embedding をメタデータと組み合わせて、1つの ドキュメント として Elasticsearch に保存します。

indexed_doc = {
    ...
}

es.index(
    ...
)

これで、audio-poc-jina-eis-v1 インデックスには次のような JSON ドキュメントが保存されます。

{
  "audio_id":         "audio-001",
  "file_name":        "1.wav",
  "expected_topic":   "ログインできない",
  "embedding":        [0.012, -0.028, 0.103, /* ...計1024個... */],
  "embedding_method": "elastic_inference_jina_omni_small",
  "created_at":       "2026-..."
}

5 つの音声に対してこの処理を繰り返すと、Elasticsearch には 5 件の音声 embedding が並びます。あとは kNN クエリで「近いベクトル」を探すだけ、という状態になりました。


検証1: audio-to-audio 検索

最初に、音声 → 音声の類似検索を試しました。1.wav を query として、最も近い音声を探します。

検索 body はシンプルな kNN クエリです。

{
  "knn": {
    "field": "embedding",
    "query_vector": [/* query 音声の 1024 次元 embedding */],
    "k": 5,
    "num_candidates": 10
  },
  "_source": ["audio_id", "file_name", "expected_topic"]
}

結果

1.wav を query にした場合:

1位: 1.wav  score: 0.99999
2位: 3.wav score: 0.99142
3位: 5.wav score: 0.99125
4位: 4.wav score: 0.98703
5位: 2.wav score: 0.98342

次、5.wav を query にした場合:

1位: 5.wav  score: 1.0000001 ※

※ cosine 類似度ベースの kNN スコアは (1 + cos) / 2 で計算されるため理論上の上限は 1.0 です。query ベクトルとインデックス側ベクトルが同一の場合に、浮動小数点演算の丸め誤差で形式上 1.0 をわずかに超えた値が返ることがあります。実質 1.0 と読み替えてください。

期待どおり、query にした音声自身が 1 位になりました。音声 → 音声の類似検索は正しく動作している と判断できます。

ただし注目すべきは 2 位以下のスコアです。すべて 0.98〜0.99 という非常に高い値に集中しています。1 位は明確に分離できるものの、それ以外は ほぼ団子状態 です。この観察は、次の text-to-audio の議論の伏線になります。


検証2: text-to-audio 検索

次に、日本語テキスト query から音声を探します。クエリは同じく kNN ですが、query_vector を「テキストから生成した embedding」に差し替えます。クライアント側のコードはほぼ同じで、入力をテキストに切り替えるだけです。

結果

query: ログインできない (期待: 1.wav が1位)

1位: 5.wav (契約プランと請求金額の確認)   score: 0.54343
2位: 1.wav (ログインできない) score: 0.54301
3位: 3.wav (クレジットカード決済エラー) score: 0.54214

少し長めの query でも試しました。

query: 正しいメールアドレスとパスワードを入力してもアカウントに入れません

1位: 5.wav
2位: 1.wav

期待した 1.wav は 1 位になりませんでした。さらに、上位 3 件のスコアが 0.543 前後に密集しており、意味的な分離がほとんど効いていないことが分かります。

つまり、今回の条件では text-to-audio 検索の精度は実用レベルに届かなかった ということです。


なぜ text-to-audio は弱かったのか

この PoC で最も重要なメッセージは、ここにあります。「マルチモーダルであれば何でも検索可能」というわけではない、という現実を共有できるかもしれません。

考えられる要因は複数あります。

1. 検証データが「音響的に似すぎている」

今回の 5 つの音声は、すべて以下の条件で作られています。

  • 同じ話者
  • 同じ口調
  • 同じ録音条件(マイク、室内環境、無音区間の入り方)
  • 同じくらいの長さ(10〜15 秒)
  • 同じ「問い合わせ口調」の文体

audio embedding は、内容(何を話しているか)だけでなく、話者の声質・録音条件・話すトーン・無音区間 といった音響的な特徴も拾います。今回のように音響条件が似すぎたデータでは、内容の違いより「音声としての雰囲気の共通性」が embedding を支配しやすくなります。audio-to-audio 検索で 2 位以下のスコアが 0.98 台に集中していたのは、まさにこの影響と整合します。

2. テキストと音声の意味空間が完全には揃っていない

マルチモーダル embedding は「同じ意味のテキストと音声を近づける」ように学習されていますが、その整合性の強さは学習データやモデルサイズに依存します。

今回使ったのは omni-small(軽量モデル) です。日本語の短い問い合わせ音声で text-to-audio の対応関係を十分捉えられるかは、未知数の領域です。スコア差が 0.001 オーダーしかなくノイズに埋もれている状態は、まさにこの「整合性が弱い」状態と読めます。

3. 音声が短く、意味的な信号が少ない

各音声は 10〜15 秒程度です。短い音声ほど、テキスト query との意味的なマッチングに使える信号は少なくなります。数十秒〜数分の音声であれば、もう少し違う結果になった可能性があります。

まとめ

audio-to-audio が動いて text-to-audio が弱かったのは「実装ミス」ではなく、マルチモーダル embedding の現実的な特性と、検証データの条件が組み合わさった結果 だと考えています。

ここから引き出せる教訓はシンプルです。「ベクトル検索の品質は、モデルだけでなく、データの性質と用途の組み合わせで決まる」。これはマルチモーダルに限らず、テキスト embedding でも同じことが言えます。


改善方針: transcript と組み合わせた Hybrid Retrieval

実務で音声検索を本気で組むなら、音声 embedding 単体で頑張るより、文字起こし(transcript)を併用する ほうが圧倒的に現実的です。

具体的にはこんな構成を考えています。

音声ファイル
├─ audio embedding (audio-to-audio 検索用)
├─ transcript text (BM25 / lexical 検索用)
├─ transcript embedding (semantic 検索用)
└─ メタデータ (時間、顧客ID、カテゴリなど)


Elasticsearch


Hybrid Retrieval (RRF などで結果を統合)

Elasticsearch には、複数の retriever の結果を統合する仕組みとして Reciprocal Rank Fusion (RRF) があります。これを使うと、以下を 1 リクエストで束ねられます。

  • 音声 embedding による kNN(似た音声を探す)
  • transcript embedding による kNN(意味の近い発話を探す)
  • transcript への BM25(キーワード検索)
  • メタデータでの filter(期間、顧客カテゴリなど)

ユーザーが「ログインできない」と検索したとき、文字起こしテキスト側がしっかり 1.wav にマッチしてくれるはずです。音声 embedding はその上で「似た声質・トーンの問い合わせ」を補強する役割で使う、という役割分担が現実的です。

次のステップとして、この hybrid 構成を実装し、再度同じ query で評価してみる予定です。


まとめ

今回の PoC で確認できたことは次のとおりです。

  • EIS 経由で音声 embedding を生成できる — .jina-embeddings-v5-omni-small に Base64 音声を送ると 1024 次元の embedding が返る
  • dense_vector に保存して kNN 検索できる — マルチモーダル検索の土台は問題なく動く
  • audio-to-audio 検索は実用的に機能する — 同じ音声を query にすればその音声が 1 位に返る
  • text-to-audio 検索は今回の条件では弱かった — 短く・音響条件が似た音声群では、テキスト query との意味的な分離が困難

ここから得た一番の学びは、「マルチモーダル embedding は万能ではなく、ユースケースに合わせて他の検索手段と組み合わせて使うもの」 という現実です。


参考資料