Public API for the hallucination guard. See architecture/hallucination-guard.md for the design rationale and ADR-0022 for the decision record.
The guard verifies a generated answer against the retrieved context one claim
at a time. It splits the answer into claims, scores each claim's faithfulness
against the evidence via a pluggable NLI scorer, and — per a per-tenant
threshold — annotates, redacts, or blocks unsupported claims, emitting the
guard.claim_blocked event when any claim fails.
| Symbol | Module | Kind |
|---|---|---|
HallucinationGuard |
rag_guard.guard |
orchestrator |
GuardConfig |
rag_guard.guard |
frozen dataclass |
LexicalNLIScorer |
rag_guard.scorers |
dependency-free NLIScorer impl |
SentenceClaimExtractor / ClaimExtractor |
rag_guard.claims |
claim splitter + Protocol |
split_into_claims |
rag_guard.claims |
pure function |
REDACTION_MARKER / BLOCK_NOTICE |
rag_guard.guard |
action output constants |
NLIScorer |
rag_core.spi.nli |
scorer SPI ABC |
NoopNLIScorer |
rag_core.spi.noop |
always-entails impl |
NLILabel / NLIScore |
rag_core.types |
NLI verdict types |
GuardVerdict / GuardAction |
rag_core.types |
StrEnums |
ClaimVerdict / GuardResult |
rag_core.types |
frozen Pydantic models |
HallucinationGuard, GuardConfig, and LexicalNLIScorer are re-exported from
rag_guard.
from rag_guard import HallucinationGuard, GuardConfig
from rag_guard.scorers import LexicalNLIScorer
from rag_core.types import GuardAction
guard = HallucinationGuard(
scorer=LexicalNLIScorer(),
config=GuardConfig(threshold=0.5, action=GuardAction.ANNOTATE),
)
result, answer = await guard.check(
ctx,
answer="Paris is the capital of France. The moon is made of cheese.",
evidence=["Paris is the capital of France, on the Seine."],
)
# result.verdict == GuardVerdict.FLAGGED, result.blocked_n == 1
# answer unchanged (ANNOTATE); REDACT would strip the moon claim, BLOCK withhold allcheck returns (GuardResult, final_answer) — the decision record plus the
answer text after the configured action (mirrors FallbackChain.run's
(FallbackResult, refs) shape; the answer text is not embedded in the
result).
entailment · neutral · contradiction — the NLI relation between the
evidence (premise) and a claim (hypothesis).
| Field | Type | Meaning |
|---|---|---|
label |
NLILabel |
arg-max relation |
entailment |
float |
P(premise entails hypothesis) in [0, 1] — the number thresholded on |
contradiction |
float |
P(premise refutes hypothesis); 0.0 for scorers that don't separate it |
pass · flagged · blocked — overall assessment. flagged = unsupported
claims found but the answer was left intact (annotate); blocked = the answer
was modified or withheld (redact / block).
annotate (observe only) · redact (drop unsupported claims) · block
(withhold the whole answer). annotate is the behaviour-neutral default.
| Field | Type | Meaning |
|---|---|---|
claim |
str |
the claim text (response-side only — never logged) |
label |
NLILabel |
best NLI label across evidence |
entailment |
float |
best entailment across evidence |
supported |
bool |
entailment >= threshold |
Frozen decision record. Carries the per-claim verdicts but not the final answer text (returned alongside).
| Field | Type | Meaning |
|---|---|---|
verdict |
GuardVerdict |
overall assessment |
action |
GuardAction |
the configured action |
threshold |
float |
resolved per-tenant entailment floor |
claims_n |
int |
claims extracted from the answer |
blocked_n |
int |
claims that fell below the threshold |
claims |
tuple[ClaimVerdict, ...] |
per-claim verdicts |
reason |
str |
advisory free-text — never parse in production |
Frozen dataclass; all fields optional. (This is the runtime config — the
enabled flag lives on the rag.yaml guard block, handled by the gateway,
mirroring FallbackConfig.)
| Field | Default | Meaning |
|---|---|---|
threshold |
0.5 |
default entailment floor |
per_tenant_thresholds |
{} |
per-tenant overrides keyed by tenant id |
action |
GuardAction.ANNOTATE |
annotate / redact / block |
min_claim_chars |
12 |
drop fragments shorter than this |
max_claims |
50 |
hard cap on claims scored per answer |
class NLIScorer(HealthCheckMixin, abc.ABC):
async def score(self, ctx, premise, hypothesis) -> NLIScore: ... # mandatory
async def score_batch(self, ctx, pairs) -> list[NLIScore]: ... # default loops over scoreMirrors Reranker: one atomic method plus a batch entry point whose default
loops — override score_batch to amortise model encoding. The guard owns the
evidence strategy (it scores each claim against each evidence passage and keeps
the best entailment), so a backend only answers the narrow NLI question.
Shipped impls: NoopNLIScorer (always entailment=1.0, the behaviour-neutral
default) and LexicalNLIScorer (token-overlap heuristic — the fraction of a
claim's content tokens present in the evidence; dependency-free, deterministic).
guard:
enabled: true # opt-in; disabled by default
threshold: 0.5 # entailment floor in [0, 1]
action: annotate # annotate | redact | block
per_tenant_thresholds:
acme: 0.7
min_claim_chars: 12
max_claims: 50When enabled, the gateway checks every generated answer on /v1/query
(generate=true), /v1/chat/completions, and the MCP query tool. The
default scorer is LexicalNLIScorer; inject a real NLI model via
build_app(hallucination_guard=...).
- REST
/v1/query—answer.guardcarries theGuardResult;answer.textalready reflects the action. - OpenAI
/v1/chat/completions—rag.guardcarries the verdict; a blocked verdict sets the choice'sfinish_reasontocontent_filter. In streaming mode the verdict rides the terminal chunk (advisory — deltas already sent).
- Span
guard.check— attributesrag.tenant_id,rag.guard.{threshold,action,scorer,claims_n,blocked_n,verdict,min_entailment,degraded,elapsed_ms}. - Event
guard.claim_blocked(typedGuardEvent) — emitted once per check whenblocked_n > 0. Carries counts, threshold,min_entailment, verdict, action, scorer — never the answer or claim text.
ragctl guard "Paris is in France. The moon is cheese." \
-c "Paris is the capital of France." \
--threshold 0.5 --action annotateDrives HallucinationGuard + LexicalNLIScorer over the provided context;
prints the per-claim verdicts and the (possibly redacted / blocked) answer. No
model download, no external services. Flags: --context/-c (repeatable),
--threshold/-T, --action/-a, --tenant/-t.
- Real NLI model — implement
NLIScorer.score(overridescore_batchto batch-encode) and inject viabuild_app(hallucination_guard=HallucinationGuard(scorer=...)). - Atomic-claim extraction — implement
ClaimExtractor.extract(e.g. an LLM-based decomposer) and passextractor=toHallucinationGuard.