When building production Retrieval-Augmented Generation (RAG) pipelines, the primary failure mode isn't retrieval latency or vector index size—it's silent hallucination.
LLMs frequently generate fluent, highly plausible-sounding claims that are completely unsupported by the retrieved reference chunks. In multi-agent systems and mission-critical workflows, accepting an ungrounded draft can corrupt downstream decision-making.
To solve this in cyclic multi-agent graphs, I built and published rag-critic on PyPI—a lightweight, adversarial hallucination auditor and citation grounding engine architected specifically for LangGraph.
🔍 The Architectural Bottleneck
Standard RAG architectures evaluate answers at a holistic paragraph level using an LLM-as-a-judge. This approach has three major flaws:
- Coarse-Grained Feedback: Telling an agent "this answer feels inaccurate" doesn't specify which sentence was hallucinated.
- High Latency: Re-prompting large frontier models for full evaluations adds 3–5 seconds per step.
- Lack of Determinism: Without an entailment matrix, confidence scores drift across model runs.
📦 Project Links & Open-Source Repositories
PyPI Package: pypi.org/project/rag-critic
GitHub Repository: github.com/samjanjua6/rag-critic
Author Engineering Portfolio: https://shaheerulhassan.com
💻 Quickstart & Python Usage
Install directly from PyPI:
bash
pip install rag-critic
from rag_critic import RAGCritic
critic = RAGCritic()
# Evaluate a draft answer against retrieved source chunks
result = critic.evaluate(
draft_response="Python was released in 1991 by Guido van Rossum. It was originally named Monty.",
retrieved_chunks=[
"Python is an interpreted programming language released in 1991 by Guido van Rossum."
]
)
print(f"Status: {result.status}") # REVISE / APPROVED
print(f"Grounding Score: {result.grounding_score:.2f}") # e.g. 0.50
print(f"Hallucination Index: {result.hallucination_index:.2f}")
# Inspect claim-level verification
for claim in result.claims:
print(f"[{claim.status.value}] {claim.text}")
# [SUPPORTED] Python was released in 1991 by Guido van Rossum.
# [UNGROUNDED] It was originally named Monty.
from langgraph.graph import StateGraph, END
from rag_critic import create_critic_node, critic_router
builder = StateGraph(MyRAGState)
# Add standard generator and retriever nodes
builder.add_node("retrieve", retrieve_docs)
builder.add_node("generate", generate_draft)
# Add rag-critic as an adversarial validation checkpoint
builder.add_node("critic", create_critic_node())
builder.add_edge("retrieve", "generate")
builder.add_edge("generate", "critic")
# Dynamic cyclic routing based on hallucination thresholds
builder.add_conditional_edges(
"critic",
critic_router,
{
"approved": END, # Output is grounded; return to user
"rewrite": "generate", # Minor gaps; instruct re-writer
"re_retrieve": "retrieve", # Heavy hallucination; expand search
}
)
Top comments (2)
The claim-level decomposition is the right call — "this answer feels inaccurate" is useless feedback to an agent because it can't localize the fix, and you end up regenerating a whole response to patch one ungrounded clause. Splitting into SUPPORTED/UNGROUNDED per claim turns a vague vibe into an actionable revise loop. The determinism point deserves more attention than it usually gets: LLM-as-judge confidence scores drift run to run, so any threshold you set ("revise if grounding < 0.7") is standing on sand unless the entailment step is deterministic. Two questions on the design: how do you handle a claim that's correctly unsupported-by-chunks but true (the model knows something the retrieval missed) — does it get flagged as hallucination and punished, or is there an escape hatch? And what's the entailment backbone — a small NLI model, or still an LLM call under the hood? That choice basically determines whether your 3–5s latency claim survives contact with a long multi-claim answer.
Really appreciate this comment — you zeroed in on the exact two headaches that kept me up when building this.
On the "true but missing from chunks" problem: This is honestly the classic RAG dilemma. If you're building for audit or legal use cases, an ungrounded truth is just as risky as a hallucination because you can't verify where it came from. But nuking the whole answer because the model knew something extra is terrible UX.
The way we handle it in LangGraph is treating UNGROUNDED not as a failure, but as a trigger for a targeted second look. Because the claim is already isolated into a single clean sentence (e.g. "Feature X was deprecated in v2.1"), the graph can fire off a quick micro-retrieval just for that proposition. A lot of the time, that surfaces the exact chunk the initial broad prompt missed.
If retrieval still comes back empty, the re-writer is instructed to either drop the claim or simply hedge it ("While not detailed in the provided documents, generally..."). We only hit the panic button and force a hard rewrite if a claim is explicitly CONTRADICTED by the source text.
On the entailment engine & latency: You're 100% right — if you do naive pairwise checks (say, 6 claims
×
× 5 chunks = 30 separate cross-encoder calls), your latency completely falls off a cliff.
Right now, rag-critic accepts any callable, but we avoid
𝑁
N individual calls by batching all claims against indexed context blocks in a single structured JSON pass. Running that through a fast SLM like Claude 3.5 Haiku, Gemini Flash, or a local 3B model via Groq/vLLM takes around 400–700ms, which keeps the full agent loop comfortably under that 3–4 second mark.
There's also a built-in deterministic heuristic mode (lexical + numeric entity matching + negation detection) that runs purely in Python in under 5ms if you want zero external API calls.
That said, long term I definitely want to bake in a small, distilled cross-encoder (testing ModernBERT / DeBERTa) so the entire critic can run locally on CPU without touching an LLM at all.
Curious what you're currently using in your setup — are you relying mostly on LLM-as-a-judge, or experimenting with local cross-encoders?