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

Cápsula 07: Integración con hybrid search — el patrón "estado del arte" final

Descripción de la cápsula

Cubrimos cada componente por separado: chunking (M02), query optimization (M03), re-ranking (M04), hybrid search (M05), metadata filtering (este módulo). Ahora viene la pregunta operativa: ¿cómo se conectan todos en un solo pipeline?

Esta cápsula te enseña la arquitectura "estado del arte" para RAG production-ready de mayo 2026: query optimization → metadata filter → hybrid retrieval → reranking → generation. Cada componente reduce un problema distinto. Combinados, llevan precision@5 desde ~70% (RAG MVP) hasta 92-95% en producción real.

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

  • ✅ Implementar el pipeline completo filter → hybrid → rerank end-to-end
  • ✅ Aplicar metadata filtering tanto a vector search como a BM25 (ambos componentes del hybrid)
  • ✅ Diseñar fallback escalonado que preserva aislamiento mientras relaja optional filters
  • ✅ Benchmarkear las cuatro arquitecturas en orden incremental: semantic-only, +hybrid, +rerank, +filter
  • ✅ Anticipar puntos de falla cuando los componentes interactúan
  • ✅ Decidir qué componentes son obligatorios vs opcionales según tu contexto

Tiempo estimado: 30 minutos


