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?” —
incomingdirection: 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
graphprojection 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, declaredrelationin the schema.seed— where the walk starts. Here: two document_keys.max_depth— the hop budget. Seeds sit at depth 0, somax_depth: 0returns just the seeds; change it to 2 and watch the circle grow.direction— edges are directed:outgoingfollows what a node points at,incomingfinds 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 structuretraverseuses, 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:
- Entities are node documents, with a discriminator field saying
which is which (
node_typebelow). - Relationships are edge documents —
{from, to}naming endpoint keys. - One relation field per relationship type —
acted_in,directedandhas_genreare 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
followsedge.incoming= followers,bothat depth 2 = friends-of-friends, ranked as follow suggestions. - Hierarchy — employees + a
reports_toedge.outgoingclimbs the management chain,incomingcollects the org below. - Citations — papers + a
citesedge.incomingis cited-by (impact),outgoingis the reference list. - Fraud rings — accounts + shared keyword columns (email, phone,
device). No relation schema at all: the
graphblock’s ad-hoc tier turns shared values into entity nodes, and a ring shows up as one connected cluster — thetagswidget 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_depthbounds the hops,directionpicks 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
graphblock 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 (referencesFK +join).
Next: Lesson 6 — hybrid search. The two-collection sibling of these edges — joins — is Lesson 8.