πŸ“š RAG β€” Retrieval-Augmented Generation Master Guide

Embeddings Β· Chunking Β· Vector Search Β· Hybrid Retrieval Β· Metadata Filtering Β· Security Β· RBAC Β· Privacy Β· Hands-On Labs

The RAG Mental Model
RAG solves the LLM's biggest limitation: it doesn't know your data. By retrieving relevant documents at query time and injecting them as context, the model can answer questions grounded in your knowledge base.

πŸ—οΈ The RAG Pipeline β€” End to End

INDEXING (offline β€” done once when documents are added)
πŸ“„ Raw Docs
β†’
βœ‚οΈ Chunk
β†’
πŸ”’ Embed
β†’
🏷️ Add Metadata
β†’
πŸ’Ύ Vector DB
RETRIEVAL (online β€” happens on every query)
πŸ‘€ User Query
β†’
πŸ”’ Embed Query
β†’
πŸ” Vector Search
β†’
πŸ“‹ Re-rank
β†’
πŸ—‚οΈ Top-K Chunks
β†’
πŸ€– LLM + Context
β†’
βœ… Answer
πŸ’‘
The quality of every answer depends entirely on which chunks are retrieved. Most RAG failures are retrieval failures β€” not LLM failures. Fix retrieval before tuning prompts.

πŸ“Š Why RAG Fails

  • ❌
    Bad chunking β€” answer split across chunk boundaries
  • ❌
    Wrong embedding β€” model doesn't understand your domain
  • ❌
    No hybrid search β€” misses exact keyword matches
  • ❌
    No reranking β€” returns semantically close but irrelevant chunks
  • ❌
    No metadata β€” can't filter stale or unauthorized content

⚑ Quick Wins

  • βœ“
    RecursiveCharacterSplitter as your default chunker
  • βœ“
    Add 10-15% overlap between chunks
  • βœ“
    Hybrid search (BM25 + vectors) from day one
  • βœ“
    Cross-encoder reranker on top-20 results
  • βœ“
    Metadata filtering before ANN search

πŸ“ Key Numbers

  • β†’
    256–512 tokens β€” fact retrieval chunks
  • β†’
    512–1024 tokens β€” context-heavy tasks
  • β†’
    Top-K = 5–20 β€” retrieve, then rerank to 3–5
  • β†’
    Ξ± = 0.5–0.7 β€” hybrid weight (vector vs BM25)
  • β†’
    10–15% β€” chunk overlap ratio
πŸ”’ Embeddings β€” Turning Text Into Numbers
An embedding is a dense vector of floats that captures the semantic meaning of text. Semantically similar text β†’ similar vectors β†’ close in vector space.

🧠 How Embeddings Work

A sentence like "The cat sat on the mat" becomes a vector like [0.12, -0.45, 0.89, ...] with hundreds of dimensions. The model is trained so that semantically related sentences produce vectors that are close by cosine similarity.

# Same meaning β†’ similar vectors embed("How do I reset my password?") # β‰ˆ cosine similarity 0.95 with: embed("Password recovery steps") embed("Forgot my login credentials") # Different meaning β†’ distant vectors # β‰ˆ cosine similarity 0.12 with: embed("The quarterly revenue report")
πŸ“
Cosine similarity = dot product of normalized vectors.
1.0 = identical meaning. 0.0 = unrelated. -1.0 = opposite.

Euclidean distance also works but cosine is standard for text because it's magnitude-invariant (length of text doesn't affect similarity).
⚠️
Query-document asymmetry: Most embedding models are trained for symmetric similarity. For RAG, use models with separate query/document encoders (bi-encoders) β€” they embed queries and documents differently for better retrieval.

πŸ“Š Embedding Model Comparison (2025)

ModelDimsMax TokensBest ForCost
text-embedding-3-small (OpenAI)15368191General purpose, low cost$0.02/1M tok
text-embedding-3-large (OpenAI)30728191High accuracy, longer docs$0.13/1M tok
voyage-3 (Anthropic/Voyage)102432000Long docs, RAG-optimized$0.06/1M tok
embed-v4 (Cohere)1024512Multilingual, enterprise$0.10/1M tok
nomic-embed-text (local)7688192Open-source, self-hostedFree
BGE-M3 (local)10248192Multilingual, hybrid-nativeFree
πŸ’‘
Rule of thumb: Evaluate on your own corpus before picking. MTEB leaderboard scores don't always translate to your domain. If you handle long docs (contracts, research papers), prioritize models with 32K+ token context windows.

πŸ’° Embedding Cost at Scale

