0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EmbeddingGemma 2 を Mac で動かし、テキスト・画像・音声・動画をまとめて日本語で検索してみた

0
Last updated at Posted at 2026-10-19
  • 2026-10-06 に公開された EmbeddingGemma 2(740M、Apache 2.0)は、テキスト・画像・音声・動画を同じ 768 次元の空間に埋め込めるモデル。Apple M3 の Mac でも、API キーなしで動いた
  • テキスト 3 件・画像 2 件・音声 2 件・動画 1 件を同じ索引に入れ、日本語の 6 問で 1 回ずつ検索した。今回の検証では、画像は 2 問とも狙いどおり 1 位になったが、テキストが混ざると「猫の生態」が 6 問中 5 問で上位 3 件に入り、関係のない「明日の天気」でも 1 位になった。画像・音声・動画だけに絞ると、6 問中 5 問で狙ったものが 1 位になった
  • 128 次元に切り詰めても、1 位は 6 問中 5 問で 768 次元と同じだった。件数が少ない検証なので、実際に使うときは自分のデータで精度を確かめる必要がある

背景

埋め込みモデル(文章などを、意味の近さを測れる数値のベクトルに変えるモデル)は、検索や RAG(検索した資料を LLM に渡して答えさせる方法)の土台です。

2026-10-06 に、Google DeepMind が EmbeddingGemma 2 を公開しました(Google 公式ブログ)。特徴は次のとおりです(モデルカード・公式ブログより)。

項目 内容
サイズ 740M パラメータ(Gemma 4 ベース)。テキストだけなら約 270M で、画像用 170M・音声用 300M のエンコーダーは必要なときだけ使う
入力 テキスト(コードも含む)・画像・音声・動画。混ぜて 1 つの入力にもできる
出力 768 次元。Matryoshka(ベクトルの先頭だけを切り出しても使えるように学習する方法)で 512・256・128 次元にも縮められる
入力の長さ 全種類あわせて 8,192 トークン。画像は 1 枚 280 トークン、動画は 1 フレーム 140 トークン(既定は 1 秒 1 枚)、音声は 1 秒 25 トークン
ライセンス Apache 2.0

これまでの EmbeddingGemma はテキスト専用でした。今回は画像・音声・動画が同じ空間に入るので、「日本語の文章で動画や音声を探す」ことがローカルでできるはずです。実際に試してみました。

試してみる

やること

次の 8 件を 1 つの索引に入れ、日本語のテキストで検索します。

種類 中身
テキスト 「猫の生態」「金門橋の歴史」「パスタのゆで方」の短い文章 3 件
画像 金門橋の写真(GoldenGate.png)、キッチンのイラスト(kitchen_painting.jpg)
音声 英語で詩を読む声(roses-are.wav)、日本語の天気予報(weather_ja.wav)
動画 猫がうたた寝している 30 秒の動画(cat_dozing.mp4)

画像 2 枚・英語の音声・動画は、公式チュートリアルが使っているサンプルです。日本語の天気予報は、macOS の say コマンドで作りました。素材は prepare_media.sh でダウンロード・生成します(この記事のリポジトリには含めていません)。

検索のときは、モデルカードの推奨どおり、クエリに task: search result | query: 、テキストのドキュメントに title: none | text: を付けます(sentence-transformers の prompt_name="SearchQuery" と prompt_name="Document")。画像・音声・動画には何も付けません(モデルカード)。

動作環境

  • OS: macOS 26.6.2(Apple M3、メモリ 24GB)
  • Python: 3.12.15
  • 主要ライブラリ: sentence-transformers==6.1.0, transformers==5.20.1, torch==2.14.1, av==19.0.1(間接依存も含めて requirements.txt で固定)
  • 実行デバイス: MPS(Apple GPU)、bfloat16
  • 実行日: 2026-10-18

セットアップ

cd code
uv venv --python 3.12 .venv
uv pip install --python .venv -r requirements.txt
./prepare_media.sh   # 素材を media/ に用意する
./run.sh             # 初回はモデル(約 1.5GB)を Hugging Face から自動でダウンロードする

API キーは不要です。モデルは gated(利用申請が必要なモデル)ではないので、Hugging Face にログインしなくてもダウンロードできました。

つまずいた点:音声と動画のファイルをそのまま渡すと FFmpeg が必要

公式チュートリアルでは、model.encode({"audio": "音声の URL"}) のように URL を渡しています(ファイルのパスも渡せます)。ただし音声と動画をこの形で渡すと、最初は torchcodec(音声・動画の読み込みライブラリ)がないというエラーになりました。チュートリアルの install コマンドどおり torchcodec を入れると、今度は次のエラーになりました。

RuntimeError Could not load libtorchcodec. Likely causes:
          1. FFmpeg is not properly installed in your environment.

ファイルの読み込みに使う torchcodec が、システムに入った FFmpeg を必要とするためです。今回は FFmpeg をシステムに入れず、自分でデコードして渡しました。

  • 音声: PyAV(FFmpeg を同梱した Python パッケージ)で 16kHz・モノラル・float32 の波形にして渡す
  • 動画: PyAV で 1 秒に 1 枚のフレームを取り出し、PIL 画像のリストで渡す

どちらの形でも渡せることは、公式チュートリアルの音声・動画の節に書かれています。このため、最終的な requirements.txt には torchcodec を入れていません。

コード

"""EmbeddingGemma 2 で、テキスト・画像・音声・動画をひとつの検索インデックスに入れて、日本語のテキストで検索する。

素材は prepare_media.sh で media/ に用意する。
"""
import platform
import time
from importlib.metadata import version

import av
import numpy as np
from sentence_transformers import SentenceTransformer

MODEL_ID = "google/embeddinggemma-2"

# 検索対象(ドキュメント)。(種類, 説明用のラベル, モデルへの入力)
TEXT_DOCS = [
    ("text", "猫の生態", "猫は一日の大半を眠って過ごす動物です。"),
    ("text", "金門橋の歴史", "サンフランシスコのゴールデンゲートブリッジは 1937 年に開通した吊り橋です。"),
    ("text", "パスタのゆで方", "パスタをゆでるときは、お湯にたっぷりの塩を入れます。"),
]
IMAGE_DOCS = [
    ("image", "GoldenGate.png", "media/GoldenGate.png"),
    ("image", "kitchen_painting.jpg", "media/kitchen_painting.jpg"),
]
AUDIO_DOCS = [
    ("audio", "roses-are.wav(英語の詩)", "media/roses-are.wav"),
    ("audio", "weather_ja.wav(日本語の天気予報)", "media/weather_ja.wav"),
]
VIDEO_DOCS = [
    ("video", "cat_dozing.mp4", "media/cat_dozing.mp4"),
]

QUERIES = [
    "うたた寝している猫の映像",
    "赤い吊り橋の写真",
    "キッチンを描いた絵",
    "明日の天気",
    "詩を朗読する声",
    "料理のコツ",
]


def load_audio(path: str) -> np.ndarray:
    """音声ファイルを 16kHz・モノラル・float32 の波形にする(モデルカードの推奨形式)。"""
    resampler = av.AudioResampler(format="flt", layout="mono", rate=16000)
    chunks = []
    with av.open(path) as container:
        for frame in container.decode(audio=0):
            for out in resampler.resample(frame):
                chunks.append(out.to_ndarray().reshape(-1))
        for out in resampler.resample(None):  # 残りを出し切る
            chunks.append(out.to_ndarray().reshape(-1))
    return np.concatenate(chunks).astype(np.float32)


def load_video_frames(path: str, fps: float = 1.0) -> list:
    """動画から 1 秒に 1 枚のフレームを取り出す(モデルの既定のサンプリングと同じ 1 fps)。"""
    frames, next_t = [], 0.0
    with av.open(path) as container:
        for frame in container.decode(video=0):
            if frame.time is not None and frame.time >= next_t:
                frames.append(frame.to_image())
                next_t += 1 / fps
    return frames


