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.
acoustic → electroniccalm → energeticLo-fi RainAcoustic Suns…Campfire FolkCoffeehouseIndie DriveSynth DawnNeon CityClub PulseRave PeakBass DropMidnight DriveGarage BeatStudy BeatsLonely SynthNow PlayingPost-filter: searched all 14, dropped non-matches → delivered 2 of 3.RECALL vs LATENCYrecallslower →FlatIVFPQHNSW
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 index
3 cand = ann_search(query, fetch=FETCH)
4 out = []
5 for id in cand: # closest first
6 if predicate(id): # keep matches
7 out.append(id)
8 if len(out) == k:
9 return out
10 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_multiplier
6 return post_filter(query, predicate, k)
Not sure what to ask? Tap a question — the staff engineer answers in the chat panel.