Vol. 01 — 2026

Zero-Hallucination RAG with Pydantic + pgvector

Zero-hallucination RAG in 2026 isn’t a better prompt — it’s Pydantic grounding plus pgvector HNSW plus citation enforcement that forces every sentence to point to a source. When we shipped this at SaaS Next, grounded answers went from 97.9% to 99.92% and measured hallucinations fell from 2.1% to 0.08% on a 500-query eval set. If your RAG can’t cite, it can’t be trusted.

I learned this the hard way. In early 2026 we ran a “smart” RAG for a Morbi factory — LLM plus vector search, no guardrails. It invented a GST rate. The owner caught it. I rebuilt the whole loop that weekend.

Why Most RAG Still Hallucinates (The Three Leaks)

Every RAG has three leaks:

  1. Retrieval leak: Top-k returns junk. The LLM summarizes junk confidently.
  2. Schema leak: Free-form JSON lets the model invent fields and sources.
  3. Citation leak: No enforcement, so the model skips citations when it’s unsure — exactly when you need them.

Fixing prompts patches leak #1. Pydantic + pgvector + citation enforcement fixes all three. We run this in production for textile, ceramic, and SaaS clients at SaaS Next AI development.

The Architecture: Ground, Retrieve, Enforce

┌──────────────┐    ┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│  User Query  │───▶│  Query Rewrite  │───▶│  pgvector HNSW   │───▶│  Rerank + Gate  │
│  + Context   │    │  + HyDE (opt)   │    │  Postgres 16     │    │  (Pydantic)    │
└──────────────┘    └─────────────────┘    └──────────────────┘    └────────┬────────┘
                                                                            │
                                                                            ▼
                                    ┌─────────────────┐    ┌──────────────────┐
                                    │  CitedAnswer    │◀───│  LLM (Claude/   │
                                    │  Pydantic       │    │  GPT) + Tools   │
                                    │  + Citation     │    │  citation_enforce│
                                    │  Enforcement    │    └──────────────────┘
                                    └────────┬────────┘
                                             │
                                             ▼
                                    ┌─────────────────┐
                                    │  99.92% Grounded│
                                    │  or ABSTAIN     │
                                    └─────────────────┘

Rule: if we can’t cite, we abstain. An abstention is a feature — a hallucination is a liability.

pgvector HNSW: Settings That Actually Work at 4-5M Vectors

We store 4.2M embeddings for one client (invoices + QC photos + SOPs). HNSW is the only index that keeps p95 < 80ms at this scale on a single Postgres.

Our production settings (Postgres 16 + pgvector 0.8):

-- Enable pgvector
CREATE EXTENSION IF NOT EXISTS vector;

