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
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)
π¦ 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
π¦ 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.
π¨β𦳠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
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);
π¦ 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,
};
}
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`
}
π¦ 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?
π¦ 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:`;
}
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]."
π¦ 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 };
}
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
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;
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
}
π¦ 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
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
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.
Top comments (10)
In hybridSearch the merge compares cosine similarity against ts_rank, and those two live on pretty different scales. Does that matter here, or does handing the whole merged set to the reranker make the merge order irrelevant anyway? Asking because the usual fix I see for this is reciprocal rank fusion, and I wonder if a cross-encoder afterwards just makes it unnecessary.
Good catch. You're absolutely right that cosine similarity and
ts_rankaren't on the same scale, so comparing the raw scores directly isn't meaningful.In this simplified example, the merge step is only intended to create a union of candidates for the reranker, which becomes the final judge of relevance. Because of that, the exact score values from the first-stage retrievers aren't used for the final ranking.
That said, in a production system I'd lean toward Reciprocal Rank Fusion (RRF) or another rank-fusion strategy before reranking. RRF avoids score calibration problems and gives a more balanced candidate set from both semantic and keyword retrieval. Thanks for pointing that outβit's definitely worth mentioning in the next phase.
"Great explanation! I really liked how you explained that retrieval is not just about finding the closest match but finding the most relevant context. The part about reranking and relevance thresholds makes a lot of sense because many beginners ignore the 'I don't know' behavior in RAG systems. Looking forward to the next phase!"
Thank you! I'm really glad that distinction came across clearly.
One thing that surprised me while learning RAG was realizing that "closest" doesn't necessarily mean "good enough." That small gap is responsible for a lot of confident hallucinations people blame on the LLM, when the retrieval pipeline is often the real issue.
I'm excited for Phase 5 because that's where we start measuring whether these techniques actually improve production quality instead of just assuming they do.
The distinction between relative ranking and an absolute quality bar is the key lesson here. One production refinement: calibrate abstention on the entire answer contract, not only the top reranker score. A high-scoring chunk can still support only part of a compound question. I would evaluate claim-level coverage, source authority, freshness, and contradiction, then return an answerable/partial/unanswerable state with the retrieval receipt. Also keep thresholds segmented by query class and corpus version; one global 0.5 can drift silently as documents and reranker versions change. A planted negative-question suite plus precision-recall curves for abstention makes the cutoff reviewable instead of a magic constant.
I really appreciate this commentβthere are several production insights here.
I completely agree that a single reranker score isn't enough for complex, multi-part questions. A chunk can be highly relevant to one part while leaving the rest unsupported. I also like your idea of treating the outcome as answerable, partially answerable, or unanswerable instead of a simple yes/no decision.
The point about version-specific thresholds and evaluating abstention with precision-recall curves is especially valuable. I was already planning to cover evaluation and guardrails in Phase 5, and you've given me a few more topics I'd like to include. Thanks for the thoughtful feedback.
The leave-policy example nails the trap - "closest" being a relative ranking with no floor is exactly why an empty corpus still hands back five confident chunks. Reranking sharpens the ordering, but does it solve the abstention case? If nothing relevant exists, a reranker still has to pick a top result. I've found you need an absolute score threshold (or an explicit "none of these clear the bar" escape hatch) on top of reranking, otherwise the system just reorders garbage instead of admitting it has nothing. Curious how you're handling the "I don't know" path in Phase 4.
Exactlyβthat's the key limitation I wanted to highlight.
Reranking only improves the ordering of the retrieved candidates. If every candidate is irrelevant, it simply produces a better-ranked list of irrelevant chunks. That's why I see reranking and abstention as complementary rather than competing techniques.
In the article I introduced the relevance threshold as that "escape hatch," but I also think there's room for stronger validation after generation. That's one of the areas I'll explore further in Phase 5.
The point that Top-K is a relative ranking and not a quality guarantee is the crux, and it is why a relevance threshold with real abstention does more for trust than swapping in a bigger model. In practice I have found the reranker score alone is a shaky abstain signal, so pairing it with a grounding check on the drafted answer catches the confident-fake-number case better than a retrieval threshold by itself. When you reach Phase 5, will you measure abstention precision separately, so you can see how often it says "I don't know" on questions it actually could have answered?
That's a great point.
I agree that retrieval confidence and answer grounding are related but different problems. A strong reranker score doesn't automatically guarantee that every claim in the generated answer is actually supported by the retrieved context.
Yesβthat's exactly what I want to cover in Phase 5. Along with retrieval precision and recall, I plan to evaluate abstention separately and measure groundedness/faithfulness so we can distinguish between retrieval quality and generation quality. Thanks for raising it.