llama.cppのフォークには複数の修正を加えました。これにより、マルチモーダルjina-embeddings-v4で動作するようになります。
jina-embeddings-v4 は、テキスト、画像、および複雑な視覚ドキュメントをベクトル検索用に処理できる、最先端のマルチモーダル埋め込みを導入しています。数週間前、テキストのみのタスク向けに v4のGGUFと動的量子化をリリースしました。これにより、VRAMフットプリントが小さくなり、パフォーマンスが向上します。ただし、GGUFでのマルチモーダル埋め込みのサポートはまだありませんでした。全体像を把握するために、llama.cppとGGUFでマルチモーダル埋め込みを生成する方法を考え出しました。詳細な手順については、こちらのREADMEファイルをご覧ください。
公平を期すために言うと、llama.cppの上流にはマルチモーダル入力のサポートがいくつかありますが、llama.cppコミュニティのほとんどがLLMとテキスト生成に焦点を当てているため、マルチモーダル埋め込み出力のサポートは完全に欠落しています。この記事では、llama.cppにマルチモーダル埋め込みを実装した方法を説明し、jina-embeddings-v4のPyTorchバージョンと比較して、そのパフォーマンス(および2つの量子化バージョン)を検証します。この記事全体を通して、PyTorchバージョンを「参照モデル」と呼びます。
tagLlama.cppでの画像入力を理解する
まず、参照モデルでマルチモーダル埋め込みがどのように行われるかを振り返りましょう。最初に、各画像入力を特別なプロンプトとペアにします。
<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>Describe the image.<|im_end|>\n
次に、モデルは画像を前処理し、(ViTを介して)エンコードし、インターリーブされたシーケンス全体を1回の順方向パスで処理します。
ただし、llama.cppの場合、状況はより複雑です。チャット補完のための画像入力はサポートしていますが、マルチモーダル入力、つまりテキストと画像を組み合わせた(上記の例のような)入力はサポートしていません。これがまさにllama.cppをフォークした理由であり、埋め込みハンドラーがbase64エンコードされた画像を受け入れるように変更し、チャット補完ハンドラーと同様の方法でマルチモーダルコンテンツを処理できるようにしました。
そのため、llama.cppでマルチモーダル入力を処理するには、参照モデルで使用されるものと同様のプロンプトから開始できます。
<|im_start|>user\n<__image__>Describe the image.<|im_end|>\n
プロセスは次のようになります。
- llama.cppは、
<__image__>トークンをビジョンマーカー<|vision_start|>と<|vision_end|>で囲み、次のようなものにします。<|im_start|>user\n<|vision_start|><__image__><|vision_end|>Describe the image.<|im_end|>\n - トークナイザーは、プロンプトをトークン化するときに、特別な
<__image__>トークンを-1に置き換え、シーケンスに(後で処理される前に)エンコードされるべき画像が含まれていることを内部的に通知します。 <__image__>マーカーの前のテキストトークン(つまり、<|im_start|>user\n<|vision_start|>)はLLMを介してデコードされ、KVCacheに挿入されます。- 画像はViTコンポーネントを介してエンコードされ、LLMを介してデコードされる一連の画像トークンが出力されます。これらのトークンはステップ1のトークンとは別に処理されますが、アテンションレイヤーは
KVCacheを介してこれらのテキストトークンにアクセスできます。ただし、この時点では、アテンションレイヤーはそれ以降のテキストトークン(<|vision_end|>Describe the image.<|im_end|>\n)にアクセスできません。 - LLMは残りのテキストトークン(
<|end_vision|>Describe the image.<|im_end|>\n)をデコードします。これで、アテンションレイヤーはKVCacheを介して、以前のすべてのトークン(テキストと画像の両方)にアクセスできます。
埋め込み推論プロセス(画像エンコードとテキスト/画像トークンデコード)を次の図に示します。

tag画像トークンへの注意
アテンションメカニズムのため、この複数ステップのプロセスは一部のモデルで問題になる可能性があります。モデルで使用されるさまざまなタイプのアテンションを簡単にまとめましょう。
- 因果的注意 - 位置
kの1つのトークンのアテンションメカニズムは、位置[0:k-1]の前のトークンのみにアクセスします。 - 非因果的注意 - 位置
kの1つのトークンのアテンションメカニズムは、シーケンス[0:n]内のすべてのトークンにアクセスします。
次の図は、2番目のステップで img_tok_n を処理するときに、アテンションメカニズムがアクセスするトークンを示しています。

img_tok_n を処理するときのモデルの状態は次のようになります。
- 以前のすべてのテキストトークン(
<|im_start|>、user、\n、<|vision_start|>)はすでに処理され、KVCacheに保存されています。 - すべての画像トークン(
img_tok_1からimg_tok_n)は、同じシーケンスの一部として、この時点で処理されます。 - 後続のすべてのテキストトークン(
<|vision_end|>、Describeなど)は後で処理されます。
因果的注意の場合、アテンションスコアを計算するときに、以前のトークンのみが考慮され、過去のトークンは KVCache を介して取得されます。
非因果的注意の場合、すべてのトークンを考慮する必要があります。ただし、将来のテキストトークン(<|vision_end|>、Describeなど)はまだ処理されていません。これらは将来のステップで処理されるため、すぐに中断されます。
jina-embeddings-v4 は因果的注意を使用しているため、複数ステップのプロセスは問題なく動作しますが、他のモデルではそうでない場合があります。
埋め込みに関しては、各トークンの隠れ状態は、処理されたときに正確にキャプチャされ、最後に単一のシーケンスに結合されます。現在、正規化とプーリングはPythonで処理されていますが、(追加の作業を行うと)これはllama.cpp側でも実行できます。
tag修正点
llama.cppサーバーの埋め込みエンドポイントで画像入力を有効にした後、ベンチマークで実装のテストを開始し、参照モデルと比較して驚くほど大きな違いが見られました。Qwen2.5 LLMが処理できる画像パッチ埋め込み(画像の正方形の密なベクトル表現)に画像をエンコードするためにQwen2.5-VLで使用されるViTのllama.cppの実装に問題があるのではないかと考えました。
参照モデルとllama.cppの実装でViT出力がどのように異なるかの例を次に示します。
=== vit_out reference === Shape: [1008, 1280]
Logging patch 0, dimensions 0-9
Patch 0: -0.375000 -0.250000 -4.281250 -5.968750 2.953125 -8.125000 8.625000 -9.250000 8.937500 -0.332031 ... (dims 10-1279)
... (patches 1-1007 not shown)=== vit_out llama.cpp === Shape: [1280, 1008, 1, 1]
Logging patch 0, dimensions 0-9
Patch 0: -2.998136 -2.226554 0.233671 -7.486460 0.596918 -12.889042 8.904849 -8.6
... (patches 1-1007 not shown)ご覧のとおり、違いはかなり顕著です。これが唯一の問題であることを確認するために、Pythonで画像トークンを事前に計算し、Qwen2.5のllama.cppの実装(LLMのみを使用)を使用してデコードし、結果の埋め込みが参照モデルの値とはるかに一致することを期待しました—しかし、そうではありませんでした。
tag修正1:アテンションレイヤーの因果的注意マスク
数値の違いの最も可能性の高い原因であるアテンションレイヤーを調べて、デバッグを続けました。アテンションレイヤーで使用されるアテンションマスクが、画像トークンに対して適切に計算されていないことに気付きました。これを確認するために、例のシーケンスに戻ることができます。
<|im_start|>user\n<|vision_start|><__image__><|vision_end|>Describe the image.<|im_end|>\n
画像トークンを処理するとき、<__image__> マーカーは img_tok_1 img_tok_2 .... img_tok_last のようなものに展開されます。したがって、完全なシーケンスは次のようになります。
<|im_start|>user\n<|vision_start|> img_tok_1 img_tok_2 ... img_tok_last <|vision_end|>Describe the image.<|im_end|>\n
<|im_start|>、userなど)と画像トークン(img_tok_1、img_tok_2 など) は、密なベクトルであり、文字通りのテキストの**詞元**ではありません。説明を簡単にするために、この形式のシーケンスを使用します。img_tok_2 をデコードする際、注意機構はすべて以前の**詞元**、すなわち以下に注意を払う必要があります。
<|im_start|>user\n<|vision_start|> img_tok_1
しかし、注意マスクのバグにより、機構は代わりに画像シーケンス全体に注意を払うようになっていました。以下のように。
<|im_start|>user\n<|vision_start|> img_tok_1 img_tok_2 ... img_tok_last
このバグを修正した後、llama.cpp モデルの**埋め込み**(Torch モデルの ViT から事前計算された画像**詞元**を使用)は、最終的に参照モデルの**埋め込み**と(わずかな誤差範囲内で)一致しました。
tag修正 #2: 画像処理とパッチ**埋め込み**
llama.cpp の ViT エンコーダーも、参照モデルとは異なる画像**埋め込み**を生成していました。この数値は、前処理の直後から乖離していました。これは特に、初期のパッチ作成ステップで顕著でした。ここでは、参照モデルと llama.cpp の両方が、生の画像(ピクセル値)を畳み込み層を介してエンコードされるパッチに分割します。生のパッチ(ViT 処理前)の間の違いは、以下で見ることができます。
=== raw_patches reference === Shape: [1008, 1176]
Logging patches 0-4, dimensions 0-9
Patch 0: 0.484375 0.484375 0.500000 0.500000 0.470703 0.470703 0.470703 0.484375 0.470703 0.484375 ... (dims 10-1175)
... (patches 1-1007 not shown)=== raw_patches llama.cpp === Shape: [1176, 1008, 1, 1]
Logging patches 0-4, dimensions 0-9
Patch 0: 0.455895 0.455895 0.455895 0.455895 0.455895 0.455895 0.470494 0.470494 0.470494 0.470494 ... (dims 10-1175)
... (patches 1-1007 not shown)参照モデルと llama.cpp は、これらのパッチを異なる方法で処理します。
- 参照モデルは、reshape 操作を使用してピクセル値をグループ化し、次に単一の conv3d 層を使用して、事前グループ化されたピクセルパッチをエンコードします。
- llama.cpp モデルは、2 つの conv2d 層を使用してこれらのパッチを作成およびエンコードします。
llama.cpp モデルの**埋め込み**を参照モデルのものに近づけるために、llama.cpp のアプローチをデバッグするよりも、参照モデルの正確な操作を使用する方が簡単だと考えました。
参照モデルは、9 次元のテンソルを必要とする複雑な reshape および転置操作を使用してピクセルパッチを生成します。llama.cpp で使用される低レベルのテンソル処理ライブラリ — ggml — は、これらをサポートできないため、これを回避するために、HTTP 経由で llama.cpp サーバーを呼び出す別の Python サービスを使用してパッチを生成しました。
ggml は、conv3d 層もサポートしていません。参照モデルでは、conv3d 層の構成は次のようになります。
kernel_size = [
2, # temporal_patch_size,
14, # patch_size
14 # patch_size
]
proj = nn.Conv3d(
3, # in_channels
1152, # embed_dim,
kernel_size=kernel_size,
stride=kernel_size,
bias=False
)
stride と kernel_size が同じであるため、conv3d 層の入力と重みを平坦化し、代わりに単純な行列乗算演算を実行できます。これを行うために、llama.cpp の変換スクリプト (convert_hf_to_gguf.py) を変更して、パッチ投影層の conv3d 重みの平坦化されたバージョンをエクスポートしました。
if 'patch_embed.proj.weight' in name:
c1, c2, kt, kh, kw = data_torch.shape
# Note: this part of the script also exports other versions of this layer
# Only showing the relevant parts
# Flat matmul weight: row-major [out, in*kT*kH*kW] = [embed_dim, 1176]
W_flat = data_torch.contiguous().view(c1, -1)
outputs.append(("v.patch_embd.weight_flat", W_flat))
llama.cpp で 2 つの conv2d 層の代わりに matmul 演算を適用するために、Qwen2.5-VL ViT のグラフを構築するコードを変更しました。
ggml_tensor * build_inp_raw_precomputed() {
ggml_tensor * inp_raw = ggml_new_tensor_2d(
ctx0,
GGML_TYPE_F32,
img.p_dim,
img.npx * img.npy
);
ggml_set_name(inp_raw, "inp_raw");
ggml_set_input(inp_raw);
return inp_raw;
}
ggml_cgraph * build_qwen2vl() {
// NOTE: only showing the code we've added for using pre-arranged image patches
const bool uses_precomputed_image = img.is_precomputed;
ggml_tensor * inp = nullptr;
if (uses_precomputed_image) {
ggml_tensor * inp_raw = build_inp_raw_precomputed();
cb(inp_raw, "inp_raw", -1);
inp = ggml_mul_mat(ctx0, model.patch_embeddings_flat, inp_raw);
} else {
// Usual 2x conv2d path
}
// rest of the code
}
これらの最後の変更により、最終的な画像**埋め込み**は、参照モデルと比較して 2% の誤差範囲内になりました (次のセクションの評価表を参照)。
tag評価
これらの変更を加えた後、ViDoRe タスクで MTEB ベンチマークを使用して、llama.cpp モデルをリファレンスモデルに対して評価しました。これらの結果を llama.cpp フォークと 2 つの量子化されたバージョンで再現するためのスクリプトと手順を参照できます。
| タスク | 参照モデル | llama.cpp (F16) | llama.cpp (Q4_K_M) | llama.cpp (IQ4_XS) |
|---|---|---|---|---|
| VidoreArxivQARetrieval | 83.55 | 85.00 | 84.38 | 84.34 |
| VidoreDocVQARetrieval | 50.53 | 52.02 | 51.93 | 51.57 |
| VidoreInfoVQARetrieval | 87.77 | 87.31 | 87.61 | 87.28 |
| VidoreShiftProjectRetrieval | 84.07 | 82.25 | 82.56 | 81.73 |
| VidoreSyntheticDocQAAIRetrieval | 97.52 | 96.71 | 97.28 | 97.15 |
| VidoreSyntheticDocQAEnergyRetrieval | 91.22 | 90.34 | 90.47 | 90.30 |
| VidoreSyntheticDocQAGovernmentReportsRetrieval | 91.61 | 93.84 | 93.47 | 94.47 |
| VidoreSyntheticDocQAHealthcareIndustryRetrieval | 95.42 | 96.08 | 95.67 | 96.05 |
| VidoreTabfquadRetrieval | 94.52 | 94.94 | 94.83 | 94.72 |
| VidoreTatdqaRetrieval | 65.52 | 64.85 | 64.63 | 64.76 |
| 平均 | 84.17 | 84.33 | 84.28 | 84.23 |
結果の表を見ると、平均して、llama.cpp モデルとその量子化されたバリアントは、参照モデルからあまり乖離していません。
モデルをより詳細に比較するために、さまざまなドメインおよび異なる解像度の画像を使用して、画像パッチ**埋め込み**間の距離をプロットしました(プーリング/正規化前)。パッチが赤いほど、その特定のパッチのリファレンスモデルと llama.cpp モデルのベクトル間のコサイン距離が大きくなります。


図 3: jina-embeddings-v4 技術レポートからのページ、372 × 526 解像度 (左)、2481 × 3508 解像度 (右)


図 4: Jina AI Web サイトからのスクリーンショット、594 × 428 解像度 (左)、1982 × 1428 解像度 (右)


図 5: 東京、渋谷 (撮影: S K) (提供: Unsplash)、383 × 255 解像度 (左)、5472 × 3649 解像度 (右)
数値的な精度の違いを超えるパターン、つまり、モデル間のさらなるバグや違いを明らかにする可能性のあるパターンを見つけることを目指しました。ただし、画像解像度が高くなるほど、乖離するパッチの数が増えることを除いて、特定のパターンは見られませんでした。これらの違いは、おそらくバックエンドの違いによるものであり、Qwen2.5-VL(jina-embeddings-v4 のバックボーンモデル)の実装における特定のバグによるものではありません。
とは言え、これらの差はごくわずかであり、ベンチマークの結果もその事実を反映していることを改めて強調する必要があります。全体として、llama.cpp モデルは、わずかに異なる埋め込みベクトルを生成するものの、参照モデルと同等の性能を発揮します。
tag残された課題
llama.cpp におけるマルチモーダルな 向量模型 に関しては、改善の余地がいくつかあります。
- ビジョンエンコーダの量子化。現在、llama.cpp は 大規模言語モデル(LLM) の量子化のみをサポートしていますが、より優れたスケーリングを実現するためには、ビジョンエンコーダも量子化したいと考えています。
llama-llava-quantize-cli を使用して CLIP モデルの量子化をサポートしていましたが、mtmd ライブラリの導入以来、関連リソースは削除されています。- ビジョンエンコーダを専用のサービスに分離する。ビジョンエンコーダは通常、非因果マスキングを使用します。つまり、特定の画像は単一のフォワードコール内でエンコードする必要があります。したがって、継続的なバッチ処理を利用することはできません。しかし、ビジョンエンコーダを別のサービスに分離することを検討できます。これにより、複数の画像(別々のソースからの画像も含む)をまとめてバッチ処理し、すべてを単一のフォワードパスでエンコードできます。これは、より高い vRAM 要件を意味しますが、各画像を 1 つずつエンコードするよりもはるかに高速になります。この分離は、言語モデルとは独立してビジョンエンコーダを自動スケーリングできることも意味します。
- マルチベクトル 向量模型 を有効にする。この記事では、シングルベクトルの 向量模型 のみを扱いました。しかし、jina-embeddings-v4 を最大限に活用するためには、マルチベクトル 向量模型 を有効にして、複雑な画像でより高い精度を達成したいと考えています。これらの 向量模型 は、ベースモデル上に単一の線形レイヤーで生成されるため、簡単に追加できます。
tag結論
最初のバグや後退にもかかわらず、マルチモーダルな 向量模型 を llama.cpp に統合することで、さまざまなベンチマークタスクを含め、参照 PyTorch モデルとほぼ一致する結果が得られるようになりました。アテンションマスクと画像パッチ処理の修正により、主な乖離の原因が解消され、量子化されたバリアントでさえ、はるかに少ないリソースを使用しながら同様の精度を達成しています。より高い画像解像度での残りの差はわずかであり、コアモデルの実装ではなく、バックエンドのバリエーションによるものである可能性があります。
今後に向けて、量子化をビジョンエンコーダに拡張し、別のサービスを介したバッチ処理を有効にし、マルチベクトル 向量模型 をサポートすることで、効率と精度の両方をさらに向上させることができます。これらの追加により、llama.cpp におけるマルチモーダルな 向量模型 は、よりスケーラブルになり、現実世界のユースケースにより適したものになるでしょう。






