Módulo 6: Metadata Filtering — el componente que casi nadie implementa primero pero todos terminan necesitando

Cápsula 02: Pre-filter vs post-filter — la decisión arquitectónica que define todo el módulo

Descripción de la cápsula

Hay dos formas de aplicar metadata filtering. Suenan equivalentes — al final, ambas devuelven solo los documentos que matchean el filtro. Pero arquitectónicamente son completamente distintas, con consecuencias dramáticas en latencia, recall y seguridad. Esta cápsula es la decisión que define el resto del módulo: cuándo es seguro hacer post-filter (raras veces) y por qué pre-filter es el default obligatorio en producción.

El resumen ejecutivo lo puedes decir en una frase: pre-filter en 99% de los casos. Pero entender el "por qué" con datos te permite defender la decisión ante un Tech Lead, identificar los casos del 1% donde post-filter aplica, y diagnosticar problemas cuando alguien implemente post-filter sin saber.

Al finalizar esta cápsula serás capaz de:

  • ✅ Explicar la diferencia operativa entre pre-filter y post-filter en una vector DB
  • ✅ Cuantificar el impacto en latencia y recall con benchmarks reproducibles
  • ✅ Identificar los tres casos donde post-filter sí tiene sentido (raros)
  • ✅ Implementar guardrails que prohíben queries sin filtros obligatorios
  • ✅ Diseñar fallback escalonado que relaja filtros opcionales sin romper aislamiento
  • ✅ Anticipar el caso patológico: filtros que reducen el subset bajo n_results

Tiempo estimado: 25-30 minutos


El insight: dónde aplicas el filtro define qué tan rápido y seguro es

Visualmente:

                    PRE-FILTER (recomendado)

  Query + filter  ─→  Vector DB (motor)
                          │
                          ├─→ 1. Aplicar filter sobre el corpus
                          │    (reduce de 5M a 100K vectores)
                          │
                          ├─→ 2. Buscar similaridad sobre los 100K
                          │
                          └─→ 3. Devolver top-K
                              ↓
                          Resultados garantizados del subset filtrado


                    POST-FILTER (peligroso)

  Query (sin filter)  ─→  Vector DB (motor)
                              │
                              ├─→ 1. Buscar similaridad sobre los 5M
                              │
                              └─→ 2. Devolver top-100
                                  ↓
                              Aplicación filtra en código:
                              candidates = [d for d in top-100 if d.tenant_id == 'acme']
                                  ↓
                              Top-K (puede ser <K si pocos pasan el filtro)

Diferencias inmediatas:

  1. Pre-filter: vector DB hace el filtrado. Pre-filter consciente del índice.
  2. Post-filter: la app filtra después. La vector DB nunca supo del filtro.

Las consecuencias se acumulan en cada dimensión.


Comparación cuantitativa con benchmarks

# benchmark_filtering.py
import chromadb
import time
from chromadb.utils import embedding_functions
import os

openai_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.getenv("OPENAI_API_KEY"),
    model_name="text-embedding-3-small",
)
client = chromadb.PersistentClient(path="./chroma_filter_test")
collection = client.get_collection("multi_tenant_corpus", embedding_function=openai_ef)

# Asumimos un corpus de 100K docs distribuidos en 50 tenants
# tenant_id: ~2K docs cada uno

query = "How do I configure authentication?"
target_tenant = "acme_corp"


# Pre-filter
def search_pre_filter(query: str):
    start = time.perf_counter()
    results = collection.query(
        query_texts=[query],
        n_results=5,
        where={"tenant_id": target_tenant},  # ← filtro EN la query
    )
    elapsed = (time.perf_counter() - start) * 1000
    return elapsed, len(results["ids"][0])


# Post-filter
def search_post_filter(query: str):
    start = time.perf_counter()
    # Buscar más candidates para tener margen post-filtro
    results = collection.query(query_texts=[query], n_results=100)

    # Filtrar manualmente
    filtered = [
        (doc, meta)
        for doc, meta in zip(results["documents"][0], results["metadatas"][0])
        if meta.get("tenant_id") == target_tenant
    ][:5]
    elapsed = (time.perf_counter() - start) * 1000
    return elapsed, len(filtered)


# Ejecutar 50 veces y comparar
pre_latencies = []
pre_results_count = []
post_latencies = []
post_results_count = []

for _ in range(50):
    lat, n = search_pre_filter(query)
    pre_latencies.append(lat)
    pre_results_count.append(n)

    lat, n = search_post_filter(query)
    post_latencies.append(lat)
    post_results_count.append(n)


