Disclosure: I built the Apify Actor used in this tutorial. This post was written with AI assistance.
A talk, a lecture series or a product channel is a knowledge base that happens to be stored as video. To search it or feed it to a language model you need the captions as text, split into pieces, each piece turned into a vector. This tutorial does that with three pieces: an Apify Actor that returns timestamped transcripts, a small chunker that keeps the start time of every chunk so results link back to the exact second in the video, and a local open-source embedding model so nothing except the transcript download leaves your machine.
What you need:
- Python 3.11 or newer
- An Apify API token
- A few hundred megabytes of disk for the embedding model
The Actor is YouTube Transcript Scraper. It costs $0.001 per transcript delivered, so $1.00 per 1,000 videos. Videos with no captions, private or unavailable videos, bad links, and the listing of a playlist or channel are all free: you pay only for transcripts that arrive in your dataset. The free Apify plan's monthly usage credit covers the 20-video example below many times over.
pip install apify-client sentence-transformers numpy export APIFY_TOKEN=apify_api_xxxxxxxx pip install sentence-transformers downloads PyTorch if you do not have it, which is the bulk of that disk space.
1. What a transcript record looks like
The Actor's only required input is videos: a list of video links, youtu.be links, Shorts, playlist links, channel links (@handle or /channel/…) or bare video IDs, mixed freely. For a playlist or channel, maxVideosPerSource (default 50, up to 1,000) says how many of its videos to take; a channel yields its newest uploads first. language (default en) picks the caption track, preferring a human-written track over an auto-generated one in that language.
Each video comes back as one record:
{ "videoId": "aircAruvnKk", "url": "https://www.youtube.com/watch?v=aircAruvnKk", "title": "But what is a neural network? | Deep learning chapter 1", "channel": "3Blue1Brown", "status": "OK", "language": "en", "isAutoGenerated": false, "languageMatched": true, "lengthSeconds": 1132, "segmentCount": 286, "segments": [ { "start": 4.22, "duration": 1.18, "text": "This is a 3." }, { "start": 6.06, "duration": 4.653, "text": "It's sloppily written and rendered at an extremely low resolution of 28x28 pixels," } ], "text": "This is a 3. It's sloppily written and rendered ..." } Three fields matter for RAG:
-
segments: every caption line withstartanddurationin seconds. This is what lets a search result point at a timestamp. -
text: the whole transcript joined into one string, for when you do not need timestamps. -
status:OK,NO_CAPTIONSorFAILED, witherrorexplaining the last two. Nothing comes back silently empty, and the two failure cases cost nothing.
isAutoGenerated: true means YouTube's automatic captions: fine for search, but expect missing punctuation and the occasional misheard term. languageMatched: false means the video had no track in the language you asked for and you got its best available one instead; availableLanguages lists what the video has.
2. Fetch, chunk, embed
Here is the whole pipeline. Save it as index.py.
import os, json import numpy as np from apify_client import ApifyClient from sentence_transformers import SentenceTransformer client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor("highbrow_fame/youtube-transcript-fast").call(run_input={ "videos": ["https://www.youtube.com/@3blue1brown"], "language": "en", "maxVideosPerSource": 20, }) WINDOW = 60 # seconds of speech per chunk chunks = [] for v in client.dataset(run.default_dataset_id).iterate_items(): if v.get("status") != "OK": print("skipped", v.get("url"), v.get("status"), v.get("error")) continue buf, start = [], None for seg in v["segments"]: start = seg["start"] if start is None else start buf.append(seg["text"]) if seg["start"] + seg["duration"] - start >= WINDOW: chunks.append({"video": v["videoId"], "title": v["title"], "start": int(start), "text": " ".join(buf)}) buf, start = [], None if buf: chunks.append({"video": v["videoId"], "title": v["title"], "start": int(start), "text": " ".join(buf)}) model = SentenceTransformer("all-MiniLM-L6-v2") vectors = model.encode([c["text"] for c in chunks], normalize_embeddings=True, show_progress_bar=True) np.save("chunks.npy", vectors) json.dump(chunks, open("chunks.json", "w")) print(len(chunks), "chunks from", len({c["video"] for c in chunks}), "videos") That is the 30 lines. What each part does:
Fetch. call() starts the Actor run and waits for it. The Actor page reports fifteen videos processed in nine seconds in its own test run. Twenty videos cost at most $0.02 (fewer if some videos have no captions).
Chunk by time, not by characters. The chunker accumulates caption lines until the window covers 60 seconds of video, then emits a chunk carrying the start of its first line. Sixty seconds of speech is roughly 120 to 180 words, a comfortable size for a small embedding model and a readable size for a human looking at a result. The last partial window of each video is kept too. Time-based chunking has a property character-based chunking lacks: every chunk maps to a deep link, https://www.youtube.com/watch?v=<id>&t=<start>s.
Embed locally. all-MiniLM-L6-v2 is a 384-dimension sentence-transformers model, under 100 MB, fast on a CPU, and good enough for "find the part of the talk about X". normalize_embeddings=True makes every vector unit length, so cosine similarity is a plain dot product later. If you want better retrieval quality and can spare the time, swap in BAAI/bge-small-en-v1.5 or intfloat/e5-small-v2; the code does not change (the e5 family expects a query: / passage: prefix, check its model card).
Store. A NumPy array and a JSON file are enough for a few thousand chunks. Replace the two saves with a call to Chroma, FAISS, pgvector or whatever you already run once you outgrow it.
3. Search it
import sys, json import numpy as np from sentence_transformers import SentenceTransformer chunks = json.load(open("chunks.json")) vectors = np.load("chunks.npy") model = SentenceTransformer("all-MiniLM-L6-v2") q = model.encode([sys.argv[1]], normalize_embeddings=True)[0] scores = vectors @ q for i in np.argsort(-scores)[:5]: c = chunks[i] print(f"{scores[i]:.2f} {c['title']}") print(f" https://www.youtube.com/watch?v={c['video']}&t={c['start']}s") print(f" {c['text'][:160]}...\n") python search.py "how does backpropagation update the weights" Each hit prints its similarity score, the video title, a link that opens the video at the right second, and the first line of the chunk. That is already a usable "search inside this channel" tool.
4. Handing the chunks to a language model
Retrieval-augmented generation is this search plus a prompt. Take the top few chunks, put them in the prompt with their source, and ask the model to answer only from them:
top = [chunks[i] for i in np.argsort(-scores)[:5]] context = "\n\n".join( f"[{c['title']} @ {c['start']}s] {c['text']}" for c in top ) prompt = ( "Answer the question using only the transcript excerpts below. " "Cite the video title and timestamp you used. If the excerpts do not " "contain the answer, say so.\n\n" f"{context}\n\nQuestion: {sys.argv[1]}" ) Send prompt to whichever model and SDK you use. Because every chunk carries a title and a start time, the model can cite [title @ 412s] and you can turn that back into a link.
Choices you may want to make differently
- Window size and overlap. 60 seconds with no overlap is simple. If answers often straddle a boundary, add a 10-second overlap by starting the next chunk from the last few segments of the previous one, or shrink the window to 30 seconds and retrieve more hits.
- Use
textinstead ofsegmentswhen you do not care about timestamps; then any text splitter will do. - Re-indexing. Keep the run's dataset ID. Transcripts of published videos rarely change, so for a growing channel fetch only new videos (a channel link with a small
maxVideosPerSourcereturns the newest uploads) and append to your store instead of re-embedding everything. - Subtitles for a player. The Actor can add
srtandvttfields (includeSrt,includeVtt) if you also want to show captions alongside the search result.
Limits
- Videos without captions return
NO_CAPTIONSand are free, but they are also absent from your index. There is no speech-to-text step here; add one yourself if you need those videos. - Machine translation to your language (
translate: true) is best effort and YouTube restricts it heavily; in my own test it came through for 1 of 8 videos. The original transcript is returned either way. - Up to 1,000 videos per playlist or channel per run.
- Transcripts are the creator's content. Indexing them for your own search or research is a different thing from republishing them; respect YouTube's terms and the creators' rights.
The complete input schema, output fields and pricing are on the Actor page.