Agentic AI

RAG Optimization: From Metrics to Action

July 15, 2026 · 16 min read

Learn how to turn RAG evaluation into concrete pipeline improvements with hybrid search, metadata filters, chunking, reranking, query expansion, compression, and latency-aware optimization.

The trap most teams fall into

A team spent weeks optimizing their RAG pipeline. Every metric was green. Precision@K: 0.92. MRR: 0.88. Groundedness: 0.94. Their dashboard was a thing of beauty. Then users started complaining. ‘The answer is wrong.’ ‘It contradicts what I just read.’ The metrics said ‘success.’ The users said ‘broken.’ That’s when they learned that metrics without action are just beautiful lies.

RAG optimization is the part where metrics become engineering decisions.

What optimization really means ?

Optimization in RAG means changing the pipeline so the metrics move in the right direction.

That is the practical loop: measure, diagnose, change, re-evaluate, monitor.

The optimization loop:

Measure ───> Diagnose ───> Change ───> Re-evaluate ───> Monitor
▲ │
└───────────────────────────────────────────────────────────┘

If you skip diagnosis, you tune randomly. If you skip re-evaluation, you do not know whether the change helped.

The multi-stage retrieval framework

The strongest production systems do not rely on a single retriever. They split the workflow into specialized stages.

User Query ───> [ LLM Query Rewrite ] ───> [ Hybrid Search (Sparse + Dense) ]

Retrieves Top-50

[ Verified Output ] <─── [ Compressed Context ] <─── [ Reranker (Top-10) ]

This is the right pattern because broad retrieval improves recall, reranking improves precision, and compression controls token cost.

Start with the metric

Every optimization should begin with the metric that is failing.

SymptomLikely Metric ProblemFirst Fix
Wrong docs are retrievedLow precisionRerank or tighten filters
Important docs are missingLow recallExpand retrieval or rewrite query
Good context is ignoredLow groundednessImprove prompt or compression
Answer is off-topicLow answer relevanceRefine query understanding
System is too slowHigh latencyReduce context size or retrieval depth
Output is expensiveHigh token countCompress context and shorten answers

Query rewriting and expansion:

Query rewriting is one of the highest-leverage RAG optimizations. The user’s original query is often vague, underspecified, or too short for good retrieval. In production, rewriting is not just string concatenation. Teams often use an LLM to generate multiple semantic variants, sub-queries, or HyDE-style retrieval prompts.

from openai import AsyncOpenAI
from typing import List
import asyncio

class ProductionQueryRewriter:
def __init__(self, model: str = “gpt-4o-mini”):
self.client = AsyncOpenAI()
self.model = model

async def rewrite(self, original_query: str) -> List[str]:
system_prompt = “””You are a query expansion engine.
Generate 3 semantically distinct search queries for the user’s question.
Each should capture a different aspect or phrasing of the intent.
Return as a list of strings, one per line.”””

response = await self.client.chat.completions.create(
model=self.model,
messages=[
{“role”: “system”, “content”: system_prompt},
{“role”: “user”, “content”: f”Original query: {original_query}”}
],
temperature=0.7,
max_tokens=150
)

lines = response.choices[0].message.content.strip().split(‘\n’)
return [line.strip() for line in lines if line.strip()]

A rewritten query often improves recall before retrieval even runs.

Query expansion with synonyms:

Query expansion can help when the query vocabulary does not match the document vocabulary.

import nltk
from nltk.corpus import wordnet

def expand_query(query: str) -> str:
words = query.lower().split()
expanded = []

for word in words:
expanded.append(word)
synonyms = []
for syn in wordnet.synsets(word):
for lemma in syn.lemmas():
name = lemma.name().lower()
if name != word:
synonyms.append(name)
expanded.extend(synonyms[:3])

return ” “.join(expanded)

print(expand_query(“leave policy employee”))

For beginners, this is a useful baseline. For complex corpora, developers should pivot to HyDE or LLM-driven sub-query generation, because WordNet-style synonym expansion often injects irrelevant terms and dilutes vector meaning.

HyDE retrieval

HyDE is useful when the query is too short or vague for dense retrieval.

class HyDERetriever:
def __init__(self, llm_client, encoder):
self.llm = llm_client
self.encoder = encoder

async def hyde_search(self, query: str, top_k: int = 5) -> List[str]:
prompt = f”””Write a hypothetical document that would answer this question:
{query}
The document should be detailed and factual.”””

response = await self.llm.generate(prompt)
hypothetical_doc = response.text

hyde_embedding = self.encoder.encode([hypothetical_doc])
results = self.vector_db.search(hyde_embedding, top_k=top_k)
return results

HyDE is especially valuable when the user’s wording is thin but the intent is rich.

Hybrid search combines sparse retrieval, dense retrieval, and metadata awareness.

from sklearn.feature_extraction.text import TfidfVectorizer
from sentence_transformers import SentenceTransformer
import numpy as np
from typing import List, Dict

class HybridRetriever:
def __init__(self, documents: List[str]):
self.documents = documents
self.vectorizer = TfidfVectorizer(stop_words=’english’)
self.tfidf_matrix = self.vectorizer.fit_transform(documents)
self.encoder = SentenceTransformer(‘all-MiniLM-L6-v2’)
self.dense_embeddings = self.encoder.encode(documents)

def search(self, query: str, top_k: int = 5, sparse_weight: float = 0.3, dense_weight: float = 0.7) -> List[Dict]:
query_vector = self.vectorizer.transform([query])
sparse_scores = np.array(self.tfidf_matrix @ query_vector.T).flatten()

query_embedding = self.encoder.encode([query])
dense_scores = np.array([
np.dot(query_embedding, doc_emb)
for doc_emb in self.dense_embeddings
]).flatten()

sparse_scores = sparse_scores / (sparse_scores.max() + 1e-8)
dense_scores = dense_scores / (dense_scores.max() + 1e-8)

combined_scores = (sparse_weight * sparse_scores) + (dense_weight * dense_scores)
top_indices = np.argsort(combined_scores)[::-1][:top_k]

return [
{
“document”: self.documents[i],
“score”: float(combined_scores[i]),
“sparse_score”: float(sparse_scores[i]),
“dense_score”: float(dense_scores[i])
}
for i in top_indices
]

Metadata filtering:

Metadata filters are essential for enterprise RAG because many failures are really scope failures.

from typing import List, Dict, Optional

class MetadataFilteredRetriever:
def __init__(self, documents: List[Dict]):
self.documents = documents

def search(self, query: str, filters: Optional[Dict] = None, top_k: int = 5) -> List[Dict]:
filtered_docs = self.documents
if filters:
filtered_docs = [
doc for doc in self.documents
if all(doc.get(‘metadata’, {}).get(k) == v for k, v in filters.items())
]

query_terms = set(query.lower().split())
scored = []
for doc in filtered_docs:
text = doc[‘text’].lower()
score = sum(1 for t in query_terms if t in text)
scored.append((score, doc))

scored.sort(key=lambda x: x[0], reverse=True)
return scored[:top_k]

This is especially important for region-specific, department-specific, or year-specific policy search.

Structure-aware chunking:

Bad chunking can ruin a good retriever. Structure-aware chunking preserves meaning while keeping retrieval focused.

from typing import List, Dict
import re

