我们在 llama.cpp 的分支中引入了多个修复,使其能够在多模态jina-embeddings-v4 上工作。
jina-embeddings-v4 引入了最先进的多模态向量模型,可以处理文本、图像和复杂的视觉文档以进行向量搜索。几周前,我们发布了 v4 的 GGUF 和动态量化,用于纯文本任务,从而减少了 VRAM 占用并提高了性能。但是,GGUF 上的多模态向量模型支持仍然缺失。为了完善这一功能,我们现在已经弄清楚了如何使用 llama.cpp 和 GGUF 生成多模态向量模型。请查看 此 README 文件 以获取完整的演练。
公平地说,llama.cpp 上游确实对多模态输入提供了一些支持,但由于 llama.cpp 社区的大部分精力都集中在 LLM 和文本生成上,因此完全缺少对多模态向量模型输出的支持。在本文中,我们将解释如何在 llama.cpp 中实现多模态向量模型,并检查其性能(以及两个量化版本)与 PyTorch 版本的 jina-embeddings-v4 相比如何。在本文中,我们将把 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 用视觉标记
<|vision_start|>和<|vision_end|>包围<__image__>词元,从而得到如下内容:<|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 解码的图像词元。虽然这些词元与步骤一中的词元分开处理,但注意力层仍然可以通过
KVCache关注这些文本词元。但是,此时,注意力层无法关注任何后续的文本词元(<|vision_end|>Describe the image.<|im_end|>\n)。 - LLM 解码任何剩余的文本词元(
<|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 LLM 可以处理这些图像补丁向量模型。
以下是参考模型和 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 实现(仅使用 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_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 都将原始图像(像素值)分割成通过卷积层编码的patch。原始patch(在 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 以不同的方式处理这些patch:
- 参考模型使用 reshape 操作对像素值进行分组,然后使用单个 conv3d 层来编码预分组的像素patch。
- llama.cpp 模型使用两个 conv2d 层创建和编码这些patch。
为了使 llama.cpp 模型的 向量模型更接近参考模型,我们认为使用参考模型的精确操作比调试 llama.cpp 的方法更简单。
我们的参考模型使用复杂的 reshape 和转置操作生成像素patch,这些操作需要 9 维张量。llama.cpp 中使用的底层张量处理库——ggml——无法支持它们,因此为了解决这个问题,我们使用一个单独的 Python 服务生成这些patch,该服务通过 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 向量模型之间的距离(在池化/归一化之前)。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(右)
我们的目标是发现任何超出数值精度差异的模式——可能揭示我们的模型之间更多错误或差异的模式。但是,没有发现任何特定的模式,只是发散的patch数量随着图像分辨率的增加而增加。这些差异很可能由于后端差异而出现,而不是由于 jina-embeddings-v4(主干模型)的 Qwen2.5-VL 实现中的任何特定错误。
尽管如此,我们需要重申这些差异是最小的,并且基准测试结果也反映了这一事实。总的来说,llama.cpp 模型表现与参考模型一样好,尽管产生略有不同的 向量模型。
tag剩余问题
llama.cpp 中多模态 向量模型 还有几个潜在的改进领域:
- 量化视觉编码器。目前,llama.cpp 仅支持 大模型 的量化,但为了实现更好的扩展,我们也希望量化视觉编码器。
llama-llava-quantize-cli 量化 CLIP 模型,但自从引入 mtmd 库以来,相关资源已被删除。- 将视觉编码器分离为专用服务。视觉编码器通常使用非因果掩码,这意味着任何给定的图像都需要在单个前向调用中进行编码。因此,我们无法利用连续批处理。但是,我们可以考虑将视觉编码器分离为单独的服务,该服务可以将多个图像(甚至来自不同来源的图像)批量处理,并在单个前向传递中对它们进行编码。这将意味着更高的 vRAM 要求,但也会比逐个编码每个图像快得多。这种分离也意味着我们可以独立于语言模型自动缩放视觉编码器。
- 启用多向量 向量模型。在本文中,我们仅使用了单向量 向量模型。但是为了充分利用 jina-embeddings-v4,我们还希望启用多向量 向量模型,以在复杂图像上实现更高的准确性。这将是一个简单的补充,因为这些 向量模型 是通过基础模型之上的单个线性层生成的。
tag结论
尽管最初存在错误和挫折,但将多模态 向量模型 集成到 llama.cpp 中现在产生的结果与我们的参考 PyTorch 模型非常匹配,包括在一系列基准测试任务中。对注意力掩码和图像块处理的修复消除了主要的差异来源,即使是量化的变体也能在使用更少资源的同时实现类似的准确性。在较高图像分辨率下剩余的差异似乎很小,并且可能是由于后端差异而不是核心模型实现造成的。
展望未来,将量化扩展到视觉编码器,通过单独的服务启用批量处理,以及支持多向量 向量模型 将进一步提高效率和准确性。这些改进将使 llama.cpp 中的多模态 向量模型 更具可扩展性,更适合实际用例。






