PGPradhumn Gupta
← Writing
/3 min read#agentic-rag#vector-search#qdrant

Zero-Result Recovery in Agentic Search: Progressive Filter Relaxation

Handle zero results in agentic RAG search by relaxing hard payload filters progressively, then falling back to a broad semantic query.

Contents

Short answer: Don't return "no results." Most zero-result cases come from over-narrow hard filters on sparse metadata fields, not a genuine absence of relevant items. Relax the hard filters progressively — dropping the least intent-critical ones first — retry after each drop, and only fall back to a broad semantic-only query as a last resort.

Why hard filters on sparse fields cause most zero-result cases

In a hybrid vector search, a zero-result response almost never means "nothing relevant exists." It means the payload pre-filter eliminated every candidate before similarity scoring ran. This is especially common on sparse fields — attributes that are populated for only a fraction of your corpus.

If you AND together three or four hard filters and even one of them touches a poorly-populated field, you can zero out the entire result set while an obviously good match sits one filter away. The similarity search never gets a chance.

The design lesson I've landed on: not every user-mentioned attribute deserves to be a hard filter. In my own restaurant search, atmosphere and amenities are treated as semantic query enrichment rather than hard payload filters, precisely to avoid over-constraining. A "cozy" cue nudges the embedding instead of demanding an exact metadata match on a field that's rarely filled in.

Distinguishing 'no match' from 'too-narrow filter' before giving up

Before you conclude there's genuinely nothing to show, separate the two failure modes:

  • Too-narrow filter: Re-run the same query with the hard filters removed. If results appear, the filters were the problem — not the query. This is the case worth recovering from.
  • Genuine no-match: Even the unfiltered semantic query returns weak or empty results. Only here does a "no results" message make sense, ideally with a suggestion to broaden the search.

A cheap way to detect this in practice: run the filtered search, and if the count is zero, run a filter-free semantic probe. The probe's outcome tells you which branch you're in.

Ordering which filters to drop first

Relaxation is only as good as its ordering. Drop filters least-to-most important to the user's intent:

  1. Sparse "nice-to-have" attributes first — amenities, ambiance, secondary tags. Rarely populated, rarely central.
  2. Numeric-range constraints next — price bands, ratings. Widen the band before removing it entirely.
  3. Soft categorical filters — a specific sub-cuisine can relax to its parent category.
  4. Core intent filters last — the dish type, the primary cuisine, the hard dietary requirement (e.g. a vegan constraint you must never silently drop for safety reasons).

The principle: keep the filters central to the request alive the longest, so a partial result still honors what the user actually asked for.

The implementation in my Qdrant search tool

My search_dishes tool uses progressive filter relaxation for its zero-result retry. The pipeline is a single Qdrant call — vector similarity plus payload pre-filters — hydrated from PostgreSQL and post-sorted. When that call comes back empty, the tool doesn't return; it re-enters with a smaller filter set.

async def search_with_relaxation(query, filters):
    # filters ordered least -> most intent-critical
    relax_order = ["amenities", "price_band", "sub_cuisine"]
    active = dict(filters)
 
    for _ in range(len(relax_order) + 1):
        hits = await qdrant.search(query, filters=active)
        if hits:
            return hits
        # drop the next least-important filter and retry
        for key in relax_order:
            if key in active:
                del active[key]
                break
        else:
            break  # only core filters remain; stop dropping
 
    # final fallback: semantic-only, no hard filters
    return await qdrant.search(query, filters=None)

This matches what the agentic-RAG literature finds: comparisons of agentic RAG designs report that enhanced routing and query relaxation are more reliable than rigid retrieval in noisy domains. Real user queries are noisy — under-specified, over-specified, mismatched against sparse metadata — and a system that adapts its constraints degrades far more gracefully than one that fails hard.

Never let a filter you added silently return nothing — relax it, retry, and fall back to semantic search before you ever show an empty page.

Related: more writing.

Frequently asked

Why not just return 'no results'?
Most zero-result cases come from over-narrow hard filters on sparse fields, not a genuine absence of relevant items. Relax and retry first.
Which filters should I relax first?
Drop the least intent-critical filters first — sparse nice-to-have attributes and wide numeric ranges — keeping core intent and safety filters (like dietary constraints) longest.
How do I tell a too-narrow filter from a genuine no-match?
Re-run the query with no hard filters. If results appear, the filters were the problem; if it's still empty, it's a genuine no-match worth surfacing to the user.
What's the final fallback?
A broader semantic-only query with all hard filters removed, so the user gets a useful if less precise result set instead of an empty page.
ShareXLinkedIn

Keep reading

Get new essays by email

Occasional field notes on production RAG and LLM systems. No spam, unsubscribe anytime.

Written by Pradhumn Gupta.

If this was useful, I share more on LinkedIn.