Back to writing

How I Built a RAG Tutor That Can't Hallucinate


A student watching a lesson on binary search asks the course chatbot: "What's the time complexity again?"

A bolt-on chatbot - a generic LLM wired to a sidebar - answers O(log n) because it knows that from training, not from the lesson. Sounds fine. But now ask it something the instructor said differently, or something specific to this course's framing, and it confidently invents an answer. It has no idea what's in the video. It's pattern-matching against the entire internet and hoping the average is right.

For a tutor, "confidently wrong" is the worst possible failure. A student can't tell a hallucination from a fact - that's why they're asking. So when I built the AI Course Companion for Skillera, the goal wasn't a smarter model. It was a tutor that structurally cannot answer from anything except the course's own content - and refuses when it doesn't have a source.

Here's how that actually works.


"Can't hallucinate" isn't a better model - it's grounding

Let me be precise, because the title is a promise I have to keep. No LLM is incapable of making things up. What you can do is build a system where the model is only ever shown text pulled from the course, instructed to answer only from that text, and told to say "I don't know" when the text doesn't cover the question.

That's Retrieval-Augmented Generation, and the important word is retrieval. The model is the last and least interesting step. The whole game is:

  1. Turn every course video into searchable meaning.
  2. For a given question, retrieve only the relevant pieces - and only from videos this learner is allowed to see.
  3. Hand the model those pieces and forbid it from going beyond them.

Get retrieval right and the model has almost no room to hallucinate, because it never sees the rest of the world. Get it wrong and no model is smart enough to save you.


Step 1: Turn videos into searchable meaning

You can't run a similarity search over an MP4. So before a course goes live, every video runs through an ingestion pipeline that ends in a vector database.

video → Whisper transcription → chunking → embeddings → pgvector
  • Transcribe. Whisper turns the audio into text, with timestamps. (Bonus: those same transcripts become WebVTT closed captions - accessibility for free, as a byproduct of an AI feature.)
  • Chunk. A 40-minute transcript is too big to be a unit of retrieval. I split it into overlapping passages of a few hundred tokens each, carrying the start timestamp along with every chunk. That timestamp matters later.
  • Embed. Each chunk goes through OpenAI's text-embedding-3-small, which turns text into a 1536-dimension vector - a list of numbers where semantic similarity becomes geometric closeness. "Big-O" and "time complexity" land near each other even though they share no words.
  • Store. The vectors go into Postgres with the pgvector extension, each row tagged with its lesson, its course, and its timestamp.

The schema is the quiet hero here. Every chunk knows where it came from:

schema.prisma (trimmed)
model LessonEmbedding {
  id         String @id @default(cuid())
  courseId   String // denormalized for scoped similarity search
  content    String // the chunk text sent to the embedding model
  chunkIndex Int    // order within the lesson
  startTime  Float? // seconds, for citations that seek the video

  embedding Unsupported("vector(1536)")

  lessonId String
  lesson   Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)

  @@index([courseId])
  @@map("lesson_embedding")
}

Two details are worth pausing on. First, courseId is denormalized: a chunk already belongs to a lesson, and the lesson to a course, but copying the course id onto every row lets retrieval filter by course without walking those joins. Second, the embedding column is declared Unsupported("vector(1536)") - Prisma has no native pgvector type (the extension itself is enabled via extensions = [vector] in the datasource). That Unsupported is foreshadowing: the ORM can store this column, but it can't query it. Remember that for the next step.

That courseId and startTime are what make the next two steps possible. Hold onto them too.

This whole job runs in the background on a queue - it's slow and CPU/IO-heavy, and re-running it for a lesson is idempotent, so a re-upload just replaces that lesson's chunks. (I run it on a separate queue from video transcoding, which is its own story for another post.)


Step 2: The one query that grounds AND gates

This is the part nobody writes about, and it's my favorite.

When a learner asks a question, the obvious RAG flow is: embed the question, find the nearest chunks, feed them to the model. That grounds the answer in the course. But it skips a second, equally important question:

Is this learner even allowed to see this content?

Skillera is a paid platform. A learner who hasn't enrolled in a course shouldn't be able to coax its content out of a chatbot, one "summarize lesson 4" at a time. Retrieval isn't just a relevance problem - it's an access-control problem. And the elegant part is that both constraints live in the same SQL WHERE clause.

course-scoped, enrollment-gated retrieval
const vector = `[${questionVector.join(",")}]`;

const chunks = await prisma.$queryRaw`
  SELECT le.id, le.content, le."lessonId", le."startTime"
  FROM lesson_embedding le
  JOIN enrollment e
    ON e."courseId" = le."courseId"
  WHERE e."userId"    = ${userId}    -- the asking learner
    AND le."courseId" = ${courseId}  -- the course they're viewing
  ORDER BY le.embedding <=> ${vector}::vector  -- cosine distance
  LIMIT 8
`;

Read that WHERE carefully, because it's doing two jobs at once:

  • le."courseId" = ${courseId} is the grounding constraint - only this course's chunks are candidates. The model will never see another course's material.
  • The JOIN enrollment ... WHERE e."userId" = ${userId} is the access constraint - the row only survives if an enrollment exists linking this user to this course. No enrollment, no rows. Not "rows you're told to ignore" - zero rows, full stop.

The <=> operator is pgvector's cosine distance - and it's the reason this is prisma.$queryRaw instead of the Prisma query API. That Unsupported("vector(1536)") column from Step 1? Prisma can store it but can't search it; no ORM speaks <=>. So the single most load-bearing query in the feature is the one written by hand. (The tagged template still parameterizes every ${...} value, so raw doesn't mean injectable.) ORDER BY ... LIMIT 8 is the actual semantic search: of the chunks that passed both filters, return the eight closest to the question. Top-K retrieval and authorization, resolved in a single round trip to the database.

I love this because the failure mode is safe by construction. If I forget the enrollment join, the bug is "logged-out users can read paid content" - loud, obvious, caught immediately. There's no path where the model quietly answers from data the learner was never entitled to, because the data never leaves the database. Security and grounding aren't two layers stacked on top of each other - they're the same query.

The request handler wraps this with a cheap guard before it ever embeds anything:

POST /api/chat (sketch)
const enrolled = await isEnrolled(userId, courseId);
if (!enrolled) return new Response("Not enrolled", { status: 403 });

const questionVector = await embed(question);   // text-embedding-3-small
const chunks = await retrieve(userId, courseId, questionVector); // the $queryRaw above

Step 3: The prompt that refuses to guess

Now the model finally enters - and its instructions are deliberately strict. The retrieved chunks are the only knowledge it's allowed to use, and it has explicit permission to give up.

building the grounded prompt
const context = chunks
  .map((c, i) => `[${i + 1}] (lesson ${c.lessonId} @ ${c.startTime}s)\n${c.content}`)
  .join("\n\n");

const system = `
You are a tutor for this course. Answer ONLY using the numbered
sources below. If the sources do not contain the answer, say you
don't have that in the course material - do NOT use outside knowledge.
Cite the sources you used by their number.

Sources:
${context}
`;

Two lines do the heavy lifting:

  • "Answer ONLY using the sources below." This is the wall. The model has a vast amount of training knowledge about binary search, but it's told to ignore all of it and speak only from what was retrieved from this course.
  • "If the sources do not contain the answer, say you don't have that." This is the escape hatch that makes the whole thing trustworthy. A grounded tutor that can't say "I don't know" will twist whatever chunks it got into an answer-shaped response. Permission to refuse is what turns "usually grounded" into "grounded or silent."

The answer streams back token by token via the Vercel AI SDK, and because the provider sits behind a single model-agnostic interface, the exact same flow runs on OpenAI or Claude - selectable per request. The grounding logic doesn't care which model is on the other end.


Making the grounding visible: citations that seek the video

Grounding the model is half the trust problem. The other half is proving it to the learner.

Every retrieved chunk still carries its startTime. So I pass the chunks back to the client as message metadata, and render them as clickable citations under the answer. Click one, and it deep-links the lesson with ?t=142, and the HLS.js player seeks to 2:22 - the exact second the tutor's answer came from.

The student doesn't have to trust the AI. They can watch the source. That's a whole post of its own, but it's the payoff of carrying the timestamp all the way from chunking through retrieval to the UI.


So can it ever hallucinate?

Honestly? In theory, a little. If retrieval pulls a chunk that's almost relevant, the model can still over-reach within it. RAG narrows the model's world to the right neighborhood; it doesn't hand-write every sentence.

But the difference in kind is what matters. A bolt-on chatbot hallucinates from the entire internet with full confidence and no way to check it. This tutor can only ever be wrong about content that's actually in the course, every answer points at a timestamp you can verify in one click, and when it has no source it says so instead of bluffing. That's not "a smarter model." It's a system where the model is structurally prevented from making things up out of thin air.


The takeaway

The interesting work in RAG isn't the model - it's everything around it. Grounding a tutor came down to three unglamorous decisions: store the timestamp with every chunk, fold access control into the retrieval query so the model never sees data the learner isn't entitled to, and give the model explicit permission to refuse.

If you're building "AI on your content," resist the urge to bolt a chatbot onto a sidebar and ship it. Spend your effort on retrieval. Decide exactly what the model is allowed to see - and prove it to the user with a citation they can click.

A tutor that says "I don't know" is worth ten that confidently make things up.


I write about RAG, full-stack development, and building in public as a CS student. Follow me on Twitter/X for more posts like this.