def percentile(arr, p):
    arr_sorted = sorted(arr)
    return arr_sorted[int(len(arr_sorted) * p / 100)]


print("Pre-filter:")
print(f"  Latencia p50: {percentile(pre_latencies, 50):.1f} ms")
print(f"  Latencia p95: {percentile(pre_latencies, 95):.1f} ms")
print(f"  Resultados promedio: {sum(pre_results_count) / len(pre_results_count):.1f}")

print("\nPost-filter:")
print(f"  Latencia p50: {percentile(post_latencies, 50):.1f} ms")
print(f"  Latencia p95: {percentile(post_latencies, 95):.1f} ms")
print(f"  Resultados promedio: {sum(post_results_count) / len(post_results_count):.1f}")

Output típico (corpus 100K, tenant con 2K docs):

Pre-filter:
  Latencia p50: 28 ms
  Latencia p95: 45 ms
  Resultados promedio: 5.0   ← siempre devuelve 5 (los hay en el subset)

Post-filter:
  Latencia p50: 185 ms
  Latencia p95: 245 ms
  Resultados promedio: 3.7   ← a veces <5 (no había suficientes en top-100)

Lecturas:

  1. Latencia 6x peor con post-filter. Vector DB busca en todo el corpus, después tiras el 98% de los resultados.
  2. Recall pobre con post-filter. Si solo 2 de los top-100 son del tenant, devuelves 2 docs en lugar de 5. Recall colapsa.
  3. Pre-filter es estable. Siempre devuelve n_results o cerca, latencia consistente.

El caso patológico: subset muy pequeño

Si el filtro deja un subset diminuto comparado con n_results, ambas técnicas pierden:

Corpus:           100K docs
Filtro:           {"tenant_id": "tiny_tenant", "type": "tutorial", "language": "es"}
Subset matching:  20 docs

Pre-filter top-K=5:  busca en los 20 → top-5 OK (devuelve 5)
Pre-filter top-K=50: busca en los 20 → top-20 (no hay 50 en el subset)

Post-filter top-100: busca en 100K, los 20 del subset rara vez aparecen en top-100
                     porque hay 99,980 docs compitiendo
                     resultado: 1-3 docs (no 5)

Conclusión operacional: si el subset es pequeño y el filter es restrictivo, pre-filter funciona perfecto, post-filter falla catastróficamente.


Los tres casos donde post-filter SÍ aplica

Hay situaciones reales donde post-filter es la opción correcta. Son raras pero legítimas:

Caso 1: el motor no soporta el filter que necesitas

Algunos backends de vector DB tienen filters limitados. Por ejemplo, si necesitas filtrar por una expresión compleja (SUBSTRING(metadata.description, 1, 5) = "ABC"), y tu motor solo soporta igualdad y rangos, vas a tener que post-filtrar.

# El motor no permite SUBSTRING
results = collection.query(query_texts=[q], n_results=200)

# Post-filter con lógica custom
filtered = [
    doc for doc in results["documents"][0]
    if doc.startswith("ABC")  # condición compleja
]

Cuándo aplica: raramente. La mayoría de vector DBs modernas (ChromaDB, Pinecone, Weaviate, Qdrant) soportan filters expresivos.

Caso 2: filter con resultado dinámico que cambia por request

A veces el filter depende de información que solo conoces DESPUÉS del retrieval. Ejemplo: descartar docs basándote en algo calculado sobre el cuerpo del documento.

# Necesitas calcular algo del contenido para decidir si filtrar
results = collection.query(query_texts=[q], n_results=50)

filtered = []
for doc in results["documents"][0]:
    if compute_complex_score(doc, user_context) > threshold:
        filtered.append(doc)

return filtered[:5]

Cuándo aplica: ranking secundario o filtros dinámicos. Aún así, considerar si se puede pre-computar y guardar como metadata.

Caso 3: prototipo con dataset chico (<10K)

Para datasets muy pequeños, la latencia extra de post-filter es despreciable, y permite probar lógica de filter rápidamente sin tocar el schema.

# Prototipo: 5K docs, latencia post-filter es ~50ms
# No vale el esfuerzo de re-indexar para agregar metadata estructurada
results = collection.query(query_texts=[q], n_results=100)
filtered = [...]  # filter manual

Cuándo aplica: solo en exploración. Para producción, siempre migrar a pre-filter.


Guardrails: convertir el aislamiento en regla de plataforma

En sistemas multi-tenant, el filter por tenant_id debe ser obligatorio — no opcional, no convención. Implementar guardrails:

# secure_query.py
class TenantIsolationError(Exception):
    pass


