Surfacing uncertainty to the user without extra LLM calls
Published
31 May 2026 00:00
1 Why a confidence signal matters
A RAG system that gives confident-sounding answers is more dangerous than one that admits uncertainty — the user can’t calibrate their trust in the output. Issue #14 adds a lightweight confidence signal to every ask() call. It costs nothing at inference time (no extra LLM calls, no RAGAS scoring) and gives the user three concrete signals to display.
2 The signal
from rag.query import confidence_signal, ABSTAIN_PHRASE# What the signal containsimport inspectprint(inspect.getsource(confidence_signal))
def confidence_signal(
answer: str,
sources: list[dict],
scores: list[float] | None = None,
used_fallback: bool = False,
) -> dict:
"""Return a lightweight confidence estimate based on answer, sources, and scores.
No additional LLM calls are made; this is a heuristic only.
Signals:
- ``is_uncertain``: ``True`` when the model admitted it could not find the answer
(detected via :data:`ABSTAIN_PHRASE`).
- ``n_sources``: number of chunks retrieved.
- ``has_sources``: ``True`` when at least one chunk was retrieved.
- ``unique_documents``: number of distinct source documents.
- ``top_score``: highest similarity score across retrieved chunks, or ``None``.
- ``avg_score``: mean similarity score, or ``None``.
- ``confidence_level``: categorical label — ``"High"`` (top_score >= 0.8),
``"Medium"`` (>= 0.6), ``"Low"`` (>= 0.4), ``"Very Low"`` otherwise.
- ``used_fallback``: ``True`` when the always-answer fallback fired.
Args:
answer: The answer string returned by :func:`ask`.
sources: The list of metadata dicts returned by :func:`ask`.
scores: Optional list of relevance scores parallel to *sources*.
used_fallback: Pass ``True`` when the always-answer fallback was used.
Returns:
Dict with confidence fields described above.
"""
is_uncertain = ABSTAIN_PHRASE in answer
n_sources = len(sources)
unique_docs = len({s.get("source", "") for s in sources if s.get("source")})
top_score = round(max(scores), 3) if scores else None
avg_score = round(sum(scores) / len(scores), 3) if scores else None
if is_uncertain or n_sources == 0:
level = "Very Low"
elif top_score is None:
level = "Unknown"
elif top_score >= 0.8:
level = "High"
elif top_score >= 0.6:
level = "Medium"
elif top_score >= 0.4:
level = "Low"
else:
level = "Very Low"
return {
"is_uncertain": is_uncertain,
"n_sources": n_sources,
"has_sources": n_sources > 0,
"unique_documents": unique_docs,
"top_score": top_score,
"avg_score": avg_score,
"confidence_level": level,
"used_fallback": used_fallback,
}
3 Demo — certain answer
sources = [ {"source": "speech_042.txt", "speaker": "Lagarde"}, {"source": "speech_107.txt", "speaker": "Lane"}, {"source": "speech_042.txt", "speaker": "Lagarde"}, # duplicate file]answer ="The ECB targets inflation at 2% over the medium term."sig = confidence_signal(answer, sources)for k, v in sig.items():print(f" {k:20s}: {v}")
The model admitted it could not find an answer in context. is_uncertain is True — the UI should flag this and prompt the user to rephrase or check whether the topic is covered by the knowledge base.
5 What the signal looks like in ask()
confidence is returned alongside answer and sources in every ask() call — no extra parameters needed:
from rag.query import askresult = ask(chain, "What is the ECB's inflation target?")print(result["answer"])# → "The ECB targets inflation at 2% over the medium term."print(result["confidence"])# → {"is_uncertain": False, "n_sources": 4, "has_sources": True, "unique_documents": 3}
6 Displaying uncertainty to the user
Code
def render_answer(result: dict) ->None:"""Print a formatted answer with confidence indicators.""" conf = result["confidence"] answer = result["answer"]if conf["is_uncertain"]: label ="⚠️ LOW CONFIDENCE" note ="The system could not find an answer in the loaded documents."elifnot conf["has_sources"]: label ="⚠️ NO SOURCES" note ="No chunks were retrieved. Check the knowledge base is populated."elif conf["n_sources"] <2: label ="🟡 MODERATE" note =f"Answer based on {conf['n_sources']} chunk from {conf['unique_documents']} document(s)."else: label ="✅ CONFIDENT" note = (f"Retrieved {conf['n_sources']} chunks from "f"{conf['unique_documents']} document(s).")print(f"[{label}]")print(f"Answer: {answer}")print(f"Note: {note}")print()# Simulate a successful retrievalrender_answer({"answer": "The ECB targets 2% inflation over the medium term.","sources": [{"source": "s1.txt"}, {"source": "s2.txt"}, {"source": "s1.txt"}],"confidence": confidence_signal("The ECB targets 2% inflation over the medium term.", [{"source": "s1.txt"}, {"source": "s2.txt"}, {"source": "s1.txt"}], ),})# Simulate an abstentionrender_answer({"answer": ABSTAIN_PHRASE,"sources": [],"confidence": confidence_signal(ABSTAIN_PHRASE, []),})
[✅ CONFIDENT]
Answer: The ECB targets 2% inflation over the medium term.
Note: Retrieved 3 chunks from 2 document(s).
[⚠️ LOW CONFIDENCE]
Answer: I don't know based on the available speeches.
Note: The system could not find an answer in the loaded documents.
7 Confidence signal at a glance
Field
Type
Meaning
is_uncertain
bool
Model returned the abstain phrase — answer not grounded
n_sources
int
Number of chunks retrieved from the vectorstore
has_sources
bool
At least one chunk was retrieved
unique_documents
int
Number of distinct source files in the retrieved set
Note
This is a heuristic signal, not a calibrated probability. A high n_sources is necessary but not sufficient for a good answer — retrieval can pull back irrelevant chunks. For rigorous scoring, run RAGAS via rag.evaluate.run_evaluation().
Tests: tests/test_query.py — 8 new tests covering confidence_signal() and the confidence key in ask() return values; all CI-safe (no Ollama required).
Source Code
---title: "Phase 4 — Confidence Signal"subtitle: "Surfacing uncertainty to the user without extra LLM calls"date: todaydate-format: "DD MMMM YYYY HH:mm"---## Why a confidence signal mattersA RAG system that gives confident-sounding answers is more dangerous than one that admitsuncertainty — the user can't calibrate their trust in the output.[Issue #14](https://github.com/almac09/private-rag-assistant/issues/14) adds a lightweightconfidence signal to every `ask()` call. It costs nothing at inference time (no extra LLMcalls, no RAGAS scoring) and gives the user three concrete signals to display.---## The signal```{python}#| code-fold: falsefrom rag.query import confidence_signal, ABSTAIN_PHRASE# What the signal containsimport inspectprint(inspect.getsource(confidence_signal))```---## Demo — certain answer```{python}#| code-fold: falsesources = [ {"source": "speech_042.txt", "speaker": "Lagarde"}, {"source": "speech_107.txt", "speaker": "Lane"}, {"source": "speech_042.txt", "speaker": "Lagarde"}, # duplicate file]answer ="The ECB targets inflation at 2% over the medium term."sig = confidence_signal(answer, sources)for k, v in sig.items():print(f" {k:20s}: {v}")```The model gave a real answer, retrieved 3 chunks from 2 distinct documents.`is_uncertain` is `False` — safe to present to the user.---## Demo — uncertain answer (abstention)```{python}#| code-fold: falsesig = confidence_signal(ABSTAIN_PHRASE, [])for k, v in sig.items():print(f" {k:20s}: {v}")```The model admitted it could not find an answer in context.`is_uncertain` is `True` — the UI should flag this and prompt the user torephrase or check whether the topic is covered by the knowledge base.---## What the signal looks like in ask()`confidence` is returned alongside `answer` and `sources` in every `ask()` call — no extraparameters needed:```pythonfrom rag.query import askresult = ask(chain, "What is the ECB's inflation target?")print(result["answer"])# → "The ECB targets inflation at 2% over the medium term."print(result["confidence"])# → {"is_uncertain": False, "n_sources": 4, "has_sources": True, "unique_documents": 3}```---## Displaying uncertainty to the user```{python}def render_answer(result: dict) ->None:"""Print a formatted answer with confidence indicators.""" conf = result["confidence"] answer = result["answer"]if conf["is_uncertain"]: label ="⚠️ LOW CONFIDENCE" note ="The system could not find an answer in the loaded documents."elifnot conf["has_sources"]: label ="⚠️ NO SOURCES" note ="No chunks were retrieved. Check the knowledge base is populated."elif conf["n_sources"] <2: label ="🟡 MODERATE" note =f"Answer based on {conf['n_sources']} chunk from {conf['unique_documents']} document(s)."else: label ="✅ CONFIDENT" note = (f"Retrieved {conf['n_sources']} chunks from "f"{conf['unique_documents']} document(s).")print(f"[{label}]")print(f"Answer: {answer}")print(f"Note: {note}")print()# Simulate a successful retrievalrender_answer({"answer": "The ECB targets 2% inflation over the medium term.","sources": [{"source": "s1.txt"}, {"source": "s2.txt"}, {"source": "s1.txt"}],"confidence": confidence_signal("The ECB targets 2% inflation over the medium term.", [{"source": "s1.txt"}, {"source": "s2.txt"}, {"source": "s1.txt"}], ),})# Simulate an abstentionrender_answer({"answer": ABSTAIN_PHRASE,"sources": [],"confidence": confidence_signal(ABSTAIN_PHRASE, []),})```---## Confidence signal at a glance| Field | Type | Meaning ||-------|------|---------||`is_uncertain`| bool | Model returned the abstain phrase — answer not grounded ||`n_sources`| int | Number of chunks retrieved from the vectorstore ||`has_sources`| bool | At least one chunk was retrieved ||`unique_documents`| int | Number of distinct source files in the retrieved set |::: {.callout-note}This is a **heuristic signal**, not a calibrated probability. A high `n_sources` isnecessary but not sufficient for a good answer — retrieval can pull back irrelevant chunks.For rigorous scoring, run RAGAS via `rag.evaluate.run_evaluation()`.:::Tests: `tests/test_query.py` — 8 new tests covering `confidence_signal()` and the `confidence`key in `ask()` return values; all CI-safe (no Ollama required).