Skip to content

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.

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 = ok
FLAG = "/tmp/citation_spike.flag"
THRESHOLD = 0.30 # >30% of answers with no sources over the window
MIN_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.

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 True
  • 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.