Post-hoc hallucination verifier for RAG pipelines. Reimplements HALT-RAG
(Goswami & Kurra, arXiv:2509.07475) — a calibrated ensemble of two frozen NLI
models plus lexical features, with out-of-fold training, precision-constrained
thresholding, and abstention — with one deliberate architectural change: NLI
scoring runs through the Hugging Face Inference API (no local GPU or
weights required). A local transformers backend exists behind a config flag.
What it does: given a source (retrieved context) and a generated text,
returns a calibrated hallucination probability and a decision:
supported / hallucinated / abstain.
What it doesn't do: it is not a fact-checker against the open world — it only checks consistency between the generation and the provided source. It was developed on clean HaluEval contexts, not noisy real-world retrieval.
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev,plots]"
export HF_TOKEN=hf_... # https://hf.co/settings/tokens (read scope)
# 0. CRITICAL GATE — verify the API pair format before spending any budget:
pip install -e ".[local]" # needed once, for the local diff side
python scripts/verify_nli_backend.py
# 1. Pilot run (~200 raw records = 400 examples):
python scripts/run_pipeline.py --task qa --limit 200 --yes
# 2. Ad-hoc check:
halt-rag verify --task qa \
--source "Paris is the capital of France. It hosts the Louvre museum." \
--generated "The capital of France is Berlin."Offline tests (no network, no token): pytest.
data.limit: 500 in every config. A full HaluEval run is ~20k examples/task
and easily 150k–400k+ hosted API calls across tasks/models/windows. The
CLI prints a call estimate and asks for confirmation on large runs (skip with
--yes). All raw NLI scores are cached in SQLite
(.cache/nli_scores.sqlite), keyed by (model_id, premise_hash, hypothesis_hash) — interrupted runs resume free, identical windows are never
re-scored. Use --limit 0 explicitly for the full dataset.
- Window the source into non-overlapping 320-token chunks; pair each with the (budget-truncated) generation as hypothesis.
- Score every pair with
roberta-large-mnliand an NLI-finetuned DeBERTa-v3-large via the HF Inference API (cached, retried, rate-bounded). - Pool max+mean of each NLI probability per model → 12 features; add 5
lexical features (lengths, ratio, ROUGE-L F1, Jaccard) → 17 total, named
schema (see
features/pipeline.py), ablation flags supported. - Train the task classifier (LogReg for summarization/dialogue, LinearSVC
for QA;
C=1.0,class_weight=balanced) with 5-fold OOF; calibrate (isotonic for LogReg tasks, Platt for QA) on OOF scores; threshold at argmax F1 s.t. precision ≥ 0.70; abstain on the least-confident predictions down to the target coverage (default 90%).
One YAML per task in configs/ (validated by pydantic — src/halt_rag/config.py
is the authoritative schema):
| Key | Meaning | Default |
|---|---|---|
data.source |
github raw JSON or hf_datasets |
github |
data.limit |
raw records for pilot runs (null/0 = full) |
500 |
inference.backend |
hf_api or local |
hf_api |
inference.deberta_model_id |
swappable NLI checkpoint | MoritzLaurer DeBERTa-v3-large |
inference.pair_format |
text_pair or zero_shot fallback |
text_pair |
inference.max_concurrency |
parallel API calls | 4 |
windowing.window_tokens/stride_tokens |
source chunking | 320/320 |
classifier.type |
logistic_regression / linear_svc |
per task |
calibration.method |
isotonic / platt |
per task |
threshold.min_precision |
precision constraint | 0.70 |
abstention.target_coverage |
kept fraction | 0.90 |
ablation |
e.g. [no_lexical], [single_model_roberta] |
[] |
Python (any framework, or none):
from halt_rag import HaltRagVerifier
verifier = HaltRagVerifier.from_config("configs/qa.yaml")
result = verifier.verify(source=context_text, generated=answer_text)
result.calibrated_prob # float in [0,1]
result.decision # "supported" | "hallucinated" | "abstain"
result.feature_breakdown # per-feature values, for debuggingGeneric dict-pipeline step:
from halt_rag.integrations.generic import verify_step
pipeline = [retrieve, generate, verify_step(verifier, policy="annotate")]LangChain:
from halt_rag.integrations.langchain_adapter import HaltRagGuard
chain = rag_chain | HaltRagGuard(verifier, policy="raise").as_runnable()LlamaIndex:
from halt_rag.integrations.llamaindex_adapter import verify_response
response = verify_response(verifier, query_engine.query("..."))Any language, via the sidecar service (pip install -e ".[serve]"):
halt-rag serve &
curl -s localhost:8000/verify -H 'Content-Type: application/json' \
-d '{"task":"qa","source":"Paris is the capital of France.","generated":"Berlin is the capital of France."}'halt-rag download-data --task qa|dialogue|summarization|all
halt-rag extract-features --task qa [--limit N] [--yes]
halt-rag train --task qa
halt-rag evaluate --task qa # writes evaluation_results/<task>/
halt-rag verify --task qa --source "..." --generated "..."
halt-rag serve
Outputs per task: oof_calibrated_pred.jsonl, oof_meta.json (precision/
recall/F1/accuracy at t*, AUROC, AUPR, ECE, abstention block), four plots
(PR, ROC, reliability, risk-coverage), and a top-level
evaluation_results/summary.md comparing against the paper's Table 1.
- DeBERTa checkpoint substituted. The paper names
microsoft/deberta-v3-large, which has no NLI head (base model only). We default toMoritzLaurer/DeBERTa-v3-large-mnli-fever-anli-ling-wanli(MNLI+FEVER-NLI+ANLI+LingNLI+WANLI); swappable viainference.deberta_model_id(e.g.cross-encoder/nli-deberta-v3-large). - Hosted inference wire format is verified, not assumed. The HF
Inference Providers text-classification spec doesn't clearly document a
pair payload for third-party NLI checkpoints.
scripts/verify_nli_backend.pydiffs the API against a localtransformerspipeline on hand-picked pairs and must PASS before any large extraction. Azero_shotfallback path exists (inference.pair_format), with the caveat that it only exposes entailment mass (neutral/contradiction are approximated by an even split — quantified by the same script). - Windowing tokenizer fallback. The paper windows with the NLI model's
own tokenizer; the lightweight base install uses whitespace tokens (with
the real tokenizer used automatically when
[local]extras are installed). 320 whitespace words ≈ 400–450 subwords, safely within the 512 budget with a 160-word hypothesis cap. - Abstention ranking reconstructed. Rank by
|calibrated_prob − t*|, keep the most confident up to the coverage target. This is a reasonable reconstruction of the paper's coverage mechanism, not a verbatim spec. - Label order is never assumed. Backends map the model's own label
names (
id2labellocally; response labels via the API) into a fixed internal order —roberta-large-mnliand DeBERTa NLI checkpoints disagree on index order, which silently corrupts features if read positionally. - Numbers will differ from the paper. Pilot-scale subsampling, the
substituted checkpoint, hosted inference, and the tokenizer fallback all
shift results.
evaluation_results/summary.mdreports our numbers next to the paper's reference numbers with this note. - Untrained fallback in
verify(). Beforehalt-rag trainhas run, ad-hoc verification uses a transparent NLI-only heuristic (1 − mean entailment) and the default 0.5 threshold, so the CLI is usable immediately — train for calibrated behavior.
Fixed windows can split evidence across chunk boundaries; classifiers are task-tuned rather than one universal model; evaluation uses clean HaluEval contexts, not noisy retrieval; hypothesis truncation may drop content in very long generations.
- HALT-RAG: Goswami & Kurra, arXiv:2509.07475.
- HaluEval: Li et al., 2023 (data fetched from the RUCAIBox/HaluEval repo — see that repo for dataset licensing).
- This code: MIT (see LICENSE).