🦤

テキスト変換方式によるマルチモーダルRAGを構築してみた

に公開

はじめに

こんにちは、TimelabLynxというカレンダーサービスを開発している諸岡(@hakoten)です。

この記事は、RAGの種類の一つである「マルチモーダルRAG」を具体的なサンプルを用いて試したものになります。

マルチモーダルRAGには大きく「画像自体をベクトル化し、テキストと一緒のベクトル空間に格納する方法」と「画像を一度テキストに変換し、ベクトル空間に格納する方法」の2種類があります。

画像自体をテキストと同じベクトル空間に格納する方式は以下の記事でも紹介していますので、興味があれば一読ください。

https://zenn.dev/timelab/articles/764104c9db7d86

今回は後者である「テキスト変換方式」を使ったものになります。前回同様、精度や運用の話には触れておらず、あくまでアプローチの入門記事であるころご理解ください。

テキスト変換方式によるマルチモーダルRAG

ここからは、今回採用したテキスト変換方式について、もう少し詳しく説明します。
この方式では、画像を直接ベクトル化するのではなく、まずテキストに変換してから格納します

テキストへの変換にはいくつか方法がありますが、今回はLLMのImage対応のAPIを使いImage to Textを行っています。

簡単な処理の流れ

メリット

  • 既存のテキストRAGの仕組み(BM25、リランキング、ハイブリッド検索など)をそのまま活用できる
  • Vision API(gpt-4o)による高品質な画像理解が可能
  • 実装がシンプルで理解しやすい
  • 多言語対応

デメリット

  • Vision APIとEmbeddings APIの両方を呼び出すため、API利用料金が発生する(LLMでテキスト化を行う場合)
  • 視覚的な細かい特徴(色の微妙な違い、正確な配置など) がテキスト説明に完全には反映されない可能性がある(LLMモデルの性能に依存してしまう)

環境

役割 技術 説明
画像→テキスト変換 gpt-4o (OpenAI Vision API) 画像を詳細なテキスト説明文に変換
テキスト埋め込み text-embedding-3-small テキストを1536次元のベクトルに変換
ベクトルデータベース Milvus 画像説明文のベクトル格納用データベース
回答生成モデル gpt-4o-mini RAGの検索結果を用いた回答生成LLMモデル
言語・フレームワーク Python Python: v3.12

事前準備

ここからは、OpenAI APIを使ったらRAGの実装に入っていきます。まずは、事前準備としてベクトルデータベースのDocker構築と、サンプル画像を準備します。

ベクトルデータベース(Milvus)の構築

まずは、ベクトルデータベースの環境をDockerで用意し、docker composeで立ち上げておきます。

compose.yaml
compose.yaml
services:
  etcd:
    container_name: milvus-etcd
    image: quay.io/coreos/etcd:v3.5.5
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
      - ETCD_QUOTA_BACKEND_BYTES=4294967296
      - ETCD_SNAPSHOT_COUNT=50000
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
    command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
    healthcheck:
      test: ["CMD", "etcdctl", "endpoint", "health"]
      interval: 30s
      timeout: 20s
      retries: 3

  minio:
    container_name: milvus-minio
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    ports:
      - "9001:9001"
      - "9000:9000"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
    command: minio server /minio_data --console-address ":9001"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3

  milvus:
    container_name: milvus-standalone
    image: milvusdb/milvus:v2.3.3
    command: ["milvus", "run", "standalone"]
    security_opt:
    - seccomp:unconfined
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
      interval: 30s
      start_period: 90s
      timeout: 20s
      retries: 3
    ports:
      - "19530:19530"
      - "9091:9091"
    depends_on:
      - "etcd"
      - "minio"

  attu:
    container_name: milvus-attu
    image: zilliz/attu:latest
    environment:
      MILVUS_URL: milvus-standalone:19530
    ports:
      - "8000:3000"
    depends_on:
      - "milvus"

networks:
  default:
    name: milvus

サンプル画像の準備

前回の記事同様に、ベクトルの埋め込みに使う画像のサンプルは、Unsplashから用意しています。

画像をベクトルデータベースに格納する

ベクターデータベース(Milvus)の準備

まず、Milvusのスキーマ定義とコレクションを作成します。

# Milvusサーバーに接続(ローカルホストのデフォルトポート19530を使用)
connections.connect("default", host="localhost", port="19530")

