DEV Community

Cover image for RAG Over YouTube Playlists: From Video URLs to Cited Answers in 60 Lines
Bloody Valentine
Bloody Valentine

Posted on

RAG Over YouTube Playlists: From Video URLs to Cited Answers in 60 Lines

RAG Over YouTube Playlists: From Video URLs to Cited Answers in 60 Lines

In the previous post I covered how to extract YouTube
transcripts four ways. This one builds the thing everyone actually wants:
ask a question, get an answer cited to the exact video and timestamp.
No vector-database server, no framework. sentence-transformers handles
embeddings, numpy handles retrieval, and transcripts with timestamps do
the heavy lifting.

The full script is ~60 lines. Every step runs on your laptop except the
transcript fetch, which goes through my Apify Actor
(lexiie/youtube-transcript-api).
Playlist in, timestamped segments out.

Step 0: install

pip install requests sentence-transformers numpy openai
Enter fullscreen mode Exit fullscreen mode

APIFY_TOKEN from Apify Console → Settings → Integrations. You only need
an OpenAI key for the final answer step. Retrieval works without it.

Step 1: fetch transcripts (one item per video)

import os
import requests

ACTOR = "lexiie~youtube-transcript-api"
TOKEN = os.environ["APIFY_TOKEN"]

resp = requests.post(
    f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
    params={"token": TOKEN},
    json={"urls": ["https://www.youtube.com/playlist?list=PL..."],
          "language": "en"},
    timeout=600,
)
resp.raise_for_status()
items = [i for i in resp.json() if i["status"] == "success"]
print(f"{len(items)} videos transcribed")
Enter fullscreen mode Exit fullscreen mode

Note the filter: private videos, removed uploads, and caption-less videos
(without the ASR fallback) come back as status: failed with a structured
error.code. They're free, and they never kill the run. For caption-less videos
worth transcribing, add "enableAsrFallback": True (GPU transcription,
flat ~$0.10/video, takes minutes, so bump the timeout to 900s).

Step 2: chunk by segments, keep timestamps

Caption segments are natural chunk boundaries. Each already carries
start/duration. Group a few per chunk so embeddings have context, and
carry the video ID plus offsets for citations later:

def chunk_item(item, per_chunk=4):
    segs = item["segments"]
    return [{
        "videoId": item["videoId"],
        "title": item.get("title", item["videoId"]),
        "start": segs[i]["start"],
        "text": " ".join(s["text"] for s in segs[i:i + per_chunk]),
    } for i in range(0, len(segs), per_chunk)]

chunks = [c for i in items for c in chunk_item(i)]
print(f"{len(chunks)} chunks from {len(items)} videos")
Enter fullscreen mode Exit fullscreen mode

A 10-video playlist typically yields a few hundred chunks. Small enough
to hold in memory, big enough to need retrieval.

Step 3: embed once, retrieve with numpy

import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")  # 80MB, runs on CPU
embs = model.encode([c["text"] for c in chunks], normalize_embeddings=True)

def retrieve(question, k=5):
    q = model.encode([question], normalize_embeddings=True)[0]
    scores = embs @ q
    return [chunks[i] for i in np.argsort(-scores)[:k]]
Enter fullscreen mode Exit fullscreen mode

No server, no index to maintain. This holds up to tens of thousands of
chunks. Past that, reach for FAISS or a hosted vector DB, but most
playlist corpora never get there.

Step 4: answer with citations

Stuff the retrieved chunks into an LLM prompt with their source attached,
and require citations in the output:

import openai

def ask(question):
    hits = retrieve(question)
    context = "\n\n".join(
        f"[{h['videoId']} @ {h['start']:.0f}s | {h['title']}] {h['text']}"
        for h in hits)
    resp = openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content":
             "Answer from the context only. Cite every claim as "
             "[videoId @ seconds]. Say when the context is insufficient."},
            {"role": "user", "content": f"Context:\n{context}\n\nQ: {question}"},
        ],
    )
    return resp.choices[0].message.content, hits

answer, hits = ask("What does the author recommend for rate limiting?")
print(answer)
Enter fullscreen mode Exit fullscreen mode

The citation format doubles as a deep link. Render it for users as
https://www.youtube.com/watch?v={videoId}&t={seconds}s and they land on
the exact moment. That single trick is what separates "AI answered" from
"AI answered and I can verify it in one click."

Keeping it fresh

Playlists grow. Re-run the fetch weekly and skip videos you already have
(match on videoId against your stored chunks). You only pay per
successful transcript (~$0.003 captions), so a weekly sync of a
slow-moving playlist costs cents. Failures are free, so new private or
removed videos in the playlist don't cost you anything either.

What I'd add next

  • Deduplicate intros/outros: channels repeat the same 30s intro; hash first-chunk texts per channel and drop near-duplicates.
  • Rerank: a cross-encoder over the top-20 before the top-5 improves answers noticeably on dense topics.
  • Multi-playlist: tag chunks with playlistId (the Actor returns it) and filter retrieval per collection.

Links

Next in the series: repurposing video into articles and shorts scripts with
the SRT output. Tell me what corpus you're pointing this at. Curious what
breaks at your scale.

Top comments (0)