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.
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
- Watch
- Try it
- Predict
- Capture
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.
Highlighted lines are the ones running in the diagram right now.
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 itreturn expand(anchor, depth)
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 scanfor node in all_nodes_with(label): # O(N) every queryif node[prop] == value:return node
def create_index(label, prop):btree = BTree()for node in all_nodes_with(label): # one-time buildbtree.insert(node[prop], node)catalog[(label, prop)] = btree # now index_for() finds itreturn btree
def expand(anchor, depth):frontier = [anchor]for _ in range(depth):nxt = []for node in frontier:rel = node.first_rel # pointer, not an indexwhile rel is not None: # O(1) per edgenxt.append(rel.end_node)rel = rel.next_relfrontier = nxtreturn 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