# コレクションのスキーマを定義
fields = [
    # id: 各レコードの一意識別子(自動採番される主キー)
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    # image_path: 画像ファイルのパス(最大500文字の文字列)
    FieldSchema(name="image_path", dtype=DataType.VARCHAR, max_length=500),
    # image_description: Vision APIで生成された画像の説明文(最大2000文字)
    FieldSchema(name="image_description", dtype=DataType.VARCHAR, max_length=2000),
    # text_embedding: OpenAI Embeddingsで生成されたテキストの特徴ベクトル
    # text-embedding-3-smallモデルは1536次元のベクトルを出力
    FieldSchema(name="text_embedding", dtype=DataType.FLOAT_VECTOR, dim=1536),
]

# スキーマとコレクション作成
schema = CollectionSchema(
    fields, description="Text-only multimodal RAG collection using OpenAI APIs"
)

collection = Collection("text_only_rag_collection", schema)

# ベクトルインデックスの作成
# IVF_FLAT: Inverted File Systemを使用した近似最近傍探索
# COSINE距離(コサイン類似度)で類似度を測定
index_params = {
    "metric_type": "COSINE",  # COSINE距離(コサイン類似度)
    "index_type": "IVF_FLAT",  # IVFインデックス
    "params": {"nlist": 128},  # クラスタ数(データ量に応じて調整)
}
collection.create_index("text_embedding", index_params)

Milvusに格納する画像ベクトルに対してインデックスを作成します。このとき、IVFというインデックス手法を使い、コサイン距離で128個のクラスタに分割し、検索を高速化します。

次に、画像をテキストに変換してベクトル化する処理を実装します。

画像をテキスト説明に変換

def encode_image_to_base64(image_path: str) -> str:
    """
    画像ファイルをbase64エンコードして返す
    
    Args:
        image_path: 画像ファイルのパス
    
    Returns:
        str: base64エンコードされた画像データ
    """
    with open(image_path, "rb") as image_file:
        return base64.b64encode(image_file.read()).decode("utf-8")


def generate_image_description(client: OpenAI, image_path: str) -> str:
    """
    OpenAI Vision APIを使って画像の説明文を生成
    
    Args:
        client: OpenAIクライアント
        image_path: 画像ファイルのパス
    
    Returns:
        str: 画像の説明文
    """
    # 画像をbase64エンコード
    base64_image = encode_image_to_base64(image_path)
    
    # Vision APIで画像を説明文に変換
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "text",
                        "text": "この画像を詳しく説明してください。画像に写っているもの、色、雰囲気などを含めて記述してください。",
                    },
                    {
                        "type": "image_url",
                        "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"},
                    },
                ],
            }
        ],
        max_tokens=300,
    )
    
    return response.choices[0].message.content or ""

generate_image_description() 関数では、OpenAI Vision API(gpt-4o)を使って画像を詳細なテキスト説明に変換します。APIに送信するために、画像はbase64エンコードしています。

テキストをベクトルに変換

def generate_text_embedding(client: OpenAI, text: str) -> list[float]:
    """
    OpenAI Embeddings APIを使ってテキストをベクトル化
    
    Args:
        client: OpenAIクライアント
        text: 埋め込みを生成するテキスト
    
    Returns:
        list[float]: 1536次元のベクトル
    """
    response = client.embeddings.create(model="text-embedding-3-small", input=text)
    return response.data[0].embedding

generate_text_embedding() 関数では、OpenAI Embeddings API(text-embedding-3-small)を使ってテキストを1536次元のベクトルに変換します。

ベクトル化したテキストをMilvusに保存

# assets/配下の画像ファイルを取得
assets_dir = Path(__file__).parent.parent / "assets"
image_files = list(assets_dir.glob("*.jpg"))

# 画像データとembeddingを格納するリスト
documents_data = []

# 各画像ファイルを処理
for image_path in image_files:
    try:
        # 1. Vision APIで画像を説明文に変換
        description = generate_image_description(client, str(image_path))
        
        # 2. Embeddings APIで説明文をベクトル化
        embedding = generate_text_embedding(client, description)
        
        # 画像情報をリストに追加
        documents_data.append(
            {
                "image_path": str(image_path),
                "image_description": description,
                "text_embedding": embedding,
            }
        )
        
    except Exception as e:
        continue

