@soulcraft/brainy 3.34.0 → 3.36.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 +63 -0
- package/README.md +32 -12
- package/dist/brainy.d.ts +15 -0
- package/dist/brainy.js +63 -34
- package/dist/hnsw/hnswIndex.d.ts +128 -1
- package/dist/hnsw/hnswIndex.js +411 -17
- package/dist/hnsw/hnswIndexOptimized.d.ts +3 -15
- package/dist/hnsw/hnswIndexOptimized.js +11 -42
- package/dist/hnsw/partitionedHNSWIndex.js +1 -1
- package/dist/interfaces/IIndex.d.ts +195 -0
- package/dist/interfaces/IIndex.js +15 -0
- package/dist/storage/adapters/baseStorageAdapter.d.ts +17 -0
- package/dist/storage/adapters/fileSystemStorage.d.ts +32 -0
- package/dist/storage/adapters/fileSystemStorage.js +66 -0
- package/dist/storage/adapters/gcsStorage.d.ts +36 -0
- package/dist/storage/adapters/gcsStorage.js +90 -0
- package/dist/storage/adapters/memoryStorage.d.ts +32 -0
- package/dist/storage/adapters/memoryStorage.js +43 -0
- package/dist/storage/adapters/opfsStorage.d.ts +36 -0
- package/dist/storage/adapters/opfsStorage.js +101 -0
- package/dist/storage/adapters/s3CompatibleStorage.d.ts +36 -0
- package/dist/storage/adapters/s3CompatibleStorage.js +112 -0
- package/dist/utils/memoryDetection.d.ts +119 -0
- package/dist/utils/memoryDetection.js +321 -0
- package/dist/utils/unifiedCache.d.ts +75 -1
- package/dist/utils/unifiedCache.js +123 -4
- package/package.json +1 -1
package/dist/hnsw/hnswIndex.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Based on the paper: "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs"
|
|
4
4
|
*/
|
|
5
5
|
import { euclideanDistance, calculateDistancesBatch } from '../utils/index.js';
|
|
6
|
+
import { getGlobalCache } from '../utils/unifiedCache.js';
|
|
7
|
+
import { prodLog } from '../utils/logger.js';
|
|
6
8
|
// Default HNSW parameters
|
|
7
9
|
const DEFAULT_CONFIG = {
|
|
8
10
|
M: 16, // Max number of connections per noun
|
|
@@ -11,6 +13,7 @@ const DEFAULT_CONFIG = {
|
|
|
11
13
|
ml: 16 // Max level
|
|
12
14
|
};
|
|
13
15
|
export class HNSWIndex {
|
|
16
|
+
// Always-adaptive caching (v3.36.0+) - no "mode" concept, system adapts automatically
|
|
14
17
|
constructor(config = {}, distanceFunction = euclideanDistance, options = {}) {
|
|
15
18
|
this.nouns = new Map();
|
|
16
19
|
this.entryPointId = null;
|
|
@@ -20,12 +23,16 @@ export class HNSWIndex {
|
|
|
20
23
|
this.MAX_TRACKED_LEVELS = 10; // Only track top levels for memory efficiency
|
|
21
24
|
this.dimension = null;
|
|
22
25
|
this.useParallelization = true; // Whether to use parallelization for performance-critical operations
|
|
26
|
+
this.storage = null; // Storage adapter for HNSW persistence (v3.35.0+)
|
|
23
27
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
24
28
|
this.distanceFunction = distanceFunction;
|
|
25
29
|
this.useParallelization =
|
|
26
30
|
options.useParallelization !== undefined
|
|
27
31
|
? options.useParallelization
|
|
28
32
|
: true;
|
|
33
|
+
this.storage = options.storage || null;
|
|
34
|
+
// Use SAME UnifiedCache as Graph and Metadata for fair memory competition
|
|
35
|
+
this.unifiedCache = getGlobalCache();
|
|
29
36
|
}
|
|
30
37
|
/**
|
|
31
38
|
* Set whether to use parallelization for performance-critical operations
|
|
@@ -136,7 +143,8 @@ export class HNSWIndex {
|
|
|
136
143
|
return id;
|
|
137
144
|
}
|
|
138
145
|
let currObj = entryPoint;
|
|
139
|
-
|
|
146
|
+
// Calculate distance to entry point (handles lazy loading + sync fast path)
|
|
147
|
+
let currDist = await Promise.resolve(this.distanceSafe(vector, entryPoint));
|
|
140
148
|
// Traverse the graph from top to bottom to find the closest noun
|
|
141
149
|
for (let level = this.maxLevel; level > nounLevel; level--) {
|
|
142
150
|
let changed = true;
|
|
@@ -144,13 +152,17 @@ export class HNSWIndex {
|
|
|
144
152
|
changed = false;
|
|
145
153
|
// Check all neighbors at current level
|
|
146
154
|
const connections = currObj.connections.get(level) || new Set();
|
|
155
|
+
// OPTIMIZATION: Preload neighbor vectors for parallel loading
|
|
156
|
+
if (connections.size > 0) {
|
|
157
|
+
await this.preloadVectors(Array.from(connections));
|
|
158
|
+
}
|
|
147
159
|
for (const neighborId of connections) {
|
|
148
160
|
const neighbor = this.nouns.get(neighborId);
|
|
149
161
|
if (!neighbor) {
|
|
150
162
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
151
163
|
continue;
|
|
152
164
|
}
|
|
153
|
-
const distToNeighbor = this.
|
|
165
|
+
const distToNeighbor = await Promise.resolve(this.distanceSafe(vector, neighbor));
|
|
154
166
|
if (distToNeighbor < currDist) {
|
|
155
167
|
currDist = distToNeighbor;
|
|
156
168
|
currObj = neighbor;
|
|
@@ -180,7 +192,20 @@ export class HNSWIndex {
|
|
|
180
192
|
neighbor.connections.get(level).add(id);
|
|
181
193
|
// Ensure neighbor doesn't have too many connections
|
|
182
194
|
if (neighbor.connections.get(level).size > this.config.M) {
|
|
183
|
-
this.pruneConnections(neighbor, level);
|
|
195
|
+
await this.pruneConnections(neighbor, level);
|
|
196
|
+
}
|
|
197
|
+
// Persist updated neighbor HNSW data (v3.35.0+)
|
|
198
|
+
if (this.storage) {
|
|
199
|
+
const neighborConnectionsObj = {};
|
|
200
|
+
for (const [lvl, nounIds] of neighbor.connections.entries()) {
|
|
201
|
+
neighborConnectionsObj[lvl.toString()] = Array.from(nounIds);
|
|
202
|
+
}
|
|
203
|
+
this.storage.saveHNSWData(neighborId, {
|
|
204
|
+
level: neighbor.level,
|
|
205
|
+
connections: neighborConnectionsObj
|
|
206
|
+
}).catch((error) => {
|
|
207
|
+
console.error(`Failed to persist neighbor HNSW data for ${neighborId}:`, error);
|
|
208
|
+
});
|
|
184
209
|
}
|
|
185
210
|
}
|
|
186
211
|
// Update entry point for the next level
|
|
@@ -213,6 +238,27 @@ export class HNSWIndex {
|
|
|
213
238
|
}
|
|
214
239
|
this.highLevelNodes.get(nounLevel).add(id);
|
|
215
240
|
}
|
|
241
|
+
// Persist HNSW graph data to storage (v3.35.0+)
|
|
242
|
+
if (this.storage) {
|
|
243
|
+
// Convert connections Map to serializable format
|
|
244
|
+
const connectionsObj = {};
|
|
245
|
+
for (const [level, nounIds] of noun.connections.entries()) {
|
|
246
|
+
connectionsObj[level.toString()] = Array.from(nounIds);
|
|
247
|
+
}
|
|
248
|
+
await this.storage.saveHNSWData(id, {
|
|
249
|
+
level: nounLevel,
|
|
250
|
+
connections: connectionsObj
|
|
251
|
+
}).catch((error) => {
|
|
252
|
+
console.error(`Failed to persist HNSW data for ${id}:`, error);
|
|
253
|
+
});
|
|
254
|
+
// Persist system data (entry point and max level)
|
|
255
|
+
await this.storage.saveHNSWSystem({
|
|
256
|
+
entryPointId: this.entryPointId,
|
|
257
|
+
maxLevel: this.maxLevel
|
|
258
|
+
}).catch((error) => {
|
|
259
|
+
console.error('Failed to persist HNSW system data:', error);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
216
262
|
return id;
|
|
217
263
|
}
|
|
218
264
|
/**
|
|
@@ -240,7 +286,9 @@ export class HNSWIndex {
|
|
|
240
286
|
return [];
|
|
241
287
|
}
|
|
242
288
|
let currObj = entryPoint;
|
|
243
|
-
|
|
289
|
+
// OPTIMIZATION: Preload entry point vector
|
|
290
|
+
await this.preloadVectors([entryPoint.id]);
|
|
291
|
+
let currDist = await Promise.resolve(this.distanceSafe(queryVector, currObj));
|
|
244
292
|
// Traverse the graph from top to bottom to find the closest noun
|
|
245
293
|
for (let level = this.maxLevel; level > 0; level--) {
|
|
246
294
|
let changed = true;
|
|
@@ -248,6 +296,10 @@ export class HNSWIndex {
|
|
|
248
296
|
changed = false;
|
|
249
297
|
// Check all neighbors at current level
|
|
250
298
|
const connections = currObj.connections.get(level) || new Set();
|
|
299
|
+
// OPTIMIZATION: Preload all neighbor vectors in parallel before distance calculations
|
|
300
|
+
if (connections.size > 0) {
|
|
301
|
+
await this.preloadVectors(Array.from(connections));
|
|
302
|
+
}
|
|
251
303
|
// If we have enough connections, use parallel distance calculation
|
|
252
304
|
if (this.useParallelization && connections.size >= 10) {
|
|
253
305
|
// Prepare vectors for parallel calculation
|
|
@@ -256,7 +308,8 @@ export class HNSWIndex {
|
|
|
256
308
|
const neighbor = this.nouns.get(neighborId);
|
|
257
309
|
if (!neighbor)
|
|
258
310
|
continue;
|
|
259
|
-
|
|
311
|
+
const neighborVector = await this.getVectorSafe(neighbor);
|
|
312
|
+
vectors.push({ id: neighborId, vector: neighborVector });
|
|
260
313
|
}
|
|
261
314
|
// Calculate distances in parallel
|
|
262
315
|
const distances = await this.calculateDistancesInParallel(queryVector, vectors);
|
|
@@ -280,7 +333,7 @@ export class HNSWIndex {
|
|
|
280
333
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
281
334
|
continue;
|
|
282
335
|
}
|
|
283
|
-
const distToNeighbor = this.
|
|
336
|
+
const distToNeighbor = await Promise.resolve(this.distanceSafe(queryVector, neighbor));
|
|
284
337
|
if (distToNeighbor < currDist) {
|
|
285
338
|
currDist = distToNeighbor;
|
|
286
339
|
currObj = neighbor;
|
|
@@ -300,7 +353,7 @@ export class HNSWIndex {
|
|
|
300
353
|
/**
|
|
301
354
|
* Remove an item from the index
|
|
302
355
|
*/
|
|
303
|
-
removeItem(id) {
|
|
356
|
+
async removeItem(id) {
|
|
304
357
|
if (!this.nouns.has(id)) {
|
|
305
358
|
return false;
|
|
306
359
|
}
|
|
@@ -316,7 +369,7 @@ export class HNSWIndex {
|
|
|
316
369
|
if (neighbor.connections.has(level)) {
|
|
317
370
|
neighbor.connections.get(level).delete(id);
|
|
318
371
|
// Prune connections after removing this noun to ensure consistency
|
|
319
|
-
this.pruneConnections(neighbor, level);
|
|
372
|
+
await this.pruneConnections(neighbor, level);
|
|
320
373
|
}
|
|
321
374
|
}
|
|
322
375
|
}
|
|
@@ -328,7 +381,7 @@ export class HNSWIndex {
|
|
|
328
381
|
if (connections.has(id)) {
|
|
329
382
|
connections.delete(id);
|
|
330
383
|
// Prune connections after removing this reference
|
|
331
|
-
this.pruneConnections(otherNoun, level);
|
|
384
|
+
await this.pruneConnections(otherNoun, level);
|
|
332
385
|
}
|
|
333
386
|
}
|
|
334
387
|
}
|
|
@@ -437,6 +490,120 @@ export class HNSWIndex {
|
|
|
437
490
|
getConfig() {
|
|
438
491
|
return { ...this.config };
|
|
439
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* Get vector safely (always uses adaptive caching via UnifiedCache)
|
|
495
|
+
*
|
|
496
|
+
* Production-grade adaptive caching (v3.36.0+):
|
|
497
|
+
* - Vector already loaded: Returns immediately (O(1))
|
|
498
|
+
* - Vector in cache: Loads from UnifiedCache (O(1) hash lookup)
|
|
499
|
+
* - Vector on disk: Loads from storage → UnifiedCache (O(disk))
|
|
500
|
+
* - Cost-aware caching: UnifiedCache manages memory competition
|
|
501
|
+
*
|
|
502
|
+
* @param noun The HNSW noun (may have empty vector if not yet loaded)
|
|
503
|
+
* @returns Promise<Vector> The vector (loaded on-demand if needed)
|
|
504
|
+
*/
|
|
505
|
+
async getVectorSafe(noun) {
|
|
506
|
+
// Vector already in memory
|
|
507
|
+
if (noun.vector.length > 0) {
|
|
508
|
+
return noun.vector;
|
|
509
|
+
}
|
|
510
|
+
// Load from UnifiedCache with storage fallback
|
|
511
|
+
const cacheKey = `hnsw:vector:${noun.id}`;
|
|
512
|
+
const vector = await this.unifiedCache.get(cacheKey, async () => {
|
|
513
|
+
// Cache miss - load from storage
|
|
514
|
+
if (!this.storage) {
|
|
515
|
+
throw new Error('Storage not available for vector loading');
|
|
516
|
+
}
|
|
517
|
+
const loaded = await this.storage.getNounVector(noun.id);
|
|
518
|
+
if (!loaded) {
|
|
519
|
+
throw new Error(`Vector not found for noun ${noun.id}`);
|
|
520
|
+
}
|
|
521
|
+
// Add to UnifiedCache with cost-aware eviction
|
|
522
|
+
// This competes fairly with Graph and Metadata indexes
|
|
523
|
+
this.unifiedCache.set(cacheKey, loaded, 'hnsw', // Type for fairness monitoring
|
|
524
|
+
loaded.length * 4, // Size in bytes (float32)
|
|
525
|
+
50 // Rebuild cost in ms (moderate priority)
|
|
526
|
+
);
|
|
527
|
+
return loaded;
|
|
528
|
+
});
|
|
529
|
+
return vector;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Get vector synchronously if available in memory (v3.36.0+)
|
|
533
|
+
*
|
|
534
|
+
* Sync fast path optimization:
|
|
535
|
+
* - Vector in memory: Returns immediately (zero overhead)
|
|
536
|
+
* - Vector in cache: Returns from UnifiedCache synchronously
|
|
537
|
+
* - Returns null if vector not available (caller must handle async path)
|
|
538
|
+
*
|
|
539
|
+
* Use for sync fast path in distance calculations - eliminates async overhead
|
|
540
|
+
* when vectors are already cached.
|
|
541
|
+
*
|
|
542
|
+
* @param noun The HNSW noun
|
|
543
|
+
* @returns Vector | null - vector if in memory/cache, null if needs async load
|
|
544
|
+
*/
|
|
545
|
+
getVectorSync(noun) {
|
|
546
|
+
// Vector already in memory
|
|
547
|
+
if (noun.vector.length > 0) {
|
|
548
|
+
return noun.vector;
|
|
549
|
+
}
|
|
550
|
+
// Try sync cache lookup
|
|
551
|
+
const cacheKey = `hnsw:vector:${noun.id}`;
|
|
552
|
+
const vector = this.unifiedCache.getSync(cacheKey);
|
|
553
|
+
return vector || null;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Preload multiple vectors in parallel via UnifiedCache
|
|
557
|
+
*
|
|
558
|
+
* Optimization for search operations:
|
|
559
|
+
* - Loads all candidate vectors before distance calculations
|
|
560
|
+
* - Reduces serial disk I/O (parallel loads are faster)
|
|
561
|
+
* - Uses UnifiedCache's request coalescing to prevent stampede
|
|
562
|
+
* - Always active (no "mode" check) for optimal performance
|
|
563
|
+
*
|
|
564
|
+
* @param nodeIds Array of node IDs to preload
|
|
565
|
+
*/
|
|
566
|
+
async preloadVectors(nodeIds) {
|
|
567
|
+
if (nodeIds.length === 0)
|
|
568
|
+
return;
|
|
569
|
+
// Use UnifiedCache's request coalescing to prevent duplicate loads
|
|
570
|
+
const promises = nodeIds.map(async (id) => {
|
|
571
|
+
const cacheKey = `hnsw:vector:${id}`;
|
|
572
|
+
return this.unifiedCache.get(cacheKey, async () => {
|
|
573
|
+
if (!this.storage)
|
|
574
|
+
return null;
|
|
575
|
+
const vector = await this.storage.getNounVector(id);
|
|
576
|
+
if (vector) {
|
|
577
|
+
this.unifiedCache.set(cacheKey, vector, 'hnsw', vector.length * 4, 50);
|
|
578
|
+
}
|
|
579
|
+
return vector;
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
await Promise.all(promises);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Calculate distance with sync fast path (v3.36.0+)
|
|
586
|
+
*
|
|
587
|
+
* Eliminates async overhead when vectors are in memory:
|
|
588
|
+
* - Sync path: Vector in memory → returns number (zero overhead)
|
|
589
|
+
* - Async path: Vector needs loading → returns Promise<number>
|
|
590
|
+
*
|
|
591
|
+
* Callers must handle union type: `const dist = await Promise.resolve(distance)`
|
|
592
|
+
*
|
|
593
|
+
* @param queryVector The query vector
|
|
594
|
+
* @param noun The target noun (may have empty vector in lazy mode)
|
|
595
|
+
* @returns number | Promise<number> - sync when cached, async when needs load
|
|
596
|
+
*/
|
|
597
|
+
distanceSafe(queryVector, noun) {
|
|
598
|
+
// Try sync fast path
|
|
599
|
+
const nounVector = this.getVectorSync(noun);
|
|
600
|
+
if (nounVector !== null) {
|
|
601
|
+
// SYNC PATH: Vector in memory - zero async overhead
|
|
602
|
+
return this.distanceFunction(queryVector, nounVector);
|
|
603
|
+
}
|
|
604
|
+
// ASYNC PATH: Vector needs loading from storage
|
|
605
|
+
return this.getVectorSafe(noun).then(loadedVector => this.distanceFunction(queryVector, loadedVector));
|
|
606
|
+
}
|
|
440
607
|
/**
|
|
441
608
|
* Get all nodes at a specific level for clustering
|
|
442
609
|
* This enables O(n) clustering using HNSW's natural hierarchy
|
|
@@ -451,6 +618,126 @@ export class HNSWIndex {
|
|
|
451
618
|
}
|
|
452
619
|
return nodesAtLevel;
|
|
453
620
|
}
|
|
621
|
+
/**
|
|
622
|
+
* Rebuild HNSW index from persisted graph data (v3.35.0+)
|
|
623
|
+
*
|
|
624
|
+
* This is a production-grade O(N) rebuild that restores the pre-computed graph structure
|
|
625
|
+
* from storage. Much faster than re-building which is O(N log N).
|
|
626
|
+
*
|
|
627
|
+
* Designed for millions of entities with:
|
|
628
|
+
* - Cursor-based pagination (no memory overflow)
|
|
629
|
+
* - Batch processing (configurable batch size)
|
|
630
|
+
* - Progress reporting (optional callback)
|
|
631
|
+
* - Error recovery (continues on partial failures)
|
|
632
|
+
* - Lazy mode support (memory-efficient for constrained environments)
|
|
633
|
+
*
|
|
634
|
+
* @param options Rebuild options
|
|
635
|
+
* @returns Promise that resolves when rebuild is complete
|
|
636
|
+
*/
|
|
637
|
+
async rebuild(options = {}) {
|
|
638
|
+
if (!this.storage) {
|
|
639
|
+
prodLog.warn('HNSW rebuild skipped: no storage adapter configured');
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const batchSize = options.batchSize || 1000;
|
|
643
|
+
try {
|
|
644
|
+
// Step 1: Clear existing in-memory index
|
|
645
|
+
this.clear();
|
|
646
|
+
// Step 2: Load system data (entry point, max level)
|
|
647
|
+
const systemData = await this.storage.getHNSWSystem();
|
|
648
|
+
if (systemData) {
|
|
649
|
+
this.entryPointId = systemData.entryPointId;
|
|
650
|
+
this.maxLevel = systemData.maxLevel;
|
|
651
|
+
}
|
|
652
|
+
// Step 3: Determine preloading strategy (adaptive caching)
|
|
653
|
+
// Check if vectors should be preloaded at init or loaded on-demand
|
|
654
|
+
const stats = await this.storage.getStatistics();
|
|
655
|
+
const entityCount = stats?.totalNodes || 0;
|
|
656
|
+
// Estimate memory needed for all vectors (384 dims × 4 bytes = 1536 bytes/vector)
|
|
657
|
+
const vectorMemory = entityCount * 1536;
|
|
658
|
+
// Get available cache size (80% threshold - preload only if fits comfortably)
|
|
659
|
+
const cacheStats = this.unifiedCache.getStats();
|
|
660
|
+
const availableCache = cacheStats.maxSize * 0.80;
|
|
661
|
+
const shouldPreload = vectorMemory < availableCache;
|
|
662
|
+
if (shouldPreload) {
|
|
663
|
+
prodLog.info(`HNSW: Preloading ${entityCount.toLocaleString()} vectors at init ` +
|
|
664
|
+
`(${(vectorMemory / 1024 / 1024).toFixed(1)}MB < ${(availableCache / 1024 / 1024).toFixed(1)}MB cache)`);
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
prodLog.info(`HNSW: Adaptive caching for ${entityCount.toLocaleString()} vectors ` +
|
|
668
|
+
`(${(vectorMemory / 1024 / 1024).toFixed(1)}MB > ${(availableCache / 1024 / 1024).toFixed(1)}MB cache) - loading on-demand`);
|
|
669
|
+
}
|
|
670
|
+
// Step 4: Paginate through all nouns and restore HNSW graph structure
|
|
671
|
+
let loadedCount = 0;
|
|
672
|
+
let totalCount = undefined;
|
|
673
|
+
let hasMore = true;
|
|
674
|
+
let cursor = undefined;
|
|
675
|
+
while (hasMore) {
|
|
676
|
+
// Fetch batch of nouns from storage (cast needed as method is not in base interface)
|
|
677
|
+
const result = await this.storage.getNounsWithPagination({
|
|
678
|
+
limit: batchSize,
|
|
679
|
+
cursor
|
|
680
|
+
});
|
|
681
|
+
// Set total count on first batch
|
|
682
|
+
if (totalCount === undefined && result.totalCount !== undefined) {
|
|
683
|
+
totalCount = result.totalCount;
|
|
684
|
+
}
|
|
685
|
+
// Process each noun in the batch
|
|
686
|
+
for (const nounData of result.items) {
|
|
687
|
+
try {
|
|
688
|
+
// Load HNSW graph data for this entity
|
|
689
|
+
const hnswData = await this.storage.getHNSWData(nounData.id);
|
|
690
|
+
if (!hnswData) {
|
|
691
|
+
// No HNSW data - skip (might be entity added before persistence)
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
// Create noun object with restored connections
|
|
695
|
+
const noun = {
|
|
696
|
+
id: nounData.id,
|
|
697
|
+
vector: shouldPreload ? nounData.vector : [], // Preload if dataset is small
|
|
698
|
+
connections: new Map(),
|
|
699
|
+
level: hnswData.level
|
|
700
|
+
};
|
|
701
|
+
// Restore connections from persisted data
|
|
702
|
+
for (const [levelStr, nounIds] of Object.entries(hnswData.connections)) {
|
|
703
|
+
const level = parseInt(levelStr, 10);
|
|
704
|
+
noun.connections.set(level, new Set(nounIds));
|
|
705
|
+
}
|
|
706
|
+
// Add to in-memory index
|
|
707
|
+
this.nouns.set(nounData.id, noun);
|
|
708
|
+
// Track high-level nodes for O(1) entry point selection
|
|
709
|
+
if (noun.level >= 2 && noun.level <= this.MAX_TRACKED_LEVELS) {
|
|
710
|
+
if (!this.highLevelNodes.has(noun.level)) {
|
|
711
|
+
this.highLevelNodes.set(noun.level, new Set());
|
|
712
|
+
}
|
|
713
|
+
this.highLevelNodes.get(noun.level).add(nounData.id);
|
|
714
|
+
}
|
|
715
|
+
loadedCount++;
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
// Log error but continue (robust error recovery)
|
|
719
|
+
console.error(`Failed to rebuild HNSW data for ${nounData.id}:`, error);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// Report progress
|
|
723
|
+
if (options.onProgress && totalCount !== undefined) {
|
|
724
|
+
options.onProgress(loadedCount, totalCount);
|
|
725
|
+
}
|
|
726
|
+
// Check for more data
|
|
727
|
+
hasMore = result.hasMore;
|
|
728
|
+
cursor = result.nextCursor;
|
|
729
|
+
}
|
|
730
|
+
const cacheInfo = shouldPreload
|
|
731
|
+
? ` (vectors preloaded)`
|
|
732
|
+
: ` (adaptive caching - vectors loaded on-demand)`;
|
|
733
|
+
prodLog.info(`✅ HNSW index rebuilt: ${loadedCount.toLocaleString()} entities, ` +
|
|
734
|
+
`${this.maxLevel + 1} levels, entry point: ${this.entryPointId || 'none'}${cacheInfo}`);
|
|
735
|
+
}
|
|
736
|
+
catch (error) {
|
|
737
|
+
prodLog.error('HNSW rebuild failed:', error);
|
|
738
|
+
throw new Error(`Failed to rebuild HNSW index: ${error}`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
454
741
|
/**
|
|
455
742
|
* Get level statistics for understanding the hierarchy
|
|
456
743
|
*/
|
|
@@ -495,6 +782,97 @@ export class HNSWIndex {
|
|
|
495
782
|
totalNodes
|
|
496
783
|
};
|
|
497
784
|
}
|
|
785
|
+
/**
|
|
786
|
+
* Get cache performance statistics for monitoring and diagnostics (v3.36.0+)
|
|
787
|
+
*
|
|
788
|
+
* Production-grade monitoring:
|
|
789
|
+
* - Adaptive caching strategy (preloading vs on-demand)
|
|
790
|
+
* - UnifiedCache performance (hits, misses, evictions)
|
|
791
|
+
* - HNSW-specific cache statistics
|
|
792
|
+
* - Fair competition metrics across all indexes
|
|
793
|
+
* - Actionable recommendations for tuning
|
|
794
|
+
*
|
|
795
|
+
* Use this to:
|
|
796
|
+
* - Diagnose performance issues (low hit rate = increase cache)
|
|
797
|
+
* - Monitor memory competition (fairness violations = adjust costs)
|
|
798
|
+
* - Verify adaptive caching decisions (memory estimates vs actual)
|
|
799
|
+
* - Track cache efficiency over time
|
|
800
|
+
*
|
|
801
|
+
* @returns Comprehensive caching and performance statistics
|
|
802
|
+
*/
|
|
803
|
+
getCacheStats() {
|
|
804
|
+
// Get UnifiedCache stats
|
|
805
|
+
const cacheStats = this.unifiedCache.getStats();
|
|
806
|
+
// Calculate entity and memory estimates
|
|
807
|
+
const entityCount = this.nouns.size;
|
|
808
|
+
const vectorDimension = this.dimension || 384;
|
|
809
|
+
const bytesPerVector = vectorDimension * 4; // float32
|
|
810
|
+
const estimatedVectorMemoryMB = (entityCount * bytesPerVector) / (1024 * 1024);
|
|
811
|
+
const availableCacheMB = (cacheStats.maxSize * 0.8) / (1024 * 1024); // 80% threshold
|
|
812
|
+
// Calculate HNSW-specific cache stats
|
|
813
|
+
const vectorsInCache = cacheStats.typeCounts.hnsw || 0;
|
|
814
|
+
const hnswMemoryBytes = cacheStats.typeSizes.hnsw || 0;
|
|
815
|
+
// Calculate fairness metrics
|
|
816
|
+
const hnswAccessCount = cacheStats.typeAccessCounts.hnsw || 0;
|
|
817
|
+
const totalAccessCount = cacheStats.totalAccessCount;
|
|
818
|
+
const hnswAccessPercent = totalAccessCount > 0 ? (hnswAccessCount / totalAccessCount) * 100 : 0;
|
|
819
|
+
// Detect fairness violation (>90% cache with <10% access)
|
|
820
|
+
const hnswCachePercent = cacheStats.maxSize > 0 ? (hnswMemoryBytes / cacheStats.maxSize) * 100 : 0;
|
|
821
|
+
const fairnessViolation = hnswCachePercent > 90 && hnswAccessPercent < 10;
|
|
822
|
+
// Calculate hit rate from cache
|
|
823
|
+
const hitRatePercent = (cacheStats.hitRate * 100) || 0;
|
|
824
|
+
// Determine caching strategy (same logic as rebuild())
|
|
825
|
+
const cachingStrategy = estimatedVectorMemoryMB < availableCacheMB ? 'preloaded' : 'on-demand';
|
|
826
|
+
// Generate actionable recommendations
|
|
827
|
+
const recommendations = [];
|
|
828
|
+
if (cachingStrategy === 'on-demand' && hitRatePercent < 50) {
|
|
829
|
+
recommendations.push(`Low cache hit rate (${hitRatePercent.toFixed(1)}%). Consider increasing UnifiedCache size for better performance`);
|
|
830
|
+
}
|
|
831
|
+
if (cachingStrategy === 'preloaded' && estimatedVectorMemoryMB > availableCacheMB * 0.5) {
|
|
832
|
+
recommendations.push(`Dataset growing (${estimatedVectorMemoryMB.toFixed(1)}MB). May switch to on-demand caching as entities increase`);
|
|
833
|
+
}
|
|
834
|
+
if (fairnessViolation) {
|
|
835
|
+
recommendations.push(`Fairness violation: HNSW using ${hnswCachePercent.toFixed(1)}% cache with only ${hnswAccessPercent.toFixed(1)}% access`);
|
|
836
|
+
}
|
|
837
|
+
if (cacheStats.utilization > 0.95) {
|
|
838
|
+
recommendations.push(`Cache utilization high (${(cacheStats.utilization * 100).toFixed(1)}%). Consider increasing cache size`);
|
|
839
|
+
}
|
|
840
|
+
if (recommendations.length === 0) {
|
|
841
|
+
recommendations.push('All metrics healthy - no action needed');
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
cachingStrategy,
|
|
845
|
+
autoDetection: {
|
|
846
|
+
entityCount,
|
|
847
|
+
estimatedVectorMemoryMB: parseFloat(estimatedVectorMemoryMB.toFixed(2)),
|
|
848
|
+
availableCacheMB: parseFloat(availableCacheMB.toFixed(2)),
|
|
849
|
+
threshold: 0.8, // 80% of UnifiedCache
|
|
850
|
+
rationale: cachingStrategy === 'preloaded'
|
|
851
|
+
? `Vectors preloaded at init (${estimatedVectorMemoryMB.toFixed(1)}MB < ${availableCacheMB.toFixed(1)}MB threshold)`
|
|
852
|
+
: `Adaptive on-demand loading (${estimatedVectorMemoryMB.toFixed(1)}MB > ${availableCacheMB.toFixed(1)}MB threshold)`
|
|
853
|
+
},
|
|
854
|
+
unifiedCache: {
|
|
855
|
+
totalSize: cacheStats.totalSize,
|
|
856
|
+
maxSize: cacheStats.maxSize,
|
|
857
|
+
utilizationPercent: parseFloat((cacheStats.utilization * 100).toFixed(2)),
|
|
858
|
+
itemCount: cacheStats.itemCount,
|
|
859
|
+
hitRatePercent: parseFloat(hitRatePercent.toFixed(2)),
|
|
860
|
+
totalAccessCount: cacheStats.totalAccessCount
|
|
861
|
+
},
|
|
862
|
+
hnswCache: {
|
|
863
|
+
vectorsInCache,
|
|
864
|
+
cacheKeyPrefix: 'hnsw:vector:',
|
|
865
|
+
estimatedMemoryMB: parseFloat((hnswMemoryBytes / (1024 * 1024)).toFixed(2))
|
|
866
|
+
},
|
|
867
|
+
fairness: {
|
|
868
|
+
hnswAccessCount,
|
|
869
|
+
hnswAccessPercent: parseFloat(hnswAccessPercent.toFixed(2)),
|
|
870
|
+
totalAccessCount,
|
|
871
|
+
fairnessViolation
|
|
872
|
+
},
|
|
873
|
+
recommendations
|
|
874
|
+
};
|
|
875
|
+
}
|
|
498
876
|
/**
|
|
499
877
|
* Search within a specific layer
|
|
500
878
|
* Returns a map of noun IDs to distances, sorted by distance
|
|
@@ -502,8 +880,10 @@ export class HNSWIndex {
|
|
|
502
880
|
async searchLayer(queryVector, entryPoint, ef, level, filter) {
|
|
503
881
|
// Set of visited nouns
|
|
504
882
|
const visited = new Set([entryPoint.id]);
|
|
505
|
-
//
|
|
506
|
-
|
|
883
|
+
// OPTIMIZATION: Preload entry point vector
|
|
884
|
+
await this.preloadVectors([entryPoint.id]);
|
|
885
|
+
// Check if entry point passes filter (with sync fast path)
|
|
886
|
+
const entryPointDistance = await Promise.resolve(this.distanceSafe(queryVector, entryPoint));
|
|
507
887
|
const entryPointPasses = filter ? await filter(entryPoint.id) : true;
|
|
508
888
|
// Priority queue of candidates (closest first)
|
|
509
889
|
const candidates = new Map();
|
|
@@ -526,10 +906,17 @@ export class HNSWIndex {
|
|
|
526
906
|
// Explore neighbors of the closest candidate
|
|
527
907
|
const noun = this.nouns.get(closestId);
|
|
528
908
|
if (!noun) {
|
|
529
|
-
|
|
909
|
+
prodLog.error(`Noun with ID ${closestId} not found in searchLayer`);
|
|
530
910
|
continue;
|
|
531
911
|
}
|
|
532
912
|
const connections = noun.connections.get(level) || new Set();
|
|
913
|
+
// OPTIMIZATION: Preload unvisited neighbor vectors in parallel
|
|
914
|
+
if (connections.size > 0) {
|
|
915
|
+
const unvisitedIds = Array.from(connections).filter(id => !visited.has(id));
|
|
916
|
+
if (unvisitedIds.length > 0) {
|
|
917
|
+
await this.preloadVectors(unvisitedIds);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
533
920
|
// If we have enough connections and parallelization is enabled, use parallel distance calculation
|
|
534
921
|
if (this.useParallelization && connections.size >= 10) {
|
|
535
922
|
// Collect unvisited neighbors
|
|
@@ -540,7 +927,8 @@ export class HNSWIndex {
|
|
|
540
927
|
const neighbor = this.nouns.get(neighborId);
|
|
541
928
|
if (!neighbor)
|
|
542
929
|
continue;
|
|
543
|
-
|
|
930
|
+
const neighborVector = await this.getVectorSafe(neighbor);
|
|
931
|
+
unvisitedNeighbors.push({ id: neighborId, vector: neighborVector });
|
|
544
932
|
}
|
|
545
933
|
}
|
|
546
934
|
if (unvisitedNeighbors.length > 0) {
|
|
@@ -580,7 +968,7 @@ export class HNSWIndex {
|
|
|
580
968
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
581
969
|
continue;
|
|
582
970
|
}
|
|
583
|
-
const distToNeighbor = this.
|
|
971
|
+
const distToNeighbor = await Promise.resolve(this.distanceSafe(queryVector, neighbor));
|
|
584
972
|
// Apply filter if provided
|
|
585
973
|
const passes = filter ? await filter(neighborId) : true;
|
|
586
974
|
// Always add to candidates for graph traversal
|
|
@@ -625,7 +1013,7 @@ export class HNSWIndex {
|
|
|
625
1013
|
/**
|
|
626
1014
|
* Ensure a noun doesn't have too many connections at a given level
|
|
627
1015
|
*/
|
|
628
|
-
pruneConnections(noun, level) {
|
|
1016
|
+
async pruneConnections(noun, level) {
|
|
629
1017
|
const connections = noun.connections.get(level);
|
|
630
1018
|
if (connections.size <= this.config.M) {
|
|
631
1019
|
return;
|
|
@@ -633,14 +1021,20 @@ export class HNSWIndex {
|
|
|
633
1021
|
// Calculate distances to all neighbors
|
|
634
1022
|
const distances = new Map();
|
|
635
1023
|
const validNeighborIds = new Set();
|
|
1024
|
+
// OPTIMIZATION: Preload all neighbor vectors
|
|
1025
|
+
if (connections.size > 0) {
|
|
1026
|
+
await this.preloadVectors(Array.from(connections));
|
|
1027
|
+
}
|
|
636
1028
|
for (const neighborId of connections) {
|
|
637
1029
|
const neighbor = this.nouns.get(neighborId);
|
|
638
1030
|
if (!neighbor) {
|
|
639
1031
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
640
1032
|
continue;
|
|
641
1033
|
}
|
|
642
|
-
// Only add valid neighbors to the distances map
|
|
643
|
-
|
|
1034
|
+
// Only add valid neighbors to the distances map (handles lazy loading + sync fast path)
|
|
1035
|
+
const nounVector = await this.getVectorSafe(noun);
|
|
1036
|
+
const distance = await Promise.resolve(this.distanceSafe(nounVector, neighbor));
|
|
1037
|
+
distances.set(neighborId, distance);
|
|
644
1038
|
validNeighborIds.add(neighborId);
|
|
645
1039
|
}
|
|
646
1040
|
// Only proceed if we have valid neighbors
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { DistanceFunction, HNSWConfig, Vector, VectorDocument } from '../coreTypes.js';
|
|
7
7
|
import { HNSWIndex } from './hnswIndex.js';
|
|
8
|
-
import {
|
|
8
|
+
import type { BaseStorage } from '../storage/baseStorage.js';
|
|
9
9
|
export interface HNSWOptimizedConfig extends HNSWConfig {
|
|
10
10
|
memoryThreshold?: number;
|
|
11
11
|
productQuantization?: {
|
|
@@ -88,15 +88,13 @@ declare class ProductQuantizer {
|
|
|
88
88
|
export declare class HNSWIndexOptimized extends HNSWIndex {
|
|
89
89
|
private optimizedConfig;
|
|
90
90
|
private productQuantizer;
|
|
91
|
-
private storage;
|
|
92
91
|
private useDiskBasedIndex;
|
|
93
92
|
private useProductQuantization;
|
|
94
93
|
private quantizedVectors;
|
|
95
94
|
private memoryUsage;
|
|
96
95
|
private vectorCount;
|
|
97
96
|
private memoryUpdateLock;
|
|
98
|
-
|
|
99
|
-
constructor(config: Partial<HNSWOptimizedConfig>, distanceFunction: DistanceFunction, storage?: StorageAdapter | null);
|
|
97
|
+
constructor(config: Partial<HNSWOptimizedConfig>, distanceFunction: DistanceFunction, storage?: BaseStorage | null);
|
|
100
98
|
/**
|
|
101
99
|
* Thread-safe method to update memory usage
|
|
102
100
|
* @param memoryDelta Change in memory usage (can be negative)
|
|
@@ -121,7 +119,7 @@ export declare class HNSWIndexOptimized extends HNSWIndex {
|
|
|
121
119
|
/**
|
|
122
120
|
* Remove an item from the index
|
|
123
121
|
*/
|
|
124
|
-
removeItem(id: string): boolean
|
|
122
|
+
removeItem(id: string): Promise<boolean>;
|
|
125
123
|
/**
|
|
126
124
|
* Clear the index
|
|
127
125
|
*/
|
|
@@ -145,16 +143,6 @@ export declare class HNSWIndexOptimized extends HNSWIndex {
|
|
|
145
143
|
* @returns Estimated memory usage in bytes
|
|
146
144
|
*/
|
|
147
145
|
getMemoryUsage(): number;
|
|
148
|
-
/**
|
|
149
|
-
* Set the storage adapter
|
|
150
|
-
* @param storage Storage adapter
|
|
151
|
-
*/
|
|
152
|
-
setStorage(storage: StorageAdapter): void;
|
|
153
|
-
/**
|
|
154
|
-
* Get the storage adapter
|
|
155
|
-
* @returns Storage adapter or null if not set
|
|
156
|
-
*/
|
|
157
|
-
getStorage(): StorageAdapter | null;
|
|
158
146
|
/**
|
|
159
147
|
* Set whether to use disk-based index
|
|
160
148
|
* @param useDiskBasedIndex Whether to use disk-based index
|