πŸ“„
1M docs
~500 tokens avg
= 500M tokens total
text-3-small: $10
πŸ”
Query costs
Queries are tiny (~20 tok)
10K queries/day
β‰ˆ $0.04/day
πŸ’Ύ
Storage
1536-dim float32
= 6KB per chunk
1M chunks β‰ˆ 6GB
βœ‚οΈ Chunking β€” The Most Impactful Decision
How you split documents determines retrieval quality more than almost any other factor. Most RAG failures trace back to poor chunking.
🚨
"The era of 'just chunk it into 500 tokens and throw it in a vector DB' is over." β€” The right strategy depends on your document type, query type, and latency budget.

πŸ“Š Chunking Strategy Comparison

StrategyHow It WorksBest ForGotcha
Fixed-size (naive)Split every N chars/tokensQuick prototypesCuts mid-sentence, destroys context
RecursiveCharacterSplit at \n\n β†’ \n β†’ ". " β†’ " " in orderDefault for most casesStill size-based, not semantic
Sentence-basedSplit at sentence boundariesFact-dense docs (Q&A, FAQs)Variable size β€” small sentences = tiny chunks
Semantic chunkingSplit when embedding similarity drops between sentencesComplex narratives, articlesSlow (needs embeddings at index time)
Parent-Child (small-to-big)Small chunks for retrieval; return larger parent for contextBest precision + context comboMore complex pipeline, 2 indexes
Hierarchical / TreeRAGBuild summary tree; retrieve at appropriate levelLong docs, Q&A at different scopesHigh complexity, expensive to build
Late ChunkingEmbed entire doc first, then pool embeddings into chunksWhen full-doc context mattersNewer technique, less tooling support

πŸ† Recommended Default: RecursiveCharacterSplitter

from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=512, # target tokens chunk_overlap=64, # ~12% overlap separators=[ "\n\n", # paragraph (try first) "\n", # line break ". ", # sentence " ", # word "" # character (last resort) ] ) chunks = splitter.split_text(document)
βœ“
Tries paragraph β†’ sentence β†’ word order. Produces the most natural splits. Use this until you have data showing you need something else.

πŸ”¬ Advanced: Parent-Child Pattern

# Small child chunks β†’ precise retrieval # Large parent chunks β†’ rich context for LLM parent_splitter = RecursiveCharacterTextSplitter( chunk_size=1500 # large = more context ) child_splitter = RecursiveCharacterTextSplitter( chunk_size=256 # small = precise retrieval ) # Two stores: # 1. child_vectorstore β€” what you search # 2. parent_docstore β€” what you return retriever = ParentDocumentRetriever( vectorstore=child_vectorstore, docstore=parent_docstore, child_splitter=child_splitter, parent_splitter=parent_splitter, ) # Query hits small child β†’ returns big parent # Best precision + best context in one step

πŸ“ Chunk Size Decision Guide

128–256
Exact fact lookup
Q&A bots
High precision needed
256–512
Most RAG use cases
Good balance
Start here
512–1024
Complex context
Code docs
Legal/medical
1024+
Narrative-heavy
Multi-page reports
Use Parent-Child instead
πŸ” Vector Search & Retrieval Patterns
Vector search finds semantically similar chunks. But similarity β‰  relevance. Here's how to make retrieval actually work in production.

βš™οΈ How ANN (Approximate Nearest Neighbor) Works

Full exact search over millions of vectors is O(NΒ·D) β€” too slow. ANN indexes build data structures (HNSW, IVF) that sacrifice a small amount of accuracy for massive speed gains.

AlgorithmSpeedRecallUse When
HNSWVery fastHighDefault for most cases
IVF-FlatFastMediumHuge datasets (>100M)
Exact searchSlow100%<100K vectors only
πŸ“Š
Vector DB comparison:
Pinecone β€” managed, easiest setup
Weaviate β€” native hybrid BM25+vector
Qdrant β€” fast, open-source, great filtering
Milvus β€” enterprise-grade, strong RBAC
pgvector β€” Postgres extension, no new infra
ChromaDB β€” local dev, embedded

🎯 Retrieval Patterns β€” Beyond Basic K-NN

PatternHowWhen to Use
MMR (Max Marginal Relevance)Balance relevance + diversity β€” penalize redundant chunksWhen top-K chunks all say the same thing
Contextual CompressionExtract only the relevant sentence(s) from each retrieved chunkReduce context window consumption
Multi-Query RetrievalGenerate N query variants β†’ retrieve for each β†’ mergeAmbiguous queries, low recall
HyDE (Hypothetical Document)LLM generates a hypothetical answer β†’ embed that β†’ searchAbstract or vague queries
Ensemble RetrievalRun multiple retrievers β†’ fuse results (RRF)High-accuracy pipelines
Self-Query RetrievalLLM converts query into structured filter + semantic searchQueries with implicit filters ("documents from Q4 2024")

