RAG answer with no sources (grounding lost)
The RAG pipeline finds relevant chunks, puts them in the context — and the
model answers off the mark: no links, no [source] markers, inventing
on top of nothing. Retrieval did its job: the chunks were there. The answer
looks confident and plausible, so the regression is quiet — the user simply
gets “facts” that aren’t in your database.
This is a signal distinct from hallucinations: there the model invents when the context is empty, here it ignores a non-empty one. The cause is almost always in three places: the retriever broke, the embeddings version changed and the old chunks are no longer found, or someone edited the system prompt and dropped the “cite your sources” line.
1. A cheap structural check
Section titled “1. A cheap structural check”The cheapest option — without a single extra model call. If retrieval
returned chunks but the answer cites none of their ids (no [source],
[1], doc_id in the text), we treat the answer as “ungrounded”. We
accumulate the share of such answers in a rolling window and send a push when
it crosses a threshold.
import os, re, json, time, statistics, requests
WIN = [] # 1 = ungrounded, 0 = okFLAG = "/tmp/citation_spike.flag"THRESHOLD = 0.30 # >30% of answers with no sources over the windowMIN_WINDOW = 40
def is_ungrounded(answer_text, retrieved_ids): # retrieval found something, but the answer cited none of the ids? if not retrieved_ids: return False # empty retrieval — that's a different scenario cited = set(re.findall(r"\[(?:source|src|doc)[:\s]*([\w\-]+)\]", answer_text)) cited |= set(re.findall(r"\[(\d+)\]", answer_text)) # footnotes like [1] return len(cited & {str(i) for i in retrieved_ids}) == 0
def observe(answer_text, retrieved_ids): WIN.append(1 if is_ungrounded(answer_text, retrieved_ids) else 0) if len(WIN) > 200: WIN.pop(0) if len(WIN) < MIN_WINDOW: return rate = statistics.mean(WIN) if rate >= THRESHOLD and not os.path.exists(FLAG): open(FLAG, "w").write(str(time.time())) notify("📄❓ Ответы без источников", f"{int(rate*100)}% ответов не цитируют найденные чанки " f"(окно {len(WIN)}). Проверьте retriever / промпт.", priority=8) elif rate < THRESHOLD * 0.5 and os.path.exists(FLAG): os.remove(FLAG) # the spike has passed — clear the flag
def notify(title, message, priority): requests.post(f"{os.environ['NOTIFLY_URL']}/message", params={"token": os.environ["NOTIFLY_TOKEN"]}, json={"title": title, "message": message, "priority": priority}, timeout=5)The flag file guarantees one push per spike — otherwise every answer in the window would fire a new alert. The flag is cleared once the share returns to normal.
2. A grounding score via an LLM judge
Section titled “2. A grounding score via an LLM judge”The structural check catches “no markers at all” but misses an answer that cites a source formally while actually paraphrasing from the model’s own head. A more precise measure is a grounding score: do the answer’s claims trace back to the retrieved context (0..1)? This is more expensive, so we run it on a sample (say, every 10th answer) and watch the window mean.
import anthropic
client = anthropic.Anthropic()GWIN = []GTHRESHOLD = 0.6 # mean grounding dropped below 0.6 → alert
def grounding_score(answer, context_chunks): ctx = "\n---\n".join(context_chunks) judge = client.messages.create( model="claude-haiku-4-5", max_tokens=8, messages=[{"role": "user", "content": f"Context:\n{ctx}\n\nAnswer:\n{answer}\n\n" "What fraction of the answer's claims follows directly from the " "context? Return only a number from 0.0 to 1.0."}], ) try: return max(0.0, min(1.0, float(judge.content[0].text.strip()))) except ValueError: return 0.0
def observe_grounding(answer, context_chunks): GWIN.append(grounding_score(answer, context_chunks)) if len(GWIN) > 100: GWIN.pop(0) if len(GWIN) >= 20: mean = statistics.mean(GWIN) if mean < GTHRESHOLD and not os.path.exists("/tmp/grounding.flag"): open("/tmp/grounding.flag", "w").write("1") notify("🧷 Grounding упал", f"Средний grounding {mean:.2f} за {len(GWIN)} ответов " f"(порог {GTHRESHOLD}). Ответы плохо опираются на контекст.", priority=8)Keep the judge model cheap — the task is binary-simple, no reason to pay for a flagship here.
3. A hard per-answer alert for high-stakes
Section titled “3. A hard per-answer alert for high-stakes”For answers where the cost of a mistake is high — legal, medical, financial — we don’t wait to accumulate statistics. A single ungrounded answer in such a category is an immediate loud push, so a human looks before the answer reaches the user.
HIGH_STAKES = {"legal", "medical", "finance"}
def guard_high_stakes(answer_text, retrieved_ids, category): if category in HIGH_STAKES and is_ungrounded(answer_text, retrieved_ids): notify("🚨 High-stakes ответ без источника", f"Категория «{category}»: ответ без цитат при " f"{len(retrieved_ids)} найденных чанках. Нужен ревью.", priority=10) return False # block the answer until it's reviewed return TrueWhat to put in the push
Section titled “What to put in the push”- the ungrounded share / mean grounding score over the window;
- how many chunks retrieval returned (to tell “empty” from “ignored”);
- the category, if it’s high-stakes;
- a hint about the cause:
embeddings version changed?,system prompt edited?,retriever returns empty?.
If the spike coincides in time with a deploy, the culprit is almost certainly an embeddings update or a prompt edit. Split the checks into layers and run the counters in shared storage (Redis) so the window survives restarts and covers all instances.
Related recipes
Section titled “Related recipes”- Vector DB / RAG — a broken retriever as the root cause.
- Hallucination spike — a neighboring signal: invention with an empty context.
- Embeddings version bump — a common trigger: old chunks stopped being found.