Build a vector database (Pinecone / Weaviate / pgvector style) (15 scenes)
Scene 11 · Filtered search — pre vs post
Real queries combine a metadata filter with similarity, and both naive orders break: post-filter can return fewer than k results; pre-filter sets up a subtler failure.
Previously
We finished the clean index-choice trilemma; now production reality: queries carry metadata predicates. Bolting a filter onto vector search has two obvious orders — search-then-filter and filter-then-search — and the first one starves below k. Pre-filtering looked like the safe answer. It isn't, once the index underneath is an HNSW graph.
Scene 11
Filtered search — pre vs post
Diagram
The same 14-song map and the 'Now Playing' query. A metadata predicate — here 'genre = electronic' — paints matching songs solid and non-matching songs gray. POST-FILTER searches all 14 by closeness, then drops the gray ones (so a selective filter can leave fewer than the k you asked for). PRE-FILTER deletes the gray ones first, then searches only what's left (so it always returns k). The result tray under the plot counts how many of the requested k were actually delivered.
Up to now a query was just 'find the closest songs'. Real queries carry an extra condition — a *metadata predicate* — a plain attribute test like 'genre = electronic', 'in stock', or 'price < $50' that each item either passes or fails. Watch the songs paint in: solid ones match the filter, gray ones don't. Notice the closest songs to 'Now Playing' are a MIX — some match, some are gray. So 'closest' and 'matches the filter' are two different sets, and a real answer has to satisfy BOTH at once.
Implementation
PostFilter.search
search everything by closeness, then drop non-matches
1def post_filter(query, predicate, k):2 # one ANN pass over the whole index3 cand = ann_search(query, fetch=FETCH)4 out = []5 for id in cand: # closest first6 if predicate(id): # keep matches7 out.append(id)8 if len(out) == k:9 return out10 return out # may be < k
FilteredSearch.route
the ordering choice — and the over-fetch knob nobody can size
1def filtered_search(query, predicate, k):2 if PRE_FILTER:3 return pre_filter(query, predicate, k)4 # post-filter over-fetches to survive the drop:5 FETCH = k * over_fetch_multiplier6 return post_filter(query, predicate, k)
Not sure what to ask? Tap a question — the staff engineer answers in the chat panel.