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