πŸ“Š Cross-Encoder Reranking β€” The Precision Layer

Bi-encoder retrieval is fast but imprecise. Cross-encoders read the query and document together β€” much more accurate but too slow for full indexes. Use them in two stages:

Query
β†’
ANN Search
Top-20
β†’
Cross-encoder
Rerank
β†’
Top-5 to
LLM
# Cohere reranker (best in class) from langchain.retrievers import ContextualCompressionRetriever from langchain_cohere import CohereRerank reranker = CohereRerank( model="rerank-v3.5", top_n=5 # return 5 after reranking 20 ) retriever = ContextualCompressionRetriever( base_compressor=reranker, base_retriever=vector_retriever )
⚑ Hybrid Search β€” BM25 + Vectors
Neither BM25 nor vector search wins consistently across all query types. Hybrid search combines both for higher precision across the board.

πŸ₯Š BM25 vs Vector Search β€” Strengths and Limits

πŸ“Š BM25 (Sparse / Keyword)
  • βœ“
    Exact terms β€” product codes, SKUs, names, IDs
  • βœ“
    Rare technical terms β€” IDF rewards specificity
  • βœ“
    Transparent β€” you can explain why a doc ranked
  • βœ—
    Can't handle synonyms or paraphrases
  • βœ—
    "Config override" β‰  "custom settings" to BM25
🧠 Vector / Dense Search
  • βœ“
    Semantic understanding β€” matches meaning, not words
  • βœ“
    Handles paraphrase β€” synonyms, different phrasing
  • βœ“
    Cross-lingual β€” multilingual models work across languages
  • βœ—
    Can miss exact rare terms (underweights low-freq tokens)
  • βœ—
    Black box β€” harder to explain rankings

πŸ”€ Reciprocal Rank Fusion (RRF) β€” The Fusion Algorithm

RRF works on ranks, not raw scores β€” which means it doesn't matter that BM25 scores (0–20) and cosine similarities (0–1) are on completely different scales. It just needs the rank positions.

# RRF formula: score = Ξ£ 1/(k + rank_i) # k=60 is the standard constant def rrf_fusion( results_list: list[list], k: int = 60 ) -> list: scores = {} for results in results_list: for rank, doc_id in enumerate(results): if doc_id not in scores: scores[doc_id] = 0 scores[doc_id] += 1 / (k + rank + 1) return sorted( scores, key=lambda x: scores[x], reverse=True ) # bm25_results and vector_results are rank-ordered lists fused = rrf_fusion([bm25_results, vector_results])
🎯
Why RRF works: A document ranked #1 by BM25 and #3 by vector search scores higher than one ranked #1 by only one method. It rewards agreement across retrievers β€” exactly what you want.
βš™οΈ
The Ξ± parameter (used in Weaviate): controls blend.
Ξ± = 1.0 β†’ pure vector
Ξ± = 0.0 β†’ pure BM25
Ξ± = 0.5 β†’ equal blend
Start at 0.5, tune with your eval set.

πŸ—ΊοΈ Full Hybrid Retrieval Pipeline

from langchain.retrievers import EnsembleRetriever from langchain_community.retrievers import BM25Retriever from langchain.retrievers import ContextualCompressionRetriever from langchain_cohere import CohereRerank # Stage 1a: BM25 keyword retrieval bm25 = BM25Retriever.from_documents(docs, k=20) # Stage 1b: Dense vector retrieval vector = vectorstore.as_retriever(search_kwargs={"k":20}) # Stage 2: Fuse with RRF (weights: 50/50 to start) ensemble = EnsembleRetriever( retrievers=[bm25, vector], weights=[0.5, 0.5] # tune this on your eval set ) # Stage 3: Cross-encoder reranking (precision layer) reranker = CohereRerank(model="rerank-v3.5", top_n=5) final_retriever = ContextualCompressionRetriever( base_compressor=reranker, base_retriever=ensemble ) # Result: top-5 highly relevant chunks, fast
🏷️ Metadata Filtering
Metadata transforms a fuzzy semantic search into a precise, scoped query. It's also the foundation of access control and freshness-aware retrieval.

πŸ“¦ What Metadata to Store on Every Chunk

# Every chunk should carry this metadata chunk_metadata = { # Source attribution "source": "hr_handbook_v3.pdf", "url": "https://wiki/hr/handbook", "page": 12, "section": "Vacation Policy", # Temporal "created_at": "2025-01-15", "updated_at": "2025-11-01", "version": "3.2", # Classification "doc_type": "policy", "department": "HR", "language": "en", # Access control (covered in Security tab) "access_level": "internal", "allowed_roles": ["employee", "manager"], "pii_flag": False, }
🏷️
Pre-filtering (before ANN search): Apply metadata filters to reduce the candidate set before computing similarities. Much faster for large corpora with clear scope boundaries.

Post-filtering (after ANN search): Retrieve more (top-50) then filter. Better when filter hit-rate is high (most docs pass the filter).
⚠️
Filter before search when: Low hit-rate (most docs are filtered out) β€” pre-filtering avoids wasted ANN computation.
Filter after search when: High hit-rate (most docs pass) β€” post-filtering gives ANN more candidates to work with.

πŸ”Ž Self-Query: LLM Extracts Filters From Natural Language

from langchain.retrievers.self_query.base import SelfQueryRetriever from langchain.chains.query_constructor.base import AttributeInfo # Tell the LLM what metadata fields exist metadata_fields = [ AttributeInfo(name="department", description="Dept: HR, Finance, Engineering", type="string"), AttributeInfo(name="updated_at", description="Last update date", type="string"), AttributeInfo(name="doc_type", description="Type: policy, report, guide", type="string"), ] # User says: "Show me HR policies updated after November 2025" # LLM converts to: {"department": "HR", "updated_at": {"$gt": "2025-11"}} # + semantic search for "policies" retriever = SelfQueryRetriever.from_llm( llm=llm, vectorstore=vectorstore, document_contents="Company internal documents", metadata_field_info=metadata_fields, )
πŸ” Security, RBAC & Privacy
Without access control, any user can retrieve any document that's semantically relevant β€” completely unacceptable in enterprise settings. Here's the complete security model.
🚨
OWASP Top 10 for LLMs 2025 explicitly identifies vector and embedding weaknesses in RAG systems as a vulnerability category. Access control is not optional.

πŸ›οΈ The Three-Layer Security Model

Layer 1
Auth
(who are you?)
β†’
Layer 2
Access Control
(what can you see?)
β†’
Layer 3
PII / Guardrails
(what gets returned?)
β†’
Safe
Response
Layer 1 β€” Authentication
JWT / OAuth2 token
Extract user_id + roles from token
Validate before any retrieval
Layer 2 β€” RBAC Filter
Inject user roles into metadata filter
Pre-filter: {allowed_roles: {$in: user_roles}}
Never let user bypass this
Layer 3 β€” Output Guardrails
Strip PII from returned chunks
Audit log all retrievals
Verify citations before sending

πŸ”‘ RBAC Implementation β€” Metadata Tag Pattern

The standard enterprise pattern: tag every chunk at ingestion time, inject role filter at query time. One vector DB serves the whole org.

INDEXING β€” Tag at ingestion:
# Finance doc: only finance + executives chunk = { "content": "Q3 revenue was $14.2M...", "metadata": { "allowed_roles": [ "finance", "executive", "admin" ], "classification": "confidential", "department": "Finance" } }
RETRIEVAL β€” Inject filter at query time:
def secure_retrieval(query, user_token): # 1. Authenticate + extract roles roles = get_roles_from_token(user_token) # 2. Build role filter (NEVER skip this) access_filter = { "allowed_roles": {"$in": roles} } # 3. Search with filter always applied results = vectorstore.similarity_search( query, k=10, filter=access_filter # mandatory ) # User never sees docs outside their roles return results

πŸ›‘οΈ Privacy & PII Protection

  • βœ“
    PII detection at ingestion β€” flag chunks containing names, SSNs, emails before indexing
  • βœ“
    Anonymize before embedding β€” replace PII with tokens in indexed text
  • βœ“
    Differential access β€” raw PII only accessible to authorized roles even within the same document
  • βœ“
    Audit every retrieval β€” log user_id, query, doc IDs retrieved, timestamp
  • βœ“
    Right to erasure β€” track chunk provenance so you can delete all chunks from a document
import re from presidio_analyzer import AnalyzerEngine analyzer = AnalyzerEngine() def anonymize_chunk(text: str) -> tuple: results = analyzer.analyze( text=text, language="en" ) has_pii = len(results) > 0 # Replace PII with type tokens clean = text for r in sorted(results, key=lambda x: -x.start): clean = ( clean[:r.start] + f"[{r.entity_type}]" + clean[r.end:] ) return clean, has_pii # "John Smith earned $120K" β†’ "[PERSON] earned [MONEY]"

