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")
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)
Model
Dims
Max Tokens
Best For
Cost
text-embedding-3-small (OpenAI)
1536
8191
General purpose, low cost
$0.02/1M tok
text-embedding-3-large (OpenAI)
3072
8191
High accuracy, longer docs
$0.13/1M tok
voyage-3 (Anthropic/Voyage)
1024
32000
Long docs, RAG-optimized
$0.06/1M tok
embed-v4 (Cohere)
1024
512
Multilingual, enterprise
$0.10/1M tok
nomic-embed-text (local)
768
8192
Open-source, self-hosted
Free
BGE-M3 (local)
1024
8192
Multilingual, hybrid-native
Free
π‘
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
Strategy
How It Works
Best For
Gotcha
Fixed-size (naive)
Split every N chars/tokens
Quick prototypes
Cuts mid-sentence, destroys context
RecursiveCharacter
Split at \n\n β \n β ". " β " " in order
Default for most cases
Still size-based, not semantic
Sentence-based
Split at sentence boundaries
Fact-dense docs (Q&A, FAQs)
Variable size β small sentences = tiny chunks
Semantic chunking
Split when embedding similarity drops between sentences
Complex narratives, articles
Slow (needs embeddings at index time)
Parent-Child (small-to-big)
Small chunks for retrieval; return larger parent for context
Best precision + context combo
More complex pipeline, 2 indexes
Hierarchical / TreeRAG
Build summary tree; retrieve at appropriate level
Long docs, Q&A at different scopes
High complexity, expensive to build
Late Chunking
Embed entire doc first, then pool embeddings into chunks
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.
Algorithm
Speed
Recall
Use When
HNSW
Very fast
High
Default for most cases
IVF-Flat
Fast
Medium
Huge datasets (>100M)
Exact search
Slow
100%
<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
Extract only the relevant sentence(s) from each retrieved chunk
Reduce context window consumption
Multi-Query Retrieval
Generate N query variants β retrieve for each β merge
Ambiguous queries, low recall
HyDE (Hypothetical Document)
LLM generates a hypothetical answer β embed that β search
Abstract or vague queries
Ensemble Retrieval
Run multiple retrievers β fuse results (RRF)
High-accuracy pipelines
Self-Query Retrieval
LLM converts query into structured filter + semantic search
Queries 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 constantdefrrf_fusion(
results_list: list[list],
k: int = 60
) -> list:
scores = {}
for results in results_list:
for rank, doc_id inenumerate(results):
if doc_id not in scores:
scores[doc_id] = 0
scores[doc_id] += 1 / (k + rank + 1)
returnsorted(
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.
Qdrant or Weaviate + hybrid search + Cohere reranker
Enterprise with RBAC
Milvus or Elasticsearch + metadata filtering + audit logging
Long documents (contracts)
Parent-Child retriever + voyage-3 (32K context)
Low-latency requirement
Pre-filter metadata + HNSW + embedding cache
Air-gapped / privacy-first
pgvector + 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β¦
π Retrieval Scores β which chunks matched best
π‘οΈ
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
π RBAC Retrieval Report
π Document Access Matrix (the knowledge base)
Document
Content
π€ Employee
π Manager
π’ Executive
π§βπΌ HR
Vacation Policy
15 days/yr, accrual rules
β
β
β
β
Remote Work Policy
Up to 3 days/week remote
β
β
β
β
Department Budget
Team-level budget breakdown
β
β
β
β
Q3 Revenue Report
$14.2M revenue, 12% YoY
β
β
β
β
Salary Bands
Compensation by level
β
β
β
β
Performance Reviews
Individual review summaries
β
β
β
β
π Production-Ready Snippets
Copy these into your RAG project. Each is self-contained and tested.
"""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"