Abbiamo introdotto diverse correzioni nel nostro fork di llama.cpp, in modo che funzioni con jina-embeddings-v4 sugli embeddings multimodali.
jina-embeddings-v4 introduce embeddings multimodali all'avanguardia in grado di elaborare testo, immagini e documenti visivi complessi per la ricerca vettoriale. Qualche settimana fa, abbiamo rilasciato i GGUF e le quantizzazioni dinamiche di v4 per attività di solo testo, che offrono un ingombro VRAM inferiore e prestazioni migliorate. Tuttavia, il supporto per gli embeddings multimodali su GGUF era ancora mancante. Per completare il quadro, abbiamo ora capito come generare embeddings multimodali con llama.cpp e GGUF. Dai un'occhiata a questo file README per la guida completa.
Ad essere onesti, llama.cpp upstream ha un certo supporto per l'input multimodale, ma poiché la maggior parte della community di llama.cpp si concentra su LLM e generazione di testo, il supporto per l'output di embeddings multimodali è completamente assente. In questo articolo, spiegheremo come abbiamo implementato gli embeddings multimodali in llama.cpp ed esamineremo le sue prestazioni (insieme a due versioni quantizzate) rispetto alla versione PyTorch di jina-embeddings-v4. Nel corso di questo articolo, faremo riferimento alla versione PyTorch come al nostro "modello di riferimento".
tagComprendere l'input di immagini in Llama.cpp
Ricapitoliamo innanzitutto come vengono gestiti gli embeddings multimodali con il nostro modello di riferimento. Innanzitutto, si associa ogni input immagine a un Prompt speciale:
<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>Describe the image.<|im_end|>\n
Quindi il modello preelabora l'immagine, la codifica (tramite il suo ViT) e quindi elabora l'intera sequenza interleaved in un singolo passaggio in avanti.
Quando si tratta di llama.cpp, tuttavia, le cose sono più complicate. Sebbene supporti gli input di immagini per il completamento della chat, non supporta gli input multimodali, ovvero input (come quello sopra) che combinano sia testo che un'immagine. Questo è esattamente il motivo per cui abbiamo creato un fork di llama.cpp, modificando il gestore degli embeddings per accettare immagini codificate in base64, consentendoci così di elaborare contenuti multimodali in modo simile al gestore del completamento della chat.
Quindi ora, per lavorare con input multimodali in llama.cpp, possiamo iniziare con un Prompt simile a quello utilizzato dal modello di riferimento:
<|im_start|>user\n<__image__>Describe the image.<|im_end|>\n
Il processo funziona come segue:
- llama.cpp circonda il Token
<__image__>con marcatori di visione<|vision_start|>e<|vision_end|>, dandoci qualcosa di simile a questo:<|im_start|>user\n<|vision_start|><__image__><|vision_end|>Describe the image.<|im_end|>\n - Il tokenizer sostituisce il Token speciale
<__image__>con-1durante la tokenizzazione del Prompt, segnalando internamente che la sequenza contiene un'immagine che deve essere codificata prima (di essere elaborata in seguito). - I Token di testo prima del marcatore
<__image__>(ovvero,<|im_start|>user\n<|vision_start|>) vengono decodificati tramite l'LLM e iniettati nellaKVCache. - L'immagine viene codificata tramite il componente ViT, producendo una serie di Token di immagine che vengono decodificati tramite l'LLM. Sebbene questi Token vengano elaborati separatamente dai Token nel passaggio uno, i livelli di attenzione possono comunque prestare attenzione a quei Token di testo tramite la
KVCache. Tuttavia, a questo punto, i livelli di attenzione non possono prestare attenzione ad alcun Token di testo successivo (<|vision_end|>Describe the image.<|im_end|>\n). - L'LLM decodifica tutti i Token di testo rimanenti (
<|end_vision|>Describe the image.<|im_end|>\n). Ora i livelli di attenzione possono prestare attenzione a tutti i Token precedenti (sia di testo che di immagine) tramite laKVCache.
Il processo di inferenza degli embeddings (codifica dell'immagine e decodifica dei Token di testo/immagine) è mostrato nella figura seguente:

tagAttenzione sui Token di immagine
A causa del meccanismo di attenzione, questo processo in più fasi può essere problematico per alcuni modelli. Ricapitoliamo rapidamente i diversi tipi di attenzione utilizzati nei modelli:
- Attenzione causale: il meccanismo di attenzione per un Token in posizione
kpresta attenzione solo ai Token precedenti, nelle posizioni[0:k-1]. - Attenzione non causale: il meccanismo di attenzione per un Token in posizione
kpresta attenzione a tutti i Token nella sequenza[0:n].
La figura seguente mostra i Token a cui il meccanismo di attenzione presterebbe attenzione durante l'elaborazione di img_tok_n nel secondo passaggio:

Durante l'elaborazione di img_tok_n, lo stato del modello è il seguente:
- Tutti i Token di testo precedenti (
<|im_start|>,user,\n,<|vision_start|>) sono già stati elaborati e salvati nellaKVCache. - Tutti i Token di immagine (
img_tok_1aimg_tok_n) vengono elaborati a questo punto, come parte della stessa sequenza. - Tutti i Token di testo successivi (
<|vision_end|>,Describe, ecc. ) verranno elaborati in seguito.
Nel caso dell'attenzione causale, vengono presi in considerazione solo i Token precedenti quando si calcolano i punteggi di attenzione, con i Token passati recuperati tramite la KVCache .
Nel caso dell'attenzione non causale, tutti i Token dovrebbero essere presi in considerazione. Tuttavia, i futuri Token di testo (<|vision_end|> , Describe , ecc.) non sono ancora stati elaborati. Verranno elaborati in un passaggio futuro, quindi le cose si rompono velocemente.
Poiché jina-embeddings-v4 utilizza l'attenzione causale, il processo in più fasi funziona senza problemi, ma per altri modelli potrebbe non essere così.
In termini di embeddings, gli stati nascosti di ogni Token vengono catturati esattamente quando vengono elaborati e combinati in una singola sequenza alla fine. Attualmente, la normalizzazione e il pooling vengono gestiti in Python, ma (con un po' di lavoro extra) questo potrebbe essere fatto anche lato llama.cpp.
tagLe nostre correzioni
Dopo aver abilitato gli input di immagini per l'endpoint degli embeddings nel server llama.cpp, abbiamo iniziato a testare l'implementazione con benchmark e abbiamo notato differenze sorprendentemente grandi rispetto al nostro modello di riferimento. Sospettavamo che ci fosse qualcosa di sbagliato nell'implementazione di llama.cpp del ViT utilizzato da Qwen2.5-VL per codificare le immagini in embeddings di patch di immagine (rappresentazione vettoriale densa dei quadrati dell'immagine) che Qwen2.5 LLM può elaborare.
Ecco un esempio di come gli output ViT differiscono tra il modello di riferimento e l'implementazione llama.cpp:
=== 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)Come puoi vedere, le differenze sono piuttosto evidenti. Per confermare che questo fosse l'unico problema, abbiamo precalcolato i Token di immagine in Python, quindi li abbiamo decodificati utilizzando l'implementazione di Qwen2.5 di llama.cpp (utilizzando solo l'LLM), sperando che gli embeddings risultanti corrispondessero molto più da vicino ai valori del modello di riferimento: tuttavia, questo non è stato il caso.
tagCorrezione n. 1: maschera di attenzione causale per i livelli di attenzione
Abbiamo continuato il debug esaminando i livelli di attenzione, la causa più probabile per le differenze numeriche. Abbiamo notato che la maschera di attenzione utilizzata dai livelli di attenzione non veniva calcolata correttamente per i Token di immagine. Per vederlo, possiamo tornare alla nostra sequenza di esempio:
<|im_start|>user\n<|vision_start|><__image__><|vision_end|>Describe the image.<|im_end|>\n
Durante l'elaborazione dei Token di immagine, il marcatore <__image__> viene spacchettato in qualcosa come img_tok_1 img_tok_2 .... img_tok_last. Quindi la sequenza completa sarebbe:
<|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 , ecc.) che i Token di immagine ( img_tok_1,img_tok_2 ecc.), sono vettori densi e non token di testo letterali. Utilizziamo questa forma della sequenza per semplificare la spiegazione.
Quando si decodifica img_tok_2, il meccanismo di attenzione *dovrebbe* fare attenzione a tutti i token precedenti, ovvero:
```
<|im_start|>user
<|vision_start|> img_tok_1
```
Tuttavia, un bug nella maschera di attenzione stava *invece* facendo sì che il meccanismo prestasse attenzione all'intera sequenza di immagini, come segue:
```
<|im_start|>user
<|vision_start|> img_tok_1 img_tok_2 ... img_tok_last
```
Dopo aver corretto questo bug, gli incorporamenti del nostro modello llama.cpp (utilizzando i token di immagine pre-calcolati dal ViT del modello Torch) corrispondevano finalmente agli incorporamenti del modello di riferimento (entro un piccolo margine di errore).
### Correzione n. 2: Elaborazione delle immagini e incorporamenti di patch
Anche il codificatore ViT di llama.cpp produceva diversi incorporamenti di immagini rispetto al modello di riferimento, con numeri che divergevano immediatamente dopo la pre-elaborazione. Ciò era particolarmente evidente durante la fase iniziale di creazione delle patch, in cui sia il nostro modello di riferimento che llama.cpp suddividono l'immagine grezza (valori dei pixel) in patch che vengono codificate tramite livelli convoluzionali. Le differenze tra le patch grezze (prima dell'elaborazione ViT) possono essere viste di seguito:
```text
=== 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)
```
```text
=== 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)
```
Il nostro modello di riferimento e llama.cpp elaborano queste patch in modi diversi:
* Il modello di riferimento raggruppa i valori dei pixel utilizzando operazioni di rimodellamento e quindi utilizza un singolo livello conv3d per codificare le patch di pixel pre-raggruppate.
* Il modello llama.cpp crea e codifica queste patch con due livelli conv2d
Per avvicinare gli **embeddings** del modello llama.cpp a quelli del modello di riferimento, abbiamo pensato che sarebbe stato più semplice utilizzare le operazioni esatte del modello di riferimento piuttosto che eseguire il debug dell'approccio di llama.cpp.
Il nostro modello di riferimento genera patch di pixel utilizzando complesse operazioni di rimodellamento e trasposizione che richiedono tensori a 9 dimensioni. La libreria di elaborazione dei tensori di basso livello utilizzata in llama.cpp — ggml — non può supportarli, quindi per aggirare questo problema, abbiamo generato le patch utilizzando un servizio Python separato che chiama il server llama.cpp tramite HTTP.
ggml manca anche del supporto per i livelli conv3d. Nel nostro modello di riferimento, la configurazione del livello conv3d è simile a questa:
```python
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
)
```
Puoi vedere che stride e kernel_size sono gli stessi, il che significa che possiamo semplicemente appiattire gli input e i pesi del livello conv3d ed eseguire invece una semplice operazione di moltiplicazione di matrici. Per fare ciò, abbiamo modificato lo script di conversione in llama.cpp (convert_hf_to_gguf.py) per esportare una versione appiattita dei pesi conv3d per il livello di proiezione della patch:
```python
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))
```
Per applicare l'operazione matmul invece dei due livelli conv2d in llama.cpp, abbiamo modificato il codice che costruisce il grafico del ViT Qwen2.5-VL:
```cpp
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
}
```
Con queste modifiche finali, gli **embeddings** finali delle immagini rientravano in un margine di errore del 2% rispetto al modello di riferimento (come si può vedere nella tabella di valutazione nella sezione successiva).
## Valutazione
Dopo aver apportato queste modifiche, abbiamo valutato il modello llama.cpp rispetto al nostro modello di riferimento sui compiti ViDoRe utilizzando il benchmark MTEB. Puoi vedere lo script e le istruzioni per replicare questi risultati nel nostro fork llama.cpp e anche due versioni quantizzate.
| Task | Modello di riferimento | 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 |
| Media | 84.17 | 84.33 | 84.28 | 84.23 |


Figura 3: Pagina del jina-embeddings-v4 rapporto tecnico, risoluzione 372 × 526 (a sinistra), risoluzione 2481 × 3508 (a destra)


Figura 4: Screenshot dal sito web di Jina AI, risoluzione 594 × 428 (a sinistra), risoluzione 1982 × 1428 (a destra)


Figura 5: Tokyo, Shibuya di S K su Unsplash, risoluzione 383 × 255 (a sinistra), risoluzione 5472 × 3649 (a destra)
Ciononostante, dobbiamo ribadire che queste differenze sono minime e i risultati del benchmark lo riflettono. Nel complesso, i modelli llama.cpp funzionano bene quanto il modello di riferimento, anche se producono vettori di 向量模型 leggermente diversi.
tagProblemi rimanenti
Ci sono diverse potenziali aree di miglioramento per gli 向量模型 multimodali in llama.cpp:
- Quantizzazione dell'encoder di visione. Attualmente, llama.cpp supporta solo la quantizzazione per gli LLM, ma per ottenere una scalabilità migliore vorremmo quantizzare anche l'encoder di visione.
llama-llava-quantize-cli, ma le risorse pertinenti sono state rimosse dall'introduzione della libreria mtmd.- Separazione dell'encoder di visione in un servizio dedicato. Gli encoder di visione utilizzano in genere maschere non causali, il che significa che ogni immagine deve essere codificata all'interno di una singola chiamata forward. Pertanto, non possiamo utilizzare il batching continuo. Tuttavia, potremmo considerare di separare l'encoder di visione in un servizio separato, che raggrupperebbe più immagini (anche da fonti separate) e le codificherebbe tutte in un singolo passaggio forward. Ciò significherebbe requisiti di vRAM più elevati, ma sarebbe anche molto più veloce della codifica di ogni immagine una per una. Questa separazione significherebbe anche che potremmo scalare automaticamente l'encoder di visione indipendentemente dal modello linguistico.
- Abilitazione di 向量模型 multi-vettore. In questo articolo, abbiamo lavorato solo con 向量模型 a vettore singolo. Ma per sfruttare appieno jina-embeddings-v4, vorremmo anche abilitare 向量模型 multi-vettore per ottenere una maggiore precisione su immagini complesse. Questa sarebbe un'aggiunta facile poiché questi 向量模型 vengono generati con un singolo livello lineare sopra il modello base.
tagConclusione
Nonostante i bug e le battute d'arresto iniziali, l'integrazione di 向量模型 multimodali in llama.cpp produce ora risultati che corrispondono strettamente al nostro modello PyTorch di riferimento, anche su una serie di attività di benchmark. Le correzioni alla maschera di attenzione e all'elaborazione delle patch di immagine hanno rimosso le principali fonti di divergenza e anche le varianti quantizzate raggiungono una precisione simile pur utilizzando molte meno risorse. Le restanti differenze a risoluzioni di immagine più elevate appaiono minime e sono probabilmente dovute a variazioni del backend piuttosto che all'implementazione del modello principale.
Guardando al futuro, l'estensione della quantizzazione all'encoder di visione, l'abilitazione dell'elaborazione in batch tramite un servizio separato e il supporto di 向量模型 multi-vettore migliorerebbero ulteriormente sia l'efficienza che la precisione. Queste aggiunte renderebbero gli 向量模型 multimodali in llama.cpp più scalabili e più adatti ai casi d'uso reali.