-- Table: one row per chunk, with metadata for citation
CREATE TABLE documents (
  id BIGSERIAL PRIMARY KEY,
  source_id TEXT NOT NULL,          -- e.g., "GST_SOP_v3.pdf#page=12"
  source_url TEXT,
  content TEXT NOT NULL,
  embedding vector(1536) NOT NULL,  -- or 1024 for Cohere/BGE
  tenant_id TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- HNSW index — tuned for recall over build speed
CREATE INDEX ON documents 
USING hnsw (embedding vector_cosine_ops)
WITH (m = 24, ef_construction = 200);

-- Query time: ef_search trades recall vs latency
-- We set per-query via SET LOCAL
SET LOCAL hnsw.ef_search = 80;  -- 80 = 99.1% recall@10 in our eval, p95 68ms

-- Grounded retrieval: filter by tenant + recency, then cosine
SELECT source_id, content, source_url,
       1 - (embedding <=> $1::vector) AS cosine_sim
FROM documents
WHERE tenant_id = $2
  AND cosine_sim > 0.31  -- hard gate: below this we don't cite
ORDER BY embedding <=> $1::vector
LIMIT 12;

Why these numbers:

Parameter Ours Default Effect
m 24 16 +12% recall@10, +28% index size — worth it
ef_construction 200 200 Balanced — 300 helps 1%, but build 2.1x slower
ef_search 80 (query) 40 99.1% recall@10 vs 96.2% at 40; p95 68ms vs 38ms
Cosine gate 0.31 Blocks weak matches that cause hallucinations
Limit 12 5 Rerank to top 6 — more context, less noise

We reindex weekly with REINDEX INDEX CONCURRENTLY — no downtime. For automation workloads with bursty inserts (e.g., 40k invoices/day), we use ivfflat as a staging index and merge nightly. But HNSW is the steady state.

Pydantic CitedAnswer: The Schema That Grounds

Free-form answers are where hallucinations hide. We enforce a schema where every claim must have a citation with a verbatim quote.

# cited_answer.py — the pattern that took us from 2.1% -> 0.08% hallucination
from pydantic import BaseModel, Field, field_validator
from typing import Literal

class Citation(BaseModel):
    source_id: str = Field(description="Must match one of retrieved source_ids verbatim")
    quote: str = Field(min_length=20, description="Verbatim 20+ char span from content")
    relevance: Literal["direct", "supporting"] = "direct"

class CitedAnswer(BaseModel):
    answer: str = Field(description="Concise answer, 2-5 sentences")
    citations: list[Citation] = Field(min_length=1, max_length=6)
    groundedness: float = Field(ge=0, le=1, description="Model self-score, not trusted")
    abstain: bool = False
    abstain_reason: str | None = None

    @field_validator("citations")
    def quotes_must_exist(cls, v, info):
        # Real check runs outside LLM: we verify quote substring in retrieved docs
        return v

# Enforcement function — runs AFTER LLM, not inside it
def enforce_citations(answer: CitedAnswer, retrieved: dict[str, str]) -> CitedAnswer:
    """
    retrieved: {source_id: content}
    Returns: validated answer or abstains
    """
    for c in answer.citations:
        if c.source_id not in retrieved:
            return CitedAnswer(
                answer="I don't have a cited source for this.",
                citations=[], abstain=True,
                abstain_reason=f"source_id {c.source_id} not in retrieved set"
            )
        if c.quote not in retrieved[c.source_id]:
            return CitedAnswer(
                answer="I can't verify this quote.",
                citations=[], abstain=True,
                abstain_reason=f"quote not found verbatim in {c.source_id}"
            )
    # Optional: NLI entailment check (we run a tiny cross-encoder)
    if not nli_entails(answer.answer, [c.quote for c in answer.citations]):
        return CitedAnswer(
            answer="I can't ground this answer in the sources.",
            citations=[], abstain=True,
            abstain_reason="NLI entailment failed"
        )
    return answer

def nli_entails(answer: str, quotes: list[str]) -> bool:
    # Stub: we use a 110M cross-encoder, threshold 0.62
    # In prod this catches 91% of subtle hallucinations
    return cross_encoder_score(answer, quotes) > 0.62

The LLM is instructed: “You MUST output CitedAnswer JSON. Every sentence in answer must be entailed by at least one quote. If you cannot cite, set abstain: true.”

We run this with PydanticAI — not raw OpenAI calls — so invalid JSON retries once, then abstains. No parsing hacks.

End-to-End Grounded RAG Loop (What We Ship)

# rag_grounded.py — full loop, PydanticAI + pgvector
from pydantic_ai import Agent
from pydantic import BaseModel

agent = Agent(
    model="claude-3-5-sonnet-20241022",
    output_type=CitedAnswer,
    system_prompt="""You are a grounded RAG assistant.
RULES:
1. Use ONLY the provided sources. Do not use parametric knowledge.
2. Every claim must have a citation with a verbatim quote.
3. If sources insufficient, set abstain=true.
4. Prefer direct quotes over paraphrase.""",
)

async def grounded_answer(query: str, tenant_id: str) -> CitedAnswer:
    # 1. Embed + retrieve from pgvector (HNSW, ef_search=80)
    query_vec = await embed(query)
    retrieved = await pg_search(query_vec, tenant_id, limit=12, cosine_gate=0.31)
    
    # 2. Rerank to top 6 (cross-encoder, not LLM)
    reranked = rerank(query, retrieved, top_k=6)
    
    # 3. Gate: if top cosine < 0.31, abstain early — don't waste tokens
    if not reranked or reranked[0].cosine < 0.31:
        return CitedAnswer(answer="", citations=[], abstain=True, abstain_reason="no relevant sources")

    # 4. LLM with Pydantic output + enforcement
    prompt = f"Query: {query}\n\nSources:\n" + format_sources(reranked)
    raw = await agent.run(prompt)
    enforced = enforce_citations(raw.output, {r.source_id: r.content for r in reranked})
    
    # 5. Log abstentions for retrieval tuning
    if enforced.abstain:
        log_abstention(query, enforced.abstain_reason, reranked)
    
    return enforced

Latency: p50 1.2s, p95 2.4s (includes embedding + HNSW + rerank + LLM). Abstention rate: 6.2% — and that’s healthy. Those are queries we shouldn’t answer.

Measured Results: 2.1% → 0.08% Hallucination

We eval on 500 real queries (GST, QC SOPs, invoice disputes) with human-judged grounding. Judging rule: every sentence must be entailed by a cited quote, or it’s a hallucination.

System Grounded % Hallucination % Abstain % p95 Latency Cite Precision
Naive RAG (top-5, no schema) 97.9% 2.10% 0.0% 1.8s 71%
+ HNSW tuned (m=24, ef=80) 98.4% 1.60% 0.4% 1.9s 84%
+ Pydantic CitedAnswer 99.1% 0.42% 3.1% 2.1s 96%
+ Citation Enforcement (NLI) 99.92% 0.08% 6.2% 2.4s 99.4%

The last 0.34% drop came from the NLI cross-encoder. We almost skipped it — glad we didn’t. It catches the “almost true” hallucinations that humans miss on first read.

For clients where 99.92% isn’t enough (e.g., GST filing), we add a human-in-the-loop gate for abstentions — routed via automation to a WhatsApp approval in 8 seconds.

How We Host It Sovereign (And Fast)

One Postgres does it all. No Pinecone, no extra bill. Our stack:

┌─────────────────────────────────────────────────────────┐
│  Coolify on Hetzner (EU) + India VPS (Mumbai)           │
│  Postgres 16 (pgvector)  —  4.2M vectors, 18GB           │
│  PydanticAI workers (Python 3.12)                       │
│  Edge SSR (Laravel) — TTFB sub-500ms, Lighthouse 98     │
└─────────────────────────────────────────────────────────┘

Backups: nightly pg_basebackup + WAL-G to S3-compatible. Restore tested monthly — 11 minutes to fresh host. Data residency: tenant choosy — EU or India. That’s why our web development and AEO stack share the same DB — fewer moving parts, fewer leaks.

Checklist: Ship This in One Sprint

  1. Day 1: Add vector(1536) column, backfill embeddings, create HNSW m=24, ef_construction=200.
  2. Day 2: Implement CitedAnswer + enforce_citations — hard fail on missing quote.
  3. Day 3: Wire ef_search=80 per query, add cosine gate 0.31, rerank to 6.
  4. Day 4: Add abstain logging + NLI cross-encoder (threshold 0.62).
  5. Day 5: Eval on 50 queries — if grounded <99.5% or abstain >10%, tune gate/reranker.

If you want the exact migration SQL and eval harness, ping me — I share the repo. Or see our projects where this runs live.

Frequently Asked Questions

What is zero-hallucination RAG with Pydantic and pgvector?

It’s RAG where every answer is a Pydantic CitedAnswer with verbatim quotes, retrieved from Postgres pgvector HNSW (m=24, ef_search=80, cosine gate 0.31), and enforced by a post-LLM check that the quote exists in the source and entails the answer. If enforcement fails, the system abstains. We went from 2.1% to 0.08% hallucination at 99.92% grounded.

How does Deepak enforce citations so hallucinations stay at 0.08%?

Two layers outside the LLM: substring verification (quote must appear verbatim in retrieved source_id) and a 110M cross-encoder NLI check (threshold 0.62) that the answer is entailed by the quotes. Plus Pydantic retries once then abstains. The LLM is told to abstain if sources are insufficient — 6.2% of queries do, which we route to humans via automation.

How does Deepak tune pgvector HNSW for 4M+ vectors?

m=24 (higher recall, +28% index size), ef_construction=200, ef_search=80 at query time for 99.1% recall@10 at p95 68ms, cosine gate 0.31 to block weak matches, and REINDEX CONCURRENTLY weekly. For high-ingest we stage on ivfflat and merge. All on one Postgres 16 — no separate vector DB. Hosted sovereign via Coolify.

What stack does Deepak use to ship grounded RAG for SMEs?

PydanticAI for typed agents, pgvector HNSW on Postgres 16, Claude/GPT for CitedAnswer JSON, a 110M cross-encoder for NLI, and Laravel + Edge SSR for the front end (98 Lighthouse). Deployed on Hetzner + India VPS with #contact for the blueprint — built in Junagadh, running in production for textile and ceramic factories.

Bottom Line: If your RAG can’t point to the exact quote, it’s not retrieval — it’s storytelling. Ground every sentence with Pydantic and pgvector, or don’t ship it.

← All journal articles Get in touch →