Module 7: Production with Pinecone — the migration from "working demo" to "24/7 service"
Metadata Filtering in Pinecone
Capsule description
Up to this point, your RAG system on Pinecone isolates by tenant using namespaces and runs on scalable serverless indexes. But production users rarely want "everything the workspace knows": they want recent tutorials, not old announcements. They want policies tagged legal, not public FAQs. They want documents created after the last audit.
That selection layer is what native metadata filtering solves: the filter parameter that Pinecone evaluates during the search, before computing the final similarity. It's the robust, performant equivalent of the where you already know from ChromaDB, but running on managed infrastructure.
In this capsule you're going to build expressive production filters, validate payloads before indexing (because one misspelled field breaks queries silently), combine namespace + filter as the canonical multi-tenant pattern, and design fallbacks to avoid the "zero results" that destroys the user experience.
By the end you'll have a maintainable filter layer that isn't a dictionary growing out of control, but a disciplined builder with schema validation and documented behavior.
The real problem: fragile filters in production
Let's look at what metadata filtering looks like in poorly thought-out code:
# Anti-pattern: filters built ad-hoc in every handler
def handle_search(query, filters):
vector = embed(query)
pinecone_filter = {}
if "type" in filters:
pinecone_filter["type"] = filters["type"]
if "tags" in filters:
pinecone_filter["tags"] = filters["tags"]
if "date" in filters:
pinecone_filter["created_at"] = filters["date"]
return index.query(vector=vector, filter=pinecone_filter, top_k=10)
Three problems you're going to live through:
- No explicit operators:
{"type": "tutorial"}works as$eq, but{"tags": ["api", "auth"]}does not automatically work as$in. Pinecone will return unexpected matches, or none at all. - No validation: if
filters["date"]comes in as an ISO string instead of a Unix timestamp, the filter passes but doesn't compare correctly, and you get silence. - No reuse: every endpoint builds its own dictionary and you duplicate the logic with subtle divergences.
The solution is a centralized, validated, tested builder that can evolve without breaking the handlers.
Filter operators in Pinecone
Pinecone supports a MongoDB-like subset of operators over indexed metadata:
| Operator | Field type | Use |
|---|---|---|
$eq | string, number, bool | Exact equality |
$ne | string, number, bool | Different from |
$gt, $gte, $lt, $lte | number | Numeric range |
$in | string, number | Belongs to a list |
$nin | string, number | Does not belong to a list |
$and, $or | composition | Boolean logic |
$exists | any | The field is present |
# An example combining several operators
filter_expr = {
"$and": [
{"type": {"$in": ["tutorial", "reference"]}},
{"created_at": {"$gte": 1735689600}},
{"deprecated": {"$ne": True}},
]
}
An important point: the fields you filter on have to exist in metadata at upsert time. Pinecone doesn't infer fields, doesn't allow joins between vectors, and missing fields simply don't match filters that reference them.
Metadata validation with Pydantic
Before indexing, define an explicit schema. This prevents 80% of production filtering bugs:
from pydantic import BaseModel, Field, field_validator
from typing import Literal
DocType = Literal["tutorial", "reference", "policy", "faq", "announcement"]
class DocMetadata(BaseModel):
doc_id: str
workspace_id: str
type: DocType
created_at: int
updated_at: int
tags: list[str] = Field(default_factory=list)
author: str | None = None
deprecated: bool = False
@field_validator("tags")
@classmethod
def normalize_tags(cls, v: list[str]) -> list[str]:
return sorted({tag.strip().lower() for tag in v if tag.strip()})
def to_pinecone(self) -> dict:
return self.model_dump(exclude_none=True)
Why this normalization matters:
tagsget lowercased and deduplicated before indexing. Without this, a query looking for"API"wouldn't find documents tagged"api".typeis constrained to aLiteral. If someone tries to indextype="Tutorial", Pydantic fails fast instead of silently creating a value no filter will ever find.created_atis anint(a Unix timestamp). If someone sends an ISO string, Pydantic rejects it.
# Correct usage: validate before the upsert
def upsert_document(index, vector, raw_metadata: dict, namespace: str):
metadata = DocMetadata(**raw_metadata) # fails if the schema is invalid
index.upsert(
vectors=[(metadata.doc_id, vector, metadata.to_pinecone())],
namespace=namespace,
)
A typed filter builder
With metadata validated at index time, now build the query's filter as a typed API instead of a free-form dictionary:
from dataclasses import dataclass, field
from typing import Literal
@dataclass
class FilterSpec:
doc_types: list[DocType] | None = None
tags_any: list[str] | None = None
tags_all: list[str] | None = None
min_created_at: int | None = None
max_created_at: int | None = None
exclude_deprecated: bool = True
author: str | None = None
def build(self) -> dict:
clauses: list[dict] = []
if self.doc_types:
clauses.append({"type": {"$in": list(self.doc_types)}})
if self.tags_any:
normalized = sorted({t.lower() for t in self.tags_any})
clauses.append({"tags": {"$in": normalized}})
if self.tags_all:
for tag in {t.lower() for t in self.tags_all}:
clauses.append({"tags": {"$in": [tag]}})
if self.min_created_at is not None:
clauses.append({"created_at": {"$gte": self.min_created_at}})
if self.max_created_at is not None:
clauses.append({"created_at": {"$lte": self.max_created_at}})
if self.exclude_deprecated:
clauses.append({"deprecated": {"$ne": True}})
if self.author:
clauses.append({"author": {"$eq": self.author}})
if not clauses:
return {}
if len(clauses) == 1:
return clauses[0]
return {"$and": clauses}
The advantages over a free-form dictionary:
- The handler receives a
FilterSpec, not adict[str, Any]. The IDE can autocomplete and mypy can type-check it. - The tag normalization lives in one place (
.lower()). exclude_deprecated=Trueby default keeps documents marked obsolete from contaminating the results.- When you add a new operator, every handler inherits the improvement without touching any code.
The canonical pattern: namespace + filter
Combine isolation (namespace) with selection (filter) in a single safe function:
def secure_filtered_query(
index,
vector: list[float],
tenant: TenantContext,
spec: FilterSpec,
top_k: int = 10,
) -> dict:
namespace = tenant_namespace(tenant.tenant_id)
filter_dict = spec.build()
return index.query(
vector=vector,
top_k=top_k,
namespace=namespace,
filter=filter_dict if filter_dict else None,
include_metadata=True,
)
Three guaranteed invariants:
- Absolute isolation: the namespace always comes from the validated
TenantContext, never from client input. - Correct composition: the filter lives inside the namespace; you never cross tenants.
- The filter is optional: if
spec.build()is empty, you sendfilter=None(Pinecone allows a query with no filter), avoiding a{}object that generates warnings in some clients.
Comparison: filtering in the app vs filtering in the engine
There are two places you can apply filters: in the engine (Pinecone evaluates before returning) or in the app (Pinecone returns N results and you filter afterwards). The choice affects latency, cost and security.
| Criterion | Filtering in the app (post-filter) | Filtering in Pinecone (native pre-filter) |
|---|---|---|
| Latency | Higher: fetch 100 to keep 10 | Lower: Pinecone returns only what's relevant |
| Data transfer | Larger: you pay for an unused payload | Smaller: only the useful matches travel |
| Security | Fragile: forgetting the filter leaks data | Robust: the engine guarantees the filter |
| Cost (read units) | Higher: an inflated top_k | Lower: a reasonable top_k |
| Valid cases | Logic with external dependencies (permissions in a DB) | 95% of business filters |
A practical rule: filter in the engine whenever the data lives in the metadata. Filter in the app only when you need data Pinecone doesn't have (dynamically computed permissions, joins with external tables).
Connection with the Production RAG project
Your Production RAG will expose a search API like this:
@app.post("/search")
async def search(req: SearchRequest, user: AuthUser = Depends(get_user)):
tenant = TenantContext(tenant_id=user.workspace_id, role=user.role)
spec = FilterSpec(
doc_types=req.types,
tags_any=req.tags,
min_created_at=req.since,
exclude_deprecated=True,
)
vector = await embed_query(req.query)
matches = secure_filtered_query(index, vector, tenant, spec, top_k=req.top_k)
return format_response(matches)
The critical part is that the client never builds raw filters. The API receives business fields (types, tags, since) and the FilterSpec translates them into Pinecone operators. This prevents malicious filter injection and keeps the contract stable when you change the backend.
Troubleshooting
Problem 1: "Filter expression invalid"
The cause: a JSON structure with an unsupported operator, or the wrong type (a string where a number is expected).
The fix: validate that min_created_at is an int, not a str. Use FilterSpec.build() instead of building dicts by hand. If you need to debug, print filter_dict before the query and compare it against the official operator reference.
Problem 2: "Inconsistent results from tags"
The cause: tags indexed without normalization ("API", "api", "Api" are treated as different).
The fix: apply DocMetadata.normalize_tags's field_validator at upsert time. For legacy data, run a re-indexing job that renormalizes and upserts over the same IDs.
Problem 3: "Zero results with a complex filter"
The cause: conditions that are too restrictive (an unusual combination of tags + date + type).
The fix: implement a graduated fallback. First try the full filter; if it doesn't return enough matches, relax the least critical dimension (typically tags).
def query_with_graduated_fallback(index, vector, tenant, spec, top_k=10, min_results=5):
primary = secure_filtered_query(index, vector, tenant, spec, top_k=top_k)
if len(primary["matches"]) >= min_results:
return primary
relaxed = FilterSpec(
doc_types=spec.doc_types,
min_created_at=spec.min_created_at,
exclude_deprecated=spec.exclude_deprecated,
)
return secure_filtered_query(index, vector, tenant, relaxed, top_k=top_k)
Problem 4: "Slow filters in large namespaces"
The cause: there are no secondary indexes for metadata in serverless; a field with high cardinality can degrade things.
The fix: keep the set of filterable fields small (5-10 fields maximum). For free-form fields (long descriptions), don't use them as filters: put them in the metadata but don't reference them in filter.
Problem 5: "Boolean fields don't work"
The cause: Pinecone serializes booleans correctly, but some old SDKs convert them into strings.
The fix: check the SDK version (pinecone>=5.0) and try an explicit {"deprecated": {"$eq": False}} instead of {"deprecated": False}.
Exercises
Exercise 1: A metadata schema for your domain
Define a Pydantic DocMetadata for a technical support domain that indexes: resolved tickets, KB articles, internal notes. It must include severity (low/medium/high/critical), an optional resolved_at, and product_line.
See the solution
from pydantic import BaseModel, Field, field_validator
from typing import Literal
Severity = Literal["low", "medium", "high", "critical"]
DocType = Literal["ticket", "kb_article", "internal_note"]
class SupportDocMetadata(BaseModel):
doc_id: str
workspace_id: str
type: DocType
severity: Severity
product_line: str
created_at: int
resolved_at: int | None = None
tags: list[str] = Field(default_factory=list)
@field_validator("tags")
@classmethod
def normalize_tags(cls, v: list[str]) -> list[str]:
return sorted({t.strip().lower() for t in v if t.strip()})
@field_validator("product_line")
@classmethod
def normalize_product(cls, v: str) -> str:
return v.strip().lower().replace(" ", "_")
The explanation: the Literal on severity and type prevents typos. product_line gets normalized so "Mobile App" and "mobile_app" match. An optional resolved_at lets you distinguish open tickets.
Exercise 2: A filter for "open critical tickets from the last month"
Build a FilterSpec that returns tickets of severity critical or high, with no resolved_at, created in the last 30 days.
See the solution
import time
now = int(time.time())
thirty_days_ago = now - 30 * 24 * 3600
filter_dict = {
"$and": [
{"type": {"$eq": "ticket"}},
{"severity": {"$in": ["critical", "high"]}},
{"created_at": {"$gte": thirty_days_ago}},
{"resolved_at": {"$exists": False}},
]
}
result = index.query(
vector=query_vector,
top_k=20,
namespace="workspace-acme",
filter=filter_dict,
include_metadata=True,
)
The explanation: $exists: False excludes resolved tickets without needing a redundant is_resolved field. It combines four constraints under an explicit $and.
Exercise 3: A builder with FilterSpec
Create a search_support function that takes business parameters and uses FilterSpec to produce the Pinecone filter.
See the solution
from dataclasses import dataclass
@dataclass
class SupportFilterSpec:
severities: list[Severity] | None = None
product_line: str | None = None
only_open: bool = False
min_created_at: int | None = None
def build(self) -> dict:
clauses = []
if self.severities:
clauses.append({"severity": {"$in": list(self.severities)}})
if self.product_line:
clauses.append({"product_line": {"$eq": self.product_line.lower().replace(" ", "_")}})
if self.only_open:
clauses.append({"resolved_at": {"$exists": False}})
if self.min_created_at is not None:
clauses.append({"created_at": {"$gte": self.min_created_at}})
if not clauses:
return {}
return clauses[0] if len(clauses) == 1 else {"$and": clauses}
def search_support(index, vector, tenant, spec: SupportFilterSpec, top_k=10):
return secure_filtered_query(index, vector, tenant, spec, top_k=top_k)
The explanation: the product_line normalization gets applied automatically in the builder. The handler never touches raw dicts.
Exercise 4: A graduated fallback for support
Implement a fallback that first searches with the full filters; if it doesn't find ≥3 results, it relaxes product_line; if it still finds nothing, it also relaxes severities.
See the solution
def search_with_graduated_relaxation(index, vector, tenant, spec, top_k=10, min_results=3):
primary = search_support(index, vector, tenant, spec, top_k)
if len(primary["matches"]) >= min_results:
return primary, "primary"
no_product = SupportFilterSpec(
severities=spec.severities,
only_open=spec.only_open,
min_created_at=spec.min_created_at,
)
second = search_support(index, vector, tenant, no_product, top_k)
if len(second["matches"]) >= min_results:
return second, "relaxed_product"
no_severity = SupportFilterSpec(
only_open=spec.only_open,
min_created_at=spec.min_created_at,
)
third = search_support(index, vector, tenant, no_severity, top_k)
return third, "relaxed_severity"
The explanation: you return the relaxation level alongside the results so the UI can tell the user that the filters were relaxed (better UX than silence).
Exercise 5: Auditing queries with filters
Implement structured logging that records every query with its applied filter, the number of results, and the tenant. Useful for debugging and analytics.
See the solution
import json
import logging
from time import perf_counter
logger = logging.getLogger("pinecone.query.audit")
def audited_query(index, vector, tenant, spec, top_k=10):
namespace = tenant_namespace(tenant.tenant_id)
filter_dict = spec.build()
start = perf_counter()
result = index.query(
vector=vector,
top_k=top_k,
namespace=namespace,
filter=filter_dict if filter_dict else None,
include_metadata=True,
)
elapsed_ms = (perf_counter() - start) * 1000
logger.info(json.dumps({
"event": "pinecone_query",
"tenant_id": tenant.tenant_id,
"namespace": namespace,
"filter": filter_dict,
"top_k": top_k,
"matches": len(result["matches"]),
"elapsed_ms": round(elapsed_ms, 2),
}))
return result
The explanation: structured JSON logs make it easy to analyze, in Datadog/CloudWatch, the filter patterns that return zero results (a signal of broken UX or an incomplete catalog).
Summary
- Pinecone supports MongoDB-style operators (
$eq,$in,$gte,$and,$exists, etc.) over metadata indexed at upsert time - Validate the metadata with Pydantic before the upsert; normalize the tags and constrain
typeto aLiteralto prevent silent bugs - Build the filters with a typed
FilterSpec, not with free-form dictionaries in every handler - The canonical multi-tenant pattern is
secure_filtered_query(tenant, spec): a namespace per tenant + a filter per business rule - Filter in the engine (the native pre-filter) except when you need data external to Pinecone
- Implement a graduated fallback to avoid "zero results" on restrictive queries
- Auditing with structured logging: every query records the tenant, the applied filter and the matches returned
Additional resources
- Filter by Metadata - Pinecone Docs - The officially supported operators.
- Pinecone Query API Reference - The query endpoint's complete parameters.
- Pydantic v2 Validators - Field validators for normalization.
- Metadata Best Practices - Official schema recommendations.
- RAG in Production - Pinecone Learn - Operational patterns.
Created: March 13, 2026
Version: 2.0