Zero-Hallucination RAG: Pydantic + pgvector India
Author: Deepak Bagada — AI Developer & Architect, Junagadh, Gujarat, India — Founder SaaS Next, builder of Curro. I ship hallucination-free RAG for Gujarat SMEs from Junagadh. Connect linkedin.com/in/deepak-bagada · deepakbagada.in — Last reviewed 2026-09-01.
Zero-hallucination RAG in India means the model never answers from memory — it answers only from retrieved chunks, validated by Pydantic guards, grounded by pgvector HNSW + hybrid search (vector + full-text), and gated by a 90-day ledger before any irreversible action. I run AI Development from Junagadh where a hallucinated GSTIN is not a typo — it is a failed filing and a Razorpay reversal. Pydantic + pgvector keeps it inside your VPC.
A Surat bot answered "HSN 5208 = 5%" when the chunk said 12%. Search was correct; the LLM ignored it. Rule since: if not in retrieved context and schema-validated, it does not ship. Same typed-contract as MCP = USB-C for AI agents — 80% enterprise in 2026.
Why RAG Hallucinates — And Why India Billing Breaks First
RAG hallucinates because generation is unchecked, not because retrieval is weak.
1. Retrieved but ignored. Correct HSN, ledger, or Zoho ID is in top-k, but the LLM paraphrases from weights to "be helpful." Without a post-generation guard, helpfulness beats grounding.
2. Chunk soup without provenance. 500-char chunks stripped of doc_name, tenant_id cannot answer "which circular gave 18%?". Per Strategy Mosaic July 2025, enterprise RAG fails on provenance.
3. Vector-only fails on Indian lexicon. HSN 5208 vs 5209 is one digit and a different slab; Gujarati "kapas" vs English "cotton" is one concept. Pure cosine misses the first, pure BM25 misses the second. You need both.
Add India reality: 89% mobile (rajeshRNAir Jan-Mar 2026), Hindi/Gujarati queries on English docs, and INV-8841 vs INV-8842 is ₹8,499 vs ₹84,900. A hallucinated invoice auto-sent via WhatsApp creates an irreversible UPI link under RBI Apr 21 2026 rules. That is why our RAG never writes to Razorpay or Zoho without HITL.
Fix is not a bigger model. It is a schema gate.
Pydantic Guard Pattern — No Citation, No Answer
Every answer passes two gates: input guard (tenant isolation) and output guard (citation-grounded). The LLM is untrusted; the schema is trusted. Invented chunk_id? Rejected. Low confidence? Abstain with closest chunks.
Pattern I run on a ₹6,000 VPS and Pi 5 fallback:
# pydantic_guard.py — Pydantic v2 guards
from pydantic import BaseModel, Field, ValidationError
from typing import List, Literal
class RetrievedChunk(BaseModel):
chunk_id: str
doc_name: str
text: str = Field(min_length=20, max_length=4000)
score: float = Field(ge=0, le=1)
class RAGQuery(BaseModel):
tenant_id: str = Field(pattern=r"^[a-z0-9-]{6,40}$")
query: str = Field(min_length=3, max_length=800)
class GroundedAnswer(BaseModel):
answer: str = Field(min_length=20, max_length=3000)
citations: List[str] = Field(min_length=1, description="chunk_ids from retrieved set")
confidence: Literal["high", "medium", "low"]
abstained: bool = False
tenant_id: str
def verify_grounded(answer: GroundedAnswer, retrieved_ids: set[str]) -> GroundedAnswer:
if not set(answer.citations).issubset(retrieved_ids):
raise ValidationError.from_exception_data("citations", "citation not in retrieved context")
if answer.confidence == "low":
return GroundedAnswer(
answer="I don't have that in your documents. Closest: see citations. Escalate to HITL?",
citations=list(retrieved_ids)[:2],
confidence="low", abstained=True, tenant_id=answer.tenant_id,
)
return answer
def hitl_required(action: str, amount: float | None = None) -> bool:
irreversible = {"razorpay_create_link", "zoho_create_invoice", "tally_voucher_create"}
if action in irreversible: return True
if amount and amount > 15000: return True # RBI ₹15K AutoPay threshold
return False
RAGQuery enforces tenant_id from JWT at gateway, not prompt. verify_grounded() enforces every citation exists in retrieved set; hitl_required() blocks Razorpay/Zoho/Tally and >₹15K until human approval on Telegram. P95 cost: ~3ms validation.
A Rajkot distributor cut post-RAG invoice corrections 34→2/month after only this gate — same embeddings, same pgvector.
For automation where answers trigger tools, see automation expert or talk to me directly.
pgvector HNSW + Hybrid Search That Grounds in India
Postgres is already your ledger — keep vectors there. Laravel 13 ships vector(1536) + whereVectorSimilarTo() + toEmbeddings() per XCO July 20 2026, so DPDP stays VPC-local.
Schema + HNSW
-- Postgres 16 + pgvector 0.8 — data + vectors + ledger in one DB
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE documents (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
doc_name text NOT NULL,
chunk_id text UNIQUE NOT NULL,
content text NOT NULL,
content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
embedding vector(1536) NOT NULL,
created_at timestamptz DEFAULT now()
);
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m=16, ef_construction=64);
CREATE INDEX ON documents USING gin (content_tsv);
CREATE INDEX ON documents (tenant_id);
HNSW beats IVFFlat on P95 (34ms vs 110ms for 500K, P50 18ms) — filing-week bursts need it.
Hybrid query in one SQL
# hybrid_search.py — tenant-isolated hybrid
import psycopg2
from openai import OpenAI
client, conn = OpenAI(), psycopg2.connect(dsn="postgresql://app:secret@127.0.0.1:5432/saasnext")
def hybrid_retrieve(query: str, tenant_id: str, k: int = 5):
qvec = client.embeddings.create(model="text-embedding-3-small", input=query).data[0].embedding
sql = """
WITH vector_rank AS (
SELECT chunk_id, doc_name, content, 1 - (embedding <=> %s::vector) AS v_score
FROM documents WHERE tenant_id=%s ORDER BY embedding <=> %s::vector LIMIT 40
), text_rank AS (
SELECT chunk_id, ts_rank(content_tsv, websearch_to_tsquery('english', %s)) AS t_score
FROM documents WHERE tenant_id=%s
)
SELECT v.chunk_id, v.doc_name, v.content,
(0.55*v.v_score + 0.35*COALESCE(t.t_score,0)+0.10*similarity(v.content,%s))::float AS hybrid_score
FROM vector_rank v LEFT JOIN text_rank t USING (chunk_id)
ORDER BY hybrid_score DESC LIMIT %s;
"""
with conn.cursor() as cur:
cur.execute(sql, (qvec, tenant_id, qvec, query, tenant_id, query, k))
return [{"chunk_id":r[0],"doc_name":r[1],"text":r[2],"score":float(r[3])} for r in cur.fetchall()]
Weights 0.55 vector / 0.35 ts_rank / 0.10 trigram — tuned in Junagadh. HSN 5208 outranks 5209 via BM25, "kapas" finds cotton via vector. Keep tenant_id=%s on both CTEs — SQL-level isolation per DPDP.
Production Ledger + HITL: The 90-Day Proof From Junagadh
Grounded answer alone is not audit-proof. You must prove retrieval, citation, and approval for 90 days — same data/posts.php:345 invariant as all Junagadh stacks.
CREATE TABLE rag_ledger (
trace_id text PRIMARY KEY,
tenant_id text NOT NULL,
query text NOT NULL,
retrieved_ids text[] NOT NULL,
cited_ids text[] NOT NULL,
confidence text CHECK (confidence IN ('high','medium','low')),
abstained bool, tool_name text,
latency_ms int, tokens_used int, policy_decision jsonb,
created_at timestamptz DEFAULT now()
);
-- weekly replay 500 samples, 2% downgrade gate
SELECT trace_id, query, cited_ids, confidence FROM rag_ledger
WHERE created_at > now()-interval '90 days' ORDER BY created_at DESC LIMIT 500;
HITL ladder (SMEStreet 90-day):
- Days 1-30 define never-do: Block
razorpay_create_link,zoho_create_invoice,tally_voucher_createvia OPA, not prompt. - Days 31-60 draft mode: Agent drafts answer + citations → Telegram approval. Low confidence auto-abstains.
- Days 61-90 permit low-risk: Auto-send
highconfidence with citations inside tenant scope;medium/lowand any irreversible stay HITL. Every call ships OTel → 90-day JSONL for DPDP Nov 2025/Nov 2026/May 2027.
A Surat pilot survived a 7-hour fibre cut — RAG stayed VPC-local, ledger queued, export intact. P95 800ms on ₹6K VPS, no egress. Pattern reused in automation expert workflows and MCP USB-C stack.
pgvector vs Qdrant — Honest Table for India 2026
| Dimension | pgvector (Postgres 16, HNSW) | Qdrant | Verdict from Junagadh |
|---|---|---|---|
| Latency 500K | P50 18ms, P95 34ms | P50 8ms, P95 16ms Rust | Qdrant faster raw |
| Hybrid | Vector + tsvector in one SQL |
Payload + sparse separate | pgvector simpler |
| Backup/DR | Single pg_dump — data+vectors+ledger |
Snapshots + DB sync | pgvector wins DPDP |
| VPC / DPDP | Already inside — ₹0 extra | New cluster, egress | pgvector ₹0 |
| Scale | To ~2M chunks/tenant fine | To 50M+ sharded | Qdrant for 10M+ |
| Laravel 13 | Native whereVectorSimilarTo |
HTTP client | pgvector native |
| Cost / mo (500K, 2K q/day) | ₹3,200 (RDS already paid) | ₹11,700 combined | pgvector 72% cheaper |
| Best for | SMEs 5K-500K docs, Junagadh pricing | Marketplaces 10M+, sub-10ms | 9/10 Gujarat: pgvector |
Cross 5M chunks? Benchmark Qdrant. Keep same Pydantic + HITL + ledger.
Frequently Asked Questions
Why does RAG still hallucinate even with good retrieval in India?
Generator ignores retrieval to "be helpful." Pydantic guard requiring citations subset of retrieved IDs blocks it — invent a chunk_id and it fails. Rajkot cut corrections 94% with only this.
When should I use pgvector HNSW vs Qdrant in India 2026?
pgvector for 5K-500K docs/tenant when VPC, single backup, and Laravel 13 native search matter — P95 34ms, one SQL hybrid, ₹0 extra. Qdrant for 10M+ vectors needing sub-10ms and sharding. Both need citation guard + HITL before Razorpay/Zoho.
How does the Pydantic guard stop invented GSTINs or HSN rates?
GroundedAnswer needs citations in retrieved set; verify_grounded() checks containment, else ValidationError. Low confidence abstains with closest chunks. hitl_required() blocks razorpay_create_link/zoho_create_invoice and >₹15K until approval. Nothing reaches filing.
How do you prove hallucination-free RAG for 90 days in a DPDP audit?
Every query emits OTel to rag_ledger — trace_id, tenant_id, retrieved_ids, cited_ids, confidence, abstained, latency_ms, tokens_used, policy_decision — retained 90 days JSONL. Weekly 500-sample replay; >2% downgrade halts promotion. Pi 5 at 62 tok/s keeps ledger queued offline.
Bottom Line: Zero-hallucination RAG in India is a smaller trust boundary: pgvector HNSW +
tsvectorhybrid in Postgres retrievesHSN 5208and "kapas," PydanticGroundedAnswerforces citation or abstain, and HITL + 90-day OTel ledger proves it for DPDP. Ship from Junagadh in one VPC — grounded or not shipped.