darylcecile/wasmbed

Offline sentence embeddings in the browser from a single 314 KB freestanding WASM module — no ONNX Runtime, no transformers.js, no network after first load.

0

stars

3

commits

C

primary language

Aug 7, 2026

updated

README

wasmbed

Offline sentence embeddings in the browser, from a single hand-written freestanding .wasm binary — no ONNX Runtime Web, no transformers.js, no WebGPU, and no network after first load.

The entire language and inference stack — Unicode normalizer, WordPiece tokenizer, mean pooling, L2 norm, int8 dequantisation — is ~700 lines of C compiled to a 314 KB wasm32-freestanding module with zero imports. Model weights ship as a separate, cacheable blob.

const emb = await createEmbedder({ wasmUrl, modelDir, fetchBytes });
const v = emb.embed("hello world");   // Float32Array(256), L2-normalised
Speed~94,000 sentences/sec, 23 ms cold load (potion-base-8M, M2)
Size314 KB .wasm (52 KB brotli) + 1.9–31 MB weights
Correctness100% identical token ids vs the Python reference over 262,702 adversarial strings; cosine(fp32, reference) = 1.000000
OfflineVerified with the HTTP server killed, not just DevTools emulation
LicenceMIT (as are all shipped models)

Scope. This is a faithful, fast runtime for Model2Vec static embeddings. Static embeddings are context-free, so quality is below a real transformer — see Honest tradeoffs. What is proven here is exact parity with the reference implementation, not state-of-the-art retrieval.


Contents


Install and build

There is no npm package: weights are multi-megabyte and are never committed, so you generate the artifacts once from source.

Prerequisiteszig (compiler), binaryen (wasm-opt), wabt (wasm2wat, optional), uv (Python), and Node ≥ 18.

brew install zig binaryen wabt      # macOS; see each project for other platforms
git clone https://github.com/darylcecile/wasmbed
cd wasmbed

# 1. Export the model blobs -> models/<slug>/  (gitignored, ~40 MB for all three)
uv venv --python 3.12 .venv
VIRTUAL_ENV=.venv uv pip install "model2vec>=0.7" numpy
.venv/bin/python export/export_model.py                    # all three models
# .venv/bin/python export/export_model.py potion-base-8M   # or just one

# 2. Build the wasm  (needs only zig + binaryen)
./build.sh                                                 # -> build/model2vec.wasm

# 3. Run the demo
python3 -m http.server 8731
# open http://localhost:8731/web/index.html

Step 2 needs only zig — the generated src/unicode_tables.h is committed, so you do not need Python to build the binary. Regenerate it with .venv/bin/python export/gen_unicode.py only if HuggingFace tokenizers ever changes its Unicode behaviour.

After export, each models/<slug>/ contains:

manifest.json      dim, vocab_size, normalize, unk_token_id, max_length, quant info
vocab.bin          [u32 count][u32 offsets[count+1]][utf8 token bytes]
weights.i8.bin     [f32 scale[vocab]][i8 q[vocab*dim]]     <- ship this one
weights.f32.bin    [f32 w[vocab*dim]]                      <- reference/debug

Usage

web/embed.js is a dependency-free ES module that works in both Node and the browser. The only thing that differs between environments is how you read bytes, which you supply as fetchBytes.

Node

import { readFile } from "node:fs/promises";
import { createEmbedder, cosine } from "./web/embed.js";

const emb = await createEmbedder({
  wasmUrl:    "build/model2vec.wasm",
  modelDir:   "models/potion-base-8M",
  quant:      "i8",                                  // "i8" (default) | "f32"
  fetchBytes: async (p) => new Uint8Array(await readFile(p)),
});

const a = emb.embed("the cat sat on the mat");
const b = emb.embed("a feline rested upon the rug");

console.log(emb.dim);          // 256
console.log(cosine(a, b));     // 0.2583

That 0.2583 is worth dwelling on: those two sentences are paraphrases but share no tokens, and a static model has no context with which to bridge catfeline. It is a compact demonstration of the tradeoff — wasmbed is extremely fast and small, not semantically clever.

Browser

import { createEmbedder, cosine } from "./embed.js";

const emb = await createEmbedder({
  wasmUrl:    "../build/model2vec.wasm",
  modelDir:   "../models/potion-base-8M",
  fetchBytes: async (u) => new Uint8Array(await (await fetch(u)).arrayBuffer()),
});

const v = emb.embed("hello world");   // Float32Array(256)

Serve over HTTP (not file://) so fetch and WebAssembly.instantiate work. No bundler, no build step, no framework — it is a plain ES module.

Vectors are already L2-normalised (manifest.normalize === true), so cosine similarity is just a dot product. In hot loops, skip cosine() and dot directly:

let dot = 0;
for (let i = 0; i < q.length; i++) dot += q[i] * d[i];   // == cosine, vectors are unit-norm

Making it work offline

Embedding is pure computation, so the only thing standing between you and offline operation is caching the files. web/sw.js is a ~40-line service worker that precaches the small app shell on install, then caches everything else cache-first on first fetch — so the multi-MB model blobs are stored the first time they are requested, without blocking installation:

const CACHE = "wasmbed-v1";
const SHELL = ["./", "./index.html", "./embed.js", "./sw.js",
               "./corpus.json", "../build/model2vec.wasm"];

// install: precache SHELL. fetch: same-origin cache-first, caching each response,
// which is how models/<slug>/{manifest.json,vocab.bin,weights.i8.bin} get stored.

That ordering matters: precaching a 7.3 MB blob would stall the service-worker install, whereas cache-on-fetch keeps first paint fast and makes model switching offline-safe after each model has been used once.

Once cached, the page runs with the server switched off — verified by killing the HTTP server, not by trusting DevTools emulation. If you want the weights available offline before first use, fetch them eagerly on idle, or precache them explicitly and show progress; it is a one-off cost that never repeats.

The complete pattern, and essentially all web/index.html does:

// 1. Embed the corpus once (80 docs in ~1 ms).
const docs = await (await fetch("corpus.json")).json();
const vecs = docs.map((d) => emb.embed(d));

// 2. Embed the query and rank by dot product (== cosine; vectors are unit-norm).
function search(query, k = 8) {
  const q = emb.embed(query);
  return vecs
    .map((v, i) => {
      let s = 0;
      for (let j = 0; j < q.length; j++) s += q[j] * v[j];
      return { doc: docs[i], score: s };
    })
    .sort((a, b) => b.score - a.score)
    .slice(0, k);
}

At ~0.011 ms/sentence you can re-embed thousands of documents per frame, so for small-to-medium corpora you do not need a vector index at all. Persist vecs to IndexedDB if your corpus is large or static.

Raw WASM ABI (non-JS hosts)

The module has zero imports (wasm2wat build/model2vec.wasm | grep import → none), so it runs in any WebAssembly host — Python wasmtime, Rust wasmer, Go wazero, a Cloudflare Worker — with no shims.

exportsignaturenotes
memorylinear memory; re-view after any wasm_alloc, it may grow and detach
wasm_alloc(bytes: i32) -> ptr: i32bump allocator, never frees
load_model(dim, vocab_size, max_length, median_token_length, unk_token_id, normalize, is_int8, vocabPtr, weightsPtr, scalesPtr) -> voidcall once, after copying blobs into memory
embed(inPtr, inLen, outPtr) -> voidUTF-8 bytes in, f32[dim] out
tokenize_ids(inPtr, inLen, idsPtr, maxOut) -> count: i32token ids only, for debugging/parity
get_dim() -> i32embedding dimension
__heap_baseglobalstart of the bump heap

Load sequence: wasm_alloc + copy vocab.binwasm_alloc + copy the weights blob → load_model(...). For int8 the blob is [f32 scale[vocab]][i8 q[vocab*dim]], so scalesPtr = base and weightsPtr = base + vocab_size * 4. web/embed.js (92 lines) is the reference implementation.


API reference

createEmbedder(options) -> Promise<Embedder>

optiontypedefaultdescription
wasmUrlstringpath/URL of model2vec.wasm, passed to fetchBytes
modelDirstringdirectory holding manifest.json, vocab.bin, weights
quant"i8" | "f32""i8"int8 is ~4× smaller; fp32 is bit-exact with the reference
fetchBytes(url) => Promise<Uint8Array>how to read bytes in your environment

Returns:

membertypedescription
embed(text)(string) => Float32Arraythe embedding, length dim, L2-normalised
tokenize(text)(string) => number[]WordPiece token ids ([UNK] already dropped)
dimnumberembedding dimension
manifestobjectparsed manifest.json
quantstringthe quantisation actually loaded

Empty or unrepresentable input yields a zero vector, matching the reference (model2vec returns zeros when the id list is empty).

cosine(a, b) -> number

Cosine similarity. Redundant when both vectors come from embed() (already unit-norm) — use a plain dot product in hot loops.


Choosing a model

One .wasm drives all three; each model brings its own vocab and manifest.

modeldimint8 blobms/sentencebest for
potion-base-2M641.9 MB0.0087tightest footprint, mobile
potion-base-8M (default)2567.3 MB0.0106balanced — start here
potion-retrieval-32M51231 MB0.0157best retrieval quality

Use quant: "f32" only to debug or reproduce the reference exactly; it is 4× the bytes for a cosine difference of ~0.00004.


How it works

Model2Vec (MIT) distils a sentence-transformer into a static embedding model: a token → vector lookup table. Inference is

ids = tokenize(text)             # BERT normalizer + WordPiece, drop [UNK]
vec = mean(embedding[ids])       # plain arithmetic mean of the looked-up rows
vec = vec / (||vec|| + 1e-32)    # L2 normalise

There is no attention, no feed-forward, no matrix multiplication. That is what makes a hand-written freestanding WASM implementation practical — the hard part is not the maths, it is reproducing the tokenizer exactly.

src/embed.c, mirroring HuggingFace bert.rs order:

  1. UTF-8 decode → codepoints.
  2. Truncate to max_length * median_token_length codepoints.
  3. clean_text — drop control chars, collapse whitespace to 0x20.
  4. handle_chinese_chars — wrap each CJK codepoint as space, cp, space.
  5. strip_accents (NFD + drop nonspacing marks) + lowercase, via a generated cp → 0..N cps transform table.
  6. BertPreTokenizer — split on 0x20, isolate each punctuation codepoint.
  7. WordPiece — greedy longest-match-first, ## continuation; a word > 100 codepoints or containing an unmatchable piece → [UNK].
  8. Drop [UNK] ids; truncate to 512.
  9. Mean the rows (double accumulator); L2 normalise.

Design decisions

Toolchain — zig cc --target=wasm32-freestanding. One brew install, no Rust toolchain, no emscripten SDK. Ships compiler-rt so memcpy/memset exist without libc; __builtin_sqrtf lowers to the f32.sqrt intrinsic so no libm is needed. freestanding (not wasi) gives zero imports — nothing to shim, nothing to trust on the JS side. wasm-opt -Oz then trims 327,635 → 321,916 bytes.

Weights as a separate blob. Keeps the .wasm tiny and logic-only, lets multi-MB weights be cached/range-requested/streamed independently, and lets one binary serve all three models.

int8 with a per-row scale. Embedding rows vary a lot in norm, so a single per-matrix scale wastes precision. Measured — per-row wins on every model (8M: 0.999957 vs 0.999651 cosine-vs-fp32). export_model.py prints both.

Unicode fidelity — probe the real tokenizer, don't reimplement it. The riskiest part is matching HF tokenizers exactly (Rust char::to_lowercase including 1→many mappings, NFD + combining-mark stripping, CJK ranges, punctuation classing). Reimplementing from Python's unicodedata risks Unicode version drift against the Rust crate, so gen_unicode.py probes the real HF primitives per codepoint across the entire U+0000..U+10FFFF range and emits sorted range tables. The committed src/unicode_tables.h is a generated artifact, not hand-written.


Correctness (proven, not assumed)

Two independent checks, both against the Python model2vec.StaticModel reference — the real HuggingFace tokenizer, not a second guess at it.

1. Curated parity corpus

316 varied strings (ASCII, accents, CJK/JP/KR, emoji, punctuation, numbers, >100-char words, empty, whitespace-only, truncation boundaries):

modeltoken-id matchcosine(fp32, py) mincosine(int8, py) minmedian (int8)
potion-base-2M316/316 (100%)1.0000000.9999640.999985
potion-base-8M316/316 (100%)1.0000000.9999340.999972
potion-retrieval-32M316/316 (100%)1.0000000.9998950.999968

The fp32 path is exactly the reference. int8 error is small but non-zero, and is reported separately so it stays visible rather than hidden in an average.

.venv/bin/python bench/make_corpus.py
.venv/bin/python bench/parity_ref.py potion-base-8M
node bench/parity.mjs potion-base-8M

2. Adversarial codepoint sweep

A curated corpus only tests what its author already thought of. The sweep is the complementary check: it mechanically enumerates every interesting codepoint — anything with a lowercase mapping (including 1→many), every Mn/Mc/Me combining mark, every P* punctuation, all eight CJK ranges, plus a strided probe of the astral planes — feeds each through both implementations alone and in two surrounding contexts, adds 4,000 seeded fuzz strings, and requires byte-identical token ids. Identical ids ⇒ identical fp32 embedding, since the vector is just the mean of the looked-up rows.

Non-circularity matters here: candidate selection in bench/sweep_ref.py uses only the Python stdlib, and the oracle is model2vec itself. It is independent of export/gen_unicode.py, so the sweep tests C against the reference, not C against its own table generator.

categorystrings2M8M32M
cased (lowercase mapping, incl. 1→many)4,299PASSPASSPASS
mark (Mn/Mc/Me combining)6,630PASSPASSPASS
punct (P* isolation)2,526PASSPASSPASS
cjk (8 is_chinese_char ranges)244,560PASSPASSPASS
astral (above the old table ceiling)687PASSPASSPASS
fuzz (seeded random, seed 1234567)4,000PASSPASSPASS
total262,702100%100%100%

A real bug this caught. The first run scored 99.9876% — astral failed 32/687. HF's clean_text drops any char whose Unicode category is in {Cc, Cf, Co, Cs} (Rust is_control) but not Cn. gen_unicode.py originally probed only to U+2FFFF, so astral private-use characters (planes 15–16, category Co, e.g. U+F0DBD) were not dropped: Python tokenized "a\u{F0DBD}b"["ab"], while the wasm kept the character, overflowed max_input_chars_per_word, and returned [] — a zero vector, silently. Fix: probe the full U+0000..U+10FFFF range (mechanical and cheap; range tables compress the astral drop-region to a handful of entries), regenerate, rebuild. Re-run: 100%.

The curated corpus would never have contained a plane-15 private-use codepoint. That is the entire reason the sweep exists.

Documented coverage limit: the tables transcribe HF behaviour across planes 0–16. The sweep proves this over its enumerated and fuzzed sample; it is not an exhaustive 1.1M codepoint diff. The cased/mark/punct/cjk enumerations are exhaustive within their classes below U+30000; astral is a strided sample above it.

.venv/bin/python bench/sweep_ref.py potion-base-8M   # writes bench/out/ (gitignored)
node bench/sweep.mjs potion-base-8M

Benchmarks

Apple M2 (8-core, 16 GB), Chrome via DevTools. Throughput = the 316-string corpus tiled to 1000 sentences, 50 warmup. Baseline: @huggingface/transformers v4.2.0 running Xenova/all-MiniLM-L6-v2 (feature-extraction, mean pool + normalize, default single-thread WASM backend). Harness: web/bench.html.

modeldimcold loadms / sentencesentences / s
wasmbed potion-base-2M6432 ms0.0087114,943
wasmbed potion-base-8M (default)25623 ms0.010694,340
wasmbed potion-retrieval-32M512158 ms0.015763,694
transformers.js all-MiniLM-L6-v23845,307 ms18.33355

potion-base-8M is ~1,730× faster per sentence and loads ~235× faster.

Under Node 22 (same machine, potion-base-8M, 50k iterations after 2k warmup) the same build measures 0.0052 ms/sentence (~194,000/s) with a 5.9 ms cold load — roughly 2× the in-browser figure. The browser numbers above are the ones quoted throughout this README, since browsers are the target.

Stated honestly: the cold-load comparison is asymmetric — the wasmbed blobs load locally or from the service-worker cache, while the transformers.js figure includes downloading the ONNX model from the HF hub. Even excluding download, transformers.js is far heavier because it runs a real transformer. It is also not an apples-to-apples quality comparison; see tradeoffs.

Artifact sizes

artifactrawgzip -9brotli -q11
model2vec.wasm (shared)321,916 (314 KB)103,47152,872 (52 KB)
2M weights.i8.bin1.9 MB1.81 MB1.80 MB
8M weights.i8.bin7.3 MB6.48 MB6.37 MB
32M weights.i8.bin31 MB27.2 MB26.7 MB
vocab.bin (2M/8M)308 KB139 KB129 KB

int8 weight blobs are near-random bytes and barely compress (~13% with brotli) — serve them raw or lightly gzipped; brotli's CPU cost is not worth it. Only the vocab compresses well (~2.3×).


Offline proof

DevTools "Offline" emulation was found not to reliably block localhost, so it is not relied on. The stronger method: kill the HTTP server, then reload the page.

  • A fresh page-context fetch(".../manifest.json?probe=<ts>")net::ERR_FAILED (the server is genuinely unreachable — no false positive).
  • The page still boots, embeds all 80 corpus documents, and returns the correct top hit.
  • All 7 app resources return HTTP 200 from the wasmbed-v1 service-worker cache.
onlineoffline (server killed)
demo onlinedemo offline

Right: the same page after kill-ing the server — a fresh query "what powers the sun and stars?" still embeds client-side in 0.8 ms and ranks correctly, entirely from cache.


Honest tradeoffs

  • Embedding quality is the real cost. Model2Vec is a static distillation: one vector per token, mean-pooled, no context. A contextual transformer like MiniLM produces better embeddings on hard semantic tasks. This project proves parity with the model2vec reference (cosine ≈ 1.0); it does not independently re-measure downstream retrieval quality (e.g. MTEB). You can see the ceiling in the offline screenshot above: for "what powers the sun and stars?" the top hit is correct, but the second is an unrelated sentence about the Industrial Revolution. If quality matters, use potion-retrieval-32M — or a real transformer.
  • potion-retrieval-32M is not small. Its 31 MB int8 blob is larger than MiniLM's quantized ONNX (~23 MB). The win there is speed and simplicity, not size.
  • src/unicode_tables.h is 726 KB of generated source in the repo, so that building needs only zig. It contributes ~230 KB to the .wasm.
  • Not published to npm, because the weights cannot reasonably be packaged. Export them yourself; it takes about a minute.

Layout

src/
  embed.c            # the whole inference core (freestanding C)
  unicode_tables.h   # GENERATED by gen_unicode.py — Unicode class + case-fold tables
build.sh             # zig cc --target=wasm32-freestanding  ->  wasm-opt -Oz
export/
  export_model.py    # model -> int8/fp32 blobs + vocab blob + manifest.json
  gen_unicode.py     # probe HF tokenizers per-codepoint -> src/unicode_tables.h
web/
  embed.js           # ESM glue (Node + browser)
  index.html         # offline semantic-search demo
  sw.js              # service worker: cache-first offline shell
  corpus.json        # 80-doc demo corpus
  bench.html         # in-browser benchmark harness
bench/
  make_corpus.py     # generates the 316-string parity corpus
  parity_ref.py      # Python reference (ids + vectors)
  parity.mjs         # comparator: id match rate + cosine distribution
  sweep_ref.py       # adversarial codepoint enumeration + fuzz (Python oracle)
  sweep.mjs          # sweep comparator: byte-identical token ids per category
  out/screens/       # committed demo screenshots
models/              # (gitignored) generated blobs — never committed
build/               # (gitignored) model2vec.wasm

Licences & citations

wasmbed itself is MIT (see LICENSE). Everything it builds on is MIT too:

  • model2vec — MIT. https://github.com/MinishLab/model2vec
  • minishlab/potion-base-2M / potion-base-8M / potion-retrieval-32M — MIT (verified via the HF model-card front matter and models API: license: mit).
  • Baseline only, not shipped: @huggingface/transformers v4.2.0 with Xenova/all-MiniLM-L6-v2 (Apache-2.0).

Algorithm and tokenizer behaviour cross-checked against model2vec==0.8.2 (model2vec/model.py) and the HuggingFace tokenizers Rust sources (normalizers/bert.rs, pre_tokenizers/bert.rs, models/wordpiece/mod.rs).

Model weights are not committed. Generate them with export/export_model.py.

Contributors

darylcecile

3 commits

darylcecile/wasmbed

Offline sentence embeddings in the browser from a single 314 KB freestanding WASM module — no ONNX Runtime, no transformers.js, no network after first load.

0

stars

3

commits

C

primary language

Aug 7, 2026

updated

README

wasmbed

Offline sentence embeddings in the browser, from a single hand-written freestanding .wasm binary — no ONNX Runtime Web, no transformers.js, no WebGPU, and no network after first load.

The entire language and inference stack — Unicode normalizer, WordPiece tokenizer, mean pooling, L2 norm, int8 dequantisation — is ~700 lines of C compiled to a 314 KB wasm32-freestanding module with zero imports. Model weights ship as a separate, cacheable blob.

const emb = await createEmbedder({ wasmUrl, modelDir, fetchBytes });
const v = emb.embed("hello world");   // Float32Array(256), L2-normalised
Speed~94,000 sentences/sec, 23 ms cold load (potion-base-8M, M2)
Size314 KB .wasm (52 KB brotli) + 1.9–31 MB weights
Correctness100% identical token ids vs the Python reference over 262,702 adversarial strings; cosine(fp32, reference) = 1.000000
OfflineVerified with the HTTP server killed, not just DevTools emulation
LicenceMIT (as are all shipped models)

Scope. This is a faithful, fast runtime for Model2Vec static embeddings. Static embeddings are context-free, so quality is below a real transformer — see Honest tradeoffs. What is proven here is exact parity with the reference implementation, not state-of-the-art retrieval.


Contents


Install and build

There is no npm package: weights are multi-megabyte and are never committed, so you generate the artifacts once from source.

Prerequisiteszig (compiler), binaryen (wasm-opt), wabt (wasm2wat, optional), uv (Python), and Node ≥ 18.

brew install zig binaryen wabt      # macOS; see each project for other platforms
git clone https://github.com/darylcecile/wasmbed
cd wasmbed

# 1. Export the model blobs -> models/<slug>/  (gitignored, ~40 MB for all three)
uv venv --python 3.12 .venv
VIRTUAL_ENV=.venv uv pip install "model2vec>=0.7" numpy
.venv/bin/python export/export_model.py                    # all three models
# .venv/bin/python export/export_model.py potion-base-8M   # or just one

# 2. Build the wasm  (needs only zig + binaryen)
./build.sh                                                 # -> build/model2vec.wasm

# 3. Run the demo
python3 -m http.server 8731
# open http://localhost:8731/web/index.html

Step 2 needs only zig — the generated src/unicode_tables.h is committed, so you do not need Python to build the binary. Regenerate it with .venv/bin/python export/gen_unicode.py only if HuggingFace tokenizers ever changes its Unicode behaviour.

After export, each models/<slug>/ contains:

manifest.json      dim, vocab_size, normalize, unk_token_id, max_length, quant info
vocab.bin          [u32 count][u32 offsets[count+1]][utf8 token bytes]
weights.i8.bin     [f32 scale[vocab]][i8 q[vocab*dim]]     <- ship this one
weights.f32.bin    [f32 w[vocab*dim]]                      <- reference/debug

Usage

web/embed.js is a dependency-free ES module that works in both Node and the browser. The only thing that differs between environments is how you read bytes, which you supply as fetchBytes.

Node

import { readFile } from "node:fs/promises";
import { createEmbedder, cosine } from "./web/embed.js";

const emb = await createEmbedder({
  wasmUrl:    "build/model2vec.wasm",
  modelDir:   "models/potion-base-8M",
  quant:      "i8",                                  // "i8" (default) | "f32"
  fetchBytes: async (p) => new Uint8Array(await readFile(p)),
});

const a = emb.embed("the cat sat on the mat");
const b = emb.embed("a feline rested upon the rug");

console.log(emb.dim);          // 256
console.log(cosine(a, b));     // 0.2583

That 0.2583 is worth dwelling on: those two sentences are paraphrases but share no tokens, and a static model has no context with which to bridge catfeline. It is a compact demonstration of the tradeoff — wasmbed is extremely fast and small, not semantically clever.

Browser

import { createEmbedder, cosine } from "./embed.js";

const emb = await createEmbedder({
  wasmUrl:    "../build/model2vec.wasm",
  modelDir:   "../models/potion-base-8M",
  fetchBytes: async (u) => new Uint8Array(await (await fetch(u)).arrayBuffer()),
});

const v = emb.embed("hello world");   // Float32Array(256)

Serve over HTTP (not file://) so fetch and WebAssembly.instantiate work. No bundler, no build step, no framework — it is a plain ES module.

Vectors are already L2-normalised (manifest.normalize === true), so cosine similarity is just a dot product. In hot loops, skip cosine() and dot directly:

let dot = 0;
for (let i = 0; i < q.length; i++) dot += q[i] * d[i];   // == cosine, vectors are unit-norm

Making it work offline

Embedding is pure computation, so the only thing standing between you and offline operation is caching the files. web/sw.js is a ~40-line service worker that precaches the small app shell on install, then caches everything else cache-first on first fetch — so the multi-MB model blobs are stored the first time they are requested, without blocking installation:

const CACHE = "wasmbed-v1";
const SHELL = ["./", "./index.html", "./embed.js", "./sw.js",
               "./corpus.json", "../build/model2vec.wasm"];

// install: precache SHELL. fetch: same-origin cache-first, caching each response,
// which is how models/<slug>/{manifest.json,vocab.bin,weights.i8.bin} get stored.

That ordering matters: precaching a 7.3 MB blob would stall the service-worker install, whereas cache-on-fetch keeps first paint fast and makes model switching offline-safe after each model has been used once.

Once cached, the page runs with the server switched off — verified by killing the HTTP server, not by trusting DevTools emulation. If you want the weights available offline before first use, fetch them eagerly on idle, or precache them explicitly and show progress; it is a one-off cost that never repeats.

The complete pattern, and essentially all web/index.html does:

// 1. Embed the corpus once (80 docs in ~1 ms).
const docs = await (await fetch("corpus.json")).json();
const vecs = docs.map((d) => emb.embed(d));

// 2. Embed the query and rank by dot product (== cosine; vectors are unit-norm).
function search(query, k = 8) {
  const q = emb.embed(query);
  return vecs
    .map((v, i) => {
      let s = 0;
      for (let j = 0; j < q.length; j++) s += q[j] * v[j];
      return { doc: docs[i], score: s };
    })
    .sort((a, b) => b.score - a.score)
    .slice(0, k);
}

At ~0.011 ms/sentence you can re-embed thousands of documents per frame, so for small-to-medium corpora you do not need a vector index at all. Persist vecs to IndexedDB if your corpus is large or static.

Raw WASM ABI (non-JS hosts)

The module has zero imports (wasm2wat build/model2vec.wasm | grep import → none), so it runs in any WebAssembly host — Python wasmtime, Rust wasmer, Go wazero, a Cloudflare Worker — with no shims.

exportsignaturenotes
memorylinear memory; re-view after any wasm_alloc, it may grow and detach
wasm_alloc(bytes: i32) -> ptr: i32bump allocator, never frees
load_model(dim, vocab_size, max_length, median_token_length, unk_token_id, normalize, is_int8, vocabPtr, weightsPtr, scalesPtr) -> voidcall once, after copying blobs into memory
embed(inPtr, inLen, outPtr) -> voidUTF-8 bytes in, f32[dim] out
tokenize_ids(inPtr, inLen, idsPtr, maxOut) -> count: i32token ids only, for debugging/parity
get_dim() -> i32embedding dimension
__heap_baseglobalstart of the bump heap

Load sequence: wasm_alloc + copy vocab.binwasm_alloc + copy the weights blob → load_model(...). For int8 the blob is [f32 scale[vocab]][i8 q[vocab*dim]], so scalesPtr = base and weightsPtr = base + vocab_size * 4. web/embed.js (92 lines) is the reference implementation.


API reference

createEmbedder(options) -> Promise<Embedder>

optiontypedefaultdescription
wasmUrlstringpath/URL of model2vec.wasm, passed to fetchBytes
modelDirstringdirectory holding manifest.json, vocab.bin, weights
quant"i8" | "f32""i8"int8 is ~4× smaller; fp32 is bit-exact with the reference
fetchBytes(url) => Promise<Uint8Array>how to read bytes in your environment

Returns:

membertypedescription
embed(text)(string) => Float32Arraythe embedding, length dim, L2-normalised
tokenize(text)(string) => number[]WordPiece token ids ([UNK] already dropped)
dimnumberembedding dimension
manifestobjectparsed manifest.json
quantstringthe quantisation actually loaded

Empty or unrepresentable input yields a zero vector, matching the reference (model2vec returns zeros when the id list is empty).

cosine(a, b) -> number

Cosine similarity. Redundant when both vectors come from embed() (already unit-norm) — use a plain dot product in hot loops.


Choosing a model

One .wasm drives all three; each model brings its own vocab and manifest.

modeldimint8 blobms/sentencebest for
potion-base-2M641.9 MB0.0087tightest footprint, mobile
potion-base-8M (default)2567.3 MB0.0106balanced — start here
potion-retrieval-32M51231 MB0.0157best retrieval quality

Use quant: "f32" only to debug or reproduce the reference exactly; it is 4× the bytes for a cosine difference of ~0.00004.


How it works

Model2Vec (MIT) distils a sentence-transformer into a static embedding model: a token → vector lookup table. Inference is

ids = tokenize(text)             # BERT normalizer + WordPiece, drop [UNK]
vec = mean(embedding[ids])       # plain arithmetic mean of the looked-up rows
vec = vec / (||vec|| + 1e-32)    # L2 normalise

There is no attention, no feed-forward, no matrix multiplication. That is what makes a hand-written freestanding WASM implementation practical — the hard part is not the maths, it is reproducing the tokenizer exactly.

src/embed.c, mirroring HuggingFace bert.rs order:

  1. UTF-8 decode → codepoints.
  2. Truncate to max_length * median_token_length codepoints.
  3. clean_text — drop control chars, collapse whitespace to 0x20.
  4. handle_chinese_chars — wrap each CJK codepoint as space, cp, space.
  5. strip_accents (NFD + drop nonspacing marks) + lowercase, via a generated cp → 0..N cps transform table.
  6. BertPreTokenizer — split on 0x20, isolate each punctuation codepoint.
  7. WordPiece — greedy longest-match-first, ## continuation; a word > 100 codepoints or containing an unmatchable piece → [UNK].
  8. Drop [UNK] ids; truncate to 512.
  9. Mean the rows (double accumulator); L2 normalise.

Design decisions

Toolchain — zig cc --target=wasm32-freestanding. One brew install, no Rust toolchain, no emscripten SDK. Ships compiler-rt so memcpy/memset exist without libc; __builtin_sqrtf lowers to the f32.sqrt intrinsic so no libm is needed. freestanding (not wasi) gives zero imports — nothing to shim, nothing to trust on the JS side. wasm-opt -Oz then trims 327,635 → 321,916 bytes.

Weights as a separate blob. Keeps the .wasm tiny and logic-only, lets multi-MB weights be cached/range-requested/streamed independently, and lets one binary serve all three models.

int8 with a per-row scale. Embedding rows vary a lot in norm, so a single per-matrix scale wastes precision. Measured — per-row wins on every model (8M: 0.999957 vs 0.999651 cosine-vs-fp32). export_model.py prints both.

Unicode fidelity — probe the real tokenizer, don't reimplement it. The riskiest part is matching HF tokenizers exactly (Rust char::to_lowercase including 1→many mappings, NFD + combining-mark stripping, CJK ranges, punctuation classing). Reimplementing from Python's unicodedata risks Unicode version drift against the Rust crate, so gen_unicode.py probes the real HF primitives per codepoint across the entire U+0000..U+10FFFF range and emits sorted range tables. The committed src/unicode_tables.h is a generated artifact, not hand-written.


Correctness (proven, not assumed)

Two independent checks, both against the Python model2vec.StaticModel reference — the real HuggingFace tokenizer, not a second guess at it.

1. Curated parity corpus

316 varied strings (ASCII, accents, CJK/JP/KR, emoji, punctuation, numbers, >100-char words, empty, whitespace-only, truncation boundaries):

modeltoken-id matchcosine(fp32, py) mincosine(int8, py) minmedian (int8)
potion-base-2M316/316 (100%)1.0000000.9999640.999985
potion-base-8M316/316 (100%)1.0000000.9999340.999972
potion-retrieval-32M316/316 (100%)1.0000000.9998950.999968

The fp32 path is exactly the reference. int8 error is small but non-zero, and is reported separately so it stays visible rather than hidden in an average.

.venv/bin/python bench/make_corpus.py
.venv/bin/python bench/parity_ref.py potion-base-8M
node bench/parity.mjs potion-base-8M

2. Adversarial codepoint sweep

A curated corpus only tests what its author already thought of. The sweep is the complementary check: it mechanically enumerates every interesting codepoint — anything with a lowercase mapping (including 1→many), every Mn/Mc/Me combining mark, every P* punctuation, all eight CJK ranges, plus a strided probe of the astral planes — feeds each through both implementations alone and in two surrounding contexts, adds 4,000 seeded fuzz strings, and requires byte-identical token ids. Identical ids ⇒ identical fp32 embedding, since the vector is just the mean of the looked-up rows.

Non-circularity matters here: candidate selection in bench/sweep_ref.py uses only the Python stdlib, and the oracle is model2vec itself. It is independent of export/gen_unicode.py, so the sweep tests C against the reference, not C against its own table generator.

categorystrings2M8M32M
cased (lowercase mapping, incl. 1→many)4,299PASSPASSPASS
mark (Mn/Mc/Me combining)6,630PASSPASSPASS
punct (P* isolation)2,526PASSPASSPASS
cjk (8 is_chinese_char ranges)244,560PASSPASSPASS
astral (above the old table ceiling)687PASSPASSPASS
fuzz (seeded random, seed 1234567)4,000PASSPASSPASS
total262,702100%100%100%

A real bug this caught. The first run scored 99.9876% — astral failed 32/687. HF's clean_text drops any char whose Unicode category is in {Cc, Cf, Co, Cs} (Rust is_control) but not Cn. gen_unicode.py originally probed only to U+2FFFF, so astral private-use characters (planes 15–16, category Co, e.g. U+F0DBD) were not dropped: Python tokenized "a\u{F0DBD}b"["ab"], while the wasm kept the character, overflowed max_input_chars_per_word, and returned [] — a zero vector, silently. Fix: probe the full U+0000..U+10FFFF range (mechanical and cheap; range tables compress the astral drop-region to a handful of entries), regenerate, rebuild. Re-run: 100%.

The curated corpus would never have contained a plane-15 private-use codepoint. That is the entire reason the sweep exists.

Documented coverage limit: the tables transcribe HF behaviour across planes 0–16. The sweep proves this over its enumerated and fuzzed sample; it is not an exhaustive 1.1M codepoint diff. The cased/mark/punct/cjk enumerations are exhaustive within their classes below U+30000; astral is a strided sample above it.

.venv/bin/python bench/sweep_ref.py potion-base-8M   # writes bench/out/ (gitignored)
node bench/sweep.mjs potion-base-8M

Benchmarks

Apple M2 (8-core, 16 GB), Chrome via DevTools. Throughput = the 316-string corpus tiled to 1000 sentences, 50 warmup. Baseline: @huggingface/transformers v4.2.0 running Xenova/all-MiniLM-L6-v2 (feature-extraction, mean pool + normalize, default single-thread WASM backend). Harness: web/bench.html.

modeldimcold loadms / sentencesentences / s
wasmbed potion-base-2M6432 ms0.0087114,943
wasmbed potion-base-8M (default)25623 ms0.010694,340
wasmbed potion-retrieval-32M512158 ms0.015763,694
transformers.js all-MiniLM-L6-v23845,307 ms18.33355

potion-base-8M is ~1,730× faster per sentence and loads ~235× faster.

Under Node 22 (same machine, potion-base-8M, 50k iterations after 2k warmup) the same build measures 0.0052 ms/sentence (~194,000/s) with a 5.9 ms cold load — roughly 2× the in-browser figure. The browser numbers above are the ones quoted throughout this README, since browsers are the target.

Stated honestly: the cold-load comparison is asymmetric — the wasmbed blobs load locally or from the service-worker cache, while the transformers.js figure includes downloading the ONNX model from the HF hub. Even excluding download, transformers.js is far heavier because it runs a real transformer. It is also not an apples-to-apples quality comparison; see tradeoffs.

Artifact sizes

artifactrawgzip -9brotli -q11
model2vec.wasm (shared)321,916 (314 KB)103,47152,872 (52 KB)
2M weights.i8.bin1.9 MB1.81 MB1.80 MB
8M weights.i8.bin7.3 MB6.48 MB6.37 MB
32M weights.i8.bin31 MB27.2 MB26.7 MB
vocab.bin (2M/8M)308 KB139 KB129 KB

int8 weight blobs are near-random bytes and barely compress (~13% with brotli) — serve them raw or lightly gzipped; brotli's CPU cost is not worth it. Only the vocab compresses well (~2.3×).


Offline proof

DevTools "Offline" emulation was found not to reliably block localhost, so it is not relied on. The stronger method: kill the HTTP server, then reload the page.

  • A fresh page-context fetch(".../manifest.json?probe=<ts>")net::ERR_FAILED (the server is genuinely unreachable — no false positive).
  • The page still boots, embeds all 80 corpus documents, and returns the correct top hit.
  • All 7 app resources return HTTP 200 from the wasmbed-v1 service-worker cache.
onlineoffline (server killed)
demo onlinedemo offline

Right: the same page after kill-ing the server — a fresh query "what powers the sun and stars?" still embeds client-side in 0.8 ms and ranks correctly, entirely from cache.


Honest tradeoffs

  • Embedding quality is the real cost. Model2Vec is a static distillation: one vector per token, mean-pooled, no context. A contextual transformer like MiniLM produces better embeddings on hard semantic tasks. This project proves parity with the model2vec reference (cosine ≈ 1.0); it does not independently re-measure downstream retrieval quality (e.g. MTEB). You can see the ceiling in the offline screenshot above: for "what powers the sun and stars?" the top hit is correct, but the second is an unrelated sentence about the Industrial Revolution. If quality matters, use potion-retrieval-32M — or a real transformer.
  • potion-retrieval-32M is not small. Its 31 MB int8 blob is larger than MiniLM's quantized ONNX (~23 MB). The win there is speed and simplicity, not size.
  • src/unicode_tables.h is 726 KB of generated source in the repo, so that building needs only zig. It contributes ~230 KB to the .wasm.
  • Not published to npm, because the weights cannot reasonably be packaged. Export them yourself; it takes about a minute.

Layout

src/
  embed.c            # the whole inference core (freestanding C)
  unicode_tables.h   # GENERATED by gen_unicode.py — Unicode class + case-fold tables
build.sh             # zig cc --target=wasm32-freestanding  ->  wasm-opt -Oz
export/
  export_model.py    # model -> int8/fp32 blobs + vocab blob + manifest.json
  gen_unicode.py     # probe HF tokenizers per-codepoint -> src/unicode_tables.h
web/
  embed.js           # ESM glue (Node + browser)
  index.html         # offline semantic-search demo
  sw.js              # service worker: cache-first offline shell
  corpus.json        # 80-doc demo corpus
  bench.html         # in-browser benchmark harness
bench/
  make_corpus.py     # generates the 316-string parity corpus
  parity_ref.py      # Python reference (ids + vectors)
  parity.mjs         # comparator: id match rate + cosine distribution
  sweep_ref.py       # adversarial codepoint enumeration + fuzz (Python oracle)
  sweep.mjs          # sweep comparator: byte-identical token ids per category
  out/screens/       # committed demo screenshots
models/              # (gitignored) generated blobs — never committed
build/               # (gitignored) model2vec.wasm

Licences & citations

wasmbed itself is MIT (see LICENSE). Everything it builds on is MIT too:

  • model2vec — MIT. https://github.com/MinishLab/model2vec
  • minishlab/potion-base-2M / potion-base-8M / potion-retrieval-32M — MIT (verified via the HF model-card front matter and models API: license: mit).
  • Baseline only, not shipped: @huggingface/transformers v4.2.0 with Xenova/all-MiniLM-L6-v2 (Apache-2.0).

Algorithm and tokenizer behaviour cross-checked against model2vec==0.8.2 (model2vec/model.py) and the HuggingFace tokenizers Rust sources (normalizers/bert.rs, pre_tokenizers/bert.rs, models/wordpiece/mod.rs).

Model weights are not committed. Generate them with export/export_model.py.

Contributors

darylcecile

3 commits

Languages

C

93.9%

Python

3.3%

JavaScript

1.4%

HTML

1.3%