πŸ¦‰ ~/projects/podcast-chatbot — pwsh

PS andrewfry.dev> cat /projects/podcast-chatbot.md

cd .. to return to /projects

Podcast Chatbot

Personal Project Β· Retrieval-Augmented Generation β€” ask a question, get an answer grounded in what was actually said

STATUS: POC_COMPLETE PATTERN: RAG BUILT: GPT-4 LAUNCH
[.NET MVC][Semantic Kernel][MongoDB][QDrant][Whisper][GPT-4]
  • 3 CRON stages (download Β· transcribe Β· vectorize)
  • ~10s transcription windows
  • 6 components in the stack
  • 2023 built on GPT-4's launch

01 The Idea

In 2023 I went through a podcast phase. Programming, working out, walking the dog, doing yard work β€” there was always a podcast running. It was informative, but I could never hold onto the details. I'd remember the shape of an intriguing idea and none of the specifics, which made it almost impossible to go back and act on anything.

When GPT-4 shipped that March, I set myself a challenge: build a chatbot grounded in the podcasts I'd actually been listening to. Not a model guessing from training data β€” a model answering from transcripts of the specific episodes. That's the RAG pattern: retrieve the relevant source material first, then let the model summarize and answer over it.

The stack:

  • .NET MVC β€” the UX/UI front end
  • .NET Semantic Kernel β€” the GenAI orchestrator
  • Custom .NET Core CRON jobs β€” the ingestion pipeline
  • MongoDB β€” the main datastore
  • QDrant β€” the vector store for similarity search
  • OpenAI GPT-4 β€” summarization and final answer

02 The Ingestion Pipeline

Before anything can be retrieved, the source material has to be collected and indexed. I built that as a chain of three CRON jobs β€” Downloader, Transcriber, Context Vectorizer β€” each one handing work to the next.

The Downloader checks the YouTube channels I followed for new videos, pulls them down, strips the audio to WAV, and persists the raw audio to MongoDB. Landing a new file kicks off a transcription task.

fig 1 β€” ingestion pipeline
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Downloader │────▢│ Transcriber │────▢│ Context          β”‚
  β”‚             β”‚     β”‚             β”‚     β”‚ Vectorizer       β”‚
  β”‚ YouTube β†’   β”‚     β”‚ WAV β†’       β”‚     β”‚ Transcription β†’  β”‚
  β”‚ WAV audio   β”‚     β”‚ Timestamped β”‚     β”‚ Vector chunks    β”‚
  β”‚ β†’ MongoDB   β”‚     β”‚ fragments   β”‚     β”‚ β†’ QDrant         β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

03 Transcription

The Transcriber takes the raw WAV and runs it through a local build of Whisper. The output is an array of fragments β€” each one a roughly 10-second window with an approximate start/end timestamp and the text spoken in that span. Those timestamps matter later: they're what let an answer point back to the exact moment in the episode.

fig 2 β€” Transcriber.cs
var processor = whisperFactory.CreateBuilder()
    .WithLanguage("auto")
    .WithPrintProgress()
    .Build();

var youTubeInfo = await _youtubeInformationRepository
    .FindByIdAsync(context.YouTubeVideoInformationId);
var downloadedFile = await _fileRepository
    .DownloadToBytesAsync(youTubeInfo.AudioFileId);

using (var fileStream = new MemoryStream(downloadedFile))
{
    await foreach (var result in
        processor.ProcessAsync(fileStream))
    {
        resultList.Add(new TranscriptionFragment()
        {
            StartTime       = result.Start,
            EndTime         = result.End,
            TranscribedText = result.Text
        });
    }
}

04 Sliding-Window Context

The final job, the Context Vectorizer, is where the retrieval quality is won or lost. A naΓ―ve approach would embed each 10-second fragment on its own β€” but a thought rarely fits in 10 seconds, and chopping on hard boundaries throws away the context that spans them.

So instead of isolated chunks, I built overlapping windows that slide across the whole episode. Each chunk carries a little of its neighbors with it, so no idea gets cut in half at a boundary. Those windowed chunks are what get embedded and inserted into QDrant.

fig 3 β€” overlapping chunks into QDrant
  Sliding-window context (overlapping chunks β†’ QDrant):

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ Transcribed  β”‚   β”‚ (timestamp) Speaker: transcript    β”‚
  β”‚ Segment 1    β”œβ”€β”€β–Άβ”‚ text for this ~10 second window... β”‚
  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€   β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
  β”‚ Transcribed  β”‚   β”‚ (timestamp) Speaker: transcript    β”‚
  β”‚ Segment 2    β”œβ”€β”€β–Άβ”‚ text for this ~10 second window... β”‚
  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€   β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
  β”‚ Transcribed  β”‚   β”‚ (timestamp) Speaker: transcript    β”‚
  β”‚ Segment 3    β”œβ”€β”€β–Άβ”‚ text for this ~10 second window... β”‚
  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€   β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
  β”‚ Transcribed  β”‚   β”‚ (timestamp) Speaker: transcript    β”‚
  β”‚ Segment 4    β”œβ”€β”€β–Άβ”‚ text for this ~10 second window... β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  Each chunk overlaps its neighbors to preserve
  cross-boundary context.

05 Retrieval & Answer

With everything indexed, the front end is simple. The user types a natural-language search β€” something like "the video where they talked about meditation lowering anxiety" β€” and Semantic Kernel hands the query to QDrant, which runs a nearest-neighbors search to find the transcribed segments closest to the question.

Those matched segments are then expanded β€” pulling the windows above and below each hit β€” to rebuild enough surrounding context, and that becomes the context window for GPT-4. A summarization prompt does the rest: the model reads the retrieved material and answers the user's question grounded in what was actually said.

fig 4 β€” query flow
  User query
      β”‚
      β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   nearest   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Semantic     β”‚  neighbors  β”‚    QDrant     β”‚
  β”‚  Kernel       │────────────▢│  Vector DB    β”‚
  β”‚               β”‚             β”‚               β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                                        β”‚ matched
                                        β”‚ chunks +
                                        β”‚ context
                                        β–Ό
                                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                β”‚    GPT-4      β”‚
                                β”‚  Summarize    β”‚
                                β”‚  & Answer     β”‚
                                β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                                        β”‚
                                        β–Ό
                                 Answer to user

06 What I'd Build Next

The POC answered questions well enough to prove the pattern. The obvious places to take it further:

  • Timestamped citations β€” every fragment already carries a start time, so an answer could link straight to the exact second in the episode it came from. That turns "I think they mentioned it somewhere" into a deep link.
  • Speaker diarization β€” attributing each segment to a speaker would let you ask "what did the host think?" versus the guest.
  • A managed vector store β€” QDrant was great for a local POC; a managed service would take the ops burden off scaling the index.
  • Re-ranking before the LLM β€” a cheap re-rank pass over the nearest-neighbor hits would tighten the context window before it ever reaches GPT-4.

> next: cat /projects/sharptechnicals β€” the financial analysis platform