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 where clauses dinámicamente desde inputs del usuario sin riesgo
  • ✅ Combinar filtros con AND/OR para queries complejas
  • ✅ Diferenciar where (metadata) de where_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 $contains y $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:

  1. Diseña la función build_secure_filter que construye where apropiado.
  2. Implementa la lógica de visibilidad por rol y permiso.
  3. 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 $and con 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.
  • where filtra metadata, where_document filtra 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

  1. ChromaDB — Metadata Filtering Operators — Sintaxis completa
  2. MongoDB Query Operators — ChromaDB hereda muchos operadores
  3. Pinecone — Filter Reference — Para comparación
  4. LangChain — Self Query Retriever — Construcción automática de filters
  5. OWASP Multi-Tenancy — Seguridad
  6. Pydantic for Validation — Validación de inputs

Tiempo estimado: 30-35 minutos Siguiente: 05-multi-tenant-isolation.md