Lesson 5 — Graph traversal

Lesson 5 — Graph traversal #

Relations are the first of the two features that most distinguish Pizza from a plain text index: edges as a first-class query target. This lesson walks edges inside one collection — the traversal sections run live in the browser widget, and the modeling section at the end is curl against a live node.

Graph queries are also the one lesson without a SQL pairing: traverse stays DSL-side — it is demand-gated outside the SQL M1 subset, which stops at the single-collection statement.

The use case #

“Related to” is everywhere — recommendations, social feeds, GraphRAG context assembly, fraud investigations. When the relationship is the question, a text index makes you assemble the neighborhood in your application; a relation field makes it a query. The questions this lesson answers:

  • “What is related to this article / user / ticket?” — traverse, one hop out.
  • “Friends of friends — who should I follow next?” — two hops in both directions, ranked by distance for free.
  • “Who points at this node?” — incoming direction: followers, cited-by, who-reports-to.
  • “Start from everything matching X, then expand the neighborhood” — query seeds.
  • “Give me the graph around these hits, for my UI or my RAG pipeline” — the graph projection block.

The movie graph at the end models entities and relationships end to end — the same shape as social, hierarchy, citation and fraud domains.

Entities and relationships are both documents #

The articles collection (44 documents) carries a related_to field of type relation. The graph lives entirely inside that one collection, in two kinds of documents:

  • Node documents — the entities. An article carries its attributes (title, body, tags, author).
  • Edge documents — the relationships. A “related to” link is its own document whose relation field names both endpoints by key:
{ "related_to": { "from": "A01", "to": "A02" } }

The dataset is 20 article documents plus 24 edge documents. No side table, no join index to maintain: write an edge document and the adjacency is queryable in the same breath. A traverse query walks those edges.

Your first traversal #

Start from two seed articles and walk one hop in either direction:

Two documents are reachable at depth 0 (the seeds themselves), 17 at depth 1 — the known answer for this dataset, 19 hits in total. The walk is a ring at a time, and the score falls with each ring:

graph LR S("seeds — depth 0
score 1.0") -->|"one hop along related_to"| R1("depth 1 — 17 documents
score 0.5") R1 -->|"the circle grows"| R2("depth 2
score 0.33")

Three knobs did the work:

  • field — the edge field to walk, declared relation in the schema.
  • seed — where the walk starts. Here: two document _keys.
  • max_depth — the hop budget. Seeds sit at depth 0, so max_depth: 0 returns just the seeds; change it to 2 and watch the circle grow.
  • direction — edges are directed: outgoing follows what a node points at, incoming finds who points at it, both (the default) walks either way. Edit the widget and flip it.

Every reached node comes back as a hit, and the score encodes the distance: 1.0 / (1.0 + depth). Seeds score 1.0, one-hop neighbors 0.5, two-hop neighbors 0.33 — the closer a document is to the seed, the higher it ranks.

Seeds do not have to be keys #

Any query can select the seeds. Instead of naming articles, start from everything a given author wrote, and walk one hop out from there:

The seed selector takes three forms: keys (as above), query (any sub-query, resolved before the traversal dispatches), or ids (pre-resolved internal ids — the programmatic form). On a live server keys also accepts system ids in "rolling,seq" form; keys that do not exist are simply dropped.

Traversal plus a query seed is the graph-search workhorse: find the documents matching X, then expand the neighborhood — related articles, referenced tickets, upstream dependencies — in one request.

Keep a graph collection on one shard #

Edge endpoints resolve against the key map of the shard that stores the edge document. An endpoint living on a different shard cannot be resolved there — that edge is dropped with a warning, not silently half-walked. So keep a graph collection (or at least its edge documents and the nodes they reference) on a single shard:

{
  "settings": {
    "rolling": {
      "partitions_sharding_strategy": { "hash": { "number_of_shards": 1 } }
    }
  },
  "schema": { "properties": { "related_to": { "type": "relation" } } }
}

That is exactly how the articles teaching dataset is declared. The traverse reference covers the placement rules in detail.

Rendering: the graph block #

traverse searches by walking edges. The graph block renders: add it to any search request and the response carries a graph object — nodes and edges — built from the hit set, alongside the normal hits. A term query picks the seeds, the graph block projects everything they reach:

The projection works in two tiers:

  • Relation fields (adjacency). On a relation-typed field it walks the same adjacency structure traverse uses, emitting document-to-document edges for every recorded edge whose endpoints are in the hit set.
  • Ad-hoc column projection. On an ordinary keyword field, every distinct value on the hit documents becomes a synthetic entity node, and each document links to its values. No relation schema needed — any faceting-style column turns into a bipartite graph:

In the response, node ids are stable (doc:<key> for documents, ent:<field>:<value> for entities), edges carry from/to/via, and a short plan line summarizes which tier executed. A UI — or a GraphRAG pipeline — consumes the structure directly, no client-side adjacency assembly. label_field and extra_fields attach titles and metadata to the nodes so rendering needs no second round trip; the graph reference has the full parameter list.

Modeling entities and relationships #

related_to is one edge type over one node type. Real domains have several of each — and the model is always the same three moves:

  1. Entities are node documents, with a discriminator field saying which is which (node_type below).
  2. Relationships are edge documents — {from, to} naming endpoint keys.
  3. One relation field per relationship type — acted_in, directed and has_genre are three fields, three edge sets, queryable independently.

This section builds a small movie graph against your Lesson 1 node and asks it real questions — the same shape as the engine’s own graph_movies example. It is curl, not a widget: the playground ships fixed datasets.

The model #

One collection holds people, movies and genres, plus three edge types:

PUT /movies
{
  "schema": { "dynamic": "strict", "properties": {
    "node_type": { "type": "keyword" },
    "name":      { "type": "text" },
    "title":     { "type": "text" },
    "year":      { "type": "integer" },
    "acted_in":  { "type": "relation",
                   "relation": { "from_type": "person", "to_type": "movie" } },
    "directed":  { "type": "relation",
                   "relation": { "from_type": "person", "to_type": "movie" } },
    "has_genre": { "type": "relation",
                   "relation": { "from_type": "movie", "to_type": "genre" } }
  }}
}

(The relation meta is descriptive — documentation for humans and the traversal planner; the engine does not enforce it.)

Node documents — four people, three movies, two genres, keys as slugs:

PUT /movies/_doc/leonardo  { "node_type": "person", "name": "Leonardo DiCaprio" }
PUT /movies/_doc/nolan     { "node_type": "person", "name": "Christopher Nolan" }
PUT /movies/_doc/gordon    { "node_type": "person", "name": "Joseph Gordon-Levitt" }
PUT /movies/_doc/page      { "node_type": "person", "name": "Ellen Page" }
PUT /movies/_doc/inception { "node_type": "movie", "title": "Inception", "year": 2010 }
PUT /movies/_doc/knight    { "node_type": "movie", "title": "The Dark Knight", "year": 2008 }
PUT /movies/_doc/star      { "node_type": "movie", "title": "Interstellar", "year": 2014 }
PUT /movies/_doc/scifi     { "node_type": "genre", "name": "sci-fi" }
PUT /movies/_doc/thriller  { "node_type": "genre", "name": "thriller" }

Edge documents — one per relationship, endpoints by key:

PUT /movies/_doc/e1  { "acted_in": { "from": "leonardo", "to": "inception" } }
PUT /movies/_doc/e2  { "acted_in": { "from": "gordon",   "to": "inception" } }
PUT /movies/_doc/e3  { "acted_in": { "from": "page",     "to": "inception" } }
PUT /movies/_doc/e4  { "acted_in": { "from": "leonardo", "to": "knight" } }
PUT /movies/_doc/e5  { "directed": { "from": "nolan", "to": "inception" } }
PUT /movies/_doc/e6  { "directed": { "from": "nolan", "to": "knight" } }
PUT /movies/_doc/e7  { "directed": { "from": "nolan", "to": "star" } }
PUT /movies/_doc/e8  { "has_genre": { "from": "inception", "to": "scifi" } }
PUT /movies/_doc/e9  { "has_genre": { "from": "inception", "to": "thriller" } }
PUT /movies/_doc/e10 { "has_genre": { "from": "knight",    "to": "thriller" } }

The graph those documents build — three node types, three edge types, one collection:

graph LR subgraph people["person nodes"] L("leonardo") N("nolan") G("gordon") P("page") end subgraph movies["movie nodes"] I("inception") K("knight") T("star") end subgraph genres["genre nodes"] SC("scifi") TH("thriller") end L -->|"acted_in"| I G -->|"acted_in"| I P -->|"acted_in"| I L -->|"acted_in"| K N -->|"directed"| I N -->|"directed"| K N -->|"directed"| T I -->|"has_genre"| SC I -->|"has_genre"| TH K -->|"has_genre"| TH

Order matters: endpoints resolve by key at write time, and an edge whose endpoint does not exist yet is silently dropped (a warn in the node log). Write the nodes first — the same bulk request is fine — then the edges. Beyond that, an edge document is ordinary: it can carry attributes (a role, a since date) that are searchable like any field, and deleting it removes the edge.

The questions the graph answers #

Who acted in Inception? Walk acted_in backwards from the movie — incoming finds everyone pointing at it:

POST /movies/_search
{
  "size": 10,
  "track_total_hits": true,
  "query": {
    "traverse": {
      "field": "acted_in",
      "seed": { "keys": ["inception"] },
      "max_depth": 1,
      "direction": "incoming"
    }
  }
}

4 hits: inception at depth 0, and at depth 1 exactly the three actors — leonardo, gordon, page. The depth-1 tier is the answer; the seed is just the anchor.

Which movies has DiCaprio been in? Same edge field, forward:

"seed": { "keys": ["leonardo"] }, "max_depth": 1, "direction": "outgoing"

3 hits: leonardo, inception, knight — one hop out along acted_in.

Who are DiCaprio’s co-actors? Two hops, both directions:

"seed": { "keys": ["leonardo"] }, "max_depth": 2, "direction": "both"

5 hits: leonardo (1.0), his two movies (0.5), and at 0.333 exactly the co-actors — gordon and page, reached through inception. This is the recommendation pattern: people two hops out through a shared movie, and the depth-scored ranking already orders them.

Query seeds: the cast of the 2010s movies. Seeds do not have to be keys — any query selects them. All movies from 2010 on, then everyone who acted in one:

POST /movies/_search
{
  "size": 10,
  "track_total_hits": true,
  "query": {
    "traverse": {
      "field": "acted_in",
      "seed": { "query": { "range": { "field": "year", "gte": 2010, "lt": 2015 } } },
      "max_depth": 1,
      "direction": "incoming"
    }
  }
}

The range matches inception (2010) and star (2014); one hop in on acted_in reaches the inception cast — 5 hits, the two movies at depth 0 and the three actors at depth 1.

What genres is Inception? A third relationship type, same mechanics — has_genre, outgoing from the movie: 3 hits, scifi and thriller one hop out.

Render the whole neighborhood #

The graph block merges several edge types into one picture — all people, linked through both their acting and directing edges:

POST /movies/_search
{
  "size": 4,
  "track_total_hits": true,
  "query": { "term": { "field": "node_type", "value": "person" } },
  "graph": {
    "relations": [ { "field": "acted_in" }, { "field": "directed" } ],
    "max_seeds": 4
  }
}

The response’s graph.nodes/graph.edges carry the four people, the movies, and every acted_in/directed edge between them (edge documents render as doc nodes the endpoints link through) — ready for a UI, or as the context assembly step of a GraphRAG pipeline.

The same shape, other domains #

  • Social — users + a follows edge. incoming = followers, both at depth 2 = friends-of-friends, ranked as follow suggestions.
  • Hierarchy — employees + a reports_to edge. outgoing climbs the management chain, incoming collects the org below.
  • Citations — papers + a cites edge. incoming is cited-by (impact), outgoing is the reference list.
  • Fraud rings — accounts + shared keyword columns (email, phone, device). No relation schema at all: the graph block’s ad-hoc tier turns shared values into entity nodes, and a ring shows up as one connected cluster — the tags widget above is exactly this shape.

One boundary to keep in mind: edges live inside ONE collection — the endpoint keys resolve against that collection’s key map. When the relationship points into another collection (orders → orgs), it is not an edge: declare a keyword foreign key with references and query it with a join — Lesson 8. Rule of thumb: same collection, walk it; across collections, join it.

What you learned #

  • Entities and relationships are both documents: nodes carry the attributes, an edge document carries {from, to} by key — graph queries without a graph database, and without a second index to maintain.
  • Seeds can be _keys, system ids, or an arbitrary sub-query; non-existent keys drop out quietly.
  • max_depth bounds the hops, direction picks the edge orientation, and scores fall off with distance (1 / (1 + depth)) — which makes two-hop walks a ranked suggestion list for free.
  • One relation field per relationship type; endpoints resolve at write time, so write nodes before edges.
  • Edge resolution is shard-local: keep graph collections on one shard.
  • The graph block renders any hit set as nodes and edges — adjacency tier for relation fields, ad-hoc entity tier for plain keyword columns.
  • The modeling boundary: same collection, walk it (traverse); across collections, join it (references FK + join).

Next: Lesson 6 — hybrid search. The two-collection sibling of these edges — joins — is Lesson 8.

Calendar October 6, 2026
Edit Edit this page