From 347b391c6ebb49cc7b7cfd9ba4117f47b765d02a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 6 May 2026 10:17:51 -0700 Subject: [PATCH] fix: prevent LLM hallucination when retrieval returns low-signal results - Strengthen synthesizer system prompt: hard 'respond with exactly' constraint instead of soft 'say so'; removes any wiggle room for the model to supplement from training data - Add early return in synthesize() when chunks is empty (belt-and-suspenders alongside the existing guard in chat.py) - Add MIN_SIGNAL threshold (0.01) in retriever: if the top combined score is below the threshold, return empty so the caller's no-results path fires instead of sending noise chunks to the LLM --- app/services/retriever.py | 10 ++++++++++ app/services/synthesizer.py | 19 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/services/retriever.py b/app/services/retriever.py index 35ee597..20f0895 100644 --- a/app/services/retriever.py +++ b/app/services/retriever.py @@ -171,6 +171,13 @@ class Retriever: return bm25 * 0.5 + vec * 0.5 ranked = sorted(merged.values(), key=_combined, reverse=True)[:top_k] + # Discard results where the best match is pure noise (neither BM25 term + # overlap nor vector similarity exceeded the minimum signal threshold). + # This lets the caller's empty-result guard fire instead of sending + # low-confidence chunks to the LLM where it fills gaps with training data. + MIN_SIGNAL = 0.01 + if ranked and _combined(ranked[0]) < MIN_SIGNAL: + return [] adjacent = _fetch_adjacent(ranked, db_path) return ranked + adjacent @@ -189,5 +196,8 @@ class Retriever: ) for r in self._bm25.query(query, top_k=top_k, doc_ids=doc_ids) ] + MIN_SIGNAL = 0.01 + if hits and hits[0].bm25_score < MIN_SIGNAL: + return [] adjacent = _fetch_adjacent(hits, db_path) return hits + adjacent diff --git a/app/services/synthesizer.py b/app/services/synthesizer.py index b9efdf2..befba3e 100644 --- a/app/services/synthesizer.py +++ b/app/services/synthesizer.py @@ -11,10 +11,18 @@ from dataclasses import dataclass from app.services.retriever import RetrievedChunk _SYSTEM_PROMPT = ( - "You are a helpful document assistant. " - "Answer the user's question using ONLY the provided document excerpts. " - "For each claim, cite the source page as [p.N]. " - "If the excerpts are insufficient, say so. Do not invent information." + "You are a document assistant. " + "Answer questions using ONLY the document excerpts provided. " + "Cite every claim with the source page as [p.N]. " + "If the excerpts do not contain the answer, respond with exactly: " + "'I could not find an answer to that question in the indexed documents.' " + "Do NOT use knowledge from outside the provided excerpts. " + "Do NOT speculate, infer, or guess beyond what is explicitly stated." +) + +_NO_RESULTS_ANSWER = ( + "I could not find any relevant passages in the indexed documents for that question. " + "Try rephrasing, or check that the relevant document has been ingested." ) @@ -42,6 +50,9 @@ class Synthesizer: history: list[dict], chunks: list[RetrievedChunk], ) -> SynthesisResult: + if not chunks: + return SynthesisResult(answer=_NO_RESULTS_ANSWER, citations=()) + # 1500 chars (~300 words) per chunk: enough to capture definitions that # appear mid-paragraph without blowing past a 32k-context model's limit. context_parts = [f"[p.{c.page_number}]\n{c.text[:1500]}" for c in chunks]