Turn your docs folder into a drop-in semantic search engine — a binary index and a tiny search shim, zipped, with no backend ever.
Live: https://searchpack.benrichardson.dev
You run a docs site on free static hosting. Search is either absent or it is Lunr/FlexSearch keyword matching, so a reader who types "how do I stop it retrying forever" gets nothing, because your page says "configure the backoff ceiling". The fixes on offer are all the wrong shape for a few hundred pages of markdown: Algolia DocSearch is application-gated, a vector database is a monthly bill and an account, and a serverless function calling an embeddings API turns every keystroke into a cost centre and ships every internal query to a vendor.
Searchpack ends the search where it started — in the browser. Point it at the folder, it builds the
index on your machine, and you leave with a zip you unpack into your site's public/. The engine then
runs on your readers' machines, so nobody's queries reach anyone, including you.
The output is search.zip: index.bin, vocab.bin, a dependency-free search.js, and a README with
the copy-paste snippet. Two <script> lines and you have search.
The trick that makes this possible is that the embedding model isn't really a neural network.
sentence-transformers/static-retrieval-mrl-en-v1 is a static embedding model, and its entire ONNX
computation graph is:
Gather(embedding.weight_quantized, input_ids) → DequantizeLinear(scale, zero_point) → ReduceMean
An embedding is the average of one table row per word piece. There is no attention, no matrix
multiply, nothing to run. So Searchpack skips ONNX Runtime entirely: it parses the model file with a
~200-line protobuf reader, lifts the uint8[30522, 1024] table out by name, and does the averaging in
plain JavaScript. That is what lets the shipped engine be 26 KB of JS instead of 11 MB of WASM — and
it is the only reason a search engine can be handed to a third party at all.
The per-tensor scale (1.249721884727478) factors out of the mean and is annihilated by L2
normalisation, so only zero_point (121) is needed to reconstruct the vectors.
your folder ─┬─ .md/.mdx → front-matter strip → heading split
├─ .html → DOMParser → chrome removed, heading ids KEPT
└─ .txt
↓
sentence-boundary chunking with overlap, heading trail prepended
↓
pass 1: tokenize everything → which rows are needed + document frequencies
↓
pass 2: embed = normalize( Σ idf(t) · (row[t] − 121) ), MRL-truncated to 256
↓
index.bin 256-bit binary codes + int8 vectors + norms + BM25 postings + metadata
vocab.bin all 30,522 token strings + the ~1,000 rows you actually use
search.js tokenizer + embedder + retrieval, emitted from the same source the app runs
↓
self-test: load the emitted search.js from a Blob URL and run it
Three modes, all in the shipped shim:
- semantic — cosine over the embeddings. A Hamming-distance pass over the 256-bit codes (32× smaller) picks candidates via a counting sort, then those candidates are rescored with an exact int8 cosine.
- keyword — Okapi BM25 over the same word pieces, from postings stored in
index.bin. - hybrid (default) — both, fused by reciprocal rank. RRF is parameter-free and does not care that a cosine and a BM25 score live on incomparable scales.
Hybrid is the default because mean-pooled embeddings are weakest on exactly what documentation search gets most: identifiers, flag names, error codes. BM25 nails those.
The full token vocabulary ships, but only the rows you use. Subsetting the tokenizer's
vocabulary is the obvious optimisation and it is wrong. WordPiece is greedy longest-match, so removing
entries does not silence unknown words — it re-segments them into pieces that are still in the
index. Measured on a real bert-base-uncased vocabulary, 41% of held-out words re-segment rather than
dropping out: carpet becomes ["car", "##pet"], so a search for "carpet" would confidently retrieve
every page about cars. So all 30,522 token strings ship (~230 KB) and tokenization always runs
against the full vocabulary; the subset is applied afterwards as a pure filter. Corpus text is
unaffected, and out-of-corpus pieces contribute exactly nothing.
Pooling is IDF-weighted, not a plain mean. A plain mean is what the model was trained to produce, and for retrieval it is measurably bad — "how do I stop it retrying forever" is mostly function words, so they dominate the average. Before weighting, that query scored 0.03 against the page answering it and ranked an unrelated page first; after weighting by document frequencies computed from your corpus, it scores 0.46 and ranks correctly. One float per shipped row.
No [CLS] / [SEP]. sentence-transformers calls encode_batch(texts, add_special_tokens=False),
so the template's special tokens never fire. Adding them moves a one-word query's vector by cosine
0.24 against the reference.
Before you can download anything, the emitted search.js is loaded from a Blob URL and run against
the emitted binaries. It re-embeds sample chunks and asserts they reproduce the packed vectors
(cosine > 0.9999 — in practice exactly 1.000000, because the tokenizer and engine are literally the
same source files in both places), measures what the Hamming pre-pass costs against exhaustive
scoring, and throws awkward queries at it. A failing pack is still emitted, labelled unverified,
so a near-miss gives you something to diagnose rather than nothing.
fetch+ CORS to huggingface.co — one anonymous GET of the model's quantised table (31.3 MB) and itstokenizer.json(711 KB), pinned to an immutable revision- Cache API — stores them after the first run, so every later build is fully offline
- First-party ONNX protobuf reader — locates the table initializer by name, no ONNX runtime
- First-party WordPiece tokenizer — BertNormalizer + BertPreTokenizer + greedy longest-match, verified byte-for-byte against HuggingFace on 39 fixture cases
- Web Worker (module) — download, parse, chunk, embed, pack and verify off the main thread
- File System Access API (
showDirectoryPicker,showSaveFilePicker) withwebkitdirectory, folder-drag and.zipfallbacks - DOMParser — structural HTML extraction that keeps heading
ids so results deep-link - fflate — reads
.zipinput and writessearch.zip(CompressionStreamcannot write a ZIP container) - Dynamic
import()of a Blob URL — the self-test runs the artefact, not a copy - Web Share (level 2), Clipboard, Service Worker (installable, offline)
Protected
- Every file you select is read, parsed, chunked and embedded inside the tab. No file, no chunk, no text and no embedding is ever uploaded.
- The index, vocabulary, shim and zip are all built on-device.
- The pack you ship contains no beacon, no callback and no network code beyond fetching its own two static files, so your readers' queries stay on your readers' machines.
- No cookies, no fingerprinting, no third-party fonts.
localStorageholds settings only.
Not protected
- The one-time model download is an anonymous
GETtohuggingface.co, so Hugging Face and its CDN see your IP and the fact that you fetched this model. It carries none of your content, and both URLs appear in the event log before they are requested. - On iOS Safari, storage for a site you have not visited in seven days is cleared, so the download can return. Installing to the Home Screen exempts it.
- GitHub Pages logs the initial page load.
- The pack is derived from your documents and contains chunk text for snippets. If your docs are
private, treat
search.zipexactly as you treat the folder it came from.
Trust model
- The static bundle served by GitHub Pages and the TLS chain to it.
huggingface.coand its CDN, for the one-time model fetch only.- A Cloudflare Web Analytics beacon records anonymous page views — no cookies, no fingerprinting, no cross-site tracking; your files and data are never sent to it.
- Feedback you choose to send goes to
feedback.benrichardson.dev, only when you open the form and press Send.
- English only. The tokenizer is
bert-base-uncased. - Static embeddings are not transformer embeddings. This model scores ~35 on MTEB Retrieval against ~43 for all-MiniLM-L6-v2 and ~52 for bge-small-en-v1.5. That gap is real. On the bundled 18-page sample, five conversational questions deliberately phrased to avoid the wording of the page that answers them put the right page in the top three twice. It shines when a query shares vocabulary with the answer and struggles when it does not, which is precisely why hybrid is the default. Judge it against the keyword-only search your site has today, not against a hosted neural service.
- Vectors come from the model's own uint8 export; against its float32 weights the cosine deviation is ~0.001 typically and ~0.013 at worst for 256 dimensions.
- No incremental re-indexing: a rebuild is a rebuild.
- Vite 6 + vanilla TypeScript
fflate— the only runtime dependency- Vitest — 144 unit tests
- GitHub Pages, deployed via GitHub Actions
npm install
npm run dev # vite dev server on :5173
npm test # run the vitest suite
npm run build # produce dist/ for deploy
npm run preview # serve dist/ locallyRegenerating the committed fixtures:
node scripts/gen-tokenizer-fixture.mjs # reference tokenizer cases + vocab.txt
node scripts/make-sample.mjs # public/samples/docs-site.zip
node scripts/make-notices.mjs # THIRD-PARTY-NOTICES.md (needs dist/)A push to main triggers .github/workflows/deploy.yml, which runs tests, builds, and deploys
dist/ to GitHub Pages. The custom domain is set via public/CNAME — point a CNAME DNS record for
searchpack.benrichardson.dev at ben-gy.github.io.
GNU Affero General Public License v3.0 or later, with an attribution requirement added under section 7(b) — see ADDITIONAL-TERMS.md.
In short: you may run, modify, redistribute and even sell this, but if you distribute it — or run a modified version where other people can reach it — you have to publish your source under the same licence and keep the attribution. A separate commercial licence without those obligations is available on request: hi@ben.gy.
The packs you build are not AGPL. The emitted search.zip is released to you under the MIT
licence so you can ship it on your own site with no source-disclosure obligation — see
ADDITIONAL-TERMS.md.
Third-party components keep their own licences — see THIRD-PARTY-NOTICES.md.