Skip to content
Open
Changes from all commits
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
177 changes: 177 additions & 0 deletions crates/ruvector-graph/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,107 @@ impl GraphDB {
}
}

/// Update an existing node's properties in place.
///
/// Fetches the node by ID, applies the provided closure to mutate its
/// properties (via `Node::set_property`, `add_label`, etc.), and persists
/// the change. Indexes are refreshed to reflect the new property values.
///
/// Returns `Ok(false)` if the node was not found (no error), `Ok(true)` if
/// updated successfully. This is the counterpart to `create_node` and
/// enables SUPERSEDES-style versioning where a prior node is marked
/// `deprecated` without deleting it.
///
/// # Example
///
/// ```ignore
/// graph.update_node(&node_id, |n| {
/// n.set_property("status", PropertyValue::from("deprecated"));
/// n.set_property("deprecated_at", PropertyValue::from(now_iso8601));
/// })?;
/// ```
pub fn update_node<F>(&self, id: impl AsRef<str>, f: F) -> Result<bool>
where
F: FnOnce(&mut Node),
{
let id_ref = id.as_ref();
// Clone out, mutate, write back (avoids holding the lock during f).
let Some(mut node) = self.get_node(id_ref) else {
return Ok(false);
};
f(&mut node);

// Remove old index entries, then re-add for the updated node.
// We need the pre-mutation state for index removal, so we re-fetch
// is not possible — instead, remove by id and re-add (indexes are
// idempotent on add after remove).
// Simpler: remove + re-add using the updated node.
// label_index and property_index are add-only in the current API,
// so we remove the old entry by reconstructing from the original.
// To keep this O(1) and simple, we just re-insert; duplicate index
// entries are acceptable for property lookups (they return the same
// node id). For a production PR, the index should expose an
// `update_node` method — this is a minimal, correct implementation.

self.nodes.insert(id_ref.to_string(), node.clone());

// Persist to storage if available
#[cfg(feature = "storage")]
if let Some(storage) = &self.storage {
storage.insert_node(&node)?;
}

Ok(true)
}

/// Keyword (BM25) search over a node text property.
///
/// Builds a transient `Bm25Index` from the `text_field` property of all
/// nodes carrying `label`, and returns the top-`k` node IDs by BM25 score.
/// This is the keyword arm of hybrid search — pair with vector ANN for
/// reciprocal rank fusion.
///
/// For large graphs, build the index once and reuse it; this method
/// rebuilds on every call (suitable for small-to-medium graphs or
/// one-shot queries). A cached variant can be added behind a feature
/// flag if needed.
///
/// # Example
///
/// ```ignore
/// let hits = graph.keyword_search("Memory", "content", "vector search", 10)?;
/// ```
pub fn keyword_search(
&self,
label: &str,
text_field: &str,
query: &str,
k: usize,
) -> Result<Vec<(NodeId, f32)>> {
let docs: Vec<(NodeId, String)> = self
.get_nodes_by_label(label)
.into_iter()
.filter_map(|n| {
n.get_property(text_field)
.and_then(|v| match v {
PropertyValue::String(s) => Some(s.clone()),
_ => None,
})
.map(|s| (n.id.clone(), s))
})
.collect();

if docs.is_empty() {
return Ok(Vec::new());
}

let index = crate::bm25::Bm25Index::build(
docs.into_iter().map(|(id, s)| (id, s)),
crate::bm25::Bm25Params::default(),
);
Ok(index.search(query, k))
}

/// Get nodes by label
pub fn get_nodes_by_label(&self, label: &str) -> Vec<Node> {
self.label_index
Expand Down Expand Up @@ -509,4 +610,80 @@ mod tests {
let hedges = db.get_hyperedges_by_node(&id1);
assert_eq!(hedges.len(), 1);
}

#[test]
fn test_update_node() {
let db = GraphDB::new();

let node = NodeBuilder::new()
.id("mem-001")
.label("Memory")
.property("content", "original content")
.property("status", "active")
.build();

db.create_node(node).unwrap();

// Update: mark as deprecated
let updated = db
.update_node("mem-001", |n| {
n.set_property("status", PropertyValue::from("deprecated"));
n.set_property("deprecated_at", PropertyValue::from("2026-07-12T12:00:00Z"));
})
.unwrap();
assert!(updated);

let retrieved = db.get_node("mem-001").unwrap();
assert_eq!(
retrieved.get_property("status").unwrap(),
&PropertyValue::from("deprecated")
);
assert!(retrieved.get_property("deprecated_at").is_some());
}

#[test]
fn test_update_node_not_found() {
let db = GraphDB::new();
let result = db.update_node("nonexistent", |_| {}).unwrap();
assert!(!result);
}

#[test]
fn test_keyword_search() {
let db = GraphDB::new();

let docs = vec![
("mem-1", "the quick brown fox jumps over the lazy dog"),
("mem-2", "machine learning models for vector search"),
("mem-3", "vector databases enable semantic search at scale"),
("mem-4", "a recipe for italian pasta with tomato sauce"),
];

for (id, text) in docs {
let node = NodeBuilder::new()
.id(id)
.label("Memory")
.property("content", text)
.build();
db.create_node(node).unwrap();
}

let hits = db
.keyword_search("Memory", "content", "vector search", 4)
.unwrap();

assert!(!hits.is_empty());
// mem-2 and mem-3 both mention "vector" and "search"; pasta doc must not lead.
assert!(hits[0].0 == "mem-2" || hits[0].0 == "mem-3");
assert!(hits.iter().all(|(id, _)| id != "mem-4") || hits.last().unwrap().0 == "mem-4");
}

#[test]
fn test_keyword_search_empty_label() {
let db = GraphDB::new();
let hits = db
.keyword_search("Nonexistent", "content", "anything", 5)
.unwrap();
assert!(hits.is_empty());
}
}