n4nAI

Fact-checking AI outputs: tools and techniques

A practical guide to fact-checking AI outputs with tools, techniques, and code patterns engineers can deploy today.

n4n Team3 min read685 words

Audio narration

Coming soon — every post will get a voice note here.

Fact-checking AI outputs tools and techniques have become essential infrastructure for any team shipping LLM features to production. The core problem is straightforward: models hallucinate, and users trust them anyway. This guide walks through an ordered path from quick wins to production-grade verification pipelines, with code you can adapt immediately.

Start with retrieval-augmented generation

Before you build verification layers, reduce the surface area for hallucinations. RAG grounds responses in source documents you control. The pattern is simple: retrieve relevant chunks, stuff them into context, generate with citations.

from openai import OpenAI
import numpy as np

client = OpenAI()

def retrieve_chunks(query: str, index, top_k: int = 5) -> list[str]:
    """Retrieve top-k chunks from a vector index."""
    query_embedding = client.embeddings.create(
        input=query, model="text-embedding-3-small"
    ).data[0].embedding
    
    scores, indices = index.search(np.array([query_embedding]), top_k)
    return [chunks[i] for i in indices[0]]

def generate_with_citations(query: str, chunks: list[str]) -> str:
    context = "\n\n".join(f"[Doc {i+1}] {c}" for i, c in enumerate(chunks))
    prompt = f"""Answer the question using only the provided documents.
    Cite sources inline like [Doc 1]. If the answer isn't in the docs, say so.
    
    Documents:
    {context}
    
    Question: {query}
    Answer:"""
    
    return client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0
    ).choices[0].message.content

This alone eliminates entire classes of fabrication. But RAG has limits: retrieval misses, context window pressure, and the model can still misread cited sources. You need verification on top.

Programmatic fact extraction and verification

For structured claims — numbers, dates, entity relationships — extract assertions programmatically and verify against authoritative sources. Don’t ask an LLM to fact-check itself; use deterministic lookups where possible.

import re
from datetime import datetime
import requests

CLAIM_PATTERNS = [
    (r"(\$[\d,]+(?:\.\d{2})?)", "currency"),
    (r"(\d{1,2}/\d{1,2}/\d{4})", "date_us"),
    (r"(\d{4}-\d{2}-\d{2})", "date_iso"),
    (r"(\d+(?:\.\d+)?%)", "percentage"),
]

def extract_claims(text: str) -> list[dict]:
    """Extract verifiable claims from generated text."""
    claims = []
    for pattern, claim_type in CLAIM_PATTERNS:
        for match in re.finditer(pattern, text):
            claims.append({
                "text": match.group(1),
                "type": claim_type,
                "position": match.span(),
                "context": text[max(0, match.start()-50):match.end()+50]
            })
    return claims

def verify_currency_claim(claim: str, source_api: str = "https://api.exchangerate.host/latest") -> dict:
    """Example: verify a currency amount against live rates."""
    # Parse amount and currency from claim like "$1,234.56"
    match = re.match(r"\$([\d,]+(?:\.\d{2})?)", claim)
    if not match:
        return {"verified": False, "reason": "unparsable"}
    
    amount = float(match.group(1).replace(",", ""))
    # In practice, you'd check against your business logic or external API
    return {"verified": True, "amount_usd": amount, "source": "parsed"}

def verify_date_claim(claim: str) -> dict:
    """Validate date format and reasonableness."""
    for fmt in ("%m/%d/%Y", "%Y-%m-%d"):
        try:
            parsed = datetime.strptime(claim, fmt)
            # Business rule: dates shouldn't be in the far future
            if parsed > datetime.now().replace(year=datetime.now().year + 2):
                return {"verified": False, "reason": "future_date"}
            return {"verified": True, "parsed": parsed.isoformat()}
        except ValueError:
            continue
    return {"verified": False, "reason": "invalid_format"}

Run this as a post-processing step on every generation. Flag unverified claims for review or suppress them entirely.

LLM-as-judge for semantic verification

Some claims resist programmatic verification: “The new policy improves customer satisfaction” or “Our competitor launched a similar feature last quarter.” For these, use a smaller, cheaper model as a critic — but structure the task carefully.

from pydantic import BaseModel, Field
from typing import Literal
import instructor

client = instructor.from_openai(OpenAI())

class VerificationResult(BaseModel):
    claim: str
    verdict: Literal["supported", "contradicted", "unverifiable"]
    confidence: float = Field(ge=0.0, le=1.0)
    evidence: str
    reasoning: str

