💬

マルチモーダルRAGの救世主?画像をベクトル空間に直接埋め込むColQwen2を試してみた

2024/12/24に公開

はじめに

皆さんこんにちは。株式会社アイデミー・データサイエンティストの藤井(X | LinkedIn)です。

最近、GPT-4oをはじめとしたマルチモーダル対応LLMモデルの出現に合わせて、「画像の情報も含めてRAGを構築したい!」といったマルチモーダルRAG(今回は画像を対象とするので、以下画像RAGと表記)へのニーズが高まってきています。Retrieverの検索対象に画像データも含めてあげて、ヒットした画像やテキストをマルチモーダルLLMに与えるイメージです。

LangChainのCookbookによると、現在の画像RAGでは図1および以下に示す3種類の実装方式が提案されています。

  1. 画像とテキストを同じベクトル空間に埋め込み、検索でヒットした画像やテキストを直接LLMに与える
  2. LLMに画像を説明させてテキスト化して埋め込み、検索でヒットしたテキストをLLMに与える(画像もテキストとして与える)
  3. LLMに画像を説明させてテキスト化して埋め込み、検索で画像部分がヒットした場合はテキストに紐付けた元画像をLLMに与える


図1 画像RAG 3つの方式

実装のしやすさやカスタマイズ性の観点から、現在主流となっているのは3の方式と思われます。
一方で、1の方式については画像をベクトル化するモデルとしてOpenAI CLIPなどが提案されていますが、正直性能がイマイチ...というのが現状です。

そんな中、最近ColQwen2というモデルが良いらしい!という噂を耳にしたので、実際に試してみました。本記事では、画像とドキュメントが混在するデータセットからユーザーのクエリに応じて適切な資料を検索するタスクについて、ColQwen2とCLIPの性能を比較した結果をまとめます。

データセット

検証方法

今回は手作業で全20問のクエリを作成しました。いずれも少なくとも1つ以上、参照として適切な資料が存在します。
ColQwen2とCLIPでそれぞれベクトルデータベースを構築し、各クエリに対して類似度の高い3件の資料を検索させました。

クエリ例
[
  {
    "query": "第一四半期の売上高はいくらですか?",
    "uri": ["04.jpg", "05.jpg", "07.jpg", "11.jpg", "19.jpg"]
  },
  {
    "query": "25/5期の連結従業員数は何人ですか?",
    "uri": ["20.jpg"]
  },
  {
    "query": "新取締役に就任した方の名前を教えてください。",
    "uri": ["44.jpg", "20240829.txt"]
  }
]

評価方法

各検索結果について、以下2種類の評価方法で評価を行いました。

  • 正解率(0~1)
    • 3件の検索結果の中に1つでも正解データに該当する資料が含まれていたら正解とみなす
  • 重み付きスコア(0~1)
    • より高い順位で正解データを拾ってくることができたらより高いスコアが得られるよう設計
    • 1位: 3pt, 2位: 2pt, 3位: 1ptとして合計スコアを計算
    • 必ずしも正解データが3つあるわけではないので、クエリによって最高スコアは異なる

実装

ColQwen2のコードを以下に示します。
興味のある方はトグルを展開してご覧ください。

ColQwen2のコード
# インポート
import os
import json
import pandas as pd
from tqdm import tqdm
from PIL import Image
from IPython.display import display

import torch
from colpali_engine.models import ColQwen2, ColQwen2Processor

# 各データのリストを作成
image_path = "/workspace/images"    # 画像ファイルのパス
doc_path = "/workspace/documents"   # ドキュメントファイルのパス
device = "cpu"

images = [
    {"uri": image_name, "image": Image.open(os.path.join(image_path, image_name))}
    for image_name in sorted(os.listdir(image_path))
    if image_name.endswith(".jpg")
]

documents = [
    {"uri": doc_name, "document": open(os.path.join(doc_path, doc_name), "r").read()}
    for doc_name in sorted(os.listdir(doc_path))
    if doc_name.endswith(".txt")
]

# モデルのロード
model_name = "vidore/colqwen2-v1.0-merged"
model = ColQwen2.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    device_map=device
).eval()
processor = ColQwen2Processor.from_pretrained(model_name)

# 埋め込み
embeddings = []
datastore = []
emb_path = "/embedding.pt"
for image in tqdm(images):
    batch_image = processor.process_images([image["image"]])
    with torch.no_grad():
        batch_image = {k: v.to(device) for k, v in batch_image.items()}
        embeddings_image = model(**batch_image)
    embeddings.extend(list(torch.unbind(embeddings_image)))
    datastore.append({"mode": "image", "uri": image["uri"], "content": image["image"]})

for doc in tqdm(documents):
    batch_doc = processor.process_queries([doc["document"]])
    with torch.no_grad():
        batch_doc = {k: v.to(device) for k, v in batch_doc.items()}
        embeddings_doc = model(**batch_doc)
    embeddings.extend(list(torch.unbind(embeddings_doc)))
    datastore.append({"mode": "document", "uri": doc["uri"], "content": doc["document"]})
    
torch.save(embeddings, emb_path)

# 検索
def retrieve(query, top_k=5):
    model = ColQwen2.from_pretrained(
        model_name,
        torch_dtype=torch.bfloat16,
        device_map=device
    )
    processor = ColQwen2Processor.from_pretrained(model_name)
    
    # Convert the query to a tensor
    processed_query = processor.process_queries([query])
    processed_query = {k: v.to(device) for k, v in processed_query.items()}

    with torch.no_grad():
        query_embedding = model(**processed_query)
        
    # Get score
    scores = processor.score_multi_vector(query_embedding, embeddings)[0]
    score_indices = scores.argsort().tolist()[-top_k:][::-1]
    uris = []
    
    for i, index in enumerate(score_indices):
        print(f"#{i + 1} | Index: {index} - Score: {scores[index]}")
        context = datastore[index]
        uris.append(context["uri"])
            
    return uris

# 評価
with open("./queries.json", "r") as f:
    queries = json.load(f)
    
print("-" * 100)
top_k = 3
score_df = pd.DataFrame()
for q_no, query in enumerate(queries):
    print(f"Query #{q_no + 1}")
    query_text = query["query"]
    correct_uris = query["uri"]
    
    context_uris = retrieve(query_text, 3)
    print(f"Correct URIs: {correct_uris}")
    print(f"Context URIs: {context_uris}")
    
    score = 0
    max_score = 0
    n = min(len(correct_uris), top_k)
    for i in range(n):
        max_score += top_k - i
    for rank, context in enumerate(context_uris):
        if context in correct_uris:
            score += top_k - rank
    if score > 0:
        iscorrect = 1
    else:
        iscorrect = 0
    score_df.loc[f"Query{q_no + 1}", "Score"] = score
    score_df.loc[f"Query{q_no + 1}", "MaxScore"] = max_score
    score_df.loc[f"Query{q_no + 1}", "isCorrect"] = iscorrect
    print(f"Score: {score}/{max_score}")
    print("-" * 100)
score_df.to_csv("ColQwen2_score.csv", index=True)
print(f"Accuracy: {score_df['isCorrect'].mean()}")
print(f"Score: {score_df['Score'].sum() / score_df['MaxScore'].sum()}")

なお、処理の中で与えられるqueries.jsonは、質問文となるqueryと、正解資料のURIのリストuriで構成されています。

結果

ColQwen2 CLIP
正解率 0.85 0.35
重み付きスコア 0.72 0.20

結果はColQwen2の圧勝でした。
ColQwen2はちゃんと多くの正解データを検索上位で引っ張ってくることができており、その結果が重み付きスコアの差に表れています。

CLIPについては、今回のタスクで拾ってきた検索結果は全てドキュメントデータでした。
類似度スコアを見る限り、画像よりもドキュメントの方が全体的に良い数字になっていました。
すなわち、テキストのクエリに対してテキストのドキュメントの類似度を過大評価する傾向があるということです。
今回は図表や日本語のテキストが多く使用された画像を題材にしているので、CLIPはその辺りを苦手にしているかもしれません。
また、今回は検索にvectorstore.similarity_search_with_score()を用いたのですが、これを他の関数に変更すると結果が変わる可能性もあるかと思います。

一方ColQwen2は画像とドキュメントの間におかしなバイアスは特に見られず、画像とドキュメントの同一空間内への埋め込みがより適切にできていると考えられます。
PowerPointやPDFなどの資料が多くなりがちな現状の画像RAGの用途では、ColQwen2に軍配が上がりそうです。

まとめ

本記事では、画像とドキュメントを同一のベクトル空間に埋め込むモデルとして、ColQwen2とCLIPを比較しました。

  • 正解率、重み付きスコアともにColQwen2の圧勝
  • CLIPは画像よりもドキュメントの類似度を過大評価する傾向
  • 現状の画像RAG用途ではColQwen2の利用が良さそう

おまけ

ColQwen2が間違えたクエリを見てみよう

Query: "今回アイデミーが株式を取得した会社の名前を教えてください。

検索結果:

#1
アイデミー、「生成AI活用 実践講座」をリリース
#2
アイデミー、「DX・GX実現を加速させる組織変革」をテーマにオムニバスセミナー開催
#3
アイデミーとクニミネ工業、 品質保証業務の効率化に関する共同研究を日本粘土学会で発表

正解(順不同):

#1

#2

#3
子会社のまぼろしが、オフィスをアイデミー本社に移転

考察など

まぼろし社に関する情報を拾ってきて欲しかったのですが...
「株式を取得した」あたりの情報が理解されていなかった可能性が考えられますね。

Query: アイデミーってどんなサービスを提供してる会社ですか?全体像を教えてください。

検索結果

#1
アイデミー、「生成AI活用 実践講座」をリリース
#2
アイデミーとマツダE&T、工場の設備監視アプリを開発
#3
アイデミー、「DX・GX実現を加速させる組織変革」をテーマにオムニバスセミナー開催

正解(順不同)

#1

#2

#3

#4

考察など

アイデミーのサービスを網羅して説明しているスライドを持ってきて欲しかった...
かなり抽象的なクエリだと思うので、難易度は高かったのかなと思います。

Query: アイデミーのM&A戦略について教えてください。

検索結果

#1
アイデミー、「DX・GX実現を加速させる組織変革」をテーマにオムニバスセミナー開催
#2
アイデミー、新取締役に執行役員 CTOの清水俊博が就任
#3
アイデミー、RAGシステムなどのLLMによる出力の品質管理を行うクラウドサービス「LLM品質管理クラウド」を開発へ

正解(順不同)

#1

#2

#3

#4

#5

考察など

こちらもかなり抽象度の高い質問だと思います(20問も考えたので疲れてきてた説はある)。
それにしても、間違えた問題はいずれも3件全てドキュメントを拾ってきており、その内容も似ているような気がします。

Aidemy Tech Blog

Discussion