When the filter disconnects the graph — pre-filtering HNSW with extra intra-category edges
Pre-filtering an HNSW search means only matching nodes are walkable, but if the path to a valid neighbor runs through a filtered-out node, the greedy walk dead-ends and recall craters — so production engines add extra intra-category edges or two-hop jumps, or fall back to brute force below a cutoff.
Pre-filtering avoided starvation, but on an HNSW graph it can sever the very paths the greedy walk needs — and now we've seen exactly how: the bridge node got filtered out and the walk dead-ended. Engines fix it with extra edges, two-hop jumps, or a brute-force fallback. But there's a different limit filters can't touch: similarity itself sometimes misses the exact word the user typed.
Scene 12
When the filter disconnects the graph
- Watch
- Try it
- Predict
- Capture
Last scene, pre-filtering looked like the safe answer: remove the non-matching songs first, then search only what's left — no starvation. Here is the trap. We're searching the HNSW graph from scenes 6-7, but first we apply the filter 'genre = electronic'. About 80% of the songs don't match, so they gray out and the walk is only allowed to step on the solid ones. Watch the greedy walk start at Indie Drive (#5) and head for the query. To get closer it needs to hop to 'Synth Dawn' (#6) — but #6 isn't electronic, so it was filtered out. With that one node gone, there's no walkable step any closer to the query. The walk dead-ends. And 'Lonely Synth' (#14, red) — which DOES match the filter and sits right next to the query — is stranded on the far side of the missing bridge, never reached.
Highlighted lines are the ones running in the diagram right now.
def search(query, predicate, k):matching = nodes_where(predicate)# below the cutoff the graph isn't worth itif len(matching) <= flatSearchCutOff:return exact_topk(query, matching, k)return filtered_greedy_walk(query, matching, k,)
def filtered_greedy_walk(query, matching, k):frontier = [entry_node] # size ef_searchwhile frontier.improving():cur = frontier.closest_to(query)for nbr in neighbors(cur):if nbr in matching:frontier.add(nbr) # walkableelif two_hop:for far in neighbors(nbr): # ACORNif far in matching:frontier.add(far) # skip the gapreturn frontier.topk(k)
def build_edges(node):link(node, nearest_neighbors(node, M))# filterable HNSW: also link to same-category# nodes so a walk inside one category never# depends on a node another filter removesif filterable_hnsw:peers = same_category(node)link(node, nearest(peers, M))
Where this sits in Build a vector database (Pinecone / Weaviate / pgvector style)
Scene 12 of 15, in the Production act — Filters, the graph-disconnection trap, hybrid RRF, and sharded scatter-gather.. Pre-filtering an HNSW search makes only matching nodes walkable — and if the path to a valid neighbor ran through a filtered-out node, the greedy walk dead-ends and recall craters.
Up next. Even perfectly filtered, dense vector search has a blind spot: it captures MEANING, so it nails paraphrases but fumbles exact tokens — a product SKU, an error code, a rare name the embedding never learned. Keyword search nails those but misses meaning. The next idea runs both and fuses their rankings.
All 15 scenes in Build a vector database (Pinecone / Weaviate / pgvector style) · Every curriculum