我們在 llama.cpp 的分支中引入了多項修正,使其能與多模態jina-embeddings-v4向量模型搭配使用。
jina-embeddings-v4 引入了最先進的多模態向量模型,可以處理文字、圖像和複雜的視覺文件,以進行向量搜尋。幾週前,我們發布了 v4 的 GGUF 和動態量化,用於純文字任務,它們提供了更小的 VRAM 佔用空間和更高的效能。然而,GGUF 上的多模態向量模型支援仍然缺失。為了補全這一塊,我們現在已經弄清楚如何使用 llama.cpp 和 GGUF 生成多模態向量模型。請查看 此 README 文件以獲得完整的逐步解說。
平心而論,llama.cpp 上游確實對多模態輸入有一些支援,但由於 llama.cpp 社群的大多數人專注於大模型和大模型生成,因此完全沒有對多模態向量模型輸出的支援。在本文中,我們將解釋我們如何在 llama.cpp 中實現多模態向量模型,並檢視它(以及兩個量化版本)與 jina-embeddings-v4 的 PyTorch 版本的效能比較。在本文中,我們將把 PyTorch 版本稱為我們的「參考模型」。
tag了解 Llama.cpp 中的圖像輸入
讓我們先回顧一下如何使用我們的參考模型完成多模態向量模型。首先,您將每個圖像輸入與一個特殊的提示詞配對:
<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>Describe the image.<|im_end|>\n
然後,模型會預處理圖像,對其進行編碼(透過其 ViT),然後在單個正向傳遞中處理整個交錯序列。
然而,當涉及到 llama.cpp 時,事情會變得更加棘手。雖然它支援用於聊天完成的圖像輸入,但它不支援多模態輸入——即結合了文字和圖像的輸入(如上所示)。這正是 我們 fork 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|>)會透過大模型進行解碼,並注入到KVCache中。 - 圖像透過 ViT 元件進行編碼,輸出透過大模型解碼的一系列圖像詞元。雖然這些詞元與步驟一中的詞元分開處理,但注意力層仍然可以透過
KVCache注意到那些文字詞元。然而,在這一點上,注意力層無法注意到任何後來的文字詞元(<|vision_end|>Describe the image.<|im_end|>\n)。 - 大模型會解碼任何剩餘的文字詞元(
<|end_vision|>Describe the image.<|im_end|>\n)。現在,注意力層可以透過KVCache注意到所有先前的詞元(文字和圖像)。
向量模型推論過程(圖像編碼和文字/圖像詞元解碼)如下圖所示:

tag圖像詞元的注意力
由於注意力機制,這種多步驟過程對於某些模型來說可能會產生問題。讓我們快速回顧一下模型中使用的不同類型的注意力:
- 因果注意力 - 位置
k處的一個詞元的注意力機制僅注意到先前位置[0:k-1]的詞元。 - 非因果注意力 - 位置
k處的一個詞元的注意力機制注意到序列[0:n]中的所有詞元。
下圖顯示了在第二步中處理 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 伺服器中的向量模型端點啟用圖像輸入後,我們開始使用基準測試來測試該實作,並發現與我們的參考模型相比,存在著令人驚訝的巨大差異。我們懷疑 llama.cpp 對 ViT 的實作一定存在問題,Qwen2.5-VL 使用該 ViT 將圖像編碼成圖像補丁向量模型(圖像正方形的密集向量表示),Qwen2.5 大模型可以處理這些圖像補丁向量模型。
以下是一個參考模型與 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 中預先計算了圖像詞元,然後使用 llama.cpp 的 Qwen2.5 實作(僅使用大模型)對它們進行解碼,希望產生的向量模型能夠更接近地匹配參考模型的值——但情況並非如此。
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_1img_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:圖像處理與 Patch **向量模型**
llama.cpp 的 ViT 編碼器也產生與參考模型不同的圖像**向量模型**,在預處理後數值立即出現差異。這在初始的 patch 建立步驟中尤其明顯,我們的參考模型和 llama.cpp 都將原始圖像(像素值)分割成通過卷積層編碼的 patches。原始 patches(在 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 以不同的方式處理這些 patches:
- 參考模型使用 reshape 運算對像素值進行分組,然後使用單個 conv3d 層來編碼預先分組的像素 patches。
- llama.cpp 模型使用兩個 conv2d 層創建和編碼這些 patches
為了使 llama.cpp 模型的**向量模型**更接近參考模型的**向量模型**,我們認為使用參考模型的確切運算,而不是調試 llama.cpp 的方法會更簡單。
我們的參考模型使用複雜的 reshape 和 transpose 運算來生成像素 patches,這些運算需要 9 維張量。llama.cpp 中使用的底層張量處理函式庫 — ggml — 無法支援它們,因此為了繞過這個問題,我們使用一個單獨的 Python 服務生成 patches,該服務透過 HTTP 呼叫 llama.cpp 伺服器。
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),以導出 patch 投影層的展平版本 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 中應用 matmul 運算來替代兩個 conv2d 層,我們修改了構建 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評估
在進行這些變更後,我們使用 MTEB 基準,針對我們的參考模型,在 ViDoRe 任務上評估了 llama.cpp 模型。您可以在我們的 llama.cpp 分支中看到 腳本和說明,以重現這些結果,以及兩個量化版本。
| 任務 | 參考模型 | 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 模型及其量化變體與參考模型沒有太大差異。
為了更深入地比較這些模型,我們使用了來自不同領域和具有不同解析度的圖像,繪製了圖像 patch **向量模型**之間的距離(在 pooling/normalization 之前)。patch 越紅,參考模型和 llama.cpp 模型對於該特定 patch 的向量之間的餘弦距離越大。


