/** * Type-Aware HNSW Index - Phase 2 Billion-Scale Optimization * * Maintains separate HNSW graphs per entity type for massive memory savings: * - Memory @ 1B scale: 384GB → 50GB (-87%) * - Query speed: 10x faster for single-type queries * - Storage: Already type-first from Phase 1a * * Architecture: * - One HNSWIndex per NounType (31 total) * - Lazy initialization (indexes created on first use) * - Type routing for optimal performance * - Falls back to multi-type search when type unknown */ import { HNSWIndex } from './hnswIndex.js' import { DistanceFunction, HNSWConfig, Vector, VectorDocument } from '../coreTypes.js' import { NounType, NOUN_TYPE_COUNT, TypeUtils } from '../types/graphTypes.js' import { euclideanDistance } from '../utils/index.js' import type { BaseStorage } from '../storage/baseStorage.js' import { prodLog } from '../utils/logger.js' // Default HNSW parameters (same as HNSWIndex) const DEFAULT_CONFIG: HNSWConfig = { M: 16, efConstruction: 200, efSearch: 50, ml: 16 } /** * Type-aware HNSW statistics */ export interface TypeAwareHNSWStats { totalNodes: number totalMemoryMB: number typeCount: number typeStats: Map memoryReductionPercent: number estimatedMonolithicMemoryMB: number } /** * TypeAwareHNSWIndex - Separate HNSW graphs per entity type * * Phase 2 of billion-scale optimization roadmap. * Reduces HNSW memory by 87% @ billion scale. */ export class TypeAwareHNSWIndex { // One HNSW index per noun type (lazy initialization) private indexes: Map = new Map() // Configuration private config: HNSWConfig private distanceFunction: DistanceFunction private storage: BaseStorage | null private useParallelization: boolean /** * Create a new TypeAwareHNSWIndex * * @param config HNSW configuration (M, efConstruction, efSearch, ml) * @param distanceFunction Distance function (default: euclidean) * @param options Additional options (storage, parallelization) */ constructor( config: Partial = {}, distanceFunction: DistanceFunction = euclideanDistance, options: { useParallelization?: boolean; storage?: BaseStorage } = {} ) { this.config = { ...DEFAULT_CONFIG, ...config } this.distanceFunction = distanceFunction this.storage = options.storage || null this.useParallelization = options.useParallelization !== undefined ? options.useParallelization : true prodLog.info('TypeAwareHNSWIndex initialized (Phase 2: Type-Aware HNSW)') } /** * Get or create HNSW index for a specific type (lazy initialization) * * Indexes are created on-demand to save memory. * Only types with entities get an index. * * @param type The noun type * @returns HNSWIndex for this type */ private getIndexForType(type: NounType): HNSWIndex { // Validate type is a valid NounType const typeIndex = TypeUtils.getNounIndex(type) if (typeIndex === undefined || typeIndex === null || typeIndex < 0) { throw new Error( `Invalid NounType: ${type}. Must be one of the 31 defined types.` ) } if (!this.indexes.has(type)) { prodLog.info(`Creating HNSW index for type: ${type}`) const index = new HNSWIndex(this.config, this.distanceFunction, { useParallelization: this.useParallelization, storage: this.storage || undefined }) this.indexes.set(type, index) } const index = this.indexes.get(type) if (!index) { throw new Error( `Unexpected: Index for type ${type} not found after creation` ) } return index } /** * Add a vector to the type-aware index * * Routes to the correct type's HNSW graph. * * @param item Vector document to add * @param type The noun type (required for routing) * @returns The item ID */ public async addItem(item: VectorDocument, type: NounType): Promise { if (!item || !item.vector) { throw new Error( 'Invalid VectorDocument: item or vector is null/undefined' ) } if (!type) { throw new Error('Type is required for type-aware indexing') } const index = this.getIndexForType(type) return await index.addItem(item) } /** * Search for nearest neighbors (type-aware) * * **Single-type search** (fast path): * ```typescript * await index.search(queryVector, 10, 'person') * // Searches only person graph (100M nodes instead of 1B) * ``` * * **Multi-type search**: * ```typescript * await index.search(queryVector, 10, ['person', 'organization']) * // Searches person + organization, merges results * ``` * * **All-types search** (fallback): * ```typescript * await index.search(queryVector, 10) * // Searches all 31 graphs (slower but comprehensive) * ``` * * @param queryVector Query vector * @param k Number of results * @param type Type or types to search (undefined = all types) * @param filter Optional filter function * @returns Array of [id, distance] tuples sorted by distance */ public async search( queryVector: Vector, k: number = 10, type?: NounType | NounType[], filter?: (id: string) => Promise ): Promise> { // Single-type search (fast path) if (type && typeof type === 'string') { const index = this.getIndexForType(type) return await index.search(queryVector, k, filter) } // Multi-type search (handle empty array edge case) if (type && Array.isArray(type) && type.length > 0) { return await this.searchMultipleTypes(queryVector, k, type, filter) } // All-types search (slowest path + empty array fallback) return await this.searchAllTypes(queryVector, k, filter) } /** * Search across multiple specific types * * @param queryVector Query vector * @param k Number of results * @param types Array of types to search * @param filter Optional filter function * @returns Merged and sorted results */ private async searchMultipleTypes( queryVector: Vector, k: number, types: NounType[], filter?: (id: string) => Promise ): Promise> { const allResults: Array<[string, number]> = [] // Search each specified type for (const type of types) { if (this.indexes.has(type)) { const index = this.indexes.get(type)! const results = await index.search(queryVector, k, filter) allResults.push(...results) } } // Merge and sort by distance allResults.sort((a, b) => a[1] - b[1]) // Return top k return allResults.slice(0, k) } /** * Search across all types (fallback for type-agnostic queries) * * This is the slowest path, but provides comprehensive results. * Used when type cannot be inferred from query. * * @param queryVector Query vector * @param k Number of results * @param filter Optional filter function * @returns Merged and sorted results from all types */ private async searchAllTypes( queryVector: Vector, k: number, filter?: (id: string) => Promise ): Promise> { const allResults: Array<[string, number]> = [] // Search each type's graph for (const [type, index] of this.indexes.entries()) { const results = await index.search(queryVector, k, filter) allResults.push(...results) } // Merge and sort by distance allResults.sort((a, b) => a[1] - b[1]) // Return top k return allResults.slice(0, k) } /** * Remove an item from the index * * @param id Item ID to remove * @param type The noun type (required for routing) * @returns True if item was removed, false if not found */ public async removeItem(id: string, type: NounType): Promise { const index = this.indexes.get(type) if (!index) { return false // Type has no index (no items ever added) } return await index.removeItem(id) } /** * Get total number of items across all types * * @returns Total item count */ public size(): number { let total = 0 for (const index of this.indexes.values()) { total += index.size() } return total } /** * Get number of items for a specific type * * @param type The noun type * @returns Item count for this type */ public sizeForType(type: NounType): number { const index = this.indexes.get(type) return index ? index.size() : 0 } /** * Clear all indexes */ public clear(): void { for (const index of this.indexes.values()) { index.clear() } this.indexes.clear() } /** * Clear index for a specific type * * @param type The noun type to clear */ public clearType(type: NounType): void { const index = this.indexes.get(type) if (index) { index.clear() this.indexes.delete(type) } } /** * Get configuration * * @returns HNSW configuration */ public getConfig(): HNSWConfig { return { ...this.config } } /** * Get distance function * * @returns Distance function */ public getDistanceFunction(): DistanceFunction { return this.distanceFunction } /** * Set parallelization (applies to all indexes) * * @param useParallelization Whether to use parallelization */ public setUseParallelization(useParallelization: boolean): void { this.useParallelization = useParallelization for (const index of this.indexes.values()) { index.setUseParallelization(useParallelization) } } /** * Get parallelization setting * * @returns Whether parallelization is enabled */ public getUseParallelization(): boolean { return this.useParallelization } /** * Rebuild HNSW indexes from storage (type-aware) * * CRITICAL: This implementation uses type-filtered pagination to avoid * loading ALL entities for each type (which would be 31 billion reads @ 1B scale). * * Can rebuild all types or specific types. * Much faster than rebuilding a monolithic index. * * @param options Rebuild options */ public async rebuild( options: { types?: NounType[] // Rebuild specific types (undefined = all types) batchSize?: number // Entities per batch onProgress?: (type: NounType, loaded: number, total: number) => void } = {} ): Promise { if (!this.storage) { prodLog.warn('TypeAwareHNSW rebuild skipped: no storage adapter') return } // Determine which types to rebuild const typesToRebuild = options.types || this.getAllNounTypes() prodLog.info( `Rebuilding ${typesToRebuild.length} type-aware HNSW indexes...` ) const errors: Array<{ type: NounType; error: Error }> = [] // Rebuild each type's index with type-filtered pagination for (const type of typesToRebuild) { try { prodLog.info(`Rebuilding HNSW index for type: ${type}`) const index = this.getIndexForType(type) index.clear() // Clear before rebuild // Load ONLY entities of this type from storage using pagination let cursor: string | undefined = undefined let hasMore = true let loaded = 0 while (hasMore) { // CRITICAL: Use type filtering to load only this type's entities const result: { items: Array<{ id: string; vector: number[] }> hasMore: boolean nextCursor?: string totalCount?: number } = await (this.storage as any).getNounsWithPagination({ limit: options.batchSize || 1000, cursor, filter: { nounType: type } // ← TYPE FILTER! }) // Add each entity to this type's index for (const noun of result.items) { try { await index.addItem({ id: noun.id, vector: noun.vector }) loaded++ if (options.onProgress) { options.onProgress(type, loaded, result.totalCount || loaded) } } catch (error) { prodLog.error( `Failed to add entity ${noun.id} to ${type} index:`, error ) // Continue with other entities } } hasMore = result.hasMore cursor = result.nextCursor } prodLog.info( `✅ Rebuilt ${type} index: ${index.size().toLocaleString()} entities` ) } catch (error) { prodLog.error(`Failed to rebuild ${type} index:`, error) errors.push({ type, error: error as Error }) // Continue with other types instead of failing completely } } // Report errors at end if (errors.length > 0) { const failedTypes = errors.map((e) => e.type).join(', ') prodLog.warn( `⚠️ Failed to rebuild ${errors.length} type indexes: ${failedTypes}` ) // Throw if ALL rebuilds failed if (errors.length === typesToRebuild.length) { throw new Error('All type-aware HNSW rebuilds failed') } } prodLog.info( `✅ TypeAwareHNSW rebuild complete: ${this.size().toLocaleString()} total entities across ${this.indexes.size} types` ) } /** * Get comprehensive statistics * * Shows memory reduction compared to monolithic approach. * * @returns Type-aware HNSW statistics */ public getStats(): TypeAwareHNSWStats { const typeStats = new Map< NounType, { nodeCount: number memoryMB: number maxLevel: number entryPointId: string | null } >() let totalNodes = 0 let totalMemoryMB = 0 // Collect stats from each type's index for (const [type, index] of this.indexes.entries()) { const cacheStats = index.getCacheStats() const nodeCount = index.size() const memoryMB = cacheStats.hnswCache.estimatedMemoryMB typeStats.set(type, { nodeCount, memoryMB, maxLevel: index.getMaxLevel(), entryPointId: index.getEntryPointId() }) totalNodes += nodeCount totalMemoryMB += memoryMB } // Estimate monolithic memory (for comparison) // Monolithic would use ~384 bytes per entity @ 1B scale const estimatedMonolithicMemoryMB = (totalNodes * 384) / (1024 * 1024) // Calculate memory reduction const memoryReductionPercent = estimatedMonolithicMemoryMB > 0 ? ((estimatedMonolithicMemoryMB - totalMemoryMB) / estimatedMonolithicMemoryMB) * 100 : 0 return { totalNodes, totalMemoryMB: parseFloat(totalMemoryMB.toFixed(2)), typeCount: this.indexes.size, typeStats, memoryReductionPercent: parseFloat(memoryReductionPercent.toFixed(2)), estimatedMonolithicMemoryMB: parseFloat( estimatedMonolithicMemoryMB.toFixed(2) ) } } /** * Get statistics for a specific type * * @param type The noun type * @returns Statistics for this type's index (null if no index) */ public getStatsForType( type: NounType ): { nodeCount: number memoryMB: number maxLevel: number entryPointId: string | null cacheStats: any } | null { const index = this.indexes.get(type) if (!index) { return null } const cacheStats = index.getCacheStats() return { nodeCount: index.size(), memoryMB: cacheStats.hnswCache.estimatedMemoryMB, maxLevel: index.getMaxLevel(), entryPointId: index.getEntryPointId(), cacheStats } } /** * Get all noun types (for iteration) * * @returns Array of all noun types */ private getAllNounTypes(): NounType[] { const types: NounType[] = [] for (let i = 0; i < NOUN_TYPE_COUNT; i++) { types.push(TypeUtils.getNounFromIndex(i)) } return types } /** * Get list of types that have indexes (have entities) * * @returns Array of types with indexes */ public getActiveTypes(): NounType[] { return Array.from(this.indexes.keys()) } }