1 Graphs Tutorial
Anton Nesterov edited this page 2026-10-21 21:37:41 +02:00

Knowledge Graph Tutorial: Linking Records Across Collections

Collections are great for storing records, but "who works with whom", "what does this document depend on", "how are these two people connected" - those are relationship questions, and answering them with filters means rebuilding joins in your application. VSKI graphs exist for exactly that.

This tutorial builds a small company knowledge base: people, projects and documents live in collections, and a graph indexes how they relate. At the end you can answer relationship questions in one API call - and visualize the result in the dashboard.

Note

This page covers the graph API itself. For the reference documentation see Graphs.

What You'll Build

A kb graph over three collections, with three relationship types:

  • works_on - person -> project (weight: hours per week)
  • authored - person -> document
  • depends_on - document -> document (weight: coupling, lower = tighter)

and the queries that come with it:

  • who works on a project (and what else they work on)
  • the full dependency impact of changing one document
  • the closest connection between two people, weighted by involvement
  • a neighborhood subgraph for the dashboard

Architecture

┌─────────────────┐      ┌──────────────────────────────┐
│   Script/Cli    │─────▶│         VSKI Server          │
│   (Deno/TS)     │ HTTP │                              │
└─────────────────┘      │  ┌────────────────────────┐  │
                         │  │ collections            │  │
                         │  │  users, projects, docs │  │
                         │  ├────────────────────────┤  │
                         │  │ graph kb               │  │
                         │  │  nodes + labelled      │  │
                         │  │  edges (SQLite)        │  │
                         │  │  + in-memory index     │  │
                         │  └────────────────────────┘  │
                         └──────────────────────────────┘

Records live in collections. The graph references them by properties.ref = { collection, recordId } and adds the relationship layer on top. Neither side owns the other: you can re-index the graph from the collections at any time.

Prerequisites

  • Docker
  • Deno
  • The VSKI CLI:
    deno install --global -A -f -n vski https://vski.sh/x/vski-js/raw/branch/main/dist/main.js
    

Quick Start

1. Start VSKI

# docker-compose.yml
services:
  vski:
    image: vski.sh/x/vski:latest-standalone
    ports:
      - "8347:8347"
    environment:
      - SERVER_PORT=8347
      - DATA_DIR=/app/data
      - JWT_SECRET=dev-secret-change-in-production
    volumes:
      - vski_data:/app/data

volumes:
  vski_data:
docker compose up -d

Create your admin at http://localhost:8347/installer, then log in with the CLI:

vski login --url http://localhost:8347 --email admin@example.com --password yourpassword
vski db create kb

Step 1: Create the Collections

The data itself is ordinary collections. Only the fields the graph rules will look at later need to be there from the start.

// src/setup.ts
import { VskiClient } from "@vski/sdk";

const client = new VskiClient("http://localhost:8347");
await client.auth.login("admin@example.com", "yourpassword");
const db = client.db("kb");

await db.settings.collections.create({
  name: "users",
  type: "base",
  fields: [
    { name: "email", type: "email", required: true },
    { name: "name", type: "text", required: true },
  ],
});

await db.settings.collections.create({
  name: "projects",
  type: "base",
  fields: [{ name: "title", type: "text", required: true }],
});

await db.settings.collections.create({
  name: "documents",
  type: "base",
  fields: [
    { name: "title", type: "text", required: true },
    { name: "status", type: "select", options: { values: ["draft", "review", "final"] } },
  ],
});

And some records:

const alice = await db.collection("users").create({
  email: "alice@example.com", name: "Alice",
});
const bob = await db.collection("users").create({
  email: "bob@example.com", name: "Bob",
});
const carol = await db.collection("users").create({
  email: "carol@example.com", name: "Carol",
});

const atlas = await db.collection("projects").create({ title: "Atlas" });

const api = await db.collection("documents").create({
  title: "API design", status: "review",
});
const dbSchema = await db.collection("documents").create({
  title: "Database schema", status: "final",
});
const onboarding = await db.collection("documents").create({
  title: "Onboarding guide", status: "draft",
});

Step 2: Create the Graph