圖 3:來自 jina-embeddings-v4 技術報告的頁面,解析度為 372 × 526(左),2481 × 3508(右)


圖 4:來自 Jina AI 網站的螢幕截圖,解析度為 594 × 428(左),1982 × 1428(右)


圖 5:東京,澀谷,由 S K 拍攝於 Unsplash,解析度為 383 × 255(左),5472 × 3649(右)
我們的目標是找出任何超出數值精確度差異的模式,這些模式可能會揭示我們模型之間的其他錯誤或差異。然而,沒有可見的特定模式,除了發散的 patches 數量隨著圖像解析度增加而增加。這些差異很可能由於後端差異而出現,而不是因為 Qwen2.5-VL(jina-embeddings-v4 的骨幹模型)的實作中存在任何特定錯誤。
儘管如此,我們需要重申這些差異是極小的,並且基準測試結果也反映了這個事實。 總體而言,llama.cpp 模型與參考模型表現得一樣好,儘管產生了略有不同的向量模型。
tagRemaining Issues
llama.cpp 中 multimodal 向量模型有幾個潛在的改進領域:
- 量化視覺編碼器。目前,llama.cpp 僅支援大模型的量化,但為了實現更好的擴展性,我們也希望量化視覺編碼器。
llama-llava-quantize-cli 量化 CLIP 模型,但自引入 mtmd 庫以來,相關資源已被刪除。- 將視覺編碼器分離為專用服務。 視覺編碼器通常使用非因果遮罩,這意味著任何給定的圖像都需要在單個前向呼叫中進行編碼。 因此,我們無法使用連續批處理。 但是,我們可以考慮將視覺編碼器分離為一個單獨的服務,該服務將多個圖像(甚至來自不同來源的圖像)批處理在一起,並在單個前向傳遞中對它們進行編碼。 這意味著更高的 vRAM 需求,但也會比逐個編碼每個圖像快得多。 這種分離也意味著我們可以獨立於語言模型自動縮放視覺編碼器。
- 啟用多向量模型。 在本文中,我們僅使用單向量模型。 但是為了充分利用 jina-embeddings-v4,我們還希望啟用多向量模型,以在複雜圖像上實現更高的準確性。 這將是一個容易的添加,因為這些向量模型是通過基礎模型之上的單個線性層生成的。
tagConclusion
儘管最初存在錯誤和挫折,但將 multimodal 向量模型整合到 llama.cpp 中現在產生的結果與我們的參考 PyTorch 模型非常接近,包括在一系列基準測試任務中。 對注意力遮罩和圖像塊處理的修復消除了主要的分歧來源,即使是量化的變體也能在使用更少資源的同時實現相似的準確性。 在較高圖像解析度下,其餘差異似乎很小,並且可能是由於後端差異而不是核心模型實作造成的。
展望未來,將量化擴展到視覺編碼器、通過單獨的服務啟用批處理以及支援多向量模型將進一步提高效率和準確性。 這些新增功能將使 llama.cpp 中的 multimodal 向量模型更具可擴展性,並且更適合實際使用案例。








