Metadata Filtering for Semantic Search
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
GINindex 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.