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:

  1. 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.
  2. 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.
  3. 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:

OperatorField typeUse
$eqstring, number, boolExact equality
$nestring, number, boolDifferent from
$gt, $gte, $lt, $ltenumberNumeric range
$instring, numberBelongs to a list
$ninstring, numberDoes not belong to a list
$and, $orcompositionBoolean logic
$existsanyThe 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:

  • tags get lowercased and deduplicated before indexing. Without this, a query looking for "API" wouldn't find documents tagged "api".
  • type is constrained to a Literal. If someone tries to index type="Tutorial", Pydantic fails fast instead of silently creating a value no filter will ever find.
  • created_at is an int (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 a dict[str, Any]. The IDE can autocomplete and mypy can type-check it.
  • The tag normalization lives in one place (.lower()).
  • exclude_deprecated=True by 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:

  1. Absolute isolation: the namespace always comes from the validated TenantContext, never from client input.
  2. Correct composition: the filter lives inside the namespace; you never cross tenants.
  3. The filter is optional: if spec.build() is empty, you send filter=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.

CriterionFiltering in the app (post-filter)Filtering in Pinecone (native pre-filter)
LatencyHigher: fetch 100 to keep 10Lower: Pinecone returns only what's relevant
Data transferLarger: you pay for an unused payloadSmaller: only the useful matches travel
SecurityFragile: forgetting the filter leaks dataRobust: the engine guarantees the filter
Cost (read units)Higher: an inflated top_kLower: a reasonable top_k
Valid casesLogic 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 type to a Literal to 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

  1. Filter by Metadata - Pinecone Docs - The officially supported operators.
  2. Pinecone Query API Reference - The query endpoint's complete parameters.
  3. Pydantic v2 Validators - Field validators for normalization.
  4. Metadata Best Practices - Official schema recommendations.
  5. RAG in Production - Pinecone Learn - Operational patterns.

Created: March 13, 2026
Version: 2.0