Software Journal
Web Development Updated Sep 2, 2026 18 min read

YouTube Transcript Analyzer: Flask RAG with Gemini

Explore how this Flask app extracts YouTube transcripts, chunks with LangChain, embeds with Gemini, indexes with FAISS, and enables semantic search and AI Q&A.

Portrait of Benmalek Zohir

Benmalek Zohir

Contributor

Share
YouTube Transcript Analyzer: Flask RAG with Gemini

YouTube Transcript Analyzer: Flask RAG with Gemini

A verified technical deep-dive into azario0/youtube-transcript-analyzer — a Flask web application that turns any YouTube video with captions into a searchable, AI-answerable knowledge base using YouTube Transcript API, LangChain, Google Gemini, and FAISS.

If you searched for “Flask YouTube transcript search,” “YouTube RAG with Gemini and FAISS,” “LangChain RecursiveCharacterTextSplitter tutorial,” or “how to build semantic search over YouTube videos,” this guide breaks down a working implementation file-by-file, with every claim verified against the actual repository code.

TL;DR — What This Project Does

This app lets you paste a YouTube URL, automatically fetches its transcript, splits it into overlapping chunks, embeds each chunk with models/text-embedding-004, builds a per-video FAISS index, and then serves two core experiences: semantic search across all videos and AI Q&A grounded in the most relevant transcript segments using models/gemini-2.0-flash. The UI in app/templates/ adds thumbnail previews, timestamp badges, relevance scores, and delete management — all without a database, just JSON + FAISS files on disk.

Key Features (Verified from Code)

Programming Languages Used

Language / MarkupRoleWhere Verified
Python 3.9+All backend logic: Flask routes, transcript fetching, chunking, embedding, FAISS indexingapp/app.py (317 lines), explanation/first_step.ipynb, explanation/second_step.ipynb
HTML (Jinja2)6 server-rendered templates extending a Bootstrap baseapp/templates/base.html, app/templates/index.html, app/templates/add_video.html, app/templates/video_detail.html, app/templates/search.html, app/templates/search_results.html
CSSBootstrap 5.3.2 + custom styles (cards, navbar shadow, answer-box)Inside app/templates/base.html#L10-L69 (inline <style>)
JavaScript (jQuery 3.7.1)AJAX search + answer generation, modal handlingapp/templates/video_detail.html#L138-L240, app/templates/search_results.html#L124-L170
JSON + FAISS binaryPersistence for chunks, metadata, and vector indicesyoutube_chunks.json (15 pre-built chunks), youtube_embeddings.index (FAISS data file), and per-video files in data/metadata/ + data/indices/ created in app/app.py#L112-L123

There is no TypeScript, no ORM, no separate build step — pure Flask + Jinja2 + jQuery on top of Python.

Technology Stack at a Glance

LayerTechnologyPurposeFile Reference
Web FrameworkFlask 2.3.3Routing, templating, flash messages, app.secret_keyapp/app.py#L3, app/requirements.txt#L1
Transcript Fetchyoutube-transcript-api 0.6.1YouTubeTranscriptApi.get_transcript(video_id)app/app.py#L4, app/app.py#L51
ChunkingLangChain 0.1.0 RecursiveCharacterTextSplitterchunk_size=1000, chunk_overlap=200, separators ["\n\n","\n"," ",""]app/app.py#L6, requirements.txt#L3
Embeddingsgoogle-generativeai 0.3.2 models/text-embedding-004task_type="retrieval_document" for documents and queriesapp/app.py#L28, app/app.py#L100-L102
LLMGoogle Gemini models/gemini-2.0-flashGrounded answer generationapp/app.py#L29, app/app.py#L194
Vector DBFAISS-CPU 1.7.4 (IndexFlatL2)Exact L2 nearest-neighbor search per videoapp/app.py#L8, requirements.txt#L5
FrontendBootstrap 5.3.2 + Font Awesome 6.5.1 + jQuery 3.7.1Responsive cards, navbar, icons, AJAXapp/templates/base.html#L8-L9, base.html#L113
Utilsnumpy 1.24.3, python-dotenv 1.0.0Embedding array astype("float32"), env loading (imported but not used for key)requirements.txt#L6-L7

