Phase 4: Retrieval Quality & Grounded Answers

Phase 4: Retrieval Quality & Grounded Answers

From "Closest Match" to "Answer You Can Actually Trust" The Story Starts: "Why Did It Confidently Lie to Me?" πŸ‘¦ Nephew: Uncle, I tested our RAG system this week. I asked about a leave policy that doesn't exist in any of our documents. Instead of saying "I don't know," it made up a completely fake answer. Confidently. With a fake number of days! πŸ‘¨β€πŸ¦³ Uncle: Welcome to the most common failure in RAG systems β€” and the exact reason Phase 4 exists. Tell me β€” in Phase 3, what did Top-K retrieval actually guarantee? πŸ‘¦ Nephew: It... returns the 5 closest vectors? πŸ‘¨β€πŸ¦³ Uncle: Say that sentence again, slowly, and notice what it does NOT say. πŸ‘¦ Nephew: It doesn't say those 5 are actually... relevant. Just that they're the closest of whatever exists. πŸ‘¨β€πŸ¦³ Uncle: Exactly the gap. Even if your database has zero chunks about leave policy, Top-K will still confidently hand back 5 chunks β€” probably about something completely unrelated, like office timings or dress code β€” because "closest" is a relative ranking, not a quality guarantee. The LLM then does what LLMs do: it takes whatever context it's given and tries its best to answer from it, even when that context is garbage. That's not the LLM's fault. That's a retrieval design flaw. Today we fix it. Phase 4 Overview: Four Key Concepts Phase 3: Storage, Indexing & Token Economics βœ… ↓ Phase 4: Retrieval Quality & Grounded Answers ← WE ARE HERE β”‚ β”œβ”€ Step 1: Reranking β”‚ (Retrieve cheap & wide, then score properly) β”‚ β”œβ”€ Step 2: Relevance Threshold / Abstention β”‚ (Knowing when to say "I don't know") β”‚ β”œβ”€ Step 3: Grounded Prompt Assembly with Citations β”‚ (Every claim traceable to a source) β”‚ └─ Step 4: Hybrid Search as a Safety Net (Catching what vector search misses) ↓ Phase 5: Evaluation & Guardrails at Scale Enter fullscreen mode Exit fullscreen mode Step 1: Reranking β€” Retrieve Wide, Then Score Properly Why "Closest" Isn't "Most Relevant" πŸ‘¨β€πŸ¦³ Uncle: Let's go back to your resume example from Phase 2. Vector similarity is fast because it's approximate β€” that's the whole point of the ANN index we built in Phase 3. But "fast and approximate" means the ranking among your Top-20 candidates can genuinely be a bit sloppy at the edges. Query: "What is the notice period for resignation?" Vector search Top-5 (by cosine similarity): 1. "Notice period starts from submission date" (0.89) 2. "Employees must inform HR before leaving" (0.85) 3. "Resignation letters must be signed by manager" (0.84) 4. "Office holiday calendar for 2025" (0.81) ← WRONG, but close enough to sneak in 5. "Exit interview process overview" (0.79) Enter fullscreen mode Exit fullscreen mode πŸ‘¦ Nephew: Wait β€” the holiday calendar chunk scored 0.81? That's completely unrelated! πŸ‘¨β€πŸ¦³ Uncle: This happens more than beginners expect. Embedding models compress meaning into 1536 numbers β€” some unrelated chunks accidentally land "nearby" in that space due to shared vocabulary, formatting, or document structure, even when a human would instantly see they don't answer the question. Vector search is a wide net, not a precise judge. The Fix: Two-Stage Retrieval πŸ‘¨β€πŸ¦³ Uncle: The production pattern is always two stages, never one: STAGE 1 β€” Retrieve (cheap, wide, approximate) ───────────────────────────────────────── Vector search β†’ Top-20 candidates Fast (uses HNSW index from Phase 3) Casts a wide net, some noise expected STAGE 2 β€” Rerank (expensive, narrow, accurate) ───────────────────────────────────────── A RERANKER model looks at the actual QUERY + each CANDIDATE CHUNK together, and scores relevance directly β†’ Keep only Top-5 after reranking Slower per-item, but far more accurate Enter fullscreen mode Exit fullscreen mode πŸ‘¦ Nephew: What makes a reranker more accurate than the embedding similarity we already had? πŸ‘¨β€πŸ¦³ Uncle: Here's the key architectural difference, and it's worth understanding properly: Embedding model (bi-encoder): Query β†’ [vector A] ─┐ β”œβ”€ compare AFTER, separately Chunk β†’ [vector B] β”€β”˜ The model NEVER sees query and chunk together. Reranker (cross-encoder): [Query + Chunk] β†’ fed into model TOGETHER β†’ model directly outputs a relevance score The model sees BOTH at once, so it can reason about how they actually relate β€” not just how "nearby" their independently-computed vectors happen to be. Enter fullscreen mode Exit fullscreen mode πŸ‘¨β€πŸ¦³ Uncle: A cross-encoder is slower β€” you can't pre-compute anything, because it needs the query at scoring time β€” which is exactly why you never run it against millions of chunks directly. You run it only against the small Top-20 that vector search already narrowed down for you. Cheap net first, expensive judge second. Code: Reranking with Cohere's Rerank API npm install cohere-ai Enter fullscreen mode Exit fullscreen mode const { CohereClient } = require("cohere-ai"); const cohere = new CohereClient({ token: process.env.COHERE_API_KEY }); async function rerankChunks(query, candidates, topN = 5) { // candidates = [{ chunk_text, metadata, similarity }, ...] (Top-20 from vector search) const response = await cohere.rerank({ model: "rerank-english-v3.0", query: query, documents: candidates.map(c => c.chunk_text), topN: topN, }); // response.results gives back indices INTO our original array, // re-sorted by true relevance score return response.results.map(result => ({ ...candidates[result.index], rerank_score: result.relevanceScore, })); } // Usage: // const top20 = await vectorSearch(queryEmbedding, { limit: 20 }); // const top5 = await rerankChunks(userQuestion, top20, 5); Enter fullscreen mode Exit fullscreen mode πŸ‘¦ Nephew: Why 20 candidates and not, say, 5 straight from vector search? πŸ‘¨β€πŸ¦³ Uncle: Because if the true best chunk was sitting at position 8 in the approximate vector ranking (remember β€” approximate!), and you only ever pulled Top-5, the reranker never even gets a chance to see it. Retrieving wider (Top-20 or Top-30) before reranking gives the accurate judge enough candidates to actually find the right answer, even when the fast-but-approximate first pass ranked it imperfectly. Step 2: Relevance Threshold β€” Knowing When to Say "I Don't Know" πŸ‘¨β€πŸ¦³ Uncle: Now the fix for your original problem β€” the fake leave policy answer. Reranking alone doesn't solve it, because even after reranking, the system will still confidently hand back its "Top-5 best of what exists" β€” even if none of them are actually good. πŸ‘¦ Nephew: So we need... a minimum bar? Like, "if nothing scores above X, don't even bother answering"? πŸ‘¨β€πŸ¦³ Uncle: Precisely. This is called a relevance threshold, and it's one of the highest-leverage, most-skipped guardrails in RAG systems. const RELEVANCE_THRESHOLD = 0.5; // tune per your reranker's score distribution async function retrieveWithAbstention(query) { const top20 = await vectorSearch(query, { limit: 20 }); const reranked = await rerankChunks(query, top20, 5); const bestScore = reranked[0]?.rerank_score ?? 0; if (bestScore `[${i + 1}] (Source: ${c.metadata.source_file}, ${c.metadata.department})\n${c.chunk_text}`) .join("\n\n"); return `You are answering questions using ONLY the numbered sources below. ${contextBlock} Instructions: - Answer using ONLY the information in the sources above. - After each claim, cite the source number in brackets, like [1]. - If the sources don't fully answer the question, say so explicitly β€” do not guess or use outside knowledge. Question: ${query} Answer:`; } Enter fullscreen mode Exit fullscreen mode Example LLM output with this prompt: "The notice period is 30 days, starting from the date of submission [1][2]. Resignation also requires manager approval before it is finalized [3]." Enter fullscreen mode Exit fullscreen mode πŸ‘¦ Nephew: Now I can map [1], [2], [3] straight back to the actual source_file and department in metadata! πŸ‘¨β€πŸ¦³ Uncle: Exactly β€” and this is where Phase 3's metadata design finally pays off in the user-facing product. You can render those citations as clickable links back to the original document, which is the difference between "an AI said so" and "here's exactly where this comes from, go verify it yourself." Code: Parsing Citations Back to Sources function attachCitationSources(llmAnswer, chunks) { const citationPattern = /\[(\d+)\]/g; const usedIndices = new Set(); let match; while ((match = citationPattern.exec(llmAnswer)) !== null) { usedIndices.add(parseInt(match[1], 10) - 1); } const sources = [...usedIndices].map(i => ({ text: chunks[i]?.chunk_text, file: chunks[i]?.metadata?.source_file, department: chunks[i]?.metadata?.department, })); return { answer: llmAnswer, sources }; } Enter fullscreen mode Exit fullscreen mode Step 4: Hybrid Search β€” Catching What Vector Search Misses The Blind Spot: Exact Terms πŸ‘¨β€πŸ¦³ Uncle: One more honest weakness. Say someone asks: "What does error code ERR_4521 mean?" πŸ‘¦ Nephew: Vector search should handle that fine, right? It's just text. πŸ‘¨β€πŸ¦³ Uncle: Try it in your head using what you learned in Phase 2. Embedding models are trained to understand meaning and concepts β€” "leadership" relating to "team management." But "ERR_4521" isn't a concept β€” it's an exact, arbitrary identifier. The embedding model has no real semantic understanding of a specific error code string; it might embed it as "some kind of technical identifier" and miss the one chunk that specifically documents ERR_4521, especially if that chunk's surrounding language doesn't semantically resemble the question. πŸ‘¦ Nephew: So the same weakness applies to product codes, order numbers, exact names... πŸ‘¨β€πŸ¦³ Uncle: Exactly β€” anything where exact match matters more than meaning. This is precisely where old-fashioned keyword search still wins, and it's why production systems don't choose one or the other β€” they run both, and combine the results. That's hybrid search. Architecture: Running Both Searches Together User Query: "What does error code ERR_4521 mean?" ↓ β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β” ↓ ↓ Vector Search Keyword Search (BM25 / full-text) (semantic) (exact term matching) ↓ ↓ Top-10 Top-10 β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ ↓ Merge & Deduplicate ↓ Rerank the COMBINED set (Step 1) ↓ Final Top-5 Enter fullscreen mode Exit fullscreen mode Code: Postgres Full-Text Search Alongside pgvector -- Add a full-text search column (once, at table setup time) ALTER TABLE document_chunks ADD COLUMN chunk_tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', chunk_text)) STORED; CREATE INDEX idx_chunk_tsv ON document_chunks USING GIN (chunk_tsv); -- Keyword search query SELECT chunk_text, metadata, ts_rank(chunk_tsv, plainto_tsquery('english', $1)) AS keyword_score FROM document_chunks WHERE chunk_tsv @@ plainto_tsquery('english', $1) ORDER BY keyword_score DESC LIMIT 10; Enter fullscreen mode Exit fullscreen mode Node.js: Combining Both Result Sets async function hybridSearch(query, queryEmbedding) { const [vectorResults, keywordResults] = await Promise.all([ db.query( `SELECT chunk_text, metadata, 1 - (embedding $1) AS score FROM document_chunks ORDER BY embedding $1 LIMIT 10`, [queryEmbedding] ), db.query( `SELECT chunk_text, metadata, ts_rank(chunk_tsv, plainto_tsquery('english', $1)) AS score FROM document_chunks WHERE chunk_tsv @@ plainto_tsquery('english', $1) ORDER BY score DESC LIMIT 10`, [query] ), ]); // Merge, de-duplicating by chunk_text, keeping the highest score seen const merged = new Map(); for (const row of [...vectorResults.rows, ...keywordResults.rows]) { const key = row.chunk_text; if (!merged.has(key) || merged.get(key).score < row.score) { merged.set(key, row); } } const combined = [...merged.values()]; return rerankChunks(query, combined, 5); // Step 1's reranker makes final sense of it } Enter fullscreen mode Exit fullscreen mode πŸ‘¦ Nephew: So hybrid search doesn't replace reranking β€” it feeds reranking a better, more complete set of candidates first? πŸ‘¨β€πŸ¦³ Uncle: Exactly right. All four steps today form one pipeline, not four separate options: hybrid search casts the widest, smartest net (semantic and exact); reranking judges that combined set accurately; the relevance threshold decides whether any of it is good enough to answer from; and grounded prompting makes sure whatever answer comes out is traceable. Complete Query-Time Flow, End to End User Question ↓ Embed query (Phase 2) ↓ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ HYBRID SEARCH (Step 4) β”‚ β”‚ Vector search (Top-10) β”‚ β”‚ + Keyword/BM25 search (Top-10) β”‚ β”‚ β†’ merge & deduplicate β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ↓ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ RERANKING (Step 1) β”‚ β”‚ Cross-encoder scores combined set β”‚ β”‚ β†’ Top-5 by true relevance β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ↓ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ RELEVANCE THRESHOLD (Step 2) β”‚ β”‚ Best score < threshold? β”‚ β”‚ β†’ YES: return "I don't know" β”‚ β”‚ β†’ NO: continue β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ↓ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ GROUNDED PROMPT (Step 3) β”‚ β”‚ Numbered, attributable context β”‚ β”‚ β†’ LLM generates cited answer β”‚ β”‚ β†’ Parse citations back to sources β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ↓ Final Answer + Verifiable Sources Enter fullscreen mode Exit fullscreen mode Interview-Level Answers Q1: Why do production RAG systems retrieve more chunks than they actually use? πŸ‘¨β€πŸ¦³ Uncle: "Because the initial retrieval (vector search over an ANN index) is fast but approximate β€” the true best match isn't guaranteed to rank in the very top positions. By retrieving a wider candidate set (e.g., Top-20) and then applying a more accurate but slower reranking model, the system gets a chance to correctly identify the best matches that the initial approximate search may have under-ranked, before narrowing down to the final Top-K actually sent to the LLM." Q2: What's the difference between a bi-encoder and a cross-encoder, and why does it matter for reranking? "A bi-encoder β€” the model used for the original embeddings β€” encodes the query and each document independently into vectors, which are compared afterward using something like cosine similarity. This is fast and can be precomputed, which is why it's used for the initial large-scale search. A cross-encoder, used for reranking, takes the query and a candidate document together as a single input and directly outputs a relevance score, allowing it to reason about their relationship more accurately β€” at the cost of being too slow to run against millions of documents directly, which is why it's only applied to the small candidate set the bi-encoder already narrowed down." Q3: How do you prevent a RAG system from hallucinating when no relevant document exists? "By applying a relevance threshold after retrieval and reranking β€” if the best-scoring retrieved chunk falls below an empirically-tuned minimum relevance score, the system should explicitly abstain and respond that it doesn't have relevant information, rather than passing weak or unrelated context to the LLM and letting it generate a plausible-sounding but ungrounded answer. This threshold is typically tuned by examining reranker score distributions across known answerable versus known unanswerable questions." Q4: Why combine vector search with keyword search instead of relying on embeddings alone? "Embedding models excel at capturing semantic meaning and conceptual relationships, but they can underperform on exact-match needs like product codes, error codes, or specific identifiers, since these carry little inherent 'meaning' for the model to encode. Keyword-based search (e.g., BM25 or Postgres full-text search) reliably catches exact term matches. Running both searches and merging their results before reranking β€” hybrid search β€” combines semantic understanding with exact-match reliability, covering each other's blind spots." Q5: What does "grounded" mean in the context of a RAG-generated answer? "A grounded answer is one where every factual claim is explicitly traceable to a specific retrieved source, typically enforced by instructing the LLM to cite source numbers inline and restricting it to only use the provided context. This allows the system (and the end user) to verify exactly where each part of an answer came from, rather than trusting an unverifiable claim β€” which is especially critical in compliance-sensitive domains like HR, legal, or financial documentation." The Complete Architecture So Far PHASE 1: DOCUMENT INGESTION βœ… ───────────────────────────── PDF Upload β†’ File Hash Check β†’ Parse & Clean β†’ Chunking β†’ Deduplication β†’ Store Chunk Text PHASE 2: EMBEDDINGS & SEMANTIC SEARCH βœ… ───────────────────────────── Chunk Text β†’ Tokenization β†’ Embedding Layer β†’ Vector β†’ Cosine Similarity β†’ Top-K Retrieval PHASE 3: STORAGE, INDEXING & TOKEN ECONOMICS βœ… ───────────────────────────── Vector + Metadata β†’ Postgres (pgvector) β†’ HNSW/IVFFlat Index β†’ Metadata Indexing (GIN/generated columns) β†’ Pre-filtering β†’ Token Economics (dedup, batching, caching) PHASE 4: RETRIEVAL QUALITY & GROUNDED ANSWERS ← YOU ARE HERE ───────────────────────────── Hybrid Search (vector + keyword) β†’ merge candidates ↓ Reranking (cross-encoder) β†’ true relevance scoring ↓ Relevance Threshold β†’ abstain if nothing qualifies ↓ Grounded Prompt Assembly β†’ numbered, cited context ↓ LLM Answer + Parsed Citations back to source documents PHASE 5: EVALUATION & GUARDRAILS AT SCALE (next) ───────────────────────────── Precision/Recall metrics β†’ Groundedness scoring β†’ Hallucination detection β†’ Prompt injection defense β†’ Cost & latency observability Enter fullscreen mode Exit fullscreen mode Summary: What Phase 4 Solves Problem Phase 3 Phase 4 Fast search at scale βœ… ANN indexing βœ… Unchanged, still relies on it Ranking accuracy among candidates ❌ Approximate only βœ… Reranking (cross-encoder) Confidently answering with no basis ❌ Not addressed βœ… Relevance threshold / abstention Traceable, verifiable answers ❌ Not addressed βœ… Grounded prompts with citations Exact-match terms (codes, IDs) ❌ Semantic search misses these βœ… Hybrid search (vector + keyword) Key Takeaways Vector search finds "closest," not "correct" β€” reranking closes that gap Bi-encoder (embeddings) = fast, precomputed, independent. Cross-encoder (reranker) = slow, accurate, sees query+chunk together Retrieve wide (Top-20), rerank narrow (Top-5) β€” never rerank straight from a Top-5 vector search A relevance threshold is what stops your system from confidently answering from garbage context Grounded prompting = numbered sources + citation instructions + parsing citations back to metadata Hybrid search (vector + keyword/BM25) catches exact-match terms that pure semantic search misses The four steps form one pipeline: hybrid search β†’ rerank β†’ threshold β†’ grounded prompt β€” not four separate optional features Next: Phase 5 β€” Evaluation & Guardrails at Scale Now that you understand: Why "closest" and "correct" aren't the same thing How reranking and relevance thresholds turn approximate search into trustworthy retrieval How to make every answer traceable back to its source Why hybrid search exists alongside vector search, not instead of it We'll implement Phase 5 in Node.js: Measuring retrieval quality with precision/recall against a test question set Automated groundedness/faithfulness scoring (does the answer actually match the cited sources?) Detecting prompt injection hidden inside retrieved documents Tracing and cost/latency observability for a production RAG API Ready? Remember: Less noise, more action. Phase 4 is where a RAG system stops guessing and starts knowing when it actually knows something.

Original Source

Read the full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.