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 < RELEVANCE_THRESHOLD) {
return {
hasRelevantContext: false,
chunks: [],
};
}
return {
hasRelevantContext: true,
chunks: reranked,
};
}
Enter fullscreen mode Exit fullscreen mode
Then, at prompt-assembly time:
async function answerQuestion(query) {
const { hasRelevantContext, chunks } = await retrieveWithAbstention(query);
if (!hasRelevantContext) {
return "I don't have information about that in the available documents. Could you check with HR directly, or rephrase your question?";
}
// proceed to Step 3 β build a grounded prompt from `chunks`
}
Enter fullscreen mode Exit fullscreen mode
π¦ Nephew: How do I pick the actual threshold number? 0.5 feels arbitrary.
π¨β𦳠Uncle: It is somewhat empirical, and that’s honest to say β you tune it against your own data. A practical way: run a batch of known “should answer” questions and known “should NOT be answerable” questions through your reranker, look at the score distributions for each group, and pick a threshold that sits in the gap between them. Revisit it periodically β as your document set grows, the distribution can shift.
Mental model β the bouncer at the door: Vector search invites 20 people who “sort of look like” they belong. The reranker checks IDs properly. The threshold is the bouncer’s rule: “if literally nobody in this line actually qualifies, don’t let anyone in β just close the door and say so.” Without that bouncer, your system politely waves in whoever’s closest to the front of the line, qualified or not.
Step 3: Grounded Prompt Assembly with Citations
The Problem with a Plain Context Dump
π¨β𦳠Uncle: Even with great chunks, how you hand them to the LLM matters. A lazy prompt looks like this:
Context:
Employees receive 30 days notice period.
Notice period starts from submission date.
Manager approval required for resignation.
Question: What is the notice period?
Enter fullscreen mode Exit fullscreen mode
π¦ Nephew: What’s wrong with that? It has the right chunks.
π¨β𦳠Uncle: Nothing technically wrong β but if the LLM’s answer is later questioned (“where did you get 30 days from?”), you have no way to trace the claim back to a specific source. In a compliance-sensitive domain β HR policy, legal, medical, financial β “trust me” isn’t good enough. You need traceability.
The Fix: Numbered, Attributable Context
function buildGroundedPrompt(query, chunks) {
const contextBlock = chunks
.map((c, i) => `[${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.
λ΅κΈ λ¨κΈ°κΈ°