--- license: apache-2.0 base_model: google/embeddinggemma-2 base_model_relation: quantized library_name: coreml pipeline_tag: feature-extraction tags: [coreml, embeddings, multimodal-embedding, image-text, sentence-similarity, ios, macos, neural-engine] --- # EmbeddingGemma 2 for Core ML This is [`google/embeddinggemma-2`](https://huggingface.co/google/embeddinggemma-2) (Google DeepMind's text and image embedding model, one shared 768-d space) converted to **multifunction Core ML models that run on the Apple Neural Engine**, one per image budget. Every stage of the conversion was checked against the original PyTorch model. It is an unofficial conversion. It is not made or endorsed by Google. - **Base model:** `google/embeddinggemma-2` at revision `914f7f89142e33e77833254d9c9b90c3cef7303b` (`model.safetensors` sha256 `197a32965d4b1105faf060417baa899e193fb73cd401f42ec9295234d5553d79`). - **Modalities:** text and images. Audio and video are not converted. - **Variant shipped:** `int8_table4`: int8 per-channel conv weights, int4 per-block (32) table; tables and norms FP16. - **Layout:** one folder per image budget, `b70/` and `b280/`. Each holds the text functions and that budget's image functions; a device uses one budget only, so it downloads one folder's archive. - **Size:** apps download one file, `b/EmbeddingGemma2.aar` (an LZFSE Apple Archive of the package and the tokenizer: 343.2 MB for budget 70, 352.9 MB for budget 280; the s4 gate is 360 MB for the larger), described by `b/manifest.json` (size, SHA-256 and image budget). Unpacked it is 435.0 / 454.9 MB; the package alone is 402.8 / 422.8 MB. The same files are also in each folder unpacked, each listed with its size and SHA-256 in `b/files.json`. - **Runs on:** iOS 18+ and macOS 15+ (the model uses `ios18` ops). It is built for `.cpuAndNeuralEngine`. - **Swift package:** [`EmbeddingGemma`](https://github.com/Delavalom/embeddinggemma-coreml) (download, verification, tokenizer, image preprocessing, plus an on-disk vector index). See "Use from Swift" below. ## License and terms - **The weights in this repo are a modified version of `google/embeddinggemma-2`, under the Apache License, Version 2.0.** This is the license the upstream model card declares at the pinned revision (`license: apache-2.0`, linking Google's [Gemma 4 license](https://ai.google.dev/gemma/docs/gemma_4_license), which is the Apache 2.0 text). The original model is by Google DeepMind. See `LICENSE` (the full Apache 2.0 text) and `NOTICE`. - **Changes made (Apache 2.0, section 4(b)):** the original weights were re-implemented in PyTorch, converted to Core ML, cast to FP16, and compressed (int8 per-channel convolution weights, int4 per-block embedding table). The vectors are close to the original's but not identical (see "Accuracy"). - **Upstream use policy, passed through:** the upstream model card says *"Deployments must adhere to the [Gemma Prohibited Use Policy](https://ai.google.dev/gemma/prohibited_use_policy)."* That requirement comes from the upstream model, and it applies to this conversion too. The upstream card also says developers are responsible for application-level safeguards (retrieval filtering, fairness testing) suited to their use case. Read Google's [model card](https://huggingface.co/google/embeddinggemma-2) for its intended uses, limitations and ethical considerations. They all apply here. - The tokenizer files (`tokenizer/tokenizer.json`, `tokenizer/tokenizer_config.json`) are unmodified copies from the upstream revision above. - The conversion code and the Swift package are Apache-2.0 too (the GitHub repo's `LICENSE` and `NOTICE`). ## Functions One `.mlpackage` per image budget, `b70/EmbeddingGemma2.mlpackage` and `b280/EmbeddingGemma2.mlpackage`, each with nine functions with fixed shapes: the four text functions (the same in both) and the five image functions of its budget. Both were split from one 14-function package, and every function gives the same output bytes as there. Every function returns `embedding`, float32 `[1, 768]`, L2-normalized. | function | input | tokens | |---|---|---| | `embed_text_16`, `_32`, `_64`, `_128` | `input_ids` int32 `[1, L]`, `attention_mask` int32 `[1, L]` (1 = token, 0 = padding) | L = 16 / 32 / 64 / 128 | | `embed_image_70_{tall,portrait,square,landscape,wide}` (in `b70/`) | `pixels` `[1, 3, H, W]`, RGB in 0-1 | 60-70 image tokens | | `embed_image_280_{tall,portrait,square,landscape,wide}` (in `b280/`) | `pixels` `[1, 3, H, W]`, RGB in 0-1 | 256-280 image tokens | Image shapes (H x W). The bucket comes from the aspect ratio r = width / height: tall r < 0.6, portrait r < 0.83, square r <= 1.2, landscape r <= 1.67, wide above that. | budget | tall | portrait | square | landscape | wide | |---|---|---|---|---|---| | 70 | 576 x 240 | 480 x 336 | 384 x 384 | 336 x 480 | 240 x 576 | | 280 | 1152 x 528 | 960 x 672 | 768 x 768 | 672 x 960 | 528 x 1152 | - **Text** is `` + tokens(prompt + text) + ``, padded with `` (id 0) to the smallest length that fits. Longer inputs are cut to 128 tokens, keeping `` and `` (the upstream model allows up to 8K tokens). Use the upstream prompts: `task: search result | query: ` for queries, `title: none | text: ` for documents (also `task: classification | query: ` and `task: clustering | query: `). - **Images** are resized straight to the bucket size (no crop or padding). Use 8-bit sRGB, antialiased bicubic resampling, and scale by 1/255 with no mean or std normalization, as the reference processor does. The Swift package does this. - **Matryoshka:** you can truncate the 768-d output to 512, 256 or 128 dims and re-normalize. The Swift package defaults to 256. All accuracy numbers below are at 768 dims. ## Accuracy against the original model Measured by the pipeline on 200 hand-written texts (120 of them search queries) and 200 images (CC0 and public domain photos from Wikimedia Commons, plus synthetic screenshots, memes and receipts; 14 categories), at both image budgets. Cosine values are the **minimum** over all inputs, not the mean. | stage | what is compared | min cosine, text | min cosine, image | |---|---|---|---| | s1 | our PyTorch rewrite (FP32) vs the official `sentence-transformers` model | 1 - 1.4e-12 | 1 - 1.0e-08 | | s2 | Core ML FP32 vs the official model | 1 - 1.2e-12 | 1 - 1.9e-08 | | s3 | Core ML FP16 on the Neural Engine vs the official model (at least 99.86% of ops on the Neural Engine) | 0.99996 | 0.99404 (1st percentile 0.99848) | | s4 | **shipped `int8_table4`** on `CPU_AND_NE` vs the official model | **0.99791** | **0.99133** (1st percentile 0.99539) | | s5 | the Swift package end to end on a Mac (tokenizer + preprocessing + model) vs the official model on the same decoded pixels; vs Python Core ML on identical inputs: 1 - 1.0e-15 | 0.99017 (text and image) | | Retrieval with the shipped variant (the 120 queries ranking the 200 images, budget 70 / 280): | metric | budget 70 | budget 280 | |---|---|---| | text-to-image recall@10 vs the FP reference on the same bucketed images | 0.962 | 0.966 | | 5th-percentile Spearman of the top 50, same reference | 0.966 | 0.972 | | text-to-image recall@10 vs the exact-aspect FP model (recorded, not gated) | 0.844 | 0.874 | For 98.3% of queries (worst budget), the exact-aspect model's top-1 image is in the shipped model's top 10. Image-to-image precision@10 by category label is at most 0.012 below the exact-aspect FP model. Swift tokenization matches the reference ids for every fixture text (`True`). The 99th-percentile pixel difference against the reference preprocessing is 0.000000 (0-1 units). **Caveats:** - **The retrieval gates use a bucketed FP reference.** The upstream processor resizes each image to its own aspect-preserving size. This model has five fixed shapes per budget, so even with no compression, the bucketed vector differs from the exact-aspect one (min cosine 0.916 at budget 70, 0.893 at 280). That is why recall@10 against the exact-aspect model is lower than recall@10 against the bucketed reference. The compression and FP16 gates (min cosine >= 0.99, recall@10 >= 0.95, Spearman >= 0.9) are therefore measured against the FP model on the same bucketed images. Bucketing gets its own task-level gates (top-1 in top 10 >= 0.95, category precision@10 drop <= 0.03). Measured by category labels, bucketing costs no retrieval quality. Details: ADR-0007 in the GitHub repo. - **Two images fall below the FP16 bar on the Neural Engine.** In FP16 on the Neural Engine, fixture images i141 and i161 reach 0.99404 / 0.99405 cosine. The other 398 are at 0.998 or above. On the GPU these two images pass, but the GPU is 2.8x slower at budget 70. They change the top 10 of only a few queries: with only those two vectors swapped in, recall@10 stays at 0.9975 (budget 70) and 0.9992 (budget 280). They were accepted at task level, and images stay on the Neural Engine. The lowest image cosines of the shipped variant are i161 0.99133 (`embed_image_280_landscape`), i141 0.99322 (`embed_image_70_wide`), i165 0.99336 (`embed_image_280_portrait`). Details: ADR-0006. - The test set is small: 200 texts, 200 images. Multilingual text, code and long documents were not tested separately. For quality on standard benchmarks, see Google's model card. This conversion only claims closeness to the original. ## Speed and memory **Mac** (s4, `CPU_AND_NE`, median of 20 runs after warm-up). These were measured on the build Mac; the v1.0.0 build Mac is an M1 Pro with macOS 26. `embed_text_16` takes 4.4 ms and `embed_image_70_square` takes 43.8 ms. In every function, at least 99.81% of the ops are placed on the Neural Engine (`MLComputePlan`, minimum over the 14 functions, measured on the package the two budget packages were split from). **iPhone** (s6, PhotoSearchDemo's benchmark: each iPhone runs its own budget, `ImageBudget.auto`, 280 on A17 Pro and newer and 70 otherwise: the newest 1,000 photos at 70 or 200 at 280, and 20 queries; times cover preprocessing + prediction): | device | iOS | ms per image, 70 (median) | ms per image, 280 (median) | ms per query (median) | peak memory, MB | |---|---|---|---|---|---| | iPhone14,3 | 27.0.1 | 64.0 | not run | 6.7 | 630 | Worst device: 64.0 ms per image at 70, not measured (no A17 Pro or newer iPhone ran s6 yet) at 280, 6.7 ms per query, peak 630 MB (the gate is 800 MB; the target for budget 70 is 60 ms, which is recorded, not gated). **Storage:** the Swift package downloads the archive of the device's image budget, `b/EmbeddingGemma2.aar` (343.2 MB at 70, 352.9 MB at 280, resumable), checks its size and SHA-256 against `b/manifest.json`, unpacks it (435.0 / 454.9 MB) with the AppleArchive framework and compiles the package. It needs free space for the archive, the unpacked files and the compiled model while it installs; afterwards it keeps the compiled model and the tokenizer. The s4 gate measures the larger archive, 352.9 MB (limit 360 MB). ## Use from Swift ```swift // Package.swift: .package(url: "https://github.com/Delavalom/embeddinggemma-coreml", from: "1.0.0") import EmbeddingGemma import EmbeddingIndex let model = try await EmbeddingGemma.load(.huggingFace(revision: EmbeddingGemma.defaultRevision)) let photo = try await model.embed(image: cgImage, orientation: .up) // [Float], 256-d by default, normalized let query = try await model.embed(query: "receipts from last week") ``` The package pins this repo (`delavalom/embeddinggemma-2-coreml`) at a commit and the SHA-256 of each budget's `manifest.json`, which pins its archive's size and SHA-256. `budget: .auto` picks 280 tokens on A17 Pro and newer iPhones and on Macs, and 70 on other devices, and loads that budget's folder (`b280/` or `b70/`) only. ## Use from Python (coremltools, macOS 15+) ```python import numpy as np, coremltools as ct from tokenizers import Tokenizer from torchvision.transforms.functional import pil_to_tensor, resize, InterpolationMode from PIL import Image BUDGET = 280 # the folder you downloaded: b280/ or b70/; its text functions are the same in both PKG = f"b{BUDGET}/EmbeddingGemma2.mlpackage" tok = Tokenizer.from_file(f"b{BUDGET}/tokenizer/tokenizer.json") # self-check: the layout the model was traced with is ... assert tok.encode("task: search result | query: screenshots of tweets").ids == \ [2, 8071, 236787, 3927, 1354, 1109, 7609, 236787, 75490, 529, 46331, 1] def fn(name): return ct.models.MLModel(PKG, function_name=name, compute_units=ct.ComputeUnit.CPU_AND_NE) def embed_text(text, prompt="task: search result | query: "): ids = tok.encode(prompt + text).ids ids = ids if len(ids) <= 128 else ids[:127] + [ids[-1]] n = next(L for L in (16, 32, 64, 128) if L >= len(ids)) x = np.zeros((1, n), np.int32); x[0, :len(ids)] = ids m = np.zeros((1, n), np.int32); m[0, :len(ids)] = 1 return fn(f"embed_text_{n}").predict({"input_ids": x, "attention_mask": m})["embedding"][0] SHAPES = {70: {"tall": (576, 240), "portrait": (480, 336), "square": (384, 384), "landscape": (336, 480), "wide": (240, 576)}, 280: {"tall": (1152, 528), "portrait": (960, 672), "square": (768, 768), "landscape": (672, 960), "wide": (528, 1152)}} def bucket(r): # r = width / height return ("tall" if r < 0.6 else "portrait" if r < 0.83 else "square" if r <= 1.2 else "landscape" if r <= 1.67 else "wide") def embed_image(path): img = Image.open(path).convert("RGB") # like the reference: 8-bit RGB, ICC profile ignored name = bucket(img.width / img.height) px = resize(pil_to_tensor(img), list(SHAPES[BUDGET][name]), InterpolationMode.BICUBIC, antialias=True) px = px.float() / 255 return fn(f"embed_image_{BUDGET}_{name}").predict({"pixels": px[None].numpy()})["embedding"][0] def truncate(v, dims=256): # Matryoshka v = v[:dims]; return v / np.linalg.norm(v) ``` The model is compiled each time `MLModel` is created. To make many calls, keep one instance per function. The Python path is not gated; the gated path is the Swift package (s5). ## Reproducibility The model was built by an 8-stage pipeline in the GitHub repo. Each stage writes `artifacts//report.json`, which is checked against `gates.toml`. A stage counts only if its report passed and is current. Each report records `git_sha` and `code_hash` (sha256 over the stage's code, the lock file and its gate table), and `inputs_sha256` chains it to the reports it read. The reports behind this release: | stage | status | git_sha | code_hash | |---|---|---|---| | s0_reference | passed | `59b9bf9863cb` | `9a6a53c2abe21038` | | s1_rewrite | passed | `7645eff4d951` | `e98a54bc6bbc6abf` | | s2_coreml_fp32 | passed | `82b02a4da1ec` | `21bb94d9d33d0230` | | s3_coreml_ane | passed | `8e33af79825f` | `edb80276335be2a4` | | s4_compress | passed | `b9064e9abb2b` | `e68c9ca0822062d0` | | s5_swift | passed | `90679438068a` | `464d01a178d66eb5` | | s6_device | passed | `d5b2e6edc33e` | `70b253e29d57b0ce` | Reference environment: transformers 5.20.2, sentence-transformers 6.2.0; conversion: coremltools 9.0. To rebuild: `just setup`, then `just stage 0` through `just stage 5` (`just stage 6` needs an iPhone). Numbers measured on the CPU and the Neural Engine can vary slightly from run to run. # Results Generated by `python -m scripts.gen_results` from `artifacts/*/report.json` and `docs/results/*.json`. Do not edit by hand. Model: `google/embeddinggemma-2` at revision `914f7f89142e33e77833254d9c9b90c3cef7303b`. ## Gates | stage | status | metric | value | gate | ok | |---|---|---|---|---|---| | s0_reference | passed | license_apache | true | == true | yes | | s0_reference | passed | key_inventory_written | true | == true | yes | | s0_reference | passed | deterministic | true | == true | yes | | s1_rewrite | passed | strict_load | true | == true | yes | | s1_rewrite | passed | min_cosine_text | 1 - 1.4e-12 | >= 0.9999 | yes | | s1_rewrite | passed | min_cosine_image | 1 - 1e-08 | >= 0.9999 | yes | | s2_coreml_fp32 | passed | min_cosine_text | 1 - 1.2e-12 | >= 0.9999 | yes | | s2_coreml_fp32 | passed | min_cosine_image | 1 - 1.9e-08 | >= 0.9999 | yes | | s3_coreml_ane | passed | min_cosine_text | 0.999965 | >= 0.995 | yes | | s3_coreml_ane | passed | min_cosine_image | 0.994037 | >= 0.99 | yes | | s3_coreml_ane | passed | min_p1_cosine_image | 0.998482 | >= 0.995 | yes | | s3_coreml_ane | passed | min_recall_at_10 | 0.9675 | >= 0.95 | yes | | s3_coreml_ane | passed | min_p5_spearman_top50 | 0.987025 | >= 0.9 | yes | | s3_coreml_ane | passed | min_ane_fraction | 0.99864 | >= 0.95 | yes | | s4_compress | passed | min_cosine | 0.991329 | >= 0.99 | yes | | s4_compress | passed | min_recall_at_10 | 0.961667 | >= 0.95 | yes | | s4_compress | passed | min_p5_spearman_top50 | 0.966043 | >= 0.9 | yes | | s4_compress | passed | min_t2i_top1_in_top10 | 0.983333 | >= 0.95 | yes | | s4_compress | passed | max_i2i_label_p10_drop | 0.012 | <= 0.03 | yes | | s4_compress | passed | max_download_mb | 352.911 | <= 360 | yes | | s5_swift | passed | token_ids_identical | true | == true | yes | | s5_swift | passed | max_pixel_abs_diff | 0 | <= 0.003922 | yes | | s5_swift | passed | min_cosine_runtime | 1 - 1e-15 | >= 0.9999 | yes | | s5_swift | passed | min_cosine | 0.990173 | >= 0.99 | yes | | s5_swift | passed | min_recall_at_10 | 0.9575 | >= 0.95 | yes | | s5_swift | passed | min_p5_spearman_top50 | 0.96449 | >= 0.9 | yes | | s5_swift | passed | min_t2i_top1_in_top10 | 0.983333 | >= 0.95 | yes | | s5_swift | passed | max_i2i_label_p10_drop | 0.0065 | <= 0.03 | yes | | s6_device | passed | max_peak_rss_mb | 630.065 | <= 800 | yes | | s6_device | passed | max_cold_compile_peak_mb | 1517.66 | <= 2100 | yes | | s6_device | passed | min_devices | 1 | >= 1 | yes | | s7_publish | not run | - | - | - | - | ## s4 variants Chosen: `int8_table4` (winner: `int8_table4`). Variants are tried smallest first; the first that passes every gate wins, so later ones may not have run. Download limit: 360 MB. | variant | download MB | package MB | min cosine | recall@10 | p5 Spearman top-50 | exact-aspect top-1 in top 10 | i2i p@10 drop | min ANE fraction | recall@10 vs exact-aspect | passes | description | |---|---|---|---|---|---|---|---|---|---|---|---| | int8_table4 | 358.669 | 452.4 | 0.991329 | 0.961667 | 0.966043 | 0.983333 | 0.012 | 0.998131 | 0.844167 | all | int8 per-channel conv weights, int4 per-block (32) table; tables and norms FP16 | Shipped per image budget (download MB = the device archive; `max_download_mb` is the larger): budget 70 `EmbeddingGemma2-b70.aar` 343.178 MB (9 functions), budget 280 `EmbeddingGemma2-b280.aar` 352.911 MB (9 functions). Same outputs as the 14-function package (CPU_ONLY, bitwise): True. ## s5 Swift parity Pixels (0-1 units; PNG and synthetic are gated, JPEG includes the decoder and is recorded only, ADR-0004): | inputs | p99 | max | bit-exact | |---|---|---|---| | png | 0 | 0 | 60/60 | | synthetic | 0 | 0 | 20/20 | | jpeg | 0.00784314 | 0.121569 | 0/340 | | comparison | min cosine | recall@10 (worst budget) | p5 Spearman top-50 | |---|---|---|---| | Swift vs Python Core ML, same inputs (gated) | 1 - 1e-15 | - | - | | Swift vs FP reference on Swift's pixels (gated) | 0.990173 | 0.9575 | 0.96449 | | Swift vs s0 (recorded) | 0.971191 | 0.946667 | 0.952845 | | FP on Swift's pixels vs s0: the decoder (recorded) | 0.971614 | 0.968333 | 0.969143 | Against s0 (recorded only; on JPEGs it includes the decoder, ADR-0008): | vectors | n | min cosine | mean cosine | |---|---|---|---| | text query | 120 | 0.998874 | 0.99951 | | text document | 80 | 0.997913 | 0.999262 | | image 70_jpeg | 170 | 0.978874 | 0.999002 | | image 70_png | 30 | 0.995402 | 0.99898 | | image 280_jpeg | 170 | 0.971191 | 0.998763 | | image 280_png | 30 | 0.999682 | 0.999764 | ## Device benchmarks (s6) | source | device | status | key metrics | |---|---|---|---| | artifacts/s6_device/report.json | iPhone14,3 | passed | max_peak_rss_mb=630.065, max_cold_compile_peak_mb=1517.66, min_devices=1 | | docs/results/s6_iPhone14,3.json | iPhone14,3 | passed | max_peak_rss_mb=630.065, max_cold_compile_peak_mb=1517.66, min_devices=1 | ## Limitations - Text and images only. Text is limited to 128 tokens; images are limited to budgets of 70 or 280 tokens and five fixed aspect buckets each. - Results are close to the original model, not bit-identical (see "Accuracy"). Do not mix vectors from this model with vectors from the PyTorch model or another conversion in the same index. The Swift index stores the model revision for that reason. - Intel Macs have no Neural Engine; they run on the CPU/GPU and are slower. The fastest settings are on the Neural Engine with `.cpuAndNeuralEngine`. - The first load compiles the model on the device, which takes a while. The Swift package caches the compiled model. - Everything in the upstream card's "Limitations" and "Ethical Considerations and Risks" applies. ## Citation Please cite the original model as Google DeepMind's model card asks ([`google/embeddinggemma-2`](https://huggingface.co/google/embeddinggemma-2)). For this conversion: ```bibtex @software{embeddinggemma_coreml, title = {EmbeddingGemma 2 for Core ML}, author = {Delavalom}, year = {2026}, url = {https://huggingface.co/delavalom/embeddinggemma-2-coreml}, note = {Core ML conversion of google/embeddinggemma-2} } ```