LaserData Cloud
Laser SDK

Graph

Follow the relationships.

Turn entities in your messages into nodes and edges. Traverse them now, search by meaning, or ask what was true at an earlier time.

Built for

Knowledge graphs, recommendations, entity resolution

How it works

A node's id is content-addressed. GraphNode::entity(label, value) derives the same id every time for the same pair, so two independent parts of your code that both mention customer:42 upsert the same node without coordinating an id scheme first.

  • link(from, relation, to) is sugar over this: given two "label:value" strings, it derives both endpoint ids, upserts both nodes if new, and writes the edge between them. One call instead of "ensure node A, ensure node B, then write the edge."
  • upsert(nodes, edges) is the explicit form, for richer node and edge payloads than link's shorthand covers.

Traversal starts simple with neighbors(node_id, direction, edge_type, depth):

  • Direction - out from the node, in toward it, or both.
  • Edge type - optionally restrict the walk to one relation.
  • Depth - bound how many hops out the walk goes.

The result is a small subgraph within that radius, not a flat list.

Bitemporal reads. Apply as_of(timestamp) before a fetch or traversal to read the graph at that time. Edges retain valid-time history after removal. Historical ownership remains queryable.

Bitemporal edges retain relationship history:

  • link(from, relation, to) asserts a fact. Idempotent: re-linking the same triple converges on the same nodes and edge.
  • relink(from, relation, to) updates a single-valued relationship. It closes live edges with the same source and relation but a different target. It then writes the new edge and returns the number closed.
  • unlink(from, relation, to) closes one specific fact: the edge gets valid_to now, superseded without being destroyed. The nodes stay, and an as_of read from before the close still sees the edge.

All three exist in all three languages.

Real traversals

neighbors is the one-hop convenience. The full traversal builder composes a walk from three parts - where to start, which hops to take, and what to return:

  • Start - start_ids([...]) from explicit nodes, start_match(filter) from every node matching a label filter, or start_nearest(embedding, k) from the k nodes semantically closest to an embedding. That last one is how "search the graph by meaning" actually works.
  • Hops - chain out(edge_type), incoming(edge_type), and both(edge_type) calls, one per step of the walk.
  • Return shape - reachable nodes (the default), return_edges(), return_triplets() (source, type, destination rows), or return_paths() (whole node-and-edge sequences).
  • Bounds and lenses - limit(n) caps the result, as_of(micros) follows only edges valid at that instant, conversation(id) narrows the whole walk to elements a single conversation asserted (unset reads the whole 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,
)

All three return the same path result. TypeScript uses the camel-case builder, Rust uses the snake-case builder, and Python expresses the ordered hops in one query call.

Two ways the graph gets built

  • Directly, with link/upsert as on this page. Upserts are idempotent on the content-addressed ids, so re-applying the same entities is a no-op.
  • Automatically, by registering a graph projection with an entity schema: node and edge extraction rules over the messages you already publish, pointer-based and deterministic. The graph then grows as a read model of the log with no extraction code in your services.

Graph requires laser-plane in 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')}")

A node is addressed by its content, so the id link derived is the id you rebuild locally with GraphNode::entity / graphNodeEntity / ls.node_id. Rust's entity_of and TypeScript's graphNodeValue are small helpers that render a node as kind:value, the same spelling link accepted.

Full runnable example: Rust · Python · TypeScript

Key operations

VerbWhat 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_nearestPick 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

Graph runs on Laser Stack and LaserData Cloud. The example exits cleanly when the graph capability is unavailable.

On this page