await db.settings.graphs.create({
  name: "kb",
  options: {
    directed: true,
    maxNodes: 10000, // this graph stays small; keep the caps tight
    maxEdges: 50000,
  },
});
const kb = db.graph("kb");

Exceeding a cap returns a clean 400 (graph edge cap exceeded) instead of growing forever - set caps to what the graph is allowed to be, not what it is today.

Step 3: Mirror the Records as Nodes

One node per record, with ref pointing back at the source of truth:

const nodes = [
  { id: "u:alice", label: "person", properties: { ref: { collection: "users", recordId: alice.id } } },
  { id: "u:bob", label: "person", properties: { ref: { collection: "users", recordId: bob.id } } },
  { id: "u:carol", label: "person", properties: { ref: { collection: "users", recordId: carol.id } } },
  { id: "p:atlas", label: "project", properties: { ref: { collection: "projects", recordId: atlas.id } } },
  { id: "d:api", label: "document", properties: { ref: { collection: "documents", recordId: api.id } } },
  { id: "d:schema", label: "document", properties: { ref: { collection: "documents", recordId: dbSchema.id } } },
  { id: "d:onboarding", label: "document", properties: { ref: { collection: "documents", recordId: onboarding.id } } },
];

await kb.createNodes(nodes); // one bulk request

The u: / p: / d: prefixes make ids globally unique inside the graph (collection record ids alone could collide) and make substring search useful - q: "d:" finds every document node.

Step 4: Add the Relationships

await kb.createEdges([
  // person -> project, weight = hours per week
  { source: "u:alice", target: "p:atlas", label: "works_on", weight: 20 },
  { source: "u:bob", target: "p:atlas", label: "works_on", weight: 30 },
  { source: "u:carol", target: "p:atlas", label: "works_on", weight: 10 },

  // person -> document
  { source: "u:alice", target: "d:api", label: "authored" },
  { source: "u:bob", target: "d:schema", label: "authored" },
  { source: "u:carol", target: "d:onboarding", label: "authored" },

  // document -> document, weight = coupling (lower = tighter)
  { source: "d:api", target: "d:schema", label: "depends_on", weight: 1 },
  { source: "d:onboarding", target: "d:api", label: "depends_on", weight: 2 },
]);

Edges require both endpoints to exist - a missing node fails the whole request with node not found.

Step 5: Ask Relationship Questions

Who works on Atlas?

One hop, into the project node:

const team = await kb.neighbors("p:atlas", { direction: "in" });
console.log(team.nodes.map((n) => n.id));
// ["u:alice", "u:bob", "u:carol"]

What breaks if the database schema changes?

Walk depends_on edges pointing at the schema document - everything that (transitively) depends on it is affected, whatever the distance:

const impact = await kb.traverse("d:schema", {
  depth: 10, // hard server maximum
  direction: "in", // things that depend on it
  labels: ["depends_on"],
});
console.log(impact.nodes.map((n) => n.id));

Note the direction: depends_on points from dependent to dependency, so "what is affected by X" is an inbound walk.

How are Alice and Carol connected?

The path finder answers this in one call. With cost: "hops" you get the fewest-step route; with cost: "weight" Dijkstra minimizes the summed weight:

const byHops = await kb.path("u:alice", "u:carol", { cost: "hops" });
console.log(byHops.nodes!.map((n) => n.id));
// ["u:alice", "p:atlas", "u:carol"] - 2 hops through the shared project
console.log(byHops.cost); // 30 (Alice's 20 + Carol's 10 weekly hours)

const byWeight = await kb.path("u:alice", "u:carol", { cost: "weight" });
console.log(byWeight.nodes!.map((n) => n.id));
// ["u:alice", "d:api", "d:onboarding", "u:carol"] - cheaper through documents
console.log(byWeight.cost); // 4 (authored = 1, depends_on = 2, authored = 1)

Two path semantics worth knowing: the finder follows edges in both directions (unlike neighbors/traverse, it has no direction parameter), and unweighted edges count as weight 1 - which is why the document route wins once weights are summed.

Restricting the walk to certain relationship types keeps the answer meaningful:

const viaWork = await kb.path("u:alice", "u:carol", {
  cost: "weight",
  labels: ["works_on"], // collaboration edges only
});
// ["u:alice", "p:atlas", "u:carol"] - the documents are excluded

Pull a neighborhood for visualization

The subgraph endpoint exists for rendering - seed nodes plus everything within a few hops, capped at 5 hops / 1000 nodes:

const hood = await kb.subgraph(["u:alice"], { hops: 2 });
console.log(`${hood.nodes.length} nodes, ${hood.edges.length} edges`);
// "4 nodes, 3 edges": alice, p:atlas, d:api and d:schema

Like neighbors and traverse, the walk follows outbound edges by default - pass direction: "both" to pull inbound relationships too (useful when the seed is a "sink", e.g. a project everyone points at).

And check the shape of the whole graph without touching a node:

const stats = await kb.stats();
console.log(stats.edgeLabels); // { works_on: 3, authored: 3, depends_on: 2 }
console.log(stats.avgDegree.toFixed(2), stats.maxDegree);

Step 6: Keep the Graph in Sync

Records change; the graph should follow. Because nodes carry ref, sync is a small loop:

// src/sync.ts - run whenever records change (e.g. from a trigger)
async function syncUser(record: { id: string }) {
  const nodeId = `u:${record.id}`;
  const existing = await kb.getNodes({ q: nodeId });
  if (existing.totalItems === 0) {
    await kb.createNodes({
      id: nodeId,
      label: "person",
      properties: { ref: { collection: "users", recordId: record.id } },
    });
  }
}

For deletions, remember that deleting a node cascades its edges - removing u:bob when Bob leaves cleanly disconnects him from every project and document. The reverse also holds: deleting a collection record does not touch the graph, so sync hooks (or the re-index script below) should handle that side.

Re-indexing from scratch is the same three steps as the initial build: create a fresh graph, mirror records, replay relationships.

Step 7: Open It Up (Rules)

Graphs use the collection rule model. This config keeps structure edits admin-only but lets any authenticated user explore:

const graphs = await db.settings.graphs.getList();
const cfg = graphs.items.find((g) => g.name === "kb")!;
await db.settings.graphs.update(cfg.id as string, {
  listRule: "@request.auth.id != ''",
  viewRule: "@request.auth.id != ''", // gates neighbors/traverse/path/subgraph
  createRule: "",
  updateRule: "",
  deleteRule: "",
});

Per-item rules see node fields, so a read-only layer is one line away:

listRule: "label = 'document'"
// every non-admin now sees just the three document nodes; persons and
// projects stay admin-only, and the response carries filtered: true

Step 8: Explore in the Dashboard

Open http://localhost:8347 - the sidebar has a Graphs section. The kb explorer gives you:

  • a searchable center-node picker (substring over ids and labels, backed by the same q parameter used above)
  • hops (1-5), limit and direction controls
  • click-to-select with Center here / Expand actions
  • a from/to shortest-path finder with hops or weight cost
  • per-label edge colors and counts, node/edge create-edit-delete

Everything the tutorial did through the SDK is available interactively - handy for verifying the graph after each sync run.

Full Script

// src/main.ts - the whole tutorial as one runnable script
import { VskiClient } from "@vski/sdk";

const client = new VskiClient("http://localhost:8347");
await client.auth.login("admin@example.com", "yourpassword");
const db = client.db("kb");

// 1. collections
await db.settings.collections.create({
  name: "users", type: "base",
  fields: [
    { name: "email", type: "email", required: true },
    { name: "name", type: "text", required: true },
  ],
});
await db.settings.collections.create({
  name: "projects", type: "base",
  fields: [{ name: "title", type: "text", required: true }],
});
await db.settings.collections.create({
  name: "documents", type: "base",
  fields: [
    { name: "title", type: "text", required: true },
    { name: "status", type: "select", options: { values: ["draft", "review", "final"] } },
  ],
});

