@soulcraft/brainy 3.35.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 +57 -0
- package/README.md +32 -12
- package/dist/hnsw/hnswIndex.d.ts +104 -1
- package/dist/hnsw/hnswIndex.js +282 -25
- package/dist/hnsw/hnswIndexOptimized.d.ts +1 -2
- package/dist/hnsw/hnswIndexOptimized.js +3 -5
- package/dist/hnsw/partitionedHNSWIndex.js +1 -1
- package/dist/interfaces/IIndex.d.ts +14 -5
- 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;
|
|
@@ -28,6 +31,8 @@ export class HNSWIndex {
|
|
|
28
31
|
? options.useParallelization
|
|
29
32
|
: true;
|
|
30
33
|
this.storage = options.storage || null;
|
|
34
|
+
// Use SAME UnifiedCache as Graph and Metadata for fair memory competition
|
|
35
|
+
this.unifiedCache = getGlobalCache();
|
|
31
36
|
}
|
|
32
37
|
/**
|
|
33
38
|
* Set whether to use parallelization for performance-critical operations
|
|
@@ -138,7 +143,8 @@ export class HNSWIndex {
|
|
|
138
143
|
return id;
|
|
139
144
|
}
|
|
140
145
|
let currObj = entryPoint;
|
|
141
|
-
|
|
146
|
+
// Calculate distance to entry point (handles lazy loading + sync fast path)
|
|
147
|
+
let currDist = await Promise.resolve(this.distanceSafe(vector, entryPoint));
|
|
142
148
|
// Traverse the graph from top to bottom to find the closest noun
|
|
143
149
|
for (let level = this.maxLevel; level > nounLevel; level--) {
|
|
144
150
|
let changed = true;
|
|
@@ -146,13 +152,17 @@ export class HNSWIndex {
|
|
|
146
152
|
changed = false;
|
|
147
153
|
// Check all neighbors at current level
|
|
148
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
|
+
}
|
|
149
159
|
for (const neighborId of connections) {
|
|
150
160
|
const neighbor = this.nouns.get(neighborId);
|
|
151
161
|
if (!neighbor) {
|
|
152
162
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
153
163
|
continue;
|
|
154
164
|
}
|
|
155
|
-
const distToNeighbor = this.
|
|
165
|
+
const distToNeighbor = await Promise.resolve(this.distanceSafe(vector, neighbor));
|
|
156
166
|
if (distToNeighbor < currDist) {
|
|
157
167
|
currDist = distToNeighbor;
|
|
158
168
|
currObj = neighbor;
|
|
@@ -182,7 +192,7 @@ export class HNSWIndex {
|
|
|
182
192
|
neighbor.connections.get(level).add(id);
|
|
183
193
|
// Ensure neighbor doesn't have too many connections
|
|
184
194
|
if (neighbor.connections.get(level).size > this.config.M) {
|
|
185
|
-
this.pruneConnections(neighbor, level);
|
|
195
|
+
await this.pruneConnections(neighbor, level);
|
|
186
196
|
}
|
|
187
197
|
// Persist updated neighbor HNSW data (v3.35.0+)
|
|
188
198
|
if (this.storage) {
|
|
@@ -276,7 +286,9 @@ export class HNSWIndex {
|
|
|
276
286
|
return [];
|
|
277
287
|
}
|
|
278
288
|
let currObj = entryPoint;
|
|
279
|
-
|
|
289
|
+
// OPTIMIZATION: Preload entry point vector
|
|
290
|
+
await this.preloadVectors([entryPoint.id]);
|
|
291
|
+
let currDist = await Promise.resolve(this.distanceSafe(queryVector, currObj));
|
|
280
292
|
// Traverse the graph from top to bottom to find the closest noun
|
|
281
293
|
for (let level = this.maxLevel; level > 0; level--) {
|
|
282
294
|
let changed = true;
|
|
@@ -284,6 +296,10 @@ export class HNSWIndex {
|
|
|
284
296
|
changed = false;
|
|
285
297
|
// Check all neighbors at current level
|
|
286
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
|
+
}
|
|
287
303
|
// If we have enough connections, use parallel distance calculation
|
|
288
304
|
if (this.useParallelization && connections.size >= 10) {
|
|
289
305
|
// Prepare vectors for parallel calculation
|
|
@@ -292,7 +308,8 @@ export class HNSWIndex {
|
|
|
292
308
|
const neighbor = this.nouns.get(neighborId);
|
|
293
309
|
if (!neighbor)
|
|
294
310
|
continue;
|
|
295
|
-
|
|
311
|
+
const neighborVector = await this.getVectorSafe(neighbor);
|
|
312
|
+
vectors.push({ id: neighborId, vector: neighborVector });
|
|
296
313
|
}
|
|
297
314
|
// Calculate distances in parallel
|
|
298
315
|
const distances = await this.calculateDistancesInParallel(queryVector, vectors);
|
|
@@ -316,7 +333,7 @@ export class HNSWIndex {
|
|
|
316
333
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
317
334
|
continue;
|
|
318
335
|
}
|
|
319
|
-
const distToNeighbor = this.
|
|
336
|
+
const distToNeighbor = await Promise.resolve(this.distanceSafe(queryVector, neighbor));
|
|
320
337
|
if (distToNeighbor < currDist) {
|
|
321
338
|
currDist = distToNeighbor;
|
|
322
339
|
currObj = neighbor;
|
|
@@ -336,7 +353,7 @@ export class HNSWIndex {
|
|
|
336
353
|
/**
|
|
337
354
|
* Remove an item from the index
|
|
338
355
|
*/
|
|
339
|
-
removeItem(id) {
|
|
356
|
+
async removeItem(id) {
|
|
340
357
|
if (!this.nouns.has(id)) {
|
|
341
358
|
return false;
|
|
342
359
|
}
|
|
@@ -352,7 +369,7 @@ export class HNSWIndex {
|
|
|
352
369
|
if (neighbor.connections.has(level)) {
|
|
353
370
|
neighbor.connections.get(level).delete(id);
|
|
354
371
|
// Prune connections after removing this noun to ensure consistency
|
|
355
|
-
this.pruneConnections(neighbor, level);
|
|
372
|
+
await this.pruneConnections(neighbor, level);
|
|
356
373
|
}
|
|
357
374
|
}
|
|
358
375
|
}
|
|
@@ -364,7 +381,7 @@ export class HNSWIndex {
|
|
|
364
381
|
if (connections.has(id)) {
|
|
365
382
|
connections.delete(id);
|
|
366
383
|
// Prune connections after removing this reference
|
|
367
|
-
this.pruneConnections(otherNoun, level);
|
|
384
|
+
await this.pruneConnections(otherNoun, level);
|
|
368
385
|
}
|
|
369
386
|
}
|
|
370
387
|
}
|
|
@@ -473,6 +490,120 @@ export class HNSWIndex {
|
|
|
473
490
|
getConfig() {
|
|
474
491
|
return { ...this.config };
|
|
475
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
|
+
}
|
|
476
607
|
/**
|
|
477
608
|
* Get all nodes at a specific level for clustering
|
|
478
609
|
* This enables O(n) clustering using HNSW's natural hierarchy
|
|
@@ -505,11 +636,10 @@ export class HNSWIndex {
|
|
|
505
636
|
*/
|
|
506
637
|
async rebuild(options = {}) {
|
|
507
638
|
if (!this.storage) {
|
|
508
|
-
|
|
639
|
+
prodLog.warn('HNSW rebuild skipped: no storage adapter configured');
|
|
509
640
|
return;
|
|
510
641
|
}
|
|
511
642
|
const batchSize = options.batchSize || 1000;
|
|
512
|
-
const lazy = options.lazy || false;
|
|
513
643
|
try {
|
|
514
644
|
// Step 1: Clear existing in-memory index
|
|
515
645
|
this.clear();
|
|
@@ -519,7 +649,25 @@ export class HNSWIndex {
|
|
|
519
649
|
this.entryPointId = systemData.entryPointId;
|
|
520
650
|
this.maxLevel = systemData.maxLevel;
|
|
521
651
|
}
|
|
522
|
-
// Step 3:
|
|
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
|
|
523
671
|
let loadedCount = 0;
|
|
524
672
|
let totalCount = undefined;
|
|
525
673
|
let hasMore = true;
|
|
@@ -546,7 +694,7 @@ export class HNSWIndex {
|
|
|
546
694
|
// Create noun object with restored connections
|
|
547
695
|
const noun = {
|
|
548
696
|
id: nounData.id,
|
|
549
|
-
vector:
|
|
697
|
+
vector: shouldPreload ? nounData.vector : [], // Preload if dataset is small
|
|
550
698
|
connections: new Map(),
|
|
551
699
|
level: hnswData.level
|
|
552
700
|
};
|
|
@@ -579,12 +727,14 @@ export class HNSWIndex {
|
|
|
579
727
|
hasMore = result.hasMore;
|
|
580
728
|
cursor = result.nextCursor;
|
|
581
729
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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}`);
|
|
585
735
|
}
|
|
586
736
|
catch (error) {
|
|
587
|
-
|
|
737
|
+
prodLog.error('HNSW rebuild failed:', error);
|
|
588
738
|
throw new Error(`Failed to rebuild HNSW index: ${error}`);
|
|
589
739
|
}
|
|
590
740
|
}
|
|
@@ -632,6 +782,97 @@ export class HNSWIndex {
|
|
|
632
782
|
totalNodes
|
|
633
783
|
};
|
|
634
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
|
+
}
|
|
635
876
|
/**
|
|
636
877
|
* Search within a specific layer
|
|
637
878
|
* Returns a map of noun IDs to distances, sorted by distance
|
|
@@ -639,8 +880,10 @@ export class HNSWIndex {
|
|
|
639
880
|
async searchLayer(queryVector, entryPoint, ef, level, filter) {
|
|
640
881
|
// Set of visited nouns
|
|
641
882
|
const visited = new Set([entryPoint.id]);
|
|
642
|
-
//
|
|
643
|
-
|
|
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));
|
|
644
887
|
const entryPointPasses = filter ? await filter(entryPoint.id) : true;
|
|
645
888
|
// Priority queue of candidates (closest first)
|
|
646
889
|
const candidates = new Map();
|
|
@@ -663,10 +906,17 @@ export class HNSWIndex {
|
|
|
663
906
|
// Explore neighbors of the closest candidate
|
|
664
907
|
const noun = this.nouns.get(closestId);
|
|
665
908
|
if (!noun) {
|
|
666
|
-
|
|
909
|
+
prodLog.error(`Noun with ID ${closestId} not found in searchLayer`);
|
|
667
910
|
continue;
|
|
668
911
|
}
|
|
669
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
|
+
}
|
|
670
920
|
// If we have enough connections and parallelization is enabled, use parallel distance calculation
|
|
671
921
|
if (this.useParallelization && connections.size >= 10) {
|
|
672
922
|
// Collect unvisited neighbors
|
|
@@ -677,7 +927,8 @@ export class HNSWIndex {
|
|
|
677
927
|
const neighbor = this.nouns.get(neighborId);
|
|
678
928
|
if (!neighbor)
|
|
679
929
|
continue;
|
|
680
|
-
|
|
930
|
+
const neighborVector = await this.getVectorSafe(neighbor);
|
|
931
|
+
unvisitedNeighbors.push({ id: neighborId, vector: neighborVector });
|
|
681
932
|
}
|
|
682
933
|
}
|
|
683
934
|
if (unvisitedNeighbors.length > 0) {
|
|
@@ -717,7 +968,7 @@ export class HNSWIndex {
|
|
|
717
968
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
718
969
|
continue;
|
|
719
970
|
}
|
|
720
|
-
const distToNeighbor = this.
|
|
971
|
+
const distToNeighbor = await Promise.resolve(this.distanceSafe(queryVector, neighbor));
|
|
721
972
|
// Apply filter if provided
|
|
722
973
|
const passes = filter ? await filter(neighborId) : true;
|
|
723
974
|
// Always add to candidates for graph traversal
|
|
@@ -762,7 +1013,7 @@ export class HNSWIndex {
|
|
|
762
1013
|
/**
|
|
763
1014
|
* Ensure a noun doesn't have too many connections at a given level
|
|
764
1015
|
*/
|
|
765
|
-
pruneConnections(noun, level) {
|
|
1016
|
+
async pruneConnections(noun, level) {
|
|
766
1017
|
const connections = noun.connections.get(level);
|
|
767
1018
|
if (connections.size <= this.config.M) {
|
|
768
1019
|
return;
|
|
@@ -770,14 +1021,20 @@ export class HNSWIndex {
|
|
|
770
1021
|
// Calculate distances to all neighbors
|
|
771
1022
|
const distances = new Map();
|
|
772
1023
|
const validNeighborIds = new Set();
|
|
1024
|
+
// OPTIMIZATION: Preload all neighbor vectors
|
|
1025
|
+
if (connections.size > 0) {
|
|
1026
|
+
await this.preloadVectors(Array.from(connections));
|
|
1027
|
+
}
|
|
773
1028
|
for (const neighborId of connections) {
|
|
774
1029
|
const neighbor = this.nouns.get(neighborId);
|
|
775
1030
|
if (!neighbor) {
|
|
776
1031
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
777
1032
|
continue;
|
|
778
1033
|
}
|
|
779
|
-
// Only add valid neighbors to the distances map
|
|
780
|
-
|
|
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);
|
|
781
1038
|
validNeighborIds.add(neighborId);
|
|
782
1039
|
}
|
|
783
1040
|
// Only proceed if we have valid neighbors
|
|
@@ -94,7 +94,6 @@ export declare class HNSWIndexOptimized extends HNSWIndex {
|
|
|
94
94
|
private memoryUsage;
|
|
95
95
|
private vectorCount;
|
|
96
96
|
private memoryUpdateLock;
|
|
97
|
-
private unifiedCache;
|
|
98
97
|
constructor(config: Partial<HNSWOptimizedConfig>, distanceFunction: DistanceFunction, storage?: BaseStorage | null);
|
|
99
98
|
/**
|
|
100
99
|
* Thread-safe method to update memory usage
|
|
@@ -120,7 +119,7 @@ export declare class HNSWIndexOptimized extends HNSWIndex {
|
|
|
120
119
|
/**
|
|
121
120
|
* Remove an item from the index
|
|
122
121
|
*/
|
|
123
|
-
removeItem(id: string): boolean
|
|
122
|
+
removeItem(id: string): Promise<boolean>;
|
|
124
123
|
/**
|
|
125
124
|
* Clear the index
|
|
126
125
|
*/
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Uses product quantization for dimensionality reduction and disk-based storage when needed
|
|
5
5
|
*/
|
|
6
6
|
import { HNSWIndex } from './hnswIndex.js';
|
|
7
|
-
import { getGlobalCache } from '../utils/unifiedCache.js';
|
|
8
7
|
// Default configuration for the optimized HNSW index
|
|
9
8
|
const DEFAULT_OPTIMIZED_CONFIG = {
|
|
10
9
|
M: 16,
|
|
@@ -230,8 +229,7 @@ export class HNSWIndexOptimized extends HNSWIndex {
|
|
|
230
229
|
}
|
|
231
230
|
// Set disk-based index flag
|
|
232
231
|
this.useDiskBasedIndex = this.optimizedConfig.useDiskBasedIndex || false;
|
|
233
|
-
//
|
|
234
|
-
this.unifiedCache = getGlobalCache();
|
|
232
|
+
// Note: UnifiedCache is inherited from base HNSWIndex class
|
|
235
233
|
}
|
|
236
234
|
/**
|
|
237
235
|
* Thread-safe method to update memory usage
|
|
@@ -332,7 +330,7 @@ export class HNSWIndexOptimized extends HNSWIndex {
|
|
|
332
330
|
/**
|
|
333
331
|
* Remove an item from the index
|
|
334
332
|
*/
|
|
335
|
-
removeItem(id) {
|
|
333
|
+
async removeItem(id) {
|
|
336
334
|
// If product quantization is active, remove the quantized vector
|
|
337
335
|
if (this.useProductQuantization) {
|
|
338
336
|
this.quantizedVectors.delete(id);
|
|
@@ -349,7 +347,7 @@ export class HNSWIndexOptimized extends HNSWIndex {
|
|
|
349
347
|
console.error('Failed to update memory usage after removal:', error);
|
|
350
348
|
});
|
|
351
349
|
// Remove the item from the in-memory index
|
|
352
|
-
return super.removeItem(id);
|
|
350
|
+
return await super.removeItem(id);
|
|
353
351
|
}
|
|
354
352
|
/**
|
|
355
353
|
* Clear the index
|
|
@@ -274,7 +274,7 @@ export class PartitionedHNSWIndex {
|
|
|
274
274
|
async removeItem(id) {
|
|
275
275
|
// Find which partition contains this item
|
|
276
276
|
for (const [partitionId, partition] of this.partitions.entries()) {
|
|
277
|
-
if (partition.removeItem(id)) {
|
|
277
|
+
if (await partition.removeItem(id)) {
|
|
278
278
|
// Update metadata
|
|
279
279
|
const metadata = this.partitionMetadata.get(partitionId);
|
|
280
280
|
metadata.nodeCount = partition.size();
|
|
@@ -45,9 +45,13 @@ export type RebuildProgressCallback = (loaded: number, total: number) => void;
|
|
|
45
45
|
*/
|
|
46
46
|
export interface RebuildOptions {
|
|
47
47
|
/**
|
|
48
|
-
* Lazy mode
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* @deprecated Lazy mode is now auto-detected based on available memory.
|
|
49
|
+
* System automatically chooses between:
|
|
50
|
+
* - Preloading: Small datasets that fit comfortably in cache (< 80% threshold)
|
|
51
|
+
* - On-demand: Large datasets loaded adaptively via UnifiedCache
|
|
52
|
+
*
|
|
53
|
+
* This option is kept for backwards compatibility but is ignored.
|
|
54
|
+
* The system always uses adaptive caching (v3.36.0+).
|
|
51
55
|
*/
|
|
52
56
|
lazy?: boolean;
|
|
53
57
|
/**
|
|
@@ -86,11 +90,16 @@ export interface IIndex {
|
|
|
86
90
|
* - Load data from storage using pagination
|
|
87
91
|
* - Restore index structure efficiently (O(N) preferred over O(N log N))
|
|
88
92
|
* - Handle millions of entities via batching
|
|
89
|
-
* -
|
|
93
|
+
* - Auto-detect caching strategy based on dataset size vs available memory
|
|
90
94
|
* - Provide progress reporting for large datasets
|
|
91
95
|
* - Recover gracefully from partial failures
|
|
92
96
|
*
|
|
93
|
-
*
|
|
97
|
+
* Adaptive Caching (v3.36.0+):
|
|
98
|
+
* System automatically chooses optimal strategy:
|
|
99
|
+
* - Small datasets: Preload all data at init for zero-latency access
|
|
100
|
+
* - Large datasets: Load on-demand via UnifiedCache for memory efficiency
|
|
101
|
+
*
|
|
102
|
+
* @param options Rebuild options (batch size, progress callback, force)
|
|
94
103
|
* @returns Promise that resolves when rebuild is complete
|
|
95
104
|
* @throws Error if rebuild fails critically (should log warnings for partial failures)
|
|
96
105
|
*/
|