Skip to main content

Graph Overlay for Agent Memory

SochDB provides a lightweight graph layer on top of its KV storage for modeling agent memory relationships. This is NOT a full graph database - it's optimized for typical agent memory patterns.

Per-SDK surface differs

The graph layer is exposed differently in each SDK:

  • Rust ships a real GraphOverlay type (sochdb::graph) generic over the connection, plus a TemporalGraphOverlay (sochdb::temporal_graph).
  • Node.js exposes graph methods directly on EmbeddedDatabase (re-exported as Database): addNode / addEdge / traverse.
  • Go exposes graph methods on the IPC client and the gRPC client (AddNode / AddEdge / Traverse); the embedded engine is behind the sochdb_embedded build tag.
  • Python exposes graph methods directly on Database (add_node / add_edge / traverse / get_neighbors / find_path) plus temporal edges (add_temporal_edge). There is no importable GraphOverlay class in the Python SDK — the standalone "graph overlay" demos live in the sochdb-python-examples repo, not in the installed package.

SDK versions: Core engine 2.0.3, Python SDK 0.5.9, Node.js SDK 0.5.3, Go SDK 0.4.5.

Overview

The Graph Overlay enables:

  • Entity relationships: user to conversation to message
  • Causal chains: action1 to action2 to action3
  • Reference graphs: document referenced by citation referenced by quote

Storage Model

Data TypeKey PatternValue
Nodes_graph/{ns}/nodes/{id}{type, properties}
Edges_graph/{ns}/edges/{from}/{type}/{to}{properties}
Reverse Index_graph/{ns}/index/{type}/{to}/{from}from_id

This enables O(1) node/edge operations and O(degree) traversals.

Quick Start

In the Python SDK (pip install sochdb, v0.5.9) the graph operations are methods on Database. The first argument is always the graph namespace, and node/edge IDs are plain strings.

from sochdb import Database

db = Database.open("./agent_memory")
ns = "agent_001"

# Create nodes: add_node(namespace, node_id, node_type, properties?)
db.add_node(ns, "user_1", "User", {"name": "Alice"})
db.add_node(ns, "conv_1", "Conversation", {"title": "Planning Session"})
db.add_node(ns, "msg_1", "Message", {"content": "Let's start planning"})

# Create edges: add_edge(namespace, from_id, edge_type, to_id, properties?)
db.add_edge(ns, "user_1", "STARTED", "conv_1")
db.add_edge(ns, "conv_1", "CONTAINS", "msg_1")
db.add_edge(ns, "user_1", "SENT", "msg_1")

# Traverse: returns (nodes, edges) as lists of dicts
nodes, edges = db.traverse(ns, "user_1", max_depth=2, order="bfs")
for node in nodes:
print(f"Node: {node['id']} ({node['node_type']})")

# Shortest path: find_path(from, to, max_depth=10, namespace="default")
result = db.find_path("user_1", "msg_1", max_depth=10, namespace=ns)
if result:
print(result["path"]) # e.g. ["user_1", "conv_1", "msg_1"]
note

Property values passed to add_node / add_edge are serialized to JSON; use string keys and JSON-serializable values.

Node Operations

These operations are exposed on the Rust GraphOverlay. The other SDKs expose a subset (add_node / add_edge / traverse and, in Python, delete_node / get_neighbors / find_path) as methods on the connection.

OperationDescriptionComplexity
add_node(id, type, props)Create or update nodeO(1)
get_node(id)Retrieve node by IDO(1)
update_node(id, props, type?)Update properties or typeO(1)
delete_node(id, cascade)Delete node (optionally with edges)O(degree)
node_exists(id)Check if node existsO(1)

Edge Operations

OperationDescriptionComplexity
add_edge(from, type, to, props)Create directed edgeO(1)
get_edge(from, type, to)Get specific edgeO(1)
get_edges(from, type?)Get outgoing edgesO(degree)
get_incoming_edges(to, type?)Get incoming edgesO(degree)
delete_edge(from, type, to)Delete edgeO(1)

Traversal Operations

Where these live

bfs, dfs, shortest_path, get_neighbors, get_nodes_by_type, and get_subgraph are first-class methods on the Rust GraphOverlay. In Python, use traverse(...) (which returns (nodes, edges)) plus the get_neighbors(...) and find_path(...) helpers on Database. In Node.js and Go, only traverse / Traverse is provided — walk the returned nodes/edges yourself.

BFS / DFS (Rust)

// Find all reachable nodes within 3 hops
let nodes = graph.bfs(&user, 3, None, None)?;

// Filter by edge types
let nodes = graph.bfs(&user, 3, Some(&["SENT", "CONTAINS"]), None)?;

// Filter by node types
let nodes = graph.bfs(&user, 3, None, Some(&["Message"]))?;

// Depth-first traversal
let nodes = graph.dfs(&user, 5, None, None)?;

Traverse (Python)

# Breadth-first traversal returns (nodes, edges)
nodes, edges = db.traverse("agent_001", "user_1", max_depth=3, order="bfs")

# Depth-first traversal
nodes, edges = db.traverse("agent_001", "user_1", max_depth=5, order="dfs")