VERIFICATION_PROMPT = """You are a fact-checker. Given a claim and source documents, determine if the claim is supported.

Rules:
- "supported": claim is directly stated or logically entailed by the sources
- "contradicted": sources explicitly disagree with the claim
- "unverifiable": sources don't contain relevant information
- Be conservative. Default to "unverifiable" when uncertain.
- Quote the exact text that supports your verdict.

Claim: {claim}
Sources:
{sources}

Return JSON matching the VerificationResult schema."""

def verify_claim_semantic(claim: str, source_chunks: list[str]) -> VerificationResult:
    sources_text = "\n\n".join(f"[Source {i+1}] {c}" for i, c in enumerate(source_chunks))
    return client.chat.completions.create(
        model="gpt-4o-mini",
        response_model=VerificationResult,
        messages=[{"role": "user", "content": VERIFICATION_PROMPT.format(
            claim=claim, sources=sources_text
        )}],
        temperature=0
    )

Key insight: the verifier model should be different from the generator. Use a smaller model (gpt-4o-mini, Claude Haiku) for verification — it’s cheaper, faster, and less prone to sycophancy. Pass only the specific claim and relevant sources, not the full conversation.

Build a verification pipeline

Chain these layers together. Each layer catches what the previous missed.

from dataclasses import dataclass
from enum import Enum
from typing import Optional

class VerificationStatus(Enum):
    VERIFIED = "verified"
    FLAGGED = "flagged"
    REJECTED = "rejected"

@dataclass
class VerifiedClaim:
    claim: str
    claim_type: str
    status: VerificationStatus
    programmatic_result: Optional[dict] = None
    semantic_result: Optional[VerificationResult] = None
    action: str = "allow"  # allow, flag, reject

def verify_generation(text: str, source_chunks: list[str]) -> list[VerifiedClaim]:
    claims = extract_claims(text)
    results = []
    
    for claim in claims:
        # Layer 1: programmatic verification
        prog_result = None
        if claim["type"] == "currency":
            prog_result = verify_currency_claim(claim["text"])
        elif claim["type"] in ("date_us", "date_iso"):
            prog_result = verify_date_claim(claim["text"])
        
        if prog_result and prog_result.get("verified"):
            results.append(VerifiedClaim(
                claim=claim["text"],
                claim_type=claim["type"],
                status=VerificationStatus.VERIFIED,
                programmatic_result=prog_result
            ))
            continue
        
        # Layer 2: semantic verification
        sem_result = verify_claim_semantic(claim["text"], source_chunks)
        
        if sem_result.verdict == "supported" and sem_result.confidence > 0.8:
            status = VerificationStatus.VERIFIED
            action = "allow"
        elif sem_result.verdict == "contradicted":
            status = VerificationStatus.REJECTED
            action = "reject"
        else:
            status = VerificationStatus.FLAGGED
            action = "flag"
        
        results.append(VerifiedClaim(
            claim=claim["text"],
            claim_type=claim["type"],
            status=status,
            programmatic_result=prog_result,
            semantic_result=sem_result,
            action=action
        ))
    
    return results

This pipeline runs in ~200-500ms per generation. Cache verification results by claim hash to avoid re-checking identical assertions across requests.

Human-in-the-loop for high-stakes outputs

Automated verification has false negatives. For medical, legal, financial, or customer-facing outputs, route flagged claims to human reviewers.

import uuid
from datetime import datetime, timezone
from typing import Callable
import json

class ReviewQueue:
    def __init__(self, storage_backend):
        self.storage = storage_backend  # Redis, Postgres, etc.
    
    def enqueue(self, generation_id: str, claims: list[VerifiedClaim], 
                original_text: str, metadata: dict) -> str:
        review_id = str(uuid.uuid4())
        flagged = [c for c in claims if c.status == VerificationStatus.FLAGGED]
        rejected = [c for c in claims if c.status == VerificationStatus.REJECTED]
        
        if not flagged and not rejected:
            return None  # Nothing to review
        
        record = {
            "review_id": review_id,
            "generation_id": generation_id,
            "original_text": original_text,
            "flagged_claims": [
                {"claim": c.claim, "type": c.claim_type, "reason": c.semantic_result.reasoning if c.semantic_result else "programmatic_failure"}
                for c in flagged
            ],
            "rejected_claims": [
                {"claim": c.claim, "type": c.claim_type, "evidence": c.semantic_result.evidence if c.semantic_result else str(c.programmatic_result)}
                for c in rejected
            ],
            "metadata": metadata,
            "status": "pending",
            "created_at": datetime.now(timezone.utc).isoformat()
        }
        
        self.storage.set(f"review:{review_id}", json.dumps(record))
        return review_id
    
    def resolve(self, review_id: str, reviewer_id: str, decision: str, notes: str = "") -> bool:
        """decision: 'approve' | 'reject' | 'modify'"""
        record = json.loads(self.storage.get(f"review:{review_id}"))
        record.update({
            "status": "resolved",
            "reviewer_id": reviewer_id,
            "decision": decision,
            "notes": notes,
            "resolved_at": datetime.now(timezone.utc).isoformat()
        })
        self.storage.set(f"review:{review_id}", json.dumps(record))
        return True