Project Structure

Verified via ls -R and git log:

youtube-transcript-analyzer/
├── app/
│   ├── app.py                 # 317-line Flask + RAG core (extract, process, search, generate)
│   ├── requirements.txt       # 7 pinned deps
│   ├── templates/
│   │   ├── base.html          # Bootstrap layout + navbar + flash + footer (123 lines)
│   │   ├── index.html         # Home: jumbotron + video cards + thumbnails (55 lines)
│   │   ├── add_video.html     # Form + 6-step “How it Works” (50 lines)
│   │   ├── video_detail.html  # Accordion chunks + ask/search/delete modals + jQuery AJAX (241 lines)
│   │   ├── search.html        # Search form + tips + video list (68 lines)
│   │   └── search_results.html# Grouped results + relevance badges + Generate Answer (171 lines)
│   └── data/                  # Created at runtime via os.makedirs() at app.py#L18-L21
│       ├── indices/{video_id}.index   # FAISS indices
│       └── metadata/{video_id}.json, {video_id}_chunks.json
├── explanation/
│   ├── first_step.ipynb       # Extraction → chunking → embedding → FAISS write
│   ├── second_step.ipynb      # Load index → search → prompt → Gemini generation
│   └── 1.png                  # Illustration
├── youtube_chunks.json        # 15 pre-built text chunks (example video about Project Euler)
├── youtube_embeddings.index   # Pre-built FAISS index (46 KB, `data` file)
├── README.md                  # Setup, usage, project structure, tech stack
└── LICENSE                    # MIT

Note: The root README.md at README.md#project-structure mentions data/, data/indices/, data/metadata/, templates/, static/ — in the actual committed code, data/ and static/ are gitignored/created at runtime, and templates/ lives under app/templates/. The pre-built youtube_chunks.json and youtube_embeddings.index at the repo root are the exported artifacts from the notebooks, not the per-video files used by the Flask app.

Architecture — The RAG Loop for YouTube

User pastes YouTube URL
        ↓
[ extract_video_id() ] — regex for watch/youtu.be/embed/v at app.py#L31-L45
        ↓
[ get_video_metadata() ] — probe transcript existence at app.py#L47-L59
        ↓
[ process_video() ] at L61-L128
   ├─ YouTubeTranscriptApi.get_transcript(video_id) → list[ {text, start, duration} ]
   ├─ " ".join(text) → Document(page_content, metadata={video_id})
   ├─ RecursiveCharacterTextSplitter (1000/200) →  ~15 Documents (for the example video)
   ├─ genai.embed_content(task_type="retrieval_document") per chunk → vectors
   ├─ np.array(astype float32) → faiss.IndexFlatL2 → faiss.write_index(data/indices/{id}.index)
   └─ json.dump chunks_metadata + metadata → data/metadata/{id}.json / {id}_chunks.json
        ↓
Home / Search / Video Detail
        ↓
[ search_video(video_id, query, top_k=3) ] at L141-L177
   ├─ faiss.read_index + json.load chunks
   ├─ genai.embed_content(query, task_type="retrieval_document") → query vector
   ├─ index.search(query_embedding, top_k) → distances, indices
   └─ return [{text, start_time, distance, chunk_id}] × top_k
        ↓
[ generate_response(query, context) ] at L179-L200
   ├─ "\n\n".join(context texts) → prompt: "Using the following context...\nAnswer the following question:"
   └─ genai.GenerativeModel(gemini-2.0-flash).generate_content(prompt) → response.text
        ↓
jQuery AJAX in video_detail.html#138-240 and search_results.html#124-170 renders chunks + AI Answer box

Per-video FAISS files keep search isolated; /search simply loops search_video() over all videos at app/app.py#L264-L266 and groups search_results[video_id].

Deep Dive 1: Extracting YouTube Video IDs

extract_video_id(url) — app/app.py#L31-L45

patterns = [
    r'(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})',
    r'youtube\.com\/embed\/([a-zA-Z0-9_-]{11})',
    r'youtube\.com\/v\/([a-zA-Z0-9_-]{11})'
]
for pattern in patterns:
    match = re.search(pattern, url)
    if match:
        return match.group(1)
return None
  • Covers four URL forms: Standard watch?v=, shortened youtu.be/, embedded /embed/, and legacy /v/ — all requiring the canonical 11-char ID charset [a-zA-Z0-9_-].
  • Caller: /add_video POST at L215 validates with if not video_id: flash('Invalid YouTube URL...') at L217-L219.

get_video_metadata(video_id) — app/app.py#L47-L59

Uses transcript existence as a health check:

transcript = YouTubeTranscriptApi.get_transcript(video_id)
return {"video_id": video_id, "url": f"https://www.youtube.com/watch?v={video_id}", "processed_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "chunks_count": 0}
  • If the transcript fetch throws (no captions, private/deleted video), it returns None at L59 — the /add_video route then flashes Could not retrieve video information at L223-L225. The initial chunks_count: 0 is overwritten after process_video succeeds at L120.

Deep Dive 2: The Transcript Processing Pipeline

process_video(video_id) — app/app.py#L61-L128

This is the longest function (68 lines) — the offline-to-online bridge.

Step 1 — Fetch & Join:

transcript_snippets = YouTubeTranscriptApi.get_transcript(video_id)  # L65
transcript = " ".join(snippet["text"] for snippet in transcript_snippets)  # L66
  • transcript_snippets is a list like [{"text":"hello everyone", "start":0.5, "duration":3.2}, ...] — verified in youtube_chunks.json where the first chunk begins "hello everyone and welcome...".

Step 2 — Document + Chunking:

doc = Document(page_content=transcript, metadata={"video_id": video_id})  # L69
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200, separators=["\n\n","\n"," ",""])  # L72-L76
documents = text_splitter.split_documents([doc])  # L77
  • chunk_size=1000 chars ≈ ~250 tokens, chunk_overlap=200 preserves cross-boundary context. Separators prefer paragraph → line → space → char fallback — the same values shown in explanation/first_step.ipynb (Cell 2) which produced Number of chunks: 15 for the Project Euler video. The committed youtube_chunks.json is exactly those 15 strings.

Step 3 — Timestamp Mapping:

for i, doc in enumerate(documents):
    start_time = transcript_snippets[i].get("start", 0) if i < len(transcript_snippets) else None  # L86-L87
    chunks_metadata.append({"chunk_id": i, "video_id": video_id, "start_time": start_time, "text": doc.page_content})  # L89-L94
  • Approximate: chunks_metadata[i].start_time uses transcript_snippets[i].start, not a precise forced-alignment. It assumes chunk i roughly aligns with snippet i — a known simplification. When i >= len(transcript_snippets), start_time stays None and the template hides the badge via {% if chunk.start_time %} at video_detail.html#L51.

Deep Dive 3: Embeddings & FAISS Indexing

Per-Chunk Embeddings — app/app.py#L96-L104

embeddings = []
for chunk in text_chunks:
    response = genai.embed_content(model=embedding_model, content=chunk, task_type="retrieval_document")
    embeddings.append(response['embedding'])
  • Embedding model: models/text-embedding-004 at L28 — the same model = "models/text-embedding-004" used in explanation/first_step.ipynb Cell 3. task_type="retrieval_document" (vs retrieval_query for searches) applies the correct Gemini task prefix.
  • One-by-one: No batching — each chunk triggers a separate genai.embed_content call. For 15 chunks this is trivial; for hour-long videos you’d want to batch or rate-limit.

FAISS IndexFlatL2 — app/app.py#L106-L116

embedding_array = np.array(embeddings).astype("float32")
index = faiss.IndexFlatL2(embedding_array.shape[1])
index.add(embedding_array)
faiss.write_index(index, f"data/indices/{video_id}.index")
with open(f"data/metadata/{video_id}_chunks.json", "w") as f:
    json.dump(chunks_metadata, f)
  • IndexFlatL2 is an exact brute-force index — no quantization, no IVF training — ideal for per-video indices with dozens to hundreds of vectors (the example youtube_embeddings.index is only 46 KB). Dimension embedding_array.shape[1] is the Gemini embedding width (768 for text-embedding-004).
  • On-disk layout: The Flask app writes data/indices/{video_id}.index (FAISS binary) + data/metadata/{video_id}_chunks.json (list of {chunk_id, video_id, start_time, text}) + data/metadata/{video_id}.json (with chunks_count updated at L119-L123). The notebooks instead write youtube_embeddings.index + youtube_chunks.json at the repo root — those are the demo artifacts checked into git.

