Metadata Filtering for Semantic Search

Ma Mahalakshmi V Updated 16 Sep 2026
7 min read

Metadata Filtering for Semantic Search

Pure semantic search answers "what is most similar in meaning to this query?" Real search features almost always need to answer a more specific question: "what is most similar in meaning to this query, among documents that also satisfy these exact conditions?" — only articles published this year, only tickets assigned to a given team, only products in stock. This lesson covers combining vector similarity with structured (metadata) filtering, why the combination is harder to get right than either technique alone, and how to implement it correctly.

Why Filtering and Similarity Search Need to Work Together

Semantic similarity and structured filtering solve different problems, and conflating them causes real bugs:

  • Similarity search cannot express exact conditions reliably. Embedding the phrase "articles from 2026" into a vector and hoping it matches documents dated 2026 is unreliable — dates, categories, and IDs are exactly the kind of information embeddings represent poorly, as covered in Lesson 1. Reaching for cosine similarity to approximate an exact filter produces inconsistent, hard-to-debug results.
  • Structured filtering alone cannot express meaning-based relevance. A WHERE category = 'returns' clause narrows the candidate set correctly, but does nothing to rank those candidates by how relevant they are to a free-text query.

The correct pattern is to use each technique for what it is good at: filter on structured fields with exact conditions, and rank the filtered set by semantic similarity.

Pre-Filtering vs. Post-Filtering

There are two ways to combine a filter with a similarity search, and they behave very differently.

Post-filtering: run the similarity search first, get the top-K results, then discard any that fail the filter.

def search_then_filter(engine, query: str, metadata_filter, over_fetch: int = 20, top_k: int = 5):
    """Naive post-filtering: search broadly, then discard non-matching results."""
    candidates = engine.search(query, top_k=over_fetch)
    filtered = [(doc, score) for doc, score in candidates if metadata_filter(doc)]
    return filtered[:top_k]

Why this is risky: if the filter is restrictive (say, only 2% of documents match it) and the semantic search's initial top-K happens not to include many of those matching documents, post-filtering can return far fewer than top_k results — or none at all — even though plenty of relevant, filter-matching documents exist elsewhere in the full collection. Increasing over_fetch reduces this risk but never eliminates it and wastes work fetching and scoring documents that get thrown away.

Pre-filtering: apply the structured filter first, narrowing the candidate set, and only then rank the remaining documents by similarity.

SELECT external_id, content, 1 - (embedding <=> %(query_vector)s) AS similarity
FROM documents
WHERE metadata->>'category' = 'returns'
  AND (metadata->>'published_at')::date >= '2026-01-01'
ORDER BY embedding <=> %(query_vector)s
LIMIT 5;

Why pre-filtering is the correct default: the database applies the exact structured conditions first, guaranteeing every candidate considered for ranking actually satisfies them, and then only has to rank the (typically much smaller) filtered set — this is both more correct and usually faster than scoring the entire collection and filtering afterward. Post-filtering is only reasonable when there is no way to push the filter into the same query as the similarity search (for example, the filter depends on data that lives in a separate, unindexed system), and even then it should be treated as a fallback, not a default design.

Filtering on JSONB Metadata in PostgreSQL

Continuing with the schema from Lesson 6, metadata is stored as JSONB, which supports direct filtering:

-- Exact match on a field
WHERE metadata->>'category' = 'returns'

-- Filter on a value inside a nested object
WHERE metadata->'author'->>'team' = 'support'

-- Filter where a field is one of several values
WHERE metadata->>'category' = ANY (ARRAY['returns', 'shipping'])

-- Filter on a numeric or date value (JSONB stores everything as text/JSON,
-- so an explicit cast is required for numeric/date comparisons)
WHERE (metadata->>'published_at')::date >= '2026-01-01'

Note: JSONB operator syntax (->, ->>, casting behavior) is standard PostgreSQL, but if using a different database or a dedicated vector database's metadata-filtering syntax, the operators and casting rules will differ. Confirm against the specific system's current documentation.

Why does ->>'field' require an explicit cast for numeric or date comparisons? The ->> operator always returns a JSONB value as text. Comparing text '150' to text '99' with a plain > would compare them alphabetically, not numerically ('150' < '99' as strings, which is wrong for numbers). Casting with ::numeric or ::date tells PostgreSQL to interpret the extracted value as that type before comparing, producing a correct numeric or chronological comparison.

Composing Filters in Python

Real applications build filters dynamically based on user input, so it helps to construct the WHERE clause and its parameters programmatically rather than string-formatting SQL by hand (which risks SQL injection).

