Módulo 7: Production con Pinecone — la migración de "demo funcional" a "servicio 24/7"
Metadata Filtering en Pinecone
Descripción de la cápsula
Hasta este momento, tu sistema RAG en Pinecone aísla por tenant usando namespaces y opera con índices serverless escalables. Pero los usuarios de producción rara vez quieren "todo el conocimiento del workspace": quieren tutoriales recientes, no anuncios viejos. Quieren políticas con etiqueta legal, no FAQs públicas. Quieren documentos creados después de la última auditoría.
Esa capa de selección la resuelve metadata filtering nativo: el parámetro filter que Pinecone evalúa durante la búsqueda, antes de calcular similitud final. Es el equivalente robusto y performante del where que ya conoces de ChromaDB, pero ejecutado en infraestructura gestionada.
En esta cápsula vas a construir filtros expresivos para producción, validar payloads antes de indexar (porque un campo mal escrito rompe queries de manera silenciosa), combinar namespace + filter como patrón canónico multi-tenant, y diseñar fallbacks para evitar el "cero resultados" que destruye la experiencia de usuario.
Al final tendrás una capa de filtros mantenible que no es un diccionario que crece sin control, sino un constructor disciplinado con validación de schema y comportamiento documentado.
El problema real: filtros frágiles en producción
Consideremos cómo se ve metadata filtering en código mal pensado:
# Anti-patrón: filtros construidos ad-hoc en cada 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)
Tres problemas que vas a vivir:
- Sin operadores explícitos:
{"type": "tutorial"}funciona como$eq, pero{"tags": ["api", "auth"]}no funciona como$inautomáticamente. Pinecone va a devolver matches inesperados o ninguno. - Sin validación: si
filters["date"]viene como string ISO en lugar de timestamp Unix, el filtro pasa pero no compara correctamente y obtienes silencio. - Sin reuso: cada endpoint construye su propio diccionario y duplicas lógica con sutiles divergencias.
La solución es un constructor centralizado, validado y testeado que pueda evolucionar sin romper handlers.
Operadores de filter en Pinecone
Pinecone soporta un subconjunto de operadores tipo MongoDB sobre metadata indexada:
| Operador | Tipo de campo | Uso |
|---|---|---|
$eq | string, number, bool | Igualdad exacta |
$ne | string, number, bool | Distinto de |
$gt, $gte, $lt, $lte | number | Rango numérico |
$in | string, number | Pertenece a lista |
$nin | string, number | No pertenece a lista |
$and, $or | composición | Lógica booleana |
$exists | cualquiera | Campo presente |
# Ejemplo combinando varios operadores
filter_expr = {
"$and": [
{"type": {"$in": ["tutorial", "reference"]}},
{"created_at": {"$gte": 1735689600}},
{"deprecated": {"$ne": True}},
]
}
Punto importante: los campos sobre los que filtras deben existir en metadata al momento de upsert. Pinecone no infiere campos, no permite joins entre vectores, y campos faltantes simplemente no matchean filtros que los referencian.
Validación de metadata con Pydantic
Antes de indexar, define un schema explícito. Esto previene el 80% de los bugs de filtering en producción:
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)
Por qué importa esta normalización:
tagsse convierten a minúsculas y sin duplicados antes de indexar. Sin esto, una query buscando"API"no encontraría documentos taggeados"api".typeestá restringido a unLiteral. Si alguien intenta indexartype="Tutorial", Pydantic falla rápido en lugar de crear silenciosamente un valor que ningún filtro va a encontrar.created_atesint(timestamp Unix). Si alguien manda string ISO, Pydantic lo rechaza.
# Uso correcto: validar antes de upsert
def upsert_document(index, vector, raw_metadata: dict, namespace: str):
metadata = DocMetadata(**raw_metadata) # falla si schema inválido
index.upsert(
vectors=[(metadata.doc_id, vector, metadata.to_pinecone())],
namespace=namespace,
)
Constructor de filtros tipado
Con metadata validada al indexar, ahora construye el filtro de query como una API tipada en lugar de un diccionario libre:
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}
Ventajas frente al diccionario libre:
- El handler recibe un
FilterSpec, no undict[str, Any]. El IDE puede autocompletar y mypy puede tipar. - La normalización de tags vive en un solo lugar (
.lower()). exclude_deprecated=Truepor defecto previene que documentos marcados como obsoletos contaminen resultados.- Cuando agregues un operador nuevo, todos los handlers heredan la mejora sin tocar código.
Patrón canónico: namespace + filter
Combina aislamiento (namespace) con selección (filter) en una sola función segura:
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,
)
Tres invariantes garantizadas:
- Aislamiento absoluto: el namespace siempre proviene del
TenantContextvalidado, nunca de input del cliente. - Composición correcta: el filtro vive dentro del namespace; nunca cruzas tenants.
- Filtro opcional: si
spec.build()está vacío, mandasfilter=None(Pinecone permite query sin filtro), evitando un objeto{}que en algunos clientes genera warnings.
Comparación: filtrar en app vs filtrar en motor
Hay dos lugares donde puedes aplicar filtros: en el motor (Pinecone evalúa antes de devolver) o en la app (Pinecone devuelve N resultados, tú filtras después). La elección impacta latencia, costo y seguridad.
| Criterio | Filtrar en app (post-filter) | Filtrar en Pinecone (pre-filter nativo) |
|---|---|---|
| Latencia | Más alta: traer 100 para quedarte con 10 | Más baja: Pinecone devuelve solo lo relevante |
| Transferencia de datos | Mayor: pagas por payload no usado | Menor: solo viajan matches útiles |
| Seguridad | Frágil: olvidar el filtro filtra datos | Robusta: el motor garantiza el filtro |
| Costo (read units) | Mayor: top_k inflado | Menor: top_k razonable |
| Casos válidos | Lógica con dependencias externas (permisos en BD) | 95% de los filtros de negocio |
Regla práctica: filtra en motor siempre que el dato esté en metadata. Filtra en app solo cuando necesitas datos que Pinecone no tiene (permisos calculados dinámicamente, joins con tablas externas).
Conexión con el proyecto Production RAG
Tu Production RAG va a exponer una API de búsqueda como esta:
@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)
Lo crítico es que el cliente nunca construye filtros raw. La API recibe campos de negocio (types, tags, since) y el FilterSpec los traduce a operadores Pinecone. Esto previene inyección de filtros maliciosos y mantiene el contrato estable cuando cambies el backend.
Troubleshooting
Problema 1: "Filter expression invalid"
Causa: estructura JSON con operador no soportado o tipo incorrecto (string donde se espera number).
Solución: valida que min_created_at sea int, no str. Usa FilterSpec.build() en lugar de construir dicts a mano. Si necesitas debuggear, imprime filter_dict antes del query y compáralo contra la referencia oficial de operadores.
Problema 2: "Resultados inconsistentes por tags"
Causa: tags indexados sin normalizar ("API", "api", "Api" se tratan como distintos).
Solución: aplica el field_validator de DocMetadata.normalize_tags en upsert. Para datos legacy, corre un job de re-indexado que renormalice y haga upsert sobre los mismos IDs.
Problema 3: "Cero resultados con filtro complejo"
Causa: condiciones demasiado restrictivas (combinación rara de tags + fecha + tipo).
Solución: implementa fallback graduado. Primero intenta el filtro completo; si no devuelve suficientes matches, relaja la dimensión menos crítica (típicamente 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)
Problema 4: "Filtros lentos en namespaces grandes"
Causa: no hay índices secundarios para metadata en serverless; cardinalidad alta de un campo puede degradar.
Solución: mantén el conjunto de campos filtrables pequeño (5-10 campos máximo). Para campos free-form (descripciones largas), no los uses como filtro: ponlos en metadata pero no los referencies en filter.
Problema 5: "Campos boolean no funcionan"
Causa: Pinecone serializa boolean correctamente pero algunos SDKs viejos los convierten en string.
Solución: verifica versión del SDK (pinecone>=5.0) y prueba con {"deprecated": {"$eq": False}} explícito en lugar de {"deprecated": False}.
Ejercicios
Ejercicio 1: Schema de metadata para tu dominio
Define un DocMetadata Pydantic para un dominio de soporte técnico que indexa: tickets resueltos, artículos de KB, notas internas. Debe incluir severity (low/medium/high/critical), resolved_at opcional y product_line.
Ver solución
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(" ", "_")
Explicación: Literal en severity y type previene typos. product_line se normaliza para que "Mobile App" y "mobile_app" matcheen. resolved_at opcional permite distinguir tickets abiertos.
Ejercicio 2: Filter para "tickets críticos abiertos del último mes"
Construye un FilterSpec que devuelva tickets de severidad critical o high, sin resolved_at, creados en los últimos 30 días.
Ver solución
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,
)
Explicación: $exists: False excluye tickets resueltos sin necesidad de un campo is_resolved redundante. Combina cuatro restricciones bajo $and explícito.
Ejercicio 3: Constructor con FilterSpec
Crea una función search_support que reciba parámetros de negocio y use FilterSpec para producir el filtro Pinecone.
Ver solución
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)
Explicación: la normalización de product_line se aplica automáticamente en el constructor. El handler nunca toca dicts raw.
Ejercicio 4: Fallback graduado para soporte
Implementa un fallback que primero busca con filtros completos; si no encuentra ≥3 resultados, relaja product_line; si aún no encuentra, relaja también severities.
Ver solución
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"
Explicación: retornas el nivel de relajación junto con los resultados para que la UI pueda comunicar al usuario que se relajaron filtros (mejor UX que silencio).
Ejercicio 5: Auditoría de queries con filtros
Implementa logging estructurado que registre cada query con su filter aplicado, número de resultados y tenant. Útil para debugging y analytics.
Ver solución
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
Explicación: logs estructurados en JSON facilitan analizar en Datadog/CloudWatch patrones de filtros que devuelven cero resultados (señal de UX rota o catálogo incompleto).
Resumen
- Pinecone soporta operadores tipo MongoDB (
$eq,$in,$gte,$and,$exists, etc.) sobre metadata indexada en upsert - Valida metadata con Pydantic antes de upsert; normaliza tags y restringe
typeaLiteralpara prevenir bugs silenciosos - Construye filtros con un
FilterSpectipado, no con diccionarios libres en cada handler - El patrón canónico multi-tenant es
secure_filtered_query(tenant, spec): namespace por tenant + filter por negocio - Filtra en motor (pre-filter nativo) salvo cuando necesites datos externos a Pinecone
- Implementa fallback graduado para evitar "cero resultados" en queries restrictivas
- Auditoría con logging estructurado: cada query registra tenant, filter aplicado y matches devueltos
Recursos adicionales
- Filter by Metadata - Pinecone Docs - Operadores oficiales soportados.
- Pinecone Query API Reference - Parámetros completos del endpoint query.
- Pydantic v2 Validators - Field validators para normalización.
- Metadata Best Practices - Recomendaciones oficiales de schema.
- RAG in Production - Pinecone Learn - Patrones operacionales.
Creado: Marzo 13, 2026
Versión: 2.0