search_video(video_id, query, top_k=3) — app/app.py#L141-L177

index = faiss.read_index(f"data/indices/{video_id}.index")
with open(f"data/metadata/{video_id}_chunks.json", "r") as f:
    chunks = json.load(f)
query_embedding = np.array(genai.embed_content(model=embedding_model, content=query, task_type="retrieval_document")['embedding']).astype("float32").reshape(1, -1)
distances, indices = index.search(query_embedding, top_k)
for i, idx in enumerate(indices[0]):
    chunk = chunks[idx]
    results.append({"text": chunk["text"], "start_time": chunk["start_time"], "distance": float(distances[0][i]), "chunk_id": chunk["chunk_id"]})
  • Load per request: Index + chunks are read from disk on every search — no in-memory cache. Simple and correct for a demo; a production app would LRU-cache indices.
  • Same task_type: Uses "retrieval_document" for the query too at L156 (strictly, "retrieval_query" would be more precise per Gemini docs, but the app consistently uses retrieval_document — verified in both search and indexing).
  • Distance → Relevance: Templates display Relevance: {(1 - distance/10).toFixed(2)} in video_detail.html#L178 and {(1 - distance/10)|round(2)} in search_results.html#L62 — a heuristic mapping L2 distance 0-10 to 1.0-0.0, not a normalized cosine.
  • Error fallback: Returns [] at L177 on any exception (missing index, bad key) — the UI shows No relevant transcript chunks found at video_detail.html#L212-L216.

The second notebook at explanation/second_step.ipynb Cell 2 shows a standalone search_similar(query, top_k=3) that prints Top matches: with Distance: 0.616... — the same FAISS L2 distances the Flask app surfaces.

Deep Dive 5: AI-Powered Answer Generation

generate_response(query, context) — app/app.py#L179-L200

context_text = "\n\n".join([item["text"] for item in context])
prompt = (
    "Using the following context from the video transcript:\n\n"
    f"{context_text}\n\n"
    "Answer the following question:\n"
    f"{query}\n\n"
    "Response:"
)
model = genai.GenerativeModel(model_name=llm_model)  # models/gemini-2.0-flash at L29
response = model.generate_content(prompt)
return response.text
  • Grounding: The prompt explicitly says Using the following context from the video transcript: — this is the RAG grounding instruction that reduces hallucinations. The notebook’s generation cell in explanation/second_step.ipynb Cell 3 uses the identical template and gemini-2.0-flash.
  • No history: Each call is stateless — no chat memory, no follow-up. The context list is the top-k chunks from search_video(), joined with double newlines at L182.
  • Exposed via API: POST /api/generate_response at app/app.py#L288-L297 expects {"query": "...", "context": [...]} and returns {"response": "..."} as JSON — the video detail JS calls it after the search succeeds at video_detail.html#L191-L209.

Deep Dive 6: Flask Routes & API Surface

All 7 routes in app/app.py#L202-L317 — verified line-by-line:

RouteMethodHandlerPurpose
/GETindex() L202-L206Lists all processed videos via get_processed_videos() → templates/index.html
/add_videoGET/POSTadd_video() L208-L237GET shows add_video.html; POST validates URL, checks transcript, calls process_video(), flashes success/failure, redirects
/video/<video_id>GETvideo_detail() L239-L253Loads {video_id}.json + {video_id}_chunks.json → video_detail.html accordion + ask box
/searchGET/POSTsearch() L255-L275GET shows search.html; POST loops search_video() per video, groups into search_results[video_id] → search_results.html
/api/search/<video_id>POST (JSON)api_search_video() L277-L286AJAX per-video search used by video_detail.html JS at L151 ({query, top_k} → [{text, start_time, distance, chunk_id}])
/api/generate_responsePOST (JSON)api_generate_response() L288-L297{query, context} → {response} from Gemini
/api/delete_video/<video_id>POSTdelete_video() L299-L314os.remove on data/indices/{id}.index, data/metadata/{id}.json, {id}_chunks.json, flashes, redirects to index

