pikelet-wasm 0.8.0 → 0.9.0
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 +399 -2
- package/README.md +81 -257
- package/complete/builder.mjs +35 -5
- package/complete/format.mjs +6 -1
- package/complete/fusion.mjs +72 -0
- package/complete/index.d.ts +23 -0
- package/complete/index.mjs +580 -76
- package/complete/inline-transformer.mjs +97 -1
- package/complete/layer-append.mjs +246 -0
- package/complete/layer-chain.mjs +284 -0
- package/complete/layer-compact.mjs +319 -0
- package/complete/layer-encoder.mjs +223 -0
- package/complete/layer-locator.mjs +228 -0
- package/complete/layer-manifest.mjs +271 -0
- package/complete/layer-reader.mjs +759 -0
- package/complete/layer-rebase.mjs +218 -0
- package/complete/lexical.mjs +78 -8
- package/complete/retrieval-abstention.mjs +132 -28
- package/complete/sources.mjs +1 -1
- package/complete/tombstones.mjs +281 -0
- 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 +65 -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} +97 -8
- 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,12 +8,28 @@ 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
|
+
|
|
13
|
+
> **Upgrade to `pikelet-wasm@0.8.1` if you mount packs you did not compile.**
|
|
14
|
+
> 0.8.0 and earlier sized the inline encoder's WASM buffers from the
|
|
15
|
+
> artifact's own `maxTokens` with an upper bound only, so a pack declaring an
|
|
16
|
+
> out-of-range value could overflow them — at mount, before any query, and
|
|
17
|
+
> with every integrity check passing, because the pack's author is the one who
|
|
18
|
+
> signed it. 0.8.1 validates the bound. Packs the toolchain produces were
|
|
19
|
+
> never affected. `pikelet@0.8.1` picks the fix up through its dependency
|
|
20
|
+
> range; see the CHANGELOG for the detail.
|
|
12
21
|
|
|
13
22
|
```bash
|
|
14
23
|
npx pikelet compile --source ./docs --out docs.pikelet
|
|
15
24
|
```
|
|
16
25
|
|
|
26
|
+
Installing rather than using `npx`? Use `npm install -g pikelet
|
|
27
|
+
--omit=optional`. The optional dependency is `@xenova/transformers`, which
|
|
28
|
+
only the `create` scaffold path uses. Measured on a clean project, omitting it
|
|
29
|
+
takes the install from **82 packages / 257 MB / "6 vulnerabilities (5 high, 1
|
|
30
|
+
critical)"** to **2 packages / 1.8 MB / "found 0 vulnerabilities"** — the
|
|
31
|
+
advisories are all inside a dependency `compile` and `mcp` never load.
|
|
32
|
+
|
|
17
33
|
```text
|
|
18
34
|
Ingested 3 docs -> 3 chunks
|
|
19
35
|
Embedded 3/3 chunks with inline transformer
|
|
@@ -47,7 +63,7 @@ console.log(out.matchQuality, out.results[0]?.title);
|
|
|
47
63
|
// 'strong' 'Snapshot restore'
|
|
48
64
|
```
|
|
49
65
|
|
|
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. `
|
|
66
|
+
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. `create` is the one path that needs the optional `@xenova/transformers` dependency, which is why the install above omits it by default.
|
|
51
67
|
|
|
52
68
|
Under the hood, the file is one container for everything a reader needs:
|
|
53
69
|
|
|
@@ -82,6 +98,22 @@ Search is the interface. **The file is the knowledge deployment unit.**
|
|
|
82
98
|
|
|
83
99
|
A real Pikelet pack contains **456,153 Simple English Wikipedia records** in a **648.5 MiB** artifact.
|
|
84
100
|
|
|
101
|
+
Try the live browser demo:
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
https://pikelet-wiki-playground.pages.dev/
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The page is static HTML/JS on Cloudflare Pages. It mounts the pack from R2:
|
|
108
|
+
|
|
109
|
+
```text
|
|
110
|
+
https://pub-6da2384a3bca4a44b2b2fa29a94cc811.r2.dev/wikipedia.pikelet
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
There is no `/search`, `/embed`, `/query`, or `/api` backend behind the demo.
|
|
114
|
+
The browser opens the `.pikelet` file over HTTP Range, verifies the artifact,
|
|
115
|
+
loads the embedded query encoder, and shows the byte ranges it reads.
|
|
116
|
+
|
|
85
117
|
That file was served by a deliberately dumb HTTP server whose only relevant capability was:
|
|
86
118
|
|
|
87
119
|
```text
|
|
@@ -131,7 +163,7 @@ That is the product in one demonstration:
|
|
|
131
163
|
|
|
132
164
|
> **A model is interrogating a 456,153-record knowledge base whose backend is a static file.**
|
|
133
165
|
|
|
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
|
|
166
|
+
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
167
|
|
|
136
168
|
**Network cost.** In a single persistent session (one mount, five queries, one repeat):
|
|
137
169
|
|
|
@@ -154,97 +186,6 @@ The headless-Claude test above used a fresh process per question, so each invoca
|
|
|
154
186
|
|
|
155
187
|
---
|
|
156
188
|
|
|
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
189
|
## Attach a pack to an LLM
|
|
249
190
|
|
|
250
191
|
`pikelet mcp` exposes one or more packs through the Model Context Protocol:
|
|
@@ -261,55 +202,29 @@ A pack mounted with a content hash has a stable identity — `https://example.co
|
|
|
261
202
|
|
|
262
203
|
**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
204
|
|
|
264
|
-
**Try it with your own questions.** The three Veyra packs from the ablation
|
|
205
|
+
**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:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
npm run demo:veyra
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Then mount all three through Pikelet's MCP server:
|
|
265
212
|
|
|
266
213
|
```bash
|
|
267
214
|
claude -p "your question here" \
|
|
268
|
-
--mcp-config examples/
|
|
215
|
+
--mcp-config examples/one-file-search/web/public/veyra.mcp.json \
|
|
269
216
|
--strict-mcp-config \
|
|
270
217
|
--allowedTools "mcp__veyra-demo__search,mcp__veyra-demo__list_packs,mcp__veyra-demo__get_record"
|
|
271
218
|
```
|
|
272
219
|
|
|
273
|
-
|
|
220
|
+
For an interactive session, use the same config without `-p`:
|
|
274
221
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
---
|
|
292
|
-
|
|
293
|
-
## Integrity is part of the read path
|
|
294
|
-
|
|
295
|
-
A range-readable artifact cannot hash the entire file on every open without defeating the point of range reads, so integrity is layered: the resident structural portion is verified during open; lazily fetched index rows and corpus records carry independent commitments and are checked when read. Bytes a query never touches don't have to cross the network merely to prove the bytes it *did* use were correct.
|
|
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
|
|
222
|
+
```bash
|
|
223
|
+
claude \
|
|
224
|
+
--mcp-config examples/one-file-search/web/public/veyra.mcp.json
|
|
310
225
|
```
|
|
311
226
|
|
|
312
|
-
|
|
227
|
+
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
228
|
|
|
314
229
|
---
|
|
315
230
|
|
|
@@ -321,94 +236,12 @@ A nearest neighbour is not automatically evidence that a corpus answers a questi
|
|
|
321
236
|
|
|
322
237
|
---
|
|
323
238
|
|
|
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
|
-
```
|
|
335
|
-
|
|
336
|
-
The artifact is immutable. Publish a new corpus by publishing a new artifact; old artifacts retain their identity.
|
|
337
|
-
|
|
338
|
-
**Encoder profiles.** Self-containment has a cost — the default profile carries ~25 MiB of MiniLM data regardless of corpus size. Three arrangements: **inline** (tokenizer + weights + runtime in the artifact — largest file, no external dependency, used by the Wikipedia demo above), **distilled/compact** (smaller corpus-specific representation, lower footprint, potentially lower quality), **host-supplied** (the artifact declares expected encoder behavior and verifies the host implementation against embedded test vectors — smallest artifact, no longer fully self-contained). The format treats the encoder as a capability, not Pikelet's identity — MiniLM is the current choice, not a permanent requirement.
|
|
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
|
|
239
|
+
## Deeper dives
|
|
379
240
|
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
---
|
|
241
|
+
- [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.
|
|
242
|
+
- [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.
|
|
243
|
+
- [Why make knowledge a file](docs/why-a-file.md) — the reasoning behind the single-artifact design, and what it commits to.
|
|
244
|
+
- [Architecture](docs/architecture.md) — the engine, the C ABI and the JavaScript wrapper; the artifact formats are specified in [`spec/`](spec/).
|
|
412
245
|
|
|
413
246
|
## What this is not
|
|
414
247
|
|
|
@@ -422,21 +255,6 @@ float32 HNSW, affine-u8 HNSW, insert/delete, compaction, import/export, determin
|
|
|
422
255
|
|
|
423
256
|
---
|
|
424
257
|
|
|
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
258
|
## When Pikelet is a good fit — and when it isn't
|
|
441
259
|
|
|
442
260
|
**Use it when:**
|
|
@@ -468,23 +286,37 @@ The resident remote scan is linear in row count. The current architecture target
|
|
|
468
286
|
## Repository map
|
|
469
287
|
|
|
470
288
|
```text
|
|
471
|
-
|
|
289
|
+
packages/pikelet-wasm/ The published pikelet-wasm package:
|
|
290
|
+
engine/ C++ vector engine (HNSW, float32 and
|
|
472
291
|
affine-u8 backends, mutation, compaction,
|
|
473
|
-
snapshot import/export
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
292
|
+
snapshot import/export), compiled to WASM
|
|
293
|
+
in dist/. Used at compile time to build and
|
|
294
|
+
quantize the index; query time does not load
|
|
295
|
+
it (see complete/index.mjs's header comment)
|
|
296
|
+
— .pikelet reads run a pure-JS sketch scan,
|
|
297
|
+
optionally SIMD-accelerated.
|
|
298
|
+
src/ Engine wrapper and artifact readers/builders
|
|
299
|
+
(the entrypoints, core/, artifact/, errors/)
|
|
300
|
+
complete/ Reader and builder for the complete
|
|
301
|
+
range-readable .pikelet artifact
|
|
302
|
+
native/ N-API build of the same engine (benchmarks)
|
|
303
|
+
packages/pikelet/ The published pikelet CLI: compiler, MCP
|
|
304
|
+
server, encoder integration, Docusaurus plugin
|
|
305
|
+
docs/ Deeper dives (how a query runs, the Veyra
|
|
306
|
+
ablation, why a file), architecture notes,
|
|
307
|
+
rename history, measurement reports
|
|
478
308
|
spec/ Byte-level artifact contracts
|
|
479
|
-
|
|
309
|
+
benchmarks/beir/ Frozen BEIR ablation harness (encoder,
|
|
480
310
|
quantization, HNSW quality)
|
|
481
|
-
|
|
311
|
+
benchmarks/range-proof/ The deliberately boring static-HTTP proof:
|
|
482
312
|
dumb-server.mjs, proof.mjs, mcp-proof.mjs,
|
|
483
313
|
llm-proof.mjs, failure-modes.mjs
|
|
484
|
-
examples/
|
|
314
|
+
examples/one-file-search/ Large single-artifact search and embedded
|
|
485
315
|
encoder work
|
|
486
|
-
examples/
|
|
316
|
+
examples/mcp-knowledge-pack/ Compile, mount, search, and hydrate
|
|
487
317
|
records through MCP
|
|
318
|
+
local-packs/ Prebuilt example .pikelet artifacts used
|
|
319
|
+
by the demo commands above
|
|
488
320
|
packs/ Pack hosting and distribution examples
|
|
489
321
|
```
|
|
490
322
|
|
|
@@ -492,9 +324,9 @@ packs/ Pack hosting and distribution examples
|
|
|
492
324
|
|
|
493
325
|
## Reproduce the proofs
|
|
494
326
|
|
|
495
|
-
**Range proof** (`
|
|
327
|
+
**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
328
|
|
|
497
|
-
**BEIR evaluation** (`
|
|
329
|
+
**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
330
|
|
|
499
331
|
---
|
|
500
332
|
|
|
@@ -504,7 +336,7 @@ Pikelet is early. The implementation is real; the format is not frozen. One prim
|
|
|
504
336
|
|
|
505
337
|
- prebuilt WASM included
|
|
506
338
|
- local and HTTP readers
|
|
507
|
-
- affine-u8 and float HNSW backends
|
|
339
|
+
- affine-u8 and float HNSW backends for compile-time indexing/quantization
|
|
508
340
|
- embedded query encoder
|
|
509
341
|
- BM25 hybrid retrieval
|
|
510
342
|
- content identity and lazy-read integrity verification
|
|
@@ -516,18 +348,10 @@ Pikelet is early. The implementation is real; the format is not frozen. One prim
|
|
|
516
348
|
|
|
517
349
|
`npm test` exercises the engine, artifact profiles, MCP, ingestion, format hardening, and related conformance suites.
|
|
518
350
|
|
|
519
|
-
|
|
351
|
+
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
352
|
|
|
521
353
|
---
|
|
522
354
|
|
|
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
355
|
## License
|
|
532
356
|
|
|
533
357
|
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
|
|
|
@@ -439,16 +456,29 @@ function selfQueriesFromSnapshot(graph, maxQueries) {
|
|
|
439
456
|
// explicitly instead of misreading the corpus tables.
|
|
440
457
|
export const PROFILE_V1 = 'pikelet-complete-v1';
|
|
441
458
|
export const PROFILE_V2 = 'pikelet-complete-v2';
|
|
442
|
-
|
|
459
|
+
// A layer is a complete-profile container under formatVersion 2 with its own
|
|
460
|
+
// manifest profile string (LAYERED_PROFILE.md 4.1). A reader that predates the
|
|
461
|
+
// layered profile sees the string under formatVersion 2 and refuses with its
|
|
462
|
+
// existing "unsupported profile" error, which is the intended behavior: a layer
|
|
463
|
+
// opened alone would silently serve a fraction of a corpus.
|
|
464
|
+
export const PROFILE_LAYER = 'pikelet-layer-v1';
|
|
465
|
+
export const FORMAT_VERSIONS = { [PROFILE_V1]: 1, [PROFILE_V2]: 2, [PROFILE_LAYER]: 2 };
|
|
443
466
|
|
|
444
467
|
export function assemblePikeletFile(manifestFields, segments, outPath) {
|
|
445
468
|
const formatVersion = FORMAT_VERSIONS[manifestFields.profile];
|
|
446
469
|
if (!formatVersion) {
|
|
447
470
|
throw new Error(`manifest.profile must be ${PROFILE_V1} or ${PROFILE_V2}, got ${manifestFields.profile}`);
|
|
448
471
|
}
|
|
472
|
+
const isLayer = manifestFields.profile === PROFILE_LAYER;
|
|
449
473
|
const layout = manifestFields.corpus?.layout;
|
|
450
|
-
|
|
451
|
-
|
|
474
|
+
// A tombstone-only layer (4.1) carries a corpus object with records 0 and
|
|
475
|
+
// MUST omit the layout fields, because it has no corpus segment to describe.
|
|
476
|
+
const tombstoneOnlyLayer = isLayer && manifestFields.corpus?.records === 0;
|
|
477
|
+
if (formatVersion === 2 && !tombstoneOnlyLayer && layout !== CORPUS_LAYOUT_V2) {
|
|
478
|
+
throw new Error(`${manifestFields.profile} requires manifest.corpus from buildCorpusSegment() (layout ${CORPUS_LAYOUT_V2})`);
|
|
479
|
+
}
|
|
480
|
+
if (tombstoneOnlyLayer && layout !== undefined) {
|
|
481
|
+
throw new Error('a tombstone-only layer must omit corpus.layout');
|
|
452
482
|
}
|
|
453
483
|
if (formatVersion === 1 && layout !== undefined) {
|
|
454
484
|
throw new Error(`${PROFILE_V1} carries corpus layout v1 (buildCorpusSegmentFromBuffers); got layout ${layout}`);
|
|
@@ -462,7 +492,7 @@ export function assemblePikeletFile(manifestFields, segments, outPath) {
|
|
|
462
492
|
// sketch's own integrity chain to the complete identity without forcing a
|
|
463
493
|
// whole-segment read at open (the index segment stays lazy).
|
|
464
494
|
let indexCommitment;
|
|
465
|
-
if (formatVersion >= 2) {
|
|
495
|
+
if (formatVersion >= 2 && !tombstoneOnlyLayer) {
|
|
466
496
|
const index = segments.find((s) => s.kind === 'index');
|
|
467
497
|
if (!index || index.bytes.length < 256) {
|
|
468
498
|
throw new Error('index segment must carry a 256-byte sketch header');
|
package/complete/format.mjs
CHANGED
|
@@ -4,5 +4,10 @@
|
|
|
4
4
|
export const MAGIC = 0x31465350; // "PSF1"
|
|
5
5
|
export const HEADER_BYTES = 64;
|
|
6
6
|
export const TABLE_ENTRY_BYTES = 48;
|
|
7
|
-
|
|
7
|
+
// Kinds 6 and 7 belong to the layered profile (LAYERED_PROFILE.md 4.2): a
|
|
8
|
+
// layer carries exactly one tombstones segment, and a base compacted from a
|
|
9
|
+
// chain carries at most one lineage segment. They are registered here so the
|
|
10
|
+
// shared table has one definition; a complete-profile artifact never has
|
|
11
|
+
// either, and the required-segment tables stay per-profile.
|
|
12
|
+
export const KINDS = { index: 1, corpus: 2, 'query-interp': 3, evaluation: 4, lexical: 5, tombstones: 6, lineage: 7 };
|
|
8
13
|
export const KIND_NAMES = Object.fromEntries(Object.entries(KINDS).map(([name, kind]) => [kind, name]));
|
|
@@ -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
|