⚠️ RBAC Anti-Patterns β€” What NOT to Do

Anti-PatternWhy It's DangerousFix
Separate vector DB per roleOperationally unscalable; N roles = N databasesOne DB, metadata-based filtering
Filter in the prompt ("only show if user is X")LLM can be jailbroken; doesn't prevent retrieval of forbidden docsFilter at retrieval layer, never in prompt
Trusting user-provided role claimsUser can forge their own role in the requestAlways validate roles from auth token server-side
No audit logCannot detect data exfiltration; fails complianceLog every retrieval: user, query, docs returned
Embedding PII-containing text directlyEmbeddings can leak PII via inversion attacksAnonymize before embedding; store PII separately
πŸ’‘ Production Tips
Hard-won lessons from building RAG systems at scale. The difference between a demo and a production system.

πŸ“Š Evaluation β€” You Can't Improve What You Don't Measure

  • β†’
    Context Precision@K β€” of retrieved chunks, what fraction are actually relevant?
  • β†’
    Context Recall β€” of all relevant chunks, what fraction did we retrieve?
  • β†’
    Faithfulness β€” does the answer only use retrieved context?
  • β†’
    Answer Relevancy β€” does the answer actually address the question?
  • β†’
    Tools: RAGAS (open-source RAG eval framework)

πŸ› Debugging RAG Failures

  • 1
    Check retrieved chunks β€” log them. Are the right docs retrieved?
  • 2
    Check chunk content β€” does each chunk contain the answer?
  • 3
    Check embedding model β€” is it domain-appropriate?
  • 4
    Check chunk boundaries β€” is the answer split across chunks?
  • 5
    Check LLM prompt β€” only after retrieval is verified

⚑ Performance Optimization

OptimizationImpactHow
Embedding caching80-90% latency reductionCache query embeddings with Redis; same query = instant
Metadata pre-filter50-80% reduction in ANN search spaceFilter before ANN, not after
Async retrievalLinear speedup with parallel retrieversasyncio.gather() on BM25 + vector in parallel
Quantized embeddings4x storage reduction, ~5% accuracy lossUse int8 quantization in Qdrant/Milvus
Batch embedding at index time10-20x faster indexingEmbed 100 chunks per API call, not one at a time

πŸ—ΊοΈ RAG Architecture Decision Tree

SituationRecommendation
Just starting, <100K docsChromaDB + RecursiveCharacterSplitter + text-embedding-3-small
Production, <10M docsQdrant or Weaviate + hybrid search + Cohere reranker
Enterprise with RBACMilvus or Elasticsearch + metadata filtering + audit logging
Long documents (contracts)Parent-Child retriever + voyage-3 (32K context)
Low-latency requirementPre-filter metadata + HNSW + embedding cache
Air-gapped / privacy-firstpgvector + nomic-embed-text or BGE-M3 (local models)
πŸ§ͺ

Lab 1 β€” Live Chunking Visualizer

Paste any text, select a chunking strategy, and see exactly how the text gets split β€” with chunk sizes, overlap visualization, and quality assessment. Understand what the retriever will actually search over.

200
10%

πŸ“Š Chunk Visualization β€” click any chunk to inspect

⚠️ Quality Issues Detected

Adjust settings above to see quality analysis…
πŸ”¬

Lab 2 β€” Live RAG Pipeline (runs real Anthropic API)

Build a tiny knowledge base, then ask questions. Watch the full pipeline: query embedding β†’ similarity scoring β†’ chunk retrieval β†’ LLM answer generation. See which chunks get retrieved and why.

πŸ“š Step 1: Knowledge Base

These are your "documents" β€” the RAG pipeline will chunk and index them, then retrieve relevant sections for your question.

❓ Step 2: Ask a Question

πŸ” Retrieval Traceidle
Retrieval trace will appear here… The trace shows: 1. Query β†’ embedding (vector of floats) 2. Similarity scores for each chunk 3. Ranked retrieval results 4. Which chunks got passed to the LLM
πŸ’¬ RAG Answer + Citations
Answer will appear here…
πŸ›‘οΈ

Lab 3 β€” RBAC RAG Simulator

Experience role-based retrieval firsthand. The knowledge base contains documents with different access levels. Switch between users and see how the same query returns completely different chunks β€” or is blocked β€” based on the user's role.

πŸ‘€ Switch User Role

πŸ“š Document Access Matrix (the knowledge base)

DocumentContentπŸ‘€ EmployeeπŸ‘” Manager🏒 ExecutiveπŸ§‘β€πŸ’Ό HR
Vacation Policy15 days/yr, accrual rulesβœ…βœ…βœ…βœ…
Remote Work PolicyUp to 3 days/week remoteβœ…βœ…βœ…βœ…
Department BudgetTeam-level budget breakdownβŒβœ…βœ…βŒ
Q3 Revenue Report$14.2M revenue, 12% YoYβŒβŒβœ…βŒ
Salary BandsCompensation by levelβŒβŒβŒβœ…
Performance ReviewsIndividual review summariesβŒβœ…βœ…βœ…
πŸ“‹ Production-Ready Snippets
Copy these into your RAG project. Each is self-contained and tested.
βœ‚οΈ RecursiveCharacter Chunker
"""Smart chunker with metadata enrichment."""
from langchain.text_splitter import RecursiveCharacterTextSplitter
from datetime import datetime

def chunk_document(
    text: str,
    source: str,
    chunk_size: int = 512,
    overlap: int = 64,
    metadata: dict = None,
) -> list[dict]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=overlap,
        separators=["\n\n", "\n", ". ", " ", ""],
        length_function=len,
    )
    chunks = splitter.split_text(text)

    base_meta = {
        "source": source,
        "indexed_at": datetime.utcnow().isoformat(),
        "chunk_count": len(chunks),
        **(metadata or {}),
    }

    return [
        {
            "content": chunk,
            "metadata": {
                **base_meta,
                "chunk_index": i,
                "char_count": len(chunk),
                # Approx token count (rough: 4 chars/token)
                "token_estimate": len(chunk) // 4,
            },
        }
        for i, chunk in enumerate(chunks)
    ]

# Usage:
# chunks = chunk_document(
#     text=my_document,
#     source="hr_policy_2025.pdf",
#     metadata={
#         "department": "HR",
#         "allowed_roles": ["employee", "manager"],
#         "doc_type": "policy",
#         "version": "2.1",
#     }
# )
πŸ” Hybrid Retriever (BM25 + Vector)
"""Hybrid BM25 + vector retrieval with RRF fusion."""
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Qdrant

def build_hybrid_retriever(docs: list, k: int = 20):
    """
    Build a hybrid retriever that fuses BM25 and vector search.
    Consistently outperforms either alone.
    """
    # BM25 (keyword / sparse)
    bm25_retriever = BM25Retriever.from_documents(docs)
    bm25_retriever.k = k

    # Dense vector (semantic)
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    vectorstore = Qdrant.from_documents(
        docs,
        embeddings,
        location=":memory:",
        collection_name="docs",
    )
    vector_retriever = vectorstore.as_retriever(
        search_kwargs={"k": k}
    )

    # EnsembleRetriever uses RRF fusion internally
    # weights=[0.5, 0.5] = equal blend; tune on your eval set
    return EnsembleRetriever(
        retrievers=[bm25_retriever, vector_retriever],
        weights=[0.4, 0.6],  # slightly favor semantic
    )

# For production: add a cross-encoder reranker on top
# from langchain_cohere import CohereRerank
# from langchain.retrievers import ContextualCompressionRetriever
# reranker = CohereRerank(model="rerank-v3.5", top_n=5)
# final = ContextualCompressionRetriever(
#     base_compressor=reranker, base_retriever=hybrid
# )
πŸ” RBAC Secure Retrieval
"""Role-based access control for RAG retrieval.
Pattern: tag chunks at ingestion, filter at query time."""
import jwt
from functools import wraps
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchAny

client = QdrantClient(":memory:")

def get_roles_from_token(token: str) -> list[str]:
    """Extract roles from JWT token."""
    try:
        payload = jwt.decode(token, options={"verify_signature": False})
        return payload.get("roles", [])
    except Exception:
        return []  # deny on error

def index_chunk_with_rbac(
    chunk_text: str,
    embedding: list[float],
    allowed_roles: list[str],
    metadata: dict,
):
    """Index a chunk with role-based access metadata."""
    client.upsert(
        collection_name="docs",
        points=[{
            "id": generate_id(),
            "vector": embedding,
            "payload": {
                "content": chunk_text,
                "allowed_roles": allowed_roles,  # the key field
                **metadata,
            }
        }]
    )

