pikelet-wasm 0.10.0 → 0.11.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 +185 -0
- package/README.md +58 -200
- package/complete/builder.mjs +1 -0
- package/complete/format.mjs +4 -2
- package/complete/index.d.ts +59 -0
- package/complete/index.mjs +252 -123
- package/complete/index.node.mjs +15 -0
- package/complete/layer-reader.mjs +32 -1
- package/complete/lexical.mjs +50 -7
- package/complete/node-fetch.mjs +113 -0
- package/complete/rowmap.mjs +62 -0
- package/complete/sources.mjs +55 -7
- package/dist/engine.scalar.wasm +0 -0
- package/dist/engine.wasm +0 -0
- package/package.json +7 -1
package/complete/index.d.ts
CHANGED
|
@@ -41,6 +41,11 @@ export interface HttpRangeSource extends CompleteRangeSource {
|
|
|
41
41
|
|
|
42
42
|
export declare function httpRangeSource(url: string, options?: {
|
|
43
43
|
preferredParallelism?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Rows of the vector rerank closer than this many bytes are fetched in
|
|
46
|
+
* one request, reading the bytes between them. Default 256 KiB. A pack's
|
|
47
|
+
* manifest recommendedGap applies only when it is wider.
|
|
48
|
+
*/
|
|
44
49
|
preferredGapBytes?: number;
|
|
45
50
|
/** Cap on the one-time full download when a host ignores Range, enforced on received bytes. Default 64 MiB. */
|
|
46
51
|
maxFullFallbackBytes?: number;
|
|
@@ -64,8 +69,33 @@ export declare function httpRangeSource(url: string, options?: {
|
|
|
64
69
|
* the child's origin and directory (LAYERED_PROFILE.md 5.1.1).
|
|
65
70
|
*/
|
|
66
71
|
redirectGuard?: (target: string) => void;
|
|
72
|
+
/**
|
|
73
|
+
* The fetch used for every request. Default nodeFetch under Node
|
|
74
|
+
* (the package's node export condition), else globalThis.fetch. A runtime
|
|
75
|
+
* whose fetch transparently decodes response bodies can pass one that does
|
|
76
|
+
* not.
|
|
77
|
+
*/
|
|
78
|
+
fetch?: (input: string, init?: {
|
|
79
|
+
method?: string;
|
|
80
|
+
headers?: Record<string, string>;
|
|
81
|
+
redirect?: 'follow' | 'manual' | 'error';
|
|
82
|
+
}) => Promise<any>;
|
|
67
83
|
}): HttpRangeSource;
|
|
68
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Node only (the 'node' export condition): a fetch over node:http/node:https
|
|
87
|
+
* that never decompresses, sends Accept-Encoding: identity and refuses any
|
|
88
|
+
* other Content-Encoding. httpRangeSource defaults to it under Node.
|
|
89
|
+
*/
|
|
90
|
+
export declare function nodeFetch(input: string, init?: {
|
|
91
|
+
method?: 'GET' | 'HEAD';
|
|
92
|
+
headers?: Record<string, string>;
|
|
93
|
+
redirect?: 'follow' | 'manual' | 'error';
|
|
94
|
+
signal?: unknown;
|
|
95
|
+
/** Inflate a gzip/deflate body a server sent despite identity (crawled HTML); Brotli/zstd still refused. */
|
|
96
|
+
inflateGzip?: boolean;
|
|
97
|
+
}): Promise<any>;
|
|
98
|
+
|
|
69
99
|
export interface CompleteQueryResult {
|
|
70
100
|
/**
|
|
71
101
|
* The pack's calibrated verdict on this retrieval. On packs calibrated
|
|
@@ -84,6 +114,22 @@ export interface CompleteQueryResult {
|
|
|
84
114
|
* other way round.
|
|
85
115
|
*/
|
|
86
116
|
results: Array<{ id: number; distance: number } & Record<string, unknown>>;
|
|
117
|
+
/**
|
|
118
|
+
* Which passage grounded the verdict and which of the query's content
|
|
119
|
+
* words it contains. Present when a fitted calibrator scored the query
|
|
120
|
+
* (absent on 'unscored'). An asked-for value in `uncovered` is one no top
|
|
121
|
+
* passage states, whatever matchQuality says.
|
|
122
|
+
*/
|
|
123
|
+
grounding?: {
|
|
124
|
+
/** Id of the passage the coverage score was taken from. */
|
|
125
|
+
recordId: number | null;
|
|
126
|
+
/** Index of that passage in the fused (or distance-ordered) window. */
|
|
127
|
+
passageIndex: number;
|
|
128
|
+
/** Weighted share of the query's content words the passage contains, 0-1. */
|
|
129
|
+
coverage: number;
|
|
130
|
+
covered: string[];
|
|
131
|
+
uncovered: string[];
|
|
132
|
+
};
|
|
87
133
|
}
|
|
88
134
|
|
|
89
135
|
export interface CompleteSearch {
|
|
@@ -105,6 +151,10 @@ export interface CompleteSearch {
|
|
|
105
151
|
*/
|
|
106
152
|
license: string | null;
|
|
107
153
|
records: number;
|
|
154
|
+
/** Rows in the index: equal to records unless a row map groups several rows per record. */
|
|
155
|
+
indexRows: number;
|
|
156
|
+
/** The policy behind a multi-row index (COMPLETE_PROFILE.md 3.9), e.g. sentence windows; null otherwise. */
|
|
157
|
+
rowMap: { kind: string; sentences: number; rows: number } | null;
|
|
108
158
|
dim: number;
|
|
109
159
|
metric: string | number;
|
|
110
160
|
encoder: Record<string, unknown>;
|
|
@@ -156,6 +206,13 @@ export interface CompleteSearch {
|
|
|
156
206
|
*/
|
|
157
207
|
residentScan: 'engine' | 'js';
|
|
158
208
|
sampleQueries: string[];
|
|
209
|
+
/**
|
|
210
|
+
* The file's parts in file order: 'header' (fixed header, manifest and
|
|
211
|
+
* segment table) followed by each segment of a known kind ('index',
|
|
212
|
+
* 'corpus', 'lexical', 'query-interp', 'evaluation', ...). Absolute
|
|
213
|
+
* byte offsets.
|
|
214
|
+
*/
|
|
215
|
+
layout: Array<{ kind: string; offset: number; length: number }>;
|
|
159
216
|
};
|
|
160
217
|
/**
|
|
161
218
|
* Verify the index's lazy vector rows against the identity-anchored
|
|
@@ -191,6 +248,8 @@ export interface CompleteSearch {
|
|
|
191
248
|
}): Promise<CompleteQueryResult>;
|
|
192
249
|
/** Hydrate one corpus record by id (verified per record on format 2). */
|
|
193
250
|
record(id: number): Promise<Record<string, unknown>>;
|
|
251
|
+
/** The absolute byte range one corpus record's stored bytes occupy in the file. */
|
|
252
|
+
recordRange(id: number): { offset: number; length: number };
|
|
194
253
|
/** The evaluation segment's JSON object, or null when the artifact carries none. */
|
|
195
254
|
evaluation(): Promise<Record<string, unknown> | null>;
|
|
196
255
|
close(): Promise<void>;
|
package/complete/index.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { loadStudentModel, embedTextWithStudent } from './student-embedder.mjs';
|
|
|
25
25
|
import { computeMatchQuality, computePreSearchAbstention } from './student-abstention.mjs';
|
|
26
26
|
import { createAbstentionScorer } from './retrieval-abstention.mjs';
|
|
27
27
|
import { openLexicalIndex, openLexicalIndexLazy } from './lexical.mjs';
|
|
28
|
+
import { parseRowMapSegment } from './rowmap.mjs';
|
|
28
29
|
import { PikeletSketchArtifact } from '../src/artifact/pikelet-artifact.js';
|
|
29
30
|
import { MAGIC, HEADER_BYTES, TABLE_ENTRY_BYTES, KINDS, KIND_NAMES } from './format.mjs';
|
|
30
31
|
|
|
@@ -769,7 +770,7 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
769
770
|
// influences a returned result. Small segments (kinds 1/2) keep the
|
|
770
771
|
// one-read eager path unchanged.
|
|
771
772
|
const qiDeferrable = qi.length > LAZY_QI_BYTES;
|
|
772
|
-
const [qiFirst, sketch, tables, lexicalBytes] = await Promise.all([
|
|
773
|
+
const [qiFirst, sketch, tables, lexicalBytes, rowMapBytes] = await Promise.all([
|
|
773
774
|
(async () => {
|
|
774
775
|
if (qiDeferrable) {
|
|
775
776
|
return readChecked(source, qi.offset, Math.min(16, qi.length), 'query-interp header', maxReadBytes, fileBytes);
|
|
@@ -813,6 +814,17 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
813
814
|
}
|
|
814
815
|
return { bytes };
|
|
815
816
|
})() : null,
|
|
817
|
+
// Row map (kind 8, OPTIONAL): a few bytes per record, read
|
|
818
|
+
// eagerly and digest-verified. Present when the index holds
|
|
819
|
+
// several vectors per record (sentence windows).
|
|
820
|
+
segments.has('rowmap') ? (async () => {
|
|
821
|
+
const seg = segments.get('rowmap');
|
|
822
|
+
const bytes = await readChecked(source, seg.offset, seg.length, 'row map segment', maxReadBytes, fileBytes);
|
|
823
|
+
if (await sha256hex(bytes) !== seg.sha256) {
|
|
824
|
+
throw new Error('.pikelet row map segment failed hash verification');
|
|
825
|
+
}
|
|
826
|
+
return bytes;
|
|
827
|
+
})() : null,
|
|
816
828
|
]);
|
|
817
829
|
|
|
818
830
|
const lexicalIndex = lexicalBytes
|
|
@@ -1057,7 +1069,18 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1057
1069
|
|
|
1058
1070
|
// Index: the embedded sketch artifact, opened in the wave above
|
|
1059
1071
|
// against a segment-windowed source (resident hash verified there).
|
|
1060
|
-
|
|
1072
|
+
// The index binds to the corpus positionally (3.4) unless a row map
|
|
1073
|
+
// (3.9) says how its rows group into records; the manifest names the
|
|
1074
|
+
// policy that produced the rows, so a row map can neither be dropped
|
|
1075
|
+
// nor added without changing the identity.
|
|
1076
|
+
const rowMapPolicy = manifest.index?.rowMap ?? null;
|
|
1077
|
+
if (!!rowMapPolicy !== !!rowMapBytes) {
|
|
1078
|
+
throw new Error(rowMapPolicy
|
|
1079
|
+
? '.pikelet manifest declares index.rowMap but carries no row map segment'
|
|
1080
|
+
: '.pikelet carries a row map segment its manifest does not declare (index.rowMap)');
|
|
1081
|
+
}
|
|
1082
|
+
const rowMap = rowMapBytes ? parseRowMapSegment(rowMapBytes, declaredRecords, sketch.count) : null;
|
|
1083
|
+
if (!rowMap && sketch.count !== declaredRecords) {
|
|
1061
1084
|
throw new Error(`.pikelet index count ${sketch.count} != corpus records ${declaredRecords}`);
|
|
1062
1085
|
}
|
|
1063
1086
|
if (sketch.dim !== undefined && sketch.dim !== dim) {
|
|
@@ -1243,6 +1266,180 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1243
1266
|
if (closed) throw new Error('.pikelet reader is closed');
|
|
1244
1267
|
};
|
|
1245
1268
|
|
|
1269
|
+
// The rerank's default coalescing gap. The identity-verified manifest
|
|
1270
|
+
// may carry a fetch hint: cluster-ordered layouts only coalesce at a
|
|
1271
|
+
// gap at least matched to their cluster geometry (the wiki pack's
|
|
1272
|
+
// k-means layout needs ~16 KiB; a 2 KiB gap squanders it — measured
|
|
1273
|
+
// 440 vs 200 requests/query at C=600). The hint is a floor: a wider
|
|
1274
|
+
// gap merges a superset of the same runs, so a source that prefers
|
|
1275
|
+
// more (HTTP's 256 KiB) keeps its own. undefined leaves the sketch
|
|
1276
|
+
// reader on the source's preference. A chain reads it per tier
|
|
1277
|
+
// through __chainTier.
|
|
1278
|
+
const rerankGapHint = Number.isFinite(manifest.recommendedGap)
|
|
1279
|
+
? Math.max(manifest.recommendedGap, Number(source.preferredGapBytes) || 0)
|
|
1280
|
+
: undefined;
|
|
1281
|
+
|
|
1282
|
+
// The one retrieval path behind query(): resident sketch scan plus
|
|
1283
|
+
// BM25 candidates, exact rerank, rank fusion and phrase pinning.
|
|
1284
|
+
// Compile-time calibration fits on exactly this (through
|
|
1285
|
+
// __calibrationEvidence below), so the evidence a verdict was fit on
|
|
1286
|
+
// is the evidence query() scores. excludeIds removes records from
|
|
1287
|
+
// both candidate sources before either is cut to size — calibration
|
|
1288
|
+
// ablations use it; query() never does.
|
|
1289
|
+
const retrieveCandidates = async (trimmed, context, k, queryOptions, excludeIds = null) => {
|
|
1290
|
+
let fusedFull = null;
|
|
1291
|
+
// Hybrid retrieval when the artifact carries a lexical
|
|
1292
|
+
// segment: the BM25 top matches join the sketch's exact
|
|
1293
|
+
// rerank as extra candidates (scored by true vector
|
|
1294
|
+
// distance like any candidate — this is what recovers
|
|
1295
|
+
// known-item lookups the sketch scan's top-C missed),
|
|
1296
|
+
// and the final ordering fuses the distance ranking
|
|
1297
|
+
// with the BM25 ranking by reciprocal rank (RRF, k=60,
|
|
1298
|
+
// untuned). Abstention still scores the distance-sorted
|
|
1299
|
+
// top-k exactly as calibrated; fusion affects result
|
|
1300
|
+
// order only. fullRerankOutput returns every reranked
|
|
1301
|
+
// candidate (all already fetched and exactly scored) so
|
|
1302
|
+
// fusion sees the true distance of every lexical match.
|
|
1303
|
+
// Common query terms give every matching document a
|
|
1304
|
+
// small, near-tied BM25 score (idf collapses them);
|
|
1305
|
+
// that flat mass carries no ranking information and
|
|
1306
|
+
// dilutes rank fusion. Keep only hits within a factor
|
|
1307
|
+
// of the best lexical score — for a known-item lookup
|
|
1308
|
+
// that is typically the one document holding the rare
|
|
1309
|
+
// terms.
|
|
1310
|
+
// retrieval: 'hybrid' (default when the artifact
|
|
1311
|
+
// carries a lexical segment) | 'vector' | 'lexical' —
|
|
1312
|
+
// the last two exist for measurement and debugging
|
|
1313
|
+
// (retrieval-quality bakeoffs run all three on one
|
|
1314
|
+
// artifact). Abstention scores the distance-sorted
|
|
1315
|
+
// top-k identically in every mode.
|
|
1316
|
+
// 'augmented' unions the lexical candidates into the
|
|
1317
|
+
// exact rerank but keeps pure distance order — the mode
|
|
1318
|
+
// for workloads scored against exact nearest neighbors,
|
|
1319
|
+
// where RRF's reordering reads as loss by definition.
|
|
1320
|
+
const retrieval = queryOptions.retrieval ?? 'hybrid';
|
|
1321
|
+
if (!['hybrid', 'vector', 'lexical', 'augmented'].includes(retrieval)) {
|
|
1322
|
+
throw new Error(`query() retrieval must be hybrid, vector, lexical, or augmented, got ${retrieval}`);
|
|
1323
|
+
}
|
|
1324
|
+
// An excluded record must not take a candidate slot on either
|
|
1325
|
+
// side, so both sources skip it before cutting to size — BM25
|
|
1326
|
+
// before its top-n sort (scored with the full corpus's term
|
|
1327
|
+
// statistics, the one way this differs from a corpus without
|
|
1328
|
+
// those records) and the sketch before its top-C cut.
|
|
1329
|
+
const excluded = excludeIds && excludeIds.size ? excludeIds : null;
|
|
1330
|
+
let excludeBits = null;
|
|
1331
|
+
if (excluded) {
|
|
1332
|
+
excludeBits = new Uint8Array(Math.ceil(sketch.count / 8));
|
|
1333
|
+
for (const id of excluded) {
|
|
1334
|
+
// With a row map, excluding a record masks every row it owns.
|
|
1335
|
+
const [from, to] = rowMap ? [rowMap.offsets[id], rowMap.offsets[id + 1]] : [id, id + 1];
|
|
1336
|
+
for (let row = from; row < to; row++) excludeBits[row >> 3] |= 1 << (row & 7);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
const lexicalRaw = lexicalIndex && retrieval !== 'vector'
|
|
1340
|
+
? await lexicalIndex.search(trimmed, LEXICAL_CANDIDATES, excluded ? { exclude: (id) => excluded.has(id) } : {})
|
|
1341
|
+
: [];
|
|
1342
|
+
const lexicalHits = lexicalRaw.filter((h) => h.score >= lexicalRaw[0].score / LEXICAL_CUTOFF);
|
|
1343
|
+
// With a row map (3.9) the sketch searches rows: a record's lexical
|
|
1344
|
+
// match joins the rerank with every row it owns, k is scaled to the
|
|
1345
|
+
// mean rows per record, and the reranked rows collapse to records
|
|
1346
|
+
// below, each scored by its best row.
|
|
1347
|
+
const extraRows = rowMap
|
|
1348
|
+
? lexicalHits.flatMap((h) => Array.from({ length: rowMap.offsets[h.id + 1] - rowMap.offsets[h.id] }, (_, i) => rowMap.offsets[h.id] + i))
|
|
1349
|
+
: lexicalHits.map((h) => h.id);
|
|
1350
|
+
const searchK = rowMap ? Math.min(sketch.count, Math.ceil(k * sketch.count / recordCount)) : k;
|
|
1351
|
+
const rerankedRows = (await sketch.search(context.vector, searchK, {
|
|
1352
|
+
rerank: queryOptions.rerank,
|
|
1353
|
+
parallelism: queryOptions.parallelism ?? queryOptions.rerankParallelism ?? options.rerankParallelism,
|
|
1354
|
+
gap: queryOptions.gap ?? queryOptions.rerankGap ?? options.rerankGap ?? rerankGapHint,
|
|
1355
|
+
maxRangeBytes: queryOptions.maxRangeBytes ?? options.rerankMaxRangeBytes,
|
|
1356
|
+
// Present once background staging finished; the
|
|
1357
|
+
// sketch reader ignores it for any query whose C
|
|
1358
|
+
// exceeds the scanner's buffers.
|
|
1359
|
+
...(scanner ? { scanner } : {}),
|
|
1360
|
+
...(extraRows.length ? { extraCandidates: extraRows } : {}),
|
|
1361
|
+
...(excludeBits ? { exclude: excludeBits } : {}),
|
|
1362
|
+
// Always return the full reranked candidate list
|
|
1363
|
+
// (already fetched and exactly scored) rather than
|
|
1364
|
+
// truncating to k: abstention is fit at a fixed
|
|
1365
|
+
// top-10 window (calibrate.mjs) independent of the
|
|
1366
|
+
// caller's k, and needs that full window to score
|
|
1367
|
+
// consistently — see retrieval-abstention.mjs.
|
|
1368
|
+
fullRerankOutput: true,
|
|
1369
|
+
})).results;
|
|
1370
|
+
// The rerank output is distance-ordered, so a record's first row
|
|
1371
|
+
// is its best: keeping first occurrences gives the records in
|
|
1372
|
+
// order of their best row's distance.
|
|
1373
|
+
let searched = rerankedRows;
|
|
1374
|
+
if (rowMap) {
|
|
1375
|
+
const seen = new Set();
|
|
1376
|
+
searched = [];
|
|
1377
|
+
for (const row of rerankedRows) {
|
|
1378
|
+
const id = rowMap.owner[row.id];
|
|
1379
|
+
if (!seen.has(id)) { seen.add(id); searched.push({ id, distance: row.distance }); }
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
const hits = searched.slice(0, k);
|
|
1383
|
+
// Full-window fused order (not k-truncated) for scoring:
|
|
1384
|
+
// coverage grounds its verdict in the passage fusion
|
|
1385
|
+
// actually ranks first, and calibrate.mjs fits it the
|
|
1386
|
+
// same way — a k-truncated fused list would silently
|
|
1387
|
+
// drop the fused top hit for callers requesting a small k.
|
|
1388
|
+
if (retrieval === 'lexical') {
|
|
1389
|
+
const byId = new Map(searched.map((hit) => [hit.id, hit]));
|
|
1390
|
+
fusedFull = lexicalHits.map((h) => byId.get(h.id)).filter(Boolean);
|
|
1391
|
+
} else if (retrieval !== 'augmented' && lexicalHits.length) {
|
|
1392
|
+
const lexRank = new Map(lexicalHits.map((h, i) => [h.id, i]));
|
|
1393
|
+
fusedFull = fuseCandidates(searched, lexicalHits.map((h) => h.id));
|
|
1394
|
+
// One batched hydration for everything this block and
|
|
1395
|
+
// scoreQuality's coverage term (below) will need, in
|
|
1396
|
+
// place of two separate staged fetches (phrase-pin's
|
|
1397
|
+
// own, then a later cold start for coverage): over a
|
|
1398
|
+
// remote range-read source each hydrate() a caller
|
|
1399
|
+
// hasn't already paid for is its own network round
|
|
1400
|
+
// trip, and sequential/staged awaits pay that cost
|
|
1401
|
+
// once per stage instead of once per query. The
|
|
1402
|
+
// union of phrase-pin's candidates (top lexical
|
|
1403
|
+
// hits) and coverage's (fusedFull's top few — phrase
|
|
1404
|
+
// pinning only ever reorders that window, never
|
|
1405
|
+
// changes its membership) is known right here, so
|
|
1406
|
+
// both stages can share one round trip. A record
|
|
1407
|
+
// that fails to hydrate here does not fail the
|
|
1408
|
+
// query: the id is just missing from the map, and
|
|
1409
|
+
// each site below falls through exactly as it would
|
|
1410
|
+
// on a cold cache miss (recs[i] undefined; coverage
|
|
1411
|
+
// sees fewer usable passages) — hydrate() raising
|
|
1412
|
+
// for a bad id is still a real failure, but only
|
|
1413
|
+
// once a site actually needs that specific record,
|
|
1414
|
+
// not speculatively for every candidate touched here.
|
|
1415
|
+
const phraseCandidates = lexicalHits.slice(0, 3);
|
|
1416
|
+
const coverageCandidates = fusedFull.slice(0, COVERAGE_BATCH_COUNT);
|
|
1417
|
+
const batchIds = [...new Set([...phraseCandidates, ...coverageCandidates].map((h) => h.id))];
|
|
1418
|
+
const batch = new Map();
|
|
1419
|
+
await Promise.all(batchIds.map(async (id) => {
|
|
1420
|
+
try { batch.set(id, await hydrate(id)); } catch { /* see comment above */ }
|
|
1421
|
+
}));
|
|
1422
|
+
// Phrase pinning: a verbatim occurrence of the query (>= 3
|
|
1423
|
+
// content words) in one of the top lexical hits is the
|
|
1424
|
+
// strongest evidence of support there is; rank it first.
|
|
1425
|
+
const norm = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9' ]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1426
|
+
const phrase = norm(trimmed);
|
|
1427
|
+
if (phrase.split(' ').length >= 3) {
|
|
1428
|
+
for (const h of phraseCandidates) {
|
|
1429
|
+
if (norm(batch.get(h.id)?.text).includes(phrase)) {
|
|
1430
|
+
fusedFull = [searched.find((x) => x.id === h.id), ...fusedFull.filter((x) => x.id !== h.id)].filter(Boolean);
|
|
1431
|
+
break;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
// Rank transparency: a consumer can see a verbatim hit sitting
|
|
1436
|
+
// at fused rank 3 behind two semantic near-misses.
|
|
1437
|
+
for (const [i, hit] of fusedFull.entries()) { hit.fusedRank = i + 1; hit.lexicalRank = lexRank.has(hit.id) ? lexRank.get(hit.id) + 1 : null; hit.vectorRank = searched.findIndex((x) => x.id === hit.id) + 1; }
|
|
1438
|
+
}
|
|
1439
|
+
const fused = retrieval === 'augmented' ? null : fusedFull?.slice(0, k) ?? null;
|
|
1440
|
+
return { hits, fused, searched, fusedFull };
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1246
1443
|
return {
|
|
1247
1444
|
info() {
|
|
1248
1445
|
return {
|
|
@@ -1270,6 +1467,11 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1270
1467
|
license: typeof manifest.corpus?.provenance?.license === 'string'
|
|
1271
1468
|
? manifest.corpus.provenance.license : null,
|
|
1272
1469
|
records: recordCount,
|
|
1470
|
+
// Index rows and the policy that produced them when the
|
|
1471
|
+
// index holds several vectors per record (row map, 3.9);
|
|
1472
|
+
// otherwise one row per record and rowMap is null.
|
|
1473
|
+
indexRows: sketch.count,
|
|
1474
|
+
rowMap: rowMapPolicy ? { ...rowMapPolicy } : null,
|
|
1273
1475
|
dim,
|
|
1274
1476
|
metric: manifest.metric,
|
|
1275
1477
|
encoder: encoderInfo,
|
|
@@ -1303,6 +1505,16 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1303
1505
|
}
|
|
1304
1506
|
: null,
|
|
1305
1507
|
sampleQueries: Array.isArray(manifest.sampleQueries) ? manifest.sampleQueries : [],
|
|
1508
|
+
// Where each part of the file lives, in file order: the
|
|
1509
|
+
// header (fixed header, manifest and segment table, up to
|
|
1510
|
+
// the first segment's aligned offset) and every segment of
|
|
1511
|
+
// a known kind. Lets a viewer attribute range reads to
|
|
1512
|
+
// what they fetched.
|
|
1513
|
+
layout: [
|
|
1514
|
+
{ kind: 'header', offset: 0, length: align16(HEADER_BYTES + manifestBytes + tableBytes) },
|
|
1515
|
+
...[...segments].map(([kind, seg]) => ({ kind, offset: seg.offset, length: seg.length }))
|
|
1516
|
+
.sort((a, b) => a.offset - b.offset),
|
|
1517
|
+
],
|
|
1306
1518
|
};
|
|
1307
1519
|
},
|
|
1308
1520
|
|
|
@@ -1328,127 +1540,7 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1328
1540
|
let fused = null;
|
|
1329
1541
|
let searched = null;
|
|
1330
1542
|
let fusedFull = null;
|
|
1331
|
-
if (!pre) {
|
|
1332
|
-
// Hybrid retrieval when the artifact carries a lexical
|
|
1333
|
-
// segment: the BM25 top matches join the sketch's exact
|
|
1334
|
-
// rerank as extra candidates (scored by true vector
|
|
1335
|
-
// distance like any candidate — this is what recovers
|
|
1336
|
-
// known-item lookups the sketch scan's top-C missed),
|
|
1337
|
-
// and the final ordering fuses the distance ranking
|
|
1338
|
-
// with the BM25 ranking by reciprocal rank (RRF, k=60,
|
|
1339
|
-
// untuned). Abstention still scores the distance-sorted
|
|
1340
|
-
// top-k exactly as calibrated; fusion affects result
|
|
1341
|
-
// order only. fullRerankOutput returns every reranked
|
|
1342
|
-
// candidate (all already fetched and exactly scored) so
|
|
1343
|
-
// fusion sees the true distance of every lexical match.
|
|
1344
|
-
// Common query terms give every matching document a
|
|
1345
|
-
// small, near-tied BM25 score (idf collapses them);
|
|
1346
|
-
// that flat mass carries no ranking information and
|
|
1347
|
-
// dilutes rank fusion. Keep only hits within a factor
|
|
1348
|
-
// of the best lexical score — for a known-item lookup
|
|
1349
|
-
// that is typically the one document holding the rare
|
|
1350
|
-
// terms.
|
|
1351
|
-
// retrieval: 'hybrid' (default when the artifact
|
|
1352
|
-
// carries a lexical segment) | 'vector' | 'lexical' —
|
|
1353
|
-
// the last two exist for measurement and debugging
|
|
1354
|
-
// (retrieval-quality bakeoffs run all three on one
|
|
1355
|
-
// artifact). Abstention scores the distance-sorted
|
|
1356
|
-
// top-k identically in every mode.
|
|
1357
|
-
// 'augmented' unions the lexical candidates into the
|
|
1358
|
-
// exact rerank but keeps pure distance order — the mode
|
|
1359
|
-
// for workloads scored against exact nearest neighbors,
|
|
1360
|
-
// where RRF's reordering reads as loss by definition.
|
|
1361
|
-
const retrieval = queryOptions.retrieval ?? 'hybrid';
|
|
1362
|
-
if (!['hybrid', 'vector', 'lexical', 'augmented'].includes(retrieval)) {
|
|
1363
|
-
throw new Error(`query() retrieval must be hybrid, vector, lexical, or augmented, got ${retrieval}`);
|
|
1364
|
-
}
|
|
1365
|
-
const lexicalRaw = lexicalIndex && retrieval !== 'vector'
|
|
1366
|
-
? await lexicalIndex.search(trimmed, LEXICAL_CANDIDATES) : [];
|
|
1367
|
-
const lexicalHits = lexicalRaw.filter((h) => h.score >= lexicalRaw[0].score / LEXICAL_CUTOFF);
|
|
1368
|
-
searched = (await sketch.search(context.vector, k, {
|
|
1369
|
-
rerank: queryOptions.rerank,
|
|
1370
|
-
parallelism: queryOptions.parallelism ?? queryOptions.rerankParallelism ?? options.rerankParallelism,
|
|
1371
|
-
// The identity-verified manifest may carry a fetch
|
|
1372
|
-
// hint: cluster-ordered layouts only coalesce at a
|
|
1373
|
-
// gap matched to their cluster geometry (the wiki
|
|
1374
|
-
// pack's k-means layout needs ~16 KiB; the default
|
|
1375
|
-
// 2 KiB squanders it — measured 440 vs 200
|
|
1376
|
-
// requests/query at C=600).
|
|
1377
|
-
gap: queryOptions.gap ?? queryOptions.rerankGap ?? options.rerankGap
|
|
1378
|
-
?? (Number.isFinite(manifest.recommendedGap) ? manifest.recommendedGap : undefined),
|
|
1379
|
-
maxRangeBytes: queryOptions.maxRangeBytes ?? options.rerankMaxRangeBytes,
|
|
1380
|
-
// Present once background staging finished; the
|
|
1381
|
-
// sketch reader ignores it for any query whose C
|
|
1382
|
-
// exceeds the scanner's buffers.
|
|
1383
|
-
...(scanner ? { scanner } : {}),
|
|
1384
|
-
...(lexicalHits.length ? { extraCandidates: lexicalHits.map((h) => h.id) } : {}),
|
|
1385
|
-
// Always return the full reranked candidate list
|
|
1386
|
-
// (already fetched and exactly scored) rather than
|
|
1387
|
-
// truncating to k: abstention is fit at a fixed
|
|
1388
|
-
// top-10 window (calibrate.mjs) independent of the
|
|
1389
|
-
// caller's k, and needs that full window to score
|
|
1390
|
-
// consistently — see retrieval-abstention.mjs.
|
|
1391
|
-
fullRerankOutput: true,
|
|
1392
|
-
})).results;
|
|
1393
|
-
hits = searched.slice(0, k);
|
|
1394
|
-
// Full-window fused order (not k-truncated) for scoring:
|
|
1395
|
-
// coverage grounds its verdict in the passage fusion
|
|
1396
|
-
// actually ranks first, and calibrate.mjs fits it the
|
|
1397
|
-
// same way — a k-truncated fused list would silently
|
|
1398
|
-
// drop the fused top hit for callers requesting a small k.
|
|
1399
|
-
if (retrieval === 'lexical') {
|
|
1400
|
-
const byId = new Map(searched.map((hit) => [hit.id, hit]));
|
|
1401
|
-
fusedFull = lexicalHits.map((h) => byId.get(h.id)).filter(Boolean);
|
|
1402
|
-
} else if (retrieval !== 'augmented' && lexicalHits.length) {
|
|
1403
|
-
const lexRank = new Map(lexicalHits.map((h, i) => [h.id, i]));
|
|
1404
|
-
fusedFull = fuseCandidates(searched, lexicalHits.map((h) => h.id));
|
|
1405
|
-
// One batched hydration for everything this block and
|
|
1406
|
-
// scoreQuality's coverage term (below) will need, in
|
|
1407
|
-
// place of two separate staged fetches (phrase-pin's
|
|
1408
|
-
// own, then a later cold start for coverage): over a
|
|
1409
|
-
// remote range-read source each hydrate() a caller
|
|
1410
|
-
// hasn't already paid for is its own network round
|
|
1411
|
-
// trip, and sequential/staged awaits pay that cost
|
|
1412
|
-
// once per stage instead of once per query. The
|
|
1413
|
-
// union of phrase-pin's candidates (top lexical
|
|
1414
|
-
// hits) and coverage's (fusedFull's top few — phrase
|
|
1415
|
-
// pinning only ever reorders that window, never
|
|
1416
|
-
// changes its membership) is known right here, so
|
|
1417
|
-
// both stages can share one round trip. A record
|
|
1418
|
-
// that fails to hydrate here does not fail the
|
|
1419
|
-
// query: the id is just missing from the map, and
|
|
1420
|
-
// each site below falls through exactly as it would
|
|
1421
|
-
// on a cold cache miss (recs[i] undefined; coverage
|
|
1422
|
-
// sees fewer usable passages) — hydrate() raising
|
|
1423
|
-
// for a bad id is still a real failure, but only
|
|
1424
|
-
// once a site actually needs that specific record,
|
|
1425
|
-
// not speculatively for every candidate touched here.
|
|
1426
|
-
const phraseCandidates = lexicalHits.slice(0, 3);
|
|
1427
|
-
const coverageCandidates = fusedFull.slice(0, COVERAGE_BATCH_COUNT);
|
|
1428
|
-
const batchIds = [...new Set([...phraseCandidates, ...coverageCandidates].map((h) => h.id))];
|
|
1429
|
-
const batch = new Map();
|
|
1430
|
-
await Promise.all(batchIds.map(async (id) => {
|
|
1431
|
-
try { batch.set(id, await hydrate(id)); } catch { /* see comment above */ }
|
|
1432
|
-
}));
|
|
1433
|
-
// Phrase pinning: a verbatim occurrence of the query (>= 3
|
|
1434
|
-
// content words) in one of the top lexical hits is the
|
|
1435
|
-
// strongest evidence of support there is; rank it first.
|
|
1436
|
-
const norm = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9' ]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1437
|
-
const phrase = norm(trimmed);
|
|
1438
|
-
if (phrase.split(' ').length >= 3) {
|
|
1439
|
-
for (const h of phraseCandidates) {
|
|
1440
|
-
if (norm(batch.get(h.id)?.text).includes(phrase)) {
|
|
1441
|
-
fusedFull = [searched.find((x) => x.id === h.id), ...fusedFull.filter((x) => x.id !== h.id)].filter(Boolean);
|
|
1442
|
-
break;
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
// Rank transparency: a consumer can see a verbatim hit sitting
|
|
1447
|
-
// at fused rank 3 behind two semantic near-misses.
|
|
1448
|
-
for (const [i, hit] of fusedFull.entries()) { hit.fusedRank = i + 1; hit.lexicalRank = lexRank.has(hit.id) ? lexRank.get(hit.id) + 1 : null; hit.vectorRank = searched.findIndex((x) => x.id === hit.id) + 1; }
|
|
1449
|
-
}
|
|
1450
|
-
fused = retrieval === 'augmented' ? null : fusedFull?.slice(0, k) ?? null;
|
|
1451
|
-
}
|
|
1543
|
+
if (!pre) ({ hits, fused, searched, fusedFull } = await retrieveCandidates(trimmed, context, k, queryOptions));
|
|
1452
1544
|
// Abstention scores the full reranked candidate window
|
|
1453
1545
|
// (searched), not the k-truncated hits: calibrate.mjs fits
|
|
1454
1546
|
// its signals at a fixed top-10 (top-5 for coverage)
|
|
@@ -1484,12 +1576,42 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1484
1576
|
return { matchQuality: quality.match_quality, confidence: quality.confidence, results, ...(grounding ? { grounding } : {}) };
|
|
1485
1577
|
},
|
|
1486
1578
|
|
|
1579
|
+
/**
|
|
1580
|
+
* The retrieval evidence query() scores, for compile-time
|
|
1581
|
+
* calibration: `vector` is the distance-ordered rerank window
|
|
1582
|
+
* d0/margin/mean10 read, `fused` the order coverage reads its
|
|
1583
|
+
* passages from. Calibration consumes this instead of
|
|
1584
|
+
* reimplementing retrieval, so a verdict is fit on the evidence
|
|
1585
|
+
* it is later scored on. Not part of the documented reader API.
|
|
1586
|
+
*/
|
|
1587
|
+
async __calibrationEvidence(text, { excludeIds = null } = {}) {
|
|
1588
|
+
assertOpen();
|
|
1589
|
+
const trimmed = String(text || '').trim();
|
|
1590
|
+
if (!trimmed) throw new Error('query text is required');
|
|
1591
|
+
// The 10-wide window calibration fits at; any k up to the
|
|
1592
|
+
// pack's rerank depth gives query() the same candidates.
|
|
1593
|
+
const k = Math.min(10, Math.max(recordCount, 1));
|
|
1594
|
+
const { searched, fusedFull } = await retrieveCandidates(trimmed, await embed(trimmed), k, {}, excludeIds);
|
|
1595
|
+
const plain = (list) => list.map((hit) => ({ id: hit.id, distance: hit.distance }));
|
|
1596
|
+
return { vector: plain(searched), fused: plain(fusedFull?.length ? fusedFull : searched) };
|
|
1597
|
+
},
|
|
1598
|
+
|
|
1487
1599
|
/** Hydrate one corpus record by id (verified per record on format 2). */
|
|
1488
1600
|
async record(id) {
|
|
1489
1601
|
assertOpen();
|
|
1490
1602
|
return hydrate(id);
|
|
1491
1603
|
},
|
|
1492
1604
|
|
|
1605
|
+
/** The absolute byte range one corpus record's stored bytes occupy in the file. */
|
|
1606
|
+
recordRange(id) {
|
|
1607
|
+
assertOpen();
|
|
1608
|
+
if (!Number.isSafeInteger(id) || id < 0 || id >= recordCount) {
|
|
1609
|
+
throw new Error(`.pikelet record id ${id} is outside the corpus`);
|
|
1610
|
+
}
|
|
1611
|
+
const start = recordOffsets[id];
|
|
1612
|
+
return { offset: corpus.offset + start, length: recordOffsets[id + 1] - start };
|
|
1613
|
+
},
|
|
1614
|
+
|
|
1493
1615
|
/**
|
|
1494
1616
|
* Full pass over the index's lazy vector rows against the
|
|
1495
1617
|
* identity-anchored vectorsSha256 (streamed where possible).
|
|
@@ -1566,6 +1688,9 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1566
1688
|
recordCount,
|
|
1567
1689
|
dim,
|
|
1568
1690
|
sketch,
|
|
1691
|
+
// A chain maps sketch rows to records positionally, so
|
|
1692
|
+
// it needs to know when a member's rows are not records.
|
|
1693
|
+
rowMap,
|
|
1569
1694
|
lexicalIndex,
|
|
1570
1695
|
hydrate,
|
|
1571
1696
|
readRecordBytes,
|
|
@@ -1588,6 +1713,10 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1588
1713
|
// the kernel. A getter, not a captured value, because
|
|
1589
1714
|
// staging completes after open() returns.
|
|
1590
1715
|
get scanner() { return scanner; },
|
|
1716
|
+
// The rerank's default coalescing gap for this member
|
|
1717
|
+
// (manifest hint as a floor under the source's own), so
|
|
1718
|
+
// a chain fetches each tier's rows as this member would.
|
|
1719
|
+
rerankGap: rerankGapHint,
|
|
1591
1720
|
// The base's OWN encoder, for `append` (6.1): "a kind-3
|
|
1592
1721
|
// base carries the teacher, and append loads it from the
|
|
1593
1722
|
// base's own query-interp segment — no model download, no
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The Node entry of 'pikelet-wasm/complete': the portable reader, except
|
|
2
|
+
// that httpRangeSource defaults to nodeFetch (node:http/node:https, no
|
|
3
|
+
// automatic decompression, Content-Encoding other than identity refused)
|
|
4
|
+
// instead of the runtime's fetch, which decodes whatever a server labels as
|
|
5
|
+
// encoded. Pass options.fetch to choose otherwise.
|
|
6
|
+
|
|
7
|
+
import { httpRangeSource as portableHttpRangeSource } from './sources.mjs';
|
|
8
|
+
import { nodeFetch } from './node-fetch.mjs';
|
|
9
|
+
|
|
10
|
+
export * from './index.mjs';
|
|
11
|
+
export { nodeFetch };
|
|
12
|
+
|
|
13
|
+
export function httpRangeSource(url, options = {}) {
|
|
14
|
+
return portableHttpRangeSource(url, { fetch: nodeFetch, ...options });
|
|
15
|
+
}
|