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.
- static_site_search_eval-0.1.0/.github/workflows/ci.yml +18 -0
- static_site_search_eval-0.1.0/.github/workflows/publish.yml +19 -0
- static_site_search_eval-0.1.0/.gitignore +8 -0
- static_site_search_eval-0.1.0/LICENSE +21 -0
- static_site_search_eval-0.1.0/PKG-INFO +260 -0
- static_site_search_eval-0.1.0/README.md +240 -0
- static_site_search_eval-0.1.0/examples/degoe-de/SNAPSHOT.md +15 -0
- static_site_search_eval-0.1.0/examples/degoe-de/corpus/2018-03-02-searching-your-hugo-site-with-lunr.md +268 -0
- static_site_search_eval-0.1.0/examples/degoe-de/corpus/2018-03-22-bloom-filters-and-bitcoin.md +152 -0
- 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
- 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
- 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
- static_site_search_eval-0.1.0/examples/degoe-de/corpus/2019-07-12-use-output-format-for-index-generation.md +51 -0
- 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
- static_site_search_eval-0.1.0/examples/degoe-de/corpus/2021-03-24-python-full-text-search-engine.md +354 -0
- static_site_search_eval-0.1.0/examples/degoe-de/corpus/2025-10-26-migrating-a-hugo-blog-with-claude-code.md +167 -0
- static_site_search_eval-0.1.0/examples/degoe-de/corpus/2026-02-09-modernizing-python-search-engine.md +206 -0
- static_site_search_eval-0.1.0/examples/degoe-de/corpus/2026-02-09-semantic-search-python.md +305 -0
- static_site_search_eval-0.1.0/examples/degoe-de/corpus/2026-02-19-ai-agent-dungeon-crawlerverse-sdk.md +334 -0
- static_site_search_eval-0.1.0/examples/degoe-de/corpus/2026-06-29-running-a-coding-agent-locally.md +116 -0
- static_site_search_eval-0.1.0/examples/degoe-de/index.json +1 -0
- static_site_search_eval-0.1.0/examples/degoe-de/queries.yaml +161 -0
- static_site_search_eval-0.1.0/node/build_minilm.mjs +30 -0
- static_site_search_eval-0.1.0/node/build_ternlight.mjs +38 -0
- static_site_search_eval-0.1.0/node/rank_fuse.mjs +52 -0
- static_site_search_eval-0.1.0/package.json +11 -0
- static_site_search_eval-0.1.0/pnpm-lock.yaml +691 -0
- static_site_search_eval-0.1.0/pyproject.toml +48 -0
- static_site_search_eval-0.1.0/src/sss_eval/__init__.py +3 -0
- static_site_search_eval-0.1.0/src/sss_eval/anchors.py +34 -0
- static_site_search_eval-0.1.0/src/sss_eval/artifacts.py +88 -0
- static_site_search_eval-0.1.0/src/sss_eval/build.py +115 -0
- static_site_search_eval-0.1.0/src/sss_eval/build_arms.py +324 -0
- static_site_search_eval-0.1.0/src/sss_eval/cache.py +50 -0
- static_site_search_eval-0.1.0/src/sss_eval/chunk.py +88 -0
- static_site_search_eval-0.1.0/src/sss_eval/cli.py +88 -0
- static_site_search_eval-0.1.0/src/sss_eval/corpus.py +19 -0
- static_site_search_eval-0.1.0/src/sss_eval/dump_chunks.py +38 -0
- static_site_search_eval-0.1.0/src/sss_eval/evaluate.py +325 -0
- static_site_search_eval-0.1.0/src/sss_eval/markdown.py +130 -0
- static_site_search_eval-0.1.0/src/sss_eval/metrics.py +55 -0
- static_site_search_eval-0.1.0/src/sss_eval/post.py +75 -0
- static_site_search_eval-0.1.0/src/sss_eval/quantize.py +44 -0
- static_site_search_eval-0.1.0/src/sss_eval/rank.py +71 -0
- static_site_search_eval-0.1.0/src/sss_eval/verify_anchors.py +76 -0
- static_site_search_eval-0.1.0/tests/__init__.py +0 -0
- static_site_search_eval-0.1.0/tests/fixtures/corpus/2020-01-01-alpha.md +15 -0
- static_site_search_eval-0.1.0/tests/fixtures/corpus/2020-02-02-beta.md +18 -0
- static_site_search_eval-0.1.0/tests/fixtures/corpus/2020-03-03-gamma-file-name-differs.md +7 -0
- static_site_search_eval-0.1.0/tests/test_anchors.py +26 -0
- static_site_search_eval-0.1.0/tests/test_artifacts.py +186 -0
- static_site_search_eval-0.1.0/tests/test_build.py +89 -0
- static_site_search_eval-0.1.0/tests/test_build_arms.py +110 -0
- static_site_search_eval-0.1.0/tests/test_cache.py +114 -0
- static_site_search_eval-0.1.0/tests/test_chunk.py +139 -0
- static_site_search_eval-0.1.0/tests/test_cli.py +43 -0
- static_site_search_eval-0.1.0/tests/test_corpus.py +39 -0
- static_site_search_eval-0.1.0/tests/test_evaluate.py +135 -0
- static_site_search_eval-0.1.0/tests/test_markdown.py +183 -0
- static_site_search_eval-0.1.0/tests/test_metrics.py +80 -0
- static_site_search_eval-0.1.0/tests/test_post.py +138 -0
- static_site_search_eval-0.1.0/tests/test_quantize.py +78 -0
- static_site_search_eval-0.1.0/tests/test_queries.py +43 -0
- static_site_search_eval-0.1.0/tests/test_rank.py +117 -0
- static_site_search_eval-0.1.0/tests/test_verify_anchors.py +48 -0
- 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,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.
|