← all writing

Zero-hallucination RAG: grounded answers over 936 Lex Fridman episodes

Every answer cites its source. Intent routing, Qdrant, and a groundedness check that rejects answers the transcripts don't support.

A chatbot that answers questions about podcast episodes has one unforgivable failure mode: inventing a quote. "What did Lex say about loneliness?" — and the model confidently produces a sentence that sounds exactly like Lex but was never spoken.

OmniPod is a chat over 936 Lex Fridman episodes where every answer is grounded, verified against the actual transcript before it reaches the user. If the source doesn't support the answer, it says so. The whole system is 1,138 lines of Python across 9 files.

Corpus and ingestion: no API keys

Two scrapers feed the index, both free: official transcript pages on lexfridman.com (requests + BeautifulSoup) and YouTube auto-captions via a public proxy. No YouTube API key, no paid source. All 936 episodes ingest in about 8 minutes.

Chunking is deliberately boring: 512 characters with 128 overlap, each chunk tagged with its episode and guest metadata so retrieval can filter by guest. That yields 19,140 chunks, small enough that a single laptop instance handles the whole corpus.

Retrieval: local embeddings, one vector DB

Chunks are embedded with bge-small-en-v1.5 (384 dims) running locally on MPS, ~100ms per query embedding. Search is cosine similarity in Qdrant: ~50ms at 19K points, with guest metadata filtering built in.

Why this stack, concretely:

The router: one prompt fails at scale

Not every question wants the same pipeline. "What did Huberman say about sleep?" and "Compare AI safety views across guests" and "Write an essay on consciousness" are three different problems. OmniPod classifies intent first, then dispatches:

typeexamplestrategy
factual"What did Huberman say about sleep?"retrieve → generate → verify
synthetic"Compare AI safety views across guests"map-reduce → deduplicate → synthesize
generative"Write an essay on consciousness"plan → draft → ground

An LRU cache avoids re-embedding repeated queries, and a semaphore caps concurrent LLM calls at 5 so bursty sessions don't blow the budget.

The groundedness check

Every generated answer runs through verify_groundedness() before it's shown:

# verify_groundedness() — the last gate before the UI
def verify_groundedness(answer: str, context: list[Chunk]) -> str:
    prompt = (
        "Check every factual claim in the answer against the context. "
        "For each claim return SUPPORTED, UNSUPPORTED, or NOT_IN_CONTEXT. "
        "If any claim is unsupported or missing, name it and explain why."
    )
    verdict = llm(prompt, answer=answer, context=context)
    if verdict.strip() != "OK":
        return refuse_or_rephrase(answer, verdict)  # says "not covered" — never guesses
    return answer

The model must defend each claim against the retrieved text. When the transcripts don't cover a question, the answer is explicit about it instead of papering over the gap.

Numbers that matter

metricvalue
episodes indexed936 lex fridman
chunks19,140 (512 chars, 128 overlap)
embedding dim384 (bge-small-en-v1.5, MPS GPU)
query embedding~100ms
vector search~50ms (cosine, 19K points)
full answer~2s on M1 Pro
codebase1,138 lines python, 9 files

Lessons

The system answers with confidence only where the transcripts are confident, and every answer carries a source you can check.