「RAG(検索拡張生成)システムに画像検索機能を追加したものの、期待通りの結果が得られない」という課題は、実務の現場で頻繁に耳にするトピックです。テキスト検索は完璧に機能しているのに、画像が絡むと途端に精度が落ちてしまう。この現象に頭を悩ませている開発者は少なくありません。
多くのプロジェクトが、テキスト処理の延長線上で画像や音声を扱おうとして、「モダリティギャップ(Modality Gap)」という深い谷底に落ちてしまうのです。これは、単なるバグではありません。異なる種類のデータ(モダリティ)が、それぞれ異なる「言葉」で話している状態なのです。
本記事では、この「失敗」をあえてPythonコードで再現します。なぜAIシステムが期待通りに動かないのか。その原因をベクトル空間の可視化を通じて「目で見て」理解し、最新のCLIP(Contrastive Language-Image Pre-Training)技術を用いて鮮やかに解決するまでのプロセスを解説します。
理論書を読み込む前に、まずは手を動かしてプロトタイプを作り、実際の挙動を確認してみましょう。技術の本質を見抜くには、仮説を即座に形にして検証することが最も確実なアプローチです。
イントロダクション:なぜマルチモーダル化プロジェクトは「データ統合」で躓くのか
非構造化データ統合の落とし穴
テキストデータと画像データ。これらはコンピュータにとって、水と油のように混ざり合わない異質な存在です。
テキストは文法構造や単語の意味的文脈に基づいて処理されますが、画像はピクセルの輝度値や空間的なパターンの集合体として認識されます。従来の開発手法では、テキスト解析にはBERT系列の言語モデル、画像解析にはResNetやViT(Vision Transformer)といった視覚モデルというように、それぞれのモダリティ(情報の種類)に特化した「専門家モデル」を個別に採用するのが定石でした。
しかし、マルチモーダル検索システム(RAGなど)を構築する際、これらを単純に結合させても期待通りの精度は出ません。なぜなら、それぞれのモデルが学習によって獲得した「世界(ベクトル空間)」が根本的に異なるからです。
例えるなら、日本語しか話せない人と、スワヒリ語しか話せない人を、通訳なしで会話させようとしているような状況を想像してください。「赤いスポーツカー」という概念に対して、テキストモデルが指し示すベクトルの方向と、画像モデルが生成するベクトルの方向は、共通の座標軸を持たないため、全く噛み合わないのです。これが、多くのプロジェクトでPoC(概念実証)が停滞する技術的な要因となっています。
本チュートリアルのゴール:モダリティギャップの可視化と解消
この記事では、抽象的な理論だけでなく、実際にPythonコードを動かしながら以下の3つのステップでこの課題を解決していきます。
- 失敗の再現(The Gap): 別々の単一モダリティモデルを使って無理やり検索を行い、なぜ精度が出ないのかを体感します。
- 断絶の可視化(Visualization): ベクトル空間を次元圧縮してプロットし、テキストと画像が乖離している「モダリティギャップ」の正体を目撃します。
- 解決策の実装(Bridge the Gap): OpenAIのCLIPモデルのようなマルチモーダルモデルを導入し、共有埋め込み空間を作り出すことで、意味に基づいた高精度な検索を実現します。
最終的には、テキストで「雪の中を走る犬」と入力すれば、即座にその情景を捉えた画像がヒットする検索システムの挙動を、手元の環境で再現できるようになります。
環境構築とデータセット準備
まずは、実験室をセットアップしましょう。Google Colabなどのノートブック環境があれば、すぐに始められます。
必要なライブラリは、深層学習フレームワークのPyTorch、Hugging Faceのtransformers、画像処理用のPillow、そして可視化のためのmatplotlibとscikit-learnです。
特にPyTorchに関しては、使用するハードウェア(NVIDIA GPU、AMD ROCm、CPUのみ等)やCUDAのバージョンによって、最適なインストール手順が異なります。Google Colabでは通常、互換性のあるバージョンがプリインストールされていますが、ローカル環境で実行する場合や、最新のパフォーマンス最適化(FP8サポート等)を利用したい場合は、必ずPyTorch公式サイトで環境に合わせたインストールコマンド(例:pip install torch --index-url ...形式)を確認してください。
# 必要なライブラリのインストール
# ※Google Colab等のマネージド環境以外では、PyTorch公式サイトを参照して
# ハードウェア(CUDAバージョン等)に適合したtorchをインストールしてください
!pip install torch transformers pillow matplotlib scikit-learn numpy
次に、実験用のデータを用意します。今回はシンプルに、数枚の画像とその内容を表すテキストのペアを作成します。外部の画像URLを使用しますが、もしリンク切れ等が心配な場合は、お手持ちの画像パスに書き換えてください。
import torch
from PIL import Image
import requests
from io import BytesIO
import matplotlib.pyplot as plt
# デバイスの設定(GPUが使えるならGPUへ)
# ※最新のPyTorchでは、mps(Mac)やrocm(AMD)など、cuda以外のアクセラレータもサポートされています
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
# 実験用データセット(画像URLと正解ラベル)
data_samples = [
{"url": "https://images.unsplash.com/photo-1543466835-00a7907e9de1", "label": "A dog running in the snow"}, # 雪の中の犬
{"url": "https://images.unsplash.com/photo-1472214103451-9374bd1c798e", "label": "A scenic view of mountains"}, # 山の風景
{"url": "https://images.unsplash.com/photo-1596727147705-54a9d7585164", "label": "A person typing on a laptop"}, # PC操作
{"url": "https://images.unsplash.com/photo-1532054248886-6e3772a81600", "label": "A bowl of fresh salad"}, # サラダ
{"url": "https://images.unsplash.com/photo-1517841905240-472988babdf9", "label": "A woman doing yoga"} # ヨガ
]
# 画像のロード関数
def load_image(url):
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
return Image.open(BytesIO(response.content)).convert("RGB")
except Exception as e:
print(f"画像読み込みエラー: {url} - {e}")
return None
# 画像を表示して確認
fig, axs = plt.subplots(1, 5, figsize=(15, 3))
images = []
valid_samples = []
for i, sample in enumerate(data_samples):
img = load_image(sample["url"])
if img:
images.append(img)
valid_samples.append(sample)
axs[i].imshow(img)
axs[i].axis('off')
axs[i].set_title(sample["label"][:20] + "...")
else:
axs[i].axis('off')
axs[i].text(0.5, 0.5, 'Load Error', ha='center')
plt.show()
# 有効なデータのみでリストを更新
data_samples = valid_samples
これで準備完了です。さあ、まずは「失敗」しに行きましょう。
Step 1: 【失敗再現】単純なベクトル化による検索精度の限界を知る
別々のモデルでエンベディングを作成する
多くのエンジニアが最初に試みるのが、「実績のある画像モデル」と「実績のあるテキストモデル」を組み合わせて使うアプローチです。例えば、画像認識の王者であるResNetと、自然言語処理の王者であるBERTを使ってみましょう。
これらは個別のタスクでは素晴らしい性能を発揮しますが、お互いの存在を知りません。
from transformers import ResNetModel, AutoImageProcessor, BertModel, BertTokenizer
# 画像用モデル: ResNet-50
image_processor = AutoImageProcessor.from_pretrained("microsoft/resnet-50")
image_model = ResNetModel.from_pretrained("microsoft/resnet-50").to(device)
# テキスト用モデル: BERT
tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
text_model = BertModel.from_pretrained("bert-base-uncased").to(device)
# 画像のベクトル化(ResNet)
def get_resnet_embedding(image):
inputs = image_processor(image, return_tensors="pt").to(device)
with torch.no_grad():
outputs = image_model(inputs)
# プーリング層の出力を取得しフラット化
return outputs.pooler_output.squeeze()
# テキストのベクトル化(BERT)
def get_bert_embedding(text):
inputs = tokenizer(text, return_tensors="pt", padding=True, truncation=True).to(device)
with torch.no_grad():
outputs = text_model(inputs)
# [CLS]トークンのベクトルを使用
return outputs.last_hidden_state[:, 0, :].squeeze()
# 全データのベクトルを作成
image_vecs_resnet = torch.stack([get_resnet_embedding(img) for img in images])
text_vecs_bert = torch.stack([get_bert_embedding(sample["label"]) for sample in data_samples])
print(f"ResNet Image Vec Shape: {image_vecs_resnet.shape}") # 通常2048次元
print(f"BERT Text Vec Shape: {text_vecs_bert.shape}") # 通常768次元
なぜ「赤い車」で「赤い服」がヒットしてしまうのか
お気づきでしょうか? 出力されたベクトルの次元数(Shape)を見てください。
- ResNet: 2048次元
- BERT: 768次元
そもそも次元数が違うので、このままでは類似度計算(ドット積など)すらできません。無理やり次元圧縮して合わせたとしても、それぞれの空間の「軸」の意味が異なります。
ResNetの第1次元は「曲線の有無」を見ているかもしれませんが、BERTの第1次元は「動詞か名詞か」を見ているかもしれません。これを比較するのは、体重(kg)と気温(℃)を足し算するようなものです。
「とりあえずタグ付けして、タグで検索すればいいじゃないか」という声も聞こえてきそうですが、それでは「雪の中を走る犬」という複雑な状況(コンテキスト)を捉えきれず、「犬」という単語が含まれるだけの無関係な画像(例えば、家のソファで寝ている犬)もヒットしてしまいます。これが従来のキーワード検索の限界です。
Step 2: 【原因究明】「モダリティ・ギャップ」を可視化してボトルネックを特定する
では、この「噛み合わなさ」を視覚的に確認してみましょう。次元削減アルゴリズムであるt-SNEを使って、これらのベクトルを2次元平面にプロットします。
t-SNEを用いたベクトル空間の次元削減とプロット
ここでは、次元数の違いを吸収するために、便宜的にPCAで主要な成分だけを取り出して可視化してみます。
from sklearn.manifold import TSNE
from sklearn.decomposition import PCA
import numpy as np
# 可視化のためにPCAで次元を50まで落としてからt-SNEを適用
def reduce_dim(vecs, n_components=50):
vecs_np = vecs.cpu().numpy()
# サンプル数が少ないのでPCAのコンポーネント数を調整
n_comp = min(len(vecs_np), n_components)
pca = PCA(n_components=n_comp)
return pca.fit_transform(vecs_np)
img_reduced = reduce_dim(image_vecs_resnet)
text_reduced = reduce_dim(text_vecs_bert)
# t-SNEで2次元へ
all_vecs = np.vstack([img_reduced, text_reduced])
tsne = TSNE(n_components=2, perplexity=2, random_state=42)
all_embedded = tsne.fit_transform(all_vecs)
# プロット
plt.figure(figsize=(10, 8))
plt.scatter(all_embedded[:5, 0], all_embedded[:5, 1], c='red', label='Images (ResNet)', marker='o', s=100)
plt.scatter(all_embedded[5:, 0], all_embedded[5:, 1], c='blue', label='Texts (BERT)', marker='^', s=100)
# 対応するペアを線で結ぶ
for i in range(5):
plt.plot([all_embedded[i, 0], all_embedded[5+i, 0]],
[all_embedded[i, 1], all_embedded[5+i, 1]],
'k--', alpha=0.3)
plt.text(all_embedded[i, 0], all_embedded[i, 1], f"Img{i}")
plt.text(all_embedded[5+i, 0], all_embedded[5+i, 1], f"Txt{i}")
plt.title("Modality Gap: Separate Models Space")
plt.legend()
plt.show()
アライメント(整列)されていない空間の問題点
生成されたグラフを見てください。おそらく、赤い点(画像)のグループと、青い点(テキスト)のグループが、まるで別々の大陸のように離れて分布しているはずです。
理想的な状態であれば、「Img0(犬の画像)」と「Txt0(犬のテキスト)」は近くにあるべきです。しかし、実際には画像同士、テキスト同士で固まってしまい、モダリティ間の距離が遠すぎます。
これが「モダリティギャップ」です。
この状態でいくら検索アルゴリズムをチューニングしても、徒労に終わります。根本的な地図(ベクトル空間)が間違っているからです。システム思考で捉えれば、個々のモデル(部分)は優秀でも、システム全体としての統合(全体)に欠陥がある状態と言えます。
Step 3: 【解決策実装】共有埋め込み空間(CLIP等)によるアライメントと再検索
さあ、ここからが本番です。この断絶された大陸に橋を架けましょう。
登場するのが、OpenAIが開発したCLIP (Contrastive Language-Image Pre-Training) です。CLIPは、インターネット上の膨大な画像とテキストのペアを使って、「この画像とこのテキストはペアである(正例)」「これらはペアではない(負例)」という学習(対照学習)を繰り返しています。
これにより、画像もテキストも同じ次元(例えば512次元)の共有ベクトル空間にマッピングされ、意味が近ければ距離も近くなるように調整(アライメント)されています。
CLIPモデルを用いた共有ベクトル空間へのマッピング
コードを修正して、CLIPを使ってみましょう。
from transformers import CLIPProcessor, CLIPModel
# CLIPモデルのロード
model_id = "openai/clip-vit-base-patch32"
processor = CLIPProcessor.from_pretrained(model_id)
model = CLIPModel.from_pretrained(model_id).to(device)
# 画像とテキストをまとめて処理
inputs = processor(
text=[sample["label"] for sample in data_samples],
images=images,
return_tensors="pt",
padding=True
).to(device)
with torch.no_grad():
outputs = model(inputs)
# 正規化されたベクトルを取得
image_vecs_clip = outputs.image_embeds / outputs.image_embeds.norm(dim=-1, keepdim=True)
text_vecs_clip = outputs.text_embeds / outputs.text_embeds.norm(dim=-1, keepdim=True)
print(f"CLIP Image Vec Shape: {image_vecs_clip.shape}") # 512次元
print(f"CLIP Text Vec Shape: {text_vecs_clip.shape}") # 512次元
次元数が揃いましたね! しかも、これらは最初から「比較されること」を前提に学習されたベクトルです。
再検索と可視化:ギャップが解消された空間の確認
では、検索精度(類似度行列)を確認し、再度プロットしてみましょう。
# 類似度行列の計算(内積)
# 画像[i]とテキスト[j]の類似度を計算
similarity_matrix = (image_vecs_clip @ text_vecs_clip.T).cpu().numpy()
# ヒートマップで表示
plt.figure(figsize=(8, 6))
plt.imshow(similarity_matrix, cmap='viridis')
plt.colorbar(label='Cosine Similarity')
plt.xlabel('Text Index')
plt.ylabel('Image Index')
plt.title('Similarity Matrix (CLIP)')
plt.show()
# 対角成分(正解ペア)の値が高いことを確認
print("Diagonal Similarities (Correct Pairs):", np.diag(similarity_matrix))
ヒートマップの対角線(左上から右下へのライン)が明るく輝いているはずです。これは、正しい画像とテキストのペアが高い類似度を示していることを意味します。
さらに、t-SNEで再度プロットしてみると、今度は「Img0」の近くに「Txt0」が配置され、モダリティの違いを超えて、意味ごとのクラスターが形成されているのが確認できるでしょう。
これが、私たちが目指していた「アライメントされた世界」です。
発展とまとめ:実務における非構造化データ統合の勘所
いかがでしたか? 異なるモデルを無理やり繋げることの危険性と、CLIPのようなマルチモーダルモデルの威力を体感できたのではないでしょうか。
しかし、実務での開発はここで終わりではありません。むしろ、ここからがスタートです。
実運用に向けたスケーラビリティの考慮
今回のデモでは数件のデータでしたが、実際のビジネスでは数百万、数億のデータを扱います。毎回すべてのデータと類似度計算を行う(全探索)のは計算コスト的に不可能です。
そこで必要になるのが、ベクトルデータベース(Vector Database)**です。
- Weaviate
- Pinecone
- Milvus
- Qdrant
これらのツールを使えば、今回作成したCLIPのエンベディングを高速にインデックス化し、ミリ秒単位で検索できるようになります。さらに、メタデータ(撮影日、カテゴリ、著者など)と組み合わせた「ハイブリッド検索」を実装することで、ビジネス要件に合った柔軟な検索システムを構築できます。
ファインチューニングが必要になる境界線
また、CLIPは一般的な物体や風景には強いですが、専門的な領域(例えば、医療用X線画像や、特定の工業製品の部品など)では精度が落ちることがあります。その場合は、自社データを使ってCLIPモデル自体をファインチューニングすることを検討してください。
次のステップへ
「データ統合の壁」を乗り越えた先には、画像を見て回答するチャットボットや、動画の内容を瞬時に検索するシステムなど、無限の可能性が広がっています。
まずは、手元のデータセットでCLIPを試し、その精度の違いを実感してみてください。もし大規模な実装や、より複雑なデータパイプラインの構築で迷った際は、専門的な知見を取り入れることをおすすめします。
AI開発は、正しい道具と正しい地図があれば、決して難しくありません。さあ、あなたのデータを「会話」させましょう。
コメント