verso-db 0.1.4 → 0.2.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +81 -49
  3. package/dist/BinaryHeap.d.ts +16 -5
  4. package/dist/BinaryHeap.d.ts.map +1 -1
  5. package/dist/BinaryHeap.js +138 -0
  6. package/dist/BinaryHeap.js.map +1 -0
  7. package/dist/Collection.d.ts +98 -17
  8. package/dist/Collection.d.ts.map +1 -1
  9. package/dist/Collection.js +1186 -0
  10. package/dist/Collection.js.map +1 -0
  11. package/dist/HNSWIndex.d.ts +170 -15
  12. package/dist/HNSWIndex.d.ts.map +1 -1
  13. package/dist/HNSWIndex.js +2818 -0
  14. package/dist/HNSWIndex.js.map +1 -0
  15. package/dist/MaxBinaryHeap.d.ts +2 -60
  16. package/dist/MaxBinaryHeap.d.ts.map +1 -1
  17. package/dist/MaxBinaryHeap.js +5 -0
  18. package/dist/MaxBinaryHeap.js.map +1 -0
  19. package/dist/SearchWorker.d.ts +104 -0
  20. package/dist/SearchWorker.d.ts.map +1 -0
  21. package/dist/SearchWorker.js +573 -0
  22. package/dist/SearchWorker.js.map +1 -0
  23. package/dist/VectorDB.d.ts +19 -5
  24. package/dist/VectorDB.d.ts.map +1 -1
  25. package/dist/VectorDB.js +246 -0
  26. package/dist/VectorDB.js.map +1 -0
  27. package/dist/WorkerPool.d.ts +92 -0
  28. package/dist/WorkerPool.d.ts.map +1 -0
  29. package/dist/WorkerPool.js +266 -0
  30. package/dist/WorkerPool.js.map +1 -0
  31. package/dist/backends/JsDistanceBackend.d.ts +3 -20
  32. package/dist/backends/JsDistanceBackend.d.ts.map +1 -1
  33. package/dist/backends/JsDistanceBackend.js +163 -0
  34. package/dist/backends/JsDistanceBackend.js.map +1 -0
  35. package/dist/encoding/DeltaEncoder.d.ts +2 -2
  36. package/dist/encoding/DeltaEncoder.d.ts.map +1 -1
  37. package/dist/encoding/DeltaEncoder.js +199 -0
  38. package/dist/encoding/DeltaEncoder.js.map +1 -0
  39. package/dist/errors.js +97 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/index.d.ts +16 -17
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +61 -3419
  44. package/dist/index.js.map +1 -0
  45. package/dist/presets.d.ts +9 -9
  46. package/dist/presets.d.ts.map +1 -1
  47. package/dist/presets.js +205 -0
  48. package/dist/presets.js.map +1 -0
  49. package/dist/quantization/ScalarQuantizer.d.ts +10 -34
  50. package/dist/quantization/ScalarQuantizer.d.ts.map +1 -1
  51. package/dist/quantization/ScalarQuantizer.js +346 -0
  52. package/dist/quantization/ScalarQuantizer.js.map +1 -0
  53. package/dist/storage/BatchWriter.d.ts.map +1 -1
  54. package/dist/storage/BatchWriter.js +351 -0
  55. package/dist/storage/BatchWriter.js.map +1 -0
  56. package/dist/storage/BunStorageBackend.d.ts +12 -5
  57. package/dist/storage/BunStorageBackend.d.ts.map +1 -1
  58. package/dist/storage/BunStorageBackend.js +182 -0
  59. package/dist/storage/BunStorageBackend.js.map +1 -0
  60. package/dist/storage/MemoryBackend.d.ts.map +1 -1
  61. package/dist/storage/MemoryBackend.js +109 -0
  62. package/dist/storage/MemoryBackend.js.map +1 -0
  63. package/dist/storage/OPFSBackend.d.ts +9 -1
  64. package/dist/storage/OPFSBackend.d.ts.map +1 -1
  65. package/dist/storage/OPFSBackend.js +325 -0
  66. package/dist/storage/OPFSBackend.js.map +1 -0
  67. package/dist/storage/StorageBackend.d.ts +1 -1
  68. package/dist/storage/StorageBackend.js +12 -0
  69. package/dist/storage/StorageBackend.js.map +1 -0
  70. package/dist/storage/WriteAheadLog.d.ts +15 -11
  71. package/dist/storage/WriteAheadLog.d.ts.map +1 -1
  72. package/dist/storage/WriteAheadLog.js +321 -0
  73. package/dist/storage/WriteAheadLog.js.map +1 -0
  74. package/dist/storage/createStorageBackend.d.ts +4 -0
  75. package/dist/storage/createStorageBackend.d.ts.map +1 -1
  76. package/dist/storage/createStorageBackend.js +119 -0
  77. package/dist/storage/createStorageBackend.js.map +1 -0
  78. package/dist/storage/index.d.ts +3 -3
  79. package/dist/storage/index.js +33 -0
  80. package/dist/storage/index.js.map +1 -0
  81. package/dist/storage/nodeFsRuntime.d.ts +14 -0
  82. package/dist/storage/nodeFsRuntime.d.ts.map +1 -0
  83. package/dist/storage/nodeFsRuntime.js +105 -0
  84. package/dist/storage/nodeFsRuntime.js.map +1 -0
  85. package/package.json +47 -23
  86. package/dist/Storage.d.ts +0 -54
  87. package/dist/Storage.d.ts.map +0 -1
  88. package/dist/backends/DistanceBackend.d.ts +0 -5
  89. package/dist/backends/DistanceBackend.d.ts.map +0 -1
  90. package/src/BinaryHeap.ts +0 -131
  91. package/src/Collection.ts +0 -695
  92. package/src/HNSWIndex.ts +0 -1839
  93. package/src/MaxBinaryHeap.ts +0 -175
  94. package/src/Storage.ts +0 -435
  95. package/src/VectorDB.ts +0 -109
  96. package/src/backends/DistanceBackend.ts +0 -17
  97. package/src/backends/JsDistanceBackend.ts +0 -227
  98. package/src/encoding/DeltaEncoder.ts +0 -217
  99. package/src/errors.ts +0 -110
  100. package/src/index.ts +0 -138
  101. package/src/presets.ts +0 -229
  102. package/src/quantization/ScalarQuantizer.ts +0 -383
  103. package/src/storage/BatchWriter.ts +0 -336
  104. package/src/storage/BunStorageBackend.ts +0 -161
  105. package/src/storage/MemoryBackend.ts +0 -120
  106. package/src/storage/OPFSBackend.ts +0 -250
  107. package/src/storage/StorageBackend.ts +0 -74
  108. package/src/storage/WriteAheadLog.ts +0 -326
  109. package/src/storage/createStorageBackend.ts +0 -137
  110. package/src/storage/index.ts +0 -53
@@ -0,0 +1,2818 @@
1
+ // Bun-native file operations - no fs import needed
2
+ import { dotProductFast, l2SquaredFast, normalizeInPlace } from './backends/JsDistanceBackend.js';
3
+ import { BinaryHeap } from './BinaryHeap.js';
4
+ import { MaxBinaryHeap } from './MaxBinaryHeap.js';
5
+ import { ScalarQuantizer, l2SquaredInt8, cosineDistanceInt8, dotProductInt8 } from './quantization/ScalarQuantizer.js';
6
+ import { deltaEncodeNeighbors, deltaDecodeNeighbors } from './encoding/DeltaEncoder.js';
7
+ import { VectorDBError } from './errors.js';
8
+ export class HNSWIndex {
9
+ // 0xFFFFFFFF is reserved as "missing result" sentinel in batch flat output.
10
+ static MISSING_ID_SENTINEL = 0xFFFFFFFF;
11
+ static MAX_NODE_ID = HNSWIndex.MISSING_ID_SENTINEL - 1;
12
+ static MAX_ARRAY_BUFFER_BYTES = 0x7FFFFFFF;
13
+ static MAX_NODE_SLOTS = 50_000_000;
14
+ /**
15
+ * Allocate a Float32Array backed by SharedArrayBuffer when available.
16
+ * This allows workers to read vector data without copying.
17
+ */
18
+ static allocateFloat32(length, useSharedMemory = false) {
19
+ if (useSharedMemory && typeof SharedArrayBuffer !== 'undefined') {
20
+ return new Float32Array(new SharedArrayBuffer(length * 4));
21
+ }
22
+ return new Float32Array(length);
23
+ }
24
+ /**
25
+ * Allocate an Int8Array, optionally backed by SharedArrayBuffer.
26
+ */
27
+ static allocateInt8(length, useSharedMemory = false) {
28
+ if (useSharedMemory && typeof SharedArrayBuffer !== 'undefined') {
29
+ return new Int8Array(new SharedArrayBuffer(length));
30
+ }
31
+ return new Int8Array(length);
32
+ }
33
+ /** Whether flatVectors/flatInt8Vectors use SharedArrayBuffer */
34
+ useSharedMemory = false;
35
+ M; // Max number of connections per node per level
36
+ M0; // Max number of connections for level 0 (typically M * 2)
37
+ efConstruction; // Size of candidate list during construction
38
+ levelMult; // Probability multiplier for level selection
39
+ maxLevel;
40
+ entryPointId;
41
+ // OPTIMIZATION: Use array instead of Map for O(1) indexed access (3-5x faster than Map.get)
42
+ nodes;
43
+ nodeCount = 0;
44
+ nextAutoId = 0;
45
+ dimension;
46
+ metric;
47
+ maxLayers;
48
+ // OPTIMIZATION: Flat vector storage for cache-friendly batch distance calculations
49
+ // All vectors stored contiguously: [v0_d0, v0_d1, ..., v1_d0, v1_d1, ...]
50
+ flatVectors;
51
+ flatVectorsCapacity = 0;
52
+ // TypedArray for fast visited tracking - much faster than Set<number>
53
+ // Uint16Array allows generation wrap at 65000 instead of 250, reducing fill(0) frequency ~260x
54
+ visitedArray;
55
+ visitedArraySize;
56
+ visitedGeneration = 0; // Increment to "clear" without filling
57
+ // Reusable heaps for searchLayer - avoids allocation on every call
58
+ candidatesHeap;
59
+ resultsHeap;
60
+ selectionHeap; // Reusable heap for selectNeighbors
61
+ heapCapacity;
62
+ // Pre-normalization optimization for cosine distance
63
+ vectorsAreNormalized = false;
64
+ // Cached distance function to avoid switch overhead
65
+ distanceFn;
66
+ // Quantization support for 3-4x faster search with Int8
67
+ scalarQuantizer = null;
68
+ // OPTIMIZATION: Use array instead of Map for int8 vectors too
69
+ int8Vectors = [];
70
+ quantizationEnabled = false;
71
+ // OPTIMIZATION: Contiguous Int8 storage for cache-friendly batch distance calculations
72
+ // Layout: [v0_d0...v0_dn, v1_d0...v1_dn, ...], offset: nodeId * dim
73
+ flatInt8Vectors = null;
74
+ flatInt8VectorsCapacity = 0;
75
+ // Reusable Int8 query buffer for quantized search — avoids allocation per query
76
+ queryInt8Buffer = null;
77
+ // Lazy loading support (v3+ format)
78
+ lazyLoadEnabled = false;
79
+ vectorOffsets = new Map(); // nodeId -> byte offset in file
80
+ vectorBuffer = null; // Cached buffer for lazy loading
81
+ vectorsLoaded = new Set(); // Track which vectors are loaded
82
+ // Reusable query buffer for search operations - avoids allocation per query
83
+ // Profiling showed 17% improvement with buffer reuse (99.5ms → 82.7ms for 1000 queries)
84
+ queryNormBuffer;
85
+ // Bulk construction optimization - O(1) neighbor lookup during construction
86
+ // Uses parallel Set<number>[] alongside neighbor arrays for fast membership testing
87
+ // Memory is released after construction completes
88
+ neighborSets = new Map();
89
+ // Construction-time quantization: use int8 search at layer 0 during bulk insert
90
+ // Set by addPointsBulkSync when useInt8Construction option is enabled
91
+ useInt8Construction = false;
92
+ constructionMode = false;
93
+ // Adaptive efSearch calibration stats
94
+ calibrationStats = null;
95
+ constructor(dimension, metric = 'cosine', M = 24, efConstruction = 200) {
96
+ if (dimension <= 0 || !Number.isInteger(dimension)) {
97
+ throw new VectorDBError(`Invalid dimension: must be a positive integer, got ${dimension}`, 'VALIDATION_ERROR');
98
+ }
99
+ if (M <= 0 || !Number.isInteger(M)) {
100
+ throw new VectorDBError(`Invalid M parameter: must be a positive integer, got ${M}`, 'VALIDATION_ERROR');
101
+ }
102
+ if (efConstruction <= 0 || !Number.isInteger(efConstruction)) {
103
+ throw new VectorDBError(`Invalid efConstruction parameter: must be a positive integer, got ${efConstruction}`, 'VALIDATION_ERROR');
104
+ }
105
+ this.dimension = dimension;
106
+ this.metric = metric;
107
+ this.M = M;
108
+ this.M0 = M * 2;
109
+ this.efConstruction = efConstruction;
110
+ this.levelMult = 1 / Math.log(M);
111
+ this.maxLevel = -1;
112
+ this.entryPointId = -1;
113
+ // OPTIMIZATION: Pre-allocate node array with initial capacity
114
+ const initialCapacity = 10000;
115
+ this.nodes = new Array(initialCapacity);
116
+ this.nodeCount = 0;
117
+ this.nextAutoId = 0;
118
+ // OPTIMIZATION: Pre-allocate flat vector storage
119
+ // Use SharedArrayBuffer when available so workers can read vectors without copying
120
+ this.flatVectorsCapacity = initialCapacity;
121
+ this.flatVectors = HNSWIndex.allocateFloat32(initialCapacity * dimension);
122
+ this.maxLayers = 32; // Maximum possible layers to pre-allocate
123
+ // Initialize visited tracking with TypedArray for speed
124
+ // Start with reasonable size, will grow as needed
125
+ this.visitedArraySize = 10000;
126
+ this.visitedArray = new Uint16Array(this.visitedArraySize);
127
+ this.visitedGeneration = 1;
128
+ // Pre-allocate searchLayer heaps - sized for typical ef values
129
+ // Will be resized if needed for larger ef
130
+ this.heapCapacity = Math.max(efConstruction * 2, 500);
131
+ this.candidatesHeap = new BinaryHeap(this.heapCapacity);
132
+ this.resultsHeap = new MaxBinaryHeap(this.heapCapacity);
133
+ // Selection heap sized for M0 (largest neighbor list size)
134
+ this.selectionHeap = new BinaryHeap(Math.max(M * 2, efConstruction));
135
+ // For cosine metric, we pre-normalize vectors for faster distance computation
136
+ this.vectorsAreNormalized = (metric === 'cosine');
137
+ // Pre-allocate query normalization buffer - reused across all searches
138
+ this.queryNormBuffer = new Float32Array(dimension);
139
+ // Initialize cached distance function based on metric
140
+ // This avoids switch statement overhead on every distance calculation
141
+ if (metric === 'cosine') {
142
+ // For cosine with pre-normalized vectors, just compute 1 - dot product
143
+ this.distanceFn = (a, b) => {
144
+ const dot = dotProductFast(a, b);
145
+ const distance = 1 - dot;
146
+ return distance < 1e-10 ? 0 : distance;
147
+ };
148
+ }
149
+ else if (metric === 'euclidean') {
150
+ this.distanceFn = (a, b) => {
151
+ return Math.sqrt(l2SquaredFast(a, b));
152
+ };
153
+ }
154
+ else if (metric === 'dot_product') {
155
+ this.distanceFn = (a, b) => {
156
+ return -dotProductFast(a, b);
157
+ };
158
+ }
159
+ else {
160
+ throw new VectorDBError(`Unsupported metric: ${metric}`, 'VALIDATION_ERROR');
161
+ }
162
+ }
163
+ // ============================================
164
+ // OPTIMIZATION: Capacity and flat storage helpers
165
+ // ============================================
166
+ /**
167
+ * Ensure node array and flat vector storage have enough capacity
168
+ */
169
+ ensureCapacity(minCapacity) {
170
+ const projectedVectorBytes = minCapacity * this.dimension * 4;
171
+ if (minCapacity > HNSWIndex.MAX_NODE_SLOTS ||
172
+ !Number.isSafeInteger(projectedVectorBytes) ||
173
+ projectedVectorBytes > HNSWIndex.MAX_ARRAY_BUFFER_BYTES) {
174
+ throw new VectorDBError(`Node ID allocation would exceed supported index capacity (requested ${minCapacity} slots at dimension ${this.dimension}). Use dense/sequential IDs or smaller batches.`, 'VALIDATION_ERROR');
175
+ }
176
+ // Grow node array if needed
177
+ if (minCapacity > this.nodes.length) {
178
+ const newCapacity = Math.max(this.nodes.length * 2, minCapacity);
179
+ const newNodes = new Array(newCapacity);
180
+ for (let i = 0; i < this.nodeCount; i++) {
181
+ newNodes[i] = this.nodes[i];
182
+ }
183
+ this.nodes = newNodes;
184
+ }
185
+ // Grow flat vector storage if needed
186
+ if (minCapacity > this.flatVectorsCapacity) {
187
+ const newCapacity = Math.max(this.flatVectorsCapacity * 2, minCapacity);
188
+ const newFlatVectors = HNSWIndex.allocateFloat32(newCapacity * this.dimension, this.useSharedMemory);
189
+ newFlatVectors.set(this.flatVectors);
190
+ this.flatVectors = newFlatVectors;
191
+ this.flatVectorsCapacity = newCapacity;
192
+ }
193
+ // Grow contiguous Int8 storage if quantization is enabled
194
+ if (this.quantizationEnabled && this.flatInt8Vectors !== null && minCapacity > this.flatInt8VectorsCapacity) {
195
+ const newCapacity = Math.max(this.flatInt8VectorsCapacity * 2, minCapacity);
196
+ const newFlatInt8 = HNSWIndex.allocateInt8(newCapacity * this.dimension, this.useSharedMemory);
197
+ newFlatInt8.set(this.flatInt8Vectors);
198
+ this.flatInt8Vectors = newFlatInt8;
199
+ this.flatInt8VectorsCapacity = newCapacity;
200
+ }
201
+ }
202
+ /**
203
+ * Set vector in flat storage
204
+ */
205
+ setFlatVector(nodeId, vector) {
206
+ const offset = nodeId * this.dimension;
207
+ this.flatVectors.set(vector, offset);
208
+ }
209
+ /**
210
+ * Set node by ID
211
+ */
212
+ setNode(node) {
213
+ const id = node.id;
214
+ this.ensureCapacity(id + 1);
215
+ this.nodes[id] = node;
216
+ // Store vector in flat storage too
217
+ this.setFlatVector(id, node.vector);
218
+ // Track node count
219
+ if (id >= this.nodeCount) {
220
+ this.nodeCount = id + 1;
221
+ }
222
+ if (id >= this.nextAutoId) {
223
+ this.nextAutoId = id + 1;
224
+ }
225
+ }
226
+ // OPTIMIZATION: Reusable arrays for batch distance calculation
227
+ batchNeighborIds = [];
228
+ batchDistances = [];
229
+ /**
230
+ * OPTIMIZATION: Batch distance calculation for better cache locality
231
+ * Computes distances from query to multiple neighbors at once
232
+ * Uses flat vector storage for contiguous memory access
233
+ */
234
+ calculateDistancesBatch(query, neighborIds, outDistances, count) {
235
+ const dim = this.dimension;
236
+ const flatVectors = this.flatVectors;
237
+ const len = count ?? neighborIds.length;
238
+ for (let i = 0; i < len; i++) {
239
+ const neighborId = neighborIds[i];
240
+ const offset = neighborId * dim;
241
+ // Inline distance calculation for better performance
242
+ // This avoids function call overhead per neighbor
243
+ if (this.metric === 'cosine') {
244
+ // Pre-normalized vectors: distance = 1 - dot(a, b)
245
+ let sum0 = 0, sum1 = 0, sum2 = 0, sum3 = 0;
246
+ let sum4 = 0, sum5 = 0, sum6 = 0, sum7 = 0;
247
+ let d = 0;
248
+ const limit8 = dim - 7;
249
+ for (; d < limit8; d += 8) {
250
+ sum0 += flatVectors[offset + d] * query[d];
251
+ sum1 += flatVectors[offset + d + 1] * query[d + 1];
252
+ sum2 += flatVectors[offset + d + 2] * query[d + 2];
253
+ sum3 += flatVectors[offset + d + 3] * query[d + 3];
254
+ sum4 += flatVectors[offset + d + 4] * query[d + 4];
255
+ sum5 += flatVectors[offset + d + 5] * query[d + 5];
256
+ sum6 += flatVectors[offset + d + 6] * query[d + 6];
257
+ sum7 += flatVectors[offset + d + 7] * query[d + 7];
258
+ }
259
+ for (; d < dim; d++) {
260
+ sum0 += flatVectors[offset + d] * query[d];
261
+ }
262
+ const dot = sum0 + sum1 + sum2 + sum3 + sum4 + sum5 + sum6 + sum7;
263
+ const dist = 1 - dot;
264
+ outDistances[i] = dist < 1e-10 ? 0 : dist;
265
+ }
266
+ else if (this.metric === 'euclidean') {
267
+ // L2 squared distance
268
+ let sum0 = 0, sum1 = 0, sum2 = 0, sum3 = 0;
269
+ let sum4 = 0, sum5 = 0, sum6 = 0, sum7 = 0;
270
+ let d = 0;
271
+ const limit8 = dim - 7;
272
+ for (; d < limit8; d += 8) {
273
+ const d0 = flatVectors[offset + d] - query[d];
274
+ const d1 = flatVectors[offset + d + 1] - query[d + 1];
275
+ const d2 = flatVectors[offset + d + 2] - query[d + 2];
276
+ const d3 = flatVectors[offset + d + 3] - query[d + 3];
277
+ const d4 = flatVectors[offset + d + 4] - query[d + 4];
278
+ const d5 = flatVectors[offset + d + 5] - query[d + 5];
279
+ const d6 = flatVectors[offset + d + 6] - query[d + 6];
280
+ const d7 = flatVectors[offset + d + 7] - query[d + 7];
281
+ sum0 += d0 * d0;
282
+ sum1 += d1 * d1;
283
+ sum2 += d2 * d2;
284
+ sum3 += d3 * d3;
285
+ sum4 += d4 * d4;
286
+ sum5 += d5 * d5;
287
+ sum6 += d6 * d6;
288
+ sum7 += d7 * d7;
289
+ }
290
+ for (; d < dim; d++) {
291
+ const diff = flatVectors[offset + d] - query[d];
292
+ sum0 += diff * diff;
293
+ }
294
+ outDistances[i] = Math.sqrt(sum0 + sum1 + sum2 + sum3 + sum4 + sum5 + sum6 + sum7);
295
+ }
296
+ else {
297
+ // dot_product: negative dot product
298
+ let sum0 = 0, sum1 = 0, sum2 = 0, sum3 = 0;
299
+ let sum4 = 0, sum5 = 0, sum6 = 0, sum7 = 0;
300
+ let d = 0;
301
+ const limit8 = dim - 7;
302
+ for (; d < limit8; d += 8) {
303
+ sum0 += flatVectors[offset + d] * query[d];
304
+ sum1 += flatVectors[offset + d + 1] * query[d + 1];
305
+ sum2 += flatVectors[offset + d + 2] * query[d + 2];
306
+ sum3 += flatVectors[offset + d + 3] * query[d + 3];
307
+ sum4 += flatVectors[offset + d + 4] * query[d + 4];
308
+ sum5 += flatVectors[offset + d + 5] * query[d + 5];
309
+ sum6 += flatVectors[offset + d + 6] * query[d + 6];
310
+ sum7 += flatVectors[offset + d + 7] * query[d + 7];
311
+ }
312
+ for (; d < dim; d++) {
313
+ sum0 += flatVectors[offset + d] * query[d];
314
+ }
315
+ outDistances[i] = -(sum0 + sum1 + sum2 + sum3 + sum4 + sum5 + sum6 + sum7);
316
+ }
317
+ }
318
+ }
319
+ /**
320
+ * OPTIMIZATION: Batch Int8 distance calculation for cache-friendly quantized search.
321
+ * Mirrors calculateDistancesBatch but operates on contiguous flatInt8Vectors.
322
+ *
323
+ * For cosine (pre-normalized) and dot_product: uses -dotProductInt8 (1 sum, 8-wide)
324
+ * For euclidean: uses l2Squared on int8 (8-wide unrolled)
325
+ */
326
+ calculateDistancesBatchInt8(queryInt8, neighborIds, outDistances, count) {
327
+ const dim = this.dimension;
328
+ const flatInt8 = this.flatInt8Vectors;
329
+ if (this.metric === 'euclidean') {
330
+ // L2 squared distance on int8
331
+ for (let i = 0; i < count; i++) {
332
+ const offset = neighborIds[i] * dim;
333
+ let sum0 = 0, sum1 = 0, sum2 = 0, sum3 = 0;
334
+ let sum4 = 0, sum5 = 0, sum6 = 0, sum7 = 0;
335
+ let d = 0;
336
+ const limit8 = dim - 7;
337
+ for (; d < limit8; d += 8) {
338
+ const d0 = queryInt8[d] - flatInt8[offset + d];
339
+ const d1 = queryInt8[d + 1] - flatInt8[offset + d + 1];
340
+ const d2 = queryInt8[d + 2] - flatInt8[offset + d + 2];
341
+ const d3 = queryInt8[d + 3] - flatInt8[offset + d + 3];
342
+ const d4 = queryInt8[d + 4] - flatInt8[offset + d + 4];
343
+ const d5 = queryInt8[d + 5] - flatInt8[offset + d + 5];
344
+ const d6 = queryInt8[d + 6] - flatInt8[offset + d + 6];
345
+ const d7 = queryInt8[d + 7] - flatInt8[offset + d + 7];
346
+ sum0 += d0 * d0;
347
+ sum1 += d1 * d1;
348
+ sum2 += d2 * d2;
349
+ sum3 += d3 * d3;
350
+ sum4 += d4 * d4;
351
+ sum5 += d5 * d5;
352
+ sum6 += d6 * d6;
353
+ sum7 += d7 * d7;
354
+ }
355
+ for (; d < dim; d++) {
356
+ const diff = queryInt8[d] - flatInt8[offset + d];
357
+ sum0 += diff * diff;
358
+ }
359
+ outDistances[i] = Math.sqrt(sum0 + sum1 + sum2 + sum3 + sum4 + sum5 + sum6 + sum7);
360
+ }
361
+ }
362
+ else {
363
+ // cosine (pre-normalized) and dot_product both use -dotProduct as distance
364
+ // cosine: vectors are pre-normalized, so distance ≈ -dot (higher dot = closer)
365
+ // dot_product: distance = -dot (standard convention)
366
+ for (let i = 0; i < count; i++) {
367
+ const offset = neighborIds[i] * dim;
368
+ let sum0 = 0, sum1 = 0, sum2 = 0, sum3 = 0;
369
+ let sum4 = 0, sum5 = 0, sum6 = 0, sum7 = 0;
370
+ let d = 0;
371
+ const limit8 = dim - 7;
372
+ for (; d < limit8; d += 8) {
373
+ sum0 += queryInt8[d] * flatInt8[offset + d];
374
+ sum1 += queryInt8[d + 1] * flatInt8[offset + d + 1];
375
+ sum2 += queryInt8[d + 2] * flatInt8[offset + d + 2];
376
+ sum3 += queryInt8[d + 3] * flatInt8[offset + d + 3];
377
+ sum4 += queryInt8[d + 4] * flatInt8[offset + d + 4];
378
+ sum5 += queryInt8[d + 5] * flatInt8[offset + d + 5];
379
+ sum6 += queryInt8[d + 6] * flatInt8[offset + d + 6];
380
+ sum7 += queryInt8[d + 7] * flatInt8[offset + d + 7];
381
+ }
382
+ for (; d < dim; d++) {
383
+ sum0 += queryInt8[d] * flatInt8[offset + d];
384
+ }
385
+ outDistances[i] = -(sum0 + sum1 + sum2 + sum3 + sum4 + sum5 + sum6 + sum7);
386
+ }
387
+ }
388
+ }
389
+ /**
390
+ * Check if a node has been visited in the current search.
391
+ * Uses generation counting to avoid clearing the array.
392
+ */
393
+ isVisited(id) {
394
+ if (id >= this.visitedArraySize) {
395
+ return false; // Not in array = not visited
396
+ }
397
+ return this.visitedArray[id] === this.visitedGeneration;
398
+ }
399
+ /**
400
+ * Mark a node as visited in the current search.
401
+ * Grows the array if needed.
402
+ */
403
+ markVisited(id) {
404
+ if (id >= this.visitedArraySize) {
405
+ // Grow array to accommodate larger IDs
406
+ const newSize = Math.max(this.visitedArraySize * 2, id + 1000);
407
+ const newArray = new Uint16Array(newSize);
408
+ newArray.set(this.visitedArray);
409
+ this.visitedArray = newArray;
410
+ this.visitedArraySize = newSize;
411
+ }
412
+ this.visitedArray[id] = this.visitedGeneration;
413
+ }
414
+ /**
415
+ * Clear all visited markers by incrementing the generation.
416
+ * Much faster than filling the array with zeros.
417
+ */
418
+ clearVisited() {
419
+ this.visitedGeneration++;
420
+ // Wrap around to avoid overflow (65535 is max for Uint16)
421
+ if (this.visitedGeneration > 65000) {
422
+ this.visitedArray.fill(0);
423
+ this.visitedGeneration = 1;
424
+ }
425
+ }
426
+ normalizeVector(vector) {
427
+ normalizeInPlace(vector);
428
+ return vector;
429
+ }
430
+ selectLevel() {
431
+ const r = Math.random() || Number.MIN_VALUE;
432
+ const level = Math.floor(-Math.log(r) * this.levelMult);
433
+ return Math.max(0, Math.min(level, this.maxLayers - 1));
434
+ }
435
+ /**
436
+ * Calculate distance between two vectors using the configured metric.
437
+ * Uses cached function pointer to avoid switch overhead.
438
+ */
439
+ calculateDistance(a, b) {
440
+ return this.distanceFn(a, b);
441
+ }
442
+ validateQueryVector(query, context = 'Query') {
443
+ if (query.length !== this.dimension) {
444
+ throw new VectorDBError(`${context} dimension ${query.length} does not match expected ${this.dimension}`, 'DIMENSION_MISMATCH');
445
+ }
446
+ for (let i = 0; i < query.length; i++) {
447
+ if (!Number.isFinite(query[i])) {
448
+ throw new VectorDBError(`${context} contains non-finite value at index ${i}: ${query[i]}`, 'VALIDATION_ERROR');
449
+ }
450
+ }
451
+ }
452
+ validateSearchParams(k, efSearch) {
453
+ if (!Number.isInteger(k) || k <= 0) {
454
+ throw new VectorDBError(`k must be a positive integer, got ${k}`, 'VALIDATION_ERROR');
455
+ }
456
+ if (efSearch !== undefined && (!Number.isInteger(efSearch) || efSearch <= 0)) {
457
+ throw new VectorDBError(`efSearch must be a positive integer, got ${efSearch}`, 'VALIDATION_ERROR');
458
+ }
459
+ }
460
+ validateNodeId(id) {
461
+ if (!Number.isInteger(id) || id < 0) {
462
+ throw new VectorDBError(`Invalid node ID ${id}: IDs must be non-negative integers`, 'VALIDATION_ERROR');
463
+ }
464
+ if (id > HNSWIndex.MAX_NODE_ID) {
465
+ throw new VectorDBError(`Invalid node ID ${id}: maximum supported ID is ${HNSWIndex.MAX_NODE_ID} (0x${HNSWIndex.MAX_NODE_ID.toString(16)})`, 'VALIDATION_ERROR');
466
+ }
467
+ }
468
+ /**
469
+ * Get a node's vector, loading it if necessary (for lazy loading support)
470
+ */
471
+ getNodeVector(nodeId) {
472
+ const node = this.nodes[nodeId];
473
+ if (!node)
474
+ return null;
475
+ if (this.lazyLoadEnabled && !this.vectorsLoaded.has(nodeId)) {
476
+ this.loadVector(nodeId);
477
+ }
478
+ return node.vector;
479
+ }
480
+ getLayerMaxConnections(layer) {
481
+ return layer === 0 ? this.M0 : this.M;
482
+ }
483
+ selectNeighbors(currentId, candidates, layer) {
484
+ const maxConnections = this.getLayerMaxConnections(layer);
485
+ // Reuse selection heap - clear and ensure capacity
486
+ this.selectionHeap.clear();
487
+ // Add all candidates to the heap (skip self-reference)
488
+ for (const candidate of candidates) {
489
+ if (candidate.id !== currentId) {
490
+ this.selectionHeap.push(candidate.id, candidate.distance);
491
+ }
492
+ }
493
+ // Extract the best neighbors (closest first)
494
+ // Pre-allocate array for expected size
495
+ const selected = new Array(Math.min(maxConnections, this.selectionHeap.size()));
496
+ let idx = 0;
497
+ // Extract up to maxConnections elements from heap
498
+ // Candidates from searchLayer are already unique (visited tracking)
499
+ while (idx < maxConnections && !this.selectionHeap.isEmpty()) {
500
+ const id = this.selectionHeap.pop();
501
+ if (id !== -1) {
502
+ selected[idx++] = id;
503
+ }
504
+ }
505
+ // Trim if needed
506
+ if (idx < selected.length) {
507
+ selected.length = idx;
508
+ }
509
+ return selected;
510
+ }
511
+ addBidirectionalConnection(fromId, toId, level) {
512
+ const fromNode = this.nodes[fromId];
513
+ const toNode = this.nodes[toId];
514
+ if (!fromNode || !toNode)
515
+ return;
516
+ // Ensure neighbor arrays exist
517
+ if (!fromNode.neighbors[level]) {
518
+ fromNode.neighbors[level] = [];
519
+ }
520
+ if (!toNode.neighbors[level]) {
521
+ toNode.neighbors[level] = [];
522
+ }
523
+ const maxConnections = this.getLayerMaxConnections(level);
524
+ if (this.constructionMode) {
525
+ // O(1) lookup using Sets during bulk construction
526
+ let fromSets = this.neighborSets.get(fromId);
527
+ if (!fromSets) {
528
+ fromSets = [];
529
+ this.neighborSets.set(fromId, fromSets);
530
+ }
531
+ if (!fromSets[level]) {
532
+ fromSets[level] = new Set(fromNode.neighbors[level]);
533
+ }
534
+ let toSets = this.neighborSets.get(toId);
535
+ if (!toSets) {
536
+ toSets = [];
537
+ this.neighborSets.set(toId, toSets);
538
+ }
539
+ if (!toSets[level]) {
540
+ toSets[level] = new Set(toNode.neighbors[level]);
541
+ }
542
+ // O(1) membership test with Set.has()
543
+ if (!fromSets[level].has(toId)) {
544
+ fromSets[level].add(toId);
545
+ fromNode.neighbors[level].push(toId);
546
+ }
547
+ if (!toSets[level].has(fromId)) {
548
+ toSets[level].add(fromId);
549
+ toNode.neighbors[level].push(fromId);
550
+ }
551
+ // Prune reverse connection if it exceeds maxConnections
552
+ if (toNode.neighbors[level].length > maxConnections) {
553
+ this.pruneConnections(toId, toNode, level, maxConnections, toSets[level]);
554
+ }
555
+ }
556
+ else {
557
+ // Original O(M) lookup for single inserts (fallback)
558
+ if (!fromNode.neighbors[level].includes(toId)) {
559
+ fromNode.neighbors[level].push(toId);
560
+ }
561
+ if (!toNode.neighbors[level].includes(fromId)) {
562
+ toNode.neighbors[level].push(fromId);
563
+ }
564
+ // Prune reverse connection if it exceeds maxConnections
565
+ if (toNode.neighbors[level].length > maxConnections) {
566
+ this.pruneConnections(toId, toNode, level, maxConnections);
567
+ }
568
+ }
569
+ }
570
+ /**
571
+ * Prune a node's neighbor list to maxConnections by removing the most distant neighbor.
572
+ */
573
+ pruneConnections(_nodeId, node, level, _maxConnections, neighborSet) {
574
+ const neighbors = node.neighbors[level];
575
+ const nodeVector = node.vector;
576
+ // Find the most distant neighbor
577
+ let worstIdx = 0;
578
+ let worstDist = -Infinity;
579
+ for (let i = 0; i < neighbors.length; i++) {
580
+ const nVector = this.nodes[neighbors[i]]?.vector;
581
+ if (!nVector) {
582
+ // Missing node — remove it
583
+ worstIdx = i;
584
+ worstDist = Infinity;
585
+ break;
586
+ }
587
+ const dist = this.distanceFn(nodeVector, nVector);
588
+ if (dist > worstDist) {
589
+ worstDist = dist;
590
+ worstIdx = i;
591
+ }
592
+ }
593
+ const removedId = neighbors[worstIdx];
594
+ // Swap-remove for O(1)
595
+ neighbors[worstIdx] = neighbors[neighbors.length - 1];
596
+ neighbors.pop();
597
+ if (neighborSet) {
598
+ neighborSet.delete(removedId);
599
+ }
600
+ }
601
+ /**
602
+ * Ensure heap capacity is sufficient for the given ef value.
603
+ * Resizes heaps if needed.
604
+ */
605
+ ensureHeapCapacity(ef) {
606
+ const requiredCapacity = Math.max(ef * 2, 100);
607
+ if (requiredCapacity > this.heapCapacity) {
608
+ this.heapCapacity = requiredCapacity;
609
+ this.candidatesHeap = new BinaryHeap(this.heapCapacity);
610
+ this.resultsHeap = new MaxBinaryHeap(this.heapCapacity);
611
+ }
612
+ }
613
+ /**
614
+ * Search a layer using the standard two-heap HNSW algorithm.
615
+ *
616
+ * Uses two heaps:
617
+ * - candidatesHeap (min-heap): Tracks nodes to explore, prioritizing closest
618
+ * - resultsHeap (max-heap): Tracks top-ef results, allowing O(log n) eviction of furthest
619
+ *
620
+ * Termination: Stops when closest unvisited candidate is farther than furthest result.
621
+ */
622
+ searchLayer(query, nearest, layer, ef) {
623
+ // Clear visited tracking
624
+ this.clearVisited();
625
+ // Ensure heaps are large enough, then clear and reuse
626
+ this.ensureHeapCapacity(ef);
627
+ this.candidatesHeap.clear();
628
+ this.resultsHeap.clear();
629
+ // Initialize with entry point
630
+ this.markVisited(nearest.id);
631
+ this.candidatesHeap.push(nearest.id, nearest.distance);
632
+ this.resultsHeap.push(nearest.id, nearest.distance);
633
+ // Cache the furthest result distance - only changes when resultsHeap is modified
634
+ let furthestResultDist = nearest.distance;
635
+ // OPTIMIZATION: Pre-allocate batch arrays for distance calculation
636
+ // Reuse across iterations to avoid allocation
637
+ const batchIds = this.batchNeighborIds;
638
+ const batchDists = this.batchDistances;
639
+ while (!this.candidatesHeap.isEmpty()) {
640
+ // Get closest unexplored candidate
641
+ const closestCandidateDist = this.candidatesHeap.peekValue();
642
+ const closestCandidateId = this.candidatesHeap.pop();
643
+ if (closestCandidateId === -1)
644
+ continue;
645
+ // TERMINATION: Stop if closest candidate is farther than worst result
646
+ if (this.resultsHeap.size() >= ef && closestCandidateDist > furthestResultDist) {
647
+ break;
648
+ }
649
+ const node = this.nodes[closestCandidateId];
650
+ if (!node)
651
+ continue;
652
+ const neighbors = node.neighbors[layer] || [];
653
+ // Use batch distance calculation for non-lazy indices (better cache locality)
654
+ // Fall back to one-by-one for lazy-loaded indices (vectors may not be in flatVectors)
655
+ if (!this.lazyLoadEnabled) {
656
+ // OPTIMIZATION: Collect unvisited neighbors and compute distances in batch
657
+ let batchCount = 0;
658
+ for (let i = 0; i < neighbors.length; i++) {
659
+ const neighborId = neighbors[i];
660
+ if (!this.nodes[neighborId])
661
+ continue;
662
+ if (!this.isVisited(neighborId)) {
663
+ this.markVisited(neighborId);
664
+ batchIds[batchCount] = neighborId;
665
+ batchCount++;
666
+ }
667
+ }
668
+ // Calculate all distances at once (better cache utilization)
669
+ if (batchCount > 0) {
670
+ // Ensure batch arrays are large enough
671
+ if (batchDists.length < batchCount) {
672
+ this.batchDistances.length = batchCount;
673
+ }
674
+ this.calculateDistancesBatch(query, batchIds, batchDists, batchCount);
675
+ // Process batch results
676
+ for (let i = 0; i < batchCount; i++) {
677
+ const neighborId = batchIds[i];
678
+ const distance = batchDists[i];
679
+ // Add to results if it's good enough
680
+ if (this.resultsHeap.size() < ef || distance < furthestResultDist) {
681
+ this.candidatesHeap.push(neighborId, distance);
682
+ this.resultsHeap.push(neighborId, distance);
683
+ // Maintain max size of ef and update cached furthest distance
684
+ if (this.resultsHeap.size() > ef) {
685
+ this.resultsHeap.pop(); // Remove furthest (O(log n))
686
+ }
687
+ furthestResultDist = this.resultsHeap.peekValue();
688
+ }
689
+ }
690
+ }
691
+ }
692
+ else {
693
+ // Original one-by-one for lazy-loaded indices
694
+ for (const neighborId of neighbors) {
695
+ if (this.isVisited(neighborId))
696
+ continue;
697
+ this.markVisited(neighborId);
698
+ const neighborVector = this.getNodeVector(neighborId);
699
+ if (!neighborVector)
700
+ continue;
701
+ const distance = this.calculateDistance(query, neighborVector);
702
+ // Add to results if it's good enough
703
+ if (this.resultsHeap.size() < ef || distance < furthestResultDist) {
704
+ this.candidatesHeap.push(neighborId, distance);
705
+ this.resultsHeap.push(neighborId, distance);
706
+ // Maintain max size of ef and update cached furthest distance
707
+ if (this.resultsHeap.size() > ef) {
708
+ this.resultsHeap.pop(); // Remove furthest (O(log n))
709
+ }
710
+ furthestResultDist = this.resultsHeap.peekValue();
711
+ }
712
+ }
713
+ }
714
+ }
715
+ // Extract results from max-heap into pre-sized array
716
+ // Build in reverse order to avoid reverse() call
717
+ const resultCount = this.resultsHeap.size();
718
+ const results = new Array(resultCount);
719
+ let idx = resultCount - 1;
720
+ while (!this.resultsHeap.isEmpty()) {
721
+ const dist = this.resultsHeap.peekValue();
722
+ const id = this.resultsHeap.pop();
723
+ results[idx--] = { id, distance: dist };
724
+ }
725
+ return results;
726
+ }
727
+ greedySearch(query, entryNode, level) {
728
+ // Simplified greedy search - no heap needed, just follow the best neighbor
729
+ this.clearVisited();
730
+ let currentNode = entryNode;
731
+ // Load entry node vector if lazy loading is enabled
732
+ const entryVector = this.getNodeVector(entryNode.id);
733
+ let currentDistance = entryVector ? this.calculateDistance(query, entryVector) : Infinity;
734
+ this.markVisited(currentNode.id);
735
+ // Keep following the best neighbor until no improvement
736
+ let improved = true;
737
+ while (improved) {
738
+ improved = false;
739
+ const neighbors = currentNode.neighbors[level] || [];
740
+ for (const neighborId of neighbors) {
741
+ if (this.isVisited(neighborId))
742
+ continue;
743
+ this.markVisited(neighborId);
744
+ const neighborVector = this.getNodeVector(neighborId);
745
+ if (!neighborVector)
746
+ continue;
747
+ const neighborNode = this.nodes[neighborId];
748
+ if (!neighborNode)
749
+ continue;
750
+ const distance = this.calculateDistance(query, neighborVector);
751
+ if (distance < currentDistance) {
752
+ currentDistance = distance;
753
+ currentNode = neighborNode;
754
+ improved = true;
755
+ }
756
+ }
757
+ }
758
+ return { id: currentNode.id, distance: currentDistance };
759
+ }
760
+ /**
761
+ * Add a point to the index (async wrapper for API compatibility)
762
+ * For bulk operations, use addPointsBulk() which uses the faster sync version internally
763
+ */
764
+ async addPoint(id, vector, options) {
765
+ this.addPointSync(id, vector, options);
766
+ }
767
+ /**
768
+ * Synchronous version of addPoint - avoids async/await microtask overhead
769
+ * 10-15x faster for bulk insertions where async is not needed
770
+ * @param skipNormalization - Set true if vectors are already unit-normalized (e.g., Cohere embeddings)
771
+ */
772
+ addPointSync(id, vector, options) {
773
+ this.validateNodeId(id);
774
+ if (this.nodes[id]) {
775
+ throw new VectorDBError(`Duplicate node ID ${id}: node already exists`, 'DUPLICATE_VECTOR');
776
+ }
777
+ // Optimize: only copy when necessary
778
+ // - Always copy arrays (need Float32Array)
779
+ // - Copy Float32Array only if we need to normalize (modifies in place)
780
+ // - Reuse input directly if skipNormalization is set (caller guarantees immutability)
781
+ let floatVector;
782
+ if (Array.isArray(vector)) {
783
+ floatVector = new Float32Array(vector);
784
+ }
785
+ else if (this.vectorsAreNormalized && !options?.skipNormalization) {
786
+ // Need to copy because normalizeVector modifies in place
787
+ floatVector = new Float32Array(vector);
788
+ }
789
+ else {
790
+ // No normalization needed and input is Float32Array - use directly
791
+ // Note: caller should not modify this array after passing it
792
+ floatVector = vector;
793
+ }
794
+ if (floatVector.length !== this.dimension) {
795
+ throw new VectorDBError(`Vector dimension ${floatVector.length} does not match expected ${this.dimension}`, 'DIMENSION_MISMATCH');
796
+ }
797
+ // Validate vector contains no NaN or Infinity values (these corrupt the graph)
798
+ for (let i = 0; i < floatVector.length; i++) {
799
+ if (!isFinite(floatVector[i])) {
800
+ throw new VectorDBError(`Vector contains non-finite value at index ${i}: ${floatVector[i]}`, 'VALIDATION_ERROR');
801
+ }
802
+ }
803
+ // Pre-normalize vectors for cosine metric for faster distance computation
804
+ // Skip if caller indicates vectors are already normalized
805
+ if (this.vectorsAreNormalized && !options?.skipNormalization) {
806
+ floatVector = this.normalizeVector(floatVector);
807
+ }
808
+ // Create new node
809
+ const level = this.selectLevel();
810
+ // Pre-allocate neighbors array without Array.from overhead
811
+ const neighbors = new Array(level + 1);
812
+ for (let i = 0; i <= level; i++) {
813
+ neighbors[i] = [];
814
+ }
815
+ const newNode = {
816
+ id,
817
+ level,
818
+ vector: floatVector,
819
+ neighbors,
820
+ };
821
+ this.setNode(newNode);
822
+ // Keep quantized representation in sync for indexes that were
823
+ // quantized before new inserts.
824
+ if (this.quantizationEnabled && this.scalarQuantizer) {
825
+ this.int8Vectors[id] = this.scalarQuantizer.quantize(floatVector);
826
+ // Also update contiguous Int8 storage
827
+ if (this.flatInt8Vectors) {
828
+ this.scalarQuantizer.quantizeInto(floatVector, this.flatInt8Vectors, id * this.dimension);
829
+ }
830
+ }
831
+ // If this is the first node, make it the entry point
832
+ if (this.entryPointId === -1) {
833
+ this.entryPointId = id;
834
+ this.maxLevel = level;
835
+ return;
836
+ }
837
+ // Find the entry point at the highest level
838
+ let currentEntryPoint = this.nodes[this.entryPointId];
839
+ let currentBest = { id: currentEntryPoint.id, distance: this.calculateDistance(floatVector, currentEntryPoint.vector) };
840
+ // Go down from max level to insertion level
841
+ for (let l = this.maxLevel; l > level; l--) {
842
+ const result = this.greedySearch(floatVector, currentEntryPoint, l);
843
+ if (result.distance < currentBest.distance) {
844
+ currentBest = result;
845
+ currentEntryPoint = this.nodes[currentBest.id];
846
+ }
847
+ }
848
+ // Now connect at each level from the insertion level down to 0
849
+ for (let l = Math.min(level, this.maxLevel); l >= 0; l--) {
850
+ // Search in the current level
851
+ // Use int8 quantized search at layer 0 during construction if enabled
852
+ const searchResults = (this.useInt8Construction && this.quantizationEnabled && l === 0)
853
+ ? this.searchLayerQuantized(floatVector, currentBest, l, this.efConstruction)
854
+ : this.searchLayer(floatVector, currentBest, l, this.efConstruction);
855
+ // Get neighbors for this level
856
+ const neighbors = this.selectNeighbors(id, searchResults, l);
857
+ // Add bidirectional connections
858
+ for (const neighborId of neighbors) {
859
+ this.addBidirectionalConnection(id, neighborId, l);
860
+ }
861
+ // Update the current best for the next level
862
+ if (searchResults.length > 0) {
863
+ currentBest = searchResults[0];
864
+ }
865
+ }
866
+ // Update the entry point if a higher level was created
867
+ if (level > this.maxLevel) {
868
+ this.maxLevel = level;
869
+ this.entryPointId = id;
870
+ }
871
+ }
872
+ searchKNN(query, k, efSearch) {
873
+ this.validateSearchParams(k, efSearch);
874
+ this.validateQueryVector(query);
875
+ if (this.entryPointId === -1 || this.nodeCount === 0) {
876
+ return [];
877
+ }
878
+ const effectiveEf = efSearch || Math.max(k * 2, 50);
879
+ // Normalize query vector for cosine metric to match stored normalized vectors
880
+ // Reuse pre-allocated buffer to avoid allocation per query (17% measured speedup)
881
+ let normalizedQuery = query;
882
+ if (this.vectorsAreNormalized) {
883
+ // Copy to reusable buffer and normalize in place
884
+ this.queryNormBuffer.set(query);
885
+ normalizedQuery = this.normalizeVector(this.queryNormBuffer);
886
+ }
887
+ // Start from the entry point at the highest level
888
+ let currentEntryPoint = this.nodes[this.entryPointId];
889
+ const entryVector = this.getNodeVector(this.entryPointId);
890
+ if (!entryVector)
891
+ return [];
892
+ let currentBest = { id: currentEntryPoint.id, distance: this.calculateDistance(normalizedQuery, entryVector) };
893
+ // Go down from max level to level 1
894
+ for (let l = this.maxLevel; l > 0; l--) {
895
+ const result = this.greedySearch(normalizedQuery, currentEntryPoint, l);
896
+ if (result.distance < currentBest.distance) {
897
+ currentBest = result;
898
+ currentEntryPoint = this.nodes[currentBest.id];
899
+ }
900
+ }
901
+ // At level 0, perform detailed search
902
+ const candidates = this.searchLayer(normalizedQuery, currentBest, 0, effectiveEf);
903
+ // Results from searchLayer are already sorted by distance ascending.
904
+ // Only re-sort if there are exact distance ties (virtually never with float32).
905
+ let hasTies = false;
906
+ for (let i = 1; i < candidates.length; i++) {
907
+ if (candidates[i].distance === candidates[i - 1].distance) {
908
+ hasTies = true;
909
+ break;
910
+ }
911
+ }
912
+ if (hasTies) {
913
+ candidates.sort((a, b) => {
914
+ const diff = a.distance - b.distance;
915
+ return diff !== 0 ? diff : a.id - b.id;
916
+ });
917
+ }
918
+ // Return only top k results - truncate in place instead of slice
919
+ if (candidates.length > k)
920
+ candidates.length = k;
921
+ return candidates;
922
+ }
923
+ /**
924
+ * Batch search for multiple query vectors.
925
+ * More efficient than calling searchKNN multiple times as it reuses internal buffers.
926
+ *
927
+ * @param queries Array of query vectors
928
+ * @param k Number of nearest neighbors to return per query
929
+ * @param efSearch Search effort parameter (higher = better recall, slower)
930
+ * @returns Array of results, one per query
931
+ */
932
+ searchKNNBatch(queries, k, efSearch) {
933
+ const numQueries = queries.length;
934
+ this.validateSearchParams(k, efSearch);
935
+ for (let i = 0; i < numQueries; i++) {
936
+ this.validateQueryVector(queries[i], `Query ${i}`);
937
+ }
938
+ if (this.entryPointId === -1 || this.nodeCount === 0) {
939
+ // Pre-allocate empty result arrays
940
+ const emptyResults = new Array(numQueries);
941
+ for (let i = 0; i < numQueries; i++) {
942
+ emptyResults[i] = [];
943
+ }
944
+ return emptyResults;
945
+ }
946
+ // Pre-allocate results array
947
+ const results = new Array(numQueries);
948
+ for (let i = 0; i < numQueries; i++) {
949
+ // searchKNN handles its own clearVisited() internally
950
+ results[i] = this.searchKNN(queries[i], k, efSearch);
951
+ }
952
+ return results;
953
+ }
954
+ /**
955
+ * Optimized batch search that returns results in a flat structure for better performance.
956
+ * Useful when you need to process many queries quickly.
957
+ *
958
+ * @param queries Flat Float32Array containing all queries concatenated
959
+ * @param numQueries Number of queries in the array
960
+ * @param k Number of nearest neighbors to return per query
961
+ * @param efSearch Search effort parameter
962
+ * @returns Object with flat arrays for ids and distances
963
+ */
964
+ searchKNNBatchFlat(queries, numQueries, k, efSearch) {
965
+ this.validateSearchParams(k, efSearch);
966
+ if (!Number.isInteger(numQueries) || numQueries < 0) {
967
+ throw new VectorDBError(`numQueries must be a non-negative integer, got ${numQueries}`, 'VALIDATION_ERROR');
968
+ }
969
+ const expectedLength = numQueries * this.dimension;
970
+ if (queries.length !== expectedLength) {
971
+ throw new VectorDBError(`Flat query buffer length ${queries.length} does not match expected ${expectedLength}`, 'VALIDATION_ERROR');
972
+ }
973
+ for (let i = 0; i < queries.length; i++) {
974
+ if (!Number.isFinite(queries[i])) {
975
+ throw new VectorDBError(`Query contains non-finite value at flat index ${i}: ${queries[i]}`, 'VALIDATION_ERROR');
976
+ }
977
+ }
978
+ if (this.entryPointId === -1 || this.nodeCount === 0) {
979
+ return {
980
+ ids: new Uint32Array(numQueries * k).fill(HNSWIndex.MISSING_ID_SENTINEL),
981
+ distances: new Float32Array(numQueries * k).fill(Infinity)
982
+ };
983
+ }
984
+ const ids = new Uint32Array(numQueries * k);
985
+ const distances = new Float32Array(numQueries * k);
986
+ for (let q = 0; q < numQueries; q++) {
987
+ // Extract query vector
988
+ const queryStart = q * this.dimension;
989
+ const query = queries.subarray(queryStart, queryStart + this.dimension);
990
+ // searchKNN handles its own clearVisited() internally
991
+ const results = this.searchKNN(query, k, efSearch);
992
+ // Copy results to output arrays
993
+ const resultStart = q * k;
994
+ for (let i = 0; i < k; i++) {
995
+ if (i < results.length) {
996
+ ids[resultStart + i] = results[i].id;
997
+ distances[resultStart + i] = results[i].distance;
998
+ }
999
+ else {
1000
+ ids[resultStart + i] = HNSWIndex.MISSING_ID_SENTINEL; // sentinel for "no result"
1001
+ distances[resultStart + i] = Infinity;
1002
+ }
1003
+ }
1004
+ }
1005
+ return { ids, distances };
1006
+ }
1007
+ // ============================================
1008
+ // Convenience Methods
1009
+ // ============================================
1010
+ allocateAutoId() {
1011
+ if (this.nextAutoId > HNSWIndex.MAX_NODE_ID) {
1012
+ throw new VectorDBError(`Cannot allocate new node ID: exhausted supported ID space (max ${HNSWIndex.MAX_NODE_ID})`, 'VALIDATION_ERROR');
1013
+ }
1014
+ const id = this.nextAutoId;
1015
+ this.nextAutoId++;
1016
+ return id;
1017
+ }
1018
+ /**
1019
+ * Add a single vector with auto-generated ID.
1020
+ * Returns the assigned ID.
1021
+ *
1022
+ * @param vector Vector to add
1023
+ * @returns The auto-generated ID
1024
+ *
1025
+ * @example
1026
+ * ```typescript
1027
+ * const id = await index.add([0.1, 0.2, 0.3]);
1028
+ * console.log(`Added vector with ID: ${id}`);
1029
+ * ```
1030
+ */
1031
+ async add(vector) {
1032
+ const id = this.allocateAutoId();
1033
+ await this.addPoint(id, vector);
1034
+ return id;
1035
+ }
1036
+ /**
1037
+ * Simple query interface - find k nearest neighbors.
1038
+ *
1039
+ * @param vector Query vector
1040
+ * @param k Number of results (default: 10)
1041
+ * @returns Array of {id, distance} results
1042
+ *
1043
+ * @example
1044
+ * ```typescript
1045
+ * const results = index.query([0.1, 0.2, 0.3], 5);
1046
+ * results.forEach(r => console.log(`ID: ${r.id}, Distance: ${r.distance}`));
1047
+ * ```
1048
+ */
1049
+ query(vector, k = 10) {
1050
+ const floatVector = Array.isArray(vector) ? new Float32Array(vector) : vector;
1051
+ return this.searchKNN(floatVector, k);
1052
+ }
1053
+ /**
1054
+ * Add multiple vectors with auto-generated IDs.
1055
+ * Returns the assigned IDs.
1056
+ *
1057
+ * @param vectors Array of vectors to add
1058
+ * @returns Array of auto-generated IDs
1059
+ *
1060
+ * @example
1061
+ * ```typescript
1062
+ * const ids = await index.addAll([[0.1, 0.2], [0.3, 0.4]]);
1063
+ * ```
1064
+ */
1065
+ async addAll(vectors) {
1066
+ if (vectors.length === 0)
1067
+ return [];
1068
+ const lastId = this.nextAutoId + vectors.length - 1;
1069
+ if (!Number.isSafeInteger(lastId) || lastId > HNSWIndex.MAX_NODE_ID) {
1070
+ throw new VectorDBError(`Cannot allocate ${vectors.length} node IDs: exhausted supported ID space (max ${HNSWIndex.MAX_NODE_ID})`, 'VALIDATION_ERROR');
1071
+ }
1072
+ const startId = this.nextAutoId;
1073
+ this.nextAutoId = lastId + 1;
1074
+ // Use bulk construction mode for better performance
1075
+ const points = new Array(vectors.length);
1076
+ for (let i = 0; i < vectors.length; i++) {
1077
+ const vector = vectors[i];
1078
+ points[i] = {
1079
+ id: startId + i,
1080
+ vector: vector instanceof Float32Array ? vector : new Float32Array(vector)
1081
+ };
1082
+ }
1083
+ await this.addPointsBulk(points);
1084
+ return points.map(p => p.id);
1085
+ }
1086
+ /**
1087
+ * Bulk add multiple points with optimized O(1) neighbor lookups.
1088
+ * Significantly faster than sequential addPoint() calls for large batches.
1089
+ * Uses Set-based membership testing during construction, then releases memory.
1090
+ *
1091
+ * @param points Array of {id, vector} to add
1092
+ * @example
1093
+ * ```typescript
1094
+ * await index.addPointsBulk([
1095
+ * { id: 0, vector: new Float32Array([0.1, 0.2, ...]) },
1096
+ * { id: 1, vector: new Float32Array([0.3, 0.4, ...]) },
1097
+ * ]);
1098
+ * ```
1099
+ */
1100
+ async addPointsBulk(points, options) {
1101
+ this.addPointsBulkSync(points, options);
1102
+ }
1103
+ /**
1104
+ * Reorder points for diversity — inserts "diverse seeds" first to create a better graph backbone.
1105
+ * Uses farthest-point sampling on a random subset to select diverse initial points.
1106
+ */
1107
+ reorderForDiversity(points) {
1108
+ if (points.length <= 1000)
1109
+ return points;
1110
+ const n = points.length;
1111
+ const dim = this.dimension;
1112
+ const sampleSize = Math.min(Math.ceil(Math.sqrt(n)), 2000);
1113
+ const seedCount = Math.min(Math.ceil(Math.sqrt(n)), 500);
1114
+ // Random sample indices
1115
+ const sampleIndices = [];
1116
+ const usedIndices = new Set();
1117
+ while (sampleIndices.length < sampleSize) {
1118
+ const idx = Math.floor(Math.random() * n);
1119
+ if (!usedIndices.has(idx)) {
1120
+ usedIndices.add(idx);
1121
+ sampleIndices.push(idx);
1122
+ }
1123
+ }
1124
+ // Farthest-point sampling on the sample
1125
+ // Start with a random point from the sample
1126
+ const seedSamplePositions = [0]; // positions within sampleIndices
1127
+ const minDists = new Float64Array(sampleSize);
1128
+ minDists.fill(Infinity);
1129
+ // Compute initial distances from first seed
1130
+ const firstVec = points[sampleIndices[0]].vector;
1131
+ for (let i = 1; i < sampleSize; i++) {
1132
+ const vec = points[sampleIndices[i]].vector;
1133
+ let dist = 0;
1134
+ for (let d = 0; d < dim; d++) {
1135
+ const diff = firstVec[d] - vec[d];
1136
+ dist += diff * diff;
1137
+ }
1138
+ minDists[i] = dist;
1139
+ }
1140
+ // Greedily pick the farthest point from current set
1141
+ for (let s = 1; s < seedCount; s++) {
1142
+ // Find farthest point
1143
+ let maxDist = -1;
1144
+ let maxIdx = -1;
1145
+ for (let i = 0; i < sampleSize; i++) {
1146
+ if (minDists[i] > maxDist) {
1147
+ maxDist = minDists[i];
1148
+ maxIdx = i;
1149
+ }
1150
+ }
1151
+ if (maxIdx === -1)
1152
+ break;
1153
+ seedSamplePositions.push(maxIdx);
1154
+ minDists[maxIdx] = -1; // Mark as selected
1155
+ // Update min distances
1156
+ const newSeedVec = points[sampleIndices[maxIdx]].vector;
1157
+ for (let i = 0; i < sampleSize; i++) {
1158
+ if (minDists[i] <= 0)
1159
+ continue; // Already selected or invalid
1160
+ const vec = points[sampleIndices[i]].vector;
1161
+ let dist = 0;
1162
+ for (let d = 0; d < dim; d++) {
1163
+ const diff = newSeedVec[d] - vec[d];
1164
+ dist += diff * diff;
1165
+ }
1166
+ if (dist < minDists[i])
1167
+ minDists[i] = dist;
1168
+ }
1169
+ }
1170
+ // Map sample positions back to original indices
1171
+ const seedOriginalIndices = new Set();
1172
+ for (const pos of seedSamplePositions) {
1173
+ seedOriginalIndices.add(sampleIndices[pos]);
1174
+ }
1175
+ // Build reordered array: seeds first, then remaining in original order
1176
+ const reordered = [];
1177
+ for (const idx of seedOriginalIndices) {
1178
+ reordered.push(points[idx]);
1179
+ }
1180
+ for (let i = 0; i < n; i++) {
1181
+ if (!seedOriginalIndices.has(i)) {
1182
+ reordered.push(points[i]);
1183
+ }
1184
+ }
1185
+ return reordered;
1186
+ }
1187
+ /**
1188
+ * Synchronous bulk insertion - 10-15x faster than async version
1189
+ * Uses addPointSync() internally to avoid microtask queue overhead
1190
+ * @param options.skipNormalization - Set true if vectors are already unit-normalized
1191
+ * @param options.useInt8Construction - Use int8 quantized search at layer 0 during construction.
1192
+ * First 1000 vectors are inserted with float32 (to train quantizer), then remaining use int8.
1193
+ * Can yield 2-4x faster build times with minimal recall loss.
1194
+ * @param options.diverseSeedInsertion - Reorder points to insert diverse seeds first for better graph backbone.
1195
+ * Uses farthest-point sampling on a random subset to select diverse initial points.
1196
+ * Only applies to batches > 1000 vectors.
1197
+ */
1198
+ addPointsBulkSync(points, options) {
1199
+ if (points.length === 0)
1200
+ return;
1201
+ // Validate whole batch first so invalid inputs don't leave partial inserts.
1202
+ const seenIds = new Set();
1203
+ for (const { id, vector } of points) {
1204
+ this.validateNodeId(id);
1205
+ if (seenIds.has(id) || this.nodes[id]) {
1206
+ throw new VectorDBError(`Duplicate node ID ${id}: node already exists or appears multiple times in batch`, 'DUPLICATE_VECTOR');
1207
+ }
1208
+ seenIds.add(id);
1209
+ if (vector.length !== this.dimension) {
1210
+ throw new VectorDBError(`Vector dimension ${vector.length} does not match expected ${this.dimension}`, 'DIMENSION_MISMATCH');
1211
+ }
1212
+ for (let i = 0; i < vector.length; i++) {
1213
+ if (!Number.isFinite(vector[i])) {
1214
+ throw new VectorDBError(`Vector contains non-finite value at index ${i}: ${vector[i]}`, 'VALIDATION_ERROR');
1215
+ }
1216
+ }
1217
+ }
1218
+ // Reorder for diverse seed insertion if requested
1219
+ if (options?.diverseSeedInsertion) {
1220
+ points = this.reorderForDiversity(points);
1221
+ }
1222
+ // Enable construction mode for O(1) neighbor lookups
1223
+ this.constructionMode = true;
1224
+ this.neighborSets.clear();
1225
+ // Construction-time quantization: insert first batch with float32 to train quantizer,
1226
+ // then switch to int8 search at layer 0 for remaining vectors
1227
+ const int8ConstructionRequested = options?.useInt8Construction === true;
1228
+ const trainingSetSize = Math.min(1000, points.length);
1229
+ try {
1230
+ if (int8ConstructionRequested && points.length > trainingSetSize) {
1231
+ // Phase 1: Insert training set with float32 search
1232
+ for (let i = 0; i < trainingSetSize; i++) {
1233
+ this.addPointSync(points[i].id, points[i].vector, options);
1234
+ }
1235
+ // Train quantizer and enable quantization on inserted vectors
1236
+ if (!this.quantizationEnabled) {
1237
+ this.enableQuantization();
1238
+ }
1239
+ // Phase 2: Insert remaining vectors using int8 search at layer 0
1240
+ this.useInt8Construction = true;
1241
+ for (let i = trainingSetSize; i < points.length; i++) {
1242
+ this.addPointSync(points[i].id, points[i].vector, options);
1243
+ }
1244
+ }
1245
+ else {
1246
+ // Standard insertion (no int8 construction)
1247
+ for (const { id, vector } of points) {
1248
+ this.addPointSync(id, vector, options);
1249
+ }
1250
+ }
1251
+ }
1252
+ finally {
1253
+ // Always cleanup, even on error
1254
+ this.constructionMode = false;
1255
+ this.useInt8Construction = false;
1256
+ this.neighborSets.clear(); // Release memory
1257
+ }
1258
+ }
1259
+ /**
1260
+ * Parallel bulk insertion using worker threads for search phase.
1261
+ * Workers perform HNSW search (the expensive part), main thread adds connections.
1262
+ * Uses SharedArrayBuffer for zero-copy vector sharing with workers.
1263
+ *
1264
+ * @param points Array of {id, vector} to insert
1265
+ * @param pool WorkerPool instance (caller manages lifecycle)
1266
+ * @param options.seedFraction Fraction of points to insert sequentially as seed (default: 0.1)
1267
+ * @param options.batchSize Points per parallel batch (default: 200). Larger = faster but lower recall.
1268
+ * 200: ~10x speedup, ~96% recall. 50: ~1.5x speedup, ~97% recall.
1269
+ * @param options.resyncInterval Full re-sync worker graph every N batches (default: 100)
1270
+ * @param options.repair Run 1-hop neighbor expansion after build to improve recall (default: false)
1271
+ * @param options.skipNormalization Skip vector normalization
1272
+ */
1273
+ async addPointsBulkParallel(points, pool, options) {
1274
+ if (points.length === 0)
1275
+ return;
1276
+ const seedFraction = options?.seedFraction ?? 0.1;
1277
+ const batchSize = options?.batchSize ?? 200;
1278
+ const resyncInterval = options?.resyncInterval ?? 100;
1279
+ const doRepair = options?.repair ?? false;
1280
+ const skipNorm = options?.skipNormalization;
1281
+ // Validate whole batch first
1282
+ const seenIds = new Set();
1283
+ for (const { id, vector } of points) {
1284
+ this.validateNodeId(id);
1285
+ if (seenIds.has(id) || this.nodes[id]) {
1286
+ throw new VectorDBError(`Duplicate node ID ${id}: node already exists or appears multiple times in batch`, 'DUPLICATE_VECTOR');
1287
+ }
1288
+ seenIds.add(id);
1289
+ if (vector.length !== this.dimension) {
1290
+ throw new VectorDBError(`Vector dimension ${vector.length} does not match expected ${this.dimension}`, 'DIMENSION_MISMATCH');
1291
+ }
1292
+ for (let i = 0; i < vector.length; i++) {
1293
+ if (!Number.isFinite(vector[i])) {
1294
+ throw new VectorDBError(`Vector contains non-finite value at index ${i}: ${vector[i]}`, 'VALIDATION_ERROR');
1295
+ }
1296
+ }
1297
+ }
1298
+ // Pre-normalize all vectors if needed
1299
+ const normalizedPoints = points.map(p => {
1300
+ if (this.vectorsAreNormalized && !skipNorm) {
1301
+ const v = new Float32Array(p.vector);
1302
+ normalizeInPlace(v);
1303
+ return { id: p.id, vector: v };
1304
+ }
1305
+ return p;
1306
+ });
1307
+ // Phase 1: Sequential seed insertion
1308
+ const seedSize = Math.min(Math.max(500, Math.floor(normalizedPoints.length * seedFraction)), normalizedPoints.length);
1309
+ // Enable shared memory so ensureCapacity allocates SAB-backed arrays.
1310
+ // Workers hold a reference to the SAB — regular ArrayBuffer can't be shared.
1311
+ // Pre-allocate capacity for ALL points so SAB doesn't get replaced during construction.
1312
+ this.enableSharedMemory();
1313
+ const maxId = normalizedPoints.reduce((max, p) => Math.max(max, p.id), 0);
1314
+ this.ensureCapacity(maxId + 1);
1315
+ this.constructionMode = true;
1316
+ this.neighborSets.clear();
1317
+ try {
1318
+ for (let i = 0; i < seedSize; i++) {
1319
+ this.addPointSync(normalizedPoints[i].id, normalizedPoints[i].vector, { skipNormalization: true });
1320
+ }
1321
+ if (seedSize >= normalizedPoints.length)
1322
+ return;
1323
+ // Phase 2: Init workers with current graph + SAB vectors
1324
+ // Workers get a view of the full-capacity SAB (pre-allocated above)
1325
+ await pool.init(this);
1326
+ // Phase 3: Process remaining points in parallel batches
1327
+ let batchCount = 0;
1328
+ for (let start = seedSize; start < normalizedPoints.length; start += batchSize) {
1329
+ const end = Math.min(start + batchSize, normalizedPoints.length);
1330
+ const batch = normalizedPoints.slice(start, end);
1331
+ // Pre-assign levels for all points in batch
1332
+ const levels = batch.map(() => this.selectLevel());
1333
+ // Workers search for neighbors for each point in the batch
1334
+ // Workers search for neighbors — efConstruction as both k and ef
1335
+ // to get the full candidate list for neighbor selection
1336
+ const searchPromises = batch.map(p => pool.search(p.vector, this.efConstruction, this.efConstruction)
1337
+ .then(results => [results]) // Wrap in layer array format
1338
+ );
1339
+ const allResults = await Promise.all(searchPromises);
1340
+ // Track all nodes whose neighbor lists change (for incremental graph update)
1341
+ const modifiedNodeIds = new Set();
1342
+ // Main thread: create nodes and add connections
1343
+ for (let i = 0; i < batch.length; i++) {
1344
+ const { id, vector } = batch[i];
1345
+ const level = levels[i];
1346
+ // Create node with empty neighbors
1347
+ const neighbors = new Array(level + 1);
1348
+ for (let l = 0; l <= level; l++)
1349
+ neighbors[l] = [];
1350
+ const newNode = { id, level, vector, neighbors };
1351
+ this.setNode(newNode);
1352
+ // Keep quantized representation in sync
1353
+ if (this.quantizationEnabled && this.scalarQuantizer) {
1354
+ this.int8Vectors[id] = this.scalarQuantizer.quantize(vector);
1355
+ if (this.flatInt8Vectors) {
1356
+ this.scalarQuantizer.quantizeInto(vector, this.flatInt8Vectors, id * this.dimension);
1357
+ }
1358
+ }
1359
+ // Layer 0: use worker search results
1360
+ const l0Results = allResults[i][0] || [];
1361
+ const l0Neighbors = this.selectNeighbors(id, l0Results, 0);
1362
+ modifiedNodeIds.add(id);
1363
+ for (const nid of l0Neighbors) {
1364
+ this.addBidirectionalConnection(id, nid, 0);
1365
+ modifiedNodeIds.add(nid); // existing node got new connection
1366
+ }
1367
+ // Higher layers: search sequentially in main thread (rare: ~6% of nodes)
1368
+ if (level > 0) {
1369
+ let currentEntryPoint = this.nodes[this.entryPointId];
1370
+ let currentBest = {
1371
+ id: currentEntryPoint.id,
1372
+ distance: this.calculateDistance(vector, currentEntryPoint.vector)
1373
+ };
1374
+ // Greedy descent to level+1
1375
+ for (let l = this.maxLevel; l > level; l--) {
1376
+ const result = this.greedySearch(vector, currentEntryPoint, l);
1377
+ if (result.distance < currentBest.distance) {
1378
+ currentBest = result;
1379
+ currentEntryPoint = this.nodes[currentBest.id];
1380
+ }
1381
+ }
1382
+ // Search and connect at each layer from level to 1 (layer 0 already done)
1383
+ for (let l = Math.min(level, this.maxLevel); l >= 1; l--) {
1384
+ const searchResults = this.searchLayer(vector, currentBest, l, this.efConstruction);
1385
+ const layerNeighbors = this.selectNeighbors(id, searchResults, l);
1386
+ for (const nid of layerNeighbors) {
1387
+ this.addBidirectionalConnection(id, nid, l);
1388
+ modifiedNodeIds.add(nid);
1389
+ }
1390
+ if (searchResults.length > 0) {
1391
+ currentBest = searchResults[0];
1392
+ }
1393
+ }
1394
+ }
1395
+ // Update entry point if new level is higher
1396
+ if (level > this.maxLevel) {
1397
+ this.maxLevel = level;
1398
+ this.entryPointId = id;
1399
+ }
1400
+ }
1401
+ batchCount++;
1402
+ // Send incremental graph update: ALL modified nodes (new + existing with new connections)
1403
+ const graphUpdate = [];
1404
+ for (const nid of modifiedNodeIds) {
1405
+ const node = this.nodes[nid];
1406
+ if (node) {
1407
+ graphUpdate.push({ id: nid, neighbors: node.neighbors });
1408
+ }
1409
+ }
1410
+ pool.broadcastGraphUpdate(graphUpdate, this.entryPointId, this.maxLevel);
1411
+ // Full re-sync periodically for accumulated neighbor changes
1412
+ // (bidirectional connections modify existing nodes' neighbor lists)
1413
+ if (batchCount % resyncInterval === 0 && start + batchSize < normalizedPoints.length) {
1414
+ pool.destroy();
1415
+ await pool.init(this);
1416
+ }
1417
+ }
1418
+ // Phase 4 (optional): Repair pass — 1-hop neighbor expansion to improve edges
1419
+ if (doRepair) {
1420
+ this.repairGraphConnections(normalizedPoints.map(p => p.id));
1421
+ }
1422
+ }
1423
+ finally {
1424
+ this.constructionMode = false;
1425
+ this.neighborSets.clear();
1426
+ }
1427
+ }
1428
+ /**
1429
+ * Repair graph connections via 1-hop neighbor expansion.
1430
+ * For each node, check if any neighbor-of-neighbor is closer than current neighbors.
1431
+ * This is O(N * M0^2) distance computations — much cheaper than re-searching.
1432
+ * Two passes catches transitive improvements.
1433
+ */
1434
+ repairGraphConnections(nodeIds) {
1435
+ const maxConn = this.M0;
1436
+ for (let pass = 0; pass < 2; pass++) {
1437
+ for (const id of nodeIds) {
1438
+ const node = this.nodes[id];
1439
+ if (!node || !node.neighbors[0] || node.neighbors[0].length === 0)
1440
+ continue;
1441
+ // Collect candidates: current neighbors + their neighbors (1-hop expansion)
1442
+ const seen = new Set();
1443
+ seen.add(id);
1444
+ const candidates = [];
1445
+ for (const nid of node.neighbors[0]) {
1446
+ if (!seen.has(nid)) {
1447
+ seen.add(nid);
1448
+ const nnode = this.nodes[nid];
1449
+ if (nnode) {
1450
+ candidates.push({
1451
+ id: nid,
1452
+ distance: this.calculateDistance(node.vector, nnode.vector)
1453
+ });
1454
+ // 1-hop: check neighbor's neighbors
1455
+ for (const nnid of nnode.neighbors[0]) {
1456
+ if (!seen.has(nnid)) {
1457
+ seen.add(nnid);
1458
+ const nnnode = this.nodes[nnid];
1459
+ if (nnnode) {
1460
+ candidates.push({
1461
+ id: nnid,
1462
+ distance: this.calculateDistance(node.vector, nnnode.vector)
1463
+ });
1464
+ }
1465
+ }
1466
+ }
1467
+ }
1468
+ }
1469
+ }
1470
+ // Select best neighbors from expanded candidate set
1471
+ candidates.sort((a, b) => a.distance - b.distance);
1472
+ const oldNeighborSet = new Set(node.neighbors[0]);
1473
+ const newNeighbors = candidates.slice(0, maxConn).map(c => c.id);
1474
+ // Check if anything changed
1475
+ let changed = false;
1476
+ if (newNeighbors.length !== node.neighbors[0].length) {
1477
+ changed = true;
1478
+ }
1479
+ else {
1480
+ for (const nid of newNeighbors) {
1481
+ if (!oldNeighborSet.has(nid)) {
1482
+ changed = true;
1483
+ break;
1484
+ }
1485
+ }
1486
+ }
1487
+ if (changed) {
1488
+ node.neighbors[0] = newNeighbors;
1489
+ // Add reverse connections for newly added neighbors
1490
+ for (const nid of newNeighbors) {
1491
+ if (!oldNeighborSet.has(nid)) {
1492
+ const neighbor = this.nodes[nid];
1493
+ if (neighbor && neighbor.neighbors[0] && !neighbor.neighbors[0].includes(id)) {
1494
+ if (neighbor.neighbors[0].length < maxConn) {
1495
+ neighbor.neighbors[0].push(id);
1496
+ }
1497
+ }
1498
+ }
1499
+ }
1500
+ }
1501
+ }
1502
+ }
1503
+ }
1504
+ /**
1505
+ * Clear construction-time data structures to free memory.
1506
+ * Called automatically after addPointsBulk(), but can be called
1507
+ * manually if needed.
1508
+ */
1509
+ clearConstructionCache() {
1510
+ this.constructionMode = false;
1511
+ this.neighborSets.clear();
1512
+ }
1513
+ // Format version constants
1514
+ static MAGIC = 0x484E5357; // "HNSW" in ASCII (big-endian: 0x48='H', 0x4E='N', 0x53='S', 0x57='W')
1515
+ static FORMAT_VERSION = 3; // v3: vector offset index for lazy loading
1516
+ static HEADER_SIZE = 40; // 4 (magic) + 4 (version) + 28 (existing header) + 4 (vectorDataOffset)
1517
+ static ensureReadable(bufferLength, offset, bytes, context) {
1518
+ if (offset + bytes > bufferLength) {
1519
+ const available = Math.max(0, bufferLength - offset);
1520
+ throw new VectorDBError(`Corrupt HNSW data: truncated ${context} (need ${bytes} bytes, only ${available} available)`, 'CORRUPT_INDEX');
1521
+ }
1522
+ }
1523
+ static wrapDeserializeError(error) {
1524
+ if (error instanceof VectorDBError) {
1525
+ return error;
1526
+ }
1527
+ if (error instanceof Error) {
1528
+ return new VectorDBError(`Corrupt HNSW data: ${error.message}`, 'CORRUPT_INDEX');
1529
+ }
1530
+ return new VectorDBError('Corrupt HNSW data: failed to deserialize index', 'CORRUPT_INDEX');
1531
+ }
1532
+ /**
1533
+ * Get all nodes as an array (filters out undefined slots)
1534
+ */
1535
+ getNodesArray() {
1536
+ const result = [];
1537
+ for (let i = 0; i < this.nodeCount; i++) {
1538
+ const node = this.nodes[i];
1539
+ if (node)
1540
+ result.push(node);
1541
+ }
1542
+ return result;
1543
+ }
1544
+ /**
1545
+ * Get shared search data for worker pool parallelism.
1546
+ * Returns a copy of flat vectors and serialized graph structure.
1547
+ */
1548
+ /**
1549
+ * Convert flat vector storage to SharedArrayBuffer for zero-copy worker sharing.
1550
+ * Called automatically by addPointsBulkParallel. Can also be called before WorkerPool.init()
1551
+ * to enable zero-copy search dispatch.
1552
+ */
1553
+ enableSharedMemory() {
1554
+ if (this.useSharedMemory)
1555
+ return; // Already enabled
1556
+ if (typeof SharedArrayBuffer === 'undefined')
1557
+ return; // Not available
1558
+ this.useSharedMemory = true;
1559
+ // Migrate flatVectors to SAB
1560
+ if (!(this.flatVectors.buffer instanceof SharedArrayBuffer)) {
1561
+ const sabFloat = new Float32Array(new SharedArrayBuffer(this.flatVectorsCapacity * this.dimension * 4));
1562
+ sabFloat.set(this.flatVectors);
1563
+ this.flatVectors = sabFloat;
1564
+ }
1565
+ // Migrate flatInt8Vectors to SAB if present
1566
+ if (this.flatInt8Vectors && !(this.flatInt8Vectors.buffer instanceof SharedArrayBuffer)) {
1567
+ const sabInt8 = new Int8Array(new SharedArrayBuffer(this.flatInt8VectorsCapacity * this.dimension));
1568
+ sabInt8.set(this.flatInt8Vectors);
1569
+ this.flatInt8Vectors = sabInt8;
1570
+ }
1571
+ }
1572
+ getSharedSearchData() {
1573
+ if (this.nodeCount === 0 || this.entryPointId === -1)
1574
+ return null;
1575
+ const graphData = this.serializeGraphStructure();
1576
+ const nodeLevels = new Uint8Array(this.nodeCount);
1577
+ for (let i = 0; i < this.nodeCount; i++) {
1578
+ const node = this.nodes[i];
1579
+ if (node)
1580
+ nodeLevels[i] = node.level;
1581
+ }
1582
+ // When backed by SharedArrayBuffer, share the FULL capacity buffer (zero-copy).
1583
+ // Workers get a view into the same memory, including space for new nodes added later.
1584
+ // For regular ArrayBuffer, copy only the used portion.
1585
+ const usedLength = this.nodeCount * this.dimension;
1586
+ let flatVectors;
1587
+ if (this.flatVectors.buffer instanceof SharedArrayBuffer) {
1588
+ // Share full capacity — allows workers to see nodes added after init
1589
+ flatVectors = new Float32Array(this.flatVectors.buffer, 0, this.flatVectorsCapacity * this.dimension);
1590
+ }
1591
+ else {
1592
+ // Fallback: copy if not SharedArrayBuffer
1593
+ flatVectors = new Float32Array(this.flatVectors.buffer.slice(0, usedLength * 4));
1594
+ }
1595
+ // Share int8 vectors if quantization is enabled
1596
+ let flatInt8Vectors = null;
1597
+ let quantizationParams = null;
1598
+ if (this.quantizationEnabled && this.flatInt8Vectors) {
1599
+ if (this.flatInt8Vectors.buffer instanceof SharedArrayBuffer) {
1600
+ flatInt8Vectors = new Int8Array(this.flatInt8Vectors.buffer, 0, this.flatInt8VectorsCapacity * this.dimension);
1601
+ }
1602
+ else {
1603
+ flatInt8Vectors = new Int8Array(this.flatInt8Vectors.buffer.slice(0, usedLength));
1604
+ }
1605
+ if (this.scalarQuantizer) {
1606
+ quantizationParams = this.scalarQuantizer.getParams();
1607
+ }
1608
+ }
1609
+ return {
1610
+ flatVectors,
1611
+ flatInt8Vectors,
1612
+ dimension: this.dimension,
1613
+ nodeCount: this.nodeCount,
1614
+ metric: this.metric,
1615
+ entryPointId: this.entryPointId,
1616
+ maxLevel: this.maxLevel,
1617
+ M: this.M,
1618
+ M0: this.M0,
1619
+ graphData,
1620
+ nodeLevels,
1621
+ quantizationEnabled: this.quantizationEnabled,
1622
+ quantizationParams,
1623
+ };
1624
+ }
1625
+ /**
1626
+ * Serialize graph structure (neighbor lists) into a compact ArrayBuffer.
1627
+ * Format per node: [numLayers:uint8] [numNeighbors:uint16, neighborId:uint32...] per layer
1628
+ */
1629
+ serializeGraphStructure() {
1630
+ // Estimate size
1631
+ let totalSize = 0;
1632
+ for (let i = 0; i < this.nodeCount; i++) {
1633
+ const node = this.nodes[i];
1634
+ if (!node) {
1635
+ totalSize += 1; // numLayers = 0
1636
+ continue;
1637
+ }
1638
+ totalSize += 1; // numLayers byte
1639
+ for (const neighbors of node.neighbors) {
1640
+ totalSize += 2; // numNeighbors uint16
1641
+ totalSize += (neighbors?.length ?? 0) * 4; // neighbor IDs uint32
1642
+ }
1643
+ }
1644
+ const buffer = new ArrayBuffer(totalSize);
1645
+ const view = new DataView(buffer);
1646
+ let offset = 0;
1647
+ for (let i = 0; i < this.nodeCount; i++) {
1648
+ const node = this.nodes[i];
1649
+ if (!node) {
1650
+ view.setUint8(offset++, 0);
1651
+ continue;
1652
+ }
1653
+ const numLayers = node.neighbors.length;
1654
+ view.setUint8(offset++, numLayers);
1655
+ for (let l = 0; l < numLayers; l++) {
1656
+ const neighbors = node.neighbors[l] ?? [];
1657
+ view.setUint16(offset, neighbors.length, true);
1658
+ offset += 2;
1659
+ for (const nid of neighbors) {
1660
+ view.setUint32(offset, nid, true);
1661
+ offset += 4;
1662
+ }
1663
+ }
1664
+ }
1665
+ return buffer;
1666
+ }
1667
+ /**
1668
+ * Reorder nodes for BFS cache locality. Nodes are renumbered so that
1669
+ * neighbors in the graph are stored contiguously in memory, reducing
1670
+ * cache misses during search.
1671
+ *
1672
+ * @returns Map from old node ID to new node ID
1673
+ */
1674
+ reorderForLocality() {
1675
+ if (this.nodeCount === 0 || this.entryPointId === -1) {
1676
+ return new Map();
1677
+ }
1678
+ const dim = this.dimension;
1679
+ const oldToNew = new Map();
1680
+ const visited = new Set();
1681
+ const queue = [];
1682
+ // BFS from entry point to assign new sequential IDs
1683
+ let newId = 0;
1684
+ queue.push(this.entryPointId);
1685
+ visited.add(this.entryPointId);
1686
+ while (queue.length > 0) {
1687
+ const oldId = queue.shift();
1688
+ oldToNew.set(oldId, newId++);
1689
+ const node = this.nodes[oldId];
1690
+ if (!node)
1691
+ continue;
1692
+ // Traverse all layers' neighbors
1693
+ for (const neighbors of node.neighbors) {
1694
+ if (!neighbors)
1695
+ continue;
1696
+ for (const neighborId of neighbors) {
1697
+ if (!visited.has(neighborId)) {
1698
+ visited.add(neighborId);
1699
+ queue.push(neighborId);
1700
+ }
1701
+ }
1702
+ }
1703
+ }
1704
+ // Any nodes not reachable from entry point (disconnected) get IDs at the end
1705
+ for (let i = 0; i < this.nodeCount; i++) {
1706
+ if (this.nodes[i] && !oldToNew.has(i)) {
1707
+ oldToNew.set(i, newId++);
1708
+ }
1709
+ }
1710
+ // Build new data structures
1711
+ const newNodes = new Array(newId);
1712
+ const newFlatVectors = HNSWIndex.allocateFloat32(Math.max(newId, this.flatVectorsCapacity) * dim, this.useSharedMemory);
1713
+ let newFlatInt8Vectors = null;
1714
+ let newInt8Vectors = [];
1715
+ if (this.quantizationEnabled && this.flatInt8Vectors) {
1716
+ newFlatInt8Vectors = HNSWIndex.allocateInt8(Math.max(newId, this.flatInt8VectorsCapacity) * dim, this.useSharedMemory);
1717
+ newInt8Vectors = new Array(newId);
1718
+ }
1719
+ // Copy nodes with remapped IDs and neighbor lists
1720
+ for (const [oldId, nid] of oldToNew) {
1721
+ const oldNode = this.nodes[oldId];
1722
+ if (!oldNode)
1723
+ continue;
1724
+ // Remap neighbor lists
1725
+ const newNeighbors = [];
1726
+ for (const neighbors of oldNode.neighbors) {
1727
+ if (!neighbors) {
1728
+ newNeighbors.push([]);
1729
+ continue;
1730
+ }
1731
+ newNeighbors.push(neighbors.map(n => oldToNew.get(n) ?? n));
1732
+ }
1733
+ // Copy vector data
1734
+ const oldOffset = oldId * dim;
1735
+ const newOffset = nid * dim;
1736
+ for (let d = 0; d < dim; d++) {
1737
+ newFlatVectors[newOffset + d] = this.flatVectors[oldOffset + d];
1738
+ }
1739
+ // Copy int8 data if present
1740
+ if (newFlatInt8Vectors && this.flatInt8Vectors) {
1741
+ for (let d = 0; d < dim; d++) {
1742
+ newFlatInt8Vectors[newOffset + d] = this.flatInt8Vectors[oldOffset + d];
1743
+ }
1744
+ }
1745
+ if (this.int8Vectors[oldId]) {
1746
+ newInt8Vectors[nid] = this.int8Vectors[oldId];
1747
+ }
1748
+ // Create new node with vector from flat storage
1749
+ const vec = new Float32Array(dim);
1750
+ vec.set(newFlatVectors.subarray(newOffset, newOffset + dim));
1751
+ newNodes[nid] = {
1752
+ id: nid,
1753
+ level: oldNode.level,
1754
+ vector: vec,
1755
+ neighbors: newNeighbors,
1756
+ };
1757
+ }
1758
+ // Update entry point
1759
+ this.entryPointId = oldToNew.get(this.entryPointId) ?? this.entryPointId;
1760
+ // Swap in new structures
1761
+ this.nodes = newNodes;
1762
+ this.flatVectors = newFlatVectors;
1763
+ this.flatVectorsCapacity = Math.max(newId, this.flatVectorsCapacity);
1764
+ if (newFlatInt8Vectors) {
1765
+ this.flatInt8Vectors = newFlatInt8Vectors;
1766
+ this.flatInt8VectorsCapacity = Math.max(newId, this.flatInt8VectorsCapacity);
1767
+ }
1768
+ this.int8Vectors = newInt8Vectors;
1769
+ this.nodeCount = newId;
1770
+ this.nextAutoId = newId;
1771
+ // Reset visited array for new ID space
1772
+ this.visitedArraySize = Math.max(newId + 1000, this.visitedArraySize);
1773
+ this.visitedArray = new Uint16Array(this.visitedArraySize);
1774
+ this.visitedGeneration = 1;
1775
+ return oldToNew;
1776
+ }
1777
+ serialize() {
1778
+ // Format v3: Vectors stored separately at end with offset table for lazy loading
1779
+ const nodesArray = this.getNodesArray();
1780
+ const nodeCount = nodesArray.length;
1781
+ // Build ID to index mapping first (needed for delta encoding)
1782
+ const idToIndex = new Map();
1783
+ for (let i = 0; i < nodesArray.length; i++) {
1784
+ idToIndex.set(nodesArray[i].id, i);
1785
+ }
1786
+ // Pre-encode all neighbor lists with delta encoding
1787
+ const encodedNeighbors = [];
1788
+ let totalNeighborBytes = 0;
1789
+ for (const node of nodesArray) {
1790
+ const nodeEncodings = [];
1791
+ for (let l = 0; l <= node.level; l++) {
1792
+ const neighborIndices = node.neighbors[l].map((id) => {
1793
+ const idx = idToIndex.get(id);
1794
+ if (idx === undefined) {
1795
+ throw new VectorDBError(`Corrupt HNSW graph: node ${node.id} references missing neighbor ${id}`, 'CORRUPT_INDEX');
1796
+ }
1797
+ return idx;
1798
+ });
1799
+ const encoded = deltaEncodeNeighbors(neighborIndices);
1800
+ nodeEncodings.push(encoded);
1801
+ totalNeighborBytes += encoded.length;
1802
+ }
1803
+ encodedNeighbors.push(nodeEncodings);
1804
+ }
1805
+ // Calculate sizes for v3 format:
1806
+ // - Header: 40 bytes (includes vectorDataOffset)
1807
+ // - Node metadata: nodeCount * 8 (id + level)
1808
+ // - Neighbor metadata: sum of (level+1) * 8 per node
1809
+ // - Encoded neighbors: totalNeighborBytes
1810
+ // - Vector offset table: nodeCount * 4 (offset within vector section)
1811
+ // - Vectors: nodeCount * dimension * 4 (at end for lazy loading)
1812
+ let graphSize = HNSWIndex.HEADER_SIZE;
1813
+ graphSize += nodeCount * 8; // id + level per node
1814
+ for (const node of nodesArray) {
1815
+ graphSize += (node.level + 1) * 8; // neighbor metadata per level
1816
+ }
1817
+ graphSize += totalNeighborBytes;
1818
+ graphSize += nodeCount * 4; // vector offset table
1819
+ const vectorDataOffset = graphSize;
1820
+ const vectorDataSize = nodeCount * this.dimension * 4;
1821
+ const totalSize = graphSize + vectorDataSize;
1822
+ const buffer = new ArrayBuffer(totalSize);
1823
+ const view = new DataView(buffer);
1824
+ const uint8Array = new Uint8Array(buffer);
1825
+ let offset = 0;
1826
+ // Write header with magic, version, and vectorDataOffset
1827
+ view.setUint32(offset, HNSWIndex.MAGIC, true);
1828
+ offset += 4;
1829
+ view.setUint32(offset, HNSWIndex.FORMAT_VERSION, true);
1830
+ offset += 4;
1831
+ view.setUint32(offset, this.dimension, true);
1832
+ offset += 4;
1833
+ const metricCode = this.metric === 'cosine' ? 0 : this.metric === 'euclidean' ? 1 : 2;
1834
+ view.setUint32(offset, metricCode, true);
1835
+ offset += 4;
1836
+ view.setUint32(offset, this.M, true);
1837
+ offset += 4;
1838
+ view.setUint32(offset, this.efConstruction, true);
1839
+ offset += 4;
1840
+ view.setInt32(offset, this.maxLevel, true);
1841
+ offset += 4;
1842
+ view.setInt32(offset, this.entryPointId, true);
1843
+ offset += 4;
1844
+ view.setUint32(offset, nodeCount, true);
1845
+ offset += 4;
1846
+ view.setUint32(offset, vectorDataOffset, true);
1847
+ offset += 4; // New in v3
1848
+ // Write node metadata (without vectors)
1849
+ for (let i = 0; i < nodesArray.length; i++) {
1850
+ const node = nodesArray[i];
1851
+ view.setUint32(offset, node.id, true);
1852
+ offset += 4;
1853
+ view.setUint32(offset, node.level, true);
1854
+ offset += 4;
1855
+ }
1856
+ // Write neighbor metadata (counts and encoded sizes)
1857
+ for (let i = 0; i < nodesArray.length; i++) {
1858
+ const node = nodesArray[i];
1859
+ const nodeEncodings = encodedNeighbors[i];
1860
+ for (let l = 0; l <= node.level; l++) {
1861
+ view.setUint32(offset, node.neighbors[l].length, true);
1862
+ offset += 4;
1863
+ view.setUint32(offset, nodeEncodings[l].length, true);
1864
+ offset += 4;
1865
+ }
1866
+ }
1867
+ // Write all encoded neighbor data
1868
+ for (let i = 0; i < nodesArray.length; i++) {
1869
+ const nodeEncodings = encodedNeighbors[i];
1870
+ for (const encoded of nodeEncodings) {
1871
+ uint8Array.set(encoded, offset);
1872
+ offset += encoded.length;
1873
+ }
1874
+ }
1875
+ // Write vector offset table (offset within vector section)
1876
+ for (let i = 0; i < nodesArray.length; i++) {
1877
+ view.setUint32(offset, i * this.dimension * 4, true); // Relative offset
1878
+ offset += 4;
1879
+ }
1880
+ // Write vectors at end (for lazy loading capability)
1881
+ for (let i = 0; i < nodesArray.length; i++) {
1882
+ const node = nodesArray[i];
1883
+ for (let j = 0; j < this.dimension; j++) {
1884
+ view.setFloat32(offset, node.vector[j], true);
1885
+ offset += 4;
1886
+ }
1887
+ }
1888
+ return buffer;
1889
+ }
1890
+ /**
1891
+ * Deserialize an HNSW index from a buffer.
1892
+ *
1893
+ * @param buffer The serialized index buffer
1894
+ * @param options Optional loading options
1895
+ * - lazyLoadVectors: If true, don't load vectors immediately (v3+ only)
1896
+ */
1897
+ static deserialize(buffer, options) {
1898
+ try {
1899
+ if (buffer.byteLength < 4) {
1900
+ throw new VectorDBError('Corrupt HNSW data: buffer too small for header', 'CORRUPT_INDEX');
1901
+ }
1902
+ const view = new DataView(buffer);
1903
+ const uint8Array = new Uint8Array(buffer);
1904
+ const lazyLoad = options?.lazyLoadVectors ?? false;
1905
+ let offset = 0;
1906
+ const ensureReadable = (bytes, context) => {
1907
+ HNSWIndex.ensureReadable(buffer.byteLength, offset, bytes, context);
1908
+ };
1909
+ const readUint32 = (context) => {
1910
+ ensureReadable(4, context);
1911
+ const value = view.getUint32(offset, true);
1912
+ offset += 4;
1913
+ return value;
1914
+ };
1915
+ const readInt32 = (context) => {
1916
+ ensureReadable(4, context);
1917
+ const value = view.getInt32(offset, true);
1918
+ offset += 4;
1919
+ return value;
1920
+ };
1921
+ const readFloat32 = (context) => {
1922
+ ensureReadable(4, context);
1923
+ const value = view.getFloat32(offset, true);
1924
+ offset += 4;
1925
+ return value;
1926
+ };
1927
+ // Check for magic header (new format v1+)
1928
+ const possibleMagic = readUint32('magic header');
1929
+ let formatVersion = 0;
1930
+ if (possibleMagic === HNSWIndex.MAGIC) {
1931
+ formatVersion = readUint32('format version');
1932
+ if (formatVersion > HNSWIndex.FORMAT_VERSION) {
1933
+ throw new VectorDBError(`Unsupported HNSW format version: ${formatVersion}. Maximum supported: ${HNSWIndex.FORMAT_VERSION}`, 'UNSUPPORTED_FORMAT');
1934
+ }
1935
+ }
1936
+ else {
1937
+ formatVersion = 0;
1938
+ offset = 0;
1939
+ }
1940
+ // Read common header fields
1941
+ const dimension = readUint32('dimension');
1942
+ const metricCode = readUint32('metric');
1943
+ let metric;
1944
+ if (metricCode === 0) {
1945
+ metric = 'cosine';
1946
+ }
1947
+ else if (metricCode === 1) {
1948
+ metric = 'euclidean';
1949
+ }
1950
+ else if (metricCode === 2) {
1951
+ metric = 'dot_product';
1952
+ }
1953
+ else {
1954
+ throw new VectorDBError(`Corrupt HNSW data: unknown metric code ${metricCode}`, 'CORRUPT_INDEX');
1955
+ }
1956
+ const M = readUint32('M');
1957
+ const efConstruction = readUint32('efConstruction');
1958
+ const maxLevel = readInt32('maxLevel');
1959
+ const entryPointId = readInt32('entryPointId');
1960
+ const nodeCount = readUint32('nodeCount');
1961
+ const vectorByteLength = dimension * 4;
1962
+ // V3+ has vectorDataOffset in header
1963
+ let vectorDataOffset = 0;
1964
+ if (formatVersion >= 3) {
1965
+ vectorDataOffset = readUint32('vectorDataOffset');
1966
+ if (vectorDataOffset > buffer.byteLength) {
1967
+ throw new VectorDBError(`Corrupt HNSW data: vectorDataOffset ${vectorDataOffset} exceeds buffer size ${buffer.byteLength}`, 'CORRUPT_INDEX');
1968
+ }
1969
+ }
1970
+ const index = new HNSWIndex(dimension, metric, M, efConstruction);
1971
+ index.maxLevel = maxLevel;
1972
+ index.entryPointId = entryPointId;
1973
+ const indexToId = new Array(nodeCount);
1974
+ if (formatVersion >= 3) {
1975
+ // V3 format: vectors at end, supports lazy loading
1976
+ const nodeMetadata = [];
1977
+ const neighborMetadata = [];
1978
+ // First pass: read node metadata (no vectors here)
1979
+ for (let i = 0; i < nodeCount; i++) {
1980
+ const id = readUint32(`node ${i} id`);
1981
+ const level = readUint32(`node ${i} level`);
1982
+ indexToId[i] = id;
1983
+ nodeMetadata.push({ id, level });
1984
+ }
1985
+ // Second pass: read neighbor metadata
1986
+ for (let i = 0; i < nodeCount; i++) {
1987
+ const level = nodeMetadata[i].level;
1988
+ const levelMeta = [];
1989
+ for (let l = 0; l <= level; l++) {
1990
+ const count = readUint32(`node ${i} layer ${l} neighbor count`);
1991
+ const encodedSize = readUint32(`node ${i} layer ${l} encoded neighbor size`);
1992
+ if ((count === 0) !== (encodedSize === 0)) {
1993
+ throw new VectorDBError(`Corrupt HNSW data: node ${i} layer ${l} has inconsistent neighbor metadata (count=${count}, encodedSize=${encodedSize})`, 'CORRUPT_INDEX');
1994
+ }
1995
+ levelMeta.push({ count, encodedSize });
1996
+ }
1997
+ neighborMetadata.push(levelMeta);
1998
+ }
1999
+ // Third pass: read and decode neighbor data
2000
+ const nodeNeighbors = [];
2001
+ for (let i = 0; i < nodeCount; i++) {
2002
+ const level = nodeMetadata[i].level;
2003
+ const neighbors = new Array(level + 1);
2004
+ for (let l = 0; l <= level; l++) {
2005
+ const { count, encodedSize } = neighborMetadata[i][l];
2006
+ if (count === 0) {
2007
+ neighbors[l] = [];
2008
+ continue;
2009
+ }
2010
+ ensureReadable(encodedSize, `node ${i} layer ${l} encoded neighbor payload`);
2011
+ const encodedSlice = uint8Array.subarray(offset, offset + encodedSize);
2012
+ offset += encodedSize;
2013
+ const neighborIndices = deltaDecodeNeighbors(encodedSlice, count);
2014
+ neighbors[l] = neighborIndices.map((idx) => {
2015
+ if (idx < 0 || idx >= indexToId.length) {
2016
+ throw new VectorDBError(`Corrupt HNSW data: neighbor index ${idx} out of range`, 'CORRUPT_INDEX');
2017
+ }
2018
+ return indexToId[idx];
2019
+ });
2020
+ }
2021
+ nodeNeighbors.push(neighbors);
2022
+ }
2023
+ // Vector offset table must be directly before vector data
2024
+ const vectorTableBytes = nodeCount * 4;
2025
+ if (offset + vectorTableBytes > vectorDataOffset) {
2026
+ throw new VectorDBError(`Corrupt HNSW data: vectorDataOffset ${vectorDataOffset} overlaps graph payload ending at ${offset + vectorTableBytes}`, 'CORRUPT_INDEX');
2027
+ }
2028
+ if (offset + vectorTableBytes < vectorDataOffset) {
2029
+ throw new VectorDBError(`Corrupt HNSW data: unexpected gap before vector section (expected offset ${offset + vectorTableBytes}, got ${vectorDataOffset})`, 'CORRUPT_INDEX');
2030
+ }
2031
+ // Read vector offset table
2032
+ for (let i = 0; i < nodeCount; i++) {
2033
+ const relativeOffset = readUint32(`node ${i} vector relative offset`);
2034
+ const absoluteOffset = vectorDataOffset + relativeOffset;
2035
+ if (absoluteOffset > buffer.byteLength - vectorByteLength) {
2036
+ throw new VectorDBError(`Corrupt HNSW data: node ${i} vector offset ${absoluteOffset} out of range`, 'CORRUPT_INDEX');
2037
+ }
2038
+ const id = nodeMetadata[i].id;
2039
+ index.vectorOffsets.set(id, absoluteOffset);
2040
+ }
2041
+ if (offset !== vectorDataOffset) {
2042
+ throw new VectorDBError(`Corrupt HNSW data: vector section starts at ${vectorDataOffset}, but parser reached ${offset}`, 'CORRUPT_INDEX');
2043
+ }
2044
+ // Create nodes and optionally load vectors
2045
+ if (lazyLoad) {
2046
+ // Lazy loading: store buffer, don't load vectors yet
2047
+ index.lazyLoadEnabled = true;
2048
+ index.vectorBuffer = buffer;
2049
+ for (let i = 0; i < nodeCount; i++) {
2050
+ const { id, level } = nodeMetadata[i];
2051
+ // Create node with empty vector (will be loaded on demand)
2052
+ const node = {
2053
+ id,
2054
+ level,
2055
+ vector: new Float32Array(dimension), // Placeholder
2056
+ neighbors: nodeNeighbors[i]
2057
+ };
2058
+ index.setNode(node);
2059
+ }
2060
+ }
2061
+ else {
2062
+ // Eager loading: load all vectors now
2063
+ for (let i = 0; i < nodeCount; i++) {
2064
+ const { id, level } = nodeMetadata[i];
2065
+ const vectorOffset = index.vectorOffsets.get(id);
2066
+ const vector = new Float32Array(dimension);
2067
+ for (let j = 0; j < dimension; j++) {
2068
+ HNSWIndex.ensureReadable(buffer.byteLength, vectorOffset + j * 4, 4, `node ${i} vector component ${j}`);
2069
+ vector[j] = view.getFloat32(vectorOffset + j * 4, true);
2070
+ }
2071
+ const node = { id, level, vector, neighbors: nodeNeighbors[i] };
2072
+ index.setNode(node);
2073
+ index.vectorsLoaded.add(id);
2074
+ }
2075
+ }
2076
+ }
2077
+ else if (formatVersion >= 2) {
2078
+ // V2 format: delta-encoded neighbor lists, vectors inline
2079
+ const nodeMetadata = [];
2080
+ const neighborMetadata = [];
2081
+ for (let i = 0; i < nodeCount; i++) {
2082
+ const id = readUint32(`node ${i} id`);
2083
+ const level = readUint32(`node ${i} level`);
2084
+ indexToId[i] = id;
2085
+ const vector = new Float32Array(dimension);
2086
+ for (let j = 0; j < dimension; j++) {
2087
+ vector[j] = readFloat32(`node ${i} vector component ${j}`);
2088
+ }
2089
+ nodeMetadata.push({ id, level, vector });
2090
+ }
2091
+ for (let i = 0; i < nodeCount; i++) {
2092
+ const level = nodeMetadata[i].level;
2093
+ const levelMeta = [];
2094
+ for (let l = 0; l <= level; l++) {
2095
+ const count = readUint32(`node ${i} layer ${l} neighbor count`);
2096
+ const encodedSize = readUint32(`node ${i} layer ${l} encoded neighbor size`);
2097
+ if ((count === 0) !== (encodedSize === 0)) {
2098
+ throw new VectorDBError(`Corrupt HNSW data: node ${i} layer ${l} has inconsistent neighbor metadata (count=${count}, encodedSize=${encodedSize})`, 'CORRUPT_INDEX');
2099
+ }
2100
+ levelMeta.push({ count, encodedSize });
2101
+ }
2102
+ neighborMetadata.push(levelMeta);
2103
+ }
2104
+ for (let i = 0; i < nodeCount; i++) {
2105
+ const { id, level, vector } = nodeMetadata[i];
2106
+ const neighbors = new Array(level + 1);
2107
+ for (let l = 0; l <= level; l++) {
2108
+ const { count, encodedSize } = neighborMetadata[i][l];
2109
+ if (count === 0) {
2110
+ neighbors[l] = [];
2111
+ continue;
2112
+ }
2113
+ ensureReadable(encodedSize, `node ${i} layer ${l} encoded neighbor payload`);
2114
+ const encodedSlice = uint8Array.subarray(offset, offset + encodedSize);
2115
+ offset += encodedSize;
2116
+ const neighborIndices = deltaDecodeNeighbors(encodedSlice, count);
2117
+ neighbors[l] = neighborIndices.map((idx) => {
2118
+ if (idx < 0 || idx >= indexToId.length) {
2119
+ throw new VectorDBError(`Corrupt HNSW data: neighbor index ${idx} out of range`, 'CORRUPT_INDEX');
2120
+ }
2121
+ return indexToId[idx];
2122
+ });
2123
+ }
2124
+ const node = { id, level, vector, neighbors };
2125
+ index.setNode(node);
2126
+ }
2127
+ }
2128
+ else {
2129
+ // V0/V1 format: raw neighbor IDs
2130
+ for (let i = 0; i < nodeCount; i++) {
2131
+ const id = readUint32(`node ${i} id`);
2132
+ const level = readUint32(`node ${i} level`);
2133
+ indexToId[i] = id;
2134
+ const vector = new Float32Array(dimension);
2135
+ for (let j = 0; j < dimension; j++) {
2136
+ vector[j] = readFloat32(`node ${i} vector component ${j}`);
2137
+ }
2138
+ const neighbors = new Array(level + 1);
2139
+ for (let l = 0; l <= level; l++) {
2140
+ const neighborCount = readUint32(`node ${i} layer ${l} neighbor count`);
2141
+ neighbors[l] = new Array(neighborCount);
2142
+ }
2143
+ const node = { id, level, vector, neighbors };
2144
+ index.setNode(node);
2145
+ }
2146
+ for (let i = 0; i < index.nodeCount; i++) {
2147
+ const node = index.nodes[i];
2148
+ if (!node)
2149
+ continue;
2150
+ for (let l = 0; l <= node.level; l++) {
2151
+ for (let j = 0; j < node.neighbors[l].length; j++) {
2152
+ const neighborIndex = readInt32(`node ${i} layer ${l} neighbor index ${j}`);
2153
+ if (neighborIndex < 0 || neighborIndex >= indexToId.length) {
2154
+ throw new VectorDBError(`Corrupt HNSW data: neighbor index ${neighborIndex} out of range`, 'CORRUPT_INDEX');
2155
+ }
2156
+ node.neighbors[l][j] = indexToId[neighborIndex];
2157
+ }
2158
+ }
2159
+ }
2160
+ }
2161
+ return index;
2162
+ }
2163
+ catch (error) {
2164
+ throw HNSWIndex.wrapDeserializeError(error);
2165
+ }
2166
+ }
2167
+ /**
2168
+ * Load a specific vector on demand (for lazy-loaded indices).
2169
+ * Returns the vector if lazy loading is enabled, otherwise returns the already-loaded vector.
2170
+ */
2171
+ loadVector(nodeId) {
2172
+ const node = this.nodes[nodeId];
2173
+ if (!node)
2174
+ return null;
2175
+ // If not lazy loading or already loaded, return existing vector
2176
+ if (!this.lazyLoadEnabled || this.vectorsLoaded.has(nodeId)) {
2177
+ return node.vector;
2178
+ }
2179
+ // Load vector from buffer
2180
+ if (!this.vectorBuffer)
2181
+ return null;
2182
+ const vectorOffset = this.vectorOffsets.get(nodeId);
2183
+ if (vectorOffset === undefined)
2184
+ return null;
2185
+ const view = new DataView(this.vectorBuffer);
2186
+ const vector = new Float32Array(this.dimension);
2187
+ for (let j = 0; j < this.dimension; j++) {
2188
+ vector[j] = view.getFloat32(vectorOffset + j * 4, true);
2189
+ }
2190
+ // Update node with loaded vector
2191
+ node.vector = vector;
2192
+ this.vectorsLoaded.add(nodeId);
2193
+ // OPTIMIZATION: Also update flat vector storage for batch distance calculations
2194
+ this.setFlatVector(nodeId, vector);
2195
+ return vector;
2196
+ }
2197
+ /**
2198
+ * Preload vectors for specific node IDs.
2199
+ * Useful for warming up cache before searches.
2200
+ */
2201
+ preloadVectors(nodeIds) {
2202
+ if (!this.lazyLoadEnabled)
2203
+ return;
2204
+ for (const nodeId of nodeIds) {
2205
+ this.loadVector(nodeId);
2206
+ }
2207
+ }
2208
+ /**
2209
+ * Check if lazy loading is enabled
2210
+ */
2211
+ isLazyLoadEnabled() {
2212
+ return this.lazyLoadEnabled;
2213
+ }
2214
+ /**
2215
+ * Get lazy loading statistics
2216
+ */
2217
+ getLazyLoadStats() {
2218
+ const totalNodes = this.nodeCount;
2219
+ const loadedVectors = this.vectorsLoaded.size;
2220
+ if (!this.lazyLoadEnabled) {
2221
+ return {
2222
+ enabled: false,
2223
+ totalNodes,
2224
+ loadedVectors: totalNodes,
2225
+ memoryReduction: '0%'
2226
+ };
2227
+ }
2228
+ const reduction = totalNodes > 0 ? ((1 - loadedVectors / totalNodes) * 100).toFixed(1) : '0';
2229
+ return {
2230
+ enabled: true,
2231
+ totalNodes,
2232
+ loadedVectors,
2233
+ memoryReduction: `${reduction}%`
2234
+ };
2235
+ }
2236
+ /**
2237
+ * Save to binary file using Bun APIs.
2238
+ * @deprecated Use serialize() with a StorageBackend instead for cross-platform support.
2239
+ */
2240
+ async saveToFile(filePath) {
2241
+ const buffer = this.serialize();
2242
+ await Bun.write(filePath, buffer);
2243
+ }
2244
+ /**
2245
+ * Load from binary file using Bun APIs.
2246
+ * @deprecated Use StorageBackend.read() + deserialize() instead for cross-platform support.
2247
+ */
2248
+ static async loadFromFile(filePath) {
2249
+ const file = Bun.file(filePath);
2250
+ const buffer = await file.arrayBuffer();
2251
+ return HNSWIndex.deserialize(buffer);
2252
+ }
2253
+ // Clean up resources
2254
+ destroy() {
2255
+ // Clear all nodes to free memory
2256
+ this.nodes = [];
2257
+ this.nodeCount = 0;
2258
+ this.nextAutoId = 0;
2259
+ this.flatVectors = new Float32Array(0);
2260
+ this.flatVectorsCapacity = 0;
2261
+ this.useSharedMemory = false;
2262
+ // Reset entry point and level so reuse after destroy doesn't crash
2263
+ this.entryPointId = -1;
2264
+ this.maxLevel = -1;
2265
+ // Clear quantization state
2266
+ this.int8Vectors = [];
2267
+ this.quantizationEnabled = false;
2268
+ this.scalarQuantizer = null;
2269
+ // Clear lazy-load state
2270
+ this.lazyLoadEnabled = false;
2271
+ this.vectorOffsets.clear();
2272
+ this.vectorBuffer = null;
2273
+ this.vectorsLoaded.clear();
2274
+ }
2275
+ /**
2276
+ * Get memory usage statistics
2277
+ */
2278
+ getMemoryUsage() {
2279
+ // Calculate approximate memory usage in bytes
2280
+ let totalBytes = 0;
2281
+ // Node objects
2282
+ for (let i = 0; i < this.nodeCount; i++) {
2283
+ const node = this.nodes[i];
2284
+ if (!node)
2285
+ continue;
2286
+ // Node structure: id (4 bytes), level (4 bytes), vector (4*dimension), neighbors array overhead
2287
+ totalBytes += 8; // id + level
2288
+ totalBytes += node.vector.length * 4; // vector data
2289
+ totalBytes += 24; // neighbors array overhead (rough estimate)
2290
+ // Neighbor connections
2291
+ for (const neighborList of node.neighbors) {
2292
+ totalBytes += neighborList.length * 4; // neighbor IDs
2293
+ totalBytes += 16; // array overhead per level
2294
+ }
2295
+ }
2296
+ // Flat vector storage
2297
+ totalBytes += this.flatVectors.byteLength;
2298
+ // Array overhead
2299
+ totalBytes += this.nodeCount * 8; // Array entry overhead (rough estimate)
2300
+ // Object overhead
2301
+ totalBytes += 1024; // Base object overhead
2302
+ return totalBytes;
2303
+ }
2304
+ getDimension() {
2305
+ return this.dimension;
2306
+ }
2307
+ getMetric() {
2308
+ return this.metric;
2309
+ }
2310
+ getM() {
2311
+ return this.M;
2312
+ }
2313
+ getEfConstruction() {
2314
+ return this.efConstruction;
2315
+ }
2316
+ /**
2317
+ * Get all vectors for brute-force search
2318
+ */
2319
+ getAllVectors() {
2320
+ const result = new Map();
2321
+ for (let i = 0; i < this.nodeCount; i++) {
2322
+ const node = this.nodes[i];
2323
+ if (!node)
2324
+ continue;
2325
+ const vector = this.getNodeVector(node.id);
2326
+ if (vector) {
2327
+ result.set(node.id, vector);
2328
+ }
2329
+ }
2330
+ return result;
2331
+ }
2332
+ // ============================================
2333
+ // Quantized Search (Int8 with automatic rescoring)
2334
+ // ============================================
2335
+ /**
2336
+ * Enable Int8 quantization for faster search with automatic rescoring.
2337
+ * Trains the quantizer on existing vectors and quantizes them.
2338
+ *
2339
+ * Performance:
2340
+ * - 4x memory reduction
2341
+ * - 3-4x faster distance calculations
2342
+ * - 99%+ recall with automatic rescoring
2343
+ *
2344
+ * @example
2345
+ * ```typescript
2346
+ * // After adding vectors
2347
+ * index.enableQuantization();
2348
+ *
2349
+ * // Now use quantized search (automatically rescores for high recall)
2350
+ * const results = index.searchKNNQuantized(query, 10);
2351
+ * ```
2352
+ */
2353
+ enableQuantization() {
2354
+ if (this.nodeCount === 0) {
2355
+ throw new VectorDBError('Cannot enable quantization on empty index. Add vectors first.', 'VALIDATION_ERROR');
2356
+ }
2357
+ // Collect all vectors for training
2358
+ const vectors = [];
2359
+ for (let i = 0; i < this.nodeCount; i++) {
2360
+ const node = this.nodes[i];
2361
+ if (!node)
2362
+ continue;
2363
+ const vector = this.getNodeVector(node.id);
2364
+ if (!vector) {
2365
+ throw new VectorDBError(`Cannot enable quantization: missing vector for node ${node.id}`, 'CORRUPT_INDEX');
2366
+ }
2367
+ vectors.push(vector);
2368
+ }
2369
+ // Initialize and train scalar (int8) quantizer
2370
+ this.scalarQuantizer = new ScalarQuantizer(this.dimension);
2371
+ this.scalarQuantizer.train(vectors);
2372
+ // Quantize all existing vectors - use array instead of Map
2373
+ this.int8Vectors = new Array(this.nodeCount);
2374
+ // Also build contiguous Int8 storage for cache-friendly batch distance calculations
2375
+ const dim = this.dimension;
2376
+ this.flatInt8VectorsCapacity = Math.max(this.nodeCount, this.flatVectorsCapacity);
2377
+ this.flatInt8Vectors = HNSWIndex.allocateInt8(this.flatInt8VectorsCapacity * dim, this.useSharedMemory);
2378
+ for (let i = 0; i < this.nodeCount; i++) {
2379
+ const node = this.nodes[i];
2380
+ if (node) {
2381
+ const vector = this.getNodeVector(node.id);
2382
+ if (!vector) {
2383
+ throw new VectorDBError(`Cannot quantize node ${node.id}: vector is unavailable`, 'CORRUPT_INDEX');
2384
+ }
2385
+ this.int8Vectors[node.id] = this.scalarQuantizer.quantize(vector);
2386
+ // Also store in contiguous buffer
2387
+ this.scalarQuantizer.quantizeInto(vector, this.flatInt8Vectors, node.id * dim);
2388
+ }
2389
+ }
2390
+ // Pre-allocate reusable query buffer for quantized search
2391
+ this.queryInt8Buffer = new Int8Array(dim);
2392
+ this.quantizationEnabled = true;
2393
+ }
2394
+ /**
2395
+ * Check if quantization is enabled
2396
+ */
2397
+ isQuantizationEnabled() {
2398
+ return this.quantizationEnabled;
2399
+ }
2400
+ /**
2401
+ * Fast quantized search with automatic rescoring.
2402
+ *
2403
+ * Uses Int8 quantized vectors for initial candidate retrieval (3-4x faster),
2404
+ * then rescores top candidates with float32 for accurate ranking.
2405
+ *
2406
+ * @param query Query vector
2407
+ * @param k Number of results to return
2408
+ * @param candidateMultiplier How many extra candidates to retrieve for rescoring (default: 3)
2409
+ * @param efSearch Search effort parameter
2410
+ * @returns Array of {id, distance} results (same format as searchKNN)
2411
+ *
2412
+ * Performance:
2413
+ * - 3-4x faster than float32 for distance calculations
2414
+ * - 99%+ recall with candidateMultiplier=3 (automatic rescoring)
2415
+ * - 4x memory reduction
2416
+ */
2417
+ searchKNNQuantized(query, k, candidateMultiplier = 3, efSearch) {
2418
+ this.validateSearchParams(k, efSearch);
2419
+ this.validateQueryVector(query);
2420
+ if (!Number.isInteger(candidateMultiplier) || candidateMultiplier <= 0) {
2421
+ throw new VectorDBError(`candidateMultiplier must be a positive integer, got ${candidateMultiplier}`, 'VALIDATION_ERROR');
2422
+ }
2423
+ // Fallback to standard search if quantization not enabled
2424
+ if (!this.quantizationEnabled) {
2425
+ return this.searchKNN(query, k, efSearch);
2426
+ }
2427
+ if (this.entryPointId === -1 || this.nodeCount === 0) {
2428
+ return [];
2429
+ }
2430
+ // Normalize query if needed - reuse buffer for efficiency
2431
+ let normalizedQuery = query;
2432
+ if (this.vectorsAreNormalized) {
2433
+ this.queryNormBuffer.set(query);
2434
+ normalizedQuery = this.normalizeVector(this.queryNormBuffer);
2435
+ }
2436
+ // Get more candidates than needed for rescoring.
2437
+ // When efSearch is explicit, respect it as-is — the caller controls the
2438
+ // recall/latency tradeoff. When omitted, auto-size based on candidateMultiplier.
2439
+ const numCandidates = k * candidateMultiplier;
2440
+ const effectiveEf = efSearch || Math.max(numCandidates * 2, 50);
2441
+ // Phase 1: Fast HNSW navigation using float32 (only for graph traversal)
2442
+ let currentEntryPoint = this.nodes[this.entryPointId];
2443
+ const entryVector = this.getNodeVector(this.entryPointId);
2444
+ if (!entryVector)
2445
+ return [];
2446
+ let currentBest = { id: currentEntryPoint.id, distance: this.calculateDistance(normalizedQuery, entryVector) };
2447
+ for (let l = this.maxLevel; l > 0; l--) {
2448
+ const result = this.greedySearch(normalizedQuery, currentEntryPoint, l);
2449
+ if (result.distance < currentBest.distance) {
2450
+ currentBest = result;
2451
+ currentEntryPoint = this.nodes[currentBest.id];
2452
+ }
2453
+ }
2454
+ // Phase 2: Search layer 0 with quantized distance for speed
2455
+ const candidates = this.searchLayerQuantized(normalizedQuery, currentBest, 0, effectiveEf);
2456
+ // Phase 3: Rescore top candidates with float32 for accuracy
2457
+ const rescoreCount = Math.min(candidates.length, numCandidates);
2458
+ if (rescoreCount <= k) {
2459
+ // Small array — direct rescore + sort
2460
+ const rescored = new Array(rescoreCount);
2461
+ for (let i = 0; i < rescoreCount; i++) {
2462
+ const c = candidates[i];
2463
+ const nodeVector = this.getNodeVector(c.id);
2464
+ if (nodeVector) {
2465
+ rescored[i] = { id: c.id, distance: this.calculateDistance(normalizedQuery, nodeVector) };
2466
+ }
2467
+ else {
2468
+ rescored[i] = c;
2469
+ }
2470
+ }
2471
+ rescored.sort((a, b) => {
2472
+ const diff = a.distance - b.distance;
2473
+ return diff !== 0 ? diff : a.id - b.id;
2474
+ });
2475
+ return rescored;
2476
+ }
2477
+ // Use max-heap to maintain top-k — O(n + k log k) vs O(n log n) sort
2478
+ const heap = new MaxBinaryHeap(k);
2479
+ for (let i = 0; i < rescoreCount; i++) {
2480
+ const c = candidates[i];
2481
+ const nodeVector = this.getNodeVector(c.id);
2482
+ const dist = nodeVector ? this.calculateDistance(normalizedQuery, nodeVector) : c.distance;
2483
+ if (heap.size() < k) {
2484
+ heap.push(c.id, dist);
2485
+ }
2486
+ else if (dist < heap.peekValue()) {
2487
+ heap.pop();
2488
+ heap.push(c.id, dist);
2489
+ }
2490
+ }
2491
+ // Extract sorted results (drain max-heap in reverse)
2492
+ const heapSize = heap.size();
2493
+ const result = new Array(heapSize);
2494
+ for (let i = heapSize - 1; i >= 0; i--) {
2495
+ result[i] = { id: heap.peek(), distance: heap.peekValue() };
2496
+ heap.pop();
2497
+ }
2498
+ return result;
2499
+ }
2500
+ /**
2501
+ * Calibrate the index for adaptive efSearch. Runs greedy descent for sample
2502
+ * queries and records statistics about entry distances.
2503
+ *
2504
+ * @param sampleQueries Array of query vectors to calibrate with
2505
+ */
2506
+ calibrate(sampleQueries) {
2507
+ if (sampleQueries.length === 0) {
2508
+ throw new VectorDBError('calibrate() requires at least one sample query', 'VALIDATION_ERROR');
2509
+ }
2510
+ if (this.entryPointId === -1 || this.nodeCount === 0) {
2511
+ throw new VectorDBError('Cannot calibrate empty index', 'VALIDATION_ERROR');
2512
+ }
2513
+ const distances = [];
2514
+ for (const query of sampleQueries) {
2515
+ this.validateQueryVector(query, 'Calibration query');
2516
+ // Normalize if needed
2517
+ let normalizedQuery = query;
2518
+ if (this.vectorsAreNormalized) {
2519
+ this.queryNormBuffer.set(query);
2520
+ normalizedQuery = this.normalizeVector(this.queryNormBuffer);
2521
+ }
2522
+ // Greedy descent to find best node
2523
+ let currentEntryPoint = this.nodes[this.entryPointId];
2524
+ const entryVector = this.getNodeVector(this.entryPointId);
2525
+ if (!entryVector)
2526
+ continue;
2527
+ let currentBest = { id: currentEntryPoint.id, distance: this.calculateDistance(normalizedQuery, entryVector) };
2528
+ for (let l = this.maxLevel; l > 0; l--) {
2529
+ const result = this.greedySearch(normalizedQuery, currentEntryPoint, l);
2530
+ if (result.distance < currentBest.distance) {
2531
+ currentBest = result;
2532
+ currentEntryPoint = this.nodes[currentBest.id];
2533
+ }
2534
+ }
2535
+ distances.push(currentBest.distance);
2536
+ }
2537
+ if (distances.length === 0)
2538
+ return;
2539
+ const mean = distances.reduce((s, d) => s + d, 0) / distances.length;
2540
+ const variance = distances.reduce((s, d) => s + (d - mean) ** 2, 0) / distances.length;
2541
+ const std = Math.sqrt(variance);
2542
+ this.calibrationStats = { meanEntryDist: mean, stdEntryDist: std };
2543
+ }
2544
+ /**
2545
+ * Check if the index has been calibrated for adaptive search.
2546
+ */
2547
+ isCalibrated() {
2548
+ return this.calibrationStats !== null;
2549
+ }
2550
+ /**
2551
+ * Adaptive efSearch — scales ef based on query difficulty.
2552
+ * Easy queries (close to graph) use lower ef, hard queries (far from graph) use higher ef.
2553
+ * Falls back to baseEfSearch if not calibrated.
2554
+ *
2555
+ * @param query Query vector
2556
+ * @param k Number of results to return
2557
+ * @param baseEfSearch Base ef parameter (will be scaled up/down)
2558
+ * @returns Same format as searchKNN
2559
+ */
2560
+ searchKNNAdaptive(query, k, baseEfSearch) {
2561
+ this.validateSearchParams(k, baseEfSearch);
2562
+ this.validateQueryVector(query);
2563
+ const baseEf = baseEfSearch || Math.max(k * 2, 50);
2564
+ // If not calibrated, fall back to standard search
2565
+ if (!this.calibrationStats) {
2566
+ return this.searchKNN(query, k, baseEf);
2567
+ }
2568
+ if (this.entryPointId === -1 || this.nodeCount === 0) {
2569
+ return [];
2570
+ }
2571
+ // Normalize query if needed
2572
+ let normalizedQuery = query;
2573
+ if (this.vectorsAreNormalized) {
2574
+ this.queryNormBuffer.set(query);
2575
+ normalizedQuery = this.normalizeVector(this.queryNormBuffer);
2576
+ }
2577
+ // Greedy descent to layer 0 entry
2578
+ let currentEntryPoint = this.nodes[this.entryPointId];
2579
+ const entryVector = this.getNodeVector(this.entryPointId);
2580
+ if (!entryVector)
2581
+ return [];
2582
+ let currentBest = { id: currentEntryPoint.id, distance: this.calculateDistance(normalizedQuery, entryVector) };
2583
+ for (let l = this.maxLevel; l > 0; l--) {
2584
+ const result = this.greedySearch(normalizedQuery, currentEntryPoint, l);
2585
+ if (result.distance < currentBest.distance) {
2586
+ currentBest = result;
2587
+ currentEntryPoint = this.nodes[currentBest.id];
2588
+ }
2589
+ }
2590
+ // Compute adaptive ef based on entry distance relative to calibration
2591
+ const ratio = currentBest.distance / (this.calibrationStats.meanEntryDist || 1);
2592
+ let adaptiveEf;
2593
+ if (ratio < 0.5) {
2594
+ adaptiveEf = Math.round(baseEf * 0.5); // Easy query
2595
+ }
2596
+ else if (ratio > 2.0) {
2597
+ adaptiveEf = Math.round(baseEf * 2.0); // Hard query
2598
+ }
2599
+ else {
2600
+ adaptiveEf = baseEf;
2601
+ }
2602
+ // Clamp between k and baseEf * 4
2603
+ adaptiveEf = Math.max(k, Math.min(adaptiveEf, baseEf * 4));
2604
+ // Search layer 0 with adaptive ef
2605
+ const candidates = this.searchLayer(normalizedQuery, currentBest, 0, adaptiveEf);
2606
+ // Conditional sort (same as searchKNN)
2607
+ let hasTies = false;
2608
+ for (let i = 1; i < candidates.length; i++) {
2609
+ if (candidates[i].distance === candidates[i - 1].distance) {
2610
+ hasTies = true;
2611
+ break;
2612
+ }
2613
+ }
2614
+ if (hasTies) {
2615
+ candidates.sort((a, b) => {
2616
+ const diff = a.distance - b.distance;
2617
+ return diff !== 0 ? diff : a.id - b.id;
2618
+ });
2619
+ }
2620
+ if (candidates.length > k)
2621
+ candidates.length = k;
2622
+ return candidates;
2623
+ }
2624
+ /**
2625
+ * Search layer using Int8 quantized distances for speed.
2626
+ * OPTIMIZED: Uses batch distance calculation on contiguous flatInt8Vectors.
2627
+ * Falls back to one-by-one with int8Vectors[] if contiguous storage unavailable.
2628
+ */
2629
+ searchLayerQuantized(query, nearest, layer, ef) {
2630
+ // Quantize query once — reuse pre-allocated buffer when available
2631
+ let int8Query = null;
2632
+ if (this.scalarQuantizer) {
2633
+ if (this.queryInt8Buffer) {
2634
+ this.scalarQuantizer.quantizeInto(query, this.queryInt8Buffer, 0);
2635
+ int8Query = this.queryInt8Buffer;
2636
+ }
2637
+ else {
2638
+ int8Query = this.scalarQuantizer.quantize(query);
2639
+ }
2640
+ }
2641
+ // Clear visited tracking
2642
+ this.clearVisited();
2643
+ // Ensure heaps are large enough, then clear and reuse
2644
+ this.ensureHeapCapacity(ef);
2645
+ this.candidatesHeap.clear();
2646
+ this.resultsHeap.clear();
2647
+ // Initialize with entry point - recompute distance using int8 for consistent scale
2648
+ this.markVisited(nearest.id);
2649
+ let entryDist = nearest.distance;
2650
+ if (int8Query && this.flatInt8Vectors) {
2651
+ // Use contiguous storage for entry point distance
2652
+ const dim = this.dimension;
2653
+ const offset = nearest.id * dim;
2654
+ const flatInt8 = this.flatInt8Vectors;
2655
+ if (this.metric === 'euclidean') {
2656
+ let sum = 0;
2657
+ for (let d = 0; d < dim; d++) {
2658
+ const diff = int8Query[d] - flatInt8[offset + d];
2659
+ sum += diff * diff;
2660
+ }
2661
+ entryDist = Math.sqrt(sum);
2662
+ }
2663
+ else {
2664
+ // cosine/dot_product: -dotProduct
2665
+ let sum = 0;
2666
+ for (let d = 0; d < dim; d++) {
2667
+ sum += int8Query[d] * flatInt8[offset + d];
2668
+ }
2669
+ entryDist = -sum;
2670
+ }
2671
+ }
2672
+ else if (int8Query) {
2673
+ const entryInt8 = this.int8Vectors[nearest.id];
2674
+ if (entryInt8) {
2675
+ if (this.metric === 'cosine') {
2676
+ entryDist = cosineDistanceInt8(int8Query, entryInt8);
2677
+ }
2678
+ else if (this.metric === 'dot_product') {
2679
+ entryDist = -dotProductInt8(int8Query, entryInt8);
2680
+ }
2681
+ else {
2682
+ entryDist = Math.sqrt(l2SquaredInt8(int8Query, entryInt8));
2683
+ }
2684
+ }
2685
+ }
2686
+ this.candidatesHeap.push(nearest.id, entryDist);
2687
+ this.resultsHeap.push(nearest.id, entryDist);
2688
+ let furthestResultDist = entryDist;
2689
+ // OPTIMIZATION: Use batch path when contiguous int8 storage is available
2690
+ const useBatchPath = int8Query !== null && this.flatInt8Vectors !== null;
2691
+ // Reuse batch arrays from parent class
2692
+ const batchIds = this.batchNeighborIds;
2693
+ const batchDists = this.batchDistances;
2694
+ while (!this.candidatesHeap.isEmpty()) {
2695
+ const closestCandidateDist = this.candidatesHeap.peekValue();
2696
+ const closestCandidateId = this.candidatesHeap.pop();
2697
+ if (closestCandidateId === -1)
2698
+ continue;
2699
+ // TERMINATION
2700
+ if (this.resultsHeap.size() >= ef && closestCandidateDist > furthestResultDist) {
2701
+ break;
2702
+ }
2703
+ const node = this.nodes[closestCandidateId];
2704
+ if (!node)
2705
+ continue;
2706
+ const neighbors = node.neighbors[layer] || [];
2707
+ if (useBatchPath) {
2708
+ // OPTIMIZED: Collect unvisited neighbors and compute distances in batch
2709
+ let batchCount = 0;
2710
+ for (let i = 0; i < neighbors.length; i++) {
2711
+ const neighborId = neighbors[i];
2712
+ if (!this.nodes[neighborId])
2713
+ continue;
2714
+ if (!this.isVisited(neighborId)) {
2715
+ this.markVisited(neighborId);
2716
+ batchIds[batchCount] = neighborId;
2717
+ batchCount++;
2718
+ }
2719
+ }
2720
+ if (batchCount > 0) {
2721
+ if (batchDists.length < batchCount) {
2722
+ this.batchDistances.length = batchCount;
2723
+ }
2724
+ this.calculateDistancesBatchInt8(int8Query, batchIds, batchDists, batchCount);
2725
+ for (let i = 0; i < batchCount; i++) {
2726
+ const neighborId = batchIds[i];
2727
+ const distance = batchDists[i];
2728
+ if (this.resultsHeap.size() < ef || distance < furthestResultDist) {
2729
+ this.candidatesHeap.push(neighborId, distance);
2730
+ this.resultsHeap.push(neighborId, distance);
2731
+ if (this.resultsHeap.size() > ef) {
2732
+ this.resultsHeap.pop();
2733
+ }
2734
+ furthestResultDist = this.resultsHeap.peekValue();
2735
+ }
2736
+ }
2737
+ }
2738
+ }
2739
+ else {
2740
+ // Fallback: one-by-one (no contiguous storage or no quantizer)
2741
+ for (const neighborId of neighbors) {
2742
+ if (this.isVisited(neighborId))
2743
+ continue;
2744
+ this.markVisited(neighborId);
2745
+ let distance;
2746
+ if (int8Query) {
2747
+ const neighborInt8 = this.int8Vectors[neighborId];
2748
+ if (neighborInt8) {
2749
+ if (this.metric === 'cosine') {
2750
+ distance = cosineDistanceInt8(int8Query, neighborInt8);
2751
+ }
2752
+ else if (this.metric === 'dot_product') {
2753
+ distance = -dotProductInt8(int8Query, neighborInt8);
2754
+ }
2755
+ else {
2756
+ distance = Math.sqrt(l2SquaredInt8(int8Query, neighborInt8));
2757
+ }
2758
+ }
2759
+ else {
2760
+ const neighborVector = this.getNodeVector(neighborId);
2761
+ if (!neighborVector)
2762
+ continue;
2763
+ distance = this.calculateDistance(query, neighborVector);
2764
+ }
2765
+ }
2766
+ else {
2767
+ const neighborVector = this.getNodeVector(neighborId);
2768
+ if (!neighborVector)
2769
+ continue;
2770
+ distance = this.calculateDistance(query, neighborVector);
2771
+ }
2772
+ if (this.resultsHeap.size() < ef || distance < furthestResultDist) {
2773
+ this.candidatesHeap.push(neighborId, distance);
2774
+ this.resultsHeap.push(neighborId, distance);
2775
+ if (this.resultsHeap.size() > ef) {
2776
+ this.resultsHeap.pop();
2777
+ }
2778
+ furthestResultDist = this.resultsHeap.peekValue();
2779
+ }
2780
+ }
2781
+ }
2782
+ }
2783
+ // Extract results from max-heap
2784
+ const resultCount = this.resultsHeap.size();
2785
+ const results = new Array(resultCount);
2786
+ let idx = resultCount - 1;
2787
+ while (!this.resultsHeap.isEmpty()) {
2788
+ const dist = this.resultsHeap.peekValue();
2789
+ const id = this.resultsHeap.pop();
2790
+ results[idx--] = { id, distance: dist };
2791
+ }
2792
+ return results;
2793
+ }
2794
+ /**
2795
+ * Get quantization statistics
2796
+ */
2797
+ getQuantizationStats() {
2798
+ const vectorCount = this.nodeCount;
2799
+ const float32Size = vectorCount * this.dimension * 4;
2800
+ if (this.quantizationEnabled) {
2801
+ const int8Size = vectorCount * this.dimension;
2802
+ const reduction = (float32Size / int8Size).toFixed(1);
2803
+ return {
2804
+ enabled: true,
2805
+ vectorCount,
2806
+ memoryReduction: `${reduction}x (${(float32Size / 1024 / 1024).toFixed(1)}MB → ${(int8Size / 1024 / 1024).toFixed(1)}MB)`,
2807
+ expectedSpeedup: '3-4x for distance calculations'
2808
+ };
2809
+ }
2810
+ return {
2811
+ enabled: false,
2812
+ vectorCount,
2813
+ memoryReduction: '1x (no quantization)',
2814
+ expectedSpeedup: '1x (baseline)'
2815
+ };
2816
+ }
2817
+ }
2818
+ //# sourceMappingURL=HNSWIndex.js.map