Skip to main content

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.json
  • chunks.<hash>.json
  • docs.<hash>.bin
  • tokens.<hash>.bin
  • scales.<hash>.bin
  • vocab.<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():

  1. No [CLS] / [SEP]. tokenize() calls tokenizer.encode_batch_fast(sentences, add_special_tokens=False). The TemplateProcessing post-processor recorded in tokenizer.json describes how special tokens would be inserted — it is a decoy left over from the tokenizer's BERT ancestry. add_special_tokens=False means it never runs. A browser implementation that adds [CLS]/[SEP] ids will embed a vector for a token sequence the Python pipeline never produces.

  2. [UNK] ids are deleted, not embedded. After tokenizing, model2vec filters unk_token_id out 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.

  3. 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: HuggingFace tokenizers' BertNormalizer treats a null strip_accents as "inherit from lowercase," and lowercase is true. So accents ARE stripped — café and cafe tokenize 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:

  1. The browser never has to dequantize docs.bin. Cosine similarity is invariant to a positive per-row scale, and every row of docs.bin was 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. A Float32Array query vector dotted directly against the raw int8 document rows produces the same ranking as dotting against the dequantized float32 rows, to within 1e-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.

  2. int8 @ int8 wraps around silently. A 300-dimension dot product of two int8 vectors whose true mathematical value is 3,000,000 does not throw, does not produce NaN, 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 a Float32Array (or a plain JS number, which is already a float64), never into an Int8Array or Int32Array sized 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

static_site_search_eval-0.1.0.tar.gz (200.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

static_site_search_eval-0.1.0-py3-none-any.whl (31.5 kB view details)

Uploaded Python 3

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

Hashes for static_site_search_eval-0.1.0.tar.gz
Algorithm Hash digest
SHA256 918cb43402d5d6dd719bb94e7667cecd6b566978cb1c90711ccef2d172bb770e
MD5 5b9f2a21bd83411d582a007ddf4b8903
BLAKE2b-256 865dea80b9c68b307d1183c14093c1f367568e557fa18d8c448e68331ee7f083

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file static_site_search_eval-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for static_site_search_eval-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f27b22868c533dd5e601e7ea960d138c20176af408a5b351fdec58e559da684b
MD5 067152ebdce818db23790a7d6e1e71ee
BLAKE2b-256 406fc0626af3be5896eaf9dc87bf907887ab43047e3ca57504dcea7e18486951

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page