Module 6: Metadata Filtering — the component almost nobody implements first but everyone ends up needing
Capsule 08: Capstone project — an end-to-end Metadata-Filtered RAG
Project description
This is the close of module 6 — and the culmination of the "state of the art" production RAG architecture. You're going to build a system that integrates it all: metadata filtering + hybrid search + re-ranking + isolation tests + auditing. It's the project that goes into your portfolio or your real codebase.
The system implements the complete pipeline (filter → hybrid → rerank), automated security tests (zero data leak between tenants), a benchmark against the baseline to justify the investment, and documentation a Tech Lead can review to approve a production deploy.
By the end of this project you'll have:
- ✅ A complete RAG pipeline with metadata filtering integrated
- ✅ A metadata schema validated with Pydantic
- ✅ A mandatory
secure_querywrapper withTenantContext - ✅ Automated isolation tests that run in CI
- ✅ Your own eval set + a comparison benchmark
- ✅ Audit logging for compliance
- ✅ A README that justifies the architecture for deploy approval
Estimated time: 4-6 hours + 1 hour of analysis.
Project architecture
metadata_rag_project/
├── src/
│ ├── schema/
│ │ ├── __init__.py
│ │ ├── document_metadata.py # Pydantic schemas
│ │ └── tenant_context.py
│ ├── retrievers/
│ │ ├── base.py
│ │ ├── semantic.py
│ │ ├── bm25.py
│ │ └── hybrid_filtered.py # The complete pipeline
│ ├── security/
│ │ ├── secure_query.py # The mandatory wrapper
│ │ └── audit_log.py
│ ├── ingestion/
│ │ └── ingest_pipeline.py # Validation + indexing
│ ├── evaluation/
│ │ ├── eval_set.py
│ │ ├── metrics.py
│ │ └── ab_test.py
│ └── pipeline.py # The top-level pipeline
├── tests/
│ ├── test_isolation.py # Cross-tenant
│ ├── test_security.py # Permission escalation
│ └── test_pipeline.py
├── data/
│ ├── corpus/
│ └── golden_set.json
├── benchmarks/
│ ├── run_benchmark.py
│ └── reports/
├── scripts/
│ └── pre_commit_check.py # The anti-bypass linter
├── .env
├── requirements.txt
└── README.md
Step 1: the metadata schema with Pydantic
# src/schema/document_metadata.py
from pydantic import BaseModel, Field
from typing import Literal
class DocumentMetadata(BaseModel):
"""The mandatory schema for every document."""
# Universals
workspace_id: str = Field(..., description="Multi-tenant isolation.")
doc_id: str
chunk_index: int = Field(..., ge=0)
source: str
created_at: int # Unix timestamp
# Categorization
type: Literal["tutorial", "reference", "faq", "changelog", "alert"]
visibility: Literal["public", "team", "private", "admin_only"] = "public"
# Domain-specific metadata (adjust it to your case)
language: Literal["en", "es", "pt"] = "en"
tags: list[str] = Field(default_factory=list)
class Config:
extra = "forbid"
Step 2: TenantContext and secure_query
# src/schema/tenant_context.py
from dataclasses import dataclass
class TenantIsolationError(Exception):
pass
@dataclass(frozen=True)
class TenantContext:
workspace_id: str
user_id: str
user_role: str = "member"
project_ids: list[str] = None
def __post_init__(self):
if not self.workspace_id or not self.user_id:
raise TenantIsolationError("workspace_id and user_id are mandatory")
# src/security/secure_query.py
def secure_query(
collection,
query_text: str,
tenant: TenantContext,
additional_filters: dict = None,
n_results: int = 5,
):
if not tenant or not tenant.workspace_id:
raise TenantIsolationError("A TenantContext is mandatory")
# Validate that additional_filters doesn't overwrite workspace_id
if additional_filters and "workspace_id" in additional_filters:
raise TenantIsolationError("Do not overwrite workspace_id")
where = {"workspace_id": tenant.workspace_id}
if additional_filters:
where = {"$and": [where, additional_filters]}
audit_log_query(tenant, query_text, n_results)
return collection.query(
query_texts=[query_text],
where=where,
n_results=n_results,
)
Step 3: the hybrid filtered retriever
# src/retrievers/hybrid_filtered.py
from concurrent.futures import ThreadPoolExecutor
from sentence_transformers import CrossEncoder
from rank_bm25 import BM25Okapi
class HybridFilteredRetriever:
"""The complete pipeline: filter → hybrid → rerank."""
def __init__(
self,
collection,
bm25_index: BM25Okapi,
all_doc_ids: list[str],
all_metadatas: list[dict],
):
self.collection = collection
self.bm25_index = bm25_index
self.all_doc_ids = all_doc_ids
self.all_metadatas = all_metadatas
self.reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-12-v2")
def search(
self,
query: str,
tenant: TenantContext,
additional_filters: dict = None,
n_candidates: int = 30,
n_final: int = 5,
) -> list[dict]:
# 1. Build the secure filter
where = self._build_secure_filter(tenant, additional_filters)
# 2. Hybrid retrieval in parallel
with ThreadPoolExecutor(max_workers=2) as executor:
sem_future = executor.submit(self._semantic_search, query, where, n_candidates)
bm25_future = executor.submit(self._bm25_search, query, where, n_candidates)
sem_ids = sem_future.result()
bm25_ids = bm25_future.result()
# 3. RRF fusion
fused_ids = self._reciprocal_rank_fusion([sem_ids, bm25_ids])
# 4. Retrieve the content + rerank
candidates = self.collection.get(
ids=fused_ids[:n_candidates],
include=["documents", "metadatas"],
)
# 5. Cross-encoder rerank
return self._rerank(query, candidates, top_k=n_final)
def _build_secure_filter(self, tenant, additional):
if not tenant or not tenant.workspace_id:
raise TenantIsolationError("workspace_id is mandatory")
base = {"workspace_id": tenant.workspace_id}
if additional and "workspace_id" in additional:
raise TenantIsolationError("Do not overwrite workspace_id")
return {"$and": [base, additional]} if additional else base
def _semantic_search(self, query, where, n):
results = self.collection.query(
query_texts=[query], where=where, n_results=n
)
return results["ids"][0]
def _bm25_search(self, query, where, n):
# Get the IDs allowed by the filter
allowed = set(self.collection.get(where=where, include=[])["ids"])
query_tokens = query.lower().split()
scores = self.bm25_index.get_scores(query_tokens)
scored = [
(self.all_doc_ids[i], scores[i])
for i in range(len(scores))
if self.all_doc_ids[i] in allowed and scores[i] > 0
]
scored.sort(key=lambda x: -x[1])
return [doc_id for doc_id, _ in scored[:n]]
def _reciprocal_rank_fusion(self, rankings, k=60):
from collections import defaultdict
scores = defaultdict(float)
for ranking in rankings:
for rank, doc_id in enumerate(ranking, 1):
scores[doc_id] += 1.0 / (k + rank)
return [doc_id for doc_id, _ in sorted(scores.items(), key=lambda x: -x[1])]
def _rerank(self, query, candidates, top_k):
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 = 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
]
Step 4: the isolation tests
# tests/test_isolation.py
import pytest
from src.schema.tenant_context import TenantContext, TenantIsolationError
from src.retrievers.hybrid_filtered import HybridFilteredRetriever
@pytest.fixture
def retriever_with_two_tenants():
"""Setup with docs from tenant_a and tenant_b."""
# ... set up ChromaDB + BM25 with 100 docs per tenant ...
return retriever
def test_tenant_a_cannot_see_tenant_b_docs(retriever_with_two_tenants):
ctx = TenantContext(workspace_id="tenant_a", user_id="user_1")
results = retriever_with_two_tenants.search("test", ctx, n_final=20)
for r in results:
assert r["metadata"]["workspace_id"] == "tenant_a", (
f"LEAK: tenant_a saw {r['metadata']['workspace_id']}"
)
def test_workspace_id_is_required():
with pytest.raises(TenantIsolationError):
TenantContext(workspace_id="", user_id="user_1")
def test_additional_filters_cannot_override_tenant():
ctx = TenantContext(workspace_id="tenant_a", user_id="user_1")
with pytest.raises(TenantIsolationError):
retriever.search(
"test",
ctx,
additional_filters={"workspace_id": "tenant_b"}, # a bypass attempt
)
def test_concurrent_tenants_no_leakage(retriever_with_two_tenants):
"""Concurrent queries from different tenants don't cross over."""
import threading
leaks = []
def query_as(tenant_name):
ctx = TenantContext(workspace_id=tenant_name, user_id=f"u_{tenant_name}")
results = retriever_with_two_tenants.search("test", ctx, n_final=20)
for r in results:
if r["metadata"]["workspace_id"] != tenant_name:
leaks.append((tenant_name, r["metadata"]["workspace_id"]))
threads = [
threading.Thread(target=query_as, args=("tenant_a",))
for _ in range(10)
] + [
threading.Thread(target=query_as, args=("tenant_b",))
for _ in range(10)
]
for t in threads:
t.start()
for t in threads:
t.join()
assert not leaks, f"Concurrent leakage: {leaks}"
Step 5: the eval set + benchmark
# data/golden_set.json
[
{
"query": "OAuth2PasswordBearer scopes",
"tenant_id": "tenant_a",
"expected_doc_ids": ["a_doc_42"],
"category": "exact_match"
},
{
"query": "how do I authenticate users",
"tenant_id": "tenant_a",
"expected_doc_ids": ["a_doc_103"],
"category": "conceptual"
}
]
# benchmarks/run_benchmark.py
def benchmark_pipeline_variants(eval_set):
"""Compares 4 architectures: semantic, +hybrid, +rerank, +filter."""
metrics = {}
for arch_name, retriever_fn in [
("A_semantic_baseline", baseline_semantic_only),
("B_hybrid", hybrid_no_filter),
("C_hybrid_rerank", hybrid_with_rerank_no_filter),
("D_full_pipeline", pipeline_with_everything),
]:
results = []
for item in eval_set:
tenant = TenantContext(workspace_id=item["tenant_id"], user_id="bench")
res = retriever_fn(item["query"], tenant)
retrieved_ids = [r["doc_id"] for r in res]
relevant = set(item["expected_doc_ids"])
in_top_5 = retrieved_ids[:5]
precision = sum(1 for d in in_top_5 if d in relevant) / 5
recall = sum(1 for d in in_top_5 if d in relevant) / max(len(relevant), 1)
results.append({"precision": precision, "recall": recall})
metrics[arch_name] = {
"precision_at_5": sum(r["precision"] for r in results) / len(results),
"recall_at_5": sum(r["recall"] for r in results) / len(results),
}
return metrics
Step 6: the solution's README
# Metadata-Filtered RAG System
## Architecture
A production-ready pipeline: `metadata filter → hybrid retrieval → rerank → LLM`.
Each component attacks a specific problem:
- Metadata filter: multi-tenant isolation + recency + categorization
- Hybrid (semantic + BM25 + RRF): conceptual coverage + exact match
- Cross-encoder rerank: refine the top-K with better scoring
- LLM with citations: generation with anti-hallucination
## Benchmark results (an eval set of 80 queries)
| Architecture | Precision@5 | Recall@5 | Latency p95 | Data Leak Risk |
|--------------|-------------|----------|--------------|----------------|
| A: Semantic baseline | 72% | 65% | 220ms | HIGH |
| B: + Hybrid | 84% | 78% | 320ms | HIGH |
| C: + Rerank | 91% | 84% | 470ms | HIGH |
| D: + Metadata filter (full) | **92%** | **87%** | **310ms** | **ZERO** |
**The highlight:** architecture D (with the filter) is **faster** than C because it searches
fewer vectors, on top of eliminating the data leak risk.
## Isolation tests
An automated suite in `tests/test_isolation.py`:
- ✅ Cross-tenant leakage: 0 detected
- ✅ Bypass via additional_filters: blocked
- ✅ Concurrent queries from different tenants: isolated
- ✅ workspace_id mandatory: enforced
## Compliance
- ✅ Audit logging for every query (workspace_id, user_id, timestamp, query_hash)
- ✅ Structured logs with a 7-year retention
- ✅ Isolation tests in CI before every deploy
- ✅ A pre-commit hook that blocks a direct collection.query()
## Setup
\`\`\`bash
pip install -r requirements.txt
cp .env.example .env # fill in OPENAI_API_KEY
python scripts/build_indexes.py
python benchmarks/run_benchmark.py
\`\`\`
## Next steps
- Migration to Pinecone to scale (M07)
- Continuous RAG evaluation (M08)
Project delivery checklist
- A Pydantic schema validated in the ingest pipeline
- TenantContext + secure_query mandatory throughout the code
- A working end-to-end HybridFilteredRetriever
- Your own eval set of 50+ queries with tenant_id and expected_doc_ids
- Isolation tests running in CI (5 tests minimum)
- A pre-commit hook that blocks a direct
collection.query() - Structured audit logging
- A benchmark report comparing the 4 architectures
- A README with the architecture, the metrics, the compliance story
- Documentation on how to deploy and monitor it
Optional extensions
Extension 1: a metrics dashboard
Build a Grafana (or similar) dashboard showing:
- Latency p50/p95/p99 per tenant
- The recall rate per query category
- An audit log search interface
- Alerts if a data leak is detected
Extension 2: A/B testing in production
A feature flag for a gradual rollout:
if user.workspace_id in ROLLOUT_GROUP:
pipeline = HybridFilteredPipeline(...)
else:
pipeline = LegacyPipeline(...)
Compare the metrics between the two groups for 2 weeks.
Extension 3: automated tenant onboarding
A script that onboards a new tenant:
- Validates that the schema still meets compliance
- Indexes the initial docs
- Creates a TenantContext template
- Configures the tenant-specific monitoring
Extension 4: pipeline observability
Every step of the pipeline emits traces (OpenTelemetry):
- Filter time, hybrid time, rerank time
- The hit rate of each component
- Bottleneck analysis
Traps and common mistakes in the project
Trap 1: isolation tests that only cover the happy path
Make sure you test:
- Concurrency (threads from different tenants)
- Bypass attempts (additional_filters with a workspace_id)
- Edge cases (an empty tenant_id, None, invalid values)
Trap 2: ignoring audit log retention
Compliance typically requires 7-year retention. It isn't optional.
Trap 3: BM25 with no filter
If BM25 searches the whole corpus even though the vector search is filtered, RRF fuses in cross-tenant docs.
Trap 4: deploying with no smoke test
After deploying, the first mandatory step: a real query from the app verifying it returns docs from the right tenant.
Trap 5: outdated documentation
The README is for the Tech Lead who's going to approve the deploy. If it documents the old architecture, the audit fails.
Project summary
You built:
- ✅ A complete pipeline (filter + hybrid + rerank) with security by design
- ✅ A schema validated with Pydantic
- ✅ Automated isolation tests
- ✅ Anti-bypass pre-commit hooks
- ✅ Audit logging for compliance
- ✅ A comparison benchmark
- ✅ A README for approval
The typical improvement expected with the full pipeline:
Metric Baseline Full pipeline Gain
Precision@5 72% 92% +20 pts
Recall@5 65% 87% +22 pts
Latency p95 220ms 310ms +90ms (better than without the filter)
Data leak risk HIGH ZERO eliminated
Next steps
Right away:
- Run the full benchmark over your eval set.
- Validate the isolation tests at 100%.
- Review the README with compliance/security.
- A gradual deploy behind a feature flag.
Module 7 (Production with Pinecone):
What you built with ChromaDB translates directly to Pinecone:
- The
workspace_idfilter → native namespaces - BM25 → Pinecone supports sparse vectors (native BM25 since 2024)
- Audit logging → Pinecone has native logs on the enterprise plans
- Isolation tests → you already have them; only the backend changes
M07 covers the migration + how to scale to 10M+ vectors while keeping the isolation.
Resources
- Pydantic Documentation — Validation
- ChromaDB — Metadata Filtering — Reference
- SOC2 Compliance Framework — The standard
- OWASP Multi-Tenancy Security
- GDPR Article 32 — Security context
- Anthropic — Contextual Retrieval
Estimated time: 4-6 hours + 1 hour of analysis Next module: Module 7 — Production with Pinecone