La arquitectura completa

                                Query del usuario
                                       │
                                       ▼
                   ┌──────────────────────────────────────┐
                   │ Query Optimization (M03)             │
                   │  ├─ Detectar tipo de query           │
                   │  ├─ Rewriting/expansion si aplica    │
                   │  └─ Output: query(s) procesada(s)    │
                   └──────────────────┬───────────────────┘
                                      │
                                      ▼
                   ┌──────────────────────────────────────┐
                   │ Metadata Filter (M06 - este módulo)  │
                   │  ├─ workspace_id obligatorio         │
                   │  ├─ visibility según rol             │
                   │  ├─ filtros temporales (recencia)    │
                   │  └─ tags relevantes                   │
                   └──────────────────┬───────────────────┘
                                      │ filter aplicado
                                      ▼
                   ┌──────────────────────────────────────┐
                   │ Hybrid Retrieval (M05)               │
                   │                                       │
                   │  ┌──────────────────┐  ┌────────────┐│
                   │  │ Semantic search  │  │ BM25       ││
                   │  │ (con filter)     │  │ (con filter│
                   │  │ Top-30           │  │ Top-30     │
                   │  └────────┬─────────┘  └─────┬──────┘│
                   │           │                  │       │
                   │           └──────┬───────────┘       │
                   │                  ▼                    │
                   │           Reciprocal Rank Fusion      │
                   │                  │                    │
                   │                  ▼                    │
                   │             Top-30 fused              │
                   └──────────────────┬───────────────────┘
                                      │
                                      ▼
                   ┌──────────────────────────────────────┐
                   │ Re-ranking (M04)                     │
                   │  Cross-encoder o LLM rerank          │
                   │  → Top-5 final                        │
                   └──────────────────┬───────────────────┘
                                      │
                                      ▼
                   ┌──────────────────────────────────────┐
                   │ LLM Generation                       │
                   │  Prompt: query + top-5 docs          │
                   │  → Respuesta                          │
                   └──────────────────────────────────────┘

Cada paso reduce un problema distinto:

ComponenteProblema que ataca
Query optimizationQueries ambiguas o incompletas del usuario
Metadata filterBuscar en menos docs (latencia + aislamiento)
Semantic searchEncontrar docs por significado
BM25Encontrar docs por keywords exactos
RRFCombinar señales de retrieval
Re-rankingRefinar top-K con mejor scoring

Implementación del pipeline completo

# pipeline.py
from concurrent.futures import ThreadPoolExecutor
from typing import Optional

import chromadb
from rank_bm25 import BM25Okapi
from sentence_transformers import CrossEncoder


class FilteredHybridRagPipeline:
    """Pipeline completo: filter → hybrid → rerank."""

    def __init__(
        self,
        collection,
        bm25_index: BM25Okapi,
        all_doc_ids: list[str],
        all_metadatas: list[dict],
        reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-12-v2",
    ):
        self.collection = collection
        self.bm25_index = bm25_index
        self.all_doc_ids = all_doc_ids
        self.all_metadatas = all_metadatas
        self.reranker = CrossEncoder(reranker_model)

    def search(
        self,
        query: str,
        tenant: TenantContext,
        additional_filters: Optional[dict] = None,
        n_candidates: int = 30,
        n_final: int = 5,
    ) -> list[dict]:
        """
        Pipeline end-to-end: metadata filter → hybrid (sem + BM25) → RRF → rerank.
        """
        # Paso 1: Construir filter seguro
        where = self._build_filter(tenant, additional_filters)

        # Paso 2: Hybrid retrieval en paralelo (con filter aplicado en ambos)
        with ThreadPoolExecutor(max_workers=2) as executor:
            future_sem = executor.submit(
                self._semantic_search, query, where, n_candidates
            )
            future_bm25 = executor.submit(
                self._bm25_search, query, where, n_candidates
            )
            sem_ids = future_sem.result()
            bm25_ids = future_bm25.result()

        # Paso 3: Fusión con RRF
        fused_ids = self._reciprocal_rank_fusion([sem_ids, bm25_ids])
        top_candidates = fused_ids[:n_candidates]

        # Paso 4: Recuperar contenido para rerank
        candidates_data = self.collection.get(
            ids=top_candidates,
            include=["documents", "metadatas"],
        )

        # Paso 5: Re-ranking con cross-encoder
        reranked = self._cross_encoder_rerank(
            query=query,
            candidates=candidates_data,
            top_k=n_final,
        )

        return reranked

    def _build_filter(
        self, tenant: TenantContext, additional: Optional[dict]
    ) -> dict:
        """Construye filter con workspace_id obligatorio."""
        if not tenant or not tenant.workspace_id:
            raise TenantIsolationError("workspace_id obligatorio")

        base = {"workspace_id": tenant.workspace_id}
        if additional and "workspace_id" in additional:
            raise TenantIsolationError("No sobrescribir workspace_id")

        if additional:
            return {"$and": [base, additional]}
        return base

    def _semantic_search(self, query: str, where: dict, n: int) -> list[str]:
        """Vector search con filter aplicado."""
        results = self.collection.query(
            query_texts=[query],
            where=where,
            n_results=n,
        )
        return results["ids"][0]

    def _bm25_search(self, query: str, where: dict, n: int) -> list[str]:
        """BM25 search con filter manual sobre IDs."""
        query_tokens = query.lower().split()
        all_scores = self.bm25_index.get_scores(query_tokens)

        # IDs que pasan el filter (pre-computar set para fast lookup)
        allowed_ids = self._get_filtered_ids(where)

        # Tomar top-N solo entre los allowed
        scored = [
            (self.all_doc_ids[i], all_scores[i])
            for i in range(len(all_scores))
            if self.all_doc_ids[i] in allowed_ids and all_scores[i] > 0
        ]
        scored.sort(key=lambda x: -x[1])

        return [doc_id for doc_id, _ in scored[:n]]

    def _get_filtered_ids(self, where: dict) -> set[str]:
        """Aplicar filter manualmente sobre los metadatos para BM25."""
        # Usar collection.get con where para obtener IDs allowed
        result = self.collection.get(where=where, include=[])
        return set(result["ids"])

    def _reciprocal_rank_fusion(
        self, rankings: list[list[str]], k: int = 60
    ) -> list[str]:
        """RRF: combinar rankings ignorando scores absolutos."""
        from collections import defaultdict

        scores = defaultdict(float)
        for ranking in rankings:
            for rank, doc_id in enumerate(ranking, start=1):
                scores[doc_id] += 1.0 / (k + rank)

        sorted_ids = sorted(scores.items(), key=lambda x: -x[1])
        return [doc_id for doc_id, _ in sorted_ids]

    def _cross_encoder_rerank(
        self, query: str, candidates: dict, top_k: int
    ) -> list[dict]:
        """Cross-encoder rerank sobre los candidates fusionados."""
        if not candidates["documents"]:
            return []

        pairs = [(query, doc) for doc in candidates["documents"]]
        scores = self.reranker.predict(pairs, batch_size=32, show_progress_bar=False)

        ranked_indices = sorted(
            range(len(scores)),
            key=lambda i: -scores[i],
        )[:top_k]

        return [
            {
                "doc_id": candidates["ids"][i],
                "document": candidates["documents"][i],
                "metadata": candidates["metadatas"][i],
                "score": float(scores[i]),
            }
            for i in ranked_indices
        ]

Uso

# Asumiendo collection y bm25 ya configurados
pipeline = FilteredHybridRagPipeline(
    collection=chroma_collection,
    bm25_index=bm25,
    all_doc_ids=all_ids,
    all_metadatas=all_metas,
)

tenant = TenantContext(
    workspace_id="acme",
    user_id="user_1",
    user_role="member",
)

results = pipeline.search(
    query="¿cómo configuro OAuth2?",
    tenant=tenant,
    additional_filters={
        "$and": [
            {"language": "es"},
            {"created_at": {"$gte": six_months_ago_ts}},
        ]
    },
    n_candidates=30,
    n_final=5,
)

# Pasar al LLM
context = "\n\n".join(r["document"] for r in results)
answer = generate_answer(query, context)

Fallback escalonado en pipeline integrado

Si el filter es muy restrictivo y devuelve pocos resultados, puedes escalar fallback a nivel pipeline:

def search_with_pipeline_fallback(
    pipeline,
    query: str,
    tenant: TenantContext,
    optional_filters: dict,
    n_final: int = 5,
):
    """Pipeline con fallback escalonado de optional filters."""
    fallback_steps = [
        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 != "created_at"},# sin time
        {},                                                               # solo tenant
    ]

    for step_filters in fallback_steps:
        try:
            results = pipeline.search(
                query=query,
                tenant=tenant,
                additional_filters=step_filters or None,
                n_final=n_final,
            )
            if len(results) >= 3:
                return {"results": results, "filters_used": step_filters}
        except Exception as e:
            print(f"Step failed: {e}")
            continue

    return {"results": [], "filters_used": None}