# Milvusにバッチ挿入
if documents_data:
    # カラム形式でデータを準備(Milvusは列指向形式を要求)
    entities = [
        [d["image_path"] for d in documents_data],  # 画像パスのリスト
        [d["image_description"] for d in documents_data],  # 説明文のリスト
        [d["text_embedding"] for d in documents_data],  # ベクトルのリスト
    ]
    
    # データを挿入
    collection.insert(entities)

画像の説明文のベクトルを、Milvusのデータベースに格納します。
この時に画像のファイルパスと、説明文の原文を一緒にメタデータとして格納しています。

RAGによる検索

画像をMilvusに格納できたので、次は実際にRAGで画像を検索してみます。
検索する際は、検索文字列をベクトル化する必要がありますが、このときも同じ text-embedding-3-small モデルを使用します。

ベクトルデータベースから類似した画像のベクトルを検索

def search_collection(query: str) -> list[dict[str, Any]]:
    """
    テキストクエリで画像を検索
    
    Args:
        query: 検索クエリ(テキスト)
    
    Returns:
        list[dict]: 検索結果のリスト
    """
    # OpenAIクライアントの初期化
    client = OpenAI()
    
    # Milvusサーバーに接続
    connections.connect("default", host="localhost", port="19530")
    
    # 既存のコレクションを取得
    collection = Collection("text_only_rag_collection")
    
    # コレクションをメモリにロード(検索を高速化するため)
    collection.load()
    
    # クエリテキストをベクトル化
    print(f"🔍 クエリをベクトル化中: '{query}'")
    query_embedding = generate_text_embedding(client, query)
    
    # Milvusでベクトル検索を実行
    search_params = {
        "metric_type": "COSINE",  # コサイン類似度で測定
        "params": {"nprobe": 10},  # 128個に分割したクラスタから近い10個を検索
    }
    
    results = collection.search(
        data=[query_embedding],  # 検索クエリベクトル
        anns_field="text_embedding",  # 検索対象のベクトルフィールド名
        param=search_params,  # 検索パラメータ
        limit=3,  # 上位3件を取得
        output_fields=["image_path", "image_description"],  # 返却するフィールド
    )
    
    # 検索結果を整形してリストで返す
    return [
        {
            "image_path": result.entity.get("image_path"),
            "image_description": result.entity.get("image_description"),
            "distance": result.distance,  # コサイン類似度(1に近いほど類似)
        }
        for result in results[0]
    ]

Milvusサーバーへの接続部分は、格納時と同じです。

検索文字列をベクトル化するには、説明文をベクトル化したときと同様にgenerate_text_embedding() 関数を使用しています。
このベクトルを使って、Milvusに格納された画像の説明文ベクトルから類似したものを検索しています。

LLMによる回答の生成

最後に、検索結果を元にLLMモデルで質問の回答を作成します。

def generate_answer(query: str, search_results: list[dict[str, Any]]) -> str:
    """
    検索結果を基にLLMで回答を生成
    
    Args:
        query: 元の質問
        search_results: 検索結果のリスト
    
    Returns:
        str: 生成された回答テキスト
    """
    print("\n🤖 回答生成中...")
    
    # 検索結果をテキスト形式に整形
    context = "検索された関連画像情報:\n\n"
    for i, result in enumerate(search_results, 1):
        context += f"{i}. 画像パス: {result['image_path']}\n"
        context += f"   類似度: {result['distance']:.3f}\n"
        context += f"   説明: {result['image_description']}\n\n"
    
    # 回答モデルの初期化
    llm = ChatOpenAI(
        model="gpt-4o-mini",
        temperature=0,
    )
    
    prompt = f"""
以下の画像情報を参考に、質問に答えてください。

質問: {query}

{context}

上記の画像情報を踏まえて、質問に対する適切な回答を生成してください。
画像の説明を基に、具体的で分かりやすい回答を心がけてください。

回答:
"""
    
    # LLMにプロンプトを送信して回答を生成
    response = llm.invoke(prompt)
    
    # response.contentの型がstr | list[...]のため、strにキャスト
    content = response.content
    return content if isinstance(content, str) else str(content)

検索結果には、画像パス、類似度、そしてVision APIが生成した画像の説明文が含まれています。これらの情報をコンテキストとしてgpt-4o-miniに渡し、自然な回答を生成します。

コード全体

全体のコードは次のようになります。
環境変数に、OPENAI_API_KEY でOpenAIのAPIキーを設定する必要があります。