def secure_query(
    collection,
    query_text: str,
    tenant_id: str,           # ← obligatorio en la signature
    additional_filters: dict = None,
    n_results: int = 5,
):
    """
    Wrapper seguro que NUNCA permite queries sin tenant_id.
    Cualquier código que llame a secure_query DEBE pasar tenant_id.
    """
    if not tenant_id:
        raise TenantIsolationError(
            "tenant_id es obligatorio. No se permite buscar sin filtro de tenant."
        )

    # Construir where con tenant_id obligatorio + filters opcionales
    where = {"tenant_id": tenant_id}
    if additional_filters:
        where = {"$and": [
            {"tenant_id": tenant_id},
            additional_filters,
        ]}

    return collection.query(
        query_texts=[query_text],
        n_results=n_results,
        where=where,
    )


# Uso correcto
results = secure_query(
    collection,
    "How do I authenticate?",
    tenant_id=current_user.tenant_id,
    additional_filters={"language": "en"},
)


# Uso incorrecto: TenantIsolationError
results = secure_query(collection, "How do I authenticate?", tenant_id=None)
# raises TenantIsolationError

Bonus: code review enforcement. Agregar un linter custom que detecte llamadas a collection.query(...) directas sin pasar por secure_query(). Ejemplo con pre-commit hook:

# pre_commit_check.py
import re
import sys
from pathlib import Path

DIRECT_QUERY_PATTERN = re.compile(r'collection\.query\(')
SAFE_FUNCTION = "secure_query"

for py_file in Path("src").rglob("*.py"):
    content = py_file.read_text()
    if DIRECT_QUERY_PATTERN.search(content) and SAFE_FUNCTION not in content:
        print(f"⚠️  Direct collection.query() in {py_file}. Use secure_query() instead.")
        sys.exit(1)

Esto previene que un nuevo dev haga collection.query(...) sin filter por accidente.


Fallback escalonado: relajar filtros opcionales sin romper seguridad

A veces tu filter es muy restrictivo y no devuelve suficientes resultados. La solución es relajar progresivamente, pero NUNCA relajar el filter de seguridad.

def search_with_progressive_fallback(
    collection,
    query: str,
    tenant_id: str,           # ← OBLIGATORIO, nunca se relaja
    optional_filters: dict,
    n_results: int = 5,
    min_results: int = 3,
):
    """
    Busca con fallback escalonado. Relaja filtros opcionales si no hay
    suficientes resultados, pero NUNCA relaja tenant_id.
    """
    # Orden de relajación: del más específico al más general
    fallback_order = [
        optional_filters,                                          # full
        {k: v for k, v in optional_filters.items() if k != "tags"},  # sin tags
        {k: v for k, v in optional_filters.items() if k not in {"tags", "category"}},
        {},                                                          # solo tenant_id
    ]

    for filter_set in fallback_order:
        # Construir where: tenant_id + filtros remanentes
        where = {"tenant_id": tenant_id}
        if filter_set:
            where = {"$and": [
                {"tenant_id": tenant_id},
                filter_set,
            ]}

        results = collection.query(
            query_texts=[query],
            n_results=n_results,
            where=where,
        )

        if len(results["ids"][0]) >= min_results:
            return results, filter_set  # devuelve también qué filters quedaron

    # Si ni con tenant_id solo encuentra, devuelve vacío
    return {"ids": [[]], "documents": [[]]}, {}


# Uso
results, used_filters = search_with_progressive_fallback(
    collection,
    "OAuth2 setup",
    tenant_id="acme",
    optional_filters={"category": "auth", "tags": "oauth2", "language": "es"},
)

print(f"Resultados: {len(results['ids'][0])}")
print(f"Filters usados al final: {used_filters}")
# Ej: si tags="oauth2" no había, filters usados = {"category": "auth", "language": "es"}

Patrón clave: tenant_id siempre va en TODOS los pasos del fallback. Sin excepciones. Si un nuevo dev intenta agregar un fallback que lo elimina, el code review debe rechazarlo.


Trampas y errores comunes

Trampa 1: post-filter como solución "porque es más fácil"

El error: equipo nuevo no quiere lidiar con metadata schema, hace post-filter.

Síntoma: latencia 5-10x peor que con pre-filter. Recall variable e impredecible. A escala, el sistema colapsa.

Cómo prevenir: pre-filter es estándar de la industria. Post-filter solo en casos excepcionales documentados.

Trampa 2: filter ultra-restrictivo sin fallback

El error:

where = {"tenant_id": "X", "category": "Y", "language": "Z", "version": "v3", "author": "John"}

Síntoma: queries que combinaban todos los filtros devuelven cero resultados. Usuario ve "no encontré nada" cuando obviamente hay docs sobre el tema.