def build_inputs() -> tuple[list, list]:
    labels, inputs = [], []
    for kind, label, text in TEXT_DOCS:
        labels.append((kind, label))
        inputs.append(text)
    for kind, label, path in IMAGE_DOCS:
        labels.append((kind, label))
        inputs.append({"image": path})
    for kind, label, path in AUDIO_DOCS:
        labels.append((kind, label))
        inputs.append({"audio": load_audio(path)})
    for kind, label, path in VIDEO_DOCS:
        labels.append((kind, label))
        frames = load_video_frames(path)
        print(f"  {label}: {len(frames)} フレームを取り出した")
        inputs.append({"video": frames})
    return labels, inputs


def encode_docs(model: SentenceTransformer, labels: list, inputs: list) -> np.ndarray:
    """ドキュメントを 1 件ずつ埋め込む。テキストには Document の接頭辞を付け、画像・音声・動画には付けない。"""
    embs = []
    for (kind, label), x in zip(labels, inputs):
        t = time.perf_counter()
        if kind == "text":
            emb = model.encode(x, prompt_name="Document")
        else:
            emb = model.encode(x)
        print(f"  {kind:<5} {label:<36} {time.perf_counter() - t:5.2f} 秒")
        embs.append(emb)
    return np.stack(embs)


def search(model, query_embs, doc_embs, labels, top_k=3, dim=None) -> list[str]:
    """クエリごとに上位 top_k 件を表示し、1 位のラベルを返す。dim を指定すると先頭 dim 次元に切り詰める。"""
    if dim is not None:
        query_embs = query_embs[:, :dim]
        doc_embs = doc_embs[:, :dim]
        # 切り詰めたら長さ 1 に正規化し直す(モデルカードの注意事項)
        query_embs = query_embs / np.linalg.norm(query_embs, axis=1, keepdims=True)
        doc_embs = doc_embs / np.linalg.norm(doc_embs, axis=1, keepdims=True)
    scores = model.similarity(query_embs, doc_embs).numpy()
    top1 = []
    for q, row in zip(QUERIES, scores):
        order = np.argsort(-row)[:top_k]
        print(f"\nQ: {q}")
        for rank, i in enumerate(order, 1):
            kind, label = labels[i]
            print(f"  {rank}. [{kind:<5}] {row[i]:.3f}  {label}")
        top1.append(labels[order[0]][1])
    return top1


def main() -> None:
    print(f"Python {platform.python_version()} / {platform.system()} {platform.machine()}")
    for pkg in ["sentence-transformers", "transformers", "torch", "av"]:
        print(f"{pkg}=={version(pkg)}")

    t = time.perf_counter()
    model = SentenceTransformer(MODEL_ID)
    n_params = sum(p.numel() for p in model.parameters())
    print(f"\nモデル読み込み: {time.perf_counter() - t:.1f} 秒 / device={model.device} / dtype={model.dtype}")
    print(f"パラメータ数: {n_params / 1e6:.0f}M")

    print("\n== ドキュメントを埋め込む ==")
    labels, inputs = build_inputs()
    doc_embs = encode_docs(model, labels, inputs)
    print(f"ドキュメントの埋め込み: {doc_embs.shape}")

    query_embs = model.encode(QUERIES, prompt_name="SearchQuery")
    print(f"クエリの埋め込み: {query_embs.shape}")

    print("\n== 768 次元で検索 ==")
    top1_768 = search(model, query_embs, doc_embs, labels)

    print("\n== 128 次元に切り詰めて検索(Matryoshka) ==")
    top1_128 = search(model, query_embs, doc_embs, labels, dim=128)

    same = sum(a == b for a, b in zip(top1_768, top1_128))
    print(f"\n1 位が 768 次元と同じだったクエリ: {same} / {len(QUERIES)}")

    print("\n== テキストを除き、画像・音声・動画だけで検索(768 次元) ==")
    media = [i for i, (kind, _) in enumerate(labels) if kind != "text"]
    search(model, query_embs, doc_embs[media], [labels[i] for i in media], top_k=2)


if __name__ == "__main__":
    main()

実行結果

outputs/run.log から、ライブラリの警告と読み込みの進捗表示を省いて貼ります。

Python 3.12.15 / Darwin arm64
sentence-transformers==6.1.0
transformers==5.20.1
torch==2.14.1
av==19.0.1
...
モデル読み込み: 7.1 秒 / device=mps:0 / dtype=torch.bfloat16
パラメータ数: 744M

== ドキュメントを埋め込む ==
  cat_dozing.mp4: 30 フレームを取り出した
  text  猫の生態                                  0.23 秒
  text  金門橋の歴史                                0.04 秒
  text  パスタのゆで方                               0.04 秒
  image GoldenGate.png                        0.87 秒
  image kitchen_painting.jpg                  0.83 秒
  audio roses-are.wav(英語の詩)                   0.18 秒
  audio weather_ja.wav(日本語の天気予報)              0.16 秒
  video cat_dozing.mp4                       12.65 秒
ドキュメントの埋め込み: (8, 768)
クエリの埋め込み: (6, 768)

== 768 次元で検索 ==

Q: うたた寝している猫の映像
  1. [text ] 0.782  猫の生態
  2. [video] 0.678  cat_dozing.mp4
  3. [audio] 0.565  roses-are.wav(英語の詩)

Q: 赤い吊り橋の写真
  1. [image] 0.715  GoldenGate.png
  2. [text ] 0.682  金門橋の歴史
  3. [audio] 0.626  roses-are.wav(英語の詩)

Q: キッチンを描いた絵
  1. [image] 0.712  kitchen_painting.jpg
  2. [text ] 0.592  パスタのゆで方
  3. [text ] 0.573  猫の生態

Q: 明日の天気
  1. [text ] 0.641  猫の生態
  2. [audio] 0.609  weather_ja.wav(日本語の天気予報)
  3. [text ] 0.563  パスタのゆで方

Q: 詩を朗読する声
  1. [text ] 0.642  猫の生態
  2. [text ] 0.615  パスタのゆで方
  3. [audio] 0.608  weather_ja.wav(日本語の天気予報)

Q: 料理のコツ
  1. [text ] 0.715  パスタのゆで方
  2. [image] 0.644  kitchen_painting.jpg
  3. [text ] 0.616  猫の生態

== 128 次元に切り詰めて検索(Matryoshka) ==
...
1 位が 768 次元と同じだったクエリ: 5 / 6

== テキストを除き、画像・音声・動画だけで検索(768 次元) ==

Q: うたた寝している猫の映像
  1. [video] 0.678  cat_dozing.mp4
  2. [audio] 0.565  roses-are.wav(英語の詩)

Q: 赤い吊り橋の写真
  1. [image] 0.715  GoldenGate.png
  2. [audio] 0.626  roses-are.wav(英語の詩)

Q: キッチンを描いた絵
  1. [image] 0.712  kitchen_painting.jpg
  2. [image] 0.557  GoldenGate.png

Q: 明日の天気
  1. [audio] 0.609  weather_ja.wav(日本語の天気予報)
  2. [image] 0.562  GoldenGate.png

Q: 詩を朗読する声
  1. [audio] 0.608  weather_ja.wav(日本語の天気予報)
  2. [audio] 0.606  roses-are.wav(英語の詩)

Q: 料理のコツ
  1. [image] 0.644  kitchen_painting.jpg
  2. [audio] 0.603  weather_ja.wav(日本語の天気予報)

結果を読む

種類が違っても、日本語のクエリで見つかった

