Chunk, embed, and evaluate semantic search over a static site's markdown
Project description
static-site-search-eval
sss-eval chunks a static site's markdown, embeds the chunks with a static embedding
model (model2vec/potion), writes browser-readable index artifacts, and evaluates
retrieval quality against a query set. The point of the package is the claim in its
name: the whole thing was built to find out whether a static site can ship real
semantic search without shipping a neural network to the browser. It can.
Why the model fits in a few megabytes
A model2vec / potion model is not a neural network. It is a lookup table: one row of floats per vocabulary token. There is no attention, no matrix multiply chain, no ONNX runtime, no WASM binary. Inference is four steps:
ids = tokenizer.encode(text) # WordPiece token ids, no [CLS]/[SEP]
rows = embedding[ids] # gather: one row per token id
pooled = rows.mean(axis=0) # mean-pool the *unnormalized* rows
vector = pooled / np.linalg.norm(pooled) # L2-normalize the pooled result
That's the forward pass. Training (distilling a real sentence-transformer down to
a static table via PCA) is expensive; using the result is a gather and a mean.
Because inference has no learned computation graph, the entire "model" a browser
needs is the token table itself — an int8 matrix plus a small vocabulary list —
which is why the winning configuration below downloads 4.21 MB instead of the
23+ MB an ONNX sentence-transformer needs for the same job.
Install
pip install static-site-search-eval
# or
uv add static-site-search-eval
Requires Python 3.12+.
Building an index
The winning configuration found by the eval in this repository (see Results below):
sss-eval build \
--corpus content/post \
--outdir static/search \
--model minishlab/potion-base-8M \
--dims 128 \
--chunk-size 600 \
--chunk-overlap 120
Title-prefixing (each chunk's embedded text is prefixed with its post title) is
the default; pass --no-title-prefix to disable it. --cache-root (default
.embed-cache) controls where content-hash-keyed embeddings are cached between
runs, so re-running build after editing one post only re-embeds that post's
chunks.
This writes six files to --outdir:
manifest.jsonchunks.<hash>.jsondocs.<hash>.bintokens.<hash>.binscales.<hash>.binvocab.<hash>.json
and prints a one-line summary:
313 chunks, 128d, vocab 29528 -> static/search
To confirm the index only points at anchors that actually exist in the rendered site (heading anchors are indexed today so that section-level deep links can be turned on later without re-embedding, but nothing renders them yet — data that nothing reads rots quietly unless something checks it):
hugo
sss-eval verify-anchors --search-dir static/search --public public
The artifact format
A browser only ever fetches manifest.json by a fixed URL; everything else it
names is content-hashed (<stem>.<sha256-prefix-12>.<ext>) and can be cached
forever. Bump the corpus or the model and the hash changes, so there is no
versioning scheme to get wrong and no stale-cache class of bug — manifest.json
is the only response that must be served with no-cache.
| file | contents |
|---|---|
manifest.json |
model id, dims, chunk size/overlap, doc_scale (127.0), and the filenames of the other five artifacts. The only URL that must never be cached. |
chunks.<hash>.json |
JSON array, one record per row of docs.bin, same order: {post, title, href, snippet, heading, anchor}. |
docs.<hash>.bin |
raw int8, shape n_chunks × dims, C order (row-major, row i is chunk i's vector). Quantized with a single global scale of 127 because document vectors are already L2-normalized. |
tokens.<hash>.bin |
raw int8, shape vocab_size × dims, C order. This is the model — the entire embedding table, quantized. |
scales.<hash>.bin |
float32, little-endian, length vocab_size — one scale per row of tokens.bin. Token rows carry the model's zipf/SIF-style downweighting in their magnitude (that's how it downweights "the" without a stopword list), so a single global scale would destroy that signal; each row needs its own. |
vocab.<hash>.json |
JSON array of token strings, indexed by token id — the id tokens.bin row i corresponds to is the position of that id's string in this array. |
Every binary artifact is a raw ArrayBuffer, never base64. Base64 costs +33%
on the largest file shipped (tokens.bin); for a payload whose entire point is
its download size, that is not a rounding error.
The three tokenizer traps
Reimplementing potion's tokenizer in JavaScript (rather than shipping a WASM
tokenizer, which would defeat the purpose) means reproducing three
non-obvious behaviors of HuggingFace tokenizers, each verified against
model2vec's own StaticModel.tokenize():
-
No
[CLS]/[SEP].tokenize()callstokenizer.encode_batch_fast(sentences, add_special_tokens=False). TheTemplateProcessingpost-processor recorded intokenizer.jsondescribes how special tokens would be inserted — it is a decoy left over from the tokenizer's BERT ancestry.add_special_tokens=Falsemeans it never runs. A browser implementation that adds[CLS]/[SEP]ids will embed a vector for a token sequence the Python pipeline never produces. -
[UNK]ids are deleted, not embedded. After tokenizing, model2vec filtersunk_token_idout of the id list entirely:[t for t in token_ids if t != unk_token_id]. It does not gather the[UNK]row and mean-pool it in. A query that is entirely out-of-vocabulary (e.g. a string of emoji, or a language the tokenizer's vocab doesn't cover) tokenizes to an empty id list, and mean-pooling zero rows must yield a zero vector rather than throwing or returning the[UNK]embedding. -
Accents are stripped even though the config says they aren't.
tokenizer.json's normalizer sets"strip_accents": null. That reads like "leave accents alone." It doesn't: HuggingFacetokenizers'BertNormalizertreats anullstrip_accentsas "inherit fromlowercase," andlowercaseistrue. So accents ARE stripped —caféandcafetokenize identically. A browser reimplementation that takes the JSON at face value and skips accent-stripping will silently diverge from the Python tokenizer on any query containing a diacritic.
Two facts a browser implementer needs
Both measured against this package's own quantization code
(src/sss_eval/quantize.py), not assumed:
-
The browser never has to dequantize
docs.bin. Cosine similarity is invariant to a positive per-row scale, and every row ofdocs.binwas quantized with the same global scale (127, applied to unit vectors), so that scale is a constant factor across every document's score — it cannot change the ranking. AFloat32Arrayquery vector dotted directly against the rawint8document rows produces the same ranking as dotting against the dequantizedfloat32rows, to within1e-6. Query-side, the query vector is built once per search from the float32 token table math above, dequantizing only what's needed (the tokens actually present in the query), never the 313-row-or-larger document matrix. -
int8 @ int8wraps around silently. A 300-dimension dot product of two int8 vectors whose true mathematical value is 3,000,000 does not throw, does not produceNaN, and does not saturate — it wraps modulo 256 per multiply-accumulate step in a naively-typed accumulator and can return something like-64. There is no exception to catch. The fix is structural, not defensive: accumulate the running dot product into aFloat32Array(or a plain JS number, which is already a float64), never into anInt8ArrayorInt32Arraysized to the inputs.
Reproducing the eval
The eval this package shipped is pinned against a frozen corpus snapshot; see
examples/degoe-de/SNAPSHOT.md for exactly
which commit of which site it was taken from and why it is not meant to be
refreshed — the numbers in the Results section below and in the
accompanying blog post are only meaningful against that exact snapshot.
uv sync
pnpm install
# Dump chunk texts (and query texts) to JSON so the Node arms embed exactly
# what Python did -- same chunker, same text, different runtime.
uv run python -m sss_eval.dump_chunks \
--corpus examples/degoe-de/corpus \
--out build/chunks.json \
--queries examples/degoe-de/queries.yaml
# Embed those chunks and queries with the non-potion arms, each with the
# real runtime a browser would use: transformers.js (ONNX q8) for MiniLM,
# the ternlight WASM engine for its two arms. build_arms (below) looks for
# these three files next to --outdir, i.e. in --outdir's parent.
node node/build_minilm.mjs build/chunks.json build/minilm.json
node node/build_ternlight.mjs build/chunks.json build/ternlight-base.json @ternlight/base
node node/build_ternlight.mjs build/chunks.json build/ternlight-mini.json @ternlight/mini
# Rank the same 30 queries with the site's real Fuse.js config, for the
# keyword baseline and the RRF-fused hybrid arms.
node node/rank_fuse.mjs examples/degoe-de/index.json examples/degoe-de/queries.yaml build/fuse-ranks.json
# Assemble every arm (potion swept over dims/chunking/title-prefix, plus the
# Node arms above) into the format evaluate.py reads.
uv run python -m sss_eval.build_arms \
--corpus examples/degoe-de/corpus \
--queries examples/degoe-de/queries.yaml \
--manifest build/arms.json
# Score every arm, semantic-only and RRF-fused with the keyword baseline,
# against the 30-query eval set, and apply the pre-registered ship rule
# (smallest download within 0.03 recall@3 of the best arm; MRR breaks ties).
uv run python -m sss_eval.evaluate \
--queries examples/degoe-de/queries.yaml \
--arms build/arms.json \
--keyword-ranks build/fuse-ranks.json \
--json build/results.json
Node is only needed to reproduce the MiniLM/ternlight/keyword arms of the
eval; it is not a dependency of sss-eval build or of using the artifacts in
a browser.
Results
Winner: potion-base-8M, PCA-truncated to 128 dims, 600-char chunks with 120
overlap, title-prefixed, RRF-fused with a keyword ranking (Fuse.js, already
shipped with the site).
| download | recall@1 | recall@3 | MRR@10 | |
|---|---|---|---|---|
| potion-base-8M (128d, 600/120, title-prefixed) + RRF | 4.21 MB | 0.717 | 0.967 | 0.944 |
| MiniLM-L6-v2, ONNX q8 | 23.10 MB | 0.700 | — | — |
First-query download of 4,205,974 bytes (4.21 MB) is the int8 token table
plus its float32 per-row scales plus the JSON vocabulary — the three files a
browser needs before it can embed a single query. The document index itself,
for the 13-post corpus in examples/degoe-de/, is 313 chunks × 128 int8
dims = 40 KB, fetched once and cached alongside the rest of the site's static
assets.
License
The code in this repository is MIT licensed (see LICENSE).
examples/degoe-de/corpus/ contains Bart de Goede's blog posts, included as
evaluation data for the eval above — that content is copyright Bart de Goede
and is not covered by the MIT license.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file static_site_search_eval-0.1.0.tar.gz.
File metadata
- Download URL: static_site_search_eval-0.1.0.tar.gz
- Upload date:
- Size: 200.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
918cb43402d5d6dd719bb94e7667cecd6b566978cb1c90711ccef2d172bb770e
|
|
| MD5 |
5b9f2a21bd83411d582a007ddf4b8903
|
|
| BLAKE2b-256 |
865dea80b9c68b307d1183c14093c1f367568e557fa18d8c448e68331ee7f083
|
Provenance
The following attestation bundles were made for static_site_search_eval-0.1.0.tar.gz:
Publisher:
publish.yml on bartdegoede/static-site-search-eval
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
static_site_search_eval-0.1.0.tar.gz -
Subject digest:
918cb43402d5d6dd719bb94e7667cecd6b566978cb1c90711ccef2d172bb770e - Sigstore transparency entry: 2135339669
- Sigstore integration time:
-
Permalink:
bartdegoede/static-site-search-eval@2a979b35d7e61454855bfa21f998519a8315078c -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/bartdegoede
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2a979b35d7e61454855bfa21f998519a8315078c -
Trigger Event:
push
-
Statement type:
File details
Details for the file static_site_search_eval-0.1.0-py3-none-any.whl.
File metadata
- Download URL: static_site_search_eval-0.1.0-py3-none-any.whl
- Upload date:
- Size: 31.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f27b22868c533dd5e601e7ea960d138c20176af408a5b351fdec58e559da684b
|
|
| MD5 |
067152ebdce818db23790a7d6e1e71ee
|
|
| BLAKE2b-256 |
406fc0626af3be5896eaf9dc87bf907887ab43047e3ca57504dcea7e18486951
|
Provenance
The following attestation bundles were made for static_site_search_eval-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on bartdegoede/static-site-search-eval
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
static_site_search_eval-0.1.0-py3-none-any.whl -
Subject digest:
f27b22868c533dd5e601e7ea960d138c20176af408a5b351fdec58e559da684b - Sigstore transparency entry: 2135339693
- Sigstore integration time:
-
Permalink:
bartdegoede/static-site-search-eval@2a979b35d7e61454855bfa21f998519a8315078c -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/bartdegoede
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2a979b35d7e61454855bfa21f998519a8315078c -
Trigger Event:
push
-
Statement type: