Argano の江川です。

前書き

Gemini の埋め込みモデルに Gemini Embedding 2 が追加されました。

これまで Vertex AI ではテキスト向けの埋め込みモデルとして gemini-embedding-001 が提供されていました。
今回追加された Gemini Embedding 2 では、モデル ID としては gemini-embedding-2-preview が公開されており、テキストだけではなく画像、動画、音声、PDF といった入力も扱えるようになっています。

ちなみに、Vertex AI では multimodalembedding@001 というマルチモーダルの埋め込みモデルも以前からありました。
なので「Google Cloud で初めてマルチモーダルの埋め込みができるようになった」というよりは、Gemini 系の埋め込みモデルでもマルチモーダル入力を扱えるようになった、という理解が近いかなと思います。

性能比較

まず gemini-embedding-001 と Gemini Embedding 2 (gemini-embedding-2-preview) の違いのうち、最初に見たいのは扱える入力とサイズ感かなと思います。
ざっくり比較すると以下のようになります。

項目gemini-embedding-001gemini-embedding-2-preview
入力テキストテキスト、画像、音声、動画、PDF
最大入力トークン2,0488,192
出力次元数最大 3,072最大 3,072
次元数の調整可 (outputDimensionality)可 (output_dimensionality)

トークン数だけ見ると Gemini Embedding 2 のほうがかなり余裕があります。
gemini-embedding-001 が 2,048 トークンなので、ざっくり日本語の短めの記事やドキュメントの一部を入れる想定に近いです。
一方で Gemini Embedding 2 は 8,192 トークンまで扱えるので、テキストだけで使う場合でも長めの文章や説明をまとめて入力しやすくなっています。

どのくらいの入力に対応しているか

テキストについては、8,192 トークンとかなり長めに入力できます。
ざっくりですが、日本語なら数千文字から 1 万文字超くらいの文章を 1 回で扱うケースも見えます
ただしこれは文章の内容や記号、改行、英数字の比率でかなり変わるので、厳密ではありません。

一方で、PDF や動画、画像、音声について、公式ドキュメントにあるメディアごとの上限を見ると

  • 画像: 1 回のリクエストで最大 6 枚。対応形式は PNG / JPEG
  • PDF: 1 ファイルまで、最大 6 ページ
  • 動画: 音声ありなら最大 80 秒、音声なしなら最大 120 秒
  • 音声: 最大 80 秒

となり、Gemini Embedding 2 は「長い動画を丸ごと 1 本埋め込む」というより、短めのクリップや数ページの PDF、数枚の画像をまとめて意味ベクトル化する用途に向いていると考えるのが良さそうです。

特に PDF は「ファイルサイズ無制限」でもページ数は最大 6 ページなので、実際には長大な資料をそのまま 1 件で入れるというより、ページ単位やセクション単位で分割して扱う設計のほうが自然かなと思います。
動画や音声も同様で、長尺データはチャンク分割して埋め込みを作る前提で考えたほうが扱いやすそうです。

マルチモーダル対応

Gemini Embedding 2 (gemini-embedding-2-preview) でテキスト以外も対応したことで、例えばテキストの入力に対して画像を検索するといったことが 1 つの埋め込みモデルでできるようになりました。

テキスト専用の埋め込みモデルだけで同じことをやろうとすると、画像の説明文や OCR 結果を別途用意して、そのテキストをベクトル化して検索する設計を取ることが多いと思います。
もちろんそれでも実現はできますが、マルチモーダル対応モデルだと画像や PDF などをそのまま扱いやすいのが良いですね。

実践マルチモーダルのベクターサーチ

今回は簡単に pgvector と gemini-embedding-2-preview を使って、画像をベクトル化して Postgres に保存し、テキストで検索するところまで試してみました。

流れとしては、

  1. 画像を 5 枚ほど用意
  2. gemini-embedding-2-preview で画像をベクトル化
  3. Postgres + pgvector に保存
  4. テキストクエリも同じモデルでベクトル化
  5. pgvector で近い画像を検索 で進めています。

コードはできるだけ小さくして、1 ファイルで試しました。
最小構成だとだいたい以下のようになります。

import { GoogleGenAI } from "@google/genai";
import { readFile } from "node:fs/promises";
import { Pool } from "pg";

const ai = new GoogleGenAI({ apiKey: "YOUR_API_KEY" });
const pool = new Pool({
  connectionString: "postgres://postgres:postgres@localhost:5432/postgres",
});

async function embedImage(filePath: string) {
  const imageBase64 = await readFile(filePath, { encoding: "base64" });
  const response = await ai.models.embedContent({
    model: "gemini-embedding-2-preview",
    config: {
      taskType: "RETRIEVAL_DOCUMENT",
      outputDimensionality: 1536,
    },
    contents: {
      parts: [
        {
          inlineData: {
            mimeType: "image/png",
            data: imageBase64,
          },
        },
      ],
    },
  });

  return response.embeddings?.[0]?.values ?? [];
}

async function embedQuery(text: string) {
  const response = await ai.models.embedContent({
    model: "gemini-embedding-2-preview",
    config: {
      taskType: "RETRIEVAL_QUERY",
      outputDimensionality: 1536,
    },
    contents: {
      parts: [{ text }],
    },
  });

  return response.embeddings?.[0]?.values ?? [];
}

function toPgvector(values: number[]) {
  return `[${values.join(",")}]`;
}

async function search(text: string) {
  const query = await embedQuery(text);
  const result = await pool.query(
    `
      select
        label,
        file_path,
        embedding <=> $1::vector as distance
      from image_embeddings
      order by embedding <=> $1::vector
      limit 5
    `,
    [toPgvector(query)],
  );

  return result.rows;
}

以下のSQLで初期化します。

create extension if not exists vector;

create table if not exists image_embeddings (
  id bigserial primary key,
  label text not null,
  file_path text not null unique,
  embedding vector(1536) not null,
  created_at timestamptz not null default now()
);

create index if not exists image_embeddings_embedding_idx
  on image_embeddings
  using hnsw (embedding vector_cosine_ops);

実際に試した時には、以下の 5枚の画像を投入しました。

  • ebi.png
  • ebihurai.png
  • seikousha.png
  • sushi.png
  • takoyakiya.png

今回使った画像は以下です。

ebiebihuraiseikousha
ebiebihuraiseikousha
sushitakoyakiya
sushitakoyakiya

投入後に 1 レコードだけ確認すると、embedding がちゃんと入っていて次元数も 1536 になっていました。

select
  id,
  label,
  file_path,
  vector_dims(embedding) as dims,
  left(embedding::text, 200) as embedding_preview
from image_embeddings
order by id
limit 1;

上記を実行すると、結果は以下のようになります。

id: 1
label: ebi
file_path: images/ebi.png
dims: 1536
embedding_preview:
[0.0036846832,0.0021558965,-0.026461832,-0.006305156,0.00855214,...]

ここまでで確認できることは

  • Gemini Embedding 2 で画像からベクトルを取得できる
  • pgvector に vector(1536) として保存できる

というところまでは動いてます。

あとは embedQuery でテキストをベクトル化して、embedding <=> $1::vector で近傍検索すれば、テキストから画像検索ができます。

例えば "寿司" のようなテキストを検索語として渡すと、近い画像から順に結果を返せます。

npm run search -- "寿司"

結果は以下のようになりました。

順位idlabelfilePathcosineDistance
14sushiimages/sushi.png0.5671
22ebihuraiimages/ebihurai.png0.6550
31ebiimages/ebi.png0.6550
45takoyakiyaimages/takoyakiya.png0.6627
53seikoushaimages/seikousha.png0.6981

sushi.png が最も距離の近い結果になっていたので、今回のデータセットでは意図に近い検索結果になっていそうです。

実際に先頭へ来た画像はこれです。

top result
sushi

(正直なところ、ebi.pngの方がebihurai.pngよりも寿司っぽいので近くあってほしかった・・・)

もう一つ

npm run search -- "人物"

でテストすると、

順位idlabelfilePathcosineDistance
13seikoushaimages/seikousha.png0.6671
25takoyakiyaimages/takoyakiya.png0.6695
31ebiimages/ebi.png0.6781
42ebihuraiimages/ebihurai.png0.6928
54sushiimages/sushi.png0.7045

となり、人物が写っている画像が上位に来ていました。
少なくとも今回試した範囲では、テキストから画像を引く流れは素直に動いていそうです。

この検索では、上位 2 件は以下の画像でした。

1位2位
seikoushatakoyakiya

今回は画像を埋め込みモデルでベクトル化して、実際に検索するところまで見ました。 マルチモーダル対応によって、テキストだけでなく画像も同じ検索基盤に載せられるのはかなり便利だと思います。
RAG アプリケーションや画像検索などを作る時には使いどころがありそうです。

参考

今回サンプルとして使った画像の一部には、いらすとやの素材を利用しています。

それでは🌕