Skip to content

mtmd: Add JinaCLIP v2 vision projector + GGUF support for jina-bert-v3 (merged-LoRA or adapter)#16574

Open
pockers21 wants to merge 4 commits into
ggml-org:masterfrom
pockers21:feature/jinaclip-v2-projector
Open

mtmd: Add JinaCLIP v2 vision projector + GGUF support for jina-bert-v3 (merged-LoRA or adapter)#16574
pockers21 wants to merge 4 commits into
ggml-org:masterfrom
pockers21:feature/jinaclip-v2-projector

Conversation

@pockers21

@pockers21 pockers21 commented Oct 14, 2025

Copy link
Copy Markdown
Contributor

Update Notes (2026‑2-9)

  • Update model conversion logic: add --mmproj mode to export the vision model only; by default,
    export the text model and LoRA head.
  • Validate numerical parity: C++ vs Python cosine similarity and RMSE are within the expected range.
  • The temporary implementation in mtmd-cli will be kept now; once upstream stabilizes the general
    embedding path (including llama-context-related support), I will refactor to align with the mainline
    approach and remove the temporary implementation.

Update Notes (2025‑11‑6)

  • CLI Merge
    • Fold the standalone Jina CLI into mtmd-cli’s projector‑only flow; remove the extra binary.
  • Conversion Script (set_gguf_parameters)
    • Emit vision keys using the standard naming: clip.has_vision_encoder, clip.vision.image_size/patch_size/embedding_length/
      block_count/projection_dim/feed_forward_length/attention.head_count.
    • Write only projector_type (set to 'jinaclip2'); do not introduce projector_version.
  • Inference (mtmd)
    • Use ggml_rope_ext to implement 2D RoPE; reuse bicubic for image preprocessing.
  • Minimal Validation
    • Conversion succeeds; gguf_dump shows clip.projector_type='jinaclip2'.
    • Minimal inference passes for both text and image; C++ vs Python cosine/RMSE are within the expected range.
      Reproduction
Minimal commands & data (CPU)
  • Produce GGUF (with ST pooling metadata)
    • Text: jina-bert-v3.pooling_type = MEAN/CLS/LAST
    • Vision: clip.projector_type = jinaclip2, clip.vision.rope_theta = 10000 (default)
  • Text parity
    • C++: CUDA_VISIBLE_DEVICES= ./build/bin/llama-embedding -m /path/jina-text-converted.gguf -p "hello world" --n-gpu-layers 0 --pooling mean --embd-normalize 2 --embd-output-format array
    • Python: python3 <ref>/debug.py --mode text --input "hello world" --out-dir <dir> --fa off
    • Metric: read both 512-d outputs and compute cosine / RMSE
  • Image parity
    • C++: CUDA_VISIBLE_DEVICES= ./build/bin/llama-mtmd-cli --mmproj /path/mmproj-jina-vision-converted.gguf --image /path/img.jpg --n-gpu-layers 0 --embd-normalize 2 --embd-output-format array
    • Python: python3 <ref>/debug.py --mode image --input /path/img.jpg --out-dir <dir> --fa off
    • Metric: read both 512-d outputs and compute cosine / RMSE

mtmd: Add JinaCLIP v2 vision projector + GGUF support for jina-bert-v3 (merged-LoRA or adapter)

Overview

  • Converter: write jina-bert-v3 text tower params into GGUF (supports both merged-LoRA checkpoints and adapter-based inputs), and export vision metadata (projector_type=jinaclip, vision.rope_theta, image_size, patch_size, projection_dim, etc.).
  • Runtime: introduce PROJECTOR_TYPE_JINACLIP in the MTMD path (JinaCLIP v2 vision tower: 2D RoPE with shared frequency cache, attention/FFN internal LayerNorm, single-token output), and normalize with common_embd_normalize(..., 2).
  • CLI (core): add a minimal validation tool llama-jinaclip-cli (built by default) for text/image embedding numerical/performance checks; depends only on common+mtmd+Threads, cross-platform buildable, no third-party deps.
  • Compatibility: only activates when related GGUF metadata exists; doesn’t affect other projectors (e.g., LLaVA/Qwen2VL); no ggml op changes; no external dependencies.

Scope of changes

  • convert_hf_to_gguf.py
    • Text: support both merged-LoRA single checkpoints and adapter-based export.
    • Vision (JinaCLIP v2): export clip.projector_type=jinaclip, clip.vision.rope_theta (configurable), image_size/patch_size/projection_dim, and map tensors for fused/non-fused QKV.
  • tools/mtmd/clip.cpp, tools/mtmd/clip-impl.h
    • Add PROJECTOR_TYPE_JINACLIP: JinaCLIP v2 vision tower (2D RoPE with shared freq cache), attention internal LN, FFN sub-layer LN (enabled when both weight/bias present), single-token output (CLS-equivalent), unified L2 normalize.
    • clip_n_output_tokens() returns 1 for JinaCLIP; clip_n_mmproj_embd() returns projection_dim.
  • tools/mtmd/jinaclip-cli.cpp, tools/mtmd/CMakeLists.txt
    • Add llama-jinaclip-cli target (default); one command covers text/image minimal validation, thread scaling, encode_ms reporting, and saves embeddings for Python parity.

Validation summary

  • CI: CPU-only ci/run.sh passes locally; no ggml op changes in this PR.
  • Correctness: embedding models have no perplexity; we verify via C++ vs Python parity.
    • TEXT (CPU, minimal sample): cosine=0.999996, RMSE=0.000125
    • IMAGE (CPU, minimal sample): cosine=0.990261, RMSE=0.006168
  • Performance: checked with CLI encode_ms and thread scaling; no regression observed. More data can be added if requested.
  • Compatibility: activated only when GGUF metadata (projector_type=jinaclip, etc.) is present; other projectors unaffected.
  • Reference: ModelScope uniontech-yourong/split_jina (used for Python-side parity).

