sweet-search 2.5.13 → 2.6.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/README.md +36 -9
- package/core/cli.js +41 -3
- package/core/embedding/embedding-local-model.js +106 -10
- package/core/embedding/embedding-service.js +59 -1
- package/core/embedding/model-client.mjs +257 -0
- package/core/embedding/model-server.mjs +217 -0
- package/core/incremental-indexing/application/maintenance-handlers.mjs +19 -98
- package/core/incremental-indexing/application/maintenance-worker.mjs +46 -9
- package/core/incremental-indexing/application/operator-cli.mjs +14 -5
- package/core/incremental-indexing/application/production-reconciler-helpers.mjs +40 -0
- package/core/incremental-indexing/application/production-reconciler.mjs +718 -54
- package/core/incremental-indexing/application/reconciler.mjs +87 -15
- package/core/incremental-indexing/domain/cutoff-cache.mjs +191 -0
- package/core/incremental-indexing/domain/interval-autotune.mjs +84 -1
- package/core/incremental-indexing/domain/reconcile-counters.mjs +0 -4
- package/core/incremental-indexing/domain/watermark-scheduler.mjs +0 -24
- package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +2 -26
- package/core/incremental-indexing/infrastructure/manifest.mjs +1 -9
- package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +72 -0
- package/core/indexing/artifact-builder.js +1 -1
- package/core/indexing/dedup/dedup-phase.js +36 -17
- package/core/indexing/dedup/exemplar-selector.js +5 -0
- package/core/indexing/index-codebase-v21.js +37 -14
- package/core/indexing/index-maintainer.mjs +337 -6
- package/core/indexing/indexer-ann.js +27 -434
- package/core/indexing/indexer-build.js +30 -14
- package/core/indexing/indexer-manifest.js +0 -3
- package/core/indexing/indexer-phases.js +101 -25
- package/core/indexing/maintainer-launcher.mjs +22 -0
- package/core/indexing/maintainer-watcher.mjs +397 -0
- package/core/indexing/os-priority.mjs +160 -0
- package/core/indexing/rss-budget.mjs +425 -0
- package/core/indexing/streaming-vectors.js +450 -0
- package/core/infrastructure/config/platform.js +14 -10
- package/core/infrastructure/onnx-session-utils.js +37 -0
- package/core/infrastructure/sparse-gram-delta-reader.js +11 -1
- package/core/ranking/late-interaction-index.js +58 -7
- package/core/search/daemon-registry.js +199 -0
- package/core/search/search-read-semantic.js +9 -3
- package/core/search/search-semantic.js +6 -29
- package/core/search/search-server.js +527 -27
- package/core/search/session-daemon-prewarm.mjs +110 -1
- package/core/search/sweet-search.js +0 -38
- package/core/vector-store/binary-hnsw-index.js +692 -78
- package/core/vector-store/index.js +1 -4
- package/eval/agent-read-workflows/bin/_ss-argparse.mjs +51 -5
- package/eval/agent-read-workflows/bin/_ss-helpers.mjs +95 -44
- package/eval/agent-read-workflows/bin/ss-read +2 -0
- package/mcp/tool-handlers.js +1 -2
- package/package.json +11 -8
- package/scripts/uninstall.js +2 -0
- package/core/vector-store/hnsw-index.js +0 -751
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import fs from 'fs/promises';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
existsSync, statSync,
|
|
32
|
+
openSync, readSync, closeSync, fstatSync,
|
|
33
|
+
} from 'fs';
|
|
31
34
|
import path from 'path';
|
|
32
35
|
import { BINARY_HNSW_CONFIG, DB_PATHS } from '../infrastructure/config/index.js';
|
|
33
36
|
import {
|
|
@@ -44,6 +47,161 @@ import { loadBitmap, isSet } from '../infrastructure/tombstone-bitmap-reader.js'
|
|
|
44
47
|
// different version are incompatible and must be rebuilt.
|
|
45
48
|
const PIPELINE_VERSION = 2;
|
|
46
49
|
|
|
50
|
+
// =============================================================================
|
|
51
|
+
// G9 — PACKED MMAP FORMAT (gated, NO MIGRATION)
|
|
52
|
+
// =============================================================================
|
|
53
|
+
//
|
|
54
|
+
// The default on-disk format is the JSON sidecar tuple
|
|
55
|
+
// (.meta.json / .vectors.json / .graph.json / .int8.json / .calibration.json),
|
|
56
|
+
// written by save() and read by load(). That format is preserved BYTE-FOR-BYTE
|
|
57
|
+
// when SWEET_SEARCH_HNSW_MMAP !== '1' — this whole subsystem is inert by
|
|
58
|
+
// default. Existing on-disk indexes (the 200 bench repos) keep loading via the
|
|
59
|
+
// JSON path regardless of the flag: format is detected by inspecting the .idx
|
|
60
|
+
// file's magic header (`_indexFormatOnDisk`), never by the flag.
|
|
61
|
+
//
|
|
62
|
+
// When SWEET_SEARCH_HNSW_MMAP === '1', NEWLY written indexes are packed into a
|
|
63
|
+
// single flat-binary `.idx` file (magic-prefixed) whose vectors / graph
|
|
64
|
+
// adjacency / int8 sections are laid out so the read/search path can fault in
|
|
65
|
+
// only the pages it touches (lazy fd reads → OS page cache). The packed format
|
|
66
|
+
// preserves the deterministic levels (G1) and deterministic entry point (FIX-B)
|
|
67
|
+
// because those are properties of the in-memory graph/entryPoint that get
|
|
68
|
+
// serialized verbatim — packing does not re-run construction.
|
|
69
|
+
//
|
|
70
|
+
// MMAP_MAGIC is 8 ASCII bytes so a packed file is trivially and cheaply
|
|
71
|
+
// distinguishable from a JSON sidecar's `.idx` (which today is never written
|
|
72
|
+
// with content — only the sidecars carry data) without parsing.
|
|
73
|
+
const MMAP_MAGIC = Buffer.from('SSHNSWP\0', 'ascii'); // 8 bytes
|
|
74
|
+
const MMAP_FORMAT_VERSION = 1;
|
|
75
|
+
|
|
76
|
+
function hnswMmapEnabled() {
|
|
77
|
+
return process.env.SWEET_SEARCH_HNSW_MMAP === '1';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Lazy, fd-backed graph adjacency for the packed (mmap) read path.
|
|
82
|
+
*
|
|
83
|
+
* Behaves like `graph[level][nodeIndex] -> number[]` (the JSON-loaded shape)
|
|
84
|
+
* but reads each (level, node) neighbor list from the open file descriptor on
|
|
85
|
+
* first touch and caches it, so a cold search faults in only the pages it
|
|
86
|
+
* visits. `?.[idx]` / `.length` / iteration over a returned neighbor list all
|
|
87
|
+
* work identically to a plain array because each level proxies an array-like
|
|
88
|
+
* via numeric-index getters that materialize lazily.
|
|
89
|
+
*
|
|
90
|
+
* The on-disk graph section layout (all little-endian uint32):
|
|
91
|
+
* [levelCount]
|
|
92
|
+
* per level: [nodeCount] then nodeCount × [neighborCount, n0, n1, ...]
|
|
93
|
+
* Per-(level,node) byte offsets are precomputed once into a flat index at load
|
|
94
|
+
* so a touch is an O(1) seek + a single readSync of just that list.
|
|
95
|
+
*/
|
|
96
|
+
class MmapGraphLevel {
|
|
97
|
+
constructor(fd, sectionBase, nodeOffsets, nodeCount) {
|
|
98
|
+
this._fd = fd;
|
|
99
|
+
this._base = sectionBase;
|
|
100
|
+
this._nodeOffsets = nodeOffsets; // Int32Array of byte offsets (relative) per node, -1 if empty
|
|
101
|
+
this._cache = new Array(nodeCount);
|
|
102
|
+
this.length = nodeCount;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_materialize(i) {
|
|
106
|
+
if (i < 0 || i >= this.length) return undefined;
|
|
107
|
+
const cached = this._cache[i];
|
|
108
|
+
if (cached !== undefined) return cached;
|
|
109
|
+
const rel = this._nodeOffsets[i];
|
|
110
|
+
if (rel < 0) {
|
|
111
|
+
this._cache[i] = [];
|
|
112
|
+
return this._cache[i];
|
|
113
|
+
}
|
|
114
|
+
const head = Buffer.allocUnsafe(4);
|
|
115
|
+
readSync(this._fd, head, 0, 4, this._base + rel);
|
|
116
|
+
const count = head.readUInt32LE(0);
|
|
117
|
+
const list = new Array(count);
|
|
118
|
+
if (count > 0) {
|
|
119
|
+
const body = Buffer.allocUnsafe(count * 4);
|
|
120
|
+
readSync(this._fd, body, 0, count * 4, this._base + rel + 4);
|
|
121
|
+
for (let j = 0; j < count; j += 1) list[j] = body.readUInt32LE(j * 4);
|
|
122
|
+
}
|
|
123
|
+
this._cache[i] = list;
|
|
124
|
+
return list;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Numeric-index access (graph[level][idx]) is the hot path; back each level
|
|
129
|
+
// with a Proxy that maps integer keys to lazy materialization and forwards
|
|
130
|
+
// `length`. Optional-chaining (`graph[level]?.[idx]`) and `.length` both work.
|
|
131
|
+
function makeMmapGraphLevelProxy(level) {
|
|
132
|
+
return new Proxy(level, {
|
|
133
|
+
get(target, prop) {
|
|
134
|
+
if (prop === 'length') return target.length;
|
|
135
|
+
if (typeof prop === 'string') {
|
|
136
|
+
const n = Number(prop);
|
|
137
|
+
if (Number.isInteger(n) && n >= 0) return target._materialize(n);
|
|
138
|
+
}
|
|
139
|
+
return target[prop];
|
|
140
|
+
},
|
|
141
|
+
has(target, prop) {
|
|
142
|
+
if (prop === 'length') return true;
|
|
143
|
+
const n = Number(prop);
|
|
144
|
+
if (Number.isInteger(n) && n >= 0 && n < target.length) return true;
|
|
145
|
+
return prop in target;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Lazy, fd-backed vectors store for the packed read path.
|
|
152
|
+
*
|
|
153
|
+
* Behaves like `vectors[idx] -> { id, binary, metadata }`. `id` and `metadata`
|
|
154
|
+
* are kept fully resident (they live in the small header JSON, parsed once),
|
|
155
|
+
* but the `binary` Uint8Array is read from the flat vector blob on first touch
|
|
156
|
+
* so cold ticks hold near-zero binary bytes in heap. Each materialized record
|
|
157
|
+
* is cached so repeated touches in a search don't re-read.
|
|
158
|
+
*/
|
|
159
|
+
class MmapVectorStore {
|
|
160
|
+
constructor(fd, blobBase, dimension, ids, metas) {
|
|
161
|
+
this._fd = fd;
|
|
162
|
+
this._base = blobBase;
|
|
163
|
+
this._dim = dimension;
|
|
164
|
+
this._ids = ids; // string[]
|
|
165
|
+
this._metas = metas; // object[]
|
|
166
|
+
this._cache = new Array(ids.length);
|
|
167
|
+
this.length = ids.length;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_materialize(i) {
|
|
171
|
+
if (i < 0 || i >= this.length) return undefined;
|
|
172
|
+
const cached = this._cache[i];
|
|
173
|
+
if (cached !== undefined) return cached;
|
|
174
|
+
const buf = Buffer.allocUnsafe(this._dim);
|
|
175
|
+
readSync(this._fd, buf, 0, this._dim, this._base + i * this._dim);
|
|
176
|
+
// Copy into a standalone Uint8Array so callers (hammingDistance) see a
|
|
177
|
+
// tight, stable view independent of the scratch Buffer.
|
|
178
|
+
const binary = new Uint8Array(this._dim);
|
|
179
|
+
binary.set(buf);
|
|
180
|
+
const rec = { id: this._ids[i], binary, metadata: this._metas[i] };
|
|
181
|
+
this._cache[i] = rec;
|
|
182
|
+
return rec;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function makeMmapVectorProxy(store) {
|
|
187
|
+
return new Proxy(store, {
|
|
188
|
+
get(target, prop) {
|
|
189
|
+
if (prop === 'length') return target.length;
|
|
190
|
+
if (typeof prop === 'string') {
|
|
191
|
+
const n = Number(prop);
|
|
192
|
+
if (Number.isInteger(n) && n >= 0) return target._materialize(n);
|
|
193
|
+
}
|
|
194
|
+
return target[prop];
|
|
195
|
+
},
|
|
196
|
+
has(target, prop) {
|
|
197
|
+
if (prop === 'length') return true;
|
|
198
|
+
const n = Number(prop);
|
|
199
|
+
if (Number.isInteger(n) && n >= 0 && n < target.length) return true;
|
|
200
|
+
return prop in target;
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
47
205
|
// =============================================================================
|
|
48
206
|
// BINARY HNSW INDEX CLASS
|
|
49
207
|
// =============================================================================
|
|
@@ -85,10 +243,17 @@ export class BinaryHNSWIndex {
|
|
|
85
243
|
this.useAsymmetric = false; // Enabled after calibration
|
|
86
244
|
this._staleBitmapCache = null;
|
|
87
245
|
this._cleanBuild = false;
|
|
246
|
+
|
|
247
|
+
// G9 mmap: set when this instance is backed by an open, packed .idx fd
|
|
248
|
+
// (loadMmap). `vectors`/`graph` are then lazy proxies and the index is
|
|
249
|
+
// read/search-only. Null/false on the default JSON path.
|
|
250
|
+
this._mmapBacked = false;
|
|
251
|
+
this._mmapFd = null;
|
|
88
252
|
}
|
|
89
253
|
|
|
90
254
|
/** Reset to empty state for a fresh build (skips loading from disk). */
|
|
91
255
|
resetForBuild() {
|
|
256
|
+
this._closeMmap();
|
|
92
257
|
this.vectors = [];
|
|
93
258
|
this.idToIndex = new Map();
|
|
94
259
|
this.int8Vectors.clear();
|
|
@@ -103,6 +268,15 @@ export class BinaryHNSWIndex {
|
|
|
103
268
|
this._cleanBuild = true;
|
|
104
269
|
}
|
|
105
270
|
|
|
271
|
+
/** Close an open packed-file descriptor if this index is mmap-backed. */
|
|
272
|
+
_closeMmap() {
|
|
273
|
+
if (this._mmapFd != null) {
|
|
274
|
+
try { closeSync(this._mmapFd); } catch { /* best-effort */ }
|
|
275
|
+
}
|
|
276
|
+
this._mmapFd = null;
|
|
277
|
+
this._mmapBacked = false;
|
|
278
|
+
}
|
|
279
|
+
|
|
106
280
|
_stalePathForIndex(indexPath = this.indexPath) {
|
|
107
281
|
return indexPath === this.indexPath ? this.stalePath : `${indexPath}.stale.bin`;
|
|
108
282
|
}
|
|
@@ -113,9 +287,22 @@ export class BinaryHNSWIndex {
|
|
|
113
287
|
async init() {
|
|
114
288
|
if (this.initialized) return;
|
|
115
289
|
|
|
116
|
-
// Try to load existing index
|
|
290
|
+
// Try to load an existing index. Detect the on-disk format by inspecting
|
|
291
|
+
// the artifacts (NOT the flag): a JSON sidecar tuple is identified by its
|
|
292
|
+
// .meta.json descriptor; a packed (mmap) index is identified by the magic
|
|
293
|
+
// header on the .idx file itself. Old indexes always take the JSON path
|
|
294
|
+
// regardless of SWEET_SEARCH_HNSW_MMAP (no migration, ever).
|
|
117
295
|
const metaPath = this.indexPath.replace('.idx', '.meta.json');
|
|
118
|
-
|
|
296
|
+
const onDisk = _indexFormatOnDisk(this.indexPath);
|
|
297
|
+
if (onDisk === 'packed') {
|
|
298
|
+
try {
|
|
299
|
+
await this.load(); // load() routes to loadMmap() for packed files
|
|
300
|
+
this.initialized = true;
|
|
301
|
+
return;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.log(`BinaryHNSW: Failed to load packed index, creating new: ${err.message}`);
|
|
304
|
+
}
|
|
305
|
+
} else if (existsSync(metaPath)) {
|
|
119
306
|
try {
|
|
120
307
|
await this.load();
|
|
121
308
|
this.initialized = true;
|
|
@@ -136,7 +323,15 @@ export class BinaryHNSWIndex {
|
|
|
136
323
|
}
|
|
137
324
|
|
|
138
325
|
/**
|
|
139
|
-
* Calculate random level for new node (exponential distribution)
|
|
326
|
+
* Calculate random level for new node (exponential distribution).
|
|
327
|
+
*
|
|
328
|
+
* Draws from the global, unseeded Math.random() at insert time, so the
|
|
329
|
+
* level a node receives depends on insertion order / RNG-stream
|
|
330
|
+
* interleaving (per-file reload-and-insert vs. batched load-once-insert-all
|
|
331
|
+
* vs. compaction rebuild all consume the stream differently). This is the
|
|
332
|
+
* default path and is intentionally left unchanged. For an
|
|
333
|
+
* insertion-order-independent level, see `levelForId(id)` (gated on
|
|
334
|
+
* SWEET_SEARCH_HNSW_DETERMINISTIC_LEVELS).
|
|
140
335
|
*/
|
|
141
336
|
getRandomLevel() {
|
|
142
337
|
const mL = 1 / Math.log(this.M);
|
|
@@ -144,6 +339,40 @@ export class BinaryHNSWIndex {
|
|
|
144
339
|
return Math.min(level, 10); // Cap at 10 levels
|
|
145
340
|
}
|
|
146
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Deterministic level for a node id (insertion-order-independent).
|
|
344
|
+
*
|
|
345
|
+
* Pure function of the string id (FNV-1a 32-bit hash → uniform u∈(0,1])
|
|
346
|
+
* fed through the SAME exponential CDF as `getRandomLevel()`
|
|
347
|
+
* (`floor(-ln(u) * (1/ln(M)))`, capped at 10). Because the level depends
|
|
348
|
+
* only on the id (not on Math.random() or insertion order), batched,
|
|
349
|
+
* per-file, and compaction construction paths all assign the same node the
|
|
350
|
+
* same level — the prerequisite for a byte-identical graph.
|
|
351
|
+
*
|
|
352
|
+
* Used only when SWEET_SEARCH_HNSW_DETERMINISTIC_LEVELS === '1'; the default
|
|
353
|
+
* path (`getRandomLevel`) is unchanged.
|
|
354
|
+
*
|
|
355
|
+
* @param {string} id - node id (coerced to string)
|
|
356
|
+
* @returns {number} level in [0, 10]
|
|
357
|
+
*/
|
|
358
|
+
levelForId(id) {
|
|
359
|
+
// FNV-1a 32-bit over the id's UTF-16 code units. Stable across calls and
|
|
360
|
+
// across a fresh process (no global state, no RNG).
|
|
361
|
+
const s = String(id);
|
|
362
|
+
let h = 0x811c9dc5; // FNV offset basis
|
|
363
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
364
|
+
h ^= s.charCodeAt(i);
|
|
365
|
+
// FNV prime multiply via shifts, kept in unsigned 32-bit space.
|
|
366
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
|
367
|
+
}
|
|
368
|
+
// Map hash → u ∈ (0,1]. Use (h+1)/2^32 so u is never 0 (avoids -ln(0)=∞)
|
|
369
|
+
// and never exceeds 1; h ranges over [0, 2^32-1] so (h+1) ∈ [1, 2^32].
|
|
370
|
+
const u = (h + 1) / 4294967296; // 2^32
|
|
371
|
+
const mL = 1 / Math.log(this.M);
|
|
372
|
+
const level = Math.floor(-Math.log(u) * mL);
|
|
373
|
+
return Math.min(level, 10); // Cap at 10 levels (same as getRandomLevel)
|
|
374
|
+
}
|
|
375
|
+
|
|
147
376
|
/**
|
|
148
377
|
* Add a vector to the index
|
|
149
378
|
*
|
|
@@ -155,6 +384,19 @@ export class BinaryHNSWIndex {
|
|
|
155
384
|
async add(id, binaryVector, metadata = {}, int8Vector = null) {
|
|
156
385
|
await this.init();
|
|
157
386
|
|
|
387
|
+
// The packed (mmap) layout is a read/search-only representation: `vectors`
|
|
388
|
+
// and `graph` are lazy fd-backed proxies, not mutable plain arrays. Build
|
|
389
|
+
// paths must construct on plain arrays and persist via save()/saveMmap().
|
|
390
|
+
// Mutating a packed-loaded index would silently corrupt the graph, so fail
|
|
391
|
+
// loudly instead. (This is unreachable on the default path; the reconciler
|
|
392
|
+
// builds in memory and only the read side mmap-loads.)
|
|
393
|
+
if (this._mmapBacked) {
|
|
394
|
+
throw new Error(
|
|
395
|
+
'BinaryHNSW: cannot add() to an mmap-backed (packed) index — '
|
|
396
|
+
+ 'it is a read/search-only view. Rebuild on a fresh in-memory index.'
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
158
400
|
// Convert float to binary if needed
|
|
159
401
|
let binary;
|
|
160
402
|
if (binaryVector instanceof Uint8Array) {
|
|
@@ -185,8 +427,17 @@ export class BinaryHNSWIndex {
|
|
|
185
427
|
this.int8Vectors.set(id, int8Vector);
|
|
186
428
|
}
|
|
187
429
|
|
|
188
|
-
// Add to HNSW graph
|
|
189
|
-
|
|
430
|
+
// Add to HNSW graph.
|
|
431
|
+
// Gate: deterministic per-id levels are DEFAULT-ON (disable with
|
|
432
|
+
// SWEET_SEARCH_HNSW_DETERMINISTIC_LEVELS=0). When on, assign the level from a
|
|
433
|
+
// stable hash of the id (insertion-order-independent) so all construction
|
|
434
|
+
// paths (incremental add, end-of-tick batch, compaction rebuild) agree and
|
|
435
|
+
// the graph is reproducible. Verified recall-neutral on GCSN (+0.01pp MRR)
|
|
436
|
+
// and byte-identical via the determinism harness. Set the flag to '0' to
|
|
437
|
+
// restore the legacy random-level (insertion-order-dependent) path.
|
|
438
|
+
const level = process.env.SWEET_SEARCH_HNSW_DETERMINISTIC_LEVELS !== '0'
|
|
439
|
+
? this.levelForId(id)
|
|
440
|
+
: this.getRandomLevel();
|
|
190
441
|
this.addToGraph(idx, level);
|
|
191
442
|
|
|
192
443
|
return idx;
|
|
@@ -248,6 +499,37 @@ export class BinaryHNSWIndex {
|
|
|
248
499
|
return selected;
|
|
249
500
|
}
|
|
250
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Whether insertion-order-independent entry-point selection is active.
|
|
504
|
+
*
|
|
505
|
+
* Mirrors the gate on level assignment in `add()`. When ON, the entry
|
|
506
|
+
* point is chosen deterministically (min id at the max level) so batched,
|
|
507
|
+
* per-file, and compaction construction paths agree on `entryPoint` even
|
|
508
|
+
* when several nodes share the max level — the last byte-identity gap left
|
|
509
|
+
* by deterministic levels (E.1 entry-point divergence). DEFAULT-ON (disable
|
|
510
|
+
* with SWEET_SEARCH_HNSW_DETERMINISTIC_LEVELS=0) → mirrors the level-assignment
|
|
511
|
+
* gate in `add()`. Set the flag to '0' to restore today's
|
|
512
|
+
* first-inserted-at-max-level behavior.
|
|
513
|
+
*/
|
|
514
|
+
_deterministicEntryPoint() {
|
|
515
|
+
return process.env.SWEET_SEARCH_HNSW_DETERMINISTIC_LEVELS !== '0';
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Tie-break two graph node indices by their string id (deterministic,
|
|
520
|
+
* insertion-order-independent). Returns the index whose id sorts first
|
|
521
|
+
* lexicographically. Lexicographic comparison of the stable string id is
|
|
522
|
+
* used (not the FNV-1a hash) so the choice is human-auditable and never
|
|
523
|
+
* subject to hash collisions.
|
|
524
|
+
*/
|
|
525
|
+
_entryPointWinner(idxA, idxB) {
|
|
526
|
+
if (idxA === -1) return idxB;
|
|
527
|
+
if (idxB === -1) return idxA;
|
|
528
|
+
const idA = String(this.vectors[idxA].id);
|
|
529
|
+
const idB = String(this.vectors[idxB].id);
|
|
530
|
+
return idA <= idB ? idxA : idxB;
|
|
531
|
+
}
|
|
532
|
+
|
|
251
533
|
/**
|
|
252
534
|
* Add node to HNSW graph.
|
|
253
535
|
* Uses heuristic selection + level-aware M0=2*M.
|
|
@@ -310,10 +592,23 @@ export class BinaryHNSWIndex {
|
|
|
310
592
|
}
|
|
311
593
|
}
|
|
312
594
|
|
|
313
|
-
// Update entry point
|
|
595
|
+
// Update entry point.
|
|
596
|
+
//
|
|
597
|
+
// Default path (flag OFF): unchanged — the entry point only moves when a
|
|
598
|
+
// strictly higher level appears, so the first node inserted at the running
|
|
599
|
+
// max level keeps it (insertion-order-dependent, today's behavior).
|
|
600
|
+
//
|
|
601
|
+
// Deterministic path (flag ON): a strictly higher level still wins, but on
|
|
602
|
+
// a TIE at the current max level the entry point is refined to the node
|
|
603
|
+
// with the smallest id. Because `maxLevel` only ever rises during a build
|
|
604
|
+
// (no deletions here) and equals the max id-assigned level, this converges
|
|
605
|
+
// to `min(id)` over all nodes at the final max level regardless of
|
|
606
|
+
// insertion order — closing the E.1 entry-point byte-identity gap.
|
|
314
607
|
if (level > this.maxLevel) {
|
|
315
608
|
this.entryPoint = idx;
|
|
316
609
|
this.maxLevel = level;
|
|
610
|
+
} else if (level === this.maxLevel && this._deterministicEntryPoint()) {
|
|
611
|
+
this.entryPoint = this._entryPointWinner(this.entryPoint, idx);
|
|
317
612
|
}
|
|
318
613
|
}
|
|
319
614
|
|
|
@@ -738,6 +1033,17 @@ export class BinaryHNSWIndex {
|
|
|
738
1033
|
* versioned manifest paths.
|
|
739
1034
|
*/
|
|
740
1035
|
async save(indexPath = this.indexPath) {
|
|
1036
|
+
// G9 gate. Default OFF → the JSON-sidecar writer below runs UNCHANGED, so
|
|
1037
|
+
// every existing index keeps its exact byte format and there is no
|
|
1038
|
+
// migration. ONLY when SWEET_SEARCH_HNSW_MMAP === '1' do NEW writes pack
|
|
1039
|
+
// into the mmap-friendly flat-binary .idx (a distinct on-disk format with
|
|
1040
|
+
// its own magic/version). The deterministic levels (G1) and entry point
|
|
1041
|
+
// (FIX-B) live in `this.graph`/`this.entryPoint`, which both writers
|
|
1042
|
+
// serialize verbatim — packing preserves them.
|
|
1043
|
+
if (hnswMmapEnabled()) {
|
|
1044
|
+
return this.saveMmap(indexPath);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
741
1047
|
await fs.mkdir(path.dirname(indexPath), { recursive: true });
|
|
742
1048
|
|
|
743
1049
|
const meta = {
|
|
@@ -833,6 +1139,14 @@ export class BinaryHNSWIndex {
|
|
|
833
1139
|
* search crash.
|
|
834
1140
|
*/
|
|
835
1141
|
async load(indexPath = this.indexPath) {
|
|
1142
|
+
// Route by DETECTED on-disk format, never by the flag: a packed .idx
|
|
1143
|
+
// (magic header) is read via the mmap path even with the flag off, and a
|
|
1144
|
+
// JSON-sidecar index is read via the JSON path even with the flag on. This
|
|
1145
|
+
// is what lets old and new coexist with zero migration.
|
|
1146
|
+
if (_indexFormatOnDisk(indexPath) === 'packed') {
|
|
1147
|
+
return this.loadMmap(indexPath);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
836
1150
|
const metaPath = indexPath.replace('.idx', '.meta.json');
|
|
837
1151
|
const vectorsPath = indexPath.replace('.idx', '.vectors.json');
|
|
838
1152
|
const graphPath = indexPath.replace('.idx', '.graph.json');
|
|
@@ -927,13 +1241,338 @@ export class BinaryHNSWIndex {
|
|
|
927
1241
|
console.log(`BinaryHNSW: Loaded ${this.vectors.length} vectors from ${indexPath} (asymmetric=${this.useAsymmetric})`);
|
|
928
1242
|
}
|
|
929
1243
|
|
|
1244
|
+
/**
|
|
1245
|
+
* Save the index in the packed, mmap-friendly flat-binary format (G9).
|
|
1246
|
+
*
|
|
1247
|
+
* Activated only via save() under SWEET_SEARCH_HNSW_MMAP === '1'. The whole
|
|
1248
|
+
* index is serialized into a SINGLE `.idx` file (so the read path can fault
|
|
1249
|
+
* in only touched pages) and the legacy JSON sidecars for this path are
|
|
1250
|
+
* removed so a stale JSON descriptor can never shadow the packed file. The
|
|
1251
|
+
* graph + entryPoint are written verbatim, preserving G1/FIX-B determinism.
|
|
1252
|
+
*
|
|
1253
|
+
* On-disk layout (all integers little-endian):
|
|
1254
|
+
* magic(8) | formatVersion(u32) | headerLen(u32) | header(JSON utf-8) | sections...
|
|
1255
|
+
* The header JSON carries the meta fields, the id/metadata arrays, the
|
|
1256
|
+
* asymmetric calibration (inlined — small), and { offset, len } descriptors
|
|
1257
|
+
* for the three binary sections (vectors, graph, int8), each section offset
|
|
1258
|
+
* being relative to the start of the section region (right after the header).
|
|
1259
|
+
*
|
|
1260
|
+
* Publish is atomic: write to `<path>.tmp.<pid>` then rename over `<path>`.
|
|
1261
|
+
*/
|
|
1262
|
+
async saveMmap(indexPath = this.indexPath) {
|
|
1263
|
+
await fs.mkdir(path.dirname(indexPath), { recursive: true });
|
|
1264
|
+
|
|
1265
|
+
const dim = this.dimension;
|
|
1266
|
+
const n = this.vectors.length;
|
|
1267
|
+
|
|
1268
|
+
// --- Section 1: vectors blob (flat n*dim bytes) ---
|
|
1269
|
+
const vectorsBlob = Buffer.allocUnsafe(n * dim);
|
|
1270
|
+
const ids = new Array(n);
|
|
1271
|
+
const metas = new Array(n);
|
|
1272
|
+
for (let i = 0; i < n; i += 1) {
|
|
1273
|
+
const v = this.vectors[i];
|
|
1274
|
+
ids[i] = v.id;
|
|
1275
|
+
metas[i] = v.metadata;
|
|
1276
|
+
vectorsBlob.set(v.binary, i * dim);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// --- Section 2: graph blob ---
|
|
1280
|
+
// [levelCount] then per level [nodeCount] then per node [count, n0, n1...]
|
|
1281
|
+
const graphParts = [];
|
|
1282
|
+
const lvlHead = Buffer.allocUnsafe(4);
|
|
1283
|
+
lvlHead.writeUInt32LE(this.graph.length, 0);
|
|
1284
|
+
graphParts.push(lvlHead);
|
|
1285
|
+
for (let l = 0; l < this.graph.length; l += 1) {
|
|
1286
|
+
const level = this.graph[l] || [];
|
|
1287
|
+
const nodeHead = Buffer.allocUnsafe(4);
|
|
1288
|
+
nodeHead.writeUInt32LE(level.length, 0);
|
|
1289
|
+
graphParts.push(nodeHead);
|
|
1290
|
+
for (let i = 0; i < level.length; i += 1) {
|
|
1291
|
+
const neighbors = level[i] || [];
|
|
1292
|
+
const buf = Buffer.allocUnsafe(4 + neighbors.length * 4);
|
|
1293
|
+
buf.writeUInt32LE(neighbors.length, 0);
|
|
1294
|
+
for (let j = 0; j < neighbors.length; j += 1) {
|
|
1295
|
+
buf.writeUInt32LE(neighbors[j], 4 + j * 4);
|
|
1296
|
+
}
|
|
1297
|
+
graphParts.push(buf);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const graphBlob = Buffer.concat(graphParts);
|
|
1301
|
+
|
|
1302
|
+
// --- Section 3: int8 blob (only live ids, matching JSON save() semantics) ---
|
|
1303
|
+
// [entryCount] then per entry [idIndex(u32), len(u32), bytes...]
|
|
1304
|
+
const liveIds = new Set(ids);
|
|
1305
|
+
const int8Parts = [];
|
|
1306
|
+
const idToIdx = new Map();
|
|
1307
|
+
for (let i = 0; i < n; i += 1) idToIdx.set(ids[i], i);
|
|
1308
|
+
let int8Count = 0;
|
|
1309
|
+
const int8Body = [];
|
|
1310
|
+
for (const [id, vec] of this.int8Vectors) {
|
|
1311
|
+
if (!liveIds.has(id)) continue;
|
|
1312
|
+
const idIndex = idToIdx.get(id);
|
|
1313
|
+
if (idIndex === undefined) continue;
|
|
1314
|
+
const entry = Buffer.allocUnsafe(8 + vec.length);
|
|
1315
|
+
entry.writeUInt32LE(idIndex, 0);
|
|
1316
|
+
entry.writeUInt32LE(vec.length, 4);
|
|
1317
|
+
// Int8Array → bytes (two's complement, identical layout)
|
|
1318
|
+
Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength).copy(entry, 8);
|
|
1319
|
+
int8Body.push(entry);
|
|
1320
|
+
int8Count += 1;
|
|
1321
|
+
}
|
|
1322
|
+
const int8Head = Buffer.allocUnsafe(4);
|
|
1323
|
+
int8Head.writeUInt32LE(int8Count, 0);
|
|
1324
|
+
int8Parts.push(int8Head, ...int8Body);
|
|
1325
|
+
const int8Blob = Buffer.concat(int8Parts);
|
|
1326
|
+
|
|
1327
|
+
// --- Header ---
|
|
1328
|
+
let vOff = 0;
|
|
1329
|
+
const gOff = vOff + vectorsBlob.length;
|
|
1330
|
+
const iOff = gOff + graphBlob.length;
|
|
1331
|
+
const header = {
|
|
1332
|
+
magic: 'SSHNSWP',
|
|
1333
|
+
formatVersion: MMAP_FORMAT_VERSION,
|
|
1334
|
+
dimension: this.dimension,
|
|
1335
|
+
floatDimension: this.floatDimension,
|
|
1336
|
+
M: this.M,
|
|
1337
|
+
efConstruction: this.efConstruction,
|
|
1338
|
+
efSearch: this.efSearch,
|
|
1339
|
+
maxElements: this.maxElements,
|
|
1340
|
+
vectorCount: n,
|
|
1341
|
+
maxLevel: this.maxLevel,
|
|
1342
|
+
entryPoint: this.entryPoint,
|
|
1343
|
+
useAsymmetric: this.useAsymmetric,
|
|
1344
|
+
pipelineVersion: PIPELINE_VERSION,
|
|
1345
|
+
savedAt: new Date().toISOString(),
|
|
1346
|
+
ids,
|
|
1347
|
+
metas,
|
|
1348
|
+
calibration: (this.useAsymmetric && this.centroid && this.signVector)
|
|
1349
|
+
? { centroid: Array.from(this.centroid), signVector: Array.from(this.signVector) }
|
|
1350
|
+
: null,
|
|
1351
|
+
sections: {
|
|
1352
|
+
vectors: { offset: vOff, len: vectorsBlob.length },
|
|
1353
|
+
graph: { offset: gOff, len: graphBlob.length },
|
|
1354
|
+
int8: { offset: iOff, len: int8Blob.length },
|
|
1355
|
+
},
|
|
1356
|
+
};
|
|
1357
|
+
const headerBuf = Buffer.from(JSON.stringify(header), 'utf-8');
|
|
1358
|
+
const prefix = Buffer.allocUnsafe(MMAP_MAGIC.length + 8);
|
|
1359
|
+
MMAP_MAGIC.copy(prefix, 0);
|
|
1360
|
+
prefix.writeUInt32LE(MMAP_FORMAT_VERSION, MMAP_MAGIC.length);
|
|
1361
|
+
prefix.writeUInt32LE(headerBuf.length, MMAP_MAGIC.length + 4);
|
|
1362
|
+
|
|
1363
|
+
const full = Buffer.concat([prefix, headerBuf, vectorsBlob, graphBlob, int8Blob]);
|
|
1364
|
+
|
|
1365
|
+
const tmpPath = `${indexPath}.tmp.${process.pid}`;
|
|
1366
|
+
await fs.writeFile(tmpPath, full);
|
|
1367
|
+
await fs.rename(tmpPath, indexPath);
|
|
1368
|
+
|
|
1369
|
+
// Retire any legacy JSON sidecars at this path so a stale descriptor can
|
|
1370
|
+
// never shadow the packed file on the next load. This is NOT a migration
|
|
1371
|
+
// of other indexes — it only cleans up the path we just wrote.
|
|
1372
|
+
await Promise.all([
|
|
1373
|
+
fs.rm(indexPath.replace('.idx', '.meta.json'), { force: true }),
|
|
1374
|
+
fs.rm(indexPath.replace('.idx', '.vectors.json'), { force: true }),
|
|
1375
|
+
fs.rm(indexPath.replace('.idx', '.graph.json'), { force: true }),
|
|
1376
|
+
fs.rm(indexPath.replace('.idx', '.int8.json'), { force: true }),
|
|
1377
|
+
fs.rm(indexPath.replace('.idx', '.calibration.json'), { force: true }),
|
|
1378
|
+
]);
|
|
1379
|
+
|
|
1380
|
+
if (this._cleanBuild) {
|
|
1381
|
+
await fs.rm(this._stalePathForIndex(indexPath), { force: true });
|
|
1382
|
+
this._staleBitmapCache = null;
|
|
1383
|
+
this._cleanBuild = false;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
console.log(`BinaryHNSW: Saved ${n} vectors to ${indexPath} (packed/mmap, asymmetric=${this.useAsymmetric})`);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Load a packed (mmap) index for read/search (G9).
|
|
1391
|
+
*
|
|
1392
|
+
* Keeps the `.idx` file descriptor OPEN and backs `vectors`/`graph` with
|
|
1393
|
+
* lazy fd-readers so a cold tick faults in only the pages a search visits
|
|
1394
|
+
* (touched-pages-only, near-zero heap until traversed). The header (ids,
|
|
1395
|
+
* metadata, calibration, section offsets) is small and parsed eagerly. The
|
|
1396
|
+
* resulting in-memory graph/entryPoint are byte-equivalent to what a
|
|
1397
|
+
* JSON-sidecar build of the same ids would hold, so search results are
|
|
1398
|
+
* identical.
|
|
1399
|
+
*/
|
|
1400
|
+
async loadMmap(indexPath = this.indexPath) {
|
|
1401
|
+
this._closeMmap();
|
|
1402
|
+
const fd = openSync(indexPath, 'r');
|
|
1403
|
+
try {
|
|
1404
|
+
const stat = fstatSync(fd);
|
|
1405
|
+
const prefix = Buffer.allocUnsafe(MMAP_MAGIC.length + 8);
|
|
1406
|
+
readSync(fd, prefix, 0, prefix.length, 0);
|
|
1407
|
+
if (!prefix.subarray(0, MMAP_MAGIC.length).equals(MMAP_MAGIC)) {
|
|
1408
|
+
throw new Error(`BinaryHNSW: not a packed index (bad magic): ${indexPath}`);
|
|
1409
|
+
}
|
|
1410
|
+
const fmt = prefix.readUInt32LE(MMAP_MAGIC.length);
|
|
1411
|
+
if (fmt !== MMAP_FORMAT_VERSION) {
|
|
1412
|
+
throw new Error(
|
|
1413
|
+
`BinaryHNSW: packed format version mismatch: file=${fmt}, current=${MMAP_FORMAT_VERSION}.`
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
const headerLen = prefix.readUInt32LE(MMAP_MAGIC.length + 4);
|
|
1417
|
+
const headerBuf = Buffer.allocUnsafe(headerLen);
|
|
1418
|
+
readSync(fd, headerBuf, 0, headerLen, prefix.length);
|
|
1419
|
+
const header = JSON.parse(headerBuf.toString('utf-8'));
|
|
1420
|
+
|
|
1421
|
+
const storedVersion = header.pipelineVersion || 1;
|
|
1422
|
+
if (storedVersion !== PIPELINE_VERSION) {
|
|
1423
|
+
throw new Error(
|
|
1424
|
+
`Pipeline version mismatch: index=${storedVersion}, current=${PIPELINE_VERSION}. `
|
|
1425
|
+
+ 'Index must be rebuilt (quantization pipeline changed).'
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
this.dimension = header.dimension;
|
|
1430
|
+
this.floatDimension = header.floatDimension;
|
|
1431
|
+
this.M = header.M;
|
|
1432
|
+
this.efConstruction = header.efConstruction;
|
|
1433
|
+
this.efSearch = header.efSearch;
|
|
1434
|
+
this.maxElements = header.maxElements;
|
|
1435
|
+
this.maxLevel = header.maxLevel;
|
|
1436
|
+
this.entryPoint = header.entryPoint;
|
|
1437
|
+
this.useAsymmetric = header.useAsymmetric || false;
|
|
1438
|
+
this.centroid = null;
|
|
1439
|
+
this.signVector = null;
|
|
1440
|
+
if (this.useAsymmetric && header.calibration) {
|
|
1441
|
+
this.centroid = new Float32Array(header.calibration.centroid);
|
|
1442
|
+
this.signVector = new Float32Array(header.calibration.signVector);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const sectionsBase = prefix.length + headerLen;
|
|
1446
|
+
const sec = header.sections;
|
|
1447
|
+
const n = header.vectorCount;
|
|
1448
|
+
const ids = header.ids;
|
|
1449
|
+
const metas = header.metas;
|
|
1450
|
+
|
|
1451
|
+
// Consistency probe mirroring the JSON load() invariant.
|
|
1452
|
+
if (ids.length !== n || (this.entryPoint !== -1 && this.entryPoint >= n)) {
|
|
1453
|
+
throw new Error(
|
|
1454
|
+
`BinaryHNSW: packed index inconsistency at ${indexPath} `
|
|
1455
|
+
+ `(vectorCount=${n}, ids=${ids.length}, entryPoint=${this.entryPoint})`
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// id → index map (resident; needed by getInt8Vector and searches).
|
|
1460
|
+
this.idToIndex.clear();
|
|
1461
|
+
for (let i = 0; i < n; i += 1) this.idToIndex.set(ids[i], i);
|
|
1462
|
+
|
|
1463
|
+
// Lazy vectors store (binary bytes faulted in per touch).
|
|
1464
|
+
const vectorStore = new MmapVectorStore(
|
|
1465
|
+
fd, sectionsBase + sec.vectors.offset, this.dimension, ids, metas
|
|
1466
|
+
);
|
|
1467
|
+
this.vectors = makeMmapVectorProxy(vectorStore);
|
|
1468
|
+
|
|
1469
|
+
// Build the per-(level,node) offset table for the graph section so each
|
|
1470
|
+
// touch is an O(1) seek. We read the section's structural integers once
|
|
1471
|
+
// (small relative to the neighbor bodies); neighbor LISTS stay on disk
|
|
1472
|
+
// until touched.
|
|
1473
|
+
const graphBase = sectionsBase + sec.graph.offset;
|
|
1474
|
+
const graphLevels = this._buildMmapGraphOffsets(fd, graphBase, sec.graph.len);
|
|
1475
|
+
this.graph = graphLevels;
|
|
1476
|
+
|
|
1477
|
+
// int8 vectors: small Int8Arrays, read eagerly into the resident Map
|
|
1478
|
+
// (getInt8Vector is an O(1) Map lookup on the hot rescore path; keeping
|
|
1479
|
+
// them resident matches the JSON path and avoids per-lookup seeks).
|
|
1480
|
+
this.int8Vectors.clear();
|
|
1481
|
+
if (sec.int8 && sec.int8.len >= 4) {
|
|
1482
|
+
const int8Base = sectionsBase + sec.int8.offset;
|
|
1483
|
+
const head = Buffer.allocUnsafe(4);
|
|
1484
|
+
readSync(fd, head, 0, 4, int8Base);
|
|
1485
|
+
const entryCount = head.readUInt32LE(0);
|
|
1486
|
+
let cursor = int8Base + 4;
|
|
1487
|
+
for (let e = 0; e < entryCount; e += 1) {
|
|
1488
|
+
const eh = Buffer.allocUnsafe(8);
|
|
1489
|
+
readSync(fd, eh, 0, 8, cursor);
|
|
1490
|
+
const idIndex = eh.readUInt32LE(0);
|
|
1491
|
+
const len = eh.readUInt32LE(4);
|
|
1492
|
+
const body = Buffer.allocUnsafe(len);
|
|
1493
|
+
readSync(fd, body, 0, len, cursor + 8);
|
|
1494
|
+
const vec = new Int8Array(len);
|
|
1495
|
+
for (let b = 0; b < len; b += 1) vec[b] = body.readInt8(b);
|
|
1496
|
+
this.int8Vectors.set(ids[idIndex], vec);
|
|
1497
|
+
cursor += 8 + len;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
this._mmapFd = fd;
|
|
1502
|
+
this._mmapBacked = true;
|
|
1503
|
+
this._visitedList.ensureCapacity(n);
|
|
1504
|
+
this.initialized = true;
|
|
1505
|
+
|
|
1506
|
+
// Touch the file size to keep the consistency contract auditable.
|
|
1507
|
+
void stat.size;
|
|
1508
|
+
console.log(`BinaryHNSW: Loaded ${n} vectors from ${indexPath} (packed/mmap, asymmetric=${this.useAsymmetric})`);
|
|
1509
|
+
} catch (err) {
|
|
1510
|
+
try { closeSync(fd); } catch { /* best-effort */ }
|
|
1511
|
+
this._mmapFd = null;
|
|
1512
|
+
this._mmapBacked = false;
|
|
1513
|
+
throw err;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* Scan the graph section's structural integers once and build a flat
|
|
1519
|
+
* per-(level,node) byte-offset table, returning an array of lazy level
|
|
1520
|
+
* proxies. Only the [levelCount]/[nodeCount]/[neighborCount] integers are
|
|
1521
|
+
* read here; the neighbor id lists themselves remain on disk until a level
|
|
1522
|
+
* proxy materializes a node on touch.
|
|
1523
|
+
*/
|
|
1524
|
+
_buildMmapGraphOffsets(fd, base, sectionLen) {
|
|
1525
|
+
const lvlHead = Buffer.allocUnsafe(4);
|
|
1526
|
+
readSync(fd, lvlHead, 0, 4, base);
|
|
1527
|
+
const levelCount = lvlHead.readUInt32LE(0);
|
|
1528
|
+
let cursor = 4; // relative to base
|
|
1529
|
+
const levels = new Array(levelCount);
|
|
1530
|
+
const four = Buffer.allocUnsafe(4);
|
|
1531
|
+
for (let l = 0; l < levelCount; l += 1) {
|
|
1532
|
+
readSync(fd, four, 0, 4, base + cursor);
|
|
1533
|
+
const nodeCount = four.readUInt32LE(0);
|
|
1534
|
+
cursor += 4;
|
|
1535
|
+
const nodeOffsets = new Int32Array(nodeCount);
|
|
1536
|
+
for (let i = 0; i < nodeCount; i += 1) {
|
|
1537
|
+
// Record this node's neighbor-list offset (points at its [count] word).
|
|
1538
|
+
nodeOffsets[i] = cursor;
|
|
1539
|
+
readSync(fd, four, 0, 4, base + cursor);
|
|
1540
|
+
const neighborCount = four.readUInt32LE(0);
|
|
1541
|
+
cursor += 4 + neighborCount * 4;
|
|
1542
|
+
}
|
|
1543
|
+
const level = new MmapGraphLevel(fd, base, nodeOffsets, nodeCount);
|
|
1544
|
+
levels[l] = makeMmapGraphLevelProxy(level);
|
|
1545
|
+
}
|
|
1546
|
+
if (cursor !== sectionLen) {
|
|
1547
|
+
// Structural mismatch → corrupt/truncated section.
|
|
1548
|
+
throw new Error(
|
|
1549
|
+
`BinaryHNSW: packed graph section length mismatch (read ${cursor}, expected ${sectionLen})`
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
return levels;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
930
1555
|
/**
|
|
931
1556
|
* Get index statistics
|
|
932
1557
|
*/
|
|
933
1558
|
getStats() {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1559
|
+
// On the packed mmap path each `this.graph[l]` is a lazy level proxy, not a
|
|
1560
|
+
// real Array, so Array.prototype.filter/reduce are unavailable and walking
|
|
1561
|
+
// it would also fault in every neighbor list (defeating mmap). Iterate by
|
|
1562
|
+
// index instead, which works for both representations.
|
|
1563
|
+
let graphNodes = 0;
|
|
1564
|
+
let graphEdges = 0;
|
|
1565
|
+
for (let l = 0; l < this.graph.length; l += 1) {
|
|
1566
|
+
const level = this.graph[l];
|
|
1567
|
+
if (!level) continue;
|
|
1568
|
+
for (let i = 0; i < level.length; i += 1) {
|
|
1569
|
+
const neighbors = level[i];
|
|
1570
|
+
if (neighbors) {
|
|
1571
|
+
graphNodes += 1;
|
|
1572
|
+
graphEdges += neighbors.length || 0;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
937
1576
|
|
|
938
1577
|
return {
|
|
939
1578
|
dimension: this.dimension,
|
|
@@ -957,6 +1596,7 @@ export class BinaryHNSWIndex {
|
|
|
957
1596
|
* Clear all data
|
|
958
1597
|
*/
|
|
959
1598
|
async clear() {
|
|
1599
|
+
this._closeMmap();
|
|
960
1600
|
this.vectors = [];
|
|
961
1601
|
this.idToIndex.clear();
|
|
962
1602
|
this.int8Vectors.clear();
|
|
@@ -967,6 +1607,14 @@ export class BinaryHNSWIndex {
|
|
|
967
1607
|
this._staleBitmapCache = null;
|
|
968
1608
|
this._cleanBuild = false;
|
|
969
1609
|
}
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* Release the packed (mmap) file descriptor, if any. The OS page cache may
|
|
1613
|
+
* retain touched pages; this only drops the fd. Safe no-op on the JSON path.
|
|
1614
|
+
*/
|
|
1615
|
+
close() {
|
|
1616
|
+
this._closeMmap();
|
|
1617
|
+
}
|
|
970
1618
|
}
|
|
971
1619
|
|
|
972
1620
|
// =============================================================================
|
|
@@ -991,11 +1639,44 @@ export async function createBinaryHNSWIndex(options = {}) {
|
|
|
991
1639
|
}
|
|
992
1640
|
|
|
993
1641
|
function binaryHnswArtifactsExist(indexPath) {
|
|
1642
|
+
// A packed (mmap) index is a single .idx file; the JSON format is the sidecar
|
|
1643
|
+
// tuple. Either is loadable, so report presence of EITHER. Detection is by
|
|
1644
|
+
// on-disk content (magic header) — never the flag — so old + new coexist.
|
|
1645
|
+
if (_indexFormatOnDisk(indexPath) === 'packed') return true;
|
|
994
1646
|
return existsSync(indexPath.replace('.idx', '.meta.json'))
|
|
995
1647
|
&& existsSync(indexPath.replace('.idx', '.vectors.json'))
|
|
996
1648
|
&& existsSync(indexPath.replace('.idx', '.graph.json'));
|
|
997
1649
|
}
|
|
998
1650
|
|
|
1651
|
+
/**
|
|
1652
|
+
* Detect the on-disk format of an HNSW index WITHOUT loading it, by content:
|
|
1653
|
+
* - 'packed' : the `.idx` file exists and begins with the MMAP magic header.
|
|
1654
|
+
* - 'json' : the JSON-sidecar descriptor (`.meta.json`) exists.
|
|
1655
|
+
* - 'none' : neither.
|
|
1656
|
+
* Reads only the leading magic bytes of the `.idx` (cheap), so the JSON path is
|
|
1657
|
+
* chosen for every legacy index regardless of SWEET_SEARCH_HNSW_MMAP — the
|
|
1658
|
+
* guarantee that existing on-disk indexes load unchanged with no migration.
|
|
1659
|
+
*/
|
|
1660
|
+
function _indexFormatOnDisk(indexPath) {
|
|
1661
|
+
try {
|
|
1662
|
+
if (existsSync(indexPath)) {
|
|
1663
|
+
const st = statSync(indexPath);
|
|
1664
|
+
if (st.isFile() && st.size >= MMAP_MAGIC.length) {
|
|
1665
|
+
const fd = openSync(indexPath, 'r');
|
|
1666
|
+
try {
|
|
1667
|
+
const head = Buffer.allocUnsafe(MMAP_MAGIC.length);
|
|
1668
|
+
readSync(fd, head, 0, MMAP_MAGIC.length, 0);
|
|
1669
|
+
if (head.equals(MMAP_MAGIC)) return 'packed';
|
|
1670
|
+
} finally {
|
|
1671
|
+
closeSync(fd);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
} catch { /* fall through to JSON detection */ }
|
|
1676
|
+
if (existsSync(indexPath.replace('.idx', '.meta.json'))) return 'json';
|
|
1677
|
+
return 'none';
|
|
1678
|
+
}
|
|
1679
|
+
|
|
999
1680
|
// =============================================================================
|
|
1000
1681
|
// CLI
|
|
1001
1682
|
// =============================================================================
|
|
@@ -1085,75 +1766,8 @@ Options:
|
|
|
1085
1766
|
console.log(` Equivalent float: ${(numVectors * floatDim * 4 / 1024 / 1024).toFixed(2)} MB`);
|
|
1086
1767
|
console.log(` Compression: ${(floatDim * 4 / binaryDim).toFixed(0)}x`);
|
|
1087
1768
|
|
|
1088
|
-
} else if (command === 'compare') {
|
|
1089
|
-
console.log('\n=== Binary vs Float HNSW Comparison ===\n');
|
|
1090
|
-
|
|
1091
|
-
const { HNSWIndex } = await import('./hnsw-index.js');
|
|
1092
|
-
|
|
1093
|
-
const numVectors = 5000;
|
|
1094
|
-
const floatDim = 512;
|
|
1095
|
-
|
|
1096
|
-
console.log(`Testing with ${numVectors} vectors, ${floatDim} dimensions\n`);
|
|
1097
|
-
|
|
1098
|
-
// Generate vectors
|
|
1099
|
-
const vectors = [];
|
|
1100
|
-
for (let i = 0; i < numVectors; i++) {
|
|
1101
|
-
const float = new Array(floatDim).fill(0).map(() => Math.random() * 2 - 1);
|
|
1102
|
-
vectors.push({ id: `vec-${i}`, float });
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
// Binary index
|
|
1106
|
-
const binaryIndex = new BinaryHNSWIndex({ dimension: Math.ceil(floatDim / 8), floatDimension: floatDim });
|
|
1107
|
-
await binaryIndex.init();
|
|
1108
|
-
|
|
1109
|
-
console.log('Building binary index...');
|
|
1110
|
-
let start = performance.now();
|
|
1111
|
-
for (const v of vectors) {
|
|
1112
|
-
await binaryIndex.add(v.id, floatToBinary(v.float));
|
|
1113
|
-
}
|
|
1114
|
-
const binaryBuildTime = performance.now() - start;
|
|
1115
|
-
|
|
1116
|
-
// Float index
|
|
1117
|
-
const floatIndex = new HNSWIndex({ dimension: floatDim });
|
|
1118
|
-
await floatIndex.init();
|
|
1119
|
-
|
|
1120
|
-
console.log('Building float index...');
|
|
1121
|
-
start = performance.now();
|
|
1122
|
-
for (const v of vectors) {
|
|
1123
|
-
await floatIndex.add(v.id, v.float);
|
|
1124
|
-
}
|
|
1125
|
-
const floatBuildTime = performance.now() - start;
|
|
1126
|
-
|
|
1127
|
-
console.log(`\nBuild time: Binary ${binaryBuildTime.toFixed(0)}ms, Float ${floatBuildTime.toFixed(0)}ms`);
|
|
1128
|
-
|
|
1129
|
-
// Search comparison
|
|
1130
|
-
const numQueries = 50;
|
|
1131
|
-
const binaryLatencies = [];
|
|
1132
|
-
const floatLatencies = [];
|
|
1133
|
-
|
|
1134
|
-
for (let i = 0; i < numQueries; i++) {
|
|
1135
|
-
const queryFloat = new Array(floatDim).fill(0).map(() => Math.random() * 2 - 1);
|
|
1136
|
-
|
|
1137
|
-
const binaryResult = await binaryIndex.search(floatToBinary(queryFloat), 10);
|
|
1138
|
-
binaryLatencies.push(binaryResult.latency_us);
|
|
1139
|
-
|
|
1140
|
-
const floatResult = await floatIndex.search(queryFloat, 10);
|
|
1141
|
-
floatLatencies.push(floatResult.latency_us);
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
const binaryP50 = binaryLatencies.sort((a, b) => a - b)[Math.floor(numQueries * 0.5)];
|
|
1145
|
-
const floatP50 = floatLatencies.sort((a, b) => a - b)[Math.floor(numQueries * 0.5)];
|
|
1146
|
-
|
|
1147
|
-
console.log(`\nSearch latency p50: Binary ${binaryP50}μs, Float ${floatP50}μs`);
|
|
1148
|
-
console.log(`Speedup: ${(floatP50 / binaryP50).toFixed(1)}x`);
|
|
1149
|
-
|
|
1150
|
-
const binaryMem = numVectors * Math.ceil(floatDim / 8);
|
|
1151
|
-
const floatMem = numVectors * floatDim * 4;
|
|
1152
|
-
console.log(`\nMemory: Binary ${(binaryMem / 1024).toFixed(0)} KB, Float ${(floatMem / 1024).toFixed(0)} KB`);
|
|
1153
|
-
console.log(`Compression: ${(floatMem / binaryMem).toFixed(0)}x`);
|
|
1154
|
-
|
|
1155
1769
|
} else {
|
|
1156
|
-
console.log('Unknown command. Use: stats, test
|
|
1770
|
+
console.log('Unknown command. Use: stats, test');
|
|
1157
1771
|
}
|
|
1158
1772
|
} catch (err) {
|
|
1159
1773
|
console.error('Error:', err.message);
|