Nous avons introduit plusieurs correctifs dans notre fork de llama.cpp, afin qu'il fonctionne avec jina-embeddings-v4 sur les 向量模型 multimodales.
jina-embeddings-v4 introduit des 向量模型 multimodales à la pointe de la technologie, capables de traiter du texte, des images et des documents visuels complexes pour la recherche vectorielle. Il y a quelques semaines, nous avons publié les GGUF et les quantifications dynamiques de la v4 pour les tâches textuelles uniquement, qui offrent un encombrement VRAM plus petit et des performances améliorées. Cependant, la prise en charge des 向量模型 multimodales sur GGUF était toujours manquante. Pour compléter le tableau, nous avons maintenant trouvé comment générer des 向量模型 multimodales avec llama.cpp et GGUF. Consultez ce fichier README pour la procédure complète.
Pour être juste, llama.cpp upstream prend en charge les entrées multimodales, mais comme la plupart de la communauté llama.cpp se concentre sur les 大模型 et la génération de texte, la prise en charge de la sortie de 向量模型 multimodales est totalement absente. Dans cet article, nous expliquerons comment nous avons implémenté les 向量模型 multimodales dans llama.cpp et examinerons ses performances (ainsi que deux versions quantifiées) par rapport à la version PyTorch de jina-embeddings-v4. Tout au long de cet article, nous désignerons la version PyTorch comme notre "modèle de référence".
tagComprendre l'entrée image dans Llama.cpp
Récapitulons d'abord comment les 向量模型 multimodales sont réalisées avec notre modèle de référence. Tout d'abord, vous associez chaque entrée image à un 提示词 spécial :
<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>Describe the image.<|im_end|>\n
Ensuite, le modèle prétraite l'image, la code (via son ViT), puis traite toute la séquence entrelacée en une seule passe avant.
Cependant, lorsqu'il s'agit de llama.cpp, les choses sont plus délicates. Bien qu'il prenne en charge les entrées d'image pour la complétion de conversation, il ne prend pas en charge les entrées multimodales, à savoir les entrées (comme ci-dessus) qui combinent à la fois du texte et une image. C'est exactement pourquoi nous avons forké llama.cpp, en modifiant le gestionnaire de 向量模型 pour accepter les images encodées en base64, ce qui nous permet de traiter le contenu multimodal de la même manière que le gestionnaire de complétion de conversation.
Ainsi, maintenant, pour travailler avec des entrées multimodales dans llama.cpp, nous pouvons commencer avec un 提示词 similaire à celui utilisé par le modèle de référence :
<|im_start|>user\n<__image__>Describe the image.<|im_end|>\n
Le processus fonctionne comme suit :
- llama.cpp entoure le 词元
<__image__>de marqueurs de vision<|vision_start|>et<|vision_end|>, ce qui nous donne quelque chose comme ceci :<|im_start|>user\n<|vision_start|><__image__><|vision_end|>Describe the image.<|im_end|>\n - Le tokenizer remplace le 词元 spécial
<__image__>par-1lors de la tokenisation du 提示词, signalant en interne que la séquence contient une image qui doit être codée avant (d'être traitée ultérieurement). - Les 词元 de texte avant le marqueur
<__image__>(à savoir,<|im_start|>user\n<|vision_start|>) sont décodés via le 大模型 et injectés dans leKVCache. - L'image est codée via le composant ViT, produisant une série de 词元 d'image qui sont décodés via le 大模型. Bien que ces 词元 soient traités séparément des 词元 de l'étape un, les couches d'attention peuvent toujours s'intéresser à ces 词元 de texte via le
KVCache. Cependant, à ce stade, les couches d'attention ne peuvent pas s'intéresser aux 词元 de texte ultérieurs (<|vision_end|>Describe the image.<|im_end|>\n). - Le 大模型 décode tous les 词元 de texte restants (
<|end_vision|>Describe the image.<|im_end|>\n). Maintenant, les couches d'attention peuvent s'intéresser à tous les 词元 précédents (texte et image) via leKVCache.
Le processus d'inférence d'intégration (codage d'image et décodage de 词元 de texte/image) est illustré dans la figure ci-dessous :

tagAttention sur les 词元 d'image
En raison du mécanisme d'attention, ce processus en plusieurs étapes peut être problématique pour certains modèles. Récapitulons rapidement les différents types d'attention utilisés dans les modèles :
- Attention causale : le mécanisme d'attention pour un 词元 en position
kne s'intéresse qu'aux 词元 précédents, en positions[0:k-1]. - Attention non causale : le mécanisme d'attention pour un 词元 en position
ks'intéresse à tous les 词元 de la séquence[0:n].
La figure ci-dessous montre les 词元 auxquels le mécanisme d'attention s'intéresserait lors du traitement de img_tok_n à la deuxième étape :

Lors du traitement de img_tok_n, l'état du modèle est le suivant :
- Tous les 词元 de texte précédents (
<|im_start|>,user,\n,<|vision_start|>) ont déjà été traités et enregistrés dans leKVCache. - Tous les 词元 d'image (
img_tok_1àimg_tok_n) sont traités à ce stade, dans le cadre de la même séquence. - Tous les 词元 de texte suivants (
<|vision_end|>,Describe, etc.) seront traités ultérieurement.
Dans le cas de l'attention causale, seuls les 词元 précédents sont pris en compte lors du calcul des scores d'attention, les 词元 passés étant récupérés via le KVCache.
Dans le cas de l'attention non causale, tous les 词元 doivent être pris en compte. Cependant, les futurs 词元 de texte (<|vision_end|>, Describe, etc.) n'ont pas encore été traités. Ils seront traités lors d'une étape ultérieure, de sorte que les choses se cassent rapidement.
Étant donné que jina-embeddings-v4 utilise l'attention causale, le processus en plusieurs étapes fonctionne sans problème, mais pour d'autres modèles, cela pourrait ne pas être le cas.
En termes de 向量模型, les états cachés de chaque 词元 sont capturés exactement lorsqu'ils sont traités et combinés en une seule séquence à la fin. Actuellement, la normalisation et le regroupement sont gérés en Python, mais (avec un peu de travail supplémentaire), cela pourrait également être fait du côté de llama.cpp.
tagNos correctifs
Après avoir activé les entrées d'image pour le point de terminaison des 向量模型 dans le serveur llama.cpp, nous avons commencé à tester l'implémentation avec des benchmarks et avons constaté des différences étonnamment importantes par rapport à notre modèle de référence. Nous soupçonnions qu'il devait y avoir quelque chose qui clochait dans l'implémentation du ViT utilisée par Qwen2.5-VL par llama.cpp pour coder les images en 向量模型 de patch d'image (représentation vectorielle dense des carrés d'image) que le 大模型 Qwen2.5 peut traiter.
Voici un exemple de la façon dont les sorties ViT diffèrent entre le modèle de référence et l'implémentation 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)Comme vous pouvez le constater, les différences sont assez notables. Pour confirmer qu'il s'agissait du seul problème, nous avons précalculé les 词元 d'image en Python, puis nous les avons décodés à l'aide de l'implémentation de Qwen2.5 par llama.cpp (en utilisant uniquement le 大模型), en espérant que les 向量模型 résultantes correspondraient beaucoup plus étroitement aux valeurs du modèle de référence - ce qui n'a pas été le cas, cependant.
tagCorrectif n° 1 : Masque d'attention causale pour les couches d'attention
Nous avons poursuivi le débogage en examinant les couches d'attention, la cause la plus probable des différences de nombre. Nous avons remarqué que le masque d'attention utilisé par les couches d'attention n'était pas calculé correctement pour les 词元 d'image. Pour le constater, nous pouvons revenir à notre séquence d'exemple :
<|im_start|>user\n<|vision_start|><__image__><|vision_end|>Describe the image.<|im_end|>\n
Lors du traitement des 词元 d'image, le marqueur <__image__> est décompressé en quelque chose comme img_tok_1 img_tok_2 .... img_tok_last. La séquence complète serait donc la suivante :
<|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, etc.) et les 词元 d'image (img_tok_1,img_tok_2, etc.), sont des vecteurs denses et non des *tokens* textuels littéraux. Nous utilisons cette forme de la séquence pour simplifier l'explication.
Lors du décodage de img_tok_2, le mécanisme d'attention *devrait* s'intéresser à tous les *tokens* précédents, à savoir :
<|im_start|>user\n<|vision_start|> img_tok_1
Cependant, un bug dans le masque d'attention faisait *à la place* que le mécanisme s'intéresse à la séquence d'image entière, comme ceci :
<|im_start|>user\n<|vision_start|> img_tok_1 img_tok_2 ... img_tok_last
Après avoir corrigé ce bug, les *Embeddings* de notre modèle llama.cpp (utilisant les *tokens* d'image pré-calculés du ViT du modèle Torch) correspondaient enfin aux *Embeddings* du modèle de référence (à une petite marge d'erreur près).
### Correction n° 2 : Traitement d'image et *Embeddings* de patch
L'encodeur ViT de llama.cpp produisait également des *Embeddings* d'image différents de ceux du modèle de référence, avec des nombres divergeant immédiatement après le prétraitement. Cela était particulièrement évident lors de l'étape initiale de création de patch, où notre modèle de référence et llama.cpp divisaient l'image brute (valeurs des pixels) en patchs qui sont encodés via des couches convolutionnelles. Les différences entre les patchs bruts (avant le traitement ViT) peuvent être observées ci-dessous :
=== 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)
Notre modèle de référence et llama.cpp traitent ces patchs de différentes manières :
* Le modèle de référence regroupe les valeurs des pixels à l'aide d'opérations de remodelage, puis utilise une seule couche conv3d pour encoder les patchs de pixels pré-regroupés.
* Le modèle llama.cpp crée et encode ces patchs avec deux couches conv2d
Pour rapprocher les *Embeddings* du modèle llama.cpp de ceux du modèle de référence, nous avons pensé qu'il serait plus simple d'utiliser les opérations exactes du modèle de référence plutôt que de déboguer l'approche de llama.cpp.
Notre modèle de référence génère des patchs de pixels en utilisant des opérations complexes de remodelage et de transposition qui nécessitent des tenseurs à 9 dimensions. La bibliothèque de traitement de tenseurs de bas niveau utilisée dans llama.cpp — ggml — ne peut pas les prendre en charge. Par conséquent, pour contourner ce problème, nous avons généré les patchs à l'aide d'un service Python distinct qui appelle le serveur llama.cpp via HTTP.
ggml ne prend pas non plus en charge les couches conv3d. Dans notre modèle de référence, la configuration de la couche conv3d ressemble à ceci :
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
)
Vous pouvez constater que stride et kernel_size sont identiques, ce qui signifie que nous pouvons simplement aplatir les entrées et les poids de la couche conv3d et effectuer une simple opération de multiplication matricielle à la place. Pour ce faire, nous avons modifié le script de conversion dans llama.cpp (convert_hf_to_gguf.py) pour exporter une version aplatie des poids conv3d pour la couche de projection de patch :
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))
Pour appliquer l'opération matmul au lieu des deux couches conv2d dans llama.cpp, nous avons modifié le code qui construit le graphique du 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
}
Avec ces dernières modifications, les *Embeddings* d'image finaux se situaient dans une marge d'erreur de 2 % par rapport au modèle de référence (comme on peut le voir dans le tableau d'évaluation de la section suivante).
## Évaluation
Après avoir apporté ces modifications, nous avons évalué le modèle llama.cpp par rapport à notre modèle de référence sur les tâches ViDoRe en utilisant le benchmark MTEB. Vous pouvez consulter le script et les instructions pour reproduire ces résultats dans notre fork llama.cpp, ainsi que deux versions quantifiées.
| Tâche | Modèle de référence | 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 |
| Moyenne | 84.17 | 84.33 | 84.28 | 84.23 |


Figure 3 : Page du rapport technique jina-embeddings-v4, résolution de 372 × 526 (à gauche), résolution de 2481 × 3508 (à droite)


Figure 4 : Capture d'écran du site web de Jina AI, résolution de 594 × 428 (à gauche), résolution de 1982 × 1428 (à droite)


Figure 5 : Tokyo, Shibuya par S K sur Unsplash, résolution de 383 × 255 (à gauche), résolution de 5472 × 3649 (à droite)
Néanmoins, nous devons réitérer que ces différences sont minimes, et les résultats des benchmarks le reflètent également. Dans l'ensemble, les modèles llama.cpp fonctionnent aussi bien que le modèle de référence, bien qu'ils produisent des vecteurs de 向量模型 légèrement différents.
tagProblèmes restants
Il existe plusieurs domaines potentiels d'amélioration pour les 向量模型 multimodales dans llama.cpp :
- Quantification de l'encodeur de vision. Actuellement, llama.cpp ne prend en charge la quantification que pour les 大模型, mais pour obtenir une meilleure mise à l'échelle, nous aimerions également quantifier l'encodeur de vision.
llama-llava-quantize-cli, mais les ressources pertinentes ont été supprimées depuis l'introduction de la bibliothèque mtmd.- Séparation de l'encodeur de vision en un service dédié. Les encodeurs de vision utilisent généralement un masquage non causal, ce qui signifie que toute image donnée doit être encodée en un seul appel direct. Par conséquent, nous ne pouvons pas utiliser le batching continu. Cependant, nous pourrions envisager de séparer l'encodeur de vision en un service distinct, qui regrouperait plusieurs images (même provenant de sources distinctes) et les encoderait toutes en un seul passage direct. Cela impliquerait des exigences de vRAM plus élevées, mais serait également beaucoup plus rapide que d'encoder chaque image une par une. Cette séparation signifierait également que nous pourrions mettre à l'échelle automatiquement l'encodeur de vision indépendamment du modèle de langage.
- Activer les 向量模型 multi-vecteurs. Dans cet article, nous n'avons travaillé qu'avec des 向量模型 à vecteur unique. Mais pour utiliser pleinement jina-embeddings-v4, nous aimerions également activer les 向量模型 multi-vecteurs afin d'obtenir une plus grande précision sur les images complexes. Ce serait un ajout facile, car ces 向量模型 sont générées avec une seule couche linéaire au-dessus du modèle de base.
tagConclusion
Malgré les bugs et les contretemps initiaux, l'intégration des 向量模型 multimodales dans llama.cpp donne désormais des résultats correspondant étroitement à notre modèle PyTorch de référence, y compris sur une série de tâches de benchmark. Les corrections apportées au masque d'attention et au traitement des patchs d'image ont supprimé les principales sources de divergence, et même les variantes quantifiées atteignent une précision similaire tout en utilisant beaucoup moins de ressources. Les différences restantes aux résolutions d'image plus élevées semblent mineures et sont probablement dues à des variations du backend plutôt qu'à l'implémentation du modèle de base.
Pour l'avenir, l'extension de la quantification à l'encodeur de vision, l'activation du traitement par lots via un service distinct et la prise en charge des 向量模型 multi-vecteurs amélioreraient encore l'efficacité et la précision. Ces ajouts rendraient les 向量模型 multimodales dans llama.cpp plus évolutives et mieux adaptées aux cas d'utilisation réels.