def secure_search(
    query_embedding: list[float],
    user_token: str,
    k: int = 10,
) -> list:
    """Search with mandatory RBAC filter. Never call without filter."""
    # 1. Get user's roles from auth token (never trust client claims)
    user_roles = get_roles_from_token(user_token)
    if not user_roles:
        return []  # deny if no valid roles

    # 2. Build filter: doc's allowed_roles must overlap user's roles
    rbac_filter = Filter(
        must=[
            FieldCondition(
                key="allowed_roles",
                match=MatchAny(any=user_roles)
            )
        ]
    )

    # 3. Search with MANDATORY filter (never omit this)
    results = client.search(
        collection_name="docs",
        query_vector=query_embedding,
        query_filter=rbac_filter,
        limit=k,
    )
    # User can only see docs where their role is in allowed_roles
    return results
πŸ—οΈ Full RAG Chain (LangChain)
"""Complete production RAG chain with citations."""
from langchain_anthropic import ChatAnthropic
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser

RAG_PROMPT = PromptTemplate.from_template("""
You are a helpful assistant. Answer the question using ONLY
the provided context. If the answer is not in the context,
say "I don't have information about that."

Always cite your sources at the end using [Source: filename, page N].

Context:
{context}

Question: {question}

Answer:""")

def build_rag_chain(retriever):
    llm = ChatAnthropic(
        model="claude-sonnet-4-6",
        temperature=0,  # deterministic for RAG
    )
    return (
        {"context": retriever, "question": lambda x: x}
        | RAG_PROMPT
        | llm
        | StrOutputParser()
    )

# With evaluation hooks:
def rag_with_eval(chain, retriever, query: str) -> dict:
    """Run RAG and capture retrieval metadata for eval."""
    docs = retriever.invoke(query)
    answer = chain.invoke(query)
    return {
        "query": query,
        "answer": answer,
        "retrieved_docs": [
            {"source": d.metadata.get("source"),
             "content": d.page_content[:200]}
            for d in docs
        ],
        "retrieval_count": len(docs),
        # Feed these to RAGAS for evaluation:
        # contexts=[d.page_content for d in docs],
    }
πŸ›‘οΈ PII Detection + Anonymization
"""PII detection and anonymization before indexing.
pip install presidio-analyzer presidio-anonymizer"""
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine

analyzer = AnalyzerEngine()
anonymizer = AnonymizerEngine()

PII_ENTITIES = [
    "PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER",
    "CREDIT_CARD", "US_SSN", "IP_ADDRESS",
    "LOCATION", "DATE_TIME",  # optional
]

def process_chunk_for_indexing(text: str) -> dict:
    """
    Analyze and optionally anonymize a chunk before indexing.
    Returns cleaned text + PII report for metadata.
    """
    results = analyzer.analyze(
        text=text,
        entities=PII_ENTITIES,
        language="en",
    )

    has_pii = len(results) > 0
    pii_types = list({r.entity_type for r in results})

    if has_pii:
        # Replace PII with type tokens e.g. [PERSON], [EMAIL]
        anonymized = anonymizer.anonymize(
            text=text,
            analyzer_results=results,
        )
        clean_text = anonymized.text
    else:
        clean_text = text

    return {
        "text_for_index": clean_text,  # use this for embedding
        "original_text": text,          # store separately if needed
        "has_pii": has_pii,
        "pii_types": pii_types,
        "metadata": {
            "pii_flag": has_pii,
            "pii_types": pii_types,
            # Increase access restriction if PII detected
            "min_access_level": "restricted" if has_pii else "internal",
        }
    }

# Usage in ingestion pipeline:
# for chunk in chunks:
#     processed = process_chunk_for_indexing(chunk["content"])
#     if processed["has_pii"]:
#         chunk["metadata"]["allowed_roles"] = ["hr", "admin"]
#     embed_and_index(processed["text_for_index"], chunk["metadata"])
πŸ“Š RAGAS Evaluation
"""Evaluate your RAG pipeline with RAGAS.
pip install ragas langchain-openai"""
from ragas import evaluate
from ragas.metrics import (
    faithfulness,          # does answer use only retrieved context?
    answer_relevancy,      # does answer address the question?
    context_precision,     # are retrieved chunks actually useful?
    context_recall,        # did we retrieve all relevant chunks?
)
from datasets import Dataset