Performance (absolute metrics, CPU-only minimal samples)

  • Environment
    • OS: Ubuntu 22.04.5 LTS
    • CPU: Intel Xeon Platinum 8352V (dual-socket, 2×32C/64T, SMT on), 128 threads total
    • Build: Release, GGML_CUDA=OFF (CPU-only), GCC 11.4, CMake 3.22
    • Model: JinaCLIP v2 vision tower (image_size=512, patch=14, depth=24, hidden=1024; official: https://huggingface.co/jinaai/jina-clip-v2); text tower (Jina Embeddings v3, output truncated to 512 dims)
    • Threads: primarily 8 threads for both text/image (with 1-thread comparison)
  • Metric definitions
    • Text: use CLI-reported JINACLIP_ENCODE_MS (pure inference, excludes load)
    • Image: use CLI line “image … done in … ms” (pure inference, excludes load)
  • Results (single sample, minimal)
    • Text (“hello world”, ≈5 tokens)
      • 1 thread: encode_ms ≈ 180.48 ms
      • 8 threads: encode_ms ≈ 34.08 ms
    • Image (512×512, single)
      • 8 threads: image done in ≈ 6154 ms (stabilizes ~6.1–6.4 s after warm-up)
  • Notes
    • Above numbers are CPU-only pure inference; end-to-end (including model load) is higher and not included.

GPU group (absolute metrics, minimal samples)

  • Environment
    • GPU: NVIDIA vGPU-32GB (cc=8.9, 32 GB), Driver 550.107, CUDA 12.4
    • Build: Release, GGML_CUDA=ON (CUDA backend), CUDA arch=89
    • Threads: -t 8 (host-side preprocessing threads)
  • Results (pure inference, excludes load)
    • Text (“hello world”, ≈5 tokens): encode_ms ≈ 84.88 ms
    • Image (512×512, single): image done in ≈ 827 ms

@pockers21
pockers21 requested review from CISC and ngxson as code owners October 14, 2025 09:04
@github-actions github-actions Bot added examples python python script changes labels Oct 14, 2025

@ngxson ngxson left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a minimal validation tool llama-jinaclip-cli (built by default) for text/image embedding numerical/performance checks;

I don't see why wee need to add this new CLI. The mtmd-cli can do this with -p and --image params

Comment thread tools/mtmd/CMakeLists.txt Outdated
Comment thread convert_hf_to_gguf.py Outdated
Comment thread convert_hf_to_gguf.py Outdated

# Top-level direct mappings
if src_no_vm == 'cls_token':
return [('v.cls_token', data_torch)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use proper mapping instead

Comment thread tools/mtmd/clip.cpp Outdated
Comment on lines +2229 to +2237
if (!ctx->jinaclip_rope_initialized) {
const int half_dim = rope_dim / 2;
std::vector<float> base_freqs(half_dim);
for (int i = 0; i < half_dim; i++) {
float arange_val = i * 2.0f; // [0, 2, 4, ..., 30]
float normalized = arange_val / rope_dim; // [0, 2/32, 4/32, ..., 30/32]
float theta_powered = powf(freq_base, normalized); // theta^normalized
base_freqs[i] = 1.0f / theta_powered; // 1.0 / theta^normalized
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you're trying to do here, is this just 2D RoPE? (which we already supported)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn’t re‑implementing generic 2D RoPE; it implements JinaCLIP’s VisionRotaryEmbeddingFast.
It uses fractional‑position 2D RoPE (t = arange(ft)/ft * pt) and precomputes a full H×W cos/sin grid; the official 2D RoPE uses integer grid positions (pos_h/pos_w) with ggml_rope_ext and does not include these steps.
This is done to strictly match Jina’s Python semantics.

@ngxson ngxson Oct 15, 2025

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fractional‑position 2D RoPE (t = arange(ft)/ft * pt)

Based on your code:

time_seq[i] = (float) i / ft_seq_len * pt_seq_len;  // [0, 16/36, 32/36, ..., 560/36]
...
freqs_h[t * half_dim + f] = time_seq[t] * base_freqs[f];

Then why don't we scale base_freqs[f] instead? The third param of ggml_rope_ext, the c tensor (freq_scale) is made for this purpose.

Honestly I think this is just YaRN

Comment thread tools/mtmd/clip.cpp Outdated
Comment thread convert_hf_to_gguf.py Outdated
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch from fd37a5c to 9d02918 Compare October 22, 2025 08:39
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch from 9d02918 to e19eb27 Compare October 22, 2025 10:35
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch from e19eb27 to 2d8885b Compare October 22, 2025 10:36
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch from 2d8885b to b9f78de Compare October 22, 2025 11:07
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch from b9f78de to 2787888 Compare October 23, 2025 02:07
@pockers21
pockers21 marked this pull request as draft October 24, 2025 05:45
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch 2 times, most recently from 46f9ee2 to 542ed6a Compare October 28, 2025 03:17
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch 4 times, most recently from 445e0d5 to bd46020 Compare October 28, 2025 10:02
@CISC

CISC commented Oct 28, 2025

Copy link
Copy Markdown
Member

@pockers21 What's up?

@pockers21

pockers21 commented Oct 29, 2025

Copy link
Copy Markdown
Contributor Author

@pockers21 What's up?

I’m currently adjusting the code and fixing issues. I originally planned to answer your questions together when
moving the PR from draft to a formal PR, let me explain now. The link you shared (https://huggingface.co/jinaai/jina-clip-v2/blob/main/config.json#L15-L38) points to the official Jina config that includes LoRA. In our work, we
modified the official Jina to fuse the text-side LoRA into the base model and then exported it to GGUF. Under JINA
logic, those fields won’t take effect when loading Jina v2; they are only triggered when loading the embeddings v3
model.

@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch 2 times, most recently from 7e0b15b to 2338880 Compare October 29, 2025 08:43
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch from ebe1377 to 87431ef Compare November 4, 2025 02:18
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch from 87431ef to d6a9167 Compare November 4, 2025 02:30
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch from d6a9167 to ee7aace Compare November 4, 2025 02:42
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch from ee7aace to 84f4c5c Compare November 4, 2025 03:04
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch from 84f4c5c to 3e4f6ed Compare November 4, 2025 03:28
@pockers21
pockers21 force-pushed the feature/jinaclip-v2-projector branch from 3e4f6ed to 2f94dcc Compare November 4, 2025 03:42
Comment thread convert_hf_to_gguf.py Outdated
Comment thread convert_hf_to_gguf.py Outdated
@CISC

CISC commented Nov 15, 2025

Copy link
Copy Markdown
Member

@pockers21 You need to address the tensor mappings, as pointed out by @ngxson, use tensor_mapping.py where possible.

@pockers21

Copy link
Copy Markdown
Contributor Author

@pockers21 You need to address the tensor mappings, as pointed out by @ngxson, use tensor_mapping.py where possible.

Done, please review again.

@CISC

CISC commented Nov 20, 2025

Copy link
Copy Markdown
Member

Hmmm, there's a major issue with conversion, the text_config is normally applied on top of the remote jina-embeddings-v3 config.json by transformers, however convert_hf_to_gguf.py has no concept of this when reading the jina-clip-v2 config.json (because trust_remote_code=False).

This means that:

  1. It thinks that the text model architecture is JinaCLIPModel and fails
  2. The vision model conversion fails assert self.n_embd_text > 0, "n_embd not found in hparams"

I tried kludging it by copying in values, but I got several other failures, so it's just not working...

@pockers21

pockers21 commented Nov 21, 2025

Copy link
Copy Markdown
Contributor Author

Hmmm, there's a major issue with conversion, the text_config is normally applied on top of the remote jina-embeddings-v3 config.json by transformers, however convert_hf_to_gguf.py has no concept of this when reading the jina-clip-v2 config.json (because trust_remote_code=False).

This means that:

  1. It thinks that the text model architecture is JinaCLIPModel and fails
  2. The vision model conversion fails assert self.n_embd_text > 0, "n_embd not found in hparams"

I tried kludging it by copying in values, but I got several other failures, so it's just not working...

The original Jina model is a single multi-modal checkpoint that contains both text and vision components, and the text side includes a LoRA head. In our workflow, we did two things:

  1. We split this original model into separate text and vision parts.
  2. We merged the text LoRA head back into the text encoder weights.

If you want to run conversion, you should follow the layout used here:
https://www.modelscope.cn/models/uniontech-yourong/split_jina/files

Concretely, our implementation assumes that you:

  1. git clone that model repo locally (after opening the page and clicking the “Download model” button, ModelScope will show you the exact clone/download command).

  2. After the download, run:

    python3 convert_hf_to_gguf.py ORIG_IMAGE_PATH --outfile out.gguf --mmproj
    

Here, ORIG_IMAGE_PATH must point to the split_jina/image directory.
In that directory you will see a vision_model_weights.bin file, which is what the converter expects to load for the vision encoder.

@pockers21

Copy link
Copy Markdown
Contributor Author

Hmmm, there's a major issue with conversion, the text_config is normally applied on top of the remote jina-embeddings-v3 config.json by transformers, however convert_hf_to_gguf.py has no concept of this when reading the jina-clip-v2 config.json (because trust_remote_code=False).

This means that:

  1. It thinks that the text model architecture is JinaCLIPModel and fails
  2. The vision model conversion fails assert self.n_embd_text > 0, "n_embd not found in hparams"

I tried kludging it by copying in values, but I got several other failures, so it's just not working...

Looking forward to your feedback.

@CISC

CISC commented Nov 28, 2025

Copy link
Copy Markdown
Member

TBH, I'm not sure this is acceptable, I would expect to be able to convert the original model, granted it's a little tricky due to the way it's constructed, but should be doable.

It might be acceptable to have a preprocessing script for it, but that's not ideal, @ngxson any opinions?

@ngxson

ngxson commented Nov 30, 2025

Copy link
Copy Markdown
Collaborator

Some thoughts about this PR:

  1. The problem with LoRA should be addressed in a dedicated PR, as it is completely unrelated to the multimodal system. Merging LoRA into the model seems counter-intuitive because the LoRA is only used for query. Merging will completely cut off its capability to acts as an indexer.
  2. I don't get why we need to implement a llama_context-less version of mtmd. The original model contains both text and image encoder, we can just use the text model to initialize the llama_context
  3. The array of API mtmd_mmproj_* that you implemented in this PR is too model-specific. I don't think we need to add any additional APIs, the existing mtmd API is already enough

So unfortunately, unless (1) is resolve, I don't think we can proceed to merge this PR.

@ngxson ngxson left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code looks quite inconsistent. Can you explicitly state if you used AI to generate part of the code?

Comment thread tools/mtmd/mtmd.h Outdated
Comment on lines +229 to +241
MTMD_API void mtmd_mmproj_free(struct mtmd_mmproj_context * ctx);

// basic queries
MTMD_API int mtmd_mmproj_get_image_size (struct mtmd_mmproj_context * ctx);
MTMD_API int mtmd_mmproj_get_patch_size (struct mtmd_mmproj_context * ctx);
MTMD_API int mtmd_mmproj_get_hidden_size(struct mtmd_mmproj_context * ctx);
MTMD_API bool mtmd_mmproj_is_jinaclip (struct mtmd_mmproj_context * ctx);
// generic support check for projector-only encode
MTMD_API bool mtmd_mmproj_is_supported (struct mtmd_mmproj_context * ctx);

// encode a bitmap (RGB) to projector embeddings
// returns 0 on success, 1 on failure
MTMD_API int mtmd_mmproj_encode_bitmap(struct mtmd_mmproj_context * ctx,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As explained above, there is no reasons to add llama_context-less API

get_image_size, get_patch_size, get_hidden_size APIs are overkill as they're being used only for logging purpose. They should not exist in the first place.

we do not allow model-specific APIs like is_jinaclip because for obvious reason, it communicates nothing to developer who use the API

Comment thread tools/mtmd/clip.cpp Outdated
clip_image_u8 processed_image;
int sz = params.image_size;

// 1) Preserve aspect ratio: resize so that the shorter side == sz (bicubic)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this whole code block is redundant. it is essentially the same logic with PROJECTOR_TYPE_GEMMA3

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PROJECTOR_TYPE_GEMMA3 directly resizes to a square, while PROJECTOR_TYPE_JINACLIP2 first resizes by the
shortest edge. See: https://huggingface.co/jinaai/jina-clip-implementation/blob/main/transform.py#L354

Comment thread tools/mtmd/clip.cpp Outdated
Comment thread tools/mtmd/clip.cpp Outdated
@pockers21

Copy link
Copy Markdown
Contributor Author

The code looks quite inconsistent. Can you explicitly state if you used AI to generate part of the code?

No, these are required in the current implementation. Please see the discussion I pasted below.

@pockers21

Copy link
Copy Markdown
Contributor Author

Some thoughts about this PR:

  1. The problem with LoRA should be addressed in a dedicated PR, as it is completely unrelated to the multimodal system. Merging LoRA into the model seems counter-intuitive because the LoRA is only used for query. Merging will completely cut off its capability to acts as an indexer.
  2. I don't get why we need to implement a llama_context-less version of mtmd. The original model contains both text and image encoder, we can just use the text model to initialize the llama_context
  3. The array of API mtmd_mmproj_* that you implemented in this PR is too model-specific. I don't think we need to add any additional APIs, the existing mtmd API is already enough

So unfortunately, unless (1) is resolve, I don't think we can proceed to merge this PR.

I’ve opened a new discussion and appreciate your feedback.
#17885 @CISC @ngxson @ggerganov

liyang and others added 4 commits February 9, 2026 02:12
…icubic;switch to 'jinaclip2'; fix converter constants
Remove unnecessary try/except Jina text hparams.

Co-authored-by: Sigbjørn Skjæret <sigbjorn.skjaeret@scala.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

examples ggml changes relating to the ggml tensor library for machine learning python python script changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants