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