def evaluate_rag_pipeline(
    queries: list[str],
    answers: list[str],
    contexts: list[list[str]],
    ground_truths: list[str] = None,
) -> dict:
    """
    Run RAGAS evaluation on a batch of RAG outputs.

    Args:
        queries:       The user questions
        answers:       The RAG-generated answers
        contexts:      List of retrieved chunks per query
        ground_truths: Expected answers (for context_recall)
    """
    data = {
        "question":  queries,
        "answer":    answers,
        "contexts":  contexts,
    }
    if ground_truths:
        data["ground_truth"] = ground_truths

    dataset = Dataset.from_dict(data)
    metrics = [faithfulness, answer_relevancy, context_precision]
    if ground_truths:
        metrics.append(context_recall)

    result = evaluate(dataset, metrics=metrics)
    return {
        "faithfulness":     result["faithfulness"],
        "answer_relevancy": result["answer_relevancy"],
        "context_precision":result["context_precision"],
        "context_recall":   result.get("context_recall"),
        "overall":          sum(result.values()) / len(result),
    }

# Interpretation:
# faithfulness     < 0.8 β†’ hallucination risk
# answer_relevancy < 0.8 β†’ prompt or retrieval issue
# context_precision< 0.7 β†’ too many irrelevant chunks retrieved
# context_recall   < 0.7 β†’ missing relevant chunks (chunking issue)
⚑ Multi-Query + HyDE Retrieval
"""Advanced retrieval patterns for hard queries."""
from langchain.retrievers import MultiQueryRetriever
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import PromptTemplate

llm = ChatAnthropic(model="claude-haiku-4-5", temperature=0.3)

# ── PATTERN 1: Multi-Query ──────────────────────────────────
# Generate N query variants to improve recall on ambiguous queries
MULTI_QUERY_PROMPT = PromptTemplate.from_template("""
Generate 3 different phrasings of this query that capture
the same intent but use different words.
Output as a numbered list.

Original query: {question}
""")

multi_query_retriever = MultiQueryRetriever.from_llm(
    retriever=base_retriever,
    llm=llm,
    prompt=MULTI_QUERY_PROMPT,
)
# Retrieves for all 3 variants, deduplicates results

# ── PATTERN 2: HyDE (Hypothetical Document Embeddings) ─────
# LLM writes a fake answer β†’ embed that β†’ search
# Works well for vague or abstract queries
HYDE_PROMPT = PromptTemplate.from_template("""
Write a short, factual passage that would answer this question.
Be specific, not vague. 2-3 sentences.

Question: {question}
Passage:""")

def hyde_retrieval(query: str, retriever, k: int = 5):
    """Generate hypothetical doc, embed it, search with that."""
    chain = HYDE_PROMPT | llm
    # Generate hypothetical answer
    hypo_doc = chain.invoke({"question": query}).content
    # Embed the hypothetical doc (not the original query)
    hypo_embedding = embeddings.embed_query(hypo_doc)
    # Search with the hypothetical embedding
    return retriever.vectorstore.similarity_search_by_vector(
        hypo_embedding, k=k
    )

# When to use which:
# Multi-query β†’ queries with multiple interpretations
# HyDE β†’ abstract or vague queries where exact match is unlikely
# Ensemble β†’ always (combine with above)
πŸ”Ž Metadata Self-Query Retriever
"""LLM extracts metadata filters from natural language queries.
'Show me HR docs updated after 2025' β†’ filter + semantic search"""
from langchain.retrievers.self_query.base import SelfQueryRetriever
from langchain.chains.query_constructor.base import AttributeInfo
from langchain_anthropic import ChatAnthropic

# Tell the LLM what metadata fields exist and their types
metadata_field_info = [
    AttributeInfo(
        name="department",
        description="Department that owns this doc: HR, Finance, Engineering, Legal",
        type="string",
    ),
    AttributeInfo(
        name="doc_type",
        description="Document type: policy, report, guide, procedure",
        type="string",
    ),
    AttributeInfo(
        name="updated_at",
        description="Last update date in YYYY-MM-DD format",
        type="string",
    ),
    AttributeInfo(
        name="access_level",
        description="Access restriction: public, internal, confidential, restricted",
        type="string",
    ),
    AttributeInfo(
        name="version",
        description="Document version number",
        type="string",
    ),
]

retriever = SelfQueryRetriever.from_llm(
    llm=ChatAnthropic(model="claude-haiku-4-5"),
    vectorstore=vectorstore,
    document_contents="Internal company documents including policies and reports",
    metadata_field_info=metadata_field_info,
    verbose=True,  # prints the generated filters for debugging
)

# These natural language queries auto-generate structured filters:
# "HR policies" β†’ filter: {department: "HR", doc_type: "policy"}
# "Finance reports from 2025" β†’ filter: {department: "Finance", updated_at: {$gte: "2025-01-01"}}
# "Latest version of the remote work guide" β†’ filter: {doc_type: "guide"} + semantic "remote work"