Search indexes bolted on the side — secondary indexes for full-text, range and vector seeks
Index-free adjacency only serves EXPAND, so finding start nodes by value — full-text, range, geo, vector — is served by classic secondary indexes (Lucene, B-tree) maintained as separate structures riding shotgun, with the usual write-amplification and staleness costs.
Sharding placed the edges; it never solved finding the anchor by value. That job goes to classic indexes — full-text, B-tree, vector — bolted on the side, feeding start nodes into the native pointer-chase, with their own staleness costs. So now we have the complete picture for LOCAL work: SEEK an anchor, then EXPAND cheaply. But every query we've built lights up only a few nodes. What about the questions that need the WHOLE graph?
Scene 12
Search indexes bolted on the side
- Watch
- Try it
- Predict
- Capture
The native pointer-chase store sits in the center — that's the index-free adjacency you built: follow edges you already hold, O(1) per hop. But it can ONLY expand from a node you already have. To start 'from the product whose description says cordless drill', or 'from users aged 30-40', or 'from the photo most similar to this embedding', the engine asks a SEPARATE box on the side. Watch each search box light up and hand exactly one start-node id into the core's SEEK — then the native walk takes over.
Highlighted lines are the ones running in the diagram right now.
def run(query):# SEEK: find the start node BY VALUE — not possible nativelyindex = pick_secondary_index(query.predicate) # lucene | btree | vectorstart_id = index.lookup(query.value) # value -> node id# EXPAND: index-free adjacency, O(1) per hopnode = store.load(start_id)return traverse(node, query.pattern) # follow pointers
def write(node, change):store.apply(change) # the core mutationfor index in secondary_indexes:if index.sync:index.reindex(node) # on the write path -> write-ampelse:enqueue_async(index, node) # lags -> stale window
def pick_index(predicate):# the core can only EXPAND, so route by value-kindif predicate.kind == TEXT:return lucene # words -> node ids (free-text)if predicate.kind == RANGE:return btree # value/range -> node idsif predicate.kind == VECTOR:return vector # nearest embedding -> node idsraise NoIndex # else: full label scan
def lookup(value):# this structure is maintained SEPARATELY from the corenode_id = self.map.get(value) # value -> node id# if a write hasn't been re-applied here yet,# self.map still holds the pre-write entryreturn node_id # may point at a since-changed node
Where this sits in Build a graph database (Neo4j / Dgraph-style)
Scene 12 of 16, in the Scale & ACID act — ACID on one box; the partition cut turns hops to RPCs.. Index-free adjacency only serves EXPAND, so finding start nodes by value — full-text, range, geo, vector — is served by classic secondary indexes (Lucene, B-tree) maintained as separate structures riding shotgun, with the usual write-amplification and staleness costs.
Up next. Every mechanism so far — index-free adjacency, anchors, sharding, bolted-on search — optimizes touching a FEW nodes near a start point. But PageRank, connected components, global counts must touch EVERY vertex and edge, repeatedly. There's no locality to exploit, so index-free adjacency buys nothing. The payoff inverts, and a completely different model takes over: think like a vertex.
All 16 scenes in Build a graph database (Neo4j / Dgraph-style) · Every curriculum