rightkit-embed
Product-neutral local text embeddings and a durable SQLite vector store for Right Suite apps.
Embedderruns a local ONNX embedding model.Embedder::loaduses caller-supplied ONNX Runtime and model paths. It never downloads or silently switches models. It runs two models and picks one from the model directory, never from a setting: EmbeddingGemma 2 Q4 (embeddinggemma2-q4, the text-only export; selected when the directory'sconfig.jsonhasmodel_typeembedding_gemma2) and the older EmbeddingGemma 300M Q4 (embeddinggemma-300m-q4, a directory with noconfig.jsonor another top-levelmodel_type; aconfig.jsonthat does not parse fails the load,src/embedder.rs:208). The suite's current model is EmbeddingGemma 2; vectors from the two models are not comparable, andpipeline_id()changes with the model. Both use the Gemma document prompt (title: none | text:) and query prompt (task: search result | query:), L2 normalisation, and Matryoshka prefix truncation (native 768, default 256, at most 2,048 tokens).VectorStorekeeps little-endianf32vectors in SQLite and ranks them by cosine similarity in Rust.
Catalog capability: embeddings. Published to crates.io (see INDEX.md). Licensed under MIT OR Apache-2.0.
When to use it
- Use it for on-device text embeddings with a model the app ships or downloads itself.
- Use
VectorStorewhen vectors must survive restarts and be searched by cosine similarity.
Do not use it to fetch models, and do not use it to pick a model at run time. Model identity and downloads belong to the app.
Cargo features
| Feature | Default | Pulls in | Effect |
|---|---|---|---|
inference |
on | rightkit-onnx (owns the ort pin and loads it dynamically), tokenizers (onig), rightkit-fs (digest), serde_json (Cargo.toml:16) |
Embedder, EmbedderConfig, ExecutionProvider, truncate_and_normalize. |
vector |
on | rusqlite and rightkit-db (both without default features; Cargo.toml:17) |
VectorStore (persistent cosine store) and cosine_similarity. |
--no-default-features --features vector builds the store without ort or tokenizers. Accelerator support comes from rightkit-onnx, which only inference enables; with inference on, it compiles CoreML on Apple targets and DirectML on Windows (rightkit-onnx/Cargo.toml:65-72).
Usage
Vector store, taken from tests/vector_e2e.rs:
use ;
let mut store = open?;
store.put_batch?;
let nearest = store.nearest?; // ranked by cosine score
Embedding, illustrative. EmbedderConfig::new(model_dir, runtime_path) and Embedder::load are the entry points in src/embedder.rs:
use ;
let embedder = load?;
let vectors = embedder.embed_batch?;
Batching and providers
- Batching.
embed_batchandembed_query_batchtokenize all texts, order them by length, and runmax_batchrows per ONNX call (EmbedderConfig::with_max_batch(n), default 16, also capped at 16384 padded tokens per call). Rows are right-padded to the chunk maximum with a zero attention mask.token_type_idsis sent only if the model declares it. Each row is pooled with its mask and normalised like a single run. Output order equals input order. Batched and single-text results agree to cosine at least 0.9999 and max element difference at most 2e-3, because Q4 kernels accumulate differently per shape. The pooling and padding math is unit-tested to 1e-6. - Threads.
EmbedderConfig::with_intra_threads(n)sets session intra-op threads. The default is 1. - Execution provider.
EmbedderConfig::with_provider(ExecutionProvider)acceptsCpu(default),Auto(DirectML on Windows, CoreML on macOS, CPU elsewhere), or an explicitDirectMlorCoreMlthat errors instead of falling back.Autoregisters the accelerator throughrightkit-onnx, runs a probe inference, and falls back to CPU if registration, model load or the probe fails. - Accelerators are admitted only after a vector parity check against CPU, at load. Whenever the session was built on an accelerator (
Auto,DirectMl,CoreMl),Embedder::loadbuilds a CPU session for the same model, embeds three fixed prompts (a short query, a medium document and a long document of several hundred tokens, with the crate's prefixes), each alone and then all together as one batch, through both sessions with the real inference path, and requires every pair to have cosine similarity of at leastACCELERATOR_PARITY_MIN_COSINE(0.995) on the full 768-dimension normalised vector (before Matryoshka truncation), with finite non-zero output. UnderAutoa failed check falls back to the CPU session withfallback_reasonsuch asdirectml -> cpu: vector parity failed: cosine 0.579 at ~20 tokens, floor 0.995. Under an explicitDirectMlorCoreMlit isEmbedError::AcceleratorParity { provider, lowest_cosine, floor, detail }, never a silent fallback and never a success. The probe still runs first underAuto; the parity check is in addition to it and cannot be turned off.Cpudoes none of this (no second session, no extra inference). - Load cost with an accelerator. One extra CPU session build and four ONNX calls on each of the two sessions (three prompts alone, then one batched call of the three; more calls only if
max_batchis below 3). The CPU session is dropped beforeloadreturns when the check passes, so steady-state memory is unchanged; while loading, both sessions are resident. Embedder::provider_used()returns"cpu","directml"or"coreml".Embedder::fallback_reason()says why an accelerator was dropped.Embedder::accelerator_parity()returns the parity result of the accelerator in use (AcceleratorParity { lowest_cosine, at_tokens, pairs }) so an app can show it, orNoneon CPU.- Accelerated runs bucket shapes (length to 64, 128, ... 2048; rows to a power of two) to avoid per-shape recompiles.
ExecutionProvideris defined by this crate.rightkit_onnx::ExecutionProvidervalues still convert.- An accelerator needs an ONNX Runtime build that includes the provider. Pass that runtime path.
- Pipeline id.
Embedder::pipeline_id()returnsrkpipe1-<32 hex>, a stable fingerprint that tells the app when stored vectors need re-embedding. It covers the pipeline schema version, model id and native dimension, SHA-256 ofmodel_q4.onnx,model_q4.onnx_data,tokenizer.jsonandconfig.json, the document and query prompt templates, pooling and normalisation descriptions, truncate dimension, max tokens, and Gemma 2 sentinel scrubbing. Provider, threads and batch size are excluded, and so is the parity check: an admitted accelerator reproduces the CPU vectors to the parity floor, so the id does not change with the provider. The first call hashes the model files; the result is cached. If a model file cannot be read, the id isrkpipe1-unavailable-…instead, which never equals a good id (src/embedder.rs:344-345).
EmbeddingGemma 2 E40 (Windows DirectML) and Core ML
- E40 bundle. A directory with
embed_tokens.f16.npy,model_32/64/128/256/512.onnx,model.onnx_dataandtokenizer.jsonis loaded asembeddinggemma2-e40-dml(EMBEDDINGGEMMA2_E40_MODEL_ID); the directory chooses, never a setting. Export and contract:scripts/embed-export/eg2-directml/README.md; catalog entry and its pending download route:rightkit-models. Each text is tokenized (max 512 tokens), embedded row by row from the fp16 table on the host, and run through the smallest fixed graph that fits (batch 1, no row batching). Output is the in-graph normalised 768-d vector. - Providers.
Cpuruns the graphs on CPU.Autois DirectML on Windows (probe, then parity against the same graphs on CPU, five prompts covering all five graphs) and CPU elsewhere.DirectMlhas no fallback.CoreMlis refused. Windows default: E40 on DirectML. After admission every graph is built and run once at load (pre-warm,EmbedderConfig::with_prewarm(true), off by default for E40 because it added 6-18 s to a 2 s cached load; time inLoadTimings::prewarm) and stays resident, so no query pays a graph build (about 2 s each on DirectML in the export measurements); resident, all five cost about 2.1 GB of process memory and 4.6 GB on the card. By default the graphs are released after the check and each is built on first use. The parity check costs a CPU reference build, so the opt-inwith_parity_cache_dir(dir)stores the verdict (keyed on bundle file sizes and mtimes, the ONNX Runtime library and the provider; trusted for 7 days): a load with a stored pass takes about 2 s warm against 42-51 s cold, with no CPU session built. Nothing is written without the directory. - Fallback.
EmbedderConfig::with_fallback_model_dir(q4_dir)makesAutoserve that Q4 bundle on CPU when DirectML is not admitted; without itAutoserves E40 on CPU. Either wayfallback_reason()says why, andmodel_id()/pipeline_id()name what is serving, so consumers reindex when the model changes. The E40 CPU path is unmeasured for speed. - Core ML bundle (macOS 15+). A directory with
EmbeddingGemma2Text.mlpackage,embeddings.bf16,tokenizer.jsonandconfig.json(FluidInference/embeddinggemma-2-coreml, store idembeddinggemma2-coreml,EMBEDDINGGEMMA2_COREML_MODEL_ID) is loaded by the Core ML backend (src/coreml.rs), chosen from the directory. ONNX Runtime's CoreML provider takes an ONNX graph and cannot load an.mlpackage, so the backend uses the Core ML framework directly throughobjc2-core-ml(macOS-only dependency; no ONNX Runtime is initialised,runtime_pathneed not exist). The package is a multi-function ML Program;embed_32,embed_64,embed_128,embed_256andembed_512are used (batch 1, smallest function that holds the text, first 512 tokens kept). Inputs:inputs_embedsfloat16[1, L, 512]=embeddings.bf16row (bf16 widened by a 16-bit shift) timesembed_scale(sqrt(512), fromconfig.json) rounded to float16, pad rows included;attention_maskfloat16[1, L]. Outputembeddingfloat32[1, 768], pooled, projected and normalised in the graph. - Core ML compile and cache. The package is compiled once with
MLModel.compileModelAtURLinto<model_dir>/.coreml-compiled/(orwith_coreml_cache_dir), keyed on the package file hashes and the macOS version, reused when present. Each function loads viaMLModelConfiguration.functionNameon its own compute units; the first load compiles the Neural Engine program and builds the GPU shaders (minutes for the Neural Engine, once, cached by Core ML; up to 51 s cold per length on the GPU), so the firstAutoload is slow. The compiled.mlmodelcdoes not depend on the units, so changing them never recompiles. macOS older than 15 is refused. - Core ML compute units and pre-warm (macOS default). Core ML fp16 with per-length units,
CoreMlUnits::per_length_default():cpu_and_ne(CPU and Neural Engine) for the 32, 64 and 128 token functions (p50 about 2.3-3.0 ms) andcpu_and_gpufor 256 (about 5.1 ms) and 512 (about 9.8-18.7 ms), from the Mac matrix of RightKit issue #81. OneMLModelper function is loaded once and kept resident.with_coreml_units(...)overrides it: passCoreMlComputeUnit::All,CpuAndNe,CpuAndGpuorCpuOnlyfor every length, or aCoreMlUnitsmap ("256=cpu_only".parse()); read the map in use withEmbedder::coreml_units(). After admissionloadruns one dummy predict per function (pre-warm,with_prewarm, default on), so the first query per length pays no compilation or shader build; the total and per-length times are inLoadTimings::prewarmandprewarm_per_length.with_prewarm(false)leaves the first query of each length to pay it. - Core ML providers.
Autoruns the per-length compute units above (provider_used()"coreml") after a probe and the parity check against the same package oncpuOnly;CoreMlis the same without fallback;Cpuis the package oncpuOnly("cpu");DirectMlis refused.with_fallback_model_dir(q4_dir)applies as for E40, and also covers macOS older than 15 or a compile failure underAuto.pipeline_id()hashes the package files, token table, tokenizer andconfig.json.
Platform support
- CPU: all platforms.
- Core ML bundle (
EmbeddingGemma2Text.mlpackage): macOS 15+ only, through the Core ML framework directly (no ONNX Runtime). - Accelerators (
Auto,DirectMl,CoreMl): DirectML on Windows, CoreML on macOS, throughrightkit-onnx. Needs a matching ONNX Runtime build, and the accelerator must pass the load-time vector parity check against CPU.
Known limits
- DirectML currently fails the parity check for EmbeddingGemma 2 Q4 on the hardware measured (Windows laptop, RTX 4070 and Intel Iris Xe, ONNX Runtime DirectML 1.24.4): cosine to the CPU vector was 0.58 at about 20 tokens, 0.59 at 40, 0.66 at 100 and 0.71-0.72 at 400, against about 0.999 between two correct runs. The output was finite, non-zero and faster than CPU, so only the parity check catches it. As a result
Autoon Windows resolves to CPU with a recorded reason, and an explicitDirectMlreturnsEmbedError::AcceleratorParity. Other hardware or runtime versions are unmeasured and are admitted only if they pass. - CoreML through ONNX Runtime is unmeasured. It must pass the same check; nothing is known yet about the result for EmbeddingGemma 2 Q4. The Core ML backend for the
.mlpackagehas not been built or run yet; the card claims cosine >= 0.9984 to PyTorch fp32 and the parity floor againstcpuOnlyis 0.995. - The parity prompts are subject to
max_tokens: a config that truncates below the long prompt's length checks the shorter lengths only.
Tests
tests/vector_e2e.rs (vector store). tests/real_model_e2e.rs, tests/batch_real_model_e2e.rs, tests/gemma2_real_model_e2e.rs, tests/accelerator_parity_e2e.rs (real model files, accelerator parity; each skips without its inputs).
Version and changes
Published version: see INDEX.md. Release notes: CHANGELOG.md.