def build_filter_clause(filters: dict) -> tuple[str, list]:
    """Build a parameterized SQL WHERE clause from a filter dict.

    Supported filter dict shape (kept intentionally small and explicit):
      {"category": "returns", "min_published_at": "2026-01-01"}
    Returns (clause_sql, params) where clause_sql uses %s placeholders.
    """
    clauses = []
    params = []

    if "category" in filters:
        clauses.append("metadata->>'category' = %s")
        params.append(filters["category"])

    if "min_published_at" in filters:
        clauses.append("(metadata->>'published_at')::date >= %s")
        params.append(filters["min_published_at"])

    if not clauses:
        return "TRUE", []
    return " AND ".join(clauses), params


def test_build_filter_clause():
    clause, params = build_filter_clause({"category": "returns", "min_published_at": "2026-01-01"})
    assert "metadata->>'category' = %s" in clause
    assert params == ["returns", "2026-01-01"]

    empty_clause, empty_params = build_filter_clause({})
    assert empty_clause == "TRUE"
    assert empty_params == []

    print("PASS: build_filter_clause produces parameterized clauses")


test_build_filter_clause()

Why build the clause with placeholders (%s) and a separate params list, instead of formatting values directly into the SQL string? Directly interpolating user-supplied values into a SQL string (f"... = '{filters['category']}'") is a SQL injection vulnerability — a malicious or malformed value could break out of the intended string and execute arbitrary SQL. Parameterized queries, where the database driver substitutes values safely, close that vulnerability entirely and are considered a baseline security requirement any time user input reaches a SQL query, not an optional hardening step. The "TRUE" fallback for an empty filter dict keeps the generated SQL syntactically valid (WHERE TRUE) when no filters are supplied, rather than requiring special-case handling at every call site.

Testing Filtering Logic Without a Real Database

The dependency-injection testing pattern applies here too — filter construction logic (like build_filter_clause above) can be tested directly since it is pure Python with no database dependency. Testing the combination of filtering and ranking end-to-end calls for a fake in-memory stand-in rather than a real database connection:

class FakeDocument:
    def __init__(self, doc_id, category, embedding):
        self.doc_id = doc_id
        self.category = category
        self.embedding = embedding


def fake_filtered_search(documents, query_vector, category, top_k=5):
    """Mimics pre-filtered similarity search against an in-memory list."""
    def cosine(a, b):
        dot = sum(x * y for x, y in zip(a, b))
        return dot  # vectors below are pre-normalized for this test

    filtered = [d for d in documents if d.category == category]
    scored = [(d, cosine(query_vector, d.embedding)) for d in filtered]
    scored.sort(key=lambda pair: pair[1], reverse=True)
    return scored[:top_k]


def test_fake_filtered_search_excludes_wrong_category():
    docs = [
        FakeDocument("a", "returns", [1.0, 0.0]),
        FakeDocument("b", "shipping", [0.99, 0.01]),  # very similar, wrong category
        FakeDocument("c", "returns", [0.8, 0.2]),
    ]
    results = fake_filtered_search(docs, query_vector=[1.0, 0.0], category="returns")
    result_ids = [doc.doc_id for doc, _ in results]
    assert "b" not in result_ids
    assert result_ids[0] == "a"
    print("PASS: filtered search excludes non-matching category even when more similar")


test_fake_filtered_search_excludes_wrong_category()

This test makes the pre-filtering behavior explicit: document "b" is the most similar vector to the query, but it belongs to the wrong category, so a correct pre-filtered search must exclude it entirely rather than merely ranking it lower — exactly the property that distinguishes pre-filtering from post-filtering discussed earlier.

Common Mistakes

  • Using post-filtering as the default instead of pushing filters into the database query. As shown above, this risks returning fewer results than requested, or none, when the filter is selective.
  • Comparing JSONB text values numerically without casting. This silently produces wrong orderings (string comparison instead of numeric/date comparison) rather than an obvious error.
  • String-formatting user input directly into SQL filter clauses. This is a SQL injection risk; always use parameterized queries as shown in build_filter_clause.

Best Practices

  • Prefer pre-filtering (filter, then rank) over post-filtering (rank, then discard) whenever the filter can be expressed in the same query as the similarity search.
  • Add database indexes on frequently filtered metadata fields (a B-tree index on an extracted JSONB field, or a GIN index for more general JSONB queries) so filtering stays fast as the table grows — this is a standard database concern, not specific to vector search, but easy to forget once attention is on the embedding column.
  • Keep filter construction logic separate, parameterized, and independently testable, as shown with build_filter_clause, rather than building ad hoc SQL strings inline wherever a search is triggered.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.out Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Mem Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources
Ask about this post
AI Ask about this post

Ask questions about Metadata Filtering for Semantic Search and get answers drawn from it.

Signed-in readers only.