DEV Community

Cover image for Semantic search on a static site with no server: range-fetching the embedding model
Artur Daschevici
Artur Daschevici

Posted on Edited on Originally published at unicow.dev

Semantic search on a static site with no server: range-fetching the embedding model

The search box on my site downloads about 0.1 KB per query. That number sounded wrong to me too, so I put the devtools network tab in the demo GIF. Watch the range requests. That's the whole trick.

There's no search server behind it. No SaaS, no API key, no request leaving the page except range fetches against static files sitting next to the HTML. The engine is called chops-search, it's on crates.io, and the search on the docs site is a live deployment you can poke at. This post is about the one architectural decision that made it work: treating the embedding model as a file you read byte offsets out of, instead of a blob you download.

You can check the claim from your terminal without trusting my recording:

curl -s -o /dev/null -w "%{http_code} %{size_download} bytes\n" \
  -H "Range: bytes=0-127" \
  https://chops-search.gitbadger.com/search/model.rows.2b9eb020575d2857.i8
Enter fullscreen mode Exit fullscreen mode

That hash in the filename is a content hash, and every rebuild mints a new one. If this URL 404s by the time you read it, grab the current name from manifest.json in the same directory. The hash is what lets the artifacts ship under immutable cache headers, so it is doing real work, not just breaking my curl example.

The problem, stated plainly

Static sites and search have a long, awkward history. Keyword search is a solved problem: Pagefind figured out how to fragment an index so the browser only pulls what a query needs, and it works beautifully. But keyword search fails on paraphrase. Search my site for "packing a repo into a prompt" and a pure keyword engine shrugs, because the post says "context packing" and never uses your words.

Semantic search fixes that, and every existing answer to "semantic search on a static site" is some flavor of the same compromise. Run a server. Pay a SaaS. Or ship the entire embedding model to the browser and ask your visitors to download tens of megabytes before they can type.

I didn't want any of those. I wanted Pagefind's loading discipline applied to the model itself.

Why that's even possible

The reason this works is model2vec. A potion-base-8M model isn't a transformer at inference time. It's a lookup table: one static vector per vocabulary token, and a sentence embedding is just the mean of its token rows. No attention, no layers, no runtime beyond "look up rows, average them."

A lookup table has a property transformers don't: you can read one row without the others. If a query tokenizes to six tokens, you need six rows. At int8 quantization, a row is dim bytes, and row i lives at byte i × dim. That's an HTTP range request. The model stops being a download and becomes an address space.

Four files, four loading rules

The build tool emits four artifacts, and each one has a different answer to "when does the browser fetch this."

model.meta.bin holds the complete vocabulary plus per-row quantization scales. Around 500 KB, and it gzips hard. This one is never partial. A truncated vocab doesn't fail loudly, it tokenizes wrong and gives you confidently bad embeddings, so completeness is the rule here.

model.prefix.i8 is the top ~2048 rows by token frequency, loaded eagerly. Common tokens cover a lot of real queries, so most lookups never leave this block.

model.rows.i8 is the full matrix as headerless raw i8. No framing, no metadata, just bytes, because the offset arithmetic is the format. This is the file the browser range-fetches per query.

index.bin carries the chunk vectors, document table, and keyword postings for the site content itself.

At query time a Web Worker asks the wasm engine which byte ranges it's missing, fetches them, feeds them back, and renders ranked results. Fetched rows go into a Cache API row cache, and the artifacts ship content-hashed under immutable cache headers. So the second time anyone searches for anything vaguely similar, the network doesn't get involved at all.

The numbers, with their caveats attached

On my 24-query labelled eval set: 92% recall@1 and 100% recall@3. The set covers exact matches, paraphrases, navigational queries, and a negative control that must return nothing. Exact and navigational sit at 100% recall@1. The two misses are both paraphrase queries, and both still put the right document in the top 3.

Caveats, next to the claim where they belong: 24 queries is small, I wrote and labelled them myself, and they run against my own site's content. This is a regression gate, not a benchmark against anyone else. It exists so a ranking change can't quietly make things worse, and it runs in CI against a real demo Zola site whose worst page (one post covering thirty unrelated topics) is deliberately kept in as the chunker's stress test.

The traffic numbers are the part I actually care about. After initial load, 16 of the 24 eval queries need no network at all, either a prefix hit or a warm row cache. The remaining eight average 0.1 KB range-fetched, 0.8 KB worst case. Compare that to shipping the matrix eagerly and the whole design justifies itself.

Ranking, briefly

Retrieval fuses BM25 (with length normalisation) and cosine similarity over embedded chunks via reciprocal rank fusion. A relevance floor suppresses junk when neither engine is confident, and the trailing query term gets prefix matching so results show up mid-word while you type. Snippets come from the best-scoring chunk with query terms highlighted.

There's a chops-search query --explain command that prints the evidence behind a ranking: keyword scores, best-chunk cosine, each engine's RRF contribution per document. It calls the same scoring code as the ranker, so the explanation cannot drift from the behavior. I built it to debug my own fusion weights and kept it because a search engine that can't show its work is a search engine you can't tune.

One tokenizer, enforced structurally

The core crate, chops-search-core, is pure Rust with no I/O: WordPiece tokenizer, int8 row store, scoring, RRF, artifact formats. It compiles unchanged to native for the build CLI and to wasm for the browser. The tokenizer that indexed your content is bit-for-bit the tokenizer that handles queries, because it's the same compiled code, not two implementations someone promised to keep in sync.

A parity test drives fixture sentences through my implementation and through MinishLab's official model2vec-rs, asserting cosine > 0.9999 per input. If my tokenizer or quantization drifts from the reference, CI fails. The guarantee is structural, not aspirational. That distinction deserves its own post, and it'll get one.

When it breaks, it says so

Partial loading creates a failure mode most search libraries don't have: what if a row you need isn't loaded? Offline, a strict CSP, a host that ignores range requests.

The wrong answer is to average the rows you have. That produces a shrunken, wrong embedding that returns plausible-looking garbage, which is worse than returning nothing. So embed() returns nothing. Search degrades to keyword-only and reports that it did, so the UI can tell the user semantic matching is off rather than silently getting dumber. A range-hostile server degrades to eager loading of the full matrix. Slower, not broken.

I spend a fair amount of my time deliberately breaking data pipelines to see what they do. Building the degradation paths on purpose, instead of discovering them in an issue tracker, was the part of this project that felt most like that work.

One gotcha worth flagging

There's a --dims flag that reduces dimensionality at build time, and it re-runs PCA on the token matrix rather than truncating columns. Potion models are trained after model2vec's distillation-time PCA, so the columns aren't ordered by variance anymore and naive truncation is silently wrong. Cost me an evening. Short follow-up post coming.

Try it

The live demo is the search box on my blog, so the fastest way to evaluate it is to search there for something I've written about, phrased in words I didn't use.

cargo install chops-search
chops-search init    # initializes the required static files in your Zola site
chops-search build   # walks a Zola content tree, emits the artifacts
chops-search eval    # runs your labelled queries as a regression gate
Enter fullscreen mode Exit fullscreen mode

It's a Rust workspace (chops-search-core, chops-search-cli, chops-search-wasm), dual-licensed MIT/Apache-2.0. Zola is the only site generator with first-class support right now because it's what I run. If you want it for Hugo or Eleventy, open an issue and tell me what your front matter looks like.

GitHub logo gitbadger-clan / chops-search

Hybrid semantic + keyword search for static sites. Runs entirely in the browser via WASM — no server, no API keys.

chops-search

Hybrid keyword + semantic search for static sites, running entirely in the browser. No API keys and no server.

The "model" is a model2vec/potion int8 lookup table streamed over HTTP range requests, so a query costs about 0.2 KB on average rather than the 23 MB a transformer would. The engine is one Rust core compiled twice natively for the build tool, to wasm for the browser. That means the tokenizer indexing your content is the same code that tokenizes queries.

On the demo corpus (9 posts, 24 labelled queries):

query kind recall@1 recall@3
exact (chromedp iframes) 100% 100%
paraphrase (how long will this project take) 82% 100%
navigational (about) 100% 100%
negative (sourdough starter hydration) 100% 100%
overall 92% 100%

Paraphrase is the row that matters. Those queries share no words with the documents that answer them, so a keyword…

Top comments (0)