Sentence-embedding backends for @ruvector/typesafe: two implementations of
the Embedder trait, one per
target (ADR-002).
cargo build --workspace (no features) compiles no inference engine and
downloads nothing: only the manifest, pooling and error types are present.
- Weights are pinned by a content-hash model manifest
(
{name, file, sha256, dims, license, source_url, added, review_by, pooling, tokenizer_file, tokenizer_sha256, max_tokens}) and verified at load. A mismatch is a typedEmbedError::HashMismatchand nothing is loaded — fail closed (ADR-005). The optionaltokenizer_sha256closes the "pinned model, unpinned tokenizer" gap; it is populated for all shipped models. - No network at runtime. Native reads a local directory; wasm is handed the bytes. Model download is a dev-time script, never the library.
- Every embedding is mean- or CLS-pooled (per the manifest — bge uses CLS, MiniLM uses mean) and L2-normalised, so cosine similarity is a dot product.
- Inputs are truncated by the tokenizer at
max_tokens(256), preserving[CLS]/[SEP]. Longerstateis rejected upstream at 16 KB (ADR-005), so this is a backstop, not the primary limit. Embedder: Send + Sync.ort'sSession::runis&mut self, so the native session lives behind aMutex.
use ruvector_embed_core::{ManifestFile, OrtEmbedder, Embedder};
let mf = ManifestFile::from_json(&std::fs::read_to_string("models/manifest.json")?)?;
let m = mf.get("bge-small-en-v1.5").unwrap();
let emb = OrtEmbedder::from_manifest("models", m)?; // verifies sha256, fails closed
let vecs = emb.embed(&["ticket: laptop won't boot", "invoice paid late"])?; // L2-normalised
assert_eq!(emb.dims(), 384);
assert!(emb.id().starts_with("bge-small-en-v1.5@")); // model@sha256-prefix, for receiptsWASM has no filesystem, so TractEmbedder::from_bytes(model_bytes, tokenizer_bytes, &m)
takes the bytes directly (hashes verified before parse).
Fetch + verify with the zero-dependency script (Node ≥ 20):
node npm/packages/typesafe/scripts/fetch-models.mjs # download + verify
node npm/packages/typesafe/scripts/fetch-models.mjs --check # verify onlyWeights live in models/<name>/ and are git-ignored; the manifest is
committed. Shipped: bge-small-en-v1.5 (MIT, CLS) and all-MiniLM-L6-v2
(Apache-2.0, mean), each FP32 and INT8, 384-d, from the Xenova ONNX exports.
Single-thread CPU, 100 fixture texts from bench/fixtures/tickets-corpus.json,
ms per embed (cache warmed first, so timing is inference not model build). Full
numbers in bench/embedder-spike-2026-09-21.json.
ms per embed (lower is better), one thread, x86_64 (2026-09-21):
ort INT8 is ~2.2–2.7× faster than FP32, matching the ADR-002 expectation. tract
runs rows one at a time, so its "b32" is not a throughput win; the FP32 numbers
track ort closely. Plan caches are warmed before timing, so the numbers are
inference, not model build.
INT8 on tract 0.23: still fails — INT8 is native-only. Both quantized
graphs fail at into_optimized() with
Failed analyse for node "/Unsqueeze" AddDims — the same failure ADR-194 §5
recorded on tract 0.21. The 0.23 bump does not fix it. WASM therefore ships
FP32; INT8 is available only through the native ort backend (ADR-002 §3).
Parity (ADR-002 §7): pass. Min cosine(ort, tract) over 50 FP32 texts is 0.999999 for bge-small and 0.999999 for MiniLM, both ≥ 0.9999.
Run it:
cargo run -p ruvector-embed-core --features native,wasm --example spike --release