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:
- Pre-filter: vector DB hace el filtrado. Pre-filter consciente del índice.
- 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:
- Latencia 6x peor con post-filter. Vector DB busca en todo el corpus, después tiras el 98% de los resultados.
- Recall pobre con post-filter. Si solo 2 de los top-100 son del tenant, devuelves 2 docs en lugar de 5. Recall colapsa.
- Pre-filter es estable. Siempre devuelve
n_resultso 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:
- Diagnostica los problemas y conecta cada uno con el approach actual.
- Diseña la migración a pre-filter.
- Estima impacto de latencia y seguridad.
Solución
1. Diagnóstico
| Síntoma | Causa raíz |
|---|---|
| Latencia 320ms p95 | Post-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:
- Día 1-2: implementar
secure_queryy migrar call-sites. - Día 3: tests de aislamiento (test_tenant_isolation).
- Día 4: deploy a staging, smoke test.
- Día 5: feature flag al 10% de tráfico productivo. Monitorear p95 y errors.
- Día 6-10: escalar 10% → 50% → 100%.
- 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
TenantIsolationErroren 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_iddebe ser obligatorio. Wrappers comosecure_querylo 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_querycontenant_idobligatorio. - 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
- ChromaDB — Where Clauses — Sintaxis pre-filter
- Pinecone — Metadata Filtering Best Practices — Comparación de patrones
- OWASP — Multi-Tenancy Security — Riesgos de aislamiento
- GDPR Article 32 — Security of Processing — Compliance y aislamiento
- LangChain — Self Querying Retriever — Auto-construcción de filters desde queries
- Anthropic — Contextual Retrieval — Patrones complementarios
Tiempo estimado: 25-30 minutos Siguiente: 03-designing-the-metadata-schema.md