実用的な例として、Visual Document Retrieval(VDR)向けにQwen/Qwen3-VL-Embedding-2B をファインチューニングする手順を説明します。VDR とは、所定のテキストクエリに対して、関連するドキュメントページを(画像として、グラフ、表、レイアウトを保持したまま)取得するタスクです。その結果得られるtomaarsen/Qwen3-VL-Embedding-2B-vdr は、自分のドメインでファインチューニングするとどれほど性能を伸ばせるかを示しています。私の評価データでは、ファインチューニング済みモデルの NDCG@10 は 0.888 だったベースモデルに対して 0.947 であり、私がテストした既存すべての VDR モデル(そのサイズが最大で 4 倍のモデルを含む)を上回ります。
Sentence Transformers のマルチモーダルモデルに初めて触れる場合は、まず Multimodal Embedding & Reranker Models with Sentence Transformers を読むことをおすすめします。テキストのみの埋め込み(embedding)、リランカー、スパース埋め込みモデルの学習については、末尾の Prior Blogposts セクションを参照してください。
目次
- なぜファインチューニングするのか?
- 学習コンポーネント
- モデル
- データセット
- 損失関数
- 学習引数
- 評価器(Evaluator)
- Trainer
- 結果
- マルチモーダル リランカー モデルの学習
- 追加リソース
なぜファインチューニングするのか?
のような汎用的なマルチモーダル埋め込みモデルは、画像-テキストのマッチング、視覚質問応答、ドキュメント理解など、幅広い言語とタスクにわたってうまく機能するよう、多様なデータで学習されています。しかし、その汎用性ゆえに、特定のタスクにおいてモデルが最良の選択であることはめったにありません。
たとえば Visual Document Retrieval を考えてみてください。「会社の Q3 売上はいくらだったか?」のようなテキストクエリが与えられると、モデルは何千もの文書スクリンショットの集合から、最も関連性の高いものを見つけ出す必要があります。これは、シューズの画像を製品説明と照合するといったこととはまったく異なるスキルであり、ドキュメントのレイアウト、グラフ、表、テキストを理解することが求められます。
ドメイン固有のデータでファインチューニングすることで、モデルはこれらの専門的なパターンを学習できます。私の実験では、ファインチューニングにより NDCG@10 が 0.888 から 0.947 に向上し、最大で 4 倍のサイズのモデルを含む、私がテストした最近のすべてのマルチモーダルモデルを上回りました。
返却形式: {"translated": "翻訳されたHTML"}Training Components
マルチモーダルのSentence Transformerモデルを学習するには、テキストのみのモデルを学習する場合と同じコンポーネントを使用します:
- Model: 学習または微調整するマルチモーダルモデル。
- Dataset: 学習と評価に使用するデータ。
- Loss Function: モデルの性能を定量化し、最適化プロセスを導く関数。
- Training Arguments(任意): 学習の性能や、トラッキング/デバッグに影響するパラメータ。
- Evaluator(任意): 学習の前・最中・後にモデルを評価するためのツール。
- Trainer: 学習のために、モデル・データセット・損失関数・その他のコンポーネントをまとめます。
マルチモーダル学習パイプラインは、テキストのみの学習と同じ SentenceTransformerTrainer を使います。重要な違いは、データセットにテキストに加えて画像(または他のモダリティ)が含まれており、モデルのプロセッサが画像の前処理を自動的に処理する点です。
それぞれのコンポーネントを、実例としてVisual Document Retrieval(テキストクエリをドキュメントのスクリーンショットに一致させる)を使って順に見ていきましょう。
Model
最も一般的なアプローチは、既存のマルチモーダル埋め込みモデルを微調整するか、Vision-Language Model(VLM)のチェックポイントから開始することです。Transformer モジュールは、モデルのプロセッサからサポートされているモダリティを自動的に検出します。
既存のマルチモーダル埋め込みモデル(例:すでに modules.json ファイルを持っているもの)を微調整する場合、processor_kwargs と model_kwargs をそれぞれ前処理とモデルのロードを制御するために渡せます。processor_kwargs は AutoProcessor.from_pretrained(...) に直接渡されます(例:画像の解像度の範囲。高い max_pixels はより高品質になる一方でより多くのメモリを使用します)。一方で model_kwargs は AutoModel.from_pretrained(...) 呼び出しに渡されます(例:精度、attentionの実装):
from sentence_transformers import SentenceTransformer
model = SentenceTransformer(
"Qwen/Qwen3-VL-Embedding-2B",
model_kwargs={"attn_implementation": "flash_attention_2", "torch_dtype": "bfloat16"},
processor_kwargs={"min_pixels": 28 * 28, "max_pixels": 600 * 600},
)
まだ埋め込み用に学習されていない新しいVLMチェックポイントから開始することもできます。Sentence Transformers はアーキテクチャを認識し、プロセッサからサポートされているモダリティを推測し、適切な forward メソッドと pooling をセットアップしようとします。自動検出が特定のモデルで完全に機能しない場合は、保存された sentence_bert_config.json 内の設定を編集して、モダリティ設定、forward メソッド、出力の取り扱いを調整できます:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("Qwen/Qwen3-VL-2B")
どちらの場合も、Transformer モジュールがプロセッサを調べて利用可能なモダリティを決定し、必要に応じて Pooling が自動的に追加されます。サポートされているモダリティを確認できます:
print(model.modalities)
# ['text', 'image', 'video', 'message']
print(model.supports("image"))
# True
Alternative: Building multimodal models with Router
単一のVLMバックボーンを使う代わりに、Router モジュールを使って、異なるモダリティごとに別々のエンコーダを組み合わせることができます。これにより、既存のあらゆるエンコーダを組み合わせ、検出されたモダリティに基づいて入力を適切なエンコーダへルーティングできます:
from sentence_transformers import SentenceTransformer
from sentence_transformers.sentence_transformer.modules import Dense, Pooling, Router, Transformer
# 異なるモダリティごとに別々のエンコーダを作成する
text_encoder = Transformer("sentence-transformers/all-MiniLM-L6-v2")
text_pooling = Pooling(text_encoder.get_embedding_dimension(), pooling_mode="mean")
text_projection = Dense(text_encoder.get_embedding_dimension(), 768)
# SigLIP はプーリングされた埋め込みを直接出力するため、別の Pooling モジュールは不要
image_encoder = Transformer("google/siglip2-base-patch16-224")
# モダリティに応じて入力をルーティングする
router = Router(
sub_modules={
"text": [text_encoder, text_pooling, text_projection],
"image": [image_encoder],
},
)
model = SentenceTransformer(modules=[router])
Router ベースのマルチモーダルモデルはモダリティごとに別々のエンコーダを使用するため、埋め込み空間は最初は整合していません。意味のあるクロスモーダル類似度のためには、学習によって空間を整列させる必要があります。上に示した
Denseの投影レイヤーは、異なるエンコーダからの埋め込みを共有空間にマッピングするのに役立ちます。
このアプローチは、大規模な VLM ではなく、軽量で専用のエンコーダを使いたい場合に有用です。また、route_mappings を使って、タスクベースのルーティング(例:クエリ用とドキュメント用で異なるエンコーダを使う)と Router ベースのマルチモダリティを組み合わせることもできます。高度なルーティングシナリオについては、Router のドキュメントを参照してください。
Dataset
視覚ドキュメント検索データセット
この例では、tomaarsen/llamaindex-vdr-en-train-preprocessed データセットを使用します。これは llamaindex/vdr-multilingual-train の前処理済み英語サブセットです。元のデータセットは LlamaIndex による Visual Document Retrieval Goes Multilingual のブログ投稿と一緒に公開されており、公的インターネット上の PDF から収集した、約 500k の多言語クエリ-画像サンプルで構成されています。クエリは VLM(gemini-1.5-pro と Qwen2-VL-72B)を使って合成的に生成されています。
私の前処理版では、53,512 の英語サンプルにフィルタし、各サンプルにつき 16 個ある ID ベースのハードネガティブのうち 4 個を実際のドキュメントのスクリーンショット画像に解決しているため、追加の前処理なしで学習にそのまま使用できます:
from datasets import load_dataset
train_dataset = load_dataset("tomaarsen/llamaindex-vdr-en-train-preprocessed", "train", split="train")
train_dataset = train_dataset.select_columns(["query", "image", "negative_0"])
eval_dataset = load_dataset("tomaarsen/llamaindex-vdr-en-train-preprocessed", "eval", split="train")
train 設定には最初の 10,000 サンプルが含まれ、eval 設定には次の 300 サンプルが含まれます(53,512 サンプルすべてを含む full 設定も利用可能です)。学習では、query、image、および negative_0 を選び、(アンカー、ポジティブ、ハードネガティブ) のトリプレットを構成します。追加のハードネガティブを含めることで学習シグナルが改善する可能性はありますが、追加するたびにメモリ使用量と学習時間も増えるため、今回は 1 つに固定しています。評価では、より難しい検索用コーパスを構築するために、各クエリにつき 4 つのハードネガティブをすべて保持します(詳細は Evaluator セクションで説明します)。
データセットの形式
テキストのみでの学習と同様に、データセットの形式は選択した 損失関数 に一致している必要があります。ルールは同じです:
- 損失関数が ラベル(Label) を必要とする場合、データセットには "label" または "score" という名前の列が必要です。
- "label" または "score" 以外のすべての列は 入力(Inputs) とみなされます。これらの列数は、選択した損失関数に対して有効な入力の数と一致していなければなりません。ラベル列以外では、列名は問題ではなく、順序だけが重要です。
マルチモーダルデータセットの場合、入力には以下を含めることができます:
- テキスト:文字列。
- 画像:PIL画像、ファイルパス、URL、または numpy/torch の配列。
- 音声:ファイルパス、numpy/torch の配列、
"array"と"sampling_rate"キーを持つ辞書、または(torchcodecがインストールされている場合)torchcodec.AudioDecoderインスタンス。 - 動画:ファイルパス、numpy/torch の配列、
"array"と"video_metadata"キーを持つ辞書、または(torchcodecがインストールされている場合)torchcodec.VideoDecoderインスタンス。 - マルチモーダル辞書:モダリティ名を値に対応させる辞書。例:
{"text": ..., "image": ...}。キーは"text"、"image"、"audio"、または"video"である必要があります。
データコラレータは自動的に model.preprocess() を呼び出し、各入力のモダリティを検出して適切な前処理を適用します。手動でのトークン化や画像処理は不要です。
Sentence Transformers と組み合わせてそのまま動く多くの Hugging Face データセットには
sentence-transformersタグが付けられており、https://huggingface.co/datasets?other=sentence-transformers で簡単に見つけられます。
損失関数
CachedMultipleNegativesRankingLoss
この学習では、検索タスクによく使われる CachedMultipleNegativesRankingLoss を使用します。これは、(クエリ, ポジティブ)のペアを受け取り、追加のハードネガティブ列を任意の数(0 から n まで)含められますが、各サンプルが同じ数のネガティブを持っている必要があります。
学習中、損失関数は各クエリの類似度について、ポジティブとは 上げる ように、すべてのネガティブとは 下げる ように促します。ネガティブは次の 2 つのソースから得られます:
- ハードネガティブ:データセットで明示的に与えられるネガティブ列(トリプレットのセットアップでは
negative_0だけです)。 - バッチ内ネガティブ:同じバッチ内の 他のすべて のサンプルから得られるポジティブとハードネガティブを、それぞれこのクエリの追加のネガティブとして再利用します(追加コストなし)。
クエリあたりのネガティブ数が多いほど学習の学習シグナルが強くなるため、バッチサイズを大きくすることがそのまま学習品質の向上につながります。それに加えて、「cached(キャッシュ)版」では、GPU メモリが限られている場合でも大きな実効バッチサイズを実現できるように、勾配キャッシュを使用します。
mini_batch_size パラメータは、キャッシュ付きの forward 計算で一度に処理するサンプル数を制御します。大規模なマルチモーダルモデルでは、例えば 1 のように小さい値に設定することが重要です。そうすることで、実効バッチサイズを大きくする利点を損なうことなく、メモリ不足(OOM)エラーを回避できます:
from sentence_transformers.sentence_transformer.losses import CachedMultipleNegativesRankingLoss
返却形式: {"translated": "翻訳されたHTML"}loss = CachedMultipleNegativesRankingLoss(model, mini_batch_size=1)
MatryoshkaLoss
複数の次元数でうまく機能する埋め込みを作るために、基礎となる損失にMatryoshkaLossでラップします。これにより、埋め込みをより少ない次元数に切り詰めても、なお良好な性能が得られるようにモデルを学習します:
from sentence_transformers.sentence_transformer.losses import CachedMultipleNegativesRankingLoss, MatryoshkaLoss
loss = CachedMultipleNegativesRankingLoss(model, mini_batch_size=1)
loss = MatryoshkaLoss(model, loss, matryoshka_dims=[2048, 1536, 1024, 512, 256, 128, 64])
これは特にマルチモーダルモデルに有用です。マルチモーダルモデルでは、埋め込みが大きくなりがち(Qwen3-VLで2048次元)です。Matryoshka学習を行うことで、デプロイ時に切り詰めた埋め込み(例:256次元または128次元)を使って、品質の損失を最小限にしつつ高速な検索を実現できます。結果のセクションで示すように、微調整済みモデルは512次元のときでもほぼピークに近い性能を達成します。
Training Arguments
SentenceTransformerTrainingArgumentsクラスを使うと、学習のハイパーパラメータを制御できます。以下はVDRの微調整に使用した設定です:
from sentence_transformers.sentence_transformer.training_args import SentenceTransformerTrainingArguments, BatchSamplers
run_name = "Qwen3-VL-Embedding-2B-vdr"
args = SentenceTransformerTrainingArguments(
# 必須パラメータ:
output_dir=f"models/{run_name}",
# 任意の学習パラメータ:
num_train_epochs=1,
per_device_train_batch_size=64,
per_device_eval_batch_size=64,
learning_rate=2e-5,
warmup_ratio=0.1,
fp16=False,
bf16=True,
batch_sampler=BatchSamplers.NO_DUPLICATES,
# 任意のトラッキング/デバッグパラメータ:
eval_strategy="steps",
eval_steps=0.1,
save_strategy="steps",
save_steps=0.1,
save_total_limit=2,
logging_steps=0.05,
run_name=run_name,
)
(マルチモーダル)学習に関して、いくつか注意点があります:
bf16=True:bfloat16は、数値の安定性がより良いため、一般にfloat16よりも好まれます。batch_sampler=BatchSamplers.NO_DUPLICATES:MultipleNegativesRankingLoss、またはそのキャッシュ版を使う場合、バッチ内に重複サンプルがないことで、バッチ内の各ネガティブが本当に別のサンプルになることが保証されます。per_device_train_batch_size=64:2BパラメータのVLMに対しては大きく見えるかもしれませんが、mini_batch_size=1のCachedMultipleNegativesRankingLossが、勾配キャッシュによってメモリ制約を処理します。eval_steps、save_steps、およびlogging_steps:これらを割合(例:0.1)に設定すると、評価・保存・ログ出力がエポックの10%ごとに行われることを意味します。学習の進捗をモニタリングするのに役立ちます。
評価器(Evaluator)
学習の前・最中・後の取得(リトリーバル)性能を追跡するために、InformationRetrievalEvaluatorを使用します。NDCG@10、MAP、Recall@kといった標準的な取得指標を計算します:
from sentence_transformers.sentence_transformer.evaluation import InformationRetrievalEvaluator
# 評価データをevalデータセットから構築する。
# クエリとコーパスは整数IDを使用:クエリ0の関連文書はコーパス0。
eval_queries = {qid: sample["query"] for qid, sample in enumerate(eval_dataset)}
eval_corpus = {did: sample["image"] for did, sample in enumerate(eval_dataset)}
num_eval = len(eval_dataset)
# オフセットIDを使ってコーパスにハードネガティブを追加(num_eval、2*num_eval、...)
# これにより、ポジティブ文書ID(0..num_eval-1)と衝突しない。
negative_columns = ["negative_0", "negative_1", "negative_2", "negative_3"]
for neg_idx, neg_col in enumerate(negative_columns):
for did, sample in enumerate(eval_dataset):
eval_corpus[num_eval * (neg_idx + 1) + did] = sample[neg_col]
# 各クエリの関連文書は、同じインデックスにあるポジティブ
eval_relevant_docs = {idx: [idx] for idx in range(len(eval_dataset))}
eval_evaluator = InformationRetrievalEvaluator(
queries=eval_queries,
corpus=eval_corpus,
relevant_docs=eval_relevant_docs,
batch_size=1,
show_progress_bar=True,
name="vdr-eval-hard",
)
この評価器は、テキストクエリ、画像のコーパス(ハードネガティブを含む)、そして「どの文書がどのクエリに関連しているか」を示すマッピングを受け取ります。コーパスには、ポジティブ文書とハードネガティブ文書のスクリーンショットが混在しているため、この評価は難しくなっています。大きなVLMの評価中にOOM(メモリ不足)を防ぐために batch_size=1 を使用します。
トレーナー
SentenceTransformerTrainer がすべてをまとめます。以下が完全なトレーニングスクリプトです:
from datasets import load_dataset
from sentence_transformers import SentenceTransformer
from sentence_transformers.sentence_transformer.evaluation import InformationRetrievalEvaluator
from sentence_transformers.sentence_transformer.losses import CachedMultipleNegativesRankingLoss, MatryoshkaLoss
from sentence_transformers.sentence_transformer.model_card import SentenceTransformerModelCardData
from sentence_transformers.sentence_transformer.trainer import SentenceTransformerTrainer
from sentence_transformers.sentence_transformer.training_args import (
BatchSamplers,
SentenceTransformerTrainingArguments,
)
# 1. (任意の)モデルカードデータ付きで、ファインチューニングするモデルをロードする
model = SentenceTransformer(
"Qwen/Qwen3-VL-Embedding-2B",
model_card_data=SentenceTransformerModelCardData(
language="en",
license="apache-2.0",
model_name="Qwen3-VL-Embedding-2B model trained on Visual Document Retrieval query-document screenshot pairs",
),
model_kwargs={"attn_implementation": "flash_attention_2", "torch_dtype": "bfloat16"},
# 画像解像度を制御する:値を小さくするとメモリ節約、値を大きくすると詳細を保持
processor_kwargs={"min_pixels": 28 * 28, "max_pixels": 600 * 600},
)
# 2. ファインチューニングに使用するデータセットをロード:トレーニング用の(query、positive、negative_0)のトリプレット、
# 評価には4つすべてのハードネガティブを保持する
train_dataset = load_dataset("tomaarsen/llamaindex-vdr-en-train-preprocessed", "train", split="train")
train_dataset = train_dataset.select_columns(["query", "image", "negative_0"])
eval_dataset = load_dataset("tomaarsen/llamaindex-vdr-en-train-preprocessed", "eval", split="train")
# 3. 損失関数を定義する
loss = CachedMultipleNegativesRankingLoss(model, mini_batch_size=1)
loss = MatryoshkaLoss(model, loss, matryoshka_dims=[2048, 1536, 1024, 512, 256, 128, 64])
返却形式: {"translated": "翻訳されたHTML"}# 4. (オプション)学習引数を指定する
run_name = "Qwen3-VL-Embedding-2B-vdr"
args = SentenceTransformerTrainingArguments(
# 必須パラメータ:
output_dir=f"models/{run_name}",
# オプションの学習パラメータ:
num_train_epochs=1,
per_device_train_batch_size=64,
per_device_eval_batch_size=64,
learning_rate=2e-5,
warmup_ratio=0.1,
fp16=False, # VLMではFP16よりBF16の方が数値安定性が高いため推奨
bf16=True, # GPUがBF16に対応している場合はTrueにする(ほとんどの最新GPUは対応しています)
batch_sampler=BatchSamplers.NO_DUPLICATES, # MultipleNegativesRankingLossは重複なしが有利です
# オプションの追跡/デバッグパラメータ:
eval_strategy="steps",
eval_steps=0.1,
save_strategy="steps",
save_steps=0.1,
save_total_limit=2,
logging_steps=0.05,
run_name=run_name, # 例:Trackioがインストールされていれば使用されます
# report_to=["codecarbon", "trackio"], # ロギングを有効にするにはこの行のコメントを外す(pip install codecarbon trackio)
)
# 5. (オプション)評価器を作成して、ベースモデルを評価する
eval_queries = {qid: sample["query"] for qid, sample in enumerate(eval_dataset)}
eval_corpus = {did: sample["image"] for did, sample in enumerate(eval_dataset)}
num_eval = len(eval_dataset)
negative_columns = ["negative_0", "negative_1", "negative_2", "negative_3"]
for neg_idx, neg_col in enumerate(negative_columns):
for did, sample in enumerate(eval_dataset):
eval_corpus[num_eval * (neg_idx + 1) + did] = sample[neg_col]
eval_relevant_docs = {idx: [idx] for idx in range(len(eval_dataset))}
eval_evaluator = InformationRetrievalEvaluator(
queries=eval_queries,
corpus=eval_corpus,
relevant_docs=eval_relevant_docs,
batch_size=1,
show_progress_bar=True,
name="vdr-eval-hard",
)
eval_evaluator(model)
# 6. トレーナーを作成して学習する
trainer = SentenceTransformerTrainer(
model=model,
args=args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
loss=loss,
evaluator=eval_evaluator,
)
trainer.train()
# 7. (オプション)Matryoshkaの各次元で評価する
eval_evaluator(model)
for dim in [2048, 1536, 1024, 512, 256, 128, 64]:
dim_evaluator = InformationRetrievalEvaluator(
queries=eval_queries,
corpus=eval_corpus,
relevant_docs=eval_relevant_docs,
truncate_dim=dim,
batch_size=1,
show_progress_bar=True,
name=f"vdr-eval-hard-{dim}d",
)
dim_evaluator(model)
# 8. 学習済みモデルを保存する
model.save_pretrained(f"models/{run_name}/final")
# 9. (オプション)Hugging Face Hubにプッシュする
# これはあなたの個人用ネームスペースへプッシュします。例:{your_username}/Qwen3-VL-Embedding-2B-vdr
model.push_to_hub("Qwen3-VL-Embedding-2B-vdr")
学習スクリプトは、テキストのみの学習スクリプトとほぼ同一です。違いは次の3点だけです:
- モデルの読み込み:精度と注意(attention)の実装については
model_kwargsを渡し、画像解像度の上限/下限についてはprocessor_kwargsを渡します。 - 損失関数:メモリ不足を防ぐため、大きなVLMを扱うには
mini_batch_size=1を指定したCachedMultipleNegativesRankingLossを使用します。 - 評価器:評価器はコーパス内に画像を使い、クエリにはテキストを用いることで、クロスモーダルな検索(retrieval)の評価を可能にします。
それ以外(トレーナー、学習引数、データセットの読み込み)は、テキストのみの学習とまったく同じように動作します。
結果
モデルサイズとNDCG@10
わずか1エポックのトレーニング後に、微調整(finetuned)された tomaarsen/Qwen3-VL-Embedding-2B-vdr モデルは、評価用データセット(300クエリ、1500コーパス文書、コサイン類似度)において NDCG@10 が0.947を達成します。これは、基盤(base)Qwen/Qwen3-VL-Embedding-2Bモデルの0.888から大幅に改善しており、既存のすべてのVDRモデルを上回ります:
モデル別のNDCG@10の全数値(20モデル)
| モデル | パラメータ | NDCG@10 |
|---|---|---|
| tomaarsen/Qwen3-VL-Embedding-2B-vdr | 2.1B | 0.947 |
| Qwen/Qwen3-VL-Embedding-8B | 8.1B | 0.923 |
| nvidia/omni-embed-nemotron-3b | 4.7B | 0.915 |
| nvidia/llama-nemotron-embed-vl-1b-v2 | 1.7B | 0.912 |
| nomic-ai/nomic-embed-multimodal-7b | 8.3B | 0.912 |
| llamaindex/vdr-2b-multi-v1 | 2.2B | 0.912 |
| llamaindex/vdr-2b-v1 | 2.2B | 0.911 |
| nomic-ai/nomic-embed-multimodal-3b | 3.8B | 0.899 |
| Qwen/Qwen3-VL-Embedding-2B | 2.1B | 0.888 |
| LCO-Embedding/LCO-Embedding-Omni-7B | 8.9B | 0.888 |
| LCO-Embedding/LCO-Embedding-Omni-3B | 4.7B | 0.860 |
| BAAI/BGE-VL-v1.5-zs | 7.6B | 0.800 |
| BAAI/BGE-VL-v1.5-mmeb | 7.6B | 0.797 |
| BAAI/BGE-VL-MLLM-S2 | 7.6B | 0.792 |
| BidirLM/BidirLM-Omni-2.5B-Embedding | 2.5B | 0.775 |
| royokong/e5-v | 8.4B | 0.767 |
| BAAI/BGE-VL-MLLM-S1 | 7.6B | 0.710 |
| sentence-transformers/clip-ViT-L-14 | 428M | 0.611 |
| BAAI/BGE-VL-large | 428M | 0.467 |
| BAAI/BGE-VL-base | 150M | 0.335 |
微調整された2Bモデルは、さらに8BのQwen3-VL-Embeddingモデルさえ上回り、タスク固有の微調整の力を示しています。より大きな汎用モデルが利用可能な場合でも、自分のドメインで微調整を行うことは検討する価値があることが多いです!
マトリョーシカ次元とNDCG@10
上の比較では、フルサイズ(2048次元)の埋め込みを使用しています。Matryoshkaトレーニングのおかげで、微調整済みモデルは次元をより少なく切り詰めた場合でも良好に性能を維持でき、配備時に埋め込みサイズと検索品質のトレードオフを行えるようになります:
微調整済みモデルのピークはフルの2048次元(0.948)ですが、512まで(4分の1)下げてもピーク値から0.3%以内に収まります。また、64まで下げても(32分の1)ピークの92%以上を維持します。Matryoshkaトレーニングは最も重要な情報をより早い次元に集中させるため、中程度の切り詰めでは性能低下のコストが非常に小さくなります。
次元別のNDCG@10の全数値
| 次元 | ベースのNDCG@10 | 微調整済みのNDCG@10 |
|---|---|---|
| 2048(フル) | 0.8961(100%) | 0.9480(100%) |
| 1536 | 0.8940(99.8%) | 0.9439(99.6%) |
| 1024 | 0.8941(99.8%) | 0.9464(99.8%) |
| 512 | 0.8760(97.8%) | 0.9451(99.7%) |
| 256 | 0.8347(93.2%) | 0.9372(98.9%) |
| 128 | 0.7888(88.0%) | 0.9058(95.5%) |
| 64 | 0.6852(76.5%) | 0.8758(92.4%) |
1024次元と2048次元の差は小さいです(0.946対0.948)ので、モデルの設定にtruncate_dim=1024を指定して保存しました。これにより、SentenceTransformer("tomaarsen/Qwen3-VL-Embedding-2B-vdr")はデフォルトで1024次元の埋め込みを生成します。これは、全2048次元と比べて保存容量のフットプリントを半分に抑えられるということです。別の次元数にしたい場合は、読み込み時にtruncate_dim=Nを指定して上書きしてください。
Training Multimodal Reranker Models
同じ学習インフラストラクチャを使って、多モーダルのCross Encoder(reranker)モデルも微調整できます。主な違いは、CrossEncoderTrainerとCross Encoder専用の損失関数を使うことです。このセクションでは概要を簡単に説明します。データセットの準備や評価を含む、完全に実行できるスクリプトは完全な学習例を参照してください。
以下は、doodles学習スクリプトに基づく簡略化した例で、画像とテキストのキャプションを対応付けるためのrerankerを学習します:
from sentence_transformers.cross_encoder import CrossEncoder
from sentence_transformers.cross_encoder.losses import BinaryCrossEntropyLoss
from sentence_transformers.cross_encoder.modules import LogitScore, Transformer
from sentence_transformers.cross_encoder.trainer import CrossEncoderTrainer
from sentence_transformers.cross_encoder.training_args import CrossEncoderTrainingArguments
# 1. モジュールからモデルを構築する
transformer = Transformer(
"Qwen/Qwen3.5-0.8B",
transformer_task="any-to-any",
model_kwargs={"torch_dtype": "bfloat16", "device_map": "auto", "attn_implementation": "flash_attention_2"},
processing_kwargs={"chat_template": {"add_generation_prompt": True}},
)
# 「query」と「document」ロールをサポートするようにチャットテンプレートを拡張する
transformer.processor.chat_template = transformer.processor.chat_template.replace(
'message.role == "user"', 'message.role in ["user", "query", "document"]'
)
# LogitScore: score = log(P("1")) - log(P("0"))
score_head = LogitScore(
true_token_id=transformer.tokenizer.convert_tokens_to_ids("1"),
false_token_id=transformer.tokenizer.convert_tokens_to_ids("0"),
)
model = CrossEncoder(
modules=[transformer, score_head],
num_labels=1,
prompts={
"image_to_text": "Given the image, judge whether the text matches it. Respond with 1 if they match, 0 if they don't.",
"text_to_image": "Given the text, judge whether the image matches it. Respond with 1 if they match, 0 if they don't.",
},
)
# 2. 損失を定義する
loss = BinaryCrossEntropyLoss(model)
# 3. 別々の方向性でのマルチデータセット学習
trainer = CrossEncoderTrainer(
model=model,
args=args,
train_dataset={"image_to_text": train_image_to_text, "text_to_image": train_text_to_image},
eval_dataset={"image_to_text": eval_image_to_text, "text_to_image": eval_text_to_image},
loss=loss,
evaluator=[image_to_text_evaluator, text_to_image_evaluator],
)
trainer.train()
マルチモーダルなrerankerには、次のように複数の正当なアーキテクチャ上の選択肢があります。
- Any-to-Any + LogitScore:マルチモーダル言語モデルを使ってトークンを生成し、その後「1」と「0」の対数オッズを計算します。
- 特徴抽出 + Pooling + Dense:マルチモーダルのベースモデルのみを使用し、最後トークンの隠れ状態を取り出してDense層を通じてスコアに射影します。言語モデリングヘッドの計算を回避します。
これら2つのアプローチは、multimodal cross encoder training examplesで実演されています。
上記の2つのスクリプトは、学習データを2つのデータセット(各方向:image-to-text と text-to-image)に分割します。そして、それぞれの方向でモデルがその方向におけるスコアリングをどう行うかを指示する、タスク固有のプロンプトを用意します。各ポジティブペアは、その後ランダムにサンプリングしたネガティブを追加して拡張されるため、損失は一致と不一致がバランスよく混ざった状態を見ます。
追加のリソース
過去のブログ記事
- Sentence Transformers によるマルチモーダルの埋め込み & リランカー・モデル: マルチモーダル推論
- Sentence Transformers v3 での埋め込みモデルの学習とファインチューニング: 埋め込みモデルの学習
- Sentence Transformers v4 でのリランカー・モデルの学習とファインチューニング: リランカー・モデルの学習
- Sentence Transformers v5 でのスパース埋め込みモデルの学習とファインチューニング: スパース埋め込みモデルの学習
学習の例
Sentence Transformers リポジトリには、いくつかのマルチモーダル学習の例が含まれています:
- ビジュアルドキュメント・リトリーバル: ドキュメントのスクリーンショットを取得するために、VLM ベースの埋め込みモデルをファインチューニングするためにこのブログ記事で使用した学習スクリプト
- マルチモーダル・リランカー(Any-to-Any): LogitScore を使ってマルチモーダルのリランカーを学習する
- マルチモーダル・リランカー(特徴抽出): Pooling + Dense を使ってマルチモーダルのリランカーを学習する
ドキュメンテーション
さらに、Sentence Transformers での学習について詳しく知るために、次のページが役立つ可能性があります:
- Sentence Transformer > トレーニング概要
- Sentence Transformer > 損失関数の概要
- Cross Encoder > トレーニング概要
- Cross Encoder > 損失関数の概要
- データセット概要
- API リファレンス






