From 37ff1eb3c2fc9cd8d8ef8029c31dcb3a3ca1defb Mon Sep 17 00:00:00 2001 From: rUv Date: Wed, 3 Dec 2025 06:46:03 +0000 Subject: [PATCH] perf(postgres): Zero-copy HNSW insert path optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Eliminate vector clone in insert() by searching first, then inserting - Remove unused hybrid-search and filtered-search feature flags - Bump versions: ruvector-postgres 0.2.2, @ruvector/postgres-cli 0.1.2 Performance: Insert operations now require zero vector copies for the common case (non-empty index), reducing memory allocations in hot path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/ruvector-postgres/Cargo.toml | 5 +- crates/ruvector-postgres/src/index/hnsw.rs | 65 +++++++++++++--------- npm/packages/postgres-cli/package.json | 2 +- 3 files changed, 41 insertions(+), 31 deletions(-) diff --git a/crates/ruvector-postgres/Cargo.toml b/crates/ruvector-postgres/Cargo.toml index 75e03a1e..34d7e067 100644 --- a/crates/ruvector-postgres/Cargo.toml +++ b/crates/ruvector-postgres/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruvector-postgres" -version = "0.2.1" +version = "0.2.2" edition = "2021" license = "MIT" description = "High-performance PostgreSQL vector database extension - pgvector drop-in replacement with 53+ SQL functions, SIMD acceleration, hyperbolic embeddings, GNN layers, and self-learning capabilities" @@ -44,8 +44,7 @@ quantization-all = ["quantization-scalar", "quantization-product", "quantization quant-all = ["quantization-all"] # Alias for convenience # Optional features -hybrid-search = [] -filtered-search = [] +# Note: hybrid-search and filtered-search are planned for future releases neon-compat = [] # Neon-specific optimizations # Advanced AI features (opt-in) diff --git a/crates/ruvector-postgres/src/index/hnsw.rs b/crates/ruvector-postgres/src/index/hnsw.rs index 2b53cb6d..473af2a1 100644 --- a/crates/ruvector-postgres/src/index/hnsw.rs +++ b/crates/ruvector-postgres/src/index/hnsw.rs @@ -196,23 +196,8 @@ impl HnswIndex { return id; } - // For non-empty index, we need to search with the vector, then store it - // Clone once for search operations (required since we need both search and store) - let query_vec = vector.clone(); - - // Create and insert node with the original vector - let mut neighbors_vec = Vec::with_capacity(level + 1); - for _ in 0..=level { - neighbors_vec.push(RwLock::new(Vec::new())); - } - - let node = HnswNode { - vector, // Move original into node - neighbors: neighbors_vec, - max_layer: level, - }; - self.nodes.insert(id, node); - + // For non-empty index: search FIRST with borrowed vector, then insert + // This avoids cloning the vector entirely - zero-copy insert path let entry_point_id = current_entry.unwrap(); let current_max_layer = self.max_layer.load(AtomicOrdering::Relaxed); @@ -221,13 +206,16 @@ impl HnswIndex { // Descend through layers above the new node's max layer for layer in (level + 1..=current_max_layer).rev() { - curr_id = self.search_layer_single(&query_vec, curr_id, layer); + curr_id = self.search_layer_single(&vector, curr_id, layer); } - // Insert at each layer from the node's max layer down to 0 + // Collect all neighbor selections before inserting the node + // This allows us to search with borrowed vector, then move it + let mut layer_neighbors: Vec> = + Vec::with_capacity(level.min(current_max_layer) + 1); + for layer in (0..=level.min(current_max_layer)).rev() { - let neighbors = - self.search_layer(&query_vec, curr_id, self.config.ef_construction, layer); + let neighbors = self.search_layer(&vector, curr_id, self.config.ef_construction, layer); // Select best neighbors let max_connections = if layer == 0 { @@ -241,6 +229,34 @@ impl HnswIndex { .map(|n| n.id) .collect(); + // Update curr_id for next layer + if !selected.is_empty() { + curr_id = selected[0]; + } + + layer_neighbors.push(selected); + } + + // Reverse since we collected in reverse order + layer_neighbors.reverse(); + + // NOW create and insert the node (moving the vector - no clone needed) + let mut neighbors_vec = Vec::with_capacity(level + 1); + for _ in 0..=level { + neighbors_vec.push(RwLock::new(Vec::new())); + } + + let node = HnswNode { + vector, // Move original into node - zero copy! + neighbors: neighbors_vec, + max_layer: level, + }; + self.nodes.insert(id, node); + + // Apply the pre-computed neighbor connections + for (layer_idx, selected) in layer_neighbors.iter().enumerate() { + let layer = layer_idx; + // Set neighbors for new node if let Some(node) = self.nodes.get(&id) { if layer < node.neighbors.len() { @@ -249,14 +265,9 @@ impl HnswIndex { } // Add bidirectional connections - for &neighbor_id in &selected { + for &neighbor_id in selected { self.connect(neighbor_id, id, layer); } - - // Update curr_id for next layer - if !selected.is_empty() { - curr_id = selected[0]; - } } // Update entry point if necessary diff --git a/npm/packages/postgres-cli/package.json b/npm/packages/postgres-cli/package.json index f81da043..c51fcd20 100644 --- a/npm/packages/postgres-cli/package.json +++ b/npm/packages/postgres-cli/package.json @@ -1,6 +1,6 @@ { "name": "@ruvector/postgres-cli", - "version": "0.1.1", + "version": "0.1.2", "description": "Advanced AI vector database CLI for PostgreSQL - pgvector drop-in replacement with 53+ SQL functions, 39 attention mechanisms, GNN layers, hyperbolic embeddings, and self-learning capabilities", "main": "dist/index.js", "types": "dist/index.d.ts",