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

빠른 답변

호스팅된 API로 각 영상의 스크립트를 가져오고, 텍스트를 청크로 나누고, 청크를 임베딩해서 벡터로 저장합니다 - 그다음 질의 시점에는 질문을 임베딩하고, 가장 가까운 청크를 검색해서, 원본 영상으로 이어지는 인용과 함께 LLM에 근거 컨텍스트로 전달합니다.
01

스크립트 수집하기

대시보드에서 키를 발급받고(100 무료 크레딧, 카드 불필요), 각 영상의 스크립트를 id로 가져옵니다. 크레딧은 영상 길이가 아니라 요청 단위로 청구되므로, 3시간짜리 강의도 2분짜리 클립과 똑같이 1크레딧입니다 - 채널의 과거 업로드 전체를 수집할 때 특히 중요한 차이입니다:

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와 title이 스크립트 텍스트와 함께 반환됩니다 - 이것이 5단계에서 답변을 인용할 때 사용하는 정보입니다.

02

스크립트를 청크로 나누기

임베딩하기 전에 각 스크립트를 겹치는 구간으로 나눕니다 - 영상 전체를 하나로 임베딩하면 특정 답변을 검색하기에는 너무 뭉뚱그려지며, 이를 위해 별도 라이브러리가 필요하지도 않습니다:

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,
  }))
);

1,200자 구간에 200자 겹침은 음성 기반 스크립트에 적당한 기본값입니다 - 짧고 밀도 높은 클립에서는 줄이고, 문맥이 더 도움이 되는 긴 강의형 콘텐츠에서는 늘리세요.

03

임베딩하고 저장하기

모든 청크를 임베딩하고, video id와 title을 벡터와 함께 보관하세요 - 이것이 나중에 검색된 청크를 인용으로 바꿔주는 정보입니다:

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) }))
);

여기서는 이해를 돕기 위해 벡터를 메모리에 보관합니다. 청크가 수백 개를 넘어가면 배열 대신 pgvector, Pinecone, 또는 다른 벡터 스토어로 바꾸세요 - 아래 검색 단계는 상위 k개의 가장 가까운 벡터를 반환하는 함수만 있으면 되고, 정확히 이 구조일 필요는 없습니다.

04

관련 청크 검색하기

질의 시점에는 같은 모델로 질문을 임베딩하고, 코사인 유사도로 저장된 청크의 순위를 매깁니다:

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는 시작점입니다 - 청크가 너무 적으면 모델이 충분히 답변할 컨텍스트를 얻지 못하고, 너무 많으면 관련 없는 청크가 답변을 흐립니다.

05

근거 있는 답변 생성하기

검색된 청크를 번호가 매겨진 컨텍스트로 모델에 전달하고, 그 안에 있는 내용만으로 답변하고 출처를 인용하도록 지시하세요 - 이것이 답변을 모델 자체의 일반 지식이 아니라 실제 영상까지 추적 가능하게 만드는 부분입니다:

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;
}

타임스탬프를 지어내는 대신 영상 제목으로 인용하는 것이 스크립트 API가 실제로 반환하는 것과 일치합니다 - 모델이 받은 적 없는 타임스탬프를 지어내게 두지 마세요.

인덱스를 최신 상태로 유지하기

계속 영상을 올리는 채널이라면, 무료 /channel/latest 엔드포인트(0크레딧)로 일정에 따라 새 업로드를 확인하고, 이미 인덱싱한 video id는 건너뛴 다음, 새 영상만 가져와서 청크로 나누고 임베딩하세요. 그러면 수집 비용이 실행할 때마다 전체를 다시 인덱싱하는 대신 새 콘텐츠에 비례해서 유지됩니다.

스크립트가 RAG에 잘 맞는 이유

영상은 검색을 구축하기에 꽤 까다로운 형식 중 하나입니다 - 영상은 grep할 수 없으니까요. 스크립트는 영상을 다시 텍스트로 바꿔주어서, 자체적으로 음성 인식 작업을 돌리지 않고도 이미 갖고 있는 임베딩·검색 스택으로 그대로 다룰 수 있습니다. 그리고 요청당 과금되는 스크립트 API는 영상 길이로 과금하지 않으므로, 장편 콘텐츠(팟캐스트, 강의, 몇 시간짜리 스트림) 백로그를 수집하는 것이 짧은 클립을 수집하는 것보다 구조적으로 더 비싸지지 않습니다 - 이는 RAG 파이프라인이 보통 검색하도록 만들어지는 바로 그런 아카이브입니다.

전체 엔드포인트 참조, 인증, 속도 제한은 API 문서에 있습니다. 처음부터 끝까지 직접 시도해 보려면 대시보드에서 100 무료 크레딧이 포함된 키를 발급받으세요.

RAG 파이프라인 자주 묻는 질문

Q01

이걸 따라 하려면 실제 벡터 데이터베이스가 필요한가요?

아니요 - 이 가이드의 메모리 내 배열은 수백 개 청크까지는 문제없이 작동하며, 파이프라인이 처음부터 끝까지 동작하는지 가장 빠르게 확인하는 방법입니다. 그보다 많이 인덱싱하거나 재시작 후에도 인덱스가 유지되어야 한다면 pgvector나 관리형 벡터 스토어로 옮기세요.

Q02

어떤 임베딩 모델을 써야 하나요?

text-embedding-3-small이 합리적인 기본값입니다 - 저렴하면서도 대부분의 스크립트 검색에 충분합니다. 검색 품질 문제가 실제로 발생하고 그것이 해결될 것 같을 때만 text-embedding-3-large로 바꾸세요 - 호출당 비용이 더 듭니다.

Q03

영상에 스크립트가 아예 없으면 어떻게 되나요?

스크립트 엔드포인트는 빈 문자열 대신 명확한 오류 코드(TRANSCRIPT_DISABLED 또는 TRANSCRIPT_NOT_FOUND)를 반환합니다 - success: false를 확인하고, 빈 문자열을 실제 콘텐츠인 것처럼 임베딩하지 말고 해당 영상은 건너뛰세요.

Q04

예를 들어 영상 200개를 인덱싱하는 데 비용이 얼마나 드나요?

스크립트를 가져오는 데 200크레딧이 듭니다 - 2분짜리 클립이든 2시간짜리 영상이든 상관없습니다. 가입 시 제공되는 100 무료 크레딧만으로도 200개 백로그의 절반 정도를 커버합니다. 임베딩 비용은 별도이며 사용하는 임베딩 제공업체가 청구합니다.

Q05

영상 제목 대신 정확한 타임스탬프로 인용할 수 있나요?

이 엔드포인트로는 불가능합니다 - 공개 스크립트 API는 타임스탬프가 있는 세그먼트가 아니라 순수 텍스트를 반환하므로, 여기 예제는 영상 제목과 id로 인용합니다. 사용 사례에서 타임스탬프 단위 인용이 중요하다면, 청크를 더 작게 유지해서 영상 단위 인용이 최대한 정확하게 유지되도록 하세요.

관련 콘텐츠