Cómo prevenir: fallback escalonado (visto arriba). Solo tenant_id obligatorio, los demás se relajan si hace falta.

Trampa 3: olvidar tenant_id en queries internas

El error: scripts de admin / cron jobs que hacen queries directas sin pasar por secure_query. Asumen "soy admin, no necesito filtrar".

Síntoma: logs de admin tienen acceso accidental a datos cruzados de tenants. Auditoría falla.

Cómo prevenir: secure_query es obligatorio en TODO el código, incluso scripts internos. Si necesitas query cross-tenant, usar una función explícita cross_tenant_admin_query que requiera permisos elevados.

Trampa 4: post-filter con n_results igual al objetivo

El error:

# Quieres 5 docs después del filter
results = collection.query(query_texts=[q], n_results=5)  # ❌ solo 5 candidates
filtered = [d for d in results if d.tenant == "X"]  # puedes terminar con 0

Síntoma: muy frecuentemente devuelve <5 docs porque los top-5 no eran del tenant correcto.

Cómo prevenir: si tienes que hacer post-filter (caso raro), n_results = top_k * 20 para tener margen. Pero mejor: pre-filter.

Trampa 5: validar tenant_id en string mal sanitizado

El error:

where = {"tenant_id": request.user.tenant_id}  # tenant_id viene de input del usuario

Si tenant_id es controlable por el usuario (ej: parameter de URL), un atacante puede cambiarlo.

Cómo prevenir: tenant_id debe venir del backend autenticado, NUNCA de input del usuario. Token JWT validado, sesión, etc.

Trampa 6: pre-filter pero metadata mal indexada

El error: activas pre-filter, pero el campo tenant_id no está en el índice. ChromaDB lo busca de forma lineal — más lento que post-filter.

Síntoma: latencia con pre-filter es peor que sin filter.

Cómo prevenir: verificar que ChromaDB/Pinecone realmente indexa los campos de metadata que usas en filters. Cubierto en cápsula 03 (schema design).


Ejercicio aplicado

Escenario: eres AI Engineer en una empresa SaaS de soporte. Stack:

  • 50 clientes (tenants)
  • Cada cliente tiene 500-50K documentos (algunos chicos, algunos grandes)
  • Sistema actual: post-filter manual en código de la app
  • Síntomas:
    • Latencia p95 = 320ms
    • Tickets reportando "vi un documento de otra empresa en mi resultado"
    • Quejas: "el bot no encuentra cosas que sé que tengo"

Tu trabajo:

  1. Diagnostica los problemas y conecta cada uno con el approach actual.
  2. Diseña la migración a pre-filter.
  3. Estima impacto de latencia y seguridad.
Solución

1. Diagnóstico

SíntomaCausa raíz
Latencia 320ms p95Post-filter busca en corpus completo (1.5M docs), descarta 99% después. Pre-filter buscaría en ~30K docs por tenant promedio.
"Documento de otra empresa"Post-filter es propenso a bugs. Si el filtro post falla por código mal escrito, salen docs ajenos al tenant. Pre-filter en el motor garantiza aislamiento.
"No encuentra cosas que tengo"Post-filter con n_results=100 puede no incluir todos los docs del tenant relevantes. Si los top-100 globales son de otros tenants, los de mi tenant quedan fuera. Recall pobre.

Los tres síntomas tienen la misma causa raíz: post-filter sobre corpus mezclado.

2. Plan de migración a pre-filter

# Paso 1: verificar que tenant_id está indexado en ChromaDB
# (cápsula 03 cubre cómo verificar esto)
all_metadatas = collection.get(include=["metadatas"])
unique_tenants = set(m["tenant_id"] for m in all_metadatas["metadatas"])
print(f"Tenants en el corpus: {len(unique_tenants)}")  # debería ser 50

# Paso 2: implementar secure_query como wrapper obligatorio
def secure_query(query: str, tenant_id: str, **kwargs):
    if not tenant_id:
        raise TenantIsolationError("tenant_id obligatorio")
    where = {"tenant_id": tenant_id}
    if kwargs.get("additional_filters"):
        where = {"$and": [where, kwargs["additional_filters"]]}
    return collection.query(query_texts=[query], where=where, n_results=kwargs.get("n_results", 5))

# Paso 3: refactorear todos los call-sites del retrieval
# - Buscar `collection.query(` en el código
# - Reemplazar por `secure_query(`
# - Asegurar que tenant_id se pasa siempre

# Paso 4: agregar pre-commit hook que falla si alguien hace collection.query directo

