Hallucination Detection & Correction
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written on 2026-09-30 against langchain 1.4.3 on Python 3.12, Spring AI 2.0.1 and LangChain4j 1.20.2,
with Vectara’s HHEM classifier as published on the Hugging Face Hub (see the
version baseline). The scoring and decision functions were run against
hand-written claims; the judge prompts, the HHEM call and the Java classes were checked against the released packages
(the Java compiled) but not run against a model.
Hallucinations & Limitations explains why language models produce fluent, confident, false text. Retrieval reduces the problem but does not remove it: the model can still ignore, misread or embellish the passages it was given. This page is about catching those answers at runtime, in the request path or right after it, and doing something about them. Offline evaluation of the same property — faithfulness on a golden set — is on Evaluating RAG Systems and Evaluation at Scale.
RAG hallucination is not LLM hallucination
The two failures have different references and different fixes:
| LLM hallucination | RAG hallucination | |
|---|---|---|
Wrong with respect to |
The world (facts the model should know). |
The retrieved context (what the model was told). |
Example |
Inventing a function that does not exist. |
The passage says |
Checkable at runtime |
Only against an external source. |
Yes: compare the answer with the passages in hand. |
Main fixes |
Retrieval, tools, abstention. |
Grounding checks, citation verification, regenerate or abstain. |
A RAG answer can be faithful (supported by the passages) and still wrong, because the passages are wrong or stale — that is an ingestion or retrieval problem. It can also be correct and unfaithful, when the model answers from its own knowledge; for a documentation assistant that is still a defect, because the answer cannot be traced to the docs. The checks below measure faithfulness to the context; correctness of the context is measured offline.
Faithfulness by claim decomposition
A whole-answer judgement ("is this answer supported?") is coarse and unreliable for long answers. Decompose the answer into atomic claims, check each claim against the passages, and score the share that is supported:
where is the set of claims extracted from the answer and the retrieved passages. means every claim is supported. The same decomposition gives the unsupported claims themselves, which is what a correction step needs. Claim decomposition is the basis of FActScore and of the RAGAS faithfulness metric.
langchain 1.4.3)import os
from typing import Literal
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
judge = init_chat_model(os.environ["JUDGE_MODEL"], temperature=0)
class Claims(BaseModel):
claims: list[str] = Field(description="Atomic factual claims made by the answer; skip greetings and hedges")
class Verdict(BaseModel):
claim: str
verdict: Literal["supported", "contradicted", "not_found"]
evidence_chunk_id: str | None = None
class Verdicts(BaseModel):
verdicts: list[Verdict]
extract = judge.with_structured_output(Claims)
check = judge.with_structured_output(Verdicts)
def faithfulness(answer: str, passages: str) -> tuple[float, list[Verdict]]:
claims = extract.invoke("List the atomic factual claims in this answer:\n\n" + answer).claims
if not claims:
return 1.0, []
verdicts = check.invoke(
"For each claim, decide whether the passages support it, contradict it, or do not mention it. "
"Use only the passages, not your own knowledge.\n\nPassages:\n" + passages
+ "\n\nClaims:\n" + "\n".join(f"- {c}" for c in claims)).verdicts
supported = sum(v.verdict == "supported" for v in verdicts)
return supported / len(claims), verdicts
Source: LangChain — Structured output and RAGAS documentation (faithfulness).
LLM-as-judge or a classifier
Two families of checker are used in practice, often together:
| LLM-as-judge | HHEM classifier | |
|---|---|---|
What it is |
A general chat model prompted to decompose and judge, as above. |
Vectara’s Hughes Hallucination Evaluation Model: a small cross-encoder that scores a (premise, hypothesis) pair for factual consistency between 0 and 1. |
Latency and cost |
One or two model calls; hundreds of milliseconds to seconds. |
Tens of milliseconds on a CPU or small GPU; no per-token cost. |
Output |
Per-claim verdicts and explanations. |
One score per pair; split the answer into sentences for per-sentence scores. |
Strengths |
Handles paraphrase, multi-hop reasoning and long contexts; explains itself. |
Fast, cheap, deterministic, self-hostable; suitable for every request. |
Weaknesses |
Shares biases with the generator (use a different model family); can be lenient; costs as much as a second answer. |
Limited context length; less robust on multi-hop support; needs a threshold. |
HHEM is published on the Hugging Face Hub with custom model code, loaded through Transformers:
from transformers import AutoModelForSequenceClassification
hhem = AutoModelForSequenceClassification.from_pretrained(
"vectara/hallucination_evaluation_model", trust_remote_code=True)
def consistency(passages: str, sentences: list[str]) -> list[float]:
"""One score per answer sentence: close to 1 = supported by the passages, close to 0 = hallucinated."""
return [float(s) for s in hhem.predict([(passages, sentence) for sentence in sentences])]
Source: Vectara HHEM model card.
trust_remote_code=True executes code from the model repository. Pin the model revision
(revision="<commit sha>") and review that code before deploying, as with any Hub model (see
Choosing Models & Licences). Check the model card for the
current context-length limit; passages longer than that must be split, and a sentence is then supported if any passage
split supports it.
Blocking or asynchronous checks
A check can run before the user sees the answer (blocking) or after (asynchronous). The trade-off is latency and cost against the chance that a wrong answer is shown:
| Mode | How | Use when |
|---|---|---|
Blocking, after generation |
Generate the full answer, check, then send (or stream from a buffer). |
High-stakes answers (security, compliance, pricing); the user loses the streaming benefit. |
Streaming with a trailing verdict |
Stream the answer as usual, run the fast check at the end, and send a final |
The default for DocsAssistant; the UI greys out or annotates flagged sentences. |
Asynchronous, sampled |
Return immediately; a background worker checks a sample of answers with the LLM judge and records the score on the trace. |
Monitoring faithfulness over time without adding latency (Evaluation at Scale). |
A useful combination: HHEM on every answer in the trailing-verdict position (tens of milliseconds), the LLM judge asynchronously on a sample of answers and on every answer HHEM flags.
Regenerate or abstain
When a check fails, the service has four options, in increasing order of cost:
-
Drop unsupported sentences and keep the rest, when the unsupported part is peripheral and the remaining answer still answers the question.
-
Regenerate once with stricter instructions and the list of unsupported claims ("do not state X; the passages do not support it"), then re-check. Never loop more than once or twice; a model that cannot ground the answer on the second try usually lacks the evidence.
-
Retrieve again with a different strategy (a broader query, a different route) when the verdicts are mostly
not_found— the evidence may exist but was not retrieved. -
Abstain: "I could not find a reliable answer in the documentation", with the closest sources.
The decision can be written as a small policy over the faithfulness score and the verdict mix:
from typing import Literal
Action = Literal["send", "drop_unsupported", "regenerate", "retrieve_again", "abstain"]
def decide(score: float, verdicts: list[str], attempt: int, min_ok: float = 0.9, min_partial: float = 0.6) -> Action:
"""verdicts: 'supported' | 'contradicted' | 'not_found' for each claim."""
if score >= min_ok:
return "send"
contradicted = verdicts.count("contradicted")
not_found = verdicts.count("not_found")
if attempt == 0 and not_found > contradicted:
return "retrieve_again"
if attempt == 0:
return "regenerate"
if score >= min_partial and contradicted == 0:
return "drop_unsupported"
return "abstain"
Source: RAGAS — Faithfulness (the score the policy consumes).
Contradicted claims are worse than missing ones: a contradicted claim means the model saw the evidence and stated the opposite, so they never survive a "drop" decision here. Record the action on the trace; the abstention rate and the regeneration rate are key quality metrics.
Spring AI: FactCheckingEvaluator at runtime
Spring AI’s FactCheckingEvaluator asks a chat model whether a claim is supported by the given documents and returns
an EvaluationResponse with isPass(). It is usually shown in tests (see Evaluation),
but it works just as well in the request path, on the documents the RAG advisor retrieved. The factory method
forBespokeMinicheck configures it for Bespoke Labs' Minicheck, a small fact-checking model served through Ollama, which
plays the role HHEM plays in Python:
package com.example.docsassistant.rag.hallucination;
import java.util.List;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.evaluation.FactCheckingEvaluator;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.document.Document;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.evaluation.EvaluationResponse;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.stereotype.Service;
@Service
public class GroundedAnswerService {
static final String ABSTAIN = "I could not find a reliable answer to this in the documentation.";
private final ChatClient chatClient;
private final FactCheckingEvaluator factChecker;
public GroundedAnswerService(ChatClient docsChatClient, ChatClient.Builder judgeBuilder) {
this.chatClient = docsChatClient; // has the RetrievalAugmentationAdvisor
this.factChecker = FactCheckingEvaluator.builder(judgeBuilder).build();
}
public String ask(String question) {
for (int attempt = 0; attempt < 2; attempt++) {
String instruction = attempt == 0 ? question
: question + "\n\nAnswer strictly from the provided context. Omit anything it does not state.";
ChatResponse response = chatClient.prompt().user(instruction).call().chatResponse();
List<Document> context = response.getMetadata().get(RetrievalAugmentationAdvisor.DOCUMENT_CONTEXT);
String answer = response.getResult().getOutput().getText();
if (context == null || context.isEmpty()) {
return ABSTAIN;
}
EvaluationResponse verdict = factChecker.evaluate(new EvaluationRequest(question, context, answer));
if (verdict.isPass()) {
return answer;
}
}
return ABSTAIN;
}
}
Source: Spring AI — Evaluation Testing (FactCheckingEvaluator).
The evaluator judges the answer as a whole. For per-claim verdicts, run the claim-decomposition prompt through a
ChatClient with .entity(…) into a record of verdicts, exactly as in the Python version.
LangChain4j: a judge as an AI service
LangChain4j has no built-in faithfulness evaluator; an AI service with a structured return type is the idiomatic
judge. The retrieved sources come from Result.sources() of the answering service (see
Citations & Grounding):
package com.example.docsassistant.l4j.hallucination;
import java.util.List;
import java.util.stream.Collectors;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.rag.content.Content;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
public final class FaithfulnessJudge {
public enum Label { SUPPORTED, CONTRADICTED, NOT_FOUND }
public record Verdict(String claim, Label label) {
}
public record Verdicts(List<Verdict> verdicts) {
public double score() {
return verdicts.isEmpty() ? 1.0
: verdicts.stream().filter(v -> v.label() == Label.SUPPORTED).count() / (double) verdicts.size();
}
}
interface Judge {
@SystemMessage("You check answers against passages. Use only the passages, never your own knowledge.")
@UserMessage("""
Split the answer into atomic factual claims and label each one SUPPORTED, CONTRADICTED or NOT_FOUND
with respect to the passages.
Passages:
{{passages}}
Answer:
{{answer}}""")
Verdicts judge(@V("passages") String passages, @V("answer") String answer);
}
private final Judge judge;
public FaithfulnessJudge(ChatModel judgeModel) {
this.judge = AiServices.create(Judge.class, judgeModel);
}
public Verdicts check(String answer, List<Content> sources) {
String passages = sources.stream().map(c -> c.textSegment().text()).collect(Collectors.joining("\n\n"));
return judge.judge(passages, answer);
}
}
Use a judge model from a different family than the generator where possible, and keep the judge’s prompt and model version under the same change control as the generator’s: a silently more lenient judge looks exactly like an improvement.
The runtime hallucination techniques on this page — LLM-as-judge, the HHEM classifier and correction — follow the concepts of Mendelevitch and Bao, Hands-On RAG for Production (Chapter 3), and faithfulness by claim decomposition follows Polzer, RAG with Python Cookbook (Chapter 10), paraphrased; see the bibliography.