Shortest Path

# find_path(from, to, max_depth=10, namespace="default")
result = db.find_path("user_1", "msg_10", max_depth=10, namespace="agent_001")
# result is {"path": [...], "edges": [...]} or None if unreachable

Query Operations

Get Neighbors

# get_neighbors(node_id, direction="outgoing", edge_type=None, namespace="default")
out = db.get_neighbors("user_1", direction="outgoing", namespace="agent_001")
incoming = db.get_neighbors("msg_1", direction="incoming", namespace="agent_001")
both = db.get_neighbors("conv_1", direction="both", namespace="agent_001")
filtered = db.get_neighbors("user_1", edge_type="STARTED", namespace="agent_001")
# Each returns a dict like {"neighbors": [...]}

Get Nodes by Type (Rust)

// Get all User nodes (scans, use sparingly)
let users = graph.get_nodes_by_type("User", 100)?;

Get Subgraph (Rust)

// Extract a subgraph around a node
let subgraph = graph.get_subgraph(&user, 2)?;
println!("Nodes: {}", subgraph.nodes.len());
println!("Edges: {}", subgraph.edges.len());

Temporal Edges

For time-travel queries ("what did the system know at time T?"), edges can carry a validity interval.

  • Rust: TemporalGraphOverlay (sochdb::temporal_graph) with add_edge_at, invalidate_edge_at, get_edges_at, get_edges_in_window, neighbors_at, and subgraph_at.
  • Python: db.add_temporal_edge(...), db.query_temporal_graph(...), and db.end_temporal_edge(...) on Database.
import time

now = int(time.time() * 1000)
one_hour = 60 * 60 * 1000

# valid_from / valid_until are Unix epoch milliseconds (0 = no expiry)
db.add_temporal_edge(
namespace="smart_home",
from_id="door_front",
edge_type="STATE",
to_id="open",
valid_from=now - one_hour,
valid_until=now,
properties={"sensor": "motion_1"},
)

# modes: "CURRENT" | "POINT_IN_TIME" | "RANGE"
edges = db.query_temporal_graph(
namespace="smart_home",
node_id="door_front",
mode="POINT_IN_TIME",
timestamp=now - 30 * 60 * 1000,
)

Agent Memory Patterns

The examples below use the Python Database graph API. The first argument is the namespace ("agent_001" here).

Conversation History

ns = "agent_001"
# Model a conversation thread
db.add_node(ns, "conv_1", "Conversation", {"title": "Support Chat"})
db.add_node(ns, "msg_1", "Message", {"role": "user", "content": "Help!"})
db.add_node(ns, "msg_2", "Message", {"role": "assistant", "content": "I can help"})

db.add_edge(ns, "conv_1", "CONTAINS", "msg_1")
db.add_edge(ns, "conv_1", "CONTAINS", "msg_2")
db.add_edge(ns, "msg_1", "FOLLOWED_BY", "msg_2")

# Walk the conversation
nodes, edges = db.traverse(ns, "conv_1", max_depth=2)

Tool Call Chains

ns = "agent_001"
# Model tool execution sequences
db.add_node(ns, "action_1", "ToolCall", {"tool": "search", "query": "docs"})
db.add_node(ns, "action_2", "ToolCall", {"tool": "read_file", "path": "README.md"})
db.add_node(ns, "action_3", "ToolCall", {"tool": "summarize", "input": "..."})

db.add_edge(ns, "action_1", "CAUSED", "action_2")
db.add_edge(ns, "action_2", "CAUSED", "action_3")

# Walk the causal chain
nodes, edges = db.traverse(ns, "action_1", max_depth=10, order="dfs")

Knowledge References

ns = "agent_001"
# Model document references
db.add_node(ns, "doc_1", "Document", {"title": "API Guide"})
db.add_node(ns, "chunk_1", "Chunk", {"text": "Authentication uses..."})
db.add_node(ns, "chunk_2", "Chunk", {"text": "Rate limits are..."})

db.add_edge(ns, "doc_1", "CONTAINS", "chunk_1")
db.add_edge(ns, "doc_1", "CONTAINS", "chunk_2")
db.add_edge(ns, "chunk_2", "REFERENCES", "chunk_1")

# Find chunks reachable from the document
nodes, edges = db.traverse(ns, "doc_1", max_depth=1)

Performance Characteristics

OperationTime ComplexityNotes
Add/Get NodeO(1)Direct KV lookup
Add/Get EdgeO(1)Direct KV lookup
Outgoing EdgesO(degree)Prefix scan
Incoming EdgesO(degree)Reverse index lookup
BFS/DFSO(V + E)For reachable subgraph
Shortest PathO(V + E)BFS-based

Best Practices

  1. Use meaningful edge types: SENT, CONTAINS, REFERENCES are clearer than generic RELATES_TO
  2. Namespace by agent: Use separate namespaces for each agent's memory
  3. Limit traversal depth: Set reasonable max_depth to avoid runaway queries
  4. Use cascade delete carefully: In Rust, delete_node(id, true) removes all connected edges
  5. Filter early: In Rust traversals, use edge_types and node_types to reduce work

See Also