# Paso 5: tests de aislamiento
def test_tenant_isolation():
    """Verificar que NUNCA cruza datos."""
    for tenant_a in test_tenants[:5]:
        for tenant_b in test_tenants[5:10]:
            results = secure_query("test query", tenant_id=tenant_a)
            for meta in results["metadatas"][0]:
                assert meta["tenant_id"] == tenant_a, f"Leak: {meta} en query de {tenant_a}"

3. Impacto esperado

Latencia:

Sistema actual (post-filter):
  - Búsqueda en 1.5M docs: ~250ms
  - Filtrar en código: ~5ms
  - Manejar caso "no hay suficientes" + retry: ~50ms (a veces)
  - Total p95: 320ms

Sistema con pre-filter:
  - Filtrar a ~30K docs (tenant promedio): ~5ms
  - Búsqueda en 30K docs: ~25ms
  - Total p95: ~40ms

Mejora: 320ms → 40ms (-87%)

Seguridad:

  • Antes: data leak posible (y ocurriendo según los tickets).
  • Después: garantizado por motor. Tests automáticos confirman aislamiento.
  • Compliance: aprueba auditoría con evidencia técnica.

Recall:

Sistema actual (post-filter):
  Algunos tenants chicos (500 docs): top-100 global a veces no incluye sus docs
  Recall@5 promedio: ~65%

Sistema con pre-filter:
  Cada tenant busca en su subset → recall garantizado del subset
  Recall@5 promedio: ~85% (depende de calidad del retrieval, no del filter)

Costo de migración:

  • ~3-5 días de ingeniería: refactor del código + tests.
  • Risk de regresión: bajo, controlado con feature flag y A/B test 1 semana.
  • ROI: dramático. Latencia 8x mejor + cero riesgo legal por leak.

Plan de rollout:

  1. Día 1-2: implementar secure_query y migrar call-sites.
  2. Día 3: tests de aislamiento (test_tenant_isolation).
  3. Día 4: deploy a staging, smoke test.
  4. Día 5: feature flag al 10% de tráfico productivo. Monitorear p95 y errors.
  5. Día 6-10: escalar 10% → 50% → 100%.
  6. Día 11: pre-commit hook + linter custom para prevenir regresión.

Métricas a monitorear post-migration:

  • Latencia p50/p95/p99 (debería caer dramáticamente).
  • Tasa de queries que invocan secure_query vs collection.query directo (debería ser 100% / 0%).
  • Tickets de "documento ajeno" (debería caer a 0).
  • Errors TenantIsolationError en logs (deberían ser raros, indicar bugs en el código que llama).

Resumen y siguiente paso

Lo que aprendiste:

  • Pre-filter aplica el filtro EN la query a la vector DB. Post-filter aplica el filtro DESPUÉS, en código.
  • Pre-filter es 5-10x más rápido y garantiza aislamiento. Post-filter es propenso a bugs y tiene recall pobre.
  • Tres casos donde post-filter SÍ aplica: motor sin soporte, lógica dinámica imposible de pre-computar, prototipo chico. Excepcionales.
  • En multi-tenant, tenant_id debe ser obligatorio. Wrappers como secure_query lo enforcan.
  • Fallback escalonado relaja filtros opcionales sin romper aislamiento.
  • Trampa principal: post-filter "porque es más fácil". El costo a escala es enorme.
  • Guardrails: pre-commit hook que prohíbe collection.query() directo, tests de aislamiento automáticos.

Checkpoint: antes de avanzar, deberías poder:

  • Explicar las dos diferencias estructurales entre pre y post filter (latencia + recall).
  • Implementar secure_query con tenant_id obligatorio.
  • Diseñar fallback escalonado que NO relaja la seguridad.

Siguiente cápsula: 03 — Diseño de metadata schema.

Sabes que pre-filter es la elección. Pero pre-filter solo funciona si tu metadata está bien indexada y diseñada para los filters que vas a necesitar. La cápsula 03 cubre cómo diseñar el schema desde el inicio para no tener que re-indexar 6 meses después porque te falta un campo.


Recursos

  1. ChromaDB — Where Clauses — Sintaxis pre-filter
  2. Pinecone — Metadata Filtering Best Practices — Comparación de patrones
  3. OWASP — Multi-Tenancy Security — Riesgos de aislamiento
  4. GDPR Article 32 — Security of Processing — Compliance y aislamiento
  5. LangChain — Self Querying Retriever — Auto-construcción de filters desde queries
  6. Anthropic — Contextual Retrieval — Patrones complementarios

Tiempo estimado: 25-30 minutos Siguiente: 03-designing-the-metadata-schema.md