Background
Most developers who have built RAG systems have been through this cycle:
The demo stage goes well — a few test cases, results look promising. Then you roll it out on real business data and accuracy drops to 60% or worse. Users complain, and you can’t figure out where the problem is.
I spent three consecutive weeks staring at bad case analysis spreadsheets every evening. I tweaked the prompt — no improvement. I swapped the LLM — no improvement. I adjusted TopK — still nothing. Eventually I realized the problem wasn’t in the LLM at all. It was in the entire pipeline upstream of it.
This article walks through four optimization steps, each one delivering measurable accuracy gains, taking the system from 60% to 85%.
The Full-Pipeline Optimization Map
Before touching any code, lay out the entire pipeline. A RAG system isn’t two black boxes labeled “retrieve” and “generate.” It’s a precision assembly line where any single weak link corrupts the final output.
1 | User Input |
Below, each stage is broken down in priority order, highest first.
Step 1: Document Chunking
Why Chunking Is the Foundation
A lot of developers take the lazy route for document splitting: fixed token count, hard cut every 500 tokens, simple and brute-force. This works in a notebook, but it falls apart in production because a blind cut can split a single coherent piece of information in half:
- A complete knowledge point gets sliced mid-sentence
- A structured table gets separated into top half and bottom half
- A cause-and-effect pair gets split — the “because” in one chunk, the “so” in the next
When retrieval runs, it pulls back fragments of incomplete information. The LLM never sees the full context, so there’s no chance it produces the right answer.
It’s like looking up a word in a dictionary only to find the page torn in half. You see “this medicine is indicated for…” but the rest is gone. You know it’s indicated for something, but you don’t know what.
How the Industry Does It: Semantic-Aware Dynamic Chunking
Instead of a rigid token counter, use NLP-based semantic awareness to drive dynamic splitting. The core principle is simple: never let a single semantic unit span two chunks.
Here’s the three-step approach.
Step 1: Document Structure Parsing
Before splitting, parse the skeleton structure of the document. This requires a proper parsing model, not just regex.
1 | from langchain.text_splitter import RecursiveCharacterTextSplitter |
Step 2: Semantic Integrity Protection
Once the structure is parsed, splitting must respect semantic integrity:
| Rule | Description |
|---|---|
| Headings stay with body text | A heading must be bound to its paragraphs in the same chunk |
| Cause-and-effect stays together | “because…so…” and “if…then…” must remain in one chunk |
| Tables stay whole | Structured tables should be a single chunk, or split by row/column with structured decomposition |
| Lists stay intact | Ordered or unordered lists should stay in the same chunk wherever possible |
Step 3: Overlapping Context Windows
Even with semantic-aware splitting, adjacent chunks can have semantic gaps at the boundaries. The fix is an overlap window: each chunk retains the first and last 10%–20% of its content as an overlap zone with neighboring chunks.
The overlap between adjacent chunks is like the exchange zone in a relay race — two runners share a stretch of track together, so the baton doesn’t get dropped during the handoff.
1 | class SemanticChunker: |
Impact of This Step
On top of the fixed-token baseline, without touching the LLM at all, switching from brute-force splitting to semantic-aware dynamic chunking plus overlap windows delivers a 10–15 percentage point accuracy gain. That’s why I call it the highest ROI optimization in the entire pipeline.
Step 2: Query Processing
Real User Queries Can Be Brutally Vague
In lab testing, we tend to use well-formed questions: “What is this product’s refund policy?”
Real users, on the other hand, type things like:
- “how refund”
- “invoice rules”
- “validity period”
- “can return?”
Two or three characters, semantically extremely vague. If you run “invoice rules” through vector retrieval, the embedding model will produce a vector, but that vector points in a highly uncertain direction in semantic space. It might match “how to issue invoices,” or “time limits for invoice issuance,” or even “instructions for using the invoicing system.”
Direct retrieval on queries like these will never achieve high accuracy.
The Standard Approach: Query Expansion
The conventional solution is to use a small model to expand the user’s raw query into several synonymous or near-synonymous rewrites, run retrieval on each, then merge the results.
1 | def expand_query(original_query: str) -> list[str]: |
The approach is sound, but there’s a serious pitfall lurking here.
The Pitfall: Hallucination in the Rewrite Model
If the expansion model hallucinates, your entire retrieval gets derailed.
Consider these examples:
| Original Query | Correct Expansion | Hallucinated Expansion |
|---|---|---|
| how refund | How to apply for a refund | How to pay ← Opposite meaning! |
| invoice rules | Rules for issuing invoices | Process for not issuing invoices |
| validity period | Product validity period | What to do if expired ← Semantic drift |
“how refund” gets expanded to “how to pay” — one asks about refunds, the other about charges, completely opposite meanings. When this hallucinated expansion enters the retrieval pipeline, it doesn’t help — it introduces noise and pushes the correct answer out of the Top-K.
The elaborate retrieval system you built gets sabotaged by its own query expansion module.
The Safety Net: Cosine Similarity Validation
A defense layer is mandatory here: validate every expanded query against the original using semantic similarity scoring.
1 | from sentence_transformers import SentenceTransformer, util |
Where does the 0.8 threshold come from?
It’s an empirically validated value for general-purpose scenarios:
- ≥ 0.8: The rewrite preserves the original meaning, safe to use
- 0.6 ~ 0.8: Some semantic drift, needs business-specific judgment
- < 0.6: The rewrite has essentially drifted from the original meaning
Your mileage will vary depending on the domain. The recommended approach is to take 100 real user queries, manually label expansion quality, plot the similarity distribution, and find the optimal cutoff point.
Value of This Step
This step’s core value isn’t about raising the ceiling — it’s about holding the floor. It prevents the system from injecting noise through its own expansion module. Without this defense layer, the query expansion module is essentially a random noise injector, and system behavior becomes unpredictable.
Step 3: Hybrid Retrieval and Reranking
The Problem: Merging Scores from Two Dimensions
Everyone knows RAG needs hybrid retrieval: vector retrieval (Dense Retrieval) + keyword retrieval (BM25 Sparse Retrieval). Vector retrieval excels at semantic matching, BM25 excels at exact keyword matching — they complement each other.
But here’s the problem.
| Retrieval Method | Score Range | Example Score |
|---|---|---|
| Vector retrieval (cosine similarity) | [0, 1] | 0.87 |
| BM25 | [0, +∞) | 15.3 |
Vector retrieval scores are cosine similarity values between 0 and 1. BM25 scores can be tens or even hundreds. The two dimensions have completely different scales, and merging them requires resolving this scale conflict.
The naive approach is normalization followed by weighted sum:
1 | # Naive approach (not recommended) |
Both 0.7 and 0.3 are pulled out of thin air.
Different query types have completely different optimal weight distributions:
- For precise keyword queries like “which clause covers breach of contract,” BM25 should carry more weight
- For semantically vague queries like “who is this product suitable for,” vector retrieval should carry more weight
A fixed weight guessed from intuition means applying one strategy to every scenario — the results speak for themselves.
Industry Solution: LambdaMART Learning to Rank
The industry standard is to use a Learning to Rank model, and LambdaMART is one of the most mature and widely adopted algorithms in this space.
LambdaMART’s core idea: don’t rely on humans to guess weights — let the model learn them.
1 | ┌──────────────┐ |
What it does: take features from all retrieval channels (vector score, BM25 score, document length, title match score, position info, etc.) and map them to a single scoring dimension, producing a principled composite ranking score.
Why LambdaMART
Learning to rank falls into three categories:
| Method | Representative Algorithms | Characteristics |
|---|---|---|
| Pointwise | Linear regression, Logistic regression | Score each document independently, ignoring relative ordering |
| Pairwise | RankSVM, RankNet | Optimize relative ordering of document pairs |
| Listwise | LambdaMART, LambdaRank | Directly optimize ranking list metrics (e.g., NDCG) |
LambdaMART is a Listwise method — it directly optimizes ranking quality metrics like NDCG rather than scoring documents one at a time or in pairs. This works best for information retrieval because users care about the quality of the entire result list, not the absolute score of any single document.
Implementation
1 | import lightgbm as lgb |
Training data labeling isn’t complicated either: for a batch of real queries, label each candidate document’s relevance grade (0=irrelevant, 1=partially relevant, 2=fully relevant) and format it for learning-to-rank training.
Impact of This Step
Compared to fixed-weight linear fusion, LambdaMART reranking typically improves Recall@10 by 5–10 percentage points, with even more pronounced gains in NDCG@10. It’s also a lightweight model — inference latency is in the millisecond range, so it doesn’t affect production performance.
Step 4: Evaluation Metric Decomposition
Why “85% Accuracy” Isn’t Convincing
Boss: What’s the system accuracy? You: 85%.
Boss: What about the other 15%? You: Uh…
Quoting an overall accuracy number is meaningless on its own because you don’t know where the failures are: did retrieval miss the right answer? Did retrieval find it but the LLM failed to use it? Did the LLM just make something up?
Problems in different stages require completely different fixes. You need to break the metrics apart.
Two Core Metrics
For RAG system evaluation, tracking two metrics is sufficient.
Metric 1: Context Recall
Definition: Does the correct answer to the user’s question appear in the Top-N retrieved chunks?
In other words: did the retrieval stage find the “clues to the right answer”?
- If the correct answer isn’t in the Top-N → retrieval has a problem → optimize document chunking, query expansion, retrieval strategy
- If the correct answer is in the Top-N but the final answer is wrong → retrieval is fine, the problem is in the generation stage
1 | def compute_context_recall( |
Metric 2: Faithfulness
Definition: Is the LLM’s generated answer faithful to the retrieved sources? Or did it make things up?
This is what we commonly call the hallucination rate. If the retrieved sources clearly say A, but the LLM says B, that’s unfaithful.
1 | def compute_faithfulness( |
Driving Optimization with Decomposed Metrics
Once you monitor these two metrics, precise problem localization becomes possible:
| Context Recall | Faithfulness | Diagnosis | Optimization Direction |
|---|---|---|---|
| High | High | ✅ System is healthy | Maintain status quo |
| Low | High | Retrieval missed the target, generation is fine | Optimize chunking, query expansion, retrieval strategy |
| High | Low | Retrieval found the right stuff, but the LLM hallucinated | Optimize prompt, add citation constraints, try a different model |
| Low | Low | Problems across the pipeline | End-to-end systematic debugging |
This is how you engineer the system. Not blindly tuning parameters against a single aggregate number, but using decomposed metrics to pinpoint the failing stage and optimize surgically.
Results: Cumulative Gains from Four Steps
Stringing the four optimization steps together, here’s what a typical gain curve looks like:
1 | Accuracy |
| Optimization Step | Core Action | Typical Gain |
|---|---|---|
| Step 1: Chunking | Semantic-aware dynamic splitting + overlap windows | +10–15pp |
| Step 2: Query validation | Expansion + cosine similarity ≥ 0.8 fallback | Hold the floor, prevent regression |
| Step 3: Hybrid reranking | LambdaMART unified scoring | +5–10pp (Recall@10) |
| Step 4: Metric decomposition | Separate monitoring of Context Recall + Faithfulness | Precise localization, continuous iteration |
Closing Thoughts
The four optimization steps share a common trait: none of them require a bigger model or more expensive GPUs. They’re pure engineering improvements extracted from the existing pipeline. Document chunking solves the information fragmentation problem. Query validation holds the floor on input quality. LambdaMART reranking gives multi-channel retrieval scores a principled way to merge. Metric decomposition turns problem localization from guesswork into precise diagnosis.
Going from 60% to 85% after these four steps isn’t surprising. What I find more valuable is the methodology this process established: lay out the full pipeline, decompose it by priority, and quantify the gain at each step. Getting a RAG system from demo to production doesn’t come from a single breakthrough — it comes from engineering discipline applied across the entire pipeline.
References: