/** * Partitioned HNSW Index for Large-Scale Vector Search * Implements sharding strategies to handle millions of vectors efficiently */ import { DistanceFunction, HNSWConfig, HNSWNoun, Vector, VectorDocument } from '../coreTypes.js' import { HNSWIndex } from './hnswIndex.js' import { euclideanDistance } from '../utils/index.js' export interface PartitionConfig { maxNodesPerPartition: number partitionStrategy: 'semantic' | 'hash' // Simplified to focus on useful strategies semanticClusters?: number // Auto-configured based on dataset size autoTuneSemanticClusters?: boolean // Automatically adjust cluster count } export interface PartitionMetadata { id: string nodeCount: number bounds?: { centroid: Vector radius: number } strategy: string created: Date } /** * Partitioned HNSW Index that splits large datasets across multiple smaller indices * This enables efficient search across millions of vectors by reducing memory usage * and parallelizing search operations */ export class PartitionedHNSWIndex { private partitions: Map = new Map() private partitionMetadata: Map = new Map() private config: PartitionConfig private hnswConfig: HNSWConfig private distanceFunction: DistanceFunction private dimension: number | null = null private nextPartitionId = 0 constructor( partitionConfig: Partial = {}, hnswConfig: Partial = {}, distanceFunction: DistanceFunction = euclideanDistance ) { this.config = { maxNodesPerPartition: 50000, // Optimal size for memory efficiency partitionStrategy: 'semantic', // Default to semantic for better performance semanticClusters: 8, // Auto-tuned based on dataset autoTuneSemanticClusters: true, ...partitionConfig } // Optimized HNSW parameters for large scale this.hnswConfig = { M: 32, // Higher connectivity for better recall efConstruction: 400, // Better build quality efSearch: 100, // Balance speed vs accuracy ml: 24, // Deeper hierarchy ...hnswConfig } this.distanceFunction = distanceFunction } /** * Add a vector to the partitioned index */ public async addItem(item: VectorDocument): Promise { if (this.dimension === null) { this.dimension = item.vector.length } // Determine which partition this item belongs to const partitionId = await this.selectPartition(item) // Get or create the partition let partition = this.partitions.get(partitionId) if (!partition) { partition = new HNSWIndex( this.hnswConfig, this.distanceFunction, { useParallelization: true } ) this.partitions.set(partitionId, partition) // Initialize partition metadata this.partitionMetadata.set(partitionId, { id: partitionId, nodeCount: 0, strategy: this.config.partitionStrategy, created: new Date() }) } // Add item to the selected partition await partition.addItem(item) // Update partition metadata const metadata = this.partitionMetadata.get(partitionId)! metadata.nodeCount = partition.size() // Update bounds for semantic strategy if (this.config.partitionStrategy === 'semantic') { this.updatePartitionBounds(partitionId, item.vector) } // Check if partition is getting too large and needs splitting if (metadata.nodeCount > this.config.maxNodesPerPartition * 1.2) { await this.splitPartition(partitionId) } return item.id } /** * Search across all partitions for nearest neighbors */ public async search( queryVector: Vector, k: number = 10, searchScope?: { partitionIds?: string[] maxPartitions?: number } ): Promise> { if (this.partitions.size === 0) { return [] } // Determine which partitions to search const partitionsToSearch = await this.selectSearchPartitions(queryVector, searchScope) // Search partitions in parallel const searchPromises = partitionsToSearch.map(async (partitionId) => { const partition = this.partitions.get(partitionId) if (!partition) return [] // Search with higher k to get better global results const partitionK = Math.min(k * 2, partition.size()) return partition.search(queryVector, partitionK) }) const partitionResults = await Promise.all(searchPromises) // Merge and sort results from all partitions const allResults: Array<[string, number]> = [] for (const results of partitionResults) { allResults.push(...results) } // Sort by distance and return top k allResults.sort((a, b) => a[1] - b[1]) return allResults.slice(0, k) } /** * Select the appropriate partition for a new item * Automatically chooses semantic partitioning when beneficial, falls back to hash */ private async selectPartition(item: VectorDocument): Promise { // Auto-tune semantic clusters based on current dataset size if (this.config.autoTuneSemanticClusters && this.config.partitionStrategy === 'semantic') { this.autoTuneSemanticClusters() } switch (this.config.partitionStrategy) { case 'semantic': return await this.semanticPartition(item.vector) case 'hash': default: return this.hashPartition(item.id) } } /** * Hash-based partitioning for even distribution */ private hashPartition(id: string): string { const hash = this.simpleHash(id) const existingPartitions = Array.from(this.partitions.keys()) // Find partition with space, or create new one for (const partitionId of existingPartitions) { const metadata = this.partitionMetadata.get(partitionId) if (metadata && metadata.nodeCount < this.config.maxNodesPerPartition) { return partitionId } } // Create new partition return `partition_${this.nextPartitionId++}` } /** * Semantic clustering partitioning */ private async semanticPartition(vector: Vector): Promise { // Find closest partition centroid let closestPartition = '' let minDistance = Infinity for (const [partitionId, metadata] of this.partitionMetadata.entries()) { if (metadata.bounds?.centroid) { const distance = this.distanceFunction(vector, metadata.bounds.centroid) if (distance < minDistance) { minDistance = distance closestPartition = partitionId } } } // If no suitable partition found or it's full, create new one if (!closestPartition || this.partitionMetadata.get(closestPartition)!.nodeCount >= this.config.maxNodesPerPartition) { closestPartition = `semantic_${this.nextPartitionId++}` } return closestPartition } /** * Auto-tune semantic clusters based on dataset size and performance */ private autoTuneSemanticClusters(): void { const totalNodes = this.size() const currentPartitions = this.partitions.size // Optimal clusters based on dataset size let optimalClusters = Math.max(4, Math.min(32, Math.floor(totalNodes / 10000))) // Adjust based on current partition performance if (currentPartitions > 0) { const avgNodesPerPartition = totalNodes / currentPartitions if (avgNodesPerPartition > this.config.maxNodesPerPartition * 0.8) { // Partitions are getting full, increase clusters optimalClusters = Math.min(32, this.config.semanticClusters! + 2) } else if (avgNodesPerPartition < this.config.maxNodesPerPartition * 0.3 && currentPartitions > 4) { // Partitions are underutilized, decrease clusters optimalClusters = Math.max(4, this.config.semanticClusters! - 1) } } if (optimalClusters !== this.config.semanticClusters) { console.log(`Auto-tuning semantic clusters: ${this.config.semanticClusters} → ${optimalClusters}`) this.config.semanticClusters = optimalClusters } } /** * Select which partitions to search based on query */ private async selectSearchPartitions( queryVector: Vector, searchScope?: { partitionIds?: string[] maxPartitions?: number } ): Promise { if (searchScope?.partitionIds) { return searchScope.partitionIds.filter(id => this.partitions.has(id)) } const maxPartitions = searchScope?.maxPartitions || Math.min(5, this.partitions.size) if (this.config.partitionStrategy === 'semantic') { // Search partitions with closest centroids const distances: Array<[string, number]> = [] for (const [partitionId, metadata] of this.partitionMetadata.entries()) { if (metadata.bounds?.centroid) { const distance = this.distanceFunction(queryVector, metadata.bounds.centroid) distances.push([partitionId, distance]) } } distances.sort((a, b) => a[1] - b[1]) return distances.slice(0, maxPartitions).map(([id]) => id) } // For other strategies, search all partitions or random subset const allPartitionIds = Array.from(this.partitions.keys()) if (allPartitionIds.length <= maxPartitions) { return allPartitionIds } // Return random subset const shuffled = [...allPartitionIds].sort(() => Math.random() - 0.5) return shuffled.slice(0, maxPartitions) } /** * Update partition bounds for semantic clustering */ private updatePartitionBounds(partitionId: string, vector: Vector): void { const metadata = this.partitionMetadata.get(partitionId)! if (!metadata.bounds) { metadata.bounds = { centroid: [...vector], radius: 0 } return } // Update centroid using incremental mean const { centroid } = metadata.bounds const nodeCount = metadata.nodeCount for (let i = 0; i < centroid.length; i++) { centroid[i] = (centroid[i] * (nodeCount - 1) + vector[i]) / nodeCount } // Update radius const distance = this.distanceFunction(vector, centroid) metadata.bounds.radius = Math.max(metadata.bounds.radius, distance) } /** * Split an overgrown partition into smaller partitions */ private async splitPartition(partitionId: string): Promise { const partition = this.partitions.get(partitionId) if (!partition) return console.log(`Splitting partition ${partitionId} with ${partition.size()} nodes`) // For now, we'll implement a simple strategy // In a full implementation, you'd want to analyze the data distribution // and create more intelligent splits // This is a placeholder - actual implementation would require // accessing the internal nodes of the HNSW index } /** * Simple hash function for consistent partitioning */ private simpleHash(str: string): number { let hash = 0 for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i) hash = ((hash << 5) - hash) + char hash = hash & hash // Convert to 32-bit integer } return Math.abs(hash) } /** * Get partition statistics */ public getPartitionStats(): { totalPartitions: number totalNodes: number averageNodesPerPartition: number partitionDetails: PartitionMetadata[] } { const partitionDetails = Array.from(this.partitionMetadata.values()) const totalNodes = partitionDetails.reduce((sum, p) => sum + p.nodeCount, 0) return { totalPartitions: partitionDetails.length, totalNodes, averageNodesPerPartition: totalNodes / partitionDetails.length || 0, partitionDetails } } /** * Remove an item from the index */ public async removeItem(id: string): Promise { // Find which partition contains this item for (const [partitionId, partition] of this.partitions.entries()) { if (partition.removeItem(id)) { // Update metadata const metadata = this.partitionMetadata.get(partitionId)! metadata.nodeCount = partition.size() return true } } return false } /** * Clear all partitions */ public clear(): void { for (const partition of this.partitions.values()) { partition.clear() } this.partitions.clear() this.partitionMetadata.clear() this.nextPartitionId = 0 } /** * Get total size across all partitions */ public size(): number { return Array.from(this.partitions.values()).reduce((sum, partition) => sum + partition.size(), 0) } }