Key details:

  • app.secret_key is hardcoded at app/app.py#L15 ("youtube_transcript_analyzer_secret_key"), and GEMINI_API_KEY is hardcoded at L24 ('YOUR_GEMINI_API_KEY') — not loaded via python-dotenv despite it being in requirements.txt. You must edit the file or add load_dotenv() before the genai.configure call. The README.md instructs GEMINI_API_KEY=... in .env, but the current code ignores .env — a verified mismatch.
  • get_processed_videos() at L130-L139 scans data/metadata/ for *.json excluding *_chunks.json — exactly how the home page builds its grid.
  • All flash() categories ('success', 'danger') map to Bootstrap alert-{{ category }} in base.html#L100.

Deep Dive 7: Frontend — Templates & Interactions

base.html — app/templates/base.html

  • Loads Bootstrap 5.3.2 CSS/JS and Font Awesome 6.5.1 at L8-L9 + L112-L113 via CDN.
  • Inline <style> at L10-L69 defines card hover lift (transform: translateY(-5px) L21), .video-thumbnail .play-icon overlay at L39-L51, .time-marker blue bold, and .answer-box left-bordered at L62-L68.
  • Navbar with Home / Add Video / Search links at L82-L91 and flash rendering at L97-L103.

index.html — app/templates/index.html

  • Jumbotron with two CTAs (Add New Video, Search Videos) at L11-L16.
  • Video cards in a col-md-4 grid: thumbnail https://img.youtube.com/vi/{{ video.video_id }}/maxresdefault.jpg at L27, play-icon overlay, Processed + Chunks counts, Details → /video/<id> and Watch → YouTube at L38-L43. Empty state No videos... Add your first video at L50-L53.

add_video.html — app/templates/add_video.html

  • POST form with video_url input + input-group-text YouTube icon at L13-L26.
  • How It Works 6-step ordered list at L35-L42 mirrors the RAG pipeline (extract → split → embed → index → search → answer).

video_detail.html — app/templates/video_detail.html (241 lines, the most complex)

  • Left column: Video card + Watch on YouTube / Search This Video (opens #searchModal) / Delete (opens #deleteModal with POST to /api/delete_video/<id> at L128-L130) at L18-L26. Hidden #answerBox + #selectedChunks placeholders at L30-L40. Accordion of all chunks at L43-L66 with Chunk {{ chunk_id+1 }} + optional MM:SS formatted via {{ '%02d:%02d' | format((start_time//60)|int, (start_time%60)|int) }} at L53.
  • Right sticky card: Ask About This Video with #questionInput + #askButton + #searchSpinner at L69-L88.
  • jQuery AJAX at L138-L240: #askButton click → POST /api/search/<id> with {query, top_k:3} → build chunksHtml cards with chunk_id+1, timeFormatted (Math.floor(start_time/60) + ':' + ...), Relevance: (1 - distance/10).toFixed(2) at L178 → $('#selectedChunks').html(chunksHtml) → second AJAX POST /api/generate_response with {query, context: results} → $('#aiResponse').html(data.response) + $('#answerBox').removeClass('d-none'). #searchButton in the modal simply copies #searchQuery into #questionInput and triggers #askButton click at L231-L238.

search.html + search_results.html

  • search.html provides a large form-control-lg POST form at L14-L15 plus tips list at L31-L36 and a video grid.
  • search_results.html regroups results[video_id].results per video at L28-L86, shows Found results in {{ results|length }} video(s) at L12, and inserts a Generate Answer button per video at L71 that AJAX-posts the same query+context to /api/generate_response via the script at L124-L170 (rendered inside a sticky sidebar of all videos at L94-L119).

Deep Dive 8: Notebooks & Pre-Built Artifacts

explanation/first_step.ipynb — Extraction → Index

Cell-by-cell (verified outputs):

  1. video_url = "https://www.youtube.com/watch?v=oRynEUH8KQ0" + YouTubeTranscriptApi.get_transcript(video_id) → single transcript string (the Project Euler video).
  2. RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200, separators=[...]) → Number of chunks: 15 for that transcript — matches the length of the committed youtube_chunks.json (15 strings, first starts "hello everyone and welcome...").
  3. genai.configure(api_key='YOUR_GEMINI_KEY') → get_embedding(chunk) via retrieval_document → prints a 768-dim float array like [-0.0148, -0.0534, ...].
  4. embedding_array = np.array(embeddings).astype("float32") → faiss.IndexFlatL2(dim) → faiss.write_index(index, "youtube_embeddings.index") + json.dump(text_chunks, "youtube_chunks.json") → the two root artifacts.

explanation/second_step.ipynb — Search → Answer

  1. genai.configure(...) + faiss.read_index("youtube_embeddings.index") + json.load("youtube_chunks.json") → Index and text chunks loaded successfully!
  2. search_similar("What is the video about?", top_k=3) embeds the query, index.search(query_embedding, top_k) → prints 3 best chunks with Distance: 0.616... — the same distances the Flask app uses.
  3. context = "\n\n".join(top_similarities) → prompt = "Using the following context...\nAnswer...{user_query}" → genai.GenerativeModel('models/gemini-2.0-flash').generate_content(prompt) — the exact RAG prompt at app/app.py#L185-L191.

The 15-chunk youtube_chunks.json is the transcript of a Project Euler #1 tutorial (multiples of 3 or 5 below 1000) — python -c "import json; print(len(json.load(open('youtube_chunks.json'))))" confirms 15.

End-to-End Flow — Concrete Example

  1. User GET /add_video → add_video.html form.
  2. Submits https://www.youtube.com/watch?v=oRynEUH8KQ0 → POST /add_video at app/app.py#L211-L235:
    • extract_video_id(url) → "oRynEUH8KQ0" via regex at L35
    • get_video_metadata("oRynEUH8KQ0") probes transcript — if None, flash Could not retrieve... and redirect
    • process_video("oRynEUH8KQ0") fetches transcript_snippets, joins, splits into chunks, builds chunks_metadata with start_time, embeds each chunk, creates IndexFlatL2, writes data/indices/oRynEUH8KQ0.index + two JSONs, returns True, metadata → flash Video processed successfully! 15 chunks created. → redirect index
  3. GET / → get_processed_videos() scans data/metadata/*.json at L133-L138 → renders card with maxresdefault.jpg thumbnail + Chunks: 15 at index.html#L27-L36.
  4. GET /video/oRynEUH8KQ0 → accordion of 15 chunks with MM:SS badges at video_detail.html#L52-L54.
  5. Types What is the recursive solution? in Ask About This Video → click Ask → jQuery POST /api/search/oRynEUH8KQ0 at L150-L158 with {query, top_k:3} → Flask search_video() at L141 embeds query, index.search → returns 3 closest chunks (e.g., distance ~0.65) → JS renders #selectedChunks cards with Relevance: (1 - distance/10) at L178 → second AJAX POST /api/generate_response at L192 with {query, context: results} → Flask generate_response() at L179 builds Using the following context... prompt → gemini-2.0-flash → response.text → #aiResponse in .answer-box at L201-L202.
  6. POST /search with Project Euler → loops per video at L264-L266 → search_results.html grouped cards + per-video Generate Answer that re-uses the same two-call AJAX pattern at L126-L168.

Setup & Running (Verified)

From README.md and app/requirements.txt:

git clone https://github.com/azario0/youtube-transcript-analyzer.git
cd youtube-transcript-analyzer/app
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate
pip install -r requirements.txt
# flask==2.3.3, youtube-transcript-api==0.6.1, langchain==0.1.0, google-generativeai==0.3.2, faiss-cpu==1.7.4, numpy==1.24.3

# API key — NOTE mismatch: app.py hardcodes 'YOUR_GEMINI_API_KEY' at L24.
# Either edit app/app.py#24 or export GEMINI_API_KEY and add:
# import os; from dotenv import load_dotenv; load_dotenv(); genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
# Quick fix:
sed -i 's/YOUR_GEMINI_API_KEY/your-real-key/' app.py

python app.py  # runs on http://127.0.0.1:5000 with debug=True at L317
  • os.makedirs('data/indices', exist_ok=True) at L20 auto-creates storage on first run — no manual mkdir -p.
  • Needs Python 3.9+ and a video with captions enabled; otherwise app.py#L128 returns False, str(e) → flash Error processing video: ....

Customization Ideas

  • Batch embeddings: Replace the per-chunk genai.embed_content loop at L98-L104 with genai.embed_content batching or langchain_google_genai batch API to reduce rate-limit hits for long videos.
  • Fix task_type: Change the query embedding at L156 from retrieval_document to retrieval_query/question_answering per Gemini guidance.
  • Chunk tuning: Adjust chunk_size=1000/chunk_overlap=200 at L73-L74 — smaller chunks improve timestamp precision but increase index size; larger chunks give more context to generate_response.
  • Timestamp accuracy: Map each chunk’s character span back to transcript_snippets time ranges instead of the heuristic transcript_snippets[i].start at L86-L87 — yields precise MM:SS seeking.
  • Cache indices: Add @lru_cache or in-memory dict for faiss.read_index in search_video() at L145 to avoid disk reads per query.
  • Load .env properly: Add from dotenv import load_dotenv; load_dotenv(); genai.configure(api_key=os.getenv("GEMINI_API_KEY")) and .gitignore for GEMINI_API_KEY — current hardcoded key at L24 is the top config bug.

Known Limitations

  • No auth / CSRF: app.secret_key is hardcoded at L15 and delete_video is a plain POST without CSRF token at L299.
  • Synchronous processing: process_video() blocks the Flask thread — long videos will timeout the browser. Use Celery/background thread in production.
  • File-system only: No DB — get_processed_videos() scans data/metadata/*.json at L133; scaling to thousands of videos needs pagination.
  • English-centric: YouTubeTranscriptApi.get_transcript(video_id) at L65 uses default language (English); multilingual videos need languages=['en','fr',...].
  • No pagination on chunks: video_detail renders all chunks in an accordion at L43-L66 — heavy DOM for hour-long videos.

Conclusion — Why This Repo Is a Great YouTube RAG Starter

If you want to learn RAG over YouTube, FAISS per-item indices, and Flask + jQuery AJAX without the bloat, this project is a minimal, verified reference:

  • RAG done clearly: youtube-transcript-api → RecursiveCharacterTextSplitter → text-embedding-004 → IndexFlatL2 → generate_content — each step is one function/graphable in the notebooks.
  • Flask done pragmatically: 7 routes, per-video index files, JSON metadata, flash messages — no ORM, yet it handles add/search/delete and cross-video aggregation in search() at L255-L275.
  • UI done accessibly: Bootstrap cards with YouTube thumbnails maxresdefault.jpg at index.html#L27, timestamp MM:SS at video_detail.html#L53, and Relevance badges give a complete watch-search-ask loop.

Clone it, add your GEMINI_API_KEY to app/app.py#L24, run python app/app.py inside app/, and try https://www.youtube.com/watch?v=oRynEUH8KQ0 — then ask “What is the iterative vs recursive solution?” in the video detail page and watch the two-step AJAX bring back Distance: 0.61... chunks and a grounded Gemini answer.


Links: app/app.py · app/requirements.txt · app/templates/base.html · youtube_chunks.json · youtube_embeddings.index · explanation/first_step.ipynb · explanation/second_step.ipynb

Verified against commit main on 2026-09-02. All file paths, line numbers, parameter values, and template IDs checked against the cloned repository.

Share
Portrait of Benmalek Zohir

Written by

Benmalek Zohir

Founder, AI Engineer & Full Stack Developer

Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.

The Software Journal Dispatch

One excellent engineering read, every week.

A concise digest of our best new essays on architecture, tooling, databases, and the craft of software. No spam, no noise — unsubscribe anytime.