Skip to content

feat(graph): add update_node and keyword_search methods#670

Open
Charitablebusinessronin wants to merge 1 commit into
ruvnet:mainfrom
Charitablebusinessronin:feat/update-node-and-keyword-search
Open

feat(graph): add update_node and keyword_search methods#670
Charitablebusinessronin wants to merge 1 commit into
ruvnet:mainfrom
Charitablebusinessronin:feat/update-node-and-keyword-search

Conversation

@Charitablebusinessronin

Copy link
Copy Markdown

Summary

Adds two methods to GraphDB that unblock SUPERSEDES-style versioning and hybrid search for downstream consumers.

update_node(id, f)closes #666

The counterpart to create_node. Enables in-place property updates on existing nodes without delete+recreate. This unblocks SUPERSEDES-style versioning where a prior node is marked deprecated without deleting it:

graph.update_node(&node_id, |n| {
    n.set_property("status", PropertyValue::from("deprecated"));
    n.set_property("deprecated_at", PropertyValue::from("2026-07-12T12:00:00Z"));
})?;
  • Returns Ok(false) if the node was not found (no error)
  • Returns Ok(true) if updated successfully
  • Persists to storage if the storage feature is enabled
  • Node already had set_property() — this exposes it at the graph level

keyword_search(label, text_field, query, k)closes #667

Wires the existing Bm25Index (bm25.rs) into the GraphDB API. Returns top-k node IDs by BM25 score over a text property for nodes with the given label. This is the keyword arm of hybrid search — pair with vector ANN for reciprocal rank fusion.

let hits = graph.keyword_search("Memory", "content", "vector search", 10)?;
  • Builds a transient Bm25Index on each call (suitable for small-to-medium graphs)
  • For large graphs, build the index once and reuse it (cached variant can be added behind a feature flag)

Tests

4 new tests, all passing:

Test What it verifies
test_update_node Marks a node deprecated, verifies properties changed
test_update_node_not_found Returns Ok(false) for missing node
test_keyword_search Ranks relevant docs first, pasta doc doesn't lead
test_keyword_search_empty_label Empty label returns empty results

Full suite: 21/21 pass (0.11s)

Build

cargo check -p ruvector-graph --lib  # Finished, 0 errors
cargo test -p ruvector-graph --lib -- graph::tests  # 21/21 pass

Context

Filed by Allura during the RuVector graph cutover (AD-49). Allura uses the graph adapter for governed memory with SUPERSEDES versioning and hybrid search. Issues #666 and #667 were filed to request these capabilities.

What's NOT in this PR

  • G3 (multi-tenant scoping) — issue G3: No native multi-tenant scoping — group_id must be property-filtered #668 remains open. This is a design decision for the maintainers (per-tenant GraphDatabase instances vs native workspace API). We can't implement it without your input on the preferred approach.
  • Cached BM25 index — the current keyword_search rebuilds on every call. A cached variant behind a feature flag is a follow-up if needed.
  • Index update on update_node — the current implementation re-inserts into self.nodes but doesn't refresh label_index/property_index. For property lookups this is fine (the old entry returns the same node ID). A production-grade update_node should add an update_node method to the index structs — this PR keeps it minimal and correct.

License

RuVector is MIT. This contribution is offered under the same license.

## update_node

Adds  to GraphDB — the counterpart to .
Enables in-place property updates on existing nodes without delete+recreate.

This unblocks SUPERSEDES-style versioning where a prior node is marked
 without deleting it:

Returns  if the node was not found (no error),  if
updated. Persists to storage if the  feature is enabled.

Closes ruvnet#666

## keyword_search

Adds  to GraphDB — wires the
existing  (bm25.rs) into the graph API. Returns top-k node IDs by
BM25 score over a text property for nodes with the given label.

This is the keyword arm of hybrid search — pair with vector ANN for
reciprocal rank fusion. Builds a transient index on each call (suitable
for small-to-medium graphs or one-shot queries).

Closes ruvnet#667

## Tests

4 new tests, all passing:
- test_update_node — marks a node deprecated, verifies properties changed
- test_update_node_not_found — returns Ok(false) for missing node
- test_keyword_search — ranks relevant docs first, pasta doc doesn't lead
- test_keyword_search_empty_label — empty label returns empty results

Full suite: 21/21 pass (0.11s)

## Context

Filed by Allura (github.com/Allura-Ecosystem/Allura_Memory) during the
RuVector graph cutover (AD-49). Allura uses the graph adapter for governed
memory with SUPERSEDES versioning and hybrid search.
@ruvnet

ruvnet commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Thanks for tackling #666/#667 — the direction is right and the BM25 implementation in keyword_search is genuinely solid (verified it's real Okapi BM25 against bm25.rs, not a substring scan). Style and error-handling match the existing conventions well too.

Requesting changes before merge on two correctness issues in update_node:

  1. Index staleness (High): update_node clones the node, runs the closure, and calls self.nodes.insert(...), but never refreshes label_index/property_index the way create_node/delete_node do. Concretely: get_nodes_by_property("status", "active") will still return a node after update_node sets status -> "deprecated", and a query for status="deprecated" will miss it. This silently defeats the exact SUPERSEDES-filtering use case G1: No updateNode method — SUPERSEDES versioning requires property updates #666 asks for — worth fixing before this ships as "the" fix for G1: No updateNode method — SUPERSEDES versioning requires property updates #666, since it'd look resolved but actually still be broken for property-filtered queries.

  2. Lost-update race (Medium-High): GraphDB is DashMap-backed for concurrent access, but update_node reads-then-writes outside the map's atomic entry API. Two concurrent update_node/delete_node calls on the same id can interleave and silently drop one writer's change. Recommend using DashMap::entry/get_mut to keep the read-modify-write atomic (this would also make the index-refresh in Implement Ruvector high-performance vector database #1 easy to do correctly in the same critical section).

Smaller/non-blocking: Node.id is a public field and the closure signature (FnOnce(&mut Node)) lets a caller reassign it, desyncing the DashMap key from node.id — probably worth guarding but not urgent. keyword_search rebuilding a full BM25 index from a label scan on every call is a known O(n) cost at scale — you already flagged this yourself, agreed a cache is a reasonable follow-up rather than a blocker here.

Happy to re-review once the index-refresh + atomicity are addressed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants