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) |
| Size | 314 KB .wasm (52 KB brotli) + 1.9–31 MB weights |
| Correctness | 100% identical token ids vs the Python reference over 262,702 adversarial strings; cosine(fp32, reference) = 1.000000 |
| Offline | Verified with the HTTP server killed, not just DevTools emulation |
| Licence | MIT (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.
There is no npm package: weights are multi-megabyte and are never committed, so you generate the artifacts once from source.
Prerequisites — zig (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
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.
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 cat→feline. It is a compact demonstration of the tradeoff — wasmbed is extremely fast and small, not semantically clever.
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
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.
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.
| export | signature | notes |
|---|---|---|
memory | — | linear memory; re-view after any wasm_alloc, it may grow and detach |
wasm_alloc | (bytes: i32) -> ptr: i32 | bump allocator, never frees |
load_model | (dim, vocab_size, max_length, median_token_length, unk_token_id, normalize, is_int8, vocabPtr, weightsPtr, scalesPtr) -> void | call once, after copying blobs into memory |
embed | (inPtr, inLen, outPtr) -> void | UTF-8 bytes in, f32[dim] out |
tokenize_ids | (inPtr, inLen, idsPtr, maxOut) -> count: i32 | token ids only, for debugging/parity |
get_dim | () -> i32 | embedding dimension |
__heap_base | global | start of the bump heap |
Load sequence: wasm_alloc + copy vocab.bin → wasm_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.
createEmbedder(options) -> Promise<Embedder>| option | type | default | description |
|---|---|---|---|
wasmUrl | string | — | path/URL of model2vec.wasm, passed to fetchBytes |
modelDir | string | — | directory 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:
| member | type | description |
|---|---|---|
embed(text) | (string) => Float32Array | the embedding, length dim, L2-normalised |
tokenize(text) | (string) => number[] | WordPiece token ids ([UNK] already dropped) |
dim | number | embedding dimension |
manifest | object | parsed manifest.json |
quant | string | the 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) -> numberCosine similarity. Redundant when both vectors come from embed() (already unit-norm) —
use a plain dot product in hot loops.
One .wasm drives all three; each model brings its own vocab and manifest.
| model | dim | int8 blob | ms/sentence | best for |
|---|---|---|---|---|
potion-base-2M | 64 | 1.9 MB | 0.0087 | tightest footprint, mobile |
potion-base-8M (default) | 256 | 7.3 MB | 0.0106 | balanced — start here |
potion-retrieval-32M | 512 | 31 MB | 0.0157 | best 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.
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:
max_length * median_token_length codepoints.clean_text — drop control chars, collapse whitespace to 0x20.handle_chinese_chars — wrap each CJK codepoint as space, cp, space.strip_accents (NFD + drop nonspacing marks) + lowercase, via a generated
cp → 0..N cps transform table.BertPreTokenizer — split on 0x20, isolate each punctuation codepoint.## continuation; a word > 100 codepoints or
containing an unmatchable piece → [UNK].[UNK] ids; truncate to 512.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.
Two independent checks, both against the Python model2vec.StaticModel reference — the
real HuggingFace tokenizer, not a second guess at it.
316 varied strings (ASCII, accents, CJK/JP/KR, emoji, punctuation, numbers, >100-char words, empty, whitespace-only, truncation boundaries):
| model | token-id match | cosine(fp32, py) min | cosine(int8, py) min | median (int8) |
|---|---|---|---|---|
potion-base-2M | 316/316 (100%) | 1.000000 | 0.999964 | 0.999985 |
potion-base-8M | 316/316 (100%) | 1.000000 | 0.999934 | 0.999972 |
potion-retrieval-32M | 316/316 (100%) | 1.000000 | 0.999895 | 0.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
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.
| category | strings | 2M | 8M | 32M |
|---|---|---|---|---|
cased (lowercase mapping, incl. 1→many) | 4,299 | PASS | PASS | PASS |
mark (Mn/Mc/Me combining) | 6,630 | PASS | PASS | PASS |
punct (P* isolation) | 2,526 | PASS | PASS | PASS |
cjk (8 is_chinese_char ranges) | 244,560 | PASS | PASS | PASS |
astral (above the old table ceiling) | 687 | PASS | PASS | PASS |
fuzz (seeded random, seed 1234567) | 4,000 | PASS | PASS | PASS |
| total | 262,702 | 100% | 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
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.
| model | dim | cold load | ms / sentence | sentences / s |
|---|---|---|---|---|
wasmbed potion-base-2M | 64 | 32 ms | 0.0087 | 114,943 |
wasmbed potion-base-8M (default) | 256 | 23 ms | 0.0106 | 94,340 |
wasmbed potion-retrieval-32M | 512 | 158 ms | 0.0157 | 63,694 |
transformers.js all-MiniLM-L6-v2 | 384 | 5,307 ms | 18.333 | 55 |
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 | raw | gzip -9 | brotli -q11 |
|---|---|---|---|
model2vec.wasm (shared) | 321,916 (314 KB) | 103,471 | 52,872 (52 KB) |
2M weights.i8.bin | 1.9 MB | 1.81 MB | 1.80 MB |
8M weights.i8.bin | 7.3 MB | 6.48 MB | 6.37 MB |
32M weights.i8.bin | 31 MB | 27.2 MB | 26.7 MB |
vocab.bin (2M/8M) | 308 KB | 139 KB | 129 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×).
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.
fetch(".../manifest.json?probe=<ts>") → net::ERR_FAILED
(the server is genuinely unreachable — no false positive).wasmbed-v1 service-worker cache.| online | offline (server killed) |
|---|---|
![]() | ![]() |
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.
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.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
wasmbed itself is MIT (see LICENSE). Everything it builds on is MIT too:
license: mit).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.
3 commits
C
93.9%
Python
3.3%
JavaScript
1.4%
HTML
1.3%
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) |
| Size | 314 KB .wasm (52 KB brotli) + 1.9–31 MB weights |
| Correctness | 100% identical token ids vs the Python reference over 262,702 adversarial strings; cosine(fp32, reference) = 1.000000 |
| Offline | Verified with the HTTP server killed, not just DevTools emulation |
| Licence | MIT (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.
There is no npm package: weights are multi-megabyte and are never committed, so you generate the artifacts once from source.
Prerequisites — zig (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
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.
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 cat→feline. It is a compact demonstration of the tradeoff — wasmbed is extremely fast and small, not semantically clever.
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
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.
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.
| export | signature | notes |
|---|---|---|
memory | — | linear memory; re-view after any wasm_alloc, it may grow and detach |
wasm_alloc | (bytes: i32) -> ptr: i32 | bump allocator, never frees |
load_model | (dim, vocab_size, max_length, median_token_length, unk_token_id, normalize, is_int8, vocabPtr, weightsPtr, scalesPtr) -> void | call once, after copying blobs into memory |
embed | (inPtr, inLen, outPtr) -> void | UTF-8 bytes in, f32[dim] out |
tokenize_ids | (inPtr, inLen, idsPtr, maxOut) -> count: i32 | token ids only, for debugging/parity |
get_dim | () -> i32 | embedding dimension |
__heap_base | global | start of the bump heap |
Load sequence: wasm_alloc + copy vocab.bin → wasm_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.
createEmbedder(options) -> Promise<Embedder>| option | type | default | description |
|---|---|---|---|
wasmUrl | string | — | path/URL of model2vec.wasm, passed to fetchBytes |
modelDir | string | — | directory 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:
| member | type | description |
|---|---|---|
embed(text) | (string) => Float32Array | the embedding, length dim, L2-normalised |
tokenize(text) | (string) => number[] | WordPiece token ids ([UNK] already dropped) |
dim | number | embedding dimension |
manifest | object | parsed manifest.json |
quant | string | the 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) -> numberCosine similarity. Redundant when both vectors come from embed() (already unit-norm) —
use a plain dot product in hot loops.
One .wasm drives all three; each model brings its own vocab and manifest.
| model | dim | int8 blob | ms/sentence | best for |
|---|---|---|---|---|
potion-base-2M | 64 | 1.9 MB | 0.0087 | tightest footprint, mobile |
potion-base-8M (default) | 256 | 7.3 MB | 0.0106 | balanced — start here |
potion-retrieval-32M | 512 | 31 MB | 0.0157 | best 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.
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:
max_length * median_token_length codepoints.clean_text — drop control chars, collapse whitespace to 0x20.handle_chinese_chars — wrap each CJK codepoint as space, cp, space.strip_accents (NFD + drop nonspacing marks) + lowercase, via a generated
cp → 0..N cps transform table.BertPreTokenizer — split on 0x20, isolate each punctuation codepoint.## continuation; a word > 100 codepoints or
containing an unmatchable piece → [UNK].[UNK] ids; truncate to 512.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.
Two independent checks, both against the Python model2vec.StaticModel reference — the
real HuggingFace tokenizer, not a second guess at it.
316 varied strings (ASCII, accents, CJK/JP/KR, emoji, punctuation, numbers, >100-char words, empty, whitespace-only, truncation boundaries):
| model | token-id match | cosine(fp32, py) min | cosine(int8, py) min | median (int8) |
|---|---|---|---|---|
potion-base-2M | 316/316 (100%) | 1.000000 | 0.999964 | 0.999985 |
potion-base-8M | 316/316 (100%) | 1.000000 | 0.999934 | 0.999972 |
potion-retrieval-32M | 316/316 (100%) | 1.000000 | 0.999895 | 0.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
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.
| category | strings | 2M | 8M | 32M |
|---|---|---|---|---|
cased (lowercase mapping, incl. 1→many) | 4,299 | PASS | PASS | PASS |
mark (Mn/Mc/Me combining) | 6,630 | PASS | PASS | PASS |
punct (P* isolation) | 2,526 | PASS | PASS | PASS |
cjk (8 is_chinese_char ranges) | 244,560 | PASS | PASS | PASS |
astral (above the old table ceiling) | 687 | PASS | PASS | PASS |
fuzz (seeded random, seed 1234567) | 4,000 | PASS | PASS | PASS |
| total | 262,702 | 100% | 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
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.
| model | dim | cold load | ms / sentence | sentences / s |
|---|---|---|---|---|
wasmbed potion-base-2M | 64 | 32 ms | 0.0087 | 114,943 |
wasmbed potion-base-8M (default) | 256 | 23 ms | 0.0106 | 94,340 |
wasmbed potion-retrieval-32M | 512 | 158 ms | 0.0157 | 63,694 |
transformers.js all-MiniLM-L6-v2 | 384 | 5,307 ms | 18.333 | 55 |
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 | raw | gzip -9 | brotli -q11 |
|---|---|---|---|
model2vec.wasm (shared) | 321,916 (314 KB) | 103,471 | 52,872 (52 KB) |
2M weights.i8.bin | 1.9 MB | 1.81 MB | 1.80 MB |
8M weights.i8.bin | 7.3 MB | 6.48 MB | 6.37 MB |
32M weights.i8.bin | 31 MB | 27.2 MB | 26.7 MB |
vocab.bin (2M/8M) | 308 KB | 139 KB | 129 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×).
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.
fetch(".../manifest.json?probe=<ts>") → net::ERR_FAILED
(the server is genuinely unreachable — no false positive).wasmbed-v1 service-worker cache.| online | offline (server killed) |
|---|---|
![]() | ![]() |
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.
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.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
wasmbed itself is MIT (see LICENSE). Everything it builds on is MIT too:
license: mit).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.
3 commits
C
93.9%
Python
3.3%
JavaScript
1.4%
HTML
1.3%