pikelet-wasm 0.8.0 → 0.8.1
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.
- package/CHANGELOG.md +321 -1
- package/README.md +65 -257
- package/complete/builder.mjs +18 -1
- package/complete/fusion.mjs +72 -0
- package/complete/index.d.ts +23 -0
- package/complete/index.mjs +100 -20
- package/complete/inline-transformer.mjs +97 -1
- package/complete/retrieval-abstention.mjs +118 -25
- package/complete/sources.mjs +1 -1
- package/complete/wordpiece.mjs +22 -0
- package/dist/engine.js +1 -1
- package/dist/engine.scalar.js +1 -1
- package/dist/engine.scalar.wasm +0 -0
- package/dist/engine.wasm +0 -0
- package/package.json +35 -77
- package/{pikelet-artifact-common.js → src/artifact/pikelet-artifact-common.js} +1 -1
- package/{pikelet-artifact-range.js → src/artifact/pikelet-artifact-range.js} +8 -1
- package/{pikelet-artifact-sketch.js → src/artifact/pikelet-artifact-sketch.js} +30 -3
- package/{pikelet-artifact.d.ts → src/artifact/pikelet-artifact.d.ts} +1 -1
- package/{pikelet-core.js → src/core/pikelet-core.js} +6 -5
- package/{pikelet.js → src/pikelet.js} +7 -7
- package/{pikelet.node.mjs → src/pikelet.node.mjs} +7 -7
- package/{pikelet.web.mjs → src/pikelet.web.mjs} +8 -8
- package/{pikelet.workerd.mjs → src/pikelet.workerd.mjs} +8 -8
- /package/{pikelet-artifact.js → src/artifact/pikelet-artifact.js} +0 -0
- /package/{pikelet-errors.js → src/errors/pikelet-errors.js} +0 -0
- /package/{pikelet-loader.js → src/loader/pikelet-loader.js} +0 -0
- /package/{pikelet.d.ts → src/pikelet.d.ts} +0 -0
- /package/{pikelet.web.d.ts → src/pikelet.web.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ A model can interrogate a 456,153-record knowledge base whose backend is a stati
|
|
|
8
8
|
|
|
9
9
|
A `.pikelet` can carry the source text, semantic index, keyword index, query encoder, integrity commitments, retrieval calibration, and evaluation fixtures needed to interrogate that corpus. Put the file on disk, S3, R2, a CDN, or any static HTTP host. A reader can mount it locally or over HTTP Range and search it without a vector database, embedding API, or retrieval server.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
The `pikelet` CLI requires Node 20+; the `pikelet-wasm` library runs on Node 18+ (CI tests 18, 20, 22), browsers, and Cloudflare Workers.
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
npx pikelet compile --source ./docs --out docs.pikelet
|
|
@@ -47,7 +47,7 @@ console.log(out.matchQuality, out.results[0]?.title);
|
|
|
47
47
|
// 'strong' 'Snapshot restore'
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
That's the whole loop. `compile` also takes a live URL (`--source https://docs.example.com`) instead of a directory. If you want a deployed search app — a Worker + UI, not a file — use `npx pikelet create` instead; see [`pikelet/README.md`](pikelet/README.md) for the full CLI reference and the tradeoffs between the two. `compile` never needs the scaffold path's `@xenova/transformers` dependency; skip its ~140 MB install with `npm install -g pikelet --omit=optional` if you only need `compile`/`mcp`.
|
|
50
|
+
That's the whole loop. `compile` also takes a live URL (`--source https://docs.example.com`) instead of a directory. If you want a deployed search app — a Worker + UI, not a file — use `npx pikelet create` instead; see [`packages/pikelet/README.md`](packages/pikelet/README.md) for the full CLI reference and the tradeoffs between the two. `compile` never needs the scaffold path's `@xenova/transformers` dependency; skip its ~140 MB install with `npm install -g pikelet --omit=optional` if you only need `compile`/`mcp`.
|
|
51
51
|
|
|
52
52
|
Under the hood, the file is one container for everything a reader needs:
|
|
53
53
|
|
|
@@ -82,6 +82,22 @@ Search is the interface. **The file is the knowledge deployment unit.**
|
|
|
82
82
|
|
|
83
83
|
A real Pikelet pack contains **456,153 Simple English Wikipedia records** in a **648.5 MiB** artifact.
|
|
84
84
|
|
|
85
|
+
Try the live browser demo:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
https://pikelet-wiki-playground.pages.dev/
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The page is static HTML/JS on Cloudflare Pages. It mounts the pack from R2:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
https://pub-6da2384a3bca4a44b2b2fa29a94cc811.r2.dev/wikipedia.pikelet
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
There is no `/search`, `/embed`, `/query`, or `/api` backend behind the demo.
|
|
98
|
+
The browser opens the `.pikelet` file over HTTP Range, verifies the artifact,
|
|
99
|
+
loads the embedded query encoder, and shows the byte ranges it reads.
|
|
100
|
+
|
|
85
101
|
That file was served by a deliberately dumb HTTP server whose only relevant capability was:
|
|
86
102
|
|
|
87
103
|
```text
|
|
@@ -131,7 +147,7 @@ That is the product in one demonstration:
|
|
|
131
147
|
|
|
132
148
|
> **A model is interrogating a 456,153-record knowledge base whose backend is a static file.**
|
|
133
149
|
|
|
134
|
-
The model may already contain some of these facts in its pretrained parameters. This test demonstrates the retrieval, synthesis, citation, and deployment path; the Veyra ablation
|
|
150
|
+
The model may already contain some of these facts in its pretrained parameters. This test demonstrates the retrieval, synthesis, citation, and deployment path; the [Veyra ablation](docs/veyra-ablation.md) tests whether support changes when evidence is removed from the pack.
|
|
135
151
|
|
|
136
152
|
**Network cost.** In a single persistent session (one mount, five queries, one repeat):
|
|
137
153
|
|
|
@@ -154,97 +170,6 @@ The headless-Claude test above used a fresh process per question, so each invoca
|
|
|
154
170
|
|
|
155
171
|
---
|
|
156
172
|
|
|
157
|
-
## What happens if the answer is changed or removed from the file?
|
|
158
|
-
|
|
159
|
-
This is different from asking whether search returns sensible documents. To test whether Pikelet's retrieval-quality signal could form a useful evidence boundary, a synthetic corpus called **Station Veyra Registry** was built. One version contained the fact:
|
|
160
|
-
|
|
161
|
-
```text
|
|
162
|
-
The Tovash project is housed in Chamber 17.
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
A second pack was byte-for-byte identical except that the record containing that fact was removed.
|
|
166
|
-
|
|
167
|
-
```text
|
|
168
|
-
full pack: matchQuality: strong confidence: 0.915 → Chamber 17
|
|
169
|
-
ablated pack: matchQuality: none confidence: 0.136 → unsupported
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
After the model had already seen the answer, it was queried against the ablated pack with prompts like "Confirm Tovash is in Chamber 17" and "Tovash project Chamber 17 location." The retrieval result stayed unsupported and the model declined to confirm the location from the pack. A separate, informal session then probed a fresh isolated agent with neutral prompts, leading prompts, authority pressure, invitations to use general knowledge, cross-record distractors, and repeated pressure — six adversarial framings — and it continued distinguishing supported facts from the removed one in every case; that session wasn't captured as a script, so treat it as a described observation rather than a reproducible result.
|
|
173
|
-
|
|
174
|
-
This does **not** mean Pikelet can prevent an LLM from hallucinating. It means the artifact can expose an explicit evidence boundary that a consuming model can choose to respect — and removing evidence from the artifact changed what that model was able to support from the mounted source.
|
|
175
|
-
|
|
176
|
-
A second intervention changed only the Tovash location source record from **Chamber 17** to **Chamber 43**, rebuilt the pack, and repeated the same prompt in a fresh session:
|
|
177
|
-
|
|
178
|
-
```text
|
|
179
|
-
chamber43 pack: matchQuality: strong confidence: 0.916 → Chamber 43
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
The model answered **Chamber 43** and cited the same logical source record. With Veyra not mounted at all, the same prompt produced no chamber number and the model declined to guess. Change the evidence and the grounded answer changes with it; remove the evidence source and the answer disappears.
|
|
183
|
-
|
|
184
|
-
The test is synthetic and intentionally narrow. All three packs and the `matchQuality`/`confidence` numbers above are reproducible: `node examples/05-one-file-search/web/public/reproduce-ablation.mjs`. The retrieval side is scripted; the paired LLM-session claims (declining to confirm the removed fact, answering Chamber 43, declining without the pack mounted) were run separately and aren't reproduced by that script.
|
|
185
|
-
|
|
186
|
-
---
|
|
187
|
-
|
|
188
|
-
## What does the compression cost?
|
|
189
|
-
|
|
190
|
-
The artifact architecture is only useful if the compact representation does not quietly destroy retrieval. The retrieval path was decomposed on BEIR rather than evaluated only end-to-end, across four configurations that isolate each transformation:
|
|
191
|
-
|
|
192
|
-
```text
|
|
193
|
-
A upstream all-MiniLM-L6-v2, float32 corpus, exhaustive search
|
|
194
|
-
B Pikelet embedded encoder, float32 corpus, exhaustive search
|
|
195
|
-
C Pikelet embedded encoder, affine-u8 corpus, exhaustive search
|
|
196
|
-
D Pikelet embedded encoder, affine-u8 corpus, Pikelet HNSW
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
Corpus mapping and evaluation config were frozen before the runs. Official BEIR qrels are used; no LLM judges relevance.
|
|
200
|
-
|
|
201
|
-
| Dataset | Config | nDCG@10 | Recall@10 | Recall@100 | median ms/q | p95 ms/q |
|
|
202
|
-
| -------- | ------------------------------------------ | ---------: | ---------: | ---------: | ----------: | -------: |
|
|
203
|
-
| SciFact | A — upstream float exhaustive | 0.6451 | 0.7833 | 0.9250 | 3.6 | 4.0 |
|
|
204
|
-
| SciFact | B — Pikelet encoder / float exhaustive | 0.6512 | 0.7942 | 0.9417 | 3.8 | 4.4 |
|
|
205
|
-
| SciFact | C — Pikelet encoder / affine-u8 exhaustive | 0.6500 | 0.7942 | 0.9417 | 6.9 | 8.7 |
|
|
206
|
-
| SciFact | D — Pikelet encoder / affine-u8 HNSW | **0.6500** | **0.7942** | **0.9417** | **0.15** | **0.24** |
|
|
207
|
-
| NFCorpus | A — upstream float exhaustive | 0.3159 | 0.1550 | 0.3115 | 2.6 | 3.1 |
|
|
208
|
-
| NFCorpus | B — Pikelet encoder / float exhaustive | 0.3154 | 0.1511 | 0.3044 | 2.8 | 3.3 |
|
|
209
|
-
| NFCorpus | C — Pikelet encoder / affine-u8 exhaustive | 0.3154 | 0.1511 | 0.3036 | 2.9 | 3.6 |
|
|
210
|
-
| NFCorpus | D — Pikelet encoder / affine-u8 HNSW | **0.3135** | **0.1482** | **0.3061** | **0.16** | **0.25** |
|
|
211
|
-
| ArguAna | A — upstream float exhaustive | 0.3698 | 0.7653 | 0.9772 | 7.8 | 10.8 |
|
|
212
|
-
| ArguAna | B — Pikelet encoder / float exhaustive | 0.3506 | 0.7397 | 0.9801 | 6.1 | 7.3 |
|
|
213
|
-
| ArguAna | C — Pikelet encoder / affine-u8 exhaustive | 0.3496 | 0.7368 | 0.9801 | 6.4 | 7.5 |
|
|
214
|
-
| ArguAna | D — Pikelet encoder / affine-u8 HNSW | **0.3496** | **0.7368** | **0.9808** | **0.12** | **0.20** |
|
|
215
|
-
|
|
216
|
-
These results are more useful because they are not uniformly flattering.
|
|
217
|
-
|
|
218
|
-
**Affine-u8 storage is not the main quality cost** (B→C nDCG@10: SciFact 0.6512→0.6500, NFCorpus 0.3154→0.3154, ArguAna 0.3506→0.3496). **HNSW is similarly close to exhaustive search** at these settings (C→D: SciFact and ArguAna unchanged at reported precision; NFCorpus exposes a small approximation loss, 0.3154→0.3135).
|
|
219
|
-
|
|
220
|
-
**The largest observed loss is the embedded query encoder on ArguAna**: A→B moves nDCG@10 from 0.3698 to 0.3506, about a 5% relative reduction. Recall@100 actually improves slightly (0.9772→0.9801) — the relevant document is generally still in the candidate set; the degradation is in fine ordering near the top. That limitation isn't hidden: MiniLM is small *because* the goal is to fit the query encoder inside the artifact. It is not state of the art, and the compact implementation is not behaviorally identical to an upstream sentence-transformers runtime on every task.
|
|
221
|
-
|
|
222
|
-
The benchmark harness, quantization-conformance test, frozen configuration, and raw runs live under `bench/beir/`.
|
|
223
|
-
|
|
224
|
-
---
|
|
225
|
-
|
|
226
|
-
## Why make knowledge a file?
|
|
227
|
-
|
|
228
|
-
The conventional retrieval deployment looks something like:
|
|
229
|
-
|
|
230
|
-
```text
|
|
231
|
-
documents → chunking → embedding service → vector database
|
|
232
|
-
→ keyword database
|
|
233
|
-
→ retrieval service → application API → agent
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
That is the correct architecture for many workloads. But it is a lot of machinery when the corpus is fundamentally a release artifact: product documentation, a manual, a source tree, a legal code, a standards corpus, a research collection, a book, a knowledge snapshot, an offline reference set. These datasets often change daily, weekly, monthly, or with releases — not hundreds of times per second.
|
|
237
|
-
|
|
238
|
-
For those workloads, Pikelet asks a different question:
|
|
239
|
-
|
|
240
|
-
> **What if retrieval could be compiled ahead of time and distributed with the corpus?**
|
|
241
|
-
|
|
242
|
-
Then deployment becomes: copy the file, cache it, pin it, put it behind a CDN, mount it by URL. The storage layer does not need to know that the file contains vectors.
|
|
243
|
-
|
|
244
|
-
It is not a replacement for a mutable vector database. It is an attempt to make **static and slowly changing knowledge deploy like any other artifact**.
|
|
245
|
-
|
|
246
|
-
---
|
|
247
|
-
|
|
248
173
|
## Attach a pack to an LLM
|
|
249
174
|
|
|
250
175
|
`pikelet mcp` exposes one or more packs through the Model Context Protocol:
|
|
@@ -261,55 +186,29 @@ A pack mounted with a content hash has a stable identity — `https://example.co
|
|
|
261
186
|
|
|
262
187
|
**A mounted pack's content reaches the model as tool output.** `verify_pack` proves the bytes are intact and match their pinned identity; it does not prove the corpus itself is trustworthy. Mounting a pack from a source you don't control is the same trust decision as giving an agent any other untrusted-content tool — treat pack text the way you'd treat search results or fetched web pages, not as instructions.
|
|
263
188
|
|
|
264
|
-
**Try it with your own questions.** The three Veyra packs from the ablation
|
|
189
|
+
**Try it with your own questions.** The three Veyra packs from the [ablation](docs/veyra-ablation.md) are published as pinned GitHub release assets. Fetch and verify them first:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npm run demo:veyra
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Then mount all three through Pikelet's MCP server:
|
|
265
196
|
|
|
266
197
|
```bash
|
|
267
198
|
claude -p "your question here" \
|
|
268
|
-
--mcp-config examples/
|
|
199
|
+
--mcp-config examples/one-file-search/web/public/veyra.mcp.json \
|
|
269
200
|
--strict-mcp-config \
|
|
270
201
|
--allowedTools "mcp__veyra-demo__search,mcp__veyra-demo__list_packs,mcp__veyra-demo__get_record"
|
|
271
202
|
```
|
|
272
203
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
---
|
|
276
|
-
|
|
277
|
-
## How a remote query runs
|
|
278
|
-
|
|
279
|
-
Over a network, what makes search slow is not bytes; it's sequential round trips. Graph traversal is a chain of dependent reads — fetch node, inspect neighbors, fetch the next node, repeat — and across object storage those dependent round trips dominate. Measured against SIFT1M over real network storage, a resident scan plus one batched candidate-fetch phase beat graph traversal by about 5× at equal recall. So the remote artifact path carries no graph. The HNSW graph still exists — it's the in-memory engine, used when the whole index is local and round trips are free; that's the path BEIR Config D measures.
|
|
280
|
-
|
|
281
|
-
For the remote path:
|
|
282
|
-
|
|
283
|
-
1. **Resident tier** — a pooled sketch of every semantic row loads once when the artifact opens and stays in memory. For the 456,153-record Wikipedia pack: 648.5 MiB artifact, 51.7 MiB mount fetch. A SIMD kernel scans all of it in milliseconds.
|
|
284
|
-
2. **Candidate selection** — the scan picks the top `C` candidates against the query vector; the lexical BM25 index can contribute additional candidates into the same set. `C` is the only real knob: the compiler measures recall against brute force on held-out queries at build time and writes the operating point into the file, so readers don't have to guess it.
|
|
285
|
-
3. **Batched byte-range fetch** — full affine-u8 rows for the candidate set are fetched in one parallel round, nearby ranges coalesced, rather than a graph-dependent network walk.
|
|
286
|
-
4. **Full rerank** — fetched rows are scored against the float query and verified against their digest.
|
|
287
|
-
5. **Corpus hydration** — only the records needed for returned results are fetched, each carrying record ID, title, section, source, and artifact identity. The reader returns evidence, not anonymous vector IDs.
|
|
288
|
-
|
|
289
|
-
That architecture is why a 648.5 MiB pack can answer a fresh query while fetching about 0.5–1.1 MiB after warmup.
|
|
290
|
-
|
|
291
|
-
---
|
|
204
|
+
For an interactive session, use the same config without `-p`:
|
|
292
205
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
One exception: a lexical (BM25) segment above 8 MiB opens lazily and is covered only by the manifest's whole-segment digest, not per-read like index rows and corpus records — the same transitional stance format-1 sketch rows carry. A pack large enough for this to apply can have its lexical candidates altered between open and a full verification pass without failing a query.
|
|
298
|
-
|
|
299
|
-
The failure-mode suite (`bench/range-proof/failure-modes.mjs`) exercises the important cases:
|
|
300
|
-
|
|
301
|
-
```text
|
|
302
|
-
correct Range server → mounts, queries successfully
|
|
303
|
-
server ignores Range → small files fall back to a bounded download;
|
|
304
|
-
large files refuse ("host ignores Range and the
|
|
305
|
-
file is 680029254 bytes; refusing full download" —
|
|
306
|
-
the cap is currently 64 MiB)
|
|
307
|
-
bytes change under a pinned identity → refused; tampered data is not silently served
|
|
308
|
-
truncated Range response → fetch fails; no partial result is silently
|
|
309
|
-
interpreted as valid data
|
|
206
|
+
```bash
|
|
207
|
+
claude \
|
|
208
|
+
--mcp-config examples/one-file-search/web/public/veyra.mcp.json
|
|
310
209
|
```
|
|
311
210
|
|
|
312
|
-
|
|
211
|
+
All three packs derive from the same small synthetic Station Veyra corpus, with controlled differences in the Tovash evidence. Ask where the Tovash project is housed: the full pack supports Chamber 17, the modified pack supports Chamber 43, and the ablated pack should abstain.
|
|
313
212
|
|
|
314
213
|
---
|
|
315
214
|
|
|
@@ -321,94 +220,12 @@ A nearest neighbour is not automatically evidence that a corpus answers a questi
|
|
|
321
220
|
|
|
322
221
|
---
|
|
323
222
|
|
|
324
|
-
##
|
|
325
|
-
|
|
326
|
-
```text
|
|
327
|
-
manifest format versions, segment offsets, artifact identity
|
|
328
|
-
semantic index resident sketch rows, full affine-u8 rows, row commitments
|
|
329
|
-
corpus source records, offsets, per-record commitments
|
|
330
|
-
query encoder WordPiece vocabulary, quantized MiniLM weights, encoder declaration
|
|
331
|
-
lexical index BM25 postings
|
|
332
|
-
calibration supported / unsupported retrieval model
|
|
333
|
-
evaluation golden queries / expected behavior
|
|
334
|
-
```
|
|
223
|
+
## Deeper dives
|
|
335
224
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
---
|
|
341
|
-
|
|
342
|
-
## One decision
|
|
343
|
-
|
|
344
|
-
Most of the implementation follows from how a vector is stored. Each row is 8-bit integers plus two floats:
|
|
345
|
-
|
|
346
|
-
```text
|
|
347
|
-
x[d] ≈ offset + scale · q[d] q[d] ∈ 0..255
|
|
348
|
-
```
|
|
349
|
-
|
|
350
|
-
That's a per-row affine map. It costs 4× less memory than float32, which is the ordinary reason to do it. The reason it runs through the whole project is that every operation search needs — dot products, sums, averages — is linear, and affine terms factor out of linear operations.
|
|
351
|
-
|
|
352
|
-
**Query against a stored row.** The query stays float32 and is never quantized.
|
|
353
|
-
|
|
354
|
-
```text
|
|
355
|
-
y·x = offset·Σy + scale·(y·q)
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
The inner loop multiplies floats by bytes. The two constants come in once at the end. No decompressed copy of the row ever exists.
|
|
359
|
-
|
|
360
|
-
**Stored row against stored row.** This is what graph construction needs, thousands of times per insert.
|
|
361
|
-
|
|
362
|
-
```text
|
|
363
|
-
x_i·x_j = D·o_i·o_j + o_i·s_j·Σq_j + o_j·s_i·Σq_i + s_i·s_j·(q_i·q_j)
|
|
364
|
-
```
|
|
365
|
-
|
|
366
|
-
`D` is the vector's dimensionality (384 for the bundled encoder). `q_i·q_j` is an integer dot over two byte arrays, which maps efficiently onto SIMD. `Σq` is stored per row. The HNSW graph is built and repaired entirely on compressed data.
|
|
367
|
-
|
|
368
|
-
**Pooling.** Average adjacent groups of `p` bytes and you get a shorter row. Because the mean of `offset + scale·q` over a group equals `offset + scale·mean(q)`, the shorter row keeps the *same two constants*. That's the resident sketch tier; a micro tier is the same thing done twice.
|
|
369
|
-
|
|
370
|
-
**Weights.** The bundled query encoder is a 6-layer MiniLM whose matrices are stored the same way, one (scale, offset) per 64-column block. Activations stay float32, weights stay bytes, dequantization happens inside the matmul — the same widen-and-multiply trick, a separate kernel, that scores vectors.
|
|
371
|
-
|
|
372
|
-
The consequence for the file format: a row decodes from its own bytes and its own two floats and nothing else. No codebook, no global statistics. Every row is a fixed-size byte range at a computable offset — locatable by arithmetic, fetchable on its own, hashable on its own, verifiable on the read that fetches it.
|
|
373
|
-
|
|
374
|
-
**Why not product quantization.** Product quantization would compress harder. It would also need a codebook, a training pass, table lookups in the distance kernel, and rows that mean nothing without the codebook. Four-to-one with no shared state was the better trade for a file meant to be read in pieces.
|
|
375
|
-
|
|
376
|
-
---
|
|
377
|
-
|
|
378
|
-
## Build once, publish anywhere
|
|
379
|
-
|
|
380
|
-
```bash
|
|
381
|
-
npx pikelet compile --source ./docs --out docs.pikelet
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
```js
|
|
385
|
-
import { openPikeletFile } from 'pikelet-wasm/complete';
|
|
386
|
-
|
|
387
|
-
const pack = await openPikeletFile('https://example.com/docs.pikelet#<sha256>');
|
|
388
|
-
const result = await pack.query('how do workers restore snapshots', { k: 5 });
|
|
389
|
-
|
|
390
|
-
console.log(result.matchQuality);
|
|
391
|
-
for (const hit of result.results) console.log(hit.title, hit.section);
|
|
392
|
-
|
|
393
|
-
await pack.close();
|
|
394
|
-
```
|
|
395
|
-
|
|
396
|
-
The same artifact can also be opened from a local path or any custom source implementing `{ size, read(offset, length) }`.
|
|
397
|
-
|
|
398
|
-
**Or use the search engine directly**, if you already have vectors and don't need the artifact layer:
|
|
399
|
-
|
|
400
|
-
```js
|
|
401
|
-
import Pikelet from 'pikelet-wasm';
|
|
402
|
-
|
|
403
|
-
const index = await Pikelet.create({ dim: 384, maxElements: 100000, metric: 'cosine', quantized: true });
|
|
404
|
-
index.add(vector);
|
|
405
|
-
const results = index.search(query, 10);
|
|
406
|
-
const snapshot = index.export();
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
float32 HNSW, affine-u8 HNSW, insert/delete, compaction, import/export, deterministic snapshots, WASM with no native addon dependency. Pikelet is usable as a vector engine — that's just no longer the main reason the project exists.
|
|
410
|
-
|
|
411
|
-
---
|
|
225
|
+
- [How a `.pikelet` query runs](docs/how-a-query-runs.md) — what is inside the file, what a query costs on the wire, remote execution, and the integrity checks on the read path.
|
|
226
|
+
- [The Veyra ablation](docs/veyra-ablation.md) — what happens to answers when the supporting record is edited or removed from the pack, with the reproduction commands.
|
|
227
|
+
- [Why make knowledge a file](docs/why-a-file.md) — the reasoning behind the single-artifact design, and what it commits to.
|
|
228
|
+
- [Architecture](docs/architecture.md) — the engine, the C ABI and the JavaScript wrapper; the artifact formats are specified in [`spec/`](spec/).
|
|
412
229
|
|
|
413
230
|
## What this is not
|
|
414
231
|
|
|
@@ -422,21 +239,6 @@ float32 HNSW, affine-u8 HNSW, insert/delete, compaction, import/export, determin
|
|
|
422
239
|
|
|
423
240
|
---
|
|
424
241
|
|
|
425
|
-
## Why the file format matters
|
|
426
|
-
|
|
427
|
-
A retrieval service is identified by an endpoint. A Pikelet is identified by its contents.
|
|
428
|
-
|
|
429
|
-
- **Reproducibility** — pack hash + record ID identifies the exact evidence a model retrieved.
|
|
430
|
-
- **Versioning** — different releases are different artifacts (`docs-v1.pikelet`, `docs-v2.pikelet`); no silent mutation.
|
|
431
|
-
- **Distribution** — a pack can be mirrored by infrastructure that knows nothing about semantic search.
|
|
432
|
-
- **Offline use** — once local, no network or embedding service needed.
|
|
433
|
-
- **Customer-controlled knowledge** — hand someone the artifact instead of granting access to internal retrieval infrastructure.
|
|
434
|
-
- **Agent knowledge environments** — run a task against a frozen snapshot, reproduce it later against the same bytes.
|
|
435
|
-
|
|
436
|
-
> **Can knowledge become a first-class software artifact rather than something that always has to live behind a service?**
|
|
437
|
-
|
|
438
|
-
---
|
|
439
|
-
|
|
440
242
|
## When Pikelet is a good fit — and when it isn't
|
|
441
243
|
|
|
442
244
|
**Use it when:**
|
|
@@ -468,23 +270,37 @@ The resident remote scan is linear in row count. The current architecture target
|
|
|
468
270
|
## Repository map
|
|
469
271
|
|
|
470
272
|
```text
|
|
471
|
-
|
|
273
|
+
packages/pikelet-wasm/ The published pikelet-wasm package:
|
|
274
|
+
engine/ C++ vector engine (HNSW, float32 and
|
|
472
275
|
affine-u8 backends, mutation, compaction,
|
|
473
|
-
snapshot import/export
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
276
|
+
snapshot import/export), compiled to WASM
|
|
277
|
+
in dist/. Used at compile time to build and
|
|
278
|
+
quantize the index; query time does not load
|
|
279
|
+
it (see complete/index.mjs's header comment)
|
|
280
|
+
— .pikelet reads run a pure-JS sketch scan,
|
|
281
|
+
optionally SIMD-accelerated.
|
|
282
|
+
src/ Engine wrapper and artifact readers/builders
|
|
283
|
+
(the entrypoints, core/, artifact/, errors/)
|
|
284
|
+
complete/ Reader and builder for the complete
|
|
285
|
+
range-readable .pikelet artifact
|
|
286
|
+
native/ N-API build of the same engine (benchmarks)
|
|
287
|
+
packages/pikelet/ The published pikelet CLI: compiler, MCP
|
|
288
|
+
server, encoder integration, Docusaurus plugin
|
|
289
|
+
docs/ Deeper dives (how a query runs, the Veyra
|
|
290
|
+
ablation, why a file), architecture notes,
|
|
291
|
+
rename history, measurement reports
|
|
478
292
|
spec/ Byte-level artifact contracts
|
|
479
|
-
|
|
293
|
+
benchmarks/beir/ Frozen BEIR ablation harness (encoder,
|
|
480
294
|
quantization, HNSW quality)
|
|
481
|
-
|
|
295
|
+
benchmarks/range-proof/ The deliberately boring static-HTTP proof:
|
|
482
296
|
dumb-server.mjs, proof.mjs, mcp-proof.mjs,
|
|
483
297
|
llm-proof.mjs, failure-modes.mjs
|
|
484
|
-
examples/
|
|
298
|
+
examples/one-file-search/ Large single-artifact search and embedded
|
|
485
299
|
encoder work
|
|
486
|
-
examples/
|
|
300
|
+
examples/mcp-knowledge-pack/ Compile, mount, search, and hydrate
|
|
487
301
|
records through MCP
|
|
302
|
+
local-packs/ Prebuilt example .pikelet artifacts used
|
|
303
|
+
by the demo commands above
|
|
488
304
|
packs/ Pack hosting and distribution examples
|
|
489
305
|
```
|
|
490
306
|
|
|
@@ -492,9 +308,9 @@ packs/ Pack hosting and distribution examples
|
|
|
492
308
|
|
|
493
309
|
## Reproduce the proofs
|
|
494
310
|
|
|
495
|
-
**Range proof** (`
|
|
311
|
+
**Range proof** (`benchmarks/range-proof/`) is built around a server that does not understand Pikelet. It checks: static HTTP + Range + Pikelet client = remote semantic retrieval, without a full download. The main script reports artifact size, records, mount bytes/requests, per-query bytes/requests, cache behavior. `failure-modes.mjs` independently exercises Range supported / ignored / tampered bytes / truncated response. `mcp-proof.mjs` and `llm-proof.mjs` use a real MCP client and a real headless Claude process rather than a mocked model response.
|
|
496
312
|
|
|
497
|
-
**BEIR evaluation** (`
|
|
313
|
+
**BEIR evaluation** (`benchmarks/beir/`) keeps stages separate so a quality change can be attributed to the component that caused it, plus a standalone conformance fixture checking the JS benchmark path against the actual C++ affine-u8 representation. Don't read the latency columns as a universal native-performance comparison — they measure the benchmark paths under the stated harness. The quality deltas are the important part.
|
|
498
314
|
|
|
499
315
|
---
|
|
500
316
|
|
|
@@ -504,7 +320,7 @@ Pikelet is early. The implementation is real; the format is not frozen. One prim
|
|
|
504
320
|
|
|
505
321
|
- prebuilt WASM included
|
|
506
322
|
- local and HTTP readers
|
|
507
|
-
- affine-u8 and float HNSW backends
|
|
323
|
+
- affine-u8 and float HNSW backends for compile-time indexing/quantization
|
|
508
324
|
- embedded query encoder
|
|
509
325
|
- BM25 hybrid retrieval
|
|
510
326
|
- content identity and lazy-read integrity verification
|
|
@@ -516,18 +332,10 @@ Pikelet is early. The implementation is real; the format is not frozen. One prim
|
|
|
516
332
|
|
|
517
333
|
`npm test` exercises the engine, artifact profiles, MCP, ingestion, format hardening, and related conformance suites.
|
|
518
334
|
|
|
519
|
-
|
|
335
|
+
Previously known as **Pancake** (renamed September 2026); artifacts, profile strings and files from before the rename remain readable — see [`docs/history.md`](docs/history.md) for what kept the old name and why. Pikelet is unrelated to the pre-existing Pikelet programming language.
|
|
520
336
|
|
|
521
337
|
---
|
|
522
338
|
|
|
523
|
-
## The experiment
|
|
524
|
-
|
|
525
|
-
> **For static and slowly changing corpora, useful semantic retrieval can be compiled into the artifact being distributed instead of operated as a separate service.**
|
|
526
|
-
|
|
527
|
-
The implementation currently demonstrates: one file, 456,153 records, 648.5 MiB, embedded query encoder, semantic + lexical retrieval, content identity, per-record integrity, MCP mounting, static HTTP hosting, ~0.5–1.1 MiB fresh-query range traffic after warmup, real multi-record LLM synthesis, no retrieval backend.
|
|
528
|
-
|
|
529
|
-
There are many reasons a database remains the right answer. Pikelet exists for the cases where the knowledge itself should be something you can **build, hash, copy, cache, publish, mount, query, and keep.**
|
|
530
|
-
|
|
531
339
|
## License
|
|
532
340
|
|
|
533
341
|
Apache-2.0. See [LICENSE](LICENSE).
|
package/complete/builder.mjs
CHANGED
|
@@ -81,8 +81,25 @@ export function buildQueryInterpSegment(kind, encoderBytes, calibrationBytes) {
|
|
|
81
81
|
// and skips a small function-word stopword list — readers need no
|
|
82
82
|
// coordination with the list, because an unindexed query token simply finds
|
|
83
83
|
// no postings.
|
|
84
|
+
//
|
|
85
|
+
// Interrogative words (how/does/what/who/why/where/when/which/do/did) are
|
|
86
|
+
// deliberately in this list, not just the base one: without them, a
|
|
87
|
+
// natural-language question's own scaffolding becomes real, indexable BM25
|
|
88
|
+
// content — a passage containing literal dialogue like "How does Georgiana
|
|
89
|
+
// get on, Darcy?" then wins a high lexical score against the query "How
|
|
90
|
+
// does Elizabeth initially react to Mr. Darcy?" purely from "how does" and
|
|
91
|
+
// "darcy" co-occurring, with zero topical relevance. Confirmed live: this
|
|
92
|
+
// pushed a genuinely correct chunk (ranked #2 by the vector search alone)
|
|
93
|
+
// completely out of the fused top-10, replaced by RRF-boosted noise. Not
|
|
94
|
+
// borrowed wholesale from calibrate.mjs's COVERAGE_STOPWORDS, which also
|
|
95
|
+
// excludes domain words ("tell", "explain", "overview", "history",
|
|
96
|
+
// "definition") that are legitimate lexical CONTENT on many real corpora
|
|
97
|
+
// (a docs site literally titled "History") — this list only adds the
|
|
98
|
+
// unambiguous interrogative set, since the lexical index (unlike coverage)
|
|
99
|
+
// also indexes passage text, not just the query.
|
|
84
100
|
const LEXICAL_LAYOUT = 'bm25-v1';
|
|
85
|
-
const LEXICAL_STOPWORDS = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'by', 'for', 'with', 'from', 'into', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'it', 'its', 'this', 'that', 'these', 'those', 'as', 'their', 'they', 'them', 'his', 'her', 'has', 'have', 'had', 'not', 'can', 'will', 'if', 'we', 'you', 'your', 'i'
|
|
101
|
+
const LEXICAL_STOPWORDS = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'by', 'for', 'with', 'from', 'into', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'it', 'its', 'this', 'that', 'these', 'those', 'as', 'their', 'they', 'them', 'his', 'her', 'has', 'have', 'had', 'not', 'can', 'will', 'if', 'we', 'you', 'your', 'i',
|
|
102
|
+
'how', 'does', 'did', 'do', 'what', 'who', 'why', 'where', 'when', 'which']);
|
|
86
103
|
const lexTokenize = (text) => (String(text).toLowerCase().match(/[a-z0-9']+/g) || [])
|
|
87
104
|
.filter((w) => w.length >= 2 && w.length <= 32 && !LEXICAL_STOPWORDS.has(w));
|
|
88
105
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Vector/lexical rank fusion for hybrid retrieval — the one definition the
|
|
2
|
+
// reader (index.mjs), the calibrator (pikelet/src/calibrate.mjs, which fits
|
|
3
|
+
// coverage on the passage fusion ranks first) and the BEIR ladder
|
|
4
|
+
// (benchmarks/beir/query-E.mjs) all call, so a fit-time or benchmark
|
|
5
|
+
// ranking is the ranking the reader serves.
|
|
6
|
+
//
|
|
7
|
+
// Reciprocal-rank fusion over the exact-reranked vector order and the BM25
|
|
8
|
+
// order: score = 1/(K + vRank) + lexicalWeight/(K + lexRank), lexical term
|
|
9
|
+
// present only for records BM25 returned. Every lexical candidate joins the
|
|
10
|
+
// vector pool (the reader reranks them exactly), so it always has a vRank.
|
|
11
|
+
//
|
|
12
|
+
// The vector-margin guard: when the vector top-1 leads the top-2 by at
|
|
13
|
+
// least guardMargin of its own distance, it keeps rank 1 regardless of
|
|
14
|
+
// fusion. Without it, with K=60 any record that appears in both lists
|
|
15
|
+
// outranks a vector-only rank 1 (1/(60+v) + w/(60+l) > 1/60 for any finite
|
|
16
|
+
// v when w=1), so a query whose terms match many chunks — a character name
|
|
17
|
+
// in a novel — fills the top ranks with term matches and buries the
|
|
18
|
+
// passage the encoder placed first. Measured on three sets with
|
|
19
|
+
// scripts/fusion-sim.mjs before any default changed.
|
|
20
|
+
|
|
21
|
+
export const FUSION_DEFAULTS = Object.freeze({
|
|
22
|
+
rrfK: 60,
|
|
23
|
+
lexicalWeight: 0.5,
|
|
24
|
+
guardMargin: 0.05,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {Array<{id: number, distance: number}>} vectorOrder hits sorted by
|
|
29
|
+
* exact distance ascending (the rerank output), including every lexical
|
|
30
|
+
* candidate.
|
|
31
|
+
* @param {number[]} lexicalIds record ids in BM25 order (after the cutoff).
|
|
32
|
+
* @param {{rrfK?: number, lexicalWeight?: number, guardMargin?: number}} [options]
|
|
33
|
+
* @returns the same hit objects in fused order.
|
|
34
|
+
*/
|
|
35
|
+
export function fuseCandidates(vectorOrder, lexicalIds, options = {}) {
|
|
36
|
+
const rrfK = options.rrfK ?? FUSION_DEFAULTS.rrfK;
|
|
37
|
+
const lexicalWeight = options.lexicalWeight ?? FUSION_DEFAULTS.lexicalWeight;
|
|
38
|
+
const guardMargin = options.guardMargin ?? FUSION_DEFAULTS.guardMargin;
|
|
39
|
+
if (!(rrfK > 0) || !(lexicalWeight >= 0) || !(guardMargin >= 0)) {
|
|
40
|
+
throw new Error('fuseCandidates: rrfK must be > 0, lexicalWeight and guardMargin >= 0');
|
|
41
|
+
}
|
|
42
|
+
if (!lexicalIds.length) return vectorOrder.slice();
|
|
43
|
+
const lexRank = new Map();
|
|
44
|
+
lexicalIds.forEach((id, i) => { if (!lexRank.has(id)) lexRank.set(id, i); });
|
|
45
|
+
let fused = vectorOrder
|
|
46
|
+
.map((hit, vRank) => ({
|
|
47
|
+
hit,
|
|
48
|
+
score: 1 / (rrfK + vRank) + (lexRank.has(hit.id) ? lexicalWeight / (rrfK + lexRank.get(hit.id)) : 0),
|
|
49
|
+
}))
|
|
50
|
+
.sort((a, b) => (b.score - a.score) || (a.hit.distance - b.hit.distance))
|
|
51
|
+
.map((entry) => entry.hit);
|
|
52
|
+
if (guardMargin > 0 && vectorOrder.length > 1) {
|
|
53
|
+
const [first, second] = vectorOrder;
|
|
54
|
+
// The margin is relative to the top-1's own distance, which needs a
|
|
55
|
+
// positive denominator. first.distance === 0 is not a degenerate case
|
|
56
|
+
// to skip but the STRONGEST possible top-1 -- cosine distance is
|
|
57
|
+
// 1 - dot, so a query matching passage text verbatim scores exactly 0
|
|
58
|
+
// -- and gating on `first.distance > 0` disabled the guard for exactly
|
|
59
|
+
// the hit it exists to protect (a worse top-1 at 0.001 was guarded, a
|
|
60
|
+
// perfect one at 0 was not). Any second distance strictly greater than
|
|
61
|
+
// a zero first is an infinite relative lead, so the guard fires.
|
|
62
|
+
// Distances are non-negative here (cosine/L2 as returned by the
|
|
63
|
+
// rerank); a metric admitting negatives would need an absolute margin.
|
|
64
|
+
const lead = first.distance > 0
|
|
65
|
+
? (second.distance - first.distance) / first.distance
|
|
66
|
+
: (second.distance > 0 ? Infinity : 0);
|
|
67
|
+
if (lead >= guardMargin && fused[0] !== first) {
|
|
68
|
+
fused = [first, ...fused.filter((hit) => hit !== first)];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return fused;
|
|
72
|
+
}
|
package/complete/index.d.ts
CHANGED
|
@@ -245,6 +245,29 @@ export declare function openPikeletFile(
|
|
|
245
245
|
|
|
246
246
|
/** Header formatVersion -> manifest profile accepted by this reader. */
|
|
247
247
|
export declare const SUPPORTED_PROFILES: Readonly<Record<number, string>>;
|
|
248
|
+
|
|
249
|
+
/** Rank-fusion parameters shared by the reader, the calibrator and the BEIR ladder. */
|
|
250
|
+
export interface FusionOptions {
|
|
251
|
+
/** Reciprocal-rank-fusion constant (default 60). */
|
|
252
|
+
rrfK?: number;
|
|
253
|
+
/** Weight of the lexical (BM25) rank term relative to the vector term (default 0.5). */
|
|
254
|
+
lexicalWeight?: number;
|
|
255
|
+
/**
|
|
256
|
+
* Relative distance margin (top-2 minus top-1, over top-1) at or above which
|
|
257
|
+
* the vector top-1 keeps rank 1 (default 0.05; 0 disables the guard).
|
|
258
|
+
*/
|
|
259
|
+
guardMargin?: number;
|
|
260
|
+
}
|
|
261
|
+
export declare const FUSION_DEFAULTS: Readonly<Required<FusionOptions>>;
|
|
262
|
+
/**
|
|
263
|
+
* Fuse the exact-reranked vector order (every lexical candidate included) with the BM25 order.
|
|
264
|
+
* Returns the same hit objects, reordered.
|
|
265
|
+
*/
|
|
266
|
+
export declare function fuseCandidates<T extends { id: number; distance: number }>(
|
|
267
|
+
vectorOrder: T[],
|
|
268
|
+
lexicalIds: number[],
|
|
269
|
+
options?: FusionOptions,
|
|
270
|
+
): T[];
|
|
248
271
|
export declare const CORPUS_LAYOUT_V2: 'records-v2';
|
|
249
272
|
/**
|
|
250
273
|
* Check a host encoder against a kind-2 declaration's verification vectors
|