Metadata Filtering for Semantic Search

Ma Mahalakshmi V Updated 16 Sep 2026
7 min read ·Lesson 121 of 224

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.output_text 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 Memorizing Names 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 Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
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.