@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.
@@ -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
- let currDist = this.distanceFunction(vector, entryPoint.vector);
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.distanceFunction(vector, neighbor.vector);
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
- let currDist = this.distanceFunction(queryVector, currObj.vector);
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
- vectors.push({ id: neighborId, vector: neighbor.vector });
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.distanceFunction(queryVector, neighbor.vector);
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
- // Check if entry point passes filter
506
- const entryPointDistance = this.distanceFunction(queryVector, entryPoint.vector);
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
- console.error(`Noun with ID ${closestId} not found in searchLayer`);
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
- unvisitedNeighbors.push({ id: neighborId, vector: neighbor.vector });
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.distanceFunction(queryVector, neighbor.vector);
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
- distances.set(neighborId, this.distanceFunction(noun.vector, neighbor.vector));
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 { StorageAdapter } from '../coreTypes.js';
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
- private unifiedCache;
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