static-site-search-eval 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. static_site_search_eval-0.1.0/.github/workflows/ci.yml +18 -0
  2. static_site_search_eval-0.1.0/.github/workflows/publish.yml +19 -0
  3. static_site_search_eval-0.1.0/.gitignore +8 -0
  4. static_site_search_eval-0.1.0/LICENSE +21 -0
  5. static_site_search_eval-0.1.0/PKG-INFO +260 -0
  6. static_site_search_eval-0.1.0/README.md +240 -0
  7. static_site_search_eval-0.1.0/examples/degoe-de/SNAPSHOT.md +15 -0
  8. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2018-03-02-searching-your-hugo-site-with-lunr.md +268 -0
  9. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2018-03-22-bloom-filters-and-bitcoin.md +152 -0
  10. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2018-03-28-free-ssl-on-github-pages-with-a-custom-domain.md +76 -0
  11. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2018-05-02-free-ssl-on-github-pages-with-a-custom-domain-part-2.md +59 -0
  12. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2018-11-21-tab-plus-search-from-your-url-bar-with-opensearch.md +92 -0
  13. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2019-07-12-use-output-format-for-index-generation.md +51 -0
  14. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2019-10-29-use-google-cloud-text-to-speech-to-create-an-audio-version-of-your-blog-posts.md +262 -0
  15. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2021-03-24-python-full-text-search-engine.md +354 -0
  16. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2025-10-26-migrating-a-hugo-blog-with-claude-code.md +167 -0
  17. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2026-02-09-modernizing-python-search-engine.md +206 -0
  18. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2026-02-09-semantic-search-python.md +305 -0
  19. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2026-02-19-ai-agent-dungeon-crawlerverse-sdk.md +334 -0
  20. static_site_search_eval-0.1.0/examples/degoe-de/corpus/2026-06-29-running-a-coding-agent-locally.md +116 -0
  21. static_site_search_eval-0.1.0/examples/degoe-de/index.json +1 -0
  22. static_site_search_eval-0.1.0/examples/degoe-de/queries.yaml +161 -0
  23. static_site_search_eval-0.1.0/node/build_minilm.mjs +30 -0
  24. static_site_search_eval-0.1.0/node/build_ternlight.mjs +38 -0
  25. static_site_search_eval-0.1.0/node/rank_fuse.mjs +52 -0
  26. static_site_search_eval-0.1.0/package.json +11 -0
  27. static_site_search_eval-0.1.0/pnpm-lock.yaml +691 -0
  28. static_site_search_eval-0.1.0/pyproject.toml +48 -0
  29. static_site_search_eval-0.1.0/src/sss_eval/__init__.py +3 -0
  30. static_site_search_eval-0.1.0/src/sss_eval/anchors.py +34 -0
  31. static_site_search_eval-0.1.0/src/sss_eval/artifacts.py +88 -0
  32. static_site_search_eval-0.1.0/src/sss_eval/build.py +115 -0
  33. static_site_search_eval-0.1.0/src/sss_eval/build_arms.py +324 -0
  34. static_site_search_eval-0.1.0/src/sss_eval/cache.py +50 -0
  35. static_site_search_eval-0.1.0/src/sss_eval/chunk.py +88 -0
  36. static_site_search_eval-0.1.0/src/sss_eval/cli.py +88 -0
  37. static_site_search_eval-0.1.0/src/sss_eval/corpus.py +19 -0
  38. static_site_search_eval-0.1.0/src/sss_eval/dump_chunks.py +38 -0
  39. static_site_search_eval-0.1.0/src/sss_eval/evaluate.py +325 -0
  40. static_site_search_eval-0.1.0/src/sss_eval/markdown.py +130 -0
  41. static_site_search_eval-0.1.0/src/sss_eval/metrics.py +55 -0
  42. static_site_search_eval-0.1.0/src/sss_eval/post.py +75 -0
  43. static_site_search_eval-0.1.0/src/sss_eval/quantize.py +44 -0
  44. static_site_search_eval-0.1.0/src/sss_eval/rank.py +71 -0
  45. static_site_search_eval-0.1.0/src/sss_eval/verify_anchors.py +76 -0
  46. static_site_search_eval-0.1.0/tests/__init__.py +0 -0
  47. static_site_search_eval-0.1.0/tests/fixtures/corpus/2020-01-01-alpha.md +15 -0
  48. static_site_search_eval-0.1.0/tests/fixtures/corpus/2020-02-02-beta.md +18 -0
  49. static_site_search_eval-0.1.0/tests/fixtures/corpus/2020-03-03-gamma-file-name-differs.md +7 -0
  50. static_site_search_eval-0.1.0/tests/test_anchors.py +26 -0
  51. static_site_search_eval-0.1.0/tests/test_artifacts.py +186 -0
  52. static_site_search_eval-0.1.0/tests/test_build.py +89 -0
  53. static_site_search_eval-0.1.0/tests/test_build_arms.py +110 -0
  54. static_site_search_eval-0.1.0/tests/test_cache.py +114 -0
  55. static_site_search_eval-0.1.0/tests/test_chunk.py +139 -0
  56. static_site_search_eval-0.1.0/tests/test_cli.py +43 -0
  57. static_site_search_eval-0.1.0/tests/test_corpus.py +39 -0
  58. static_site_search_eval-0.1.0/tests/test_evaluate.py +135 -0
  59. static_site_search_eval-0.1.0/tests/test_markdown.py +183 -0
  60. static_site_search_eval-0.1.0/tests/test_metrics.py +80 -0
  61. static_site_search_eval-0.1.0/tests/test_post.py +138 -0
  62. static_site_search_eval-0.1.0/tests/test_quantize.py +78 -0
  63. static_site_search_eval-0.1.0/tests/test_queries.py +43 -0
  64. static_site_search_eval-0.1.0/tests/test_rank.py +117 -0
  65. static_site_search_eval-0.1.0/tests/test_verify_anchors.py +48 -0
  66. static_site_search_eval-0.1.0/uv.lock +713 -0
@@ -0,0 +1,18 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ pull_request:
7
+ branches: ["main"]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: astral-sh/setup-uv@v5
15
+ - run: uv sync
16
+ - run: uv run pytest
17
+ - run: uv run ruff check .
18
+ - run: uv run black --check .
@@ -0,0 +1,19 @@
1
+ name: publish
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write # required for trusted publishing
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v5
16
+ - run: uv sync
17
+ - run: uv run pytest
18
+ - run: uv build
19
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ .embed-cache/
4
+ build/
5
+ dist/
6
+ node_modules/
7
+ *.egg-info/
8
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bart de Goede
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,260 @@
1
+ Metadata-Version: 2.4
2
+ Name: static-site-search-eval
3
+ Version: 0.1.0
4
+ Summary: Chunk, embed, and evaluate semantic search over a static site's markdown
5
+ Project-URL: Homepage, https://github.com/bartdegoede/static-site-search-eval
6
+ Project-URL: Issues, https://github.com/bartdegoede/static-site-search-eval/issues
7
+ Author: Bart de Goede
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: embeddings,evaluation,model2vec,search,static-site
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Text Processing :: Indexing
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: model2vec>=0.6.0
17
+ Requires-Dist: numpy>=2.1
18
+ Requires-Dist: pyyaml>=6.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # static-site-search-eval
22
+
23
+ `sss-eval` chunks a static site's markdown, embeds the chunks with a static embedding
24
+ model (model2vec/potion), writes browser-readable index artifacts, and evaluates
25
+ retrieval quality against a query set. The point of the package is the claim in its
26
+ name: the whole thing was built to find out whether a static site can ship real
27
+ semantic search without shipping a neural network to the browser. It can.
28
+
29
+ ## Why the model fits in a few megabytes
30
+
31
+ A model2vec / potion model is not a neural network. It is a lookup table: one
32
+ row of floats per vocabulary token. There is no attention, no matrix multiply
33
+ chain, no ONNX runtime, no WASM binary. Inference is four steps:
34
+
35
+ ```python
36
+ ids = tokenizer.encode(text) # WordPiece token ids, no [CLS]/[SEP]
37
+ rows = embedding[ids] # gather: one row per token id
38
+ pooled = rows.mean(axis=0) # mean-pool the *unnormalized* rows
39
+ vector = pooled / np.linalg.norm(pooled) # L2-normalize the pooled result
40
+ ```
41
+
42
+ That's the forward pass. Training (distilling a real sentence-transformer down to
43
+ a static table via PCA) is expensive; using the result is a gather and a mean.
44
+ Because inference has no learned computation graph, the entire "model" a browser
45
+ needs is the token table itself — an `int8` matrix plus a small vocabulary list —
46
+ which is why the winning configuration below downloads 4.21 MB instead of the
47
+ 23+ MB an ONNX sentence-transformer needs for the same job.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install static-site-search-eval
53
+ # or
54
+ uv add static-site-search-eval
55
+ ```
56
+
57
+ Requires Python 3.12+.
58
+
59
+ ## Building an index
60
+
61
+ The winning configuration found by the eval in this repository (see
62
+ [Results](#results) below):
63
+
64
+ ```bash
65
+ sss-eval build \
66
+ --corpus content/post \
67
+ --outdir static/search \
68
+ --model minishlab/potion-base-8M \
69
+ --dims 128 \
70
+ --chunk-size 600 \
71
+ --chunk-overlap 120
72
+ ```
73
+
74
+ Title-prefixing (each chunk's embedded text is prefixed with its post title) is
75
+ the default; pass `--no-title-prefix` to disable it. `--cache-root` (default
76
+ `.embed-cache`) controls where content-hash-keyed embeddings are cached between
77
+ runs, so re-running `build` after editing one post only re-embeds that post's
78
+ chunks.
79
+
80
+ This writes six files to `--outdir`:
81
+
82
+ - `manifest.json`
83
+ - `chunks.<hash>.json`
84
+ - `docs.<hash>.bin`
85
+ - `tokens.<hash>.bin`
86
+ - `scales.<hash>.bin`
87
+ - `vocab.<hash>.json`
88
+
89
+ and prints a one-line summary:
90
+
91
+ ```
92
+ 313 chunks, 128d, vocab 29528 -> static/search
93
+ ```
94
+
95
+ To confirm the index only points at anchors that actually exist in the rendered
96
+ site (heading anchors are indexed today so that section-level deep links can be
97
+ turned on later without re-embedding, but nothing renders them yet — data that
98
+ nothing reads rots quietly unless something checks it):
99
+
100
+ ```bash
101
+ hugo
102
+ sss-eval verify-anchors --search-dir static/search --public public
103
+ ```
104
+
105
+ ## The artifact format
106
+
107
+ A browser only ever fetches `manifest.json` by a fixed URL; everything else it
108
+ names is content-hashed (`<stem>.<sha256-prefix-12>.<ext>`) and can be cached
109
+ forever. Bump the corpus or the model and the hash changes, so there is no
110
+ versioning scheme to get wrong and no stale-cache class of bug — `manifest.json`
111
+ is the only response that must be served with `no-cache`.
112
+
113
+ | file | contents |
114
+ |---|---|
115
+ | `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. |
116
+ | `chunks.<hash>.json` | JSON array, one record per row of `docs.bin`, same order: `{post, title, href, snippet, heading, anchor}`. |
117
+ | `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. |
118
+ | `tokens.<hash>.bin` | raw `int8`, shape `vocab_size × dims`, C order. This *is* the model — the entire embedding table, quantized. |
119
+ | `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. |
120
+ | `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. |
121
+
122
+ Every binary artifact is a raw `ArrayBuffer`, never base64. Base64 costs +33%
123
+ on the largest file shipped (`tokens.bin`); for a payload whose entire point is
124
+ its download size, that is not a rounding error.
125
+
126
+ ## The three tokenizer traps
127
+
128
+ Reimplementing potion's tokenizer in JavaScript (rather than shipping a WASM
129
+ tokenizer, which would defeat the purpose) means reproducing three
130
+ non-obvious behaviors of HuggingFace `tokenizers`, each verified against
131
+ `model2vec`'s own `StaticModel.tokenize()`:
132
+
133
+ 1. **No `[CLS]` / `[SEP]`.** `tokenize()` calls
134
+ `tokenizer.encode_batch_fast(sentences, add_special_tokens=False)`. The
135
+ `TemplateProcessing` post-processor recorded in `tokenizer.json` describes
136
+ how special tokens *would* be inserted — it is a decoy left over from the
137
+ tokenizer's BERT ancestry. `add_special_tokens=False` means it never runs.
138
+ A browser implementation that adds `[CLS]`/`[SEP]` ids will embed a vector
139
+ for a token sequence the Python pipeline never produces.
140
+
141
+ 2. **`[UNK]` ids are deleted, not embedded.** After tokenizing, model2vec
142
+ filters `unk_token_id` out of the id list entirely:
143
+ `[t for t in token_ids if t != unk_token_id]`. It does not gather the
144
+ `[UNK]` row and mean-pool it in. A query that is entirely out-of-vocabulary
145
+ (e.g. a string of emoji, or a language the tokenizer's vocab doesn't cover)
146
+ tokenizes to an **empty** id list, and mean-pooling zero rows must yield a
147
+ zero vector rather than throwing or returning the `[UNK]` embedding.
148
+
149
+ 3. **Accents are stripped even though the config says they aren't.**
150
+ `tokenizer.json`'s normalizer sets `"strip_accents": null`. That reads like
151
+ "leave accents alone." It doesn't: HuggingFace `tokenizers`' `BertNormalizer`
152
+ treats a `null` `strip_accents` as "inherit from `lowercase`," and
153
+ `lowercase` is `true`. So accents ARE stripped — `café` and `cafe` tokenize
154
+ identically. A browser reimplementation that takes the JSON at face value
155
+ and skips accent-stripping will silently diverge from the Python tokenizer
156
+ on any query containing a diacritic.
157
+
158
+ ## Two facts a browser implementer needs
159
+
160
+ Both measured against this package's own quantization code
161
+ (`src/sss_eval/quantize.py`), not assumed:
162
+
163
+ 1. **The browser never has to dequantize `docs.bin`.** Cosine similarity is
164
+ invariant to a positive per-row scale, and every row of `docs.bin` was
165
+ quantized with the *same* global scale (127, applied to unit vectors), so
166
+ that scale is a constant factor across every document's score — it cannot
167
+ change the ranking. A `Float32Array` query vector dotted directly against
168
+ the raw `int8` document rows produces the same ranking as dotting against
169
+ the dequantized `float32` rows, to within `1e-6`. Query-side, the query
170
+ vector is built once per search from the float32 token table math above,
171
+ dequantizing only what's needed (the tokens actually present in the
172
+ query), never the 313-row-or-larger document matrix.
173
+
174
+ 2. **`int8 @ int8` wraps around silently.** A 300-dimension dot product of two
175
+ int8 vectors whose true mathematical value is 3,000,000 does not throw, does
176
+ not produce `NaN`, and does not saturate — it wraps modulo 256 per
177
+ multiply-accumulate step in a naively-typed accumulator and can return
178
+ something like `-64`. There is no exception to catch. The fix is
179
+ structural, not defensive: accumulate the running dot product into a
180
+ `Float32Array` (or a plain JS number, which is already a float64), never
181
+ into an `Int8Array` or `Int32Array` sized to the inputs.
182
+
183
+ ## Reproducing the eval
184
+
185
+ The eval this package shipped is pinned against a frozen corpus snapshot; see
186
+ [`examples/degoe-de/SNAPSHOT.md`](examples/degoe-de/SNAPSHOT.md) for exactly
187
+ which commit of which site it was taken from and why it is not meant to be
188
+ refreshed — the numbers in the [Results](#results) section below and in the
189
+ accompanying blog post are only meaningful against that exact snapshot.
190
+
191
+ ```bash
192
+ uv sync
193
+ pnpm install
194
+
195
+ # Dump chunk texts (and query texts) to JSON so the Node arms embed exactly
196
+ # what Python did -- same chunker, same text, different runtime.
197
+ uv run python -m sss_eval.dump_chunks \
198
+ --corpus examples/degoe-de/corpus \
199
+ --out build/chunks.json \
200
+ --queries examples/degoe-de/queries.yaml
201
+
202
+ # Embed those chunks and queries with the non-potion arms, each with the
203
+ # real runtime a browser would use: transformers.js (ONNX q8) for MiniLM,
204
+ # the ternlight WASM engine for its two arms. build_arms (below) looks for
205
+ # these three files next to --outdir, i.e. in --outdir's parent.
206
+ node node/build_minilm.mjs build/chunks.json build/minilm.json
207
+ node node/build_ternlight.mjs build/chunks.json build/ternlight-base.json @ternlight/base
208
+ node node/build_ternlight.mjs build/chunks.json build/ternlight-mini.json @ternlight/mini
209
+
210
+ # Rank the same 30 queries with the site's real Fuse.js config, for the
211
+ # keyword baseline and the RRF-fused hybrid arms.
212
+ node node/rank_fuse.mjs examples/degoe-de/index.json examples/degoe-de/queries.yaml build/fuse-ranks.json
213
+
214
+ # Assemble every arm (potion swept over dims/chunking/title-prefix, plus the
215
+ # Node arms above) into the format evaluate.py reads.
216
+ uv run python -m sss_eval.build_arms \
217
+ --corpus examples/degoe-de/corpus \
218
+ --queries examples/degoe-de/queries.yaml \
219
+ --manifest build/arms.json
220
+
221
+ # Score every arm, semantic-only and RRF-fused with the keyword baseline,
222
+ # against the 30-query eval set, and apply the pre-registered ship rule
223
+ # (smallest download within 0.03 recall@3 of the best arm; MRR breaks ties).
224
+ uv run python -m sss_eval.evaluate \
225
+ --queries examples/degoe-de/queries.yaml \
226
+ --arms build/arms.json \
227
+ --keyword-ranks build/fuse-ranks.json \
228
+ --json build/results.json
229
+ ```
230
+
231
+ Node is only needed to reproduce the MiniLM/ternlight/keyword arms of the
232
+ eval; it is not a dependency of `sss-eval build` or of using the artifacts in
233
+ a browser.
234
+
235
+ ## Results
236
+
237
+ Winner: **`potion-base-8M`, PCA-truncated to 128 dims, 600-char chunks with 120
238
+ overlap, title-prefixed**, RRF-fused with a keyword ranking (Fuse.js, already
239
+ shipped with the site).
240
+
241
+ | | download | recall@1 | recall@3 | MRR@10 |
242
+ |---|---|---|---|---|
243
+ | potion-base-8M (128d, 600/120, title-prefixed) + RRF | 4.21 MB | 0.717 | 0.967 | 0.944 |
244
+ | MiniLM-L6-v2, ONNX q8 | 23.10 MB | 0.700 | — | — |
245
+
246
+ First-query download of 4,205,974 bytes (4.21 MB) is the `int8` token table
247
+ plus its `float32` per-row scales plus the JSON vocabulary — the three files a
248
+ browser needs before it can embed a single query. The document index itself,
249
+ for the 13-post corpus in `examples/degoe-de/`, is 313 chunks × 128 `int8`
250
+ dims = 40 KB, fetched once and cached alongside the rest of the site's static
251
+ assets.
252
+
253
+ <!-- TODO: link the blog post once published -->
254
+
255
+ ## License
256
+
257
+ The code in this repository is MIT licensed (see [`LICENSE`](LICENSE)).
258
+ `examples/degoe-de/corpus/` contains Bart de Goede's blog posts, included as
259
+ evaluation data for the eval above — that content is copyright Bart de Goede
260
+ and is not covered by the MIT license.
@@ -0,0 +1,240 @@
1
+ # static-site-search-eval
2
+
3
+ `sss-eval` chunks a static site's markdown, embeds the chunks with a static embedding
4
+ model (model2vec/potion), writes browser-readable index artifacts, and evaluates
5
+ retrieval quality against a query set. The point of the package is the claim in its
6
+ name: the whole thing was built to find out whether a static site can ship real
7
+ semantic search without shipping a neural network to the browser. It can.
8
+
9
+ ## Why the model fits in a few megabytes
10
+
11
+ A model2vec / potion model is not a neural network. It is a lookup table: one
12
+ row of floats per vocabulary token. There is no attention, no matrix multiply
13
+ chain, no ONNX runtime, no WASM binary. Inference is four steps:
14
+
15
+ ```python
16
+ ids = tokenizer.encode(text) # WordPiece token ids, no [CLS]/[SEP]
17
+ rows = embedding[ids] # gather: one row per token id
18
+ pooled = rows.mean(axis=0) # mean-pool the *unnormalized* rows
19
+ vector = pooled / np.linalg.norm(pooled) # L2-normalize the pooled result
20
+ ```
21
+
22
+ That's the forward pass. Training (distilling a real sentence-transformer down to
23
+ a static table via PCA) is expensive; using the result is a gather and a mean.
24
+ Because inference has no learned computation graph, the entire "model" a browser
25
+ needs is the token table itself — an `int8` matrix plus a small vocabulary list —
26
+ which is why the winning configuration below downloads 4.21 MB instead of the
27
+ 23+ MB an ONNX sentence-transformer needs for the same job.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install static-site-search-eval
33
+ # or
34
+ uv add static-site-search-eval
35
+ ```
36
+
37
+ Requires Python 3.12+.
38
+
39
+ ## Building an index
40
+
41
+ The winning configuration found by the eval in this repository (see
42
+ [Results](#results) below):
43
+
44
+ ```bash
45
+ sss-eval build \
46
+ --corpus content/post \
47
+ --outdir static/search \
48
+ --model minishlab/potion-base-8M \
49
+ --dims 128 \
50
+ --chunk-size 600 \
51
+ --chunk-overlap 120
52
+ ```
53
+
54
+ Title-prefixing (each chunk's embedded text is prefixed with its post title) is
55
+ the default; pass `--no-title-prefix` to disable it. `--cache-root` (default
56
+ `.embed-cache`) controls where content-hash-keyed embeddings are cached between
57
+ runs, so re-running `build` after editing one post only re-embeds that post's
58
+ chunks.
59
+
60
+ This writes six files to `--outdir`:
61
+
62
+ - `manifest.json`
63
+ - `chunks.<hash>.json`
64
+ - `docs.<hash>.bin`
65
+ - `tokens.<hash>.bin`
66
+ - `scales.<hash>.bin`
67
+ - `vocab.<hash>.json`
68
+
69
+ and prints a one-line summary:
70
+
71
+ ```
72
+ 313 chunks, 128d, vocab 29528 -> static/search
73
+ ```
74
+
75
+ To confirm the index only points at anchors that actually exist in the rendered
76
+ site (heading anchors are indexed today so that section-level deep links can be
77
+ turned on later without re-embedding, but nothing renders them yet — data that
78
+ nothing reads rots quietly unless something checks it):
79
+
80
+ ```bash
81
+ hugo
82
+ sss-eval verify-anchors --search-dir static/search --public public
83
+ ```
84
+
85
+ ## The artifact format
86
+
87
+ A browser only ever fetches `manifest.json` by a fixed URL; everything else it
88
+ names is content-hashed (`<stem>.<sha256-prefix-12>.<ext>`) and can be cached
89
+ forever. Bump the corpus or the model and the hash changes, so there is no
90
+ versioning scheme to get wrong and no stale-cache class of bug — `manifest.json`
91
+ is the only response that must be served with `no-cache`.
92
+
93
+ | file | contents |
94
+ |---|---|
95
+ | `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. |
96
+ | `chunks.<hash>.json` | JSON array, one record per row of `docs.bin`, same order: `{post, title, href, snippet, heading, anchor}`. |
97
+ | `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. |
98
+ | `tokens.<hash>.bin` | raw `int8`, shape `vocab_size × dims`, C order. This *is* the model — the entire embedding table, quantized. |
99
+ | `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. |
100
+ | `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. |
101
+
102
+ Every binary artifact is a raw `ArrayBuffer`, never base64. Base64 costs +33%
103
+ on the largest file shipped (`tokens.bin`); for a payload whose entire point is
104
+ its download size, that is not a rounding error.
105
+
106
+ ## The three tokenizer traps
107
+
108
+ Reimplementing potion's tokenizer in JavaScript (rather than shipping a WASM
109
+ tokenizer, which would defeat the purpose) means reproducing three
110
+ non-obvious behaviors of HuggingFace `tokenizers`, each verified against
111
+ `model2vec`'s own `StaticModel.tokenize()`:
112
+
113
+ 1. **No `[CLS]` / `[SEP]`.** `tokenize()` calls
114
+ `tokenizer.encode_batch_fast(sentences, add_special_tokens=False)`. The
115
+ `TemplateProcessing` post-processor recorded in `tokenizer.json` describes
116
+ how special tokens *would* be inserted — it is a decoy left over from the
117
+ tokenizer's BERT ancestry. `add_special_tokens=False` means it never runs.
118
+ A browser implementation that adds `[CLS]`/`[SEP]` ids will embed a vector
119
+ for a token sequence the Python pipeline never produces.
120
+
121
+ 2. **`[UNK]` ids are deleted, not embedded.** After tokenizing, model2vec
122
+ filters `unk_token_id` out of the id list entirely:
123
+ `[t for t in token_ids if t != unk_token_id]`. It does not gather the
124
+ `[UNK]` row and mean-pool it in. A query that is entirely out-of-vocabulary
125
+ (e.g. a string of emoji, or a language the tokenizer's vocab doesn't cover)
126
+ tokenizes to an **empty** id list, and mean-pooling zero rows must yield a
127
+ zero vector rather than throwing or returning the `[UNK]` embedding.
128
+
129
+ 3. **Accents are stripped even though the config says they aren't.**
130
+ `tokenizer.json`'s normalizer sets `"strip_accents": null`. That reads like
131
+ "leave accents alone." It doesn't: HuggingFace `tokenizers`' `BertNormalizer`
132
+ treats a `null` `strip_accents` as "inherit from `lowercase`," and
133
+ `lowercase` is `true`. So accents ARE stripped — `café` and `cafe` tokenize
134
+ identically. A browser reimplementation that takes the JSON at face value
135
+ and skips accent-stripping will silently diverge from the Python tokenizer
136
+ on any query containing a diacritic.
137
+
138
+ ## Two facts a browser implementer needs
139
+
140
+ Both measured against this package's own quantization code
141
+ (`src/sss_eval/quantize.py`), not assumed:
142
+
143
+ 1. **The browser never has to dequantize `docs.bin`.** Cosine similarity is
144
+ invariant to a positive per-row scale, and every row of `docs.bin` was
145
+ quantized with the *same* global scale (127, applied to unit vectors), so
146
+ that scale is a constant factor across every document's score — it cannot
147
+ change the ranking. A `Float32Array` query vector dotted directly against
148
+ the raw `int8` document rows produces the same ranking as dotting against
149
+ the dequantized `float32` rows, to within `1e-6`. Query-side, the query
150
+ vector is built once per search from the float32 token table math above,
151
+ dequantizing only what's needed (the tokens actually present in the
152
+ query), never the 313-row-or-larger document matrix.
153
+
154
+ 2. **`int8 @ int8` wraps around silently.** A 300-dimension dot product of two
155
+ int8 vectors whose true mathematical value is 3,000,000 does not throw, does
156
+ not produce `NaN`, and does not saturate — it wraps modulo 256 per
157
+ multiply-accumulate step in a naively-typed accumulator and can return
158
+ something like `-64`. There is no exception to catch. The fix is
159
+ structural, not defensive: accumulate the running dot product into a
160
+ `Float32Array` (or a plain JS number, which is already a float64), never
161
+ into an `Int8Array` or `Int32Array` sized to the inputs.
162
+
163
+ ## Reproducing the eval
164
+
165
+ The eval this package shipped is pinned against a frozen corpus snapshot; see
166
+ [`examples/degoe-de/SNAPSHOT.md`](examples/degoe-de/SNAPSHOT.md) for exactly
167
+ which commit of which site it was taken from and why it is not meant to be
168
+ refreshed — the numbers in the [Results](#results) section below and in the
169
+ accompanying blog post are only meaningful against that exact snapshot.
170
+
171
+ ```bash
172
+ uv sync
173
+ pnpm install
174
+
175
+ # Dump chunk texts (and query texts) to JSON so the Node arms embed exactly
176
+ # what Python did -- same chunker, same text, different runtime.
177
+ uv run python -m sss_eval.dump_chunks \
178
+ --corpus examples/degoe-de/corpus \
179
+ --out build/chunks.json \
180
+ --queries examples/degoe-de/queries.yaml
181
+
182
+ # Embed those chunks and queries with the non-potion arms, each with the
183
+ # real runtime a browser would use: transformers.js (ONNX q8) for MiniLM,
184
+ # the ternlight WASM engine for its two arms. build_arms (below) looks for
185
+ # these three files next to --outdir, i.e. in --outdir's parent.
186
+ node node/build_minilm.mjs build/chunks.json build/minilm.json
187
+ node node/build_ternlight.mjs build/chunks.json build/ternlight-base.json @ternlight/base
188
+ node node/build_ternlight.mjs build/chunks.json build/ternlight-mini.json @ternlight/mini
189
+
190
+ # Rank the same 30 queries with the site's real Fuse.js config, for the
191
+ # keyword baseline and the RRF-fused hybrid arms.
192
+ node node/rank_fuse.mjs examples/degoe-de/index.json examples/degoe-de/queries.yaml build/fuse-ranks.json
193
+
194
+ # Assemble every arm (potion swept over dims/chunking/title-prefix, plus the
195
+ # Node arms above) into the format evaluate.py reads.
196
+ uv run python -m sss_eval.build_arms \
197
+ --corpus examples/degoe-de/corpus \
198
+ --queries examples/degoe-de/queries.yaml \
199
+ --manifest build/arms.json
200
+
201
+ # Score every arm, semantic-only and RRF-fused with the keyword baseline,
202
+ # against the 30-query eval set, and apply the pre-registered ship rule
203
+ # (smallest download within 0.03 recall@3 of the best arm; MRR breaks ties).
204
+ uv run python -m sss_eval.evaluate \
205
+ --queries examples/degoe-de/queries.yaml \
206
+ --arms build/arms.json \
207
+ --keyword-ranks build/fuse-ranks.json \
208
+ --json build/results.json
209
+ ```
210
+
211
+ Node is only needed to reproduce the MiniLM/ternlight/keyword arms of the
212
+ eval; it is not a dependency of `sss-eval build` or of using the artifacts in
213
+ a browser.
214
+
215
+ ## Results
216
+
217
+ Winner: **`potion-base-8M`, PCA-truncated to 128 dims, 600-char chunks with 120
218
+ overlap, title-prefixed**, RRF-fused with a keyword ranking (Fuse.js, already
219
+ shipped with the site).
220
+
221
+ | | download | recall@1 | recall@3 | MRR@10 |
222
+ |---|---|---|---|---|
223
+ | potion-base-8M (128d, 600/120, title-prefixed) + RRF | 4.21 MB | 0.717 | 0.967 | 0.944 |
224
+ | MiniLM-L6-v2, ONNX q8 | 23.10 MB | 0.700 | — | — |
225
+
226
+ First-query download of 4,205,974 bytes (4.21 MB) is the `int8` token table
227
+ plus its `float32` per-row scales plus the JSON vocabulary — the three files a
228
+ browser needs before it can embed a single query. The document index itself,
229
+ for the 13-post corpus in `examples/degoe-de/`, is 313 chunks × 128 `int8`
230
+ dims = 40 KB, fetched once and cached alongside the rest of the site's static
231
+ assets.
232
+
233
+ <!-- TODO: link the blog post once published -->
234
+
235
+ ## License
236
+
237
+ The code in this repository is MIT licensed (see [`LICENSE`](LICENSE)).
238
+ `examples/degoe-de/corpus/` contains Bart de Goede's blog posts, included as
239
+ evaluation data for the eval above — that content is copyright Bart de Goede
240
+ and is not covered by the MIT license.
@@ -0,0 +1,15 @@
1
+ # Corpus snapshot: bart.degoe.de
2
+
3
+ - **Source:** https://github.com/bartdegoede/blog — `content/post/`
4
+ - **Commit:** dc70bff724340d88caf2838c39646431099aaf85
5
+ - **Taken:** 2026-07-09
6
+ - **Posts:** 13
7
+ - **Size:** 196 KB
8
+
9
+ Frozen deliberately. The benchmark in the accompanying blog post is measured against exactly
10
+ these files, and re-running the eval on a newer corpus would produce different numbers than the
11
+ post reports. Do not refresh this snapshot to "keep it current" — that is the opposite of what
12
+ it is for.
13
+
14
+ The posts are copyright Bart de Goede and are included here as evaluation data. The code in
15
+ this repository is MIT licensed; the posts are not.