Back to blogIntegrations

How to Build a RAG Pipeline with YouTube Transcripts

A working RAG pipeline over YouTube video transcripts: collect transcripts across a video list, chunk them, embed and store the vectors, retrieve the relevant chunks, and generate a cited answer - full code for every step, plus how to keep the index fresh.

00:09:00 · SEP 27, 2026 · UPDATED OCT 09, 2026

Quick answer

Fetch each video's transcript through a hosted API, chunk the text, embed the chunks, and store the vectors - then at query time, embed the question, retrieve the closest chunks, and hand them to an LLM as grounded context with citations back to the source video.
01

Collect the transcripts

Get a key from the dashboard (100 free credits, no card required) and fetch each video's transcript by id. Credits are charged per request, not per minute of video, so a 3-hour lecture costs the same 1 credit as a 2-minute clip - which matters once you're ingesting a whole channel's back catalog:

async function fetchTranscript(videoId: string) {
  const res = await fetch(
    `https://getyoutubetranscript.com/api/v1/transcript?v=${videoId}`,
    { headers: { Authorization: `Bearer ${process.env.GETYOUTUBETRANSCRIPT_API_KEY}` } }
  );
  const json = await res.json();
  if (!json.success) throw new Error(json.message);
  return json.data as { video_id: string; title: string; transcript: string; word_count: number };
}

const videoIds = ['dQw4w9WgXcQ', 'jNQXAC9IVRw' /* ... */];
const videos = await Promise.all(videoIds.map(fetchTranscript));

video_id and title come back alongside the transcript text - that's what step 5 cites the answer back to.

02

Chunk the transcripts

Split each transcript into overlapping windows before embedding it - a whole-video embedding is too coarse to retrieve a specific answer from, and no library is required for this:

function chunkText(text: string, size = 1200, overlap = 200) {
  const chunks: string[] = [];
  let start = 0;
  while (start < text.length) {
    const end = Math.min(start + size, text.length);
    chunks.push(text.slice(start, end));
    start += size - overlap;
  }
  return chunks;
}

const records = videos.flatMap((video) =>
  chunkText(video.transcript).map((text, chunkIndex) => ({
    videoId: video.video_id,
    title: video.title,
    chunkIndex,
    text,
  }))
);

A 1,200-character window with 200 characters of overlap is a reasonable default for spoken-word transcripts - shrink it for short, dense clips, grow it for long lecture-style content where more surrounding context helps.

03

Embed and store

Embed every chunk and keep the video id and title alongside the vector, since that's what turns a retrieved chunk into a citation later:

import OpenAI from 'openai';
const openai = new OpenAI();

async function embed(text: string) {
  const res = await openai.embeddings.create({ model: 'text-embedding-3-small', input: text });
  return res.data[0].embedding;
}

const index = await Promise.all(
  records.map(async (r) => ({ ...r, embedding: await embed(r.text) }))
);

This keeps vectors in memory for clarity. Past a few hundred chunks, swap the array for pgvector, Pinecone, or another vector store - the retrieval step below only needs a function that returns the top-k nearest vectors, not this exact shape.

04

Retrieve the relevant chunks

At query time, embed the question with the same model and rank stored chunks by cosine similarity:

function cosineSimilarity(a: number[], b: number[]) {
  let dot = 0, normA = 0, normB = 0;
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];
    normA += a[i] ** 2;
    normB += b[i] ** 2;
  }
  return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}

async function retrieve(query: string, k = 5) {
  const queryEmbedding = await embed(query);
  return index
    .map((r) => ({ ...r, score: cosineSimilarity(r.embedding, queryEmbedding) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, k);
}

k=5 is a starting point - too few chunks and the model doesn't have enough context to answer fully, too many and irrelevant chunks start diluting the answer.

05

Generate a grounded answer

Pass the retrieved chunks to the model as numbered context and instruct it to answer only from what's there and to cite its sources - this is what keeps the answer traceable back to a real video instead of the model's own general knowledge:

async function answer(query: string) {
  const matches = await retrieve(query);
  const context = matches
    .map((m, i) => `[${i + 1}] From "${m.title}":\n${m.text}`)
    .join('\n\n');

  const completion = await openai.chat.completions.create({
    model: 'gpt-4.1-mini',
    messages: [
      {
        role: 'system',
        content:
          "Answer only using the numbered transcript excerpts below. Cite sources as [1], [2], etc. " +
          "If the excerpts don't contain the answer, say so.",
      },
      { role: 'user', content: `${context}\n\nQuestion: ${query}` },
    ],
  });

  return completion.choices[0].message.content;
}

Citing by video title, rather than inventing a timestamp, matches what the transcript API actually returns - don't let the model fabricate a timestamp it was never given.

Keep the index fresh

For a channel that keeps publishing, check for new uploads on a schedule with the /channel/latest endpoint (1 credit per check), skip video ids you've already indexed, and only fetch, chunk, and embed the new ones. Ingestion cost then stays proportional to new content instead of a full re-index every run.

Why transcripts work well for RAG

Video is one of the more awkward formats to build retrieval over - you can't grep a video. A transcript turns it back into text your existing embedding and retrieval stack already knows how to handle, without running your own speech-to-text job. And because a transcript-per-request API doesn't meter by video length, ingesting a backlog of long-form content (podcasts, lectures, multi-hour streams) doesn't get systematically more expensive than ingesting short clips - which is exactly the kind of archive a RAG pipeline is usually built to search.

Full endpoint reference, authentication, and rate limits are in the API docs. Get a key with 100 free credits from the dashboard to try this end to end.

RAG pipeline FAQs

Q01

Do I need a real vector database to follow this?

No - the in-memory array in this guide works fine up to a few hundred chunks and is the fastest way to verify the pipeline end to end. Move to pgvector or a managed vector store once you're indexing more than that, or need the index to persist across restarts.

Q02

Which embedding model should I use?

text-embedding-3-small is a reasonable default - cheap and good enough for most transcript retrieval. Switch to text-embedding-3-large only if you're seeing retrieval quality problems it would plausibly fix; it costs more per call.

Q03

What happens if a video has no transcript at all?

The transcript endpoint returns a clear error code (TRANSCRIPT_DISABLED or TRANSCRIPT_NOT_FOUND) instead of an empty string - check for success: false and skip that video rather than embedding an empty string as if it were real content.

Q04

How much does it cost to index, say, 200 videos?

200 credits for the transcript fetches, regardless of whether they're 2-minute clips or 2-hour videos - the 100 free signup credits cover roughly half a 200-video backlog on their own. Embedding cost is separate and billed by your embedding provider.

Q05

Can I cite an exact timestamp instead of just the video title?

Not with this endpoint - the public transcript API returns plain text, not timestamped segments, so the examples here cite by video title and id. If timestamp-level citation matters for your use case, keep chunks smaller so the video-level citation stays as precise as possible.

Related