Módulo 6: Metadata Filtering — el componente que casi nadie implementa primero pero todos terminan necesitando
Cápsula 04: Where clauses en ChromaDB — la sintaxis completa de filtros
Descripción de la cápsula
Tienes el schema (cápsula 03) y la decisión de pre-filter (cápsula 02). Ahora viene la práctica: cómo se escriben los filtros en ChromaDB. La sintaxis es similar a MongoDB, lo cual es buena noticia si vienes de stack JS, pero tiene sus particularidades. Aprender los seis operadores y cómo combinarlos te va a evitar las dos trampas comunes: filtros demasiado restrictivos que devuelven cero resultados, y filtros demasiado laxos que no aprovechan el potencial de pre-filtering.
Esta cápsula te enseña la sintaxis completa con ejemplos prácticos, cómo construir filtros dinámicamente desde inputs del usuario (sin SQL injection), y los patrones para combinar múltiples filtros con AND/OR/NOT correctamente.
Al finalizar esta cápsula serás capaz de:
- ✅ Usar los seis operadores principales: igualdad,
$ne,$gt/$gte/$lt/$lte,$in,$and,$or - ✅ Construir
whereclauses dinámicamente desde inputs del usuario sin riesgo - ✅ Combinar filtros con AND/OR para queries complejas
- ✅ Diferenciar
where(metadata) dewhere_document(substring del texto) - ✅ Anticipar errores típicos: tipos incompatibles, claves no indexadas, AND implícito mal interpretado
- ✅ Implementar guardrails para queries multi-tenant que NO se pueden ejecutar sin
tenant_id
Tiempo estimado: 30-35 minutos
Los seis operadores que cubren 95% de los casos
Operador 1: igualdad (default)
collection.query(
query_texts=[query],
where={"tenant_id": "acme_corp"},
n_results=5,
)
Cuando pasas un valor sin operador explícito, ChromaDB asume =. Es el caso más común.
Operador 2: $ne (not equal)
where={"category": {"$ne": "marketing"}} # Todo menos marketing
Útil para excluir categorías específicas.
Operador 3: rangos numéricos ($gt, $gte, $lt, $lte)
import time
one_year_ago = int(time.time()) - (365 * 24 * 60 * 60)
where={"created_at": {"$gte": one_year_ago}} # docs del último año
Solo funciona con tipos numéricos. Por eso created_at debe ser int (Unix timestamp), no string.
Operador 4: $in y $nin (membership)
# Docs de las categorías support O docs
where={"category": {"$in": ["support", "docs"]}}
# Excluir múltiples categorías
where={"category": {"$nin": ["marketing", "internal"]}}
Más legible y a menudo más rápido que $or con múltiples igualdades.
Operador 5: $and (todos los filtros deben cumplirse)
# AND implícito (más simple para casos básicos)
where={
"tenant_id": "acme",
"language": "en",
"type": "tutorial",
}
# AND explícito (necesario para combinar con $or)
where={
"$and": [
{"tenant_id": "acme"},
{"language": "en"},
{"created_at": {"$gte": one_year_ago}},
]
}
Operador 6: $or (al menos uno debe cumplirse)
# Docs de soporte O documentación
where={
"$or": [
{"category": "support"},
{"category": "docs"},
]
}
# Combinación: tenant + (recientes O alta prioridad)
where={
"$and": [
{"tenant_id": "acme"},
{
"$or": [
{"created_at": {"$gte": one_year_ago}},
{"priority": "high"},
]
},
]
}
Patrón crítico: cuando combinas $or con tenant_id, el tenant_id SIEMPRE debe estar afuera del $or para no relajar el aislamiento.
Diferencia entre where y where_document
ChromaDB tiene dos tipos de filter:
# where: filtra por metadata
collection.query(
query_texts=[query],
where={"tenant_id": "acme"},
)
# where_document: filtra por substring en el texto del documento
collection.query(
query_texts=[query],
where_document={"$contains": "OAuth2PasswordBearer"},
)
# Combinar los dos
collection.query(
query_texts=[query],
where={"tenant_id": "acme"},
where_document={"$contains": "OAuth2"},
)
where_document es útil para casos donde necesitas match substring exacto en el texto. Pero ojo:
- Solo soporta
$containsy$not_contains. - NO es búsqueda full-text avanzada (BM25). Para eso, hybrid search (M05).
Construcción dinámica de filtros
En producción, los filters vienen de inputs del usuario o del contexto de la app:
def build_filter(
tenant_id: str, # obligatorio
category: str | None = None,
language: str | None = None,
days_ago: int | None = None,
tags: list[str] | None = None,
) -> dict:
"""Construye where clause dinámicamente, asegurando tenant_id."""
if not tenant_id:
raise ValueError("tenant_id es obligatorio")
conditions = [{"tenant_id": tenant_id}]
if category:
conditions.append({"category": category})
if language:
conditions.append({"language": language})
if days_ago:
cutoff_ts = int(time.time()) - (days_ago * 86400)
conditions.append({"created_at": {"$gte": cutoff_ts}})
if tags:
conditions.append({"tags": {"$in": tags}})
# Si solo hay un condition, devuélvelo solo (no envolver en $and innecesariamente)
if len(conditions) == 1:
return conditions[0]
return {"$and": conditions}
# Uso desde un endpoint
filter_dict = build_filter(
tenant_id=current_user.tenant_id,
category="auth",
language="en",
days_ago=180,
)
results = collection.query(
query_texts=[query],
where=filter_dict,
n_results=5,
)
Validación de inputs (no injection)
ChromaDB valida tipos internamente, pero conviene validar antes para mensajes de error claros:
ALLOWED_CATEGORIES = {"auth", "billing", "support", "docs"}
ALLOWED_LANGUAGES = {"en", "es", "pt"}
def validate_inputs(category: str | None, language: str | None) -> None:
if category and category not in ALLOWED_CATEGORIES:
raise ValueError(f"Invalid category: {category}. Allowed: {ALLOWED_CATEGORIES}")
if language and language not in ALLOWED_LANGUAGES:
raise ValueError(f"Invalid language: {language}. Allowed: {ALLOWED_LANGUAGES}")
Casos de uso típicos
Caso 1: filter para multi-tenant SaaS
where = {"tenant_id": user.tenant_id}
Aplicar siempre, sin excepciones. Cubierto en cápsula 05.
Caso 2: filter por recencia + relevancia
where = {
"$and": [
{"tenant_id": "acme"},
{"created_at": {"$gte": six_months_ago_ts}},
]
}
Para queries donde el contenido viejo es engañoso (APIs deprecated, info desactualizada).
Caso 3: filter por permisos del usuario
where = {
"$and": [
{"tenant_id": user.tenant_id},
{"$or": [
{"visibility": "public"},
{"visibility": "private", "owner_id": user.id},
]}
]
}
Solo docs públicos o privados del propio usuario.
Caso 4: filter por features habilitados
# Docs sobre features que el plan del usuario incluye
allowed_features = user.subscription.features # ej: ["api_v2", "analytics"]
where = {
"$and": [
{"tenant_id": user.tenant_id},
{"feature": {"$in": allowed_features}},
]
}
Caso 5: filter excluyendo deprecated
where = {
"$and": [
{"tenant_id": "acme"},
{"status": {"$nin": ["deprecated", "draft"]}},
]
}
Solo contenido aprobado y vigente.
Trampas y errores comunes
Trampa 1: AND implícito malinterpretado
El error: queremos (category=A AND lang=en) OR (category=B AND lang=es).
# ❌ Mal: AND implícito hace TODO con AND
where={
"$or": [
{"category": "A", "language": "en"}, # ← Esto es A AND en
{"category": "B", "language": "es"}, # ← Esto es B AND es
]
}
# Resultado: (A AND en) OR (B AND es). En este caso es lo que querías,
# PERO algunos pueden interpretar mal y poner los pares como AND a nivel raíz.
Cómo evitar confusión: usar $and explícito siempre que combines con $or:
where={
"$or": [
{"$and": [{"category": "A"}, {"language": "en"}]},
{"$and": [{"category": "B"}, {"language": "es"}]},
]
}
Trampa 2: rangos sobre strings
El error:
where={"created_at": {"$gte": "2024-01-01"}} # string
Síntoma: funciona por casualidad si los strings tienen ordenamiento lexicográfico (ej: ISO dates), pero falla con cualquier otra cosa.
Cómo prevenir: Unix timestamps int siempre.
Trampa 3: filter con clave que no existe en metadata
where={"nonexistent_field": "value"}
Síntoma: ChromaDB devuelve cero resultados sin error. Piensas que tu filter excluye todo, pero en realidad estás filtrando por un campo que no existe.
Cómo prevenir: validación pre-ingest fuerza schema consistente. Sin metadata válida, el doc no se indexa.
Trampa 4: tipos inconsistentes en metadata
# Doc 1: priority=1 (int)
# Doc 2: priority="1" (string)
where={"priority": {"$lte": 2}} # solo matchea Doc 1
Síntoma: filter parece funcionar pero pierde docs con tipos inconsistentes.
Cómo prevenir: Pydantic schema (cápsula 03) fuerza tipos al ingestar.
Trampa 5: $in con lista vacía
where={"category": {"$in": []}} # ← lista vacía
Síntoma: ChromaDB puede comportarse de forma inconsistente (algunas versiones devuelven todo, otras nada).
Cómo prevenir: validar antes:
if not allowed_categories:
# Decidir: rechazar o no aplicar filter
raise ValueError("allowed_categories no puede estar vacío")
where = {"category": {"$in": allowed_categories}}
Trampa 6: olvidar tenant_id en queries internas
El error: scripts de admin que hacen collection.query(query_texts=[q]) sin filter.
Síntoma: logs internos muestran datos cross-tenant. Auditoría falla.
Cómo prevenir: usar secure_query siempre (cápsula 02), incluso en scripts.
Ejercicio aplicado
Escenario: eres AI Engineer en una plataforma SaaS de gestión de proyectos. Datos:
- 100 tenants
- Cada tenant tiene tareas, comentarios, documentos
- Cada usuario tiene rol:
admin,member,viewer - Algunos docs son
private(solo creador),team(miembros del proyecto),tenant(todos en la empresa)
Endpoint API: /api/search recibe query del usuario.
Tu trabajo:
- Diseña la función
build_secure_filterque construyewhereapropiado. - Implementa la lógica de visibilidad por rol y permiso.
- Define los casos edge que necesitan testing.
Solución
# secure_filter.py
from typing import Optional
import time
def build_secure_filter(
user_id: str,
user_role: str,
tenant_id: str,
project_ids: list[str], # proyectos a los que el usuario pertenece
optional_filters: dict = None,
) -> dict:
"""
Construye where clause con seguridad multi-nivel:
- tenant_id obligatorio
- visibility filter según rol y permisos
- filtros opcionales se aplican como AND
"""
if not tenant_id or not user_id:
raise PermissionError("tenant_id y user_id son obligatorios")
# Visibility filter según rol
visibility_clauses = []
# 1. Docs públicos del tenant siempre visibles
visibility_clauses.append({"visibility": "tenant"})
# 2. Docs de team: solo si el user es miembro del proyecto
if project_ids:
visibility_clauses.append({
"$and": [
{"visibility": "team"},
{"project_id": {"$in": project_ids}},
]
})
# 3. Docs privados: solo del propio usuario
visibility_clauses.append({
"$and": [
{"visibility": "private"},
{"owner_id": user_id},
]
})
# 4. Si admin: ve también docs internal_admin
if user_role == "admin":
visibility_clauses.append({"visibility": "admin_only"})
# Combinar: tenant + (cualquier visibility válida) + filtros opcionales
base_clauses = [
{"tenant_id": tenant_id},
{"$or": visibility_clauses},
]
# Agregar filtros opcionales (categoría, fecha, etc.)
if optional_filters:
for key, value in optional_filters.items():
base_clauses.append({key: value})
return {"$and": base_clauses}
# Uso desde el endpoint
def search_endpoint(query: str, user, optional_filters: dict = None):
where = build_secure_filter(
user_id=user.id,
user_role=user.role,
tenant_id=user.tenant_id,
project_ids=user.project_ids,
optional_filters=optional_filters,
)
return collection.query(
query_texts=[query],
where=where,
n_results=10,
)
Casos edge a testear:
def test_security_filters():
# Test 1: usuario sin tenant_id → PermissionError
with pytest.raises(PermissionError):
build_secure_filter(user_id="x", user_role="member", tenant_id="", project_ids=[])
# Test 2: viewer NO ve docs admin_only
where = build_secure_filter("u1", "viewer", "tenant_a", project_ids=[])
# Verificar que no incluye visibility="admin_only"
# Test 3: member NO ve team docs de proyectos a los que NO pertenece
where = build_secure_filter("u1", "member", "tenant_a", project_ids=["proj_a"])
# Doc team de proj_b NO debería pasar el filter
# Test 4: admin SÍ ve admin_only
where = build_secure_filter("u1", "admin", "tenant_a", project_ids=[])
# Verificar admin_only en visibility_clauses
# Test 5: cross-tenant siempre falla
where = build_secure_filter("u1", "admin", "tenant_a", project_ids=[])
# Indexar docs en tenant_a y tenant_b. Ejecutar query con where.
# NINGÚN doc de tenant_b debe aparecer.
# Test 6: filtros opcionales NO bypassean tenant_id
where = build_secure_filter("u1", "member", "tenant_a", project_ids=[], optional_filters={"category": "auth"})
# Verificar tenant_id sigue presente
Beneficios de este patrón:
- No bypass por design: estructura
$andcon tenant_id como primer condition. - Visibility en una sola función: lógica de permisos centralizada, fácil de auditar.
- Tests cobertura ataques: cross-tenant, escalación de roles, filters bypass.
- Auditable: cada query puede loggearse con el filter usado.
Resumen y siguiente paso
Lo que aprendiste:
- Seis operadores cubren 95% de casos: igualdad,
$ne,$gt/$gte/$lt/$lte,$in/$nin,$and,$or. - AND implícito es OK para casos simples; AND/OR explícitos cuando combinas.
wherefiltra metadata,where_documentfiltra substrings del texto.- Construir filtros dinámicamente con función dedicada que valida tenant_id obligatorio.
- Casos típicos: multi-tenant, recency, permisos, features.
- Trampas: AND mal anidado, rangos en strings, claves inexistentes, tipos inconsistentes, lista vacía en
$in.
Checkpoint: antes de avanzar, deberías poder:
- Usar los seis operadores en queries reales.
- Construir filtros dinámicos validando inputs.
- Diseñar visibility filter para multi-rol con tests de seguridad.
Siguiente cápsula: 05 — Multi-tenant isolation.
Cubrimos los operadores. La cápsula 05 profundiza en el caso más crítico operacionalmente: aislamiento entre tenants. Vas a ver patterns de implementación, tests de aislamiento automáticos, y cómo prevenir el "leak silencioso" que solo se descubre cuando alguien reporta haber visto datos de otra empresa.
Recursos
- ChromaDB — Metadata Filtering Operators — Sintaxis completa
- MongoDB Query Operators — ChromaDB hereda muchos operadores
- Pinecone — Filter Reference — Para comparación
- LangChain — Self Query Retriever — Construcción automática de filters
- OWASP Multi-Tenancy — Seguridad
- Pydantic for Validation — Validación de inputs
Tiempo estimado: 30-35 minutos Siguiente: 05-multi-tenant-isolation.md