// 2. records
const [alice, bob, carol] = await Promise.all(
  [
    { email: "alice@example.com", name: "Alice" },
    { email: "bob@example.com", name: "Bob" },
    { email: "carol@example.com", name: "Carol" },
  ].map((u) => db.collection("users").create(u)),
);
const atlas = await db.collection("projects").create({ title: "Atlas" });
const [api, schema, onboarding] = await Promise.all(
  [
    { title: "API design", status: "review" },
    { title: "Database schema", status: "final" },
    { title: "Onboarding guide", status: "draft" },
  ].map((d) => db.collection("documents").create(d)),
);

// 3. graph
await db.settings.graphs.create({
  name: "kb",
  options: { directed: true, maxNodes: 10000, maxEdges: 50000 },
});
const kb = db.graph("kb");

// 4. nodes (ref -> collection records)
const ref = (collection: string, recordId: string) => ({ collection, recordId });
await kb.createNodes([
  { id: "u:alice", label: "person", properties: { ref: ref("users", alice.id) } },
  { id: "u:bob", label: "person", properties: { ref: ref("users", bob.id) } },
  { id: "u:carol", label: "person", properties: { ref: ref("users", carol.id) } },
  { id: "p:atlas", label: "project", properties: { ref: ref("projects", atlas.id) } },
  { id: "d:api", label: "document", properties: { ref: ref("documents", api.id) } },
  { id: "d:schema", label: "document", properties: { ref: ref("documents", schema.id) } },
  { id: "d:onboarding", label: "document", properties: { ref: ref("documents", onboarding.id) } },
]);

// 5. relationships
await kb.createEdges([
  { source: "u:alice", target: "p:atlas", label: "works_on", weight: 20 },
  { source: "u:bob", target: "p:atlas", label: "works_on", weight: 30 },
  { source: "u:carol", target: "p:atlas", label: "works_on", weight: 10 },
  { source: "u:alice", target: "d:api", label: "authored" },
  { source: "u:bob", target: "d:schema", label: "authored" },
  { source: "u:carol", target: "d:onboarding", label: "authored" },
  { source: "d:api", target: "d:schema", label: "depends_on", weight: 1 },
  { source: "d:onboarding", target: "d:api", label: "depends_on", weight: 2 },
]);

// 6. queries
const team = await kb.neighbors("p:atlas", { direction: "in" });
console.log("Atlas team:", team.nodes.map((n) => n.id));

const impact = await kb.traverse("d:schema", {
  depth: 10, direction: "in", labels: ["depends_on"],
});
console.log("Schema change affects:", impact.nodes.map((n) => n.id));

const route = await kb.path("u:alice", "u:carol", { cost: "weight" });
console.log("Alice -> Carol:", route.nodes?.map((n) => n.id), "cost", route.cost);
// -> ["u:alice", "d:api", "d:onboarding", "u:carol"] cost 4

const stats = await kb.stats();
console.log("Labels:", stats.edgeLabels, "avgDegree", stats.avgDegree.toFixed(2));

client.close();

Run it with:

deno run -A src/main.ts

(@vski/sdk resolves from an import map - add "@vski/sdk": "npm:@vski/sdk@^2.1.1" to your deno.json imports.)

Troubleshooting

node not found when creating edges

Both source and target must exist in the graph before the edge is created. Check with graph.getNodes({ q: "u:" }) - remember that graph node ids (u:alice) are not collection record ids.

graph edge cap exceeded (400)

The graph hit options.maxEdges (or the server-wide GRAPH_MAX_EDGES). Raise the per-graph cap via settings.graphs.update and retry - the write that failed did not partially apply.

graph not found (404) although it exists

Graphs are db-scoped with strict resolution: client.graph("kb") targets the client's default database. Use client.db("kb").graph("kb") (as in this tutorial) or client.graph("kb", "kb").

Traversal returns empty results

Two usual causes: direction (depends_on impact analysis is an inbound walk) and labels filtering (labels must match exactly, including case and empty strings).

Next Steps

  • Wire the sync loop from Step 6 into Triggers so record changes update the graph automatically
  • Subscribe to db:kb realtime events to live-update a visualization (Realtime)
  • Combine graph traversal with collection queries: resolve a route's node ids to full records via properties.ref
  • Explore weighting schemes - works_on hours, review status, whatever makes cost: "weight" meaningful in your domain

Resources