コード
test_text_only_rag.py
import base64
import sys
from pathlib import Path
from typing import Any

# プロジェクトルートをパスに追加
sys.path.insert(0, str(Path(__file__).parent.parent))

import argparse

from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from openai import OpenAI
from pymilvus import Collection, CollectionSchema, DataType, FieldSchema, connections

# .envファイルから環境変数(OPENAI_API_KEY等)を読み込み
load_dotenv()

"""
単一モーダリティ(テキスト)マルチモーダルRAG
画像をOpenAI Vision APIでテキスト説明に変換し、OpenAI Embeddingsでベクトル化

実行方法:
uv run python samples/test_text_only_rag.py --create  # コレクションを作成して画像を登録
uv run python samples/test_text_only_rag.py --query "cat"  # 画像を検索
"""


def encode_image_to_base64(image_path: str) -> str:
    """
    画像ファイルをbase64エンコードして返す

    Args:
        image_path: 画像ファイルのパス

    Returns:
        str: base64エンコードされた画像データ
    """
    with open(image_path, "rb") as image_file:
        return base64.b64encode(image_file.read()).decode("utf-8")


def generate_image_description(client: OpenAI, image_path: str) -> str:
    """
    OpenAI Vision APIを使って画像の説明文を生成

    Args:
        client: OpenAIクライアント
        image_path: 画像ファイルのパス

    Returns:
        str: 画像の説明文
    """
    # 画像をbase64エンコード
    base64_image = encode_image_to_base64(image_path)

    # Vision APIで画像を説明文に変換
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "text",
                        "text": "この画像を詳しく説明してください。画像に写っているもの、色、雰囲気などを含めて記述してください。",
                    },
                    {
                        "type": "image_url",
                        "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"},
                    },
                ],
            }
        ],
        max_tokens=300,
    )

    return response.choices[0].message.content or ""


def generate_text_embedding(client: OpenAI, text: str) -> list[float]:
    """
    OpenAI Embeddings APIを使ってテキストをベクトル化

    Args:
        client: OpenAIクライアント
        text: 埋め込みを生成するテキスト

    Returns:
        list[float]: 1536次元のベクトル
    """
    response = client.embeddings.create(model="text-embedding-3-small", input=text)
    return response.data[0].embedding


def create_collection():
    """
    Milvusコレクションを作成し、画像データを登録

    処理フロー:
    1. Milvusに接続
    2. コレクションスキーマを定義
    3. assets/配下の画像を処理
    4. 各画像をVision APIで説明文に変換
    5. 説明文をEmbeddings APIでベクトル化
    6. Milvusに保存
    """
    # OpenAIクライアントの初期化
    client = OpenAI()

    # Milvusサーバーに接続(ローカルホストのデフォルトポート19530を使用)
    connections.connect("default", host="localhost", port="19530")

    # コレクションのスキーマを定義
    fields = [
        # id: 各レコードの一意識別子(自動採番される主キー)
        FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
        # image_path: 画像ファイルのパス(最大500文字の文字列)
        FieldSchema(name="image_path", dtype=DataType.VARCHAR, max_length=500),
        # image_description: Vision APIで生成された画像の説明文(最大2000文字)
        FieldSchema(name="image_description", dtype=DataType.VARCHAR, max_length=2000),
        # text_embedding: OpenAI Embeddingsで生成されたテキストの特徴ベクトル
        # text-embedding-3-smallモデルは1536次元のベクトルを出力
        FieldSchema(name="text_embedding", dtype=DataType.FLOAT_VECTOR, dim=1536),
    ]

    # スキーマとコレクション作成
    schema = CollectionSchema(
        fields, description="Text-only multimodal RAG collection using OpenAI APIs"
    )

    collection = Collection("text_only_rag_collection", schema)

    # ベクトルインデックスの作成
    # IVF_FLAT: Inverted File Systemを使用した近似最近傍探索
    # COSINE距離(コサイン類似度)で類似度を測定
    index_params = {
        "metric_type": "COSINE",  # COSINE距離(コサイン類似度)
        "index_type": "IVF_FLAT",  # IVFインデックス
        "params": {"nlist": 128},  # クラスタ数(データ量に応じて調整)
    }
    collection.create_index("text_embedding", index_params)

    # assets/配下の画像ファイルを取得
    assets_dir = Path(__file__).parent.parent / "assets"
    image_files = list(assets_dir.glob("*.jpg"))

    if not image_files:
        print("❌ 画像ファイルが見つかりません")
        return

    print(f"📁 {len(image_files)}個の画像ファイルを発見")

    # 画像データとembeddingを格納するリスト
    documents_data = []

    # 各画像ファイルを処理
    for image_path in image_files:
        print(f"  処理中: {image_path.name}")

        try:
            # 1. Vision APIで画像を説明文に変換
            print("    → Vision APIで説明文を生成中...")
            description = generate_image_description(client, str(image_path))
            print(f"    → 説明文: {description[:100]}...")

            # 2. Embeddings APIで説明文をベクトル化
            print("    → Embeddings APIでベクトル化中...")
            embedding = generate_text_embedding(client, description)
            print(f"    → ベクトル次元: {len(embedding)}")

            # 画像情報をリストに追加
            documents_data.append(
                {
                    "image_path": str(image_path),
                    "image_description": description,
                    "text_embedding": embedding,
                }
            )

        except Exception as e:
            print(f"    ❌ エラー: {e}")
            continue

    # Milvusにバッチ挿入
    if documents_data:
        # カラム形式でデータを準備(Milvusは列指向形式を要求)
        entities = [
            [d["image_path"] for d in documents_data],  # 画像パスのリスト
            [d["image_description"] for d in documents_data],  # 説明文のリスト
            [d["text_embedding"] for d in documents_data],  # ベクトルのリスト
        ]

        # データを挿入
        collection.insert(entities)
        print(f"\n✅ {len(documents_data)}個の画像データを格納しました")
    else:
        print("\n❌ 処理できた画像データがありませんでした")


