Skip to content

Repository files navigation

searchpack

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


what it is

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.

how it works

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

retrieval

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.

two decisions worth explaining

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.

the pack verifies itself

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.

browser APIs used

  • fetch + CORS to huggingface.co — one anonymous GET of the model's quantised table (31.3 MB) and its tokenizer.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) with webkitdirectory, folder-drag and .zip fallbacks
  • DOMParser — structural HTML extraction that keeps heading ids so results deep-link
  • fflate — reads .zip input and writes search.zip (CompressionStream cannot 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)

security / privacy model

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. localStorage holds settings only.

Not protected

  • The one-time model download is an anonymous GET to huggingface.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.zip exactly as you treat the folder it came from.

Trust model

  • The static bundle served by GitHub Pages and the TLS chain to it.
  • huggingface.co and 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.

honest limits

  • 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.

stack

  • Vite 6 + vanilla TypeScript
  • fflate — the only runtime dependency
  • Vitest — 144 unit tests
  • GitHub Pages, deployed via GitHub Actions

local development

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/ locally

Regenerating 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/)

deploying

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.

license

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.

About

Turn your docs folder into a drop-in semantic search engine — a binary index and a tiny search shim, zipped, with no backend ever.

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages