Filters, Facets and Hard Constraints
In one line: some parts of a query are non-negotiable, and treating them as strong preferences produces results that are confidently wrong.
Constraint or preference
The distinction that organises this whole lesson.
A constraint disqualifies. Size 10 means a size 9 is not a slightly worse answer — it is not an answer. Out of stock, wrong region, no permission, outside the date range: these are all binary.
A preference trades. Cheaper is better, newer is better, higher-rated is better, all else equal. These belong in the ranking score.
Candidates routinely put everything in the score with tuned weights. That produces a system where a sufficiently attractive wrong-sized item outranks a correct one, and no weight setting fixes it — the model is wrong, not the tuning.
Where the filter runs
Three options, and the choice depends on selectivity.
Pre-filter — restrict the candidate set first, then search within it. Correct by construction. Cheap when the filter is selective and the eligible set is small. Expensive when the eligible set is most of the corpus, because you have built a large set for nothing.
Post-filter — retrieve, then discard non-matching results. Cheap, and it fails exactly when the filter is strict: retrieve a hundred, discard ninety-eight, return two. The fix is over-fetching, and you cannot know how much to over-fetch without knowing the selectivity in advance.
Filter during traversal — the index checks eligibility as it walks. Best of both for a lexical index, where a filter is just another postings list to intersect. Genuinely difficult for a graph-based vector index, where excluding most nodes fragments the traversal and recall collapses.
| Selectivity | Approach |
|---|---|
| Most documents pass | Post-filter with modest over-fetch |
| Middling | Filter during traversal |
| Very few pass | Pre-filter, or scan the eligible set exactly |
That bottom row is worth saying out loud: when the eligible set is small enough, an exact scan beats any index. A filter matching two hundred documents does not need approximate nearest neighbours at all.
Permissions are not a filter
The case where getting this wrong is a security incident rather than a quality issue.
In enterprise search, a document the user cannot see must not influence anything they observe — not the results, not the result count, not the facet counts, not the "did you mean" suggestion. Post-filtering leaks all four. A result count of 47 that becomes 3 after filtering has told the user that 44 documents exist matching their query.
So permissions belong in the retrieval predicate itself, evaluated as part of the query rather than applied to its output. Practically that means indexing the ACL alongside the document and intersecting with the user's permission set at retrieval time.
Facets are more expensive than they look
A facet panel — 47 in Running, 23 in Training, 12 in Casual — requires counting matches per value across the entire matching set, not just the returned page.
That is a full aggregation over potentially millions of documents, on every query, and it is frequently the slowest part of a search request. Three mitigations, in order of preference:
Approximate counts above a threshold. "500+" is as useful to a shopper as "1,247" and vastly cheaper.
Restrict which facets are computed. Not every field needs a live count. Fields with high cardinality — brand, when there are ten thousand brands — should return top values only.
Cache aggressively for head queries. The facet distribution for shoes changes slowly. Compute it periodically rather than per request.
Facet interaction, and the dead end
The subtle behaviour that separates a considered design from a naive one.
When a user selects a filter, what happens to the other facets' counts? If they update to reflect the selection, the user can see that choosing Nike leaves only 3 items in size 10, which is honest and helpful. If they do not update, the user selects a second filter and lands on zero results — having been shown a count that promised otherwise.
The right behaviour is that each facet's counts are computed with all filters applied except its own, so the user always sees what would happen if they changed that one dimension. It is more expensive and it is the difference between a filter panel that guides and one that traps.
Constraints that come from nowhere
Some constraints are never typed and are always expected. Availability. Region and shipping eligibility. Age restrictions. Language. Adult content settings.
These apply to every query and are easy to forget in a design discussion — until an interviewer asks why an out-of-stock item is ranked first. Naming them as an implicit filter set applied to every request is a small detail that reads as production experience.
For e-commerce specifically, this is where "availability is part of relevance" becomes concrete. Stock status is not a ranking feature to be traded against a good text match. It is a filter, and it changes continuously, which is what forces a near-line index update path.
Key takeaway
A constraint disqualifies and a preference trades, and putting a constraint in the score produces results that are confidently wrong at any weighting. Choose the filter strategy from selectivity — and when very few documents pass, scan them exactly rather than reaching for an index. Permissions are never a post-filter, because result counts and facet counts leak the existence of documents the user cannot see.
Next: learning to rank, and why pointwise loss is the wrong objective.