You still need an index to start — index-free adjacency covers EXPAND, not SEEK

Every query is two phases — SEEK the anchor once with an ordinary index (O(log N)), then EXPAND with index-free adjacency (O(1) per edge) — so 'index-free' never meant 'no indexes', and a MATCH on an unindexed property silently degrades the SEEK into a full scan of every node of that label.

Previously

The doubly-linked chain lets us EXPAND from a node we hold — but we still had to find Alice. That first step, the SEEK, needs an ordinary index; index-free adjacency never covered it. Pull that index out and watch the start node turn into a full scan. So the store has TWO jobs with two cost models — and that raises a deeper storage question we've dodged.

Scene 05

You still need an index to start

  1. Watch
  2. Try it
  3. Predict
  4. Capture
SEEK-EXPANDAliceBobCarolDaveErinFrankGraceHeidiIvanJudyThe RockThe MatrixInceptionGothamFRIENDRATEDLIVES_INFOLLOWSSEEK: the index finds Alice in O(log N). EXPAND: O(1) per hop from her.SEEK → EXPANDB-tree SEEK · O(log n)anchor: Alicethen EXPAND · O(1)/hopSPINElocal 0 · global 14SEEK 1 anchor → EXPAND a few.…
What to watch for

Last scene we walked a node's relationship chain — but we ASSUMED we were already standing on Alice. How did we get there? Watch the two phases. First the SEEK: a plain B-tree index on User.name lights up and jumps straight to Alice (the ringed node) — one O(log N) lookup, the same kind of index a relational store uses. Only now do we hold a node. Second the EXPAND: from Alice we follow relationship pointers — the index-free O(1) walk — hop by hop to her friends and their friends. Two phases, two completely different cost models.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

Query.run
every query is two phases — SEEK the anchor, then EXPAND from it
def run(label, prop, value, depth):
# phase 1: SEEK — find the start node by value (once)
anchor = seek_anchor(label, prop, value)
# phase 2: EXPAND — index-free pointer walk from it
return expand(anchor, depth)
Query.seek_anchor
find the start node by value — indexed, or a full label scan
def seek_anchor(label, prop, value):
idx = index_for(label, prop)
if idx is not None:
return idx.lookup(value) # O(log N) once
# no index → full label scan
for node in all_nodes_with(label): # O(N) every query
if node[prop] == value:
return node
Index.create
what the toggle does: register a B-tree so seek_anchor can look up, not scan
def create_index(label, prop):
btree = BTree()
for node in all_nodes_with(label): # one-time build
btree.insert(node[prop], node)
catalog[(label, prop)] = btree # now index_for() finds it
return btree
Query.expand
from the anchor, follow relationship pointers (index-free)
def expand(anchor, depth):
frontier = [anchor]
for _ in range(depth):
nxt = []
for node in frontier:
rel = node.first_rel # pointer, not an index
while rel is not None: # O(1) per edge
nxt.append(rel.end_node)
rel = rel.next_rel
frontier = nxt
return frontier

Where this sits in Build a graph database (Neo4j / Dgraph-style)

Scene 05 of 16, in the The store act — Store records, index-free adjacency, the CSR trade.. Index-free adjacency only covers EXPAND (traversing from a node you hold); to SEEK the anchor — 'start from Alice' — you still need a regular index, and without one finding the start node is a full label scan over every :User.

Up next. We've been drawing the relationship chain as a doubly-linked list because it's easy to insert and delete. But pointer-chasing across the heap is cache-hostile, and there's a rival layout — a packed array — that streams through memory beautifully but can't cheaply add an edge. Which one you pick depends entirely on whether your graph mutates or just gets read.

All 16 scenes in Build a graph database (Neo4j / Dgraph-style) · Every curriculum

Built with Arqly
Every scene in Build a graph database (Neo4j / Dgraph-style) builds on the one before it.All 16 Build a graph database (Neo4j / Dgraph-style) scenes