def search_collection(query: str) -> list[dict[str, Any]]:
    """
    テキストクエリで画像を検索

    Args:
        query: 検索クエリ(テキスト)

    Returns:
        list[dict]: 検索結果のリスト
    """
    # OpenAIクライアントの初期化
    client = OpenAI()

    # Milvusサーバーに接続
    connections.connect("default", host="localhost", port="19530")

    # 既存のコレクションを取得
    collection = Collection("text_only_rag_collection")

    # コレクションをメモリにロード(検索を高速化するため)
    collection.load()

    # クエリテキストをベクトル化
    print(f"🔍 クエリをベクトル化中: '{query}'")
    query_embedding = generate_text_embedding(client, query)

    # Milvusでベクトル検索を実行
    search_params = {
        "metric_type": "COSINE",  # コサイン類似度で測定
        "params": {"nprobe": 10},  # 128個に分割したクラスタから近い10個を検索
    }

    results = collection.search(
        data=[query_embedding],  # 検索クエリベクトル
        anns_field="text_embedding",  # 検索対象のベクトルフィールド名
        param=search_params,  # 検索パラメータ
        limit=3,  # 上位3件を取得
        output_fields=["image_path", "image_description"],  # 返却するフィールド
    )

    # 検索結果を整形してリストで返す
    return [
        {
            "image_path": result.entity.get("image_path"),
            "image_description": result.entity.get("image_description"),
            "distance": result.distance,  # コサイン類似度(1に近いほど類似)
        }
        for result in results[0]  # type: ignore[index]
    ]


def generate_answer(query: str, search_results: list[dict[str, Any]]) -> str:
    """
    検索結果を基にLLMで回答を生成

    Args:
        query: 元の質問
        search_results: 検索結果のリスト

    Returns:
        str: 生成された回答テキスト
    """
    print("\n🤖 回答生成中...")

    # 検索結果をテキスト形式に整形
    context = "検索された関連画像情報:\n\n"
    for i, result in enumerate(search_results, 1):
        context += f"{i}. 画像パス: {result['image_path']}\n"
        context += f"   類似度: {result['distance']:.3f}\n"
        context += f"   説明: {result['image_description']}\n\n"

    # 回答モデルの初期化
    llm = ChatOpenAI(
        model="gpt-4o-mini",
        temperature=0,
    )

    prompt = f"""
以下の画像情報を参考に、質問に答えてください。

質問: {query}

{context}

上記の画像情報を踏まえて、質問に対する適切な回答を生成してください。
画像の説明を基に、具体的で分かりやすい回答を心がけてください。

回答:
"""

    # LLMにプロンプトを送信して回答を生成
    response = llm.invoke(prompt)

    # response.contentの型がstr | list[...]のため、strにキャスト
    content = response.content
    return content if isinstance(content, str) else str(content)


