Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions helix-db/src/helix_engine/vector_core/hnsw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,23 @@ pub trait HNSW {
/// * `txn` - The transaction to use
/// * `id` - The id of the vector
fn delete(&self, txn: &mut RwTxn, id: u128, arena: &bumpalo::Bump) -> Result<(), VectorError>;

/// Reconnect an existing vector to the HNSW graph without changing its ID.
/// Used for index rebuilding after deletions cause graph fragmentation.
///
/// # Arguments
///
/// * `txn` - The write transaction to use
/// * `vector` - The vector to reconnect (with existing ID and level)
/// * `arena` - The bump allocator for temporary allocations
fn reconnect_vector<'db, 'arena, 'txn, F>(
&'db self,
txn: &'txn mut RwTxn<'db>,
vector: &HVector<'arena>,
arena: &'arena bumpalo::Bump,
) -> Result<(), VectorError>
where
F: Fn(&HVector<'arena>, &RoTxn<'db>) -> bool,
'db: 'arena,
'arena: 'txn;
}
78 changes: 78 additions & 0 deletions helix-db/src/helix_engine/vector_core/vector_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,84 @@ impl HNSW for VectorCore {
Ok(query)
}

/// Reconnect an existing vector to the HNSW graph without changing its ID.
/// Used for index rebuilding after deletions cause graph fragmentation.
fn reconnect_vector<'db, 'arena, 'txn, F>(
&'db self,
txn: &'txn mut RwTxn<'db>,
vector: &HVector<'arena>,
arena: &'arena bumpalo::Bump,
) -> Result<(), VectorError>
where
F: Fn(&HVector<'arena>, &RoTxn<'db>) -> bool,
'db: 'arena,
'arena: 'txn,
{
let new_level = vector.level; // Keep existing level
let label = vector.label;

let entry_point = match self.get_entry_point(txn, label, arena) {
Ok(ep) => ep,
Err(_) => {
// First vector becomes entry point
self.set_entry_point(txn, vector)?;
return Ok(());
}
};

let l = entry_point.level;
let mut curr_ep = entry_point;

// Navigate to insertion level
for level in (new_level + 1..=l).rev() {
let mut nearest =
self.search_level::<F>(txn, label, vector, &mut curr_ep, 1, level, None, arena)?;
if let Some(closest) = nearest.pop() {
curr_ep = closest;
}
Comment thread
himanalot marked this conversation as resolved.
Outdated
}

// Connect at each level
for level in (0..=l.min(new_level)).rev() {
let nearest = self.search_level::<F>(
txn,
label,
vector,
&mut curr_ep,
self.config.ef_construct,
level,
None,
arena,
)?;

if let Some(closest) = nearest.peek() {
curr_ep = *closest;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent failure allows continuing with stale curr_ep, diverging from insert's error handling at vector_core.rs:648-650. This could produce incorrect graph connections.

Suggested change
if let Some(closest) = nearest.peek() {
curr_ep = *closest;
}
curr_ep = *nearest.peek().ok_or(VectorError::VectorCoreError(
"empty search result".to_string(),
))?;


let neighbors =
self.select_neighbors::<F>(txn, label, vector, nearest, level, true, None, arena)?;
self.set_neighbours(txn, vector.id, &neighbors, level)?;

// Update neighbors' connections
for e in neighbors {
let e_conns = BinaryHeap::from(
arena,
self.get_neighbors::<F>(txn, label, e.id, level, None, arena)?,
);
let e_new_conn =
self.select_neighbors::<F>(txn, label, vector, e_conns, level, true, None, arena)?;
self.set_neighbours(txn, e.id, &e_new_conn, level)?;
}
}

// Update entry point if this vector has higher level
if new_level > l {
self.set_entry_point(txn, vector)?;
}

Ok(())
}

fn delete(&self, txn: &mut RwTxn, id: u128, arena: &bumpalo::Bump) -> Result<(), VectorError> {
match self.get_vector_properties(txn, id, arena)? {
Some(mut properties) => {
Expand Down
1 change: 1 addition & 0 deletions helix-db/src/helix_gateway/builtin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ pub mod all_nodes_and_edges;
pub mod node_by_id;
pub mod node_connections;
pub mod nodes_by_label;
pub mod rebuild_hnsw_index;
104 changes: 104 additions & 0 deletions helix-db/src/helix_gateway/builtin/rebuild_hnsw_index.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use std::sync::Arc;

use sonic_rs::json;

use crate::helix_engine::types::GraphError;
use crate::helix_engine::vector_core::hnsw::HNSW;
use crate::helix_engine::vector_core::vector_core::ENTRY_POINT_KEY;
use crate::helix_gateway::router::router::{Handler, HandlerInput, HandlerSubmission};
use crate::protocol;

const BATCH_SIZE: usize = 50;

/// Rebuild the HNSW index by reconnecting all vectors.
/// This fixes graph fragmentation caused by deletions and re-insertions.
/// Processes in batches to avoid OOM.
pub fn rebuild_hnsw_index_inner(input: HandlerInput) -> Result<protocol::Response, GraphError> {
eprintln!("[RebuildHNSWIndex] Starting rebuild...");

let db = Arc::clone(&input.graph.storage);

// Step 1: Get vector IDs only (to avoid loading all vector data at once)
eprintln!("[RebuildHNSWIndex] Counting vectors...");
let vector_ids: Vec<u128> = {
let txn = db.graph_env.read_txn().map_err(GraphError::from)?;
let arena = bumpalo::Bump::new();
let vectors = db
.vectors
.get_all_vectors(&txn, None, &arena)
.map_err(|e| GraphError::New(format!("Failed to get vectors: {}", e)))?;
vectors.iter().map(|v| v.id).collect()
};
Comment on lines +45 to +50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading all vectors into memory defeats the purpose of the batch processing mentioned in the PR description. get_all_vectors loads full vector data for all vectors, which could cause OOM on large datasets.

Consider iterating over vector IDs directly from the database using a prefix iterator (similar to the existing implementation in get_all_vectors at vector_core.rs:446-489) to truly avoid loading all data at once.

let vector_count = vector_ids.len();
eprintln!("[RebuildHNSWIndex] Found {} vectors", vector_count);

if vector_count == 0 {
return Ok(protocol::Response {
body: sonic_rs::to_vec(&json!({
"status": "success",
"message": "No vectors to rebuild",
"vectors_rebuilt": 0
}))
.map_err(|e| GraphError::New(e.to_string()))?,
fmt: Default::default(),
});
}

// Step 2: Clear all HNSW edges
{
let mut txn = db.graph_env.write_txn().map_err(GraphError::from)?;
eprintln!("[RebuildHNSWIndex] Clearing HNSW edges...");
db.vectors
.edges_db
.clear(&mut txn)
.map_err(|e| GraphError::New(format!("Failed to clear edges: {}", e)))?;

eprintln!("[RebuildHNSWIndex] Clearing entry point...");
let _ = db.vectors.vectors_db.delete(&mut txn, ENTRY_POINT_KEY);
txn.commit().map_err(GraphError::from)?;
}

// Step 3: Reconnect vectors one at a time (fresh arena each to control memory)
eprintln!("[RebuildHNSWIndex] Reconnecting {} vectors...", vector_count);

for (i, &vector_id) in vector_ids.iter().enumerate() {
if i % 500 == 0 {
eprintln!("[RebuildHNSWIndex] Progress: {}/{} ({:.1}%)",
i, vector_count, (i as f64 / vector_count as f64) * 100.0);
}

// Fresh arena for each vector to prevent memory growth
let arena = bumpalo::Bump::new();
let mut txn = db.graph_env.write_txn().map_err(GraphError::from)?;

match db.vectors.get_full_vector(&txn, vector_id, &arena) {
Ok(v) => {
db.vectors
.reconnect_vector::<fn(&_, &_) -> bool>(&mut txn, &v, &arena)
.map_err(|e| GraphError::New(format!("Failed to reconnect vector {}: {}", vector_id, e)))?;
}
Err(e) => {
eprintln!("[RebuildHNSWIndex] Warning: skipping vector {}: {}", vector_id, e);
}
}

txn.commit().map_err(GraphError::from)?;
// Arena dropped here, memory freed
}

eprintln!("[RebuildHNSWIndex] Rebuild complete! {} vectors reconnected", vector_count);
Ok(protocol::Response {
body: sonic_rs::to_vec(&json!({
"status": "success",
"vectors_rebuilt": vector_count
}))
.map_err(|e| GraphError::New(e.to_string()))?,
fmt: Default::default(),
})
}

inventory::submit! {
HandlerSubmission(
Handler::new("RebuildHNSWIndex", rebuild_hnsw_index_inner, true)
)
}