workspace_id SIEMPRE preservado — está en pipeline._build_filter().


Benchmarking incremental

Demostrar el impacto de cada componente con benchmarks:

def benchmark_incremental(eval_set):
    """Mide cada componente incremental sobre el mismo eval set."""
    results = {}

    # Architecture A: solo semantic baseline
    print("Architecture A (baseline)...")
    results["A_semantic_only"] = evaluate(
        eval_set,
        retriever_fn=lambda q, t: collection.query(query_texts=[q], n_results=5),
    )

    # Architecture B: + hybrid
    print("Architecture B (+ hybrid)...")
    results["B_hybrid"] = evaluate(
        eval_set,
        retriever_fn=lambda q, t: hybrid_search(q, n_results=5),
    )

    # Architecture C: + rerank
    print("Architecture C (+ rerank)...")
    results["C_hybrid_rerank"] = evaluate(
        eval_set,
        retriever_fn=lambda q, t: hybrid_search_with_rerank(q, n_final=5),
    )

    # Architecture D: + metadata filter (la final)
    print("Architecture D (+ metadata filter — full pipeline)...")
    pipeline = FilteredHybridRagPipeline(...)
    results["D_full_pipeline"] = evaluate(
        eval_set,
        retriever_fn=lambda q, t: pipeline.search(q, t, n_final=5),
    )

    return results


