chops-search

Hybrid (keyword + semantic) search for static sites, running entirely in the browser. No search server, no SaaS, no request leaves the page except range fetches against static files. Published on crates.io as the chops-search CLI; the search on this site is it.

Results first: 92% recall@1 and 100% recall@3 on a 24-query labelled eval set (exact, paraphrase, navigational, plus a negative control that must return nothing). Exact and navigational queries sit at 100% recall@1; the misses are two paraphrase queries that still land the right document in the top 3. After initial load, most queries need no network at all (a prefix hit or a warm row cache), and the rest fetch a handful of 128-byte rows out of a multi-megabyte file. chops-search plan prints the exact ranges for any query and, over a labelled set, the prefix hit rate and mean bytes per query, so the number is measured per site rather than quoted from mine.

How it works

The "model" is a model2vec/potion-base-8M int8 lookup table. Instead of shipping the whole matrix eagerly, the build tool emits four artifacts with distinct loading disciplines, Pagefind-style:

  • model.meta.bin: complete vocab plus per-row quantization scales (~500 KB, gzips hard). Always complete, because a partial vocab means silently wrong tokenization.
  • model.prefix.i8: the top ~2048 frequency-ordered rows, loaded eagerly.
  • model.rows.i8: the full matrix as headerless raw i8, range-fetched per query (row i lives at byte i × dim).
  • index.bin: chunk vectors, document table, and keyword postings.

At query time a Web Worker asks the wasm engine which byte ranges it needs, fetches them, feeds them back, and renders ranked results. Fetched rows persist in a Cache API row cache, and artifacts ship with content-hashed filenames under immutable cache headers, so most queries resolve without touching the network at all.

Ranking fuses BM25 (with length normalisation) and cosine similarity over embedded chunks via reciprocal rank fusion, with a relevance floor to suppress junk and prefix matching on the trailing query term so results appear mid-word. Snippets come from the best-scoring chunk with query terms highlighted.

The invariant that makes it trustworthy

The core engine (chops-search-core) is a pure crate 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), so the tokenizer that indexes your content is bit-for-bit the one that tokenizes queries. A parity test drives fixture sentences through both this implementation and MinishLab's official model2vec-rs, asserting cosine > 0.9999 per input. The single-tokenizer guarantee is structural, not aspirational.

Failure is designed, not accidental: if any needed row is unloaded (offline, CSP, a host that ignores range requests), embed() returns nothing rather than a shrunken mean, and search degrades to keyword-only while reporting that it did. A range-hostile server degrades to eager loading, not breakage.

Tooling around it

  • chops-search build walks a Zola content tree (real TOML front matter parsing, draft handling, markdown stripping) and emits gzipped, byte-stable artifacts.
  • chops-search query --explain prints the evidence behind a ranking: keyword scores, best-chunk cosine, and each engine's RRF contribution per document. It calls the same scoring code as the ranker, so it cannot drift.
  • chops-search eval gates every ranking change against the labelled query set in CI, run against a real demo Zola site whose worst page (one post covering thirty unrelated topics) is deliberately kept as the chunker's stress test.
  • Dimensionality reduction via --dims re-runs PCA on the token matrix at build time, because potion models are trained after model2vec's distillation-time PCA and naive column truncation would be wrong.

Rust workspace (chops-search-core, chops-search-cli, chops-search-wasm), dual-licensed MIT/Apache-2.0.