skip to content

On a marketplace search surface, why is applying an in-stock facet inside retrieval different from filtering the ranked list afterwards?

level: middleimportance: must knowfreq 57%

answer

  1. which stage evaluates the predicate
  2. the shortlist is a budget
  3. eligible count is k times selectivity
  4. over-fetch multiplier cannot be bounded
  5. selective constraint needs a filter-first path

basics

~20 s

Inside retrieval, the shortlist comes back full of eligible items. Filtering afterwards spends the shortlist on ineligible ones, so a selective facet leaves too few results to fill the page and wastes the scoring stage's budget.

solid answer

~50 s

The two orderings differ in **what the shortlist is spent on**. If the facet travels into the retrieval call, all 400 candidates already satisfy it, the heavy scoring stage ranks 400 useful items and the page fills. If the facet is applied to the ranked list, retrieval returns the 400 best matches ignoring stock, and a facet that only 2% of the catalogue satisfies leaves roughly 8 usable items for a 24-slot page — the classic under-fill. Post-filtering also forces an over-fetch multiplier you cannot bound, because the multiplier depends on a selectivity that varies per query. The cost of pre-filtering is on the other side: a constraint that matches very few items makes constrained traversal slow or lossy, so a production funnel usually keeps a filter-first exact path for highly selective constraints and a constrained-traversal path for the rest.

code

pseudocode · 23 lines
pseudocode
FUNCTION search(queryText, facets, pageSize = 24):
    selectivity = estimate_matching_fraction(facets)

    IF selectivity < 0.01:
        candidates = enumerate_matching(facets, queryText, limit = 2000)
    ELSE:
        candidates = retrieve(queryText, constraint = facets, k = 400)

    ranked = score_stage(candidates)
    page   = take(ranked, pageSize)

    IF size(page) < pageSize:
        page = page + relaxed_matches(queryText, facets, need = pageSize - size(page))
        mark_page_as_relaxed(page)

    log_impressions(page)
    RETURN page


FUNCTION search_postfilter(queryText, facets, pageSize = 24):
    candidates = retrieve(queryText, k = 400)
    eligible   = filter(candidates, facets)
    RETURN take(score_stage(eligible), pageSize)

go deeper

for a junior

Learn the order of operations: if the constraint is applied after retrieval, the shortlist was already spent on items that cannot be shown.

for a middle

Do the arithmetic out loud — eligible items are roughly k times the facet's selectivity — and explain why no fixed over-fetch multiplier is safe across queries.

for a senior

Describe the two-path design chosen from a selectivity estimate, and state the under-fill policy: widen, then relax visibly, never drop a ticked facet.

for a principal

Weigh the operational cost of maintaining two retrieval paths against the trust cost of occasional silent constraint violations, and decide where that line sits for the business.

## The same predicate, two places in the cascade A marketplace search request carries two different things: free text ("hiking boots") and constraints the shopper ticked (`in stock`, `size 44`, `ships in 2 days`). The constraint is a predicate, and the only real question is **which stage of the funnel evaluates it**. - **Pre-filter** — the predicate is handed to the retrieval tier and evaluated during the lookup. Every one of the k candidates satisfies it. - **Post-filter** — retrieval runs unconstrained, returns its k best matches, and the predicate is applied to that list afterwards. The stages downstream are identical. Only the arithmetic changes. ## Why post-filtering under-fills Suppose retrieval returns `k = 400` and the page shows 24 items. | facet selectivity | eligible items left after post-filter | 24-slot page | |---|---|---| | 50% of catalogue | ~200 | fills comfortably | | 10% | ~40 | fills, thin tail | | 2% | ~8 | **under-fills** | | 0.2% | ~1 | effectively empty | The eligible count is `k x selectivity`, and selectivity is a property of the *query and the shopper's facets*, not of the system — so no fixed k is safe. The usual patch is an over-fetch multiplier ("retrieve 4,000 instead of 400"), which is a guess: it is wasteful for common facets and still insufficient for rare ones. Worse, the heavy scoring stage either runs on all 4,000 (a 10x cost increase on every request, including the 90% that did not need it) or runs after the filter, at which point retrieval's own relevance ordering has already decided which 400 got a chance. ## Why pre-filtering is not free either Pushing the predicate into retrieval moves the problem rather than deleting it. Retrieval tiers are built to traverse toward *similar* or *matching* items quickly; forcing every visited item to also satisfy a predicate changes the traversal: - When the constraint matches **most** of the catalogue, it is nearly free — almost everything visited passes. - When it matches **very little**, the traversal spends its effort rejecting items, and depending on how the tier is built it either gets slow (it keeps searching) or gets lossy (it stops early and returns fewer, worse candidates than it should). That is why production search funnels usually keep **two paths** and choose between them from an estimate of selectivity, which the system already has from index statistics: 1. **Highly selective constraint** — enumerate the matching set directly and score it exhaustively or near-exhaustively. If only 300 items are in stock in size 44, there is no reason to approximate at all. 2. **Weakly selective constraint** — constrained traversal in the retrieval tier, the ordinary path. ## What to do when the page still comes up short Under-fill is a real outcome, not only a bug, and the funnel needs a stated policy for it: 1. **Widen retrieval under the same constraint** — raise k, or add a broader retrieval source. The constraint is still honoured; you simply looked harder. 2. **Relax the query explicitly** — drop the weakest free-text term or loosen a soft attribute, and **tell the shopper** ("no exact matches in stock; showing similar items"). Relaxation is legitimate; silent relaxation is not. 3. **Show fewer items.** A short page of correct results beats a full page containing things the shopper explicitly excluded. What a design should not do is quietly drop a ticked facet to fill slots. A facet is the one part of the request the shopper knows they made, and violating it destroys trust in every other result on the page. ## The logging consequence people forget Whichever path runs, only the items actually shown produce user decisions. If post-filtering removed an item after scoring, it was never seen, so it must not be logged as an impression or as a non-click. Training data assembled from "everything the ranker scored" rather than "everything the shopper could see" teaches the model that eligible-but-hidden items are bad, which is a slow, quiet corruption of the next model. ## The line to hold in an interview Name the stage, name the arithmetic, name the fallback. "Push the predicate into retrieval so the shortlist is spent on eligible items; keep an exact filter-first path for constraints that match almost nothing; if the page still under-fills, widen or relax **visibly**, and never silently drop a facet the shopper ticked." That is a funnel answer, and it is checkable — unlike "use a filter".

  • If you must post-filter, how do you size the over-fetch multiplier?
    You estimate selectivity per request from index statistics and set the multiplier as roughly the page size divided by that estimate, with a ceiling. It is still a guess: rare facets blow past any ceiling and common ones pay for headroom they never use. That unbounded dependence on a per-query property is the argument for pushing the predicate into retrieval instead.
  • The page comes back with four results. What is the right response?
    Widen retrieval under the same constraint first, then relax the free-text side of the query and label the page as relaxed. Showing four correct results is acceptable; filling the page by quietly dropping a facet the shopper ticked is not, because it violates the one part of the request they know they made.
  • Which items from that request belong in the training log?
    Only the ones actually rendered to the shopper. Items scored and then filtered away were never seen, so logging them as impressions or non-clicks invents a decision the shopper never made and teaches the next model that eligible-but-hidden items are unattractive.

saying these in an interview costs you the question

  • Assuming a fixed over-fetch multiplier makes post-filtering safe
  • Believing a constraint pushed into retrieval is always free
  • Filling an under-filled page by silently dropping a ticked facet
  • Logging scored-then-filtered items as impressions
  • Treating under-fill as impossible because retrieval always returns k
  • Applying eligibility only at render time, after the funnel has run