はじめに
この記事は、OCI Generative AI(Enterprise AI Models) で使える Cohere Embed 4(cohere.embed-v4.0)の目玉機能である ミックスモーダル埋め込み(テキスト+画像を1本のベクトルにする機能)を、実際に動かして試してみたレポートです。
「スライド画像だけを埋め込んでテキスト検索すると、どのくらいヒットするのか?」というのば実応用でもいろいろ試していて正直そこそこ使えるけど画像の説明文をテキストでベクトル検索した方が精度が高いという印象でした。
そして、昨年、Cohere Embed 4 は、「1枚の画像と複数のテキスト入力を 1つの埋め込みベクトル にまとめられる」と聞いたときから「画像に説明テキストを 混ぜて 埋め込むと、検索精度は本当に上がるのか?」を試してみたいと思っていました。OCI の Generative AI(Enterprise AI Models)の Embed 4 でもこのミックスモーダル埋め込みがサポートされたのでやってみたというわけです。
「上がる気はするけど、実際どれくらい?」というのが正直なところ。というわけで、画像のみの埋め込み と 画像+テキストのミックスモーダル埋め込み を作って、同じテキストクエリーに対するコサイン類似度を比べてみました。
結論を先に言うと、コサイン類似度が 0.22 → 0.43 とほぼ倍になりました。これがミックスモーダルの威力です。それでは、はじまります!
Cohere Embed 4 とミックスモーダル埋め込み
まずは、なぜミックスモーダルが効くのか、その源泉となる Cohere Embed 4 の特徴を軽くおさらいします。ここは本質の周辺なので、さらっと読み流していただいて大丈夫です。
そもそもミックスモーダル埋め込みとは
一般的なマルチモーダル埋め込みモデルは、「テキストはテキストで1本」「画像は画像で1本」とモダリティごとにベクトルを作ります。CLIP 系のイメージですね。
これに対して Cohere Embed 4 は、テキストと画像を織り交ぜた入力(interleaved text and image)を、そのまま1本のベクトルに落とし込める のが特徴です。Cohere 自身も、テキスト・画像・テキストと画像の混在を「a single vector representation」に変換できる、とアナウンスしています。
これが効くのは、たとえば次のようなケースです。
- 商品画像と商品説明文をセットで1ベクトルにする
- チャート画像と、その解説テキストをセットで1ベクトルにする
- PDF ページの図と周辺テキストをまとめて1ベクトルにする
「図だけ」でも「文だけ」でもなく、両方をひとかたまりの意味として扱える というのがポイントです。
OCI での新機能 embedContents
OCI Generative AI では、Embed 4 のミックスモーダル埋め込み機能追加に合わせて EmbedTextDetails に embedContents という配列パラメータが追加されました。ここに EmbedTextContent(テキスト)と EmbedImageContent(画像)を並べて渡すことで、ミックスモーダル埋め込みが作れます。このパラメータは Embed 4 モデルでのみ サポートされます。
主なスペック
今回関係するあたりを表にまとめておきます。
| 項目 | 内容 |
|---|---|
| モデル OCID | cohere.embed-v4.0 |
| モダリティ | テキストのみ / 画像のみ(1枚)/ テキスト+画像のミックス |
| 出力次元数 | 256 / 512 / 1,024 / 1,536(デフォルトは 1,536、Matryoshka 対応) |
| 最大コンテキスト | 約 128,000 トークン/実行(SDK・API、テキストと画像の合計) |
| 埋め込み型 | float / int8 / uint8 / binary / ubinary / base64 |
| 画像 | Base64 エンコード、1ペイロードあたり1枚 |
| input_type |
search_query / search_document など |
出力次元が Matryoshka Embeddings になっているのが地味に嬉しいところで、1,536 次元で作っておいて先頭 256 次元だけ使う、といった「切り詰め」ができます。ストレージやベクトル検索のコストと精度をあとから調整できるわけですね。といいつつやったことないです...
今回試す検証の狙い
検証のシナリオはシンプルです。テキストクエリーで画像を探す、いわゆるテキスト to 画像のベクトル検索を想定します。
- 検索対象の画像は、LLM の推論フロー(Prefill / Decode / KV キャッシュ)を説明した 1枚のスライド
- 検索クエリーは
LLMの推論の仕組みは?という日本語テキスト
この状況で、次の2つの埋め込みを作って比較します。
- 画像のみ を埋め込んだベクトル
- 画像+説明テキスト をミックスモーダルで埋め込んだベクトル
そして、それぞれとクエリーとのコサイン類似度を比べます。ミックスモーダルが本当に効くなら、2 のスコアが高くなるはずです。
検証対象のスライドはこちらです。
見てのとおり、LLMの推論のアーキテクチャを説明したイラストですが、イラスト中には「LLMの推論」という言葉が書かれて(描かれて)いないところがポイントです。ここにテキストを混ぜてどうなるか、というのが知りたいところです。
実装
ここからは実際のコードです。OCI Python SDK の GenerativeAiInferenceClient を使います。
事前準備
~/.oci/config に OCI の認証情報を設定し、.env にリージョンとコンパートメント OCID を書いておきます。
OCI_REGION=us-chicago-1
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..xxxxxxxx
必要なライブラリをインポートします。
import oci
from oci.generative_ai_inference import GenerativeAiInferenceClient
from oci.generative_ai_inference.models import (
EmbedImageContent,
EmbedTextContent,
EmbedTextDetails,
ImageUrl,
OnDemandServingMode,
)
from PIL import Image
from io import BytesIO
import base64
import os
from dotenv import load_dotenv, find_dotenv
from rich import print
_ = load_dotenv(find_dotenv())
クライアントの作成と共通関数
推論クライアントを作り、モデルや次元数などの定数を定義します。次元数は今回 1,536 を指定しました。
oci_config = oci.config.from_file()
oci_inference_client = GenerativeAiInferenceClient(
oci_config,
service_endpoint=(
f"https://inference.generativeai.{os.getenv('OCI_REGION')}.oci.oraclecloud.com"
),
)
MODEL_ID = "cohere.embed-v4.0"
COMPARTMENT_ID = os.getenv("OCI_COMPARTMENT_ID")
OUTPUT_DIMENSIONS = 1536
def get_float_embeddings(response):
embeddings = response.data.embeddings
if embeddings is None:
embeddings_by_type = response.data.embeddings_by_type or {}
embeddings = embeddings_by_type.get("float")
if embeddings is None:
raise RuntimeError(f"Float embeddings were not returned: {response.data}")
return embeddings
def embed_with_contents(embed_contents, input_type):
response = oci_inference_client.embed_text(
EmbedTextDetails(
serving_mode=OnDemandServingMode(model_id=MODEL_ID),
compartment_id=COMPARTMENT_ID,
input_type=input_type,
embedding_types=[EmbedTextDetails.EMBEDDING_TYPES_FLOAT],
output_dimensions=OUTPUT_DIMENSIONS,
embed_contents=embed_contents,
)
)
return response, get_float_embeddings(response)
ポイントは embed_with_contents の中で embed_contents=embed_contents を渡しているところです。ここに テキストと画像を並べて渡せる のが Embed 4 の新機能です。
画像を Data URL に変換
画像は Base64 エンコードした Data URL の形で渡します。
def image_to_base64_data_url(image_path):
with Image.open(image_path) as img:
image_format = img.format.lower()
buffered = BytesIO()
img.save(buffered, format=img.format)
img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
data_url = f"data:image/{image_format};base64,{img_base64}"
return data_url
埋め込み対象のテキスト
スライドの内容を説明したテキスト(Markdown)を用意します。長いので一部だけ抜粋します。実際にはスライドの Prefill / Decode / KV キャッシュの解説をまるごと入れています。
text_input = """
# Transformer系LLMの推論フロー
このスライドは、Transformer系LLMが1つのリクエストを処理するときの推論フローを、
特に Prefill Phase と Decode Phase に分けて説明しています。
(... 中略。KVキャッシュ、COMPUTE BOUND、MEMORY BW BOUND などの解説が続く ...)
"""
画像のみ/画像+テキストの埋め込みを生成
いよいよ本題です。画像のみ と 画像+テキスト の2種類の埋め込みを作ります。違いは embed_contents に渡す中身だけです。
# 画像を Data URL に変換
processed_image = image_to_base64_data_url(image_path)
# 画像のみの埋め込み(input_type は search_document)
image_ret, image_embeddings = embed_with_contents(
embed_contents=[
EmbedImageContent(image_url=ImageUrl(url=processed_image)),
],
input_type=EmbedTextDetails.INPUT_TYPE_SEARCH_DOCUMENT,
)
# 画像 + テキストのミックスモーダル埋め込み
mixed_ret, mixed_embeddings = embed_with_contents(
embed_contents=[
EmbedTextContent(text=text_input),
EmbedImageContent(image_url=ImageUrl(url=processed_image)),
],
input_type=EmbedTextDetails.INPUT_TYPE_SEARCH_DOCUMENT,
)
image_embedding = image_embeddings[0]
mixed_embedding = mixed_embeddings[0]
見てのとおり、ミックスモーダル側は EmbedTextContent と EmbedImageContent を 1つの配列に並べて 渡しているだけです。これで両方をまとめた 1 本のベクトルが返ってきます。
出力例(先頭・末尾の各5要素を比較)
+------------------------------------------------------------------+
| Position | Index | Image Only | Image + Text | Difference |
|----------+-------+---------------+---------------+---------------|
| Head | 0 | -0.0099694520 | -0.0148585370 | -0.0048890850 |
| Head | 1 | -0.0116378100 | -0.0137155730 | -0.0020777630 |
| Head | 2 | 0.0058189050 | -0.0051229300 | -0.0109418350 |
| Head | 3 | 0.0118005770 | 0.0231858500 | 0.0113852730 |
| Head | 4 | 0.0065513547 | 0.0162464230 | 0.0096950683 |
| Tail | 1531 | 0.0180670900 | 0.0137972130 | -0.0042698770 |
| Tail | 1532 | -0.0143234580 | -0.0114296440 | 0.0028938140 |
| Tail | 1533 | -0.0041505476 | -0.0162464230 | -0.0120958754 |
| Tail | 1534 | -0.0113122770 | 0.0023879790 | 0.0137002560 |
| Tail | 1535 | -0.0349948150 | -0.0280842670 | 0.0069105480 |
+------------------------------------------------------------------+
Dimensions: 1536
どちらも 1,536 次元のベクトルですが、同じインデックスでも値がしっかり変わっています。テキストを混ぜたことでベクトルの「意味」がずれているのが数字からも分かりますね。
クエリーとのコサイン類似度を計算
最後に、検索クエリー LLMの推論の仕組みは? を埋め込み、2つの文書ベクトルとのコサイン類似度を計算します。クエリー側の input_type は search_query です。
import math
text_query = "LLMの推論の仕組みは?"
text_ret, text_embeddings = embed_with_contents(
embed_contents=[
EmbedTextContent(text=text_query),
],
input_type=EmbedTextDetails.INPUT_TYPE_SEARCH_QUERY,
)
text_embedding = text_embeddings[0]
def cosine_similarity(left_embedding, right_embedding):
dot_product = sum(x * y for x, y in zip(left_embedding, right_embedding))
left_norm = math.sqrt(sum(x * x for x in left_embedding))
right_norm = math.sqrt(sum(y * y for y in right_embedding))
return dot_product / (left_norm * right_norm)
image_similarity = cosine_similarity(text_embedding, image_embedding)
mixed_similarity = cosine_similarity(text_embedding, mixed_embedding)
similarity_diff = mixed_similarity - image_similarity
出力例
+-------------------------------------------------------------------------+
| Text | Target Embedding | Metric | Score |
|-----------------------+------------------+-------------------+----------|
| LLMの推論の仕組みは? | Image Only | Cosine Similarity | 0.223566 |
| LLMの推論の仕組みは? | Image + Text | Cosine Similarity | 0.428018 |
| LLMの推論の仕組みは? | Difference | Cosine Similarity | 0.204452 |
+-------------------------------------------------------------------------+
結果の考察
表にすると、こうなりました。
| 検索クエリー | 類似度計算対象 | コサイン類似度 |
|---|---|---|
| LLMの推論の仕組みは? | 画像のみ | 0.223566 |
| LLMの推論の仕組みは? | 画像+テキスト | 0.428018 |
| LLMの推論の仕組みは? | 差分 | +0.204452 |
画像のみの 0.22 に対して、画像+テキストは 0.43。ざっくり 1.9 倍 です。差分は +0.20 で、これはコサイン類似度の世界ではかなり大きな改善です。
なぜこうなるのかを考えてみます。
- 対象のスライドは、図中のテキストには「LLMの推論」という言葉は描かれていません。画像だけだと、日本語クエリー
LLMの推論の仕組みは?との意味的な距離が遠い ままでした。 - そこに「LLM」「推論フロー」「Prefill」「Decode」「KVキャッシュ」といった 概念を説明する日本語テキストを混ぜる ことで、ベクトルがクエリーの意味へぐっと引き寄せられました。
つまり、画像が単独では表現しきれない意味を、テキストが補って1本のベクトルに凝縮できた ということです。これがミックスモーダル埋め込みの威力ですね。
ここで大事なのは、これを 1回の API 呼び出し・1本のベクトル で実現できている点です。「画像ベクトル」と「テキストベクトル」を別々に作って後から重み付けして足す、といった面倒なチューニングが要りません(ベクトルを混ぜてこんなことできたーと言ってブログを書くのも楽しいのですが)。文書側に図と説明をまとめて放り込んでおけば、あとは普通のベクトル検索と同じように扱えます。
あとがき
冒頭の疑問「画像に説明テキストを混ぜると、検索精度は本当に上がるのか?」に対する答えは、はっきり YES でした。コサイン類似度で 0.22 → 0.43 という結果は、思っていた以上にくっきりした差でした。
今回の検証から見えてきたことをまとめます。
- OCI の Cohere Embed 4 は、
embedContentsに テキストと画像を並べて渡すだけ でミックスモーダル埋め込みが作れる - スライドや図表のように 画像単独では意味が拾いにくいコンテンツ ほど、テキストを混ぜる効果が大きい
- 出力次元は Matryoshka なので、あとから 精度とコストのバランス を調整できる
RAG の文書取り込み時に「ページ画像+その OCR やキャプション」をミックスモーダルで埋め込んでおく、といった使い方ができそうですね。図表の多い PDF やスライド資料を検索対象にするケースで、特に威力を発揮するはずです。いつもお世話になっている自家製 「AI司書、お調べします」 さん に早く実装したいです。
次は次元数(256 / 512 / 1,024 / 1,536)を振って、精度とコストのトレードオフも見てみたいところです。それはまた別の記事で...となるかどうかわかりませんが。ここまで読んでいただき、ありがとうございました!