def structure_aware_chunking(document: str, max_chunk_size: int = 500) -> List[Dict]:
chunks = []
sections = re.split(r'(?=^(?:#|Section|CHAPTER|Part)\s+)’, document, flags=re.MULTILINE)

current_chunk = “”
current_heading = “intro”

for section in sections:
if not section.strip():
continue

heading_match = re.match(r’^(#|Section|CHAPTER|Part)\s+(.+)$’, section.strip(), re.MULTILINE)
if heading_match:
if current_chunk:
chunks.append({
“heading”: current_heading,
“text”: current_chunk.strip(),
“length”: len(current_chunk.split())
})
current_chunk = “”
current_heading = heading_match.group(2).strip()
current_chunk = section
else:
if len(current_chunk.split()) + len(section.split()) <= max_chunk_size:
current_chunk += ” ” + section
else:
chunks.append({
“heading”: current_heading,
“text”: current_chunk.strip(),
“length”: len(current_chunk.split())
})
current_chunk = section

if current_chunk:
chunks.append({
“heading”: current_heading,
“text”: current_chunk.strip(),
“length”: len(current_chunk.split())
})

return chunks

Useful chunking rules:

Semantic reranking:

Reranking is one of the strongest ways to improve precision and MRR. But there is a trade-off: cross-encoder rerankers are slower than vector search because they score each query-document pair jointly.

The mechanical difference matters:

The practical rule is simple: retrieve broadly, then rerank only the top candidates.

from sentence_transformers import CrossEncoder
from typing import List, Dict

class SemanticReranker:
def __init__(self, model_name=”cross-encoder/ms-marco-MiniLM-L-6-v2″):
self.model = CrossEncoder(model_name)

def rerank(self, query: str, documents: List[str], top_k: int = 5) -> List[Dict]:
if not documents:
return []

pairs = [[query, doc] for doc in documents]
scores = self.model.predict(pairs)

ranked = sorted(
[{“document”: doc, “score”: float(score)} for doc, score in zip(documents, scores)],
key=lambda x: x[“score”],
reverse=True
)

return ranked[:top_k]

Reranking latency trade-off

Reranking improves MRR and precision, but it increases latency. The safest production pattern is to retrieve 50 to 100 candidates with vector search, then rerank only the top 10 or top 20.

That keeps the context high quality without turning the reranker into your main bottleneck.

Smart context compression

Truncation works, but summarization is better when the context is long.

from transformers import pipeline

class SmartCompressor:
def __init__(self):
self.summarizer = pipeline(“summarization”, model=”facebook/bart-large-cnn”)

def compress(self, chunks: List[str], max_words: int = 100) -> List[str]:
compressed = []
for chunk in chunks:
words = chunk.split()
if len(words) <= max_words:
compressed.append(chunk)
else:
try:
summary = self.summarizer(chunk, max_length=max_words, min_length=30, do_sample=False)
compressed.append(summary[0][‘summary_text’])
except:
compressed.append(” “.join(words[:max_words]) + “…”)
return compressed

In production, smaller models or API-based summarizers may be more practical than a local BART pipeline.

Lost in the middle mitigation

Long contexts can bury the most important evidence in the center of the prompt. A simple mitigation is to reorder chunks so the most relevant content appears near the edges.

from typing import List, Tuple

def prioritize_chunks(chunks: List[str], important_terms: List[str]) -> List[str]:
scored = []
for chunk in chunks:
score = sum(1 for term in important_terms if term.lower() in chunk.lower())
scored.append((score, chunk))

scored.sort(key=lambda x: x[0], reverse=True)
reordered = [chunk for _, chunk in scored]

if len(reordered) > 4:
result = reordered[:2] + reordered[4:] + reordered[2:4]
return result
return reordered

def contextual_summarization(chunks: List[str], query: str) -> str:
return “\n\n”.join(chunks[:3]) + “\n\n[Additional context follows]”

Latency reduction techniques:

def optimize_retrieval_depth(recall_metric: float, current_top_k: int) -> int:
if recall_metric > 0.95:
return max(5, current_top_k // 2)
elif recall_metric > 0.90:
return current_top_k
else:
return min(20, current_top_k * 2)

def optimize_chunk_size(precision_metric: float, current_size: int) -> int:
if precision_metric > 0.85:
return min(800, current_size + 200)
else:
return max(200, current_size – 100)

These simple rules show how metric movement can guide operational tuning.

Production RAG optimizer:

from typing import List, Dict
import asyncio

class ProductionRAGOptimizer:
def __init__(self, cross_encoder_client=None, llm_client=None):
self.encoder = cross_encoder_client
self.llm = llm_client

async def generate_expanded_queries(self, original_query: str) -> List[str]:
if self.llm:
return [
original_query,
f”Official documentation guidelines for {original_query}”,
f”Standard operating procedures and exceptions regarding {original_query}”
]

return [
original_query,
f”Official documentation guidelines for {original_query}”,
f”Standard operating procedures and exceptions regarding {original_query}”
]

def enforce_context_compression(self, retrieved_chunks: List[str], max_tokens_per_chunk: int = 50) -> List[str]:
compressed_payload = []
for chunk in retrieved_chunks:
tokens = chunk.split()
if len(tokens) > max_tokens_per_chunk:
compressed_payload.append(” “.join(tokens[:max_tokens_per_chunk]) + “…”)
else:
compressed_payload.append(chunk)
return compressed_payload

Cost-aware optimization logic:

def decide_optimization_path(current_cost_per_query: float, target_cost: float = 0.01) -> dict:
decisions = {}

if current_cost_per_query > target_cost:
decisions[“compression”] = True
decisions[“retrieval_depth”] = “reduce_to_20”
decisions[“reranking”] = “use_fast_model”
decisions[“query_rewrites”] = “reduce_to_2”
else:
decisions[“compression”] = False
decisions[“retrieval_depth”] = “keep_50”
decisions[“reranking”] = “use_accurate_model”
decisions[“query_rewrites”] = “use_3”

return decisions

print(decide_optimization_path(0.025))
print(decide_optimization_path(0.005))

Feedback-driven indexing:

Optimization does not stop with retrieval and reranking. The index itself should improve from failure patterns.

class FeedbackDrivenIndexer:
def __init__(self, vector_store, feedback_threshold: float = 0.3):
self.vector_store = vector_store
self.threshold = feedback_threshold
self.failures = []

def log_failure(self, query: str, retrieved_docs: List[str], wrong_answer: str, correct_answer: str):
self.failures.append({
“query”: query,
“retrieved”: retrieved_docs,
“wrong”: wrong_answer,
“correct”: correct_answer
})

def refresh_index(self):
new_documents = self._generate_synthetic_examples()
self.vector_store.add_documents(new_documents)
self.failures.clear()

def _generate_synthetic_examples(self):
examples = []
for failure in self.failures:
examples.append({
“text”: f”Q: {failure[‘query’]}\nA: {failure[‘correct’]}”,
“metadata”: {“type”: “synthetic”, “corrected”: True}
})
return examples

Retrieval-generation correlation analysis:

This is one of the fastest ways to understand whether the problem is retrieval or generation.

from typing import List, Dict

def analyze_pipeline_performance(eval_results: List[Dict]) -> Dict:
good_retrieval_bad_generation = 0
bad_retrieval_good_generation = 0
both_good = 0
both_bad = 0

for result in eval_results:
retrieval_ok = result[“retrieval”][“precision”] > 0.7
generation_ok = result[“response”][“groundedness”] > 0.8

if retrieval_ok and not generation_ok:
good_retrieval_bad_generation += 1
elif not retrieval_ok and generation_ok:
bad_retrieval_good_generation += 1
elif retrieval_ok and generation_ok:
both_good += 1
else:
both_bad += 1

total = len(eval_results) if eval_results else 1
return {
“retrieval_problem”: bad_retrieval_good_generation / total,
“generation_problem”: good_retrieval_bad_generation / total,
“both_good”: both_good / total,
“both_bad”: both_bad / total,
“recommendation”: (
“Fix retrieval” if bad_retrieval_good_generation > 0.3 else
“Fix generation” if good_retrieval_bad_generation > 0.3 else
“Both need work” if both_bad > 0.3 else
“System is healthy”
)
}

Symptom-driven diagnostic matrix:

Production SymptomFailing System MetricPipeline BottleneckMitigating Action
Critical documentation chunks are missing completelyLow Recall@KVocabulary mismatch or narrow query scopeHybrid search + LLM query rewriting
Top retrieved snippets contain noisy fillerLow Precision@K / MRRWeak vector-space rankingAdd semantic reranking
The model is fluent and polite, but factually incorrectLow GroundednessWeak evidence anchoringUse strict delimiters and compression
Retrieval works, but response breaks UX expectationsHigh p95 LatencyToo many candidates or too much contextCap reranker input and compress context

def estimate_evaluation_cost(eval_runs: int, tokens_per_run: int, cost_per_1k_tokens: float = 0.002) -> dict:
total_tokens = eval_runs * tokens_per_run
total_cost = total_tokens * cost_per_1k_tokens / 1000
return {
“eval_runs”: eval_runs,
“tokens_per_run”: tokens_per_run,
“total_tokens”: total_tokens,
“estimated_cost_usd”: round(total_cost, 3)
}

print(estimate_evaluation_cost(1000, 500))

When to use which optimization ?

ProblemBest Fix
Low recallQuery rewriting, hybrid search, broader candidate pool
Low precisionReranking, metadata filters, better chunking
Low groundednessBetter context, stronger prompt constraints, verification
High latencyReduce chunk count, compress context, fewer retrieval hops
Repeated misses on specific topicsFeedback-driven indexing
Off-topic answersQuery refinement and answer relevance checks

This matters a lot in enterprise systems where the knowledge base may contain English policies, regional updates, office-specific exceptions, and code-switched content. If your system relies strictly on dense vector search, it can miss documents with exact legal terms, local codes, or regional policy labels.

Hybrid retrieval plus metadata tagging is the cleanest way to recover context while keeping token usage under control.

Quick-reference card

Glossary

RAG optimization is not about changing the model first. It is about changing the pipeline around the model so the metrics move in the right direction. If recall is low, fix retrieval. If precision is low, fix ranking. If groundedness is low, fix evidence quality. If latency is high, fix context size. The best RAG teams do not chase clever prompts first; they turn metrics into concrete system changes and then remeasure.

 

0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
// STILL BROWSING?
Build along, don't just read.
Get labs & articles matched to what you're into — free, takes 30 seconds.
Start building free
0
Would love your thoughts, please comment.x
()
x