Graph
Store entities and relationships, traverse paths, and read relationship history
Graph represents entities as nodes and relationships as edges. You can traverse relationships, search by meaning, or read historical facts. The graph can be built directly or derived from log messages.
Built for
Use Graph for knowledge graphs, recommendations, and entity resolution.
How it works
GraphNode::entity(label, value) derives an ID from the label and value. The same pair always identifies the same node. Two services that mention customer:42 therefore update one node without coordinating ID generation.
link(from, relation, to) accepts two "label:value" strings. It derives their IDs, creates missing nodes, and writes the relationship. Use upsert(nodes, edges) for richer node and edge payloads.
neighbors(node_id, direction, edge_type, depth) retrieves a bounded subgraph. Direction selects outgoing, incoming, or both. An optional edge type restricts the relationship. Depth limits the number of hops, or relationship steps. The result is a subgraph rather than a flat list.
Use as_of(timestamp) before a read or traversal to read relationships at that time. Edges retain valid-time history after removal. Historical ownership remains queryable.
Facts change: relink and unlink
These operations retain relationship history:
link(from, relation, to)records a fact. Repeating the same triple produces the same nodes and edge.relink(from, relation, to)replaces a single-valued relationship. It closes live edges with the same source and relation but another target. It writes the new edge and returns the number closed.unlink(from, relation, to)closes one edge by settingvalid_to. The nodes remain. An earlieras_ofread still sees the relationship.
All three languages support these operations.
Real traversals
Use neighbors for a direct neighborhood query. The traversal builder provides more control:
- Start with
start_ids([...]),start_match(filter), orstart_nearest(embedding, k). These select explicit nodes, label matches, or the k closest nodes by embedding similarity. - Add hops with
out(edge_type),incoming(edge_type), andboth(edge_type). - Return nodes by default, or choose
return_edges(),return_triplets(), orreturn_paths(). Triplets containsource, type, destination. Paths contain node and edge sequences. - Use
limit(n)to bound results.as_of(micros)selects valid edges at a time.conversation(id)limits the walk to facts asserted by one conversation. Without it, the query spans the graph.
const paths = await laser
.graph("kg")
.startNearest(embedding, 5)
.out("purchased")
.incoming("recommends")
.returnPaths()
.limit(20)
.fetch()let paths = laser
.graph("kg")
.start_nearest(embedding, 5)
.out("purchased")
.incoming("recommends")
.return_paths()
.limit(20)
.fetch()
.await?;paths = await laser.graph("kg").query(
nearest=(embedding, 5),
hops=[
("purchased", "out"),
("recommends", "in"),
],
returns="paths",
limit=20,
)The clients return the same path result. TypeScript uses camelCase builders, Rust uses snake_case builders, and Python supplies ordered hops in one query call.
Two ways the graph gets built
Direct link and upsert calls use content-derived IDs. Reapplying the same entities does not create duplicates.
Alternatively, register a graph projection with an entity schema. Its pointer-based extraction rules derive nodes and edges from published messages. The graph then updates from the log without extraction code in application services.
Graph requires laser-plane through Laser Stack or LaserData Cloud.
Quick example
const graph = laser.graph("kg")
for (const product of ["product:7", "product:9"]) {
await graph.link("customer:42", "purchased", product)
}
const customer = graphNodeEntity("customer", "42")
const purchases = await graph.neighbors(customer.id, "out", "purchased", 1)
for (const node of purchases.nodes) {
console.log(`${node.labels[0]}:${graphNodeValue(node)}`)
}for product in ["product:7", "product:9"] {
laser
.graph("kg")
.link("customer:42", "purchased", product)
.await?;
}
let customer = GraphNode::entity("customer", "42").id;
let purchases = laser
.graph("kg")
.neighbors(customer, EdgeDir::Out, Some("purchased".to_owned()), 1)
.await?;
for node in &purchases.nodes {
println!("{}", entity_of(node));
}graph = laser.graph("kg")
for product in ("product:7", "product:9"):
await graph.link("customer:42", "purchased", product)
customer_id = ls.node_id("customer", "42")
purchases = await graph.neighbors(
customer_id, direction="out", edge_type="purchased", depth=1
)
for node in purchases["nodes"]:
print(f"{node['labels'][0]}:{node['attrs'].get('value')}")Recreate a link endpoint's ID with GraphNode::entity, graphNodeEntity, or ls.node_id. Rust's entity_of and TypeScript's graphNodeValue render it as kind:value, the input format accepted by link.
Complete examples: Rust, Python, and TypeScript.
Key operations
| Verb | What it does |
|---|---|
GraphNode::entity(label, value) | Derive a content-addressed node id |
graph(name).link(from, relation, to) | Upsert both endpoint nodes and the edge, in one call |
relink(from, relation, to) | Supersede a single-valued relationship's old targets, then link the new one |
unlink(from, relation, to) | Close a fact bitemporally, keeping it visible to as_of reads |
upsert(nodes, edges) | The explicit form, for your own node and edge values |
neighbors(node_id, direction, edge_type, depth) | One-hop traversal, bounded by hop count |
start_ids / start_match / start_nearest | Pick a traversal's starting set, by id, label, or meaning |
out(t) / incoming(t) / both(t) | Add one hop per call to the walk |
return_edges() / return_triplets() / return_paths() | Choose the result shape |
as_of(timestamp) | Read the graph as it looked at a past moment |
conversation(id) | Narrow the walk to what one conversation asserted |
Running it
Use Laser Stack or LaserData Cloud. The example exits normally when graph support is unavailable.