# Output esperado típico:
# A_semantic_only:    Recall@5 = 65%, Precision@5 = 72%, Latency p95 = 220ms
# B_hybrid:           Recall@5 = 78%, Precision@5 = 82%, Latency p95 = 340ms
# C_hybrid_rerank:    Recall@5 = 85%, Precision@5 = 91%, Latency p95 = 480ms
# D_full_pipeline:    Recall@5 = 87%, Precision@5 = 92%, Latency p95 = 290ms  ← ¡filter mejora latencia!

Lectura clave: metadata filter NO solo agrega seguridad, también mejora latencia (busca en menos vectores) y a veces precision (menos ruido).


Trampas comunes en la integración

Trampa 1: BM25 no respeta el filter

El error: vector search aplica where, pero BM25 busca sobre todo el corpus.

Síntoma: RRF fusiona resultados de tenant A (vector) con resultados de varios tenants (BM25). Data leak parcial.

Cómo prevenir: el approach del pipeline arriba — _get_filtered_ids aplica filter manualmente sobre los IDs antes de scoring BM25.

Trampa 2: filter cambia el ranking de RRF

Si vector retrieval con filter devuelve 10 docs y BM25 con filter devuelve 25, el ranking RRF está sesgado hacia BM25 (más candidatos).

Cómo prevenir: asegurar que ambos retrievers devuelven n_candidates consistente. Si un retriever no encuentra suficientes docs en el subset filtrado, anotarlo como warning pero seguir.

Trampa 3: rerank scoring sobre poco contexto

Después del filter agresivo, puedes tener solo 5-8 candidates. Reranker sobre tan pocos no aporta mucho.

Cómo prevenir: si len(candidates) < min_for_rerank, skip rerank — los pocos que hay ya pasaron filter + RRF, ranking adicional no agrega valor.

Trampa 4: latencia subiendo a pesar del filter

Filter aplicado correctamente debería bajar latencia. Si sube:

  • ¿tenant_id está indexado en ChromaDB? Sin índice, filter es lineal.
  • ¿BM25 está aplicando el filter eficientemente o re-scanning todo?
  • ¿Rerank batch_size correcto?

Cómo prevenir: profile cada paso del pipeline. Identificar el componente lento.


Ejercicio aplicado

Escenario: eres AI Engineer en una empresa SaaS de DevOps. Tu pipeline actual:

  • 50 tenants, ~1M docs total
  • Sistema actual: solo semantic + cross-encoder rerank
  • Métricas: Precision@5 = 80%, Recall@5 = 68%, Latency p95 = 320ms
  • Tickets reportando: "vi un doc de otra empresa", "el bot no encuentra error codes específicos"

Tu trabajo:

  1. Diseña el pipeline final que ataca los dos problemas (data leak + recall en queries con identificadores).
  2. Justifica qué componentes agregas y en qué orden.
  3. Estima impacto esperado.
Solución

1. Pipeline propuesto: filter → hybrid → rerank (full architecture D)

class FullPipeline:
    def search(self, query, tenant: TenantContext, n_final=5):
        # Paso 1: filter por tenant_id + visibility (CRÍTICO para data leak)
        where = build_secure_filter(tenant)

        # Paso 2: hybrid retrieval en paralelo
        with ThreadPoolExecutor(max_workers=2) as ex:
            sem_future = ex.submit(semantic_search, query, where, n=30)
            bm25_future = ex.submit(bm25_search_filtered, query, where, n=30)

        sem_ids = sem_future.result()
        bm25_ids = bm25_future.result()

        # Paso 3: RRF
        fused = reciprocal_rank_fusion([sem_ids, bm25_ids])[:30]

        # Paso 4: recuperar contenido + rerank
        candidates = collection.get(ids=fused)
        return cross_encoder_rerank(query, candidates, top_k=n_final)

2. Justificación de cada componente

ComponentePor quéResuelve qué problema
Metadata filtertenant_id obligatorio, visibility según rol"vi un doc de otra empresa"
BM25 (en hybrid)Identificadores exactos: error codes, comandos"no encuentra error codes específicos"
Semantic search(ya estaba, mantener) — queries conceptualesCubierto por baseline
RRF fusionCombina señales de semantic + BM25Mejora recall sin perder precision
Cross-encoder rerank(ya estaba, mantener) — refinar top-KCalidad final del retrieval

Orden de impacto:

  1. Metadata filter (CRÍTICO): elimina data leak. Sin esto, el sistema viola compliance.
  2. BM25: ataca el problema de "error codes específicos". Mejora recall +15-20%.
  3. Mantener rerank: ya funciona, no romper.

3. Estimación de impacto

Métrica              Antes        Después          Cambio
─────────────────────────────────────────────────────────
Precision@5          80%          92% (+12 pts)    Mejora
Recall@5             68%          88% (+20 pts)    Mejora dramática
Latency p95          320ms        220ms (-100ms)   Mejora (filter reduce search space)
Data leak risk       ALTO         CERO             Eliminado
Costo $/mes          (igual)      +$30 (BM25 infra) Aceptable

Plan de migración (4 semanas):

Semana 1: Implementar metadata filter + secure_query
- Tests de aislamiento automatizados
- A/B test interno
- Compliance review

Semana 2: Integrar BM25 (rank_bm25 si <1M docs, ES si más)
- Construir índice
- Implementar bm25_search_filtered con _get_filtered_ids
- Tests de performance

Semana 3: Pipeline completo + RRF
- Integrar todo
- Benchmark vs baseline
- Validar mejora en eval set propio

Semana 4: Rollout gradual
- Feature flag al 10%
- Monitorear data_leak_count (debería ser 0)
- Escalar a 50% → 100%

Métricas a monitorear post-deploy:

  • Crítico: zero data leak (count debe ser 0).
  • Recall@5 sobre eval set diario.
  • Precision@5.
  • Latency p50/p95/p99.
  • BM25 contribution rate (% de docs en top-K que vinieron del BM25 ranking).
  • Filter coverage (% queries con filter aplicado correctamente).

Resumen y siguiente paso

Lo que aprendiste:

  • Pipeline completo: query opt → filter → hybrid (semantic + BM25 con RRF) → rerank → LLM.
  • Cada componente reduce un problema distinto. Combinados, llevan precision@5 de 70% a 92-95%.
  • BM25 también debe respetar el filter — no solo vector search.
  • Fallback escalonado preserva tenant_id mientras relaja optional filters.
  • Metadata filter mejora seguridad Y latencia (busca en menos docs).
  • Benchmark incremental: medir cada componente para justificar su valor.

Checkpoint: antes de avanzar, deberías poder:

  • Implementar pipeline completo filter → hybrid → rerank.
  • Asegurar que BM25 respeta el filter (no solo vector search).
  • Diseñar fallback escalonado a nivel pipeline.

Siguiente cápsula: 08 — Proyecto integrador metadata-filtered RAG.

Cierre del módulo: vas a construir el pipeline completo de este módulo — filter + hybrid + rerank + tests de aislamiento + benchmark — como proyecto del portfolio. Es la culminación de M01-M06.


Recursos

  1. Anthropic — Contextual Retrieval — Técnica complementaria
  2. Pinecone — Hybrid Search — Tutorial visual
  3. LangChain — EnsembleRetriever — Implementación de referencia
  4. LlamaIndex — Multi-Step Query Engine — Patrones avanzados
  5. BEIR Benchmark — Comparaciones empíricas
  6. Cross-Encoders Guide — Reranking

Tiempo estimado: 30 minutos Siguiente: 08-project-metadata-filtered-rag.md