Мы внесли несколько исправлений в нашу версию llama.cpp, чтобы она работала с jina-embeddings-v4 для мультимодальных 向量模型.
jina-embeddings-v4 представляет собой современный мультимодальный 向量模型, который может обрабатывать текст, изображения и сложные визуальные документы для векторного поиска. Несколько недель назад мы выпустили GGUF и динамическую квантизацию v4 для задач, связанных только с текстом, что обеспечивает меньший объем занимаемой VRAM и повышенную производительность. Однако поддержка мультимодальных 向量模型 на GGUF по-прежнему отсутствовала. Чтобы завершить картину, мы выяснили, как генерировать мультимодальные 向量模型 с помощью llama.cpp и GGUF. Ознакомьтесь с этим файлом README для получения полной информации.
Справедливости ради стоит отметить, что в llama.cpp upstream есть некоторая поддержка мультимодального ввода, но поскольку большая часть сообщества llama.cpp сосредоточена на 大模型 и генерации текста, поддержка вывода мультимодальных 向量模型 полностью отсутствует. В этой статье мы объясним, как мы реализовали мультимодальные 向量模型 в 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, все становится сложнее. Хотя он поддерживает ввод изображений для завершения чата, он не поддерживает мультимодальный ввод, а именно ввод (например, приведенный выше), который объединяет как текст, так и изображение. Это именно почему мы форкнули llama.cpp, изменив обработчик 向量模型, чтобы он принимал изображения, закодированные в base64, что позволяет нам обрабатывать мультимодальный контент аналогично обработчику завершения чата.
Итак, теперь, чтобы работать с мультимодальным вводом в llama.cpp, мы можем начать с 提示词, аналогичного тому, который используется эталонной моделью:
<|im_start|>user\n<__image__>Describe the image.<|im_end|>\n
Процесс работает следующим образом:
- llama.cpp окружает
<__image__>токен маркерами vision<|vision_start|>и<|vision_end|>, что дает нам что-то вроде этого:<|im_start|>user\n<|vision_start|><__image__><|vision_end|>Describe the image.<|im_end|>\n - Токенизатор заменяет специальный токен
<__image__>на-1при токенизации 提示词, внутренне сигнализируя о том, что последовательность содержит изображение, которое следует закодировать до (последующей обработки). - Текстовые Tokens перед маркером
<__image__>(а именно,<|im_start|>user\n<|vision_start|>) декодируются через 大模型 и внедряются вKVCache. - Изображение кодируется через компонент ViT, выводя серию токенов изображений, которые декодируются через 大模型. Хотя эти Tokens обрабатываются отдельно от токенов на первом шаге, слои внимания по-прежнему могут обращаться к этим текстовым токенам через
KVCache. Однако на этом этапе слои внимания не могут обращаться к каким-либо более поздним текстовым токенам (<|vision_end|>Describe the image.<|im_end|>\n). - 大模型 декодирует любые оставшиеся текстовые Tokens (
<|end_vision|>Describe the image.<|im_end|>\n). Теперь слои внимания могут обращаться ко всем более ранним токенам (как текстовым, так и изображению) черезKVCache.
Процесс логического вывода 向量模型 (кодирование изображения и декодирование текстовых/графических токенов) показан на рисунке ниже:

tagВнимание на Tokens изображений
Из-за механизма внимания этот многоэтапный процесс может быть проблематичным для некоторых моделей. Давайте быстро повторим различные типы внимания, используемые в моделях:
- Причинное внимание — механизм внимания для одного токена в позиции
kобращает внимание только на предыдущие Tokens в позициях[0:k-1]. - Непричинное внимание — механизм внимания для одного токена в позиции
kобращает внимание на все Tokens в последовательности[0:n].
На рисунке ниже показаны Tokens, на которые механизм внимания обращал бы внимание при обработке img_tok_n на втором шаге:

При обработке img_tok_n состояние модели следующее:
- Все предыдущие текстовые Tokens (
<|im_start|>,user,\n,<|vision_start|>) уже обработаны и сохранены вKVCache. - Все Tokens изображений (
img_tok_1toimg_tok_n) обрабатываются на этом этапе как часть одной и той же последовательности. - Все следующие текстовые Tokens (
<|vision_end|>,Describe, и т. д.) будут обработаны позже.
В случае причинного внимания при вычислении оценок внимания учитываются только предыдущие Tokens, а прошлые Tokens извлекаются через KVCache.
В случае непричинного внимания следует учитывать все Tokens. Однако будущие текстовые Tokens (<|vision_end|> , Describe и т. д.) еще не были обработаны. Они будут обработаны на следующем шаге, поэтому все быстро ломается.
Поскольку jina-embeddings-v4 использует причинное внимание, многоэтапный процесс работает без проблем, но для других моделей это может быть не так.
С точки зрения 向量模型, скрытые состояния каждого токена захватываются именно тогда, когда они обрабатываются и объединяются в единую последовательность в конце. В настоящее время нормализация и объединение обрабатываются в Python, но (при некоторой дополнительной работе) это также можно сделать на стороне llama.cpp.
tagНаши исправления
После включения ввода изображений для конечной точки 向量模型 на сервере llama.cpp мы начали тестировать реализацию с помощью тестов и увидели на удивление большие различия по сравнению с нашей эталонной моделью. Мы заподозрили, что с реализацией ViT в llama.cpp, используемой Qwen2.5-VL для кодирования изображений в 向量模型 патчей изображений (плотное векторное представление квадратов изображений), которые может обрабатывать 大模型 Qwen2.5, что-то не так.
Вот пример того, как результаты ViT различаются между эталонной моделью и реализацией 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)Как видите, различия весьма заметны. Чтобы убедиться, что это единственная проблема, мы предварительно вычислили Tokens изображения в Python, а затем декодировали их с помощью реализации Qwen2.5 в llama.cpp (используя только 大模型), надеясь, что результирующие 向量模型 будут гораздо ближе соответствовать значениям эталонной модели, однако это было не так.
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 (используя предварительно вычисленные токены изображений из ViT модели Torch) наконец-то совпали с векторными моделями эталонной модели (с небольшой погрешностью).
tagИсправление #2: Обработка изображений и патч-векторные модели
ViT-кодировщик llama.cpp также создавал векторные модели изображений, отличные от эталонной модели, причем числа расходились сразу после предварительной обработки. Это было особенно заметно на начальном этапе создания патчей, когда и наша эталонная модель, и 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 обрабатывают эти патчи по-разному:
- Эталонная модель группирует значения пикселей с помощью операций изменения формы, а затем использует один слой conv3d для кодирования предварительно сгруппированных пиксельных патчей.
- Модель llama.cpp создает и кодирует эти патчи с помощью двух слоев conv2d
Чтобы приблизить векторные модели llama.cpp к векторным моделям эталонной модели, мы решили, что будет проще использовать точные операции эталонной модели, чем отлаживать подход llama.cpp.
Наша эталонная модель генерирует пиксельные патчи с использованием сложных операций изменения формы и транспонирования, которые требуют 9-мерных тензоров. Низкоуровневая библиотека обработки тензоров, используемая в llama.cpp, — ggml — не может их поддерживать, поэтому, чтобы обойти это, мы сгенерировали патчи с помощью отдельной службы Python, которая вызывает сервер llama.cpp по HTTP.
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))
Чтобы применить операцию matmul вместо двух слоев conv2d в llama.cpp, мы изменили код, который строит граф 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Оценка
После внесения этих изменений мы оценили модель llama.cpp по сравнению с нашей эталонной моделью на задачах ViDoRe с использованием бенчмарка MTEB. Вы можете увидеть скрипт и инструкции для воспроизведения этих результатов в нашем форке 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 и ее квантованные варианты не сильно отличаются от эталонной модели.
Чтобы сравнить модели более подробно, мы использовали изображения из разных областей и с разным разрешением, отображая расстояние между векторными моделями патчей изображений (до объединения/нормализации). Чем краснее патч, тем больше косинусное расстояние между векторами эталонной модели и модели llama.cpp для этого конкретного патча.


Рисунок 3: Страница из jina-embeddings-v4 технического отчета, разрешение 372 × 526 (слева), разрешение 2481 × 3508 (справа)


Рисунок 4: Скриншот с веб-сайта Jina AI, разрешение 594 × 428 (слева), разрешение 1982 × 1428 (справа)


Рисунок 5: Токио, Сибуя от S K на Unsplash, разрешение 383 × 255 (слева), разрешение 5472 × 3649 (справа)
Мы стремились выявить любые закономерности, выходящие за рамки различий в числовой точности — закономерности, которые могут выявить дальнейшие ошибки или различия между нашими моделями. Однако никаких особых закономерностей не было видно, за исключением того, что количество расходящихся патчей увеличивается с разрешением изображения. Эти различия, скорее всего, возникают из-за различий в бэкенде, а не из-за каких-либо конкретных ошибок в реализации Qwen2.5-VL (базовой модели jina-embeddings-v4).
Тем не менее, необходимо повторить, что эти различия минимальны, и результаты тестов также отражают этот факт. В целом, модели llama.cpp работают так же хорошо, как и эталонная модель, хотя и выдают немного отличающиеся векторы 向量模型 (Embeddings).
tagОстающиеся проблемы
Существует несколько потенциальных областей для улучшения мультимодальных 向量模型 (Embeddings) в llama.cpp:
- Квантование vision encoder. В настоящее время llama.cpp поддерживает квантование только для 大模型 (LLM), но для достижения лучшего масштабирования мы также хотели бы квантовать vision encoder.
llama-llava-quantize-cli, но соответствующие ресурсы были удалены после внедрения библиотеки mtmd.- Выделение vision encoder в отдельный сервис. Vision encoder обычно используют non-causal masking, что означает, что любое данное изображение необходимо закодировать в рамках одного forward call. Поэтому мы не можем использовать continuous batching. Однако мы могли бы рассмотреть возможность выделения vision encoder в отдельный сервис, который объединял бы несколько изображений (даже из разных источников) и кодировал бы их все за один forward pass. Это означало бы более высокие требования к vRAM, но также было бы намного быстрее, чем кодирование каждого изображения по отдельности. Это разделение также означало бы, что мы могли бы автоматически масштабировать vision encoder независимо от языковой модели.
- Включение multi-vector 向量模型 (Embeddings). В этой статье мы работали только с single-vector 向量模型 (Embeddings). Но для полного использования jina-embeddings-v4, мы также хотели бы включить multi-vector 向量模型 (Embeddings) для достижения более высокой точности на сложных изображениях. Это было бы легким дополнением, поскольку эти 向量模型 (Embeddings) генерируются с помощью одного линейного слоя поверх базовой модели.
tagЗаключение
Несмотря на первоначальные ошибки и неудачи, интеграция мультимодальных 向量模型 (Embeddings) в llama.cpp теперь дает результаты, близко совпадающие с нашей эталонной моделью PyTorch, в том числе в ряде тестов. Исправления в attention mask и обработке патчей изображений устранили основные источники расхождений, и даже квантованные варианты достигают аналогичной точности при использовании значительно меньшего количества ресурсов. Остающиеся различия при более высоких разрешениях изображений кажутся незначительными и, вероятно, связаны с различиями в бэкенде, а не с основной реализацией модели.
В перспективе, расширение квантования на vision encoder, включение пакетной обработки через отдельный сервис и поддержка multi-vector 向量模型 (Embeddings) еще больше повысят как эффективность, так и точность. Эти дополнения сделают мультимодальные 向量模型 (Embeddings) в llama.cpp более масштабируемыми и лучше подходящими для реальных сценариев использования.