def main():
    parser = argparse.ArgumentParser(
        description="単一モーダリティ(テキスト)マルチモーダルRAG with OpenAI APIs"
    )
    parser.add_argument("--create", action="store_true", help="コレクションを作成して画像を登録")
    parser.add_argument(
        "--query", type=str, default="cat", help="検索クエリ(--create未指定時に使用)"
    )

    args = parser.parse_args()

    print("🚀 単一モーダリティ(テキスト)マルチモーダルRAG start")
    print("=" * 60)

    if args.create:
        print("\n📦 コレクション作成モード")
        print("=" * 60)
        create_collection()
    else:
        print(f"\n🔍 検索モード: '{args.query}'")
        print("=" * 60)

        try:
            search_results = search_collection(args.query)

            print("\n📊 検索結果:")
            for i, result in enumerate(search_results, 1):
                print(f"\n{i}. 画像: {Path(result['image_path']).name}")
                print(f"   類似度: {result['distance']:.3f}")
                print(f"   説明: {result['image_description'][:100]}...")

            if search_results:
                answer = generate_answer(args.query, search_results)
                print("\n" + "=" * 60)
                print(f"💬 質問: {args.query}")
                print(f"📝 回答:\n{answer}")
                print("=" * 60)
            else:
                print("\n❌ 関連する結果が見つかりませんでした")

        except Exception as e:
            print(f"\n❌ エラーが発生しました: {e}")


if __name__ == "__main__":
    main()
  • ベクトルデータベースのセット: uv run python test_text_only_rag.py --create
  • 検索: uv run python test_text_only_rag.py --query "cat"

実際に検索してみる

uv run python test_text_only_rag.py --query 'コーヒーの画像はありますか?'
🚀 単一モーダリティ(テキスト)マルチモーダルRAG start
============================================================

🔍 検索モード: 'コーヒーの画像はありますか?'
============================================================
🔍 クエリをベクトル化中: 'コーヒーの画像はありますか?'

📊 検索結果:

1. 画像: coffee_image.jpg
   類似度: 0.575
   説明: この画像には、黒いカップに注がれたカフェラテが写っています。カップにはラテアートが施されており、クリーミーで滑らかなミルクが美しい模様を描いています。カップは黒色で、ソーサーも同じ色です。背景はぼかさ...

2. 画像: technology_image.jpg
   類似度: 0.406
   説明: この画像には、デスクに置かれたノートパソコンとその横にあるワイヤレスマウス、そしてカップが写っています。

- **ノートパソコン**: スクリーンには薄暗い色調の壁紙が表示されています。壁紙には何か...

3. 画像: cat_image.jpg
   類似度: 0.382
   説明: この画像では、可愛らしいクリーム色の子ネコが写っています。子ネコの目は青く、賢そうな表情をしています。背景は暗めの色調で、ネコがよく目立つようになっています。前景にはヒョウ柄の布があり、子ネコがそこに...

🤖 回答生成中...

============================================================
💬 質問: コーヒーの画像はありますか?
📝 回答:
はい、コーヒーの画像があります。具体的には、黒いカップに注がれたカフェラテの画像です。このカップには美しいラテアートが施されており、クリーミーで滑らかなミルクが模様を描いています。 カップとソーサーは同じ黒色で、背景はぼかされており、全体的に落ち着いた色調でリラックスしたカフェの雰囲気を感じさせます。
============================================================

コーヒーの画像について尋ねたところ、「coffee_image.jpg」が最も類似度の高い画像として検索されていることがわかります。

Vision APIが生成した説明文に類似しているほど精度高く検索が可能であることがわかります。

まとめ

今回は、単一モーダリティ(テキスト変換)方式のマルチモーダルRAGを実装してみました。

このアプローチのメリット:

  • 実装がシンプルで、既存のテキストRAGの知見をそのまま活用できる
  • テキストベースのため、BM25などの伝統的な検索手法とも組み合わせやすい(ハイブリット検索)

課題点:

  • 画像→テキスト変換のコストがかかる(Vision API呼び出し)
  • 視覚的な細かい特徴が説明文に反映されない可能性がある

一度テキストに変換するため、処理フローとしてはシンプルである一方、説明文の表現力に依存するというのが、テキスト変換方式の特徴になります。

Timelabテックブログ

Discussion