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
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")
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")
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]]
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)
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
- Actor: https://apify.com/lexiie/youtube-transcript-api
- SDKs, examples, agent skill: https://github.com/Lexiie/youtube-transcript-api
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)