「赤い吊り橋の写真」で金門橋の写真が、「キッチンを描いた絵」でキッチンのイラストが 1 位になりました。画像の中に文字はないので、日本語の文と画像の中身が同じ空間で近くなっていると考えられます(今回の 2 問での結果です)。

画像・音声・動画だけに絞った検索では、6 問中 5 問で狙ったものが 1 位でした(「料理のコツ」はキッチンのイラストを、「詩を朗読する声」は英語の詩の音声を正解とみなしました)。「明日の天気」では、日本語で読み上げた天気予報の音声が 1 位です。今回の例では、音声を文字起こしせずに、そのまま検索できました。

今回は、テキストが混ざるとテキストが上に来た

一方、テキストと他の種類を 1 つの索引に入れると、今回の 6 問ではテキストが上位を取ることが多くありました。たとえば「明日の天気」と「詩を朗読する声」の 1 位は、どちらも関係のない「猫の生態」です。

スコアを見ると、画像・音声・動画の 1 位は 0.6〜0.7 ほどでした。テキスト同士は、内容が関係なくても 0.6 前後になることがあります。種類によってスコアの水準が違うので、そのまま混ぜて並べると比べにくくなります。今回の 8 件・6 問だけで一般化はできません。この傾向が自分のデータでも出るかを確かめたうえで、出るなら次のような工夫を検討するとよさそうです。

  • 種類ごとに上位を出してから並べる(今回の「画像・音声・動画だけ」の検索がこれ)
  • 種類ごとにスコアのしきい値を変える

外れたもの

「詩を朗読する声」は、メディアだけに絞っても、英語の詩(0.606)より日本語の天気予報(0.608)が僅差で上でした。「声である」ことは捉えていそうですが、「詩かどうか」までは区別できていません。英語の音声と日本語のクエリという組み合わせも、不利に働いたのかもしれません(推測です)。

今回の 6 問では、128 次元でも 1 位はほぼ同じだった

先頭 128 次元に切り詰めても、1 位は 6 問中 5 問で 768 次元と同じでした。違ったのは「詩を朗読する声」だけで、768 次元でもテキスト同士が 1 位・2 位を争っていた問題です。保存するベクトルのサイズは 6 分の 1 になります。ただしモデルカードには、128 次元ではマルチモーダルの品質が大きく落ちるので、自分のデータで確かめるよう書かれています(モデルカード)。今回も、128 次元ではスコア全体が高くなり、3 位は 6 問中 5 問で入れ替わりました。

速さ

今回の 1 回の計測では、M3 の Mac(MPS、bfloat16)で、テキストは 1 件 0.04〜0.23 秒、画像は 1 枚 0.8〜0.9 秒ほどでした。動画は 30 フレームで 12.7 秒ほどかかりました。処理時間はマシンや入力の長さで変わるので、実際に使う前に自分の環境で測る必要があります。動画をたくさん入れるなら、フレーム数(max_frames)や 1 枚あたりのトークン数を減らすことも検討するとよさそうです。なお、モデルカードには float16 を使うと NaN(数値にならない値)が出ることがあると書かれているので、bfloat16 か float32 で動かします。今回は何も指定せず、bfloat16 で読み込まれました。

まとめ

  • EmbeddingGemma 2 は、テキスト・画像・音声・動画を 1 つの空間に埋め込める。M3 の Mac でも、API キーなしで動いた
  • 今回の検証では、日本語のテキストで画像・音声・動画を見つけられた。日本語の音声も、文字起こしせずに検索できた
  • ただし種類を混ぜた索引では、今回はテキストのスコアが高く出た。種類ごとに順位を出すなどの工夫を検討したい
  • どの結果も 8 件・6 問・1 回の検証によるもの。実際に使うときは、自分の環境とデータで精度と速度を確かめる必要がある
  • 音声・動画のファイルパスを渡すには FFmpeg が必要。入れたくない場合は、PyAV でデコードして波形やフレームで渡せばよい

参考

公式ドキュメント・一次情報

参考にした記事

  • なし
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?