Integrate this with your existing ticketing system (Linear, Jira, GitHub Issues) so reviewers work in familiar tools. The key metric: time-to-resolution for flagged claims. Target < 4 hours for customer-facing content.

Observability: measure what matters

You can’t improve what you don’t measure. Instrument every layer.

from dataclasses import dataclass, asdict
import time
import logging

@dataclass
class VerificationMetrics:
    generation_id: str
    total_claims: int
    verified_programmatic: int
    verified_semantic: int
    flagged: int
    rejected: int
    latency_ms: int
    model_used: str

def record_verification(metrics: VerificationMetrics):
    # Ship to your observability stack (Datadog, Honeycomb, etc.)
    logging.info("verification_complete", extra=asdict(metrics))
    
    # Alert on anomalies
    if metrics.flagged / max(metrics.total_claims, 1) > 0.3:
        logging.warning("high_flag_rate", extra={"generation_id": metrics.generation_id})
    if metrics.latency_ms > 1000:
        logging.warning("verification_latency_spike", extra={"generation_id": metrics.generation_id})

# Wrap your pipeline
def verified_generate(query: str, index, metadata: dict) -> tuple[str, list[VerifiedClaim]]:
    start = time.time()
    generation_id = str(uuid.uuid4())
    
    chunks = retrieve_chunks(query, index)
    raw_answer = generate_with_citations(query, chunks)
    claims = verify_generation(raw_answer, chunks)
    
    # Apply actions
    final_answer = raw_answer
    for claim in claims:
        if claim.action == "reject":
            final_answer = final_answer.replace(claim.claim, f"[REMOVED: {claim.claim}]")
        elif claim.action == "flag":
            final_answer = final_answer.replace(claim.claim, f"⚠️ {claim.claim}")
    
    latency_ms = int((time.time() - start) * 1000)
    metrics = VerificationMetrics(
        generation_id=generation_id,
        total_claims=len(claims),
        verified_programmatic=sum(1 for c in claims if c.programmatic_result and c.programmatic_result.get("verified")),
        verified_semantic=sum(1 for c in claims if c.semantic_result and c.semantic_result.verdict == "supported"),
        flagged=sum(1 for c in claims if c.status == VerificationStatus.FLAGGED),
        rejected=sum(1 for c in claims if c.status == VerificationStatus.REJECTED),
        latency_ms=latency_ms,
        model_used="gpt-4o-mini"
    )
    record_verification(metrics)
    
    return final_answer, claims

Track these dashboards:

  • Claim verification rate by type (currency, date, percentage, semantic)
  • False positive rate — claims flagged by automation but approved by humans
  • False negative rate — claims that passed automation but caught in human review or user reports
  • End-to-end latency p50/p95/p99

Common pitfalls and tradeoffs

Over-verifying kills latency. Every verification layer adds 100-300ms. Profile your pipeline. If p95 latency exceeds your budget, drop semantic verification for low-risk claim types (percentages in marketing copy) and keep it for high-risk (dosage amounts in medical summaries).

The verifier can hallucinate too. A study by Min et al. (2023) found LLM judges agree with human annotators only ~80% of the time on factual consistency. Mitigate: use few-shot examples in your verification prompt, require quoted evidence, and set confidence thresholds conservatively.

Citation format matters. Models cite inconsistently: [1], [Doc 1], (source: doc1). Normalize citations during generation with a strict format instruction, then parse them programmatically to map claims to source chunks.

Context contamination. When you feed the full generation to the verifier, it may “remember” the model’s reasoning and rationalize errors. Pass only the extracted claim + source chunks to the verifier. Keep contexts isolated.

Cost scales with claim density. A 500-token response might contain 20 verifiable claims. At $0.15/1M tokens for gpt-4o-mini, that’s ~$0.003 per verification pass. At 10K generations/day, budget ~$90/month for the verification layer alone. Cache aggressively.

n4n.ai handles routing fallbacks automatically when a provider degrades, which matters for verification pipelines that call multiple models in sequence — if your primary verifier model goes down, the gateway fails over without your code changing.

What to build next

Start with programmatic extraction on your highest-volume endpoint. Add semantic verification for the claim types that matter most to your domain. Instrument everything. Route flagged claims to your existing review workflow. Iterate thresholds based on false positive/negative rates.

The goal isn’t perfect accuracy — it’s a measurable, improvable system where hallucinations become visible, auditable, and rare enough that users trust the output without blind faith.

Tagsfact-checkingai-outputsguide

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All grounding & fact-checking in ai posts →