Retrieval & Hybrid Search7 min readยท

How to Add Metadata Filters (Product, Locale, Brand) to Semantic Search

Your user types "trail runners under $150, in stock at the Portland warehouse, men's size 11" and your AI-powered search returns a $220 road shoe that is sold out.

Your user types "trail runners under $150, in stock at the Portland warehouse, men's size 11" and your AI-powered search returns a $220 road shoe that is sold out. The semantic model understood "trail runner" beautifully and ignored every constraint that actually mattered. That is the failure mode metadata filters exist to fix, and it is more common than most teams expect: a query carries a real structural component (a price ceiling, a locale, a brand, "in stock") that pure vector similarity cannot resolve, so the result comes back empty or wrong and the model hallucinates or hedges.

Sanity Context (previously Agent Context) treats this as one problem, not two. Sanity is the Content Operating System for the AI era, the intelligent backend for companies building AI content operations at scale, and its retrieval path runs your structured predicates and your semantic ranking inside a single GROQ query against the Content Lake. Filtering by product, locale, and brand is not a second system bolted onto search; it is the first clause of the same expression that ranks the results.

This guide walks through why filters break vector-only search, how to compose product, locale, and brand predicates with keyword and semantic scoring, and how index freshness quietly decides whether any of it keeps working in production.

Why pure vector search ignores your filters

Embedding-based search does one thing well and one thing only. You encode your content as vectors, encode the incoming query as a vector, and return the nearest neighbors. That works for fuzzy semantic intent: "find me something like a trail runner" lands on the right neighborhood of your catalog. It falls over the moment the query carries structure. "Trail runners under $150, in stock at the Portland warehouse, men's size 11" has three constraints that vector similarity does not respect, because cosine distance between embeddings has no concept of a price ceiling, a stock location, or a size. The model will happily return the most semantically similar shoe even if it costs $220 and is out of stock.

This matters because most products marketed as "AI-powered search" are pure embeddings and only that. They present semantic recall as the whole story, when in production the structural side of the query is where agents fail first. A version number, a category, a brand, or an "in stock" flag is not a nuance the ranker can smooth over; it is a predicate that either holds or does not. When it silently does not hold, your agent surfaces a confident wrong answer, which is worse than no answer at all in a support, commerce, or documentation context.

The fix is not a better model or a larger embedding dimension. It is teaching the retrieval step the shape of your data. Metadata filters are how you supply that structure. Instead of hoping similarity approximates "under $150," you state it as a constraint that must be true before anything gets ranked. Product, locale, and brand are exactly this kind of hard predicate: a French customer should never see a US-only SKU, and a query for one brand should never be diluted by a semantically similar competitor. Those are filters, not preferences, and they belong in the query, not in a post-processing pass that hopes for the best.

Metadata filters are structured predicates, not post-processing

The cleanest way to think about a metadata filter is as a predicate that has to hold before ranking begins. Category, price, locale, brand, stock location, and availability are constraints, not signals to weigh. You do not want "probably in the right locale" or "mostly under budget." You want the candidate set narrowed to exactly the documents that satisfy the constraint, and then you rank within that set. Bolting filtering on after a vector search inverts this: you retrieve the top-k by similarity, then throw away everything that fails the filter, and if all of your top-k happened to fail, you return nothing. That is the empty-result problem, and it is structural, not a tuning issue.

Pure structured query is the mirror image. GROQ, SQL, and GraphQL let you write the predicate and get exactly what you asked for, with no fuzziness. That is perfect for "brand equals Hoka and locale equals fr-FR and price is under 150," and useless the moment the user says "the cozy one" or "something like X," because those live in vibes, not fields. It also assumes the user knows precisely what they are looking for, which is rarely true when someone is exploring or chatting with an agent.

So the discipline is not filters versus semantics. It is filters first, as the gate, then semantic and keyword ranking inside the gate. In Sanity Context these are not two systems stitched together with glue code and a sync job. The structured predicate lives in the same GROQ query as the scoring pipeline, so the constraints that must hold and the relevance signals that decide order are one operation against the Content Lake. That single-expression model is why adding a locale or brand filter is a one-line change to the predicate block rather than a change to a separate search service that then has to be kept consistent with your source of truth.

Illustration for How to Add Metadata Filters (Product, Locale, Brand) to Semantic Search
Illustration for How to Add Metadata Filters (Product, Locale, Brand) to Semantic Search

Composing product, locale, and brand filters in a GROQ query

Here is the canonical hybrid shape, drawn straight from the retrieval path. The predicate block does the filtering, and the score pipeline does the ranking, in one expression:

*[ _type == "product" && category == $category && price < $maxPrice && stockLocation == $warehouse ] | score( boost([title] match text::query($queryText), 2), text::semanticSimilarity($queryText) ) | order(_score desc) [0...10]

Read it left to right. The `*[...]` block is your metadata filter: `_type == "product"` scopes the type, and each additional clause is a hard constraint that has to hold. To add the filters this article is about, you extend that same block. Locale and brand slot in exactly where category and price do, for example `&& locale == $locale && brand == $brand`, so a French storefront query for one brand never has to rank a US-only SKU or a competitor's product out of the result set, because those documents never enter it.

The score pipeline then ranks only the documents that survived the filter. `text::query()` is the BM25 keyword operator for literal matches, `text::semanticSimilarity()` is the embedding-based semantic score, and `score()` combines them into `_score`. The `boost()` around the title match weights literal title hits 2x, because a keyword landing in the title matters more than the same keyword buried in a description. Finally `order(_score desc)[0...10]` returns the ten best of what remains. This is the practical answer to "how do I add metadata filters to semantic search": you do not add them to a separate layer, you add them as clauses in the predicate block of the query that already does your semantic ranking. Product, locale, and brand are just more predicates, and because they gate before scoring, they cannot be diluted by similarity.

Why hybrid beats any single method

The reason to blend three signals rather than pick one is that no single method is enough, and this is measurable rather than a matter of taste. Anthropic's contextual retrieval research quantified the layers directly: contextual embeddings alone cut top-20 retrieval failures by 35 percent, adding contextual BM25 took that to 49 percent, and adding a reranking step on top brought it to 67 percent. The shape of that improvement is the whole argument. Each layer caught failures the others missed, and none of the three alone closed the gap. That is the case for hybrid over vector-only, keyword-only, or filter-only retrieval in one number.

Think of the earlier query decomposed. "Trail runners under $150 like a Hoka" is three retrieval signals running in parallel: a structured predicate (category equals shoes and price under 150), a BM25 lexical match on the keywords "trail" and "runner," and a vector similarity score from the embedding of the full query capturing "like a Hoka." Drop the predicate and you get out-of-budget results. Drop BM25 and you miss exact term matches that embeddings smooth over. Drop the vector score and "like a Hoka" becomes meaningless. Keyword search handles literal matches, embeddings handle semantic ranking, and structured predicates handle the filters that have to hold. Metadata filters are the layer this article is about, and they are load-bearing precisely because they are the one layer the other two cannot approximate. A price ceiling or a brand constraint is binary; there is no similarity score that gracefully degrades "under $150" into "close enough to $150." This is why filters belong in the predicate block as a gate, and why the surrounding signals do the nuanced work of ordering what passes through it.

What production data says about where agents actually fail

It is tempting to assume semantic search is the star of any agent retrieval system and filters are a supporting detail. When you look at how agents actually call the Sanity Context MCP endpoint, the heavy majority of calls are structured: GROQ queries and schema lookups, with compressed initial context behind that. Semantic search is a small slice of real traffic. The structural side of the query, the part that metadata filters serve, is where agents reach for the tool most often.

Embeddings adoption tells the same story. In Sanity Context, dataset embeddings are opt-in and off by default, and most projects shipping on the MCP endpoint never turn them on. That is not because embeddings are bad; it is because the structural side of the query is where agents fail first, so teams get most of the retrieval reliability they need from predicates and keyword matching before semantic ranking ever enters the picture. The blunt version, from the same production notes: vector search and "RAG" are one ingredient, not a strategy, and "we have embeddings" is not a retrieval strategy.

The practical takeaway for anyone building this is to invert the usual priority. Get your metadata filters right first, because a wrong locale, an out-of-stock product, or the wrong brand is a hard failure your users will notice immediately. Layer semantic ranking on top when the query genuinely needs fuzzy intent matching, and enable dataset embeddings when you have queries that live in vibes rather than fields. The failure that ships to production is almost never "the ranking was slightly off." It is "the constraint was ignored," and that is a filter problem, solved in the predicate block, not a model problem solved by a bigger embedding.

The freshness problem that decides whether filters keep working

You can build all of this without GROQ. PostgreSQL can do it with pgvector and full-text search. Elasticsearch can. Algolia is purpose-built for the structured-plus-relevance case. Pinecone plus a metadata filter layer can. Every one of those can express keyword, vector, and filter predicates together, and it would be dishonest to pretend otherwise. What none of them let you skip, and what quietly decides whether your metadata filters keep returning correct results, is the pipeline that keeps the search index fresh.

Here is the trap. When a product description updates, when a price changes, when an article publishes, or when a record is deleted, the index has to know, or your filters start lying. A `price < $maxPrice` predicate is only correct if the indexed price matches the real price. A `stockLocation == $warehouse` filter is only useful if the availability data is current. When your search index is a separate system from your source of truth, keeping the two in sync is your job: incremental indexing, re-embedding on change, deletion handling, eventual-consistency reasoning, and backfill for schema changes. That is a real project and a class of bug all its own, and freshness becomes a permanent line item on your roadmap rather than something you ever finish.

This is the structural difference with Sanity Context. Because dataset embeddings in the Content Lake are tied to the content, the index stays fresh by default. When the price changes or the record is deleted, the retrieval path reflects it within minutes without a separate re-embedding job to operate. You are not stitching a vector database to a content backend and writing glue to keep them consistent; the filter, the ranking, and the content that feeds both live in one system. That is what it means for Sanity to be the intelligent backend for AI content operations rather than one more index you have to babysit: the discipline of hybrid retrieval only pays off if the data underneath the filters is true, and freshness is where most self-assembled stacks quietly lose that guarantee.

Adding metadata filters to semantic search: native vs assembled

FeatureSanityPineconepgvector / PostgreSQLAlgolia
Product, locale, and brand filtersPredicates in the GROQ `*[...]` block gate before ranking, so a locale or brand clause is a one-line addition to the same query.Metadata filtering supported by pairing vectors with a filter layer; filters and content live in separate systems you keep in sync.SQL WHERE predicates handle product, locale, and brand cleanly alongside vector columns; you own the schema and the sync logic.Facet and attribute filtering is a core strength, applied inside a search index kept in sync with your source of truth.
Hybrid keyword + semantic in one queryNative: `text::query()` (BM25) and `text::semanticSimilarity()` blended with `score()` and `boost()` in a single GROQ expression.Vector similarity is native; BM25 keyword ranking requires a separate sparse index or an added service to blend results.Full-text search plus pgvector can be combined in SQL, but you write and tune the blending yourself across two index types.Keyword relevance plus AI ranking is built in; combining it with your own embeddings still runs inside a separate index.
Weighting signals (e.g. title 2x)`boost([title] match text::query($queryText), 2)` weights title hits inline in the same scoring pipeline.Boosting is done in application code or a reranker after retrieval, not inside the vector query itself.ts_rank weights and vector weights are combined manually in SQL; workable but hand-tuned per query.Attribute and custom ranking are configurable in the index settings rather than expressed per query.
Index freshness on content changeDataset embeddings are tied to content in the Content Lake, so price, publish, and deletion changes propagate within minutes by default.You build incremental indexing, re-embedding on change, and deletion handling; freshness is a permanent roadmap line item.Triggers or jobs must re-embed and reindex on every change; eventual-consistency reasoning is your responsibility.A separate index kept in sync with the source of truth; staleness is a sync-pipeline concern you operate.
Relationship to the content backendFiltering, ranking, and content are one system; Sanity Context exposes it through a hosted read-only MCP endpoint agents connect to.A dedicated vector database, deliberately separate from wherever your content actually lives.Lives beside or inside your app database; still distinct from a managed content backend and editorial workflow.A managed search service that sits downstream of your content store rather than inside it.
Governing agent accessEditors govern and stage agent behavior in the Studio with Content Releases, alongside Roles & Permissions and Audit logs.No editorial layer; access and governance are handled in your own application code.Database roles and app logic; no content-editor-facing governance for agent instructions.API keys and index-level access controls, without an editorial staging surface for agent behavior.