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.
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)
- Multi-video transcript ingestion: Add any video with captions via
app/app.py#L208-L237(/add_video). - LangChain chunking:
RecursiveCharacterTextSplitterwithchunk_size=1000/chunk_overlap=200atapp/app.py#L72-L76. - Gemini embeddings:
genai.embed_content(task_type="retrieval_document")atapp/app.py#L99-L103andL152-L157. - FAISS vector search:
IndexFlatL2per video stored indata/indices/{video_id}.indexand queried inapp/app.py#L141-L177. - Timestamp navigation:
start_timeextracted from transcript snippets atapp/app.py#L84-L93and rendered asMM:SSbadges inapp/templates/video_detail.html#L52-L54andapp/templates/search_results.html#L55-L60. - AI answers: Prompt
Using the following context from the video transcript:+generate_contentatapp/app.py#L179-L200, exposed via AJAX at/api/generate_response.
Programming Languages Used
| Language / Markup | Role | Where Verified |
|---|---|---|
| Python 3.9+ | All backend logic: Flask routes, transcript fetching, chunking, embedding, FAISS indexing | app/app.py (317 lines), explanation/first_step.ipynb, explanation/second_step.ipynb |
| HTML (Jinja2) | 6 server-rendered templates extending a Bootstrap base | app/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 |
| CSS | Bootstrap 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 handling | app/templates/video_detail.html#L138-L240, app/templates/search_results.html#L124-L170 |
| JSON + FAISS binary | Persistence for chunks, metadata, and vector indices | youtube_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
| Layer | Technology | Purpose | File Reference |
|---|---|---|---|
| Web Framework | Flask 2.3.3 | Routing, templating, flash messages, app.secret_key | app/app.py#L3, app/requirements.txt#L1 |
| Transcript Fetch | youtube-transcript-api 0.6.1 | YouTubeTranscriptApi.get_transcript(video_id) | app/app.py#L4, app/app.py#L51 |
| Chunking | LangChain 0.1.0 RecursiveCharacterTextSplitter | chunk_size=1000, chunk_overlap=200, separators ["\n\n","\n"," ",""] | app/app.py#L6, requirements.txt#L3 |
| Embeddings | google-generativeai 0.3.2 models/text-embedding-004 | task_type="retrieval_document" for documents and queries | app/app.py#L28, app/app.py#L100-L102 |
| LLM | Google Gemini models/gemini-2.0-flash | Grounded answer generation | app/app.py#L29, app/app.py#L194 |
| Vector DB | FAISS-CPU 1.7.4 (IndexFlatL2) | Exact L2 nearest-neighbor search per video | app/app.py#L8, requirements.txt#L5 |
| Frontend | Bootstrap 5.3.2 + Font Awesome 6.5.1 + jQuery 3.7.1 | Responsive cards, navbar, icons, AJAX | app/templates/base.html#L8-L9, base.html#L113 |
| Utils | numpy 1.24.3, python-dotenv 1.0.0 | Embedding 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=, shortenedyoutu.be/, embedded/embed/, and legacy/v/— all requiring the canonical 11-char ID charset[a-zA-Z0-9_-]. - Caller:
/add_videoPOST at L215 validates withif not video_id: flash('Invalid YouTube URL...')atL217-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
NoneatL59— the/add_videoroute then flashesCould not retrieve video informationatL223-L225. The initialchunks_count: 0is overwritten afterprocess_videosucceeds atL120.
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_snippetsis a list like[{"text":"hello everyone", "start":0.5, "duration":3.2}, ...]— verified inyoutube_chunks.jsonwhere 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=1000chars ≈ ~250 tokens,chunk_overlap=200preserves cross-boundary context. Separators prefer paragraph → line → space → char fallback — the same values shown inexplanation/first_step.ipynb(Cell 2) which producedNumber of chunks: 15for the Project Euler video. The committedyoutube_chunks.jsonis 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_timeusestranscript_snippets[i].start, not a precise forced-alignment. It assumes chunkiroughly aligns with snippeti— a known simplification. Wheni >= len(transcript_snippets),start_timestaysNoneand the template hides the badge via{% if chunk.start_time %}atvideo_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-004atL28— the samemodel = "models/text-embedding-004"used inexplanation/first_step.ipynbCell 3.task_type="retrieval_document"(vsretrieval_queryfor searches) applies the correct Gemini task prefix. - One-by-one: No batching — each chunk triggers a separate
genai.embed_contentcall. 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)
IndexFlatL2is an exact brute-force index — no quantization, no IVF training — ideal for per-video indices with dozens to hundreds of vectors (the exampleyoutube_embeddings.indexis only 46 KB). Dimensionembedding_array.shape[1]is the Gemini embedding width (768 fortext-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(withchunks_countupdated atL119-L123). The notebooks instead writeyoutube_embeddings.index+youtube_chunks.jsonat the repo root — those are the demo artifacts checked into git.
Deep Dive 4: Semantic Search
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 atL156(strictly,"retrieval_query"would be more precise per Gemini docs, but the app consistently usesretrieval_document— verified in both search and indexing). - Distance → Relevance: Templates display
Relevance: {(1 - distance/10).toFixed(2)}invideo_detail.html#L178and{(1 - distance/10)|round(2)}insearch_results.html#L62— a heuristic mapping L2 distance0-10to1.0-0.0, not a normalized cosine. - Error fallback: Returns
[]atL177on any exception (missing index, bad key) — the UI showsNo relevant transcript chunks foundatvideo_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 inexplanation/second_step.ipynbCell 3 uses the identical template andgemini-2.0-flash. - No history: Each call is stateless — no chat memory, no follow-up. The
contextlist is the top-k chunks fromsearch_video(), joined with double newlines atL182. - Exposed via API:
POST /api/generate_responseatapp/app.py#L288-L297expects{"query": "...", "context": [...]}and returns{"response": "..."}as JSON — the video detail JS calls it after the search succeeds atvideo_detail.html#L191-L209.
Deep Dive 6: Flask Routes & API Surface
All 7 routes in app/app.py#L202-L317 — verified line-by-line:
| Route | Method | Handler | Purpose |
|---|---|---|---|
/ | GET | index() L202-L206 | Lists all processed videos via get_processed_videos() → templates/index.html |
/add_video | GET/POST | add_video() L208-L237 | GET shows add_video.html; POST validates URL, checks transcript, calls process_video(), flashes success/failure, redirects |
/video/<video_id> | GET | video_detail() L239-L253 | Loads {video_id}.json + {video_id}_chunks.json → video_detail.html accordion + ask box |
/search | GET/POST | search() L255-L275 | GET 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-L286 | AJAX per-video search used by video_detail.html JS at L151 ({query, top_k} → [{text, start_time, distance, chunk_id}]) |
/api/generate_response | POST (JSON) | api_generate_response() L288-L297 | {query, context} → {response} from Gemini |
/api/delete_video/<video_id> | POST | delete_video() L299-L314 | os.remove on data/indices/{id}.index, data/metadata/{id}.json, {id}_chunks.json, flashes, redirects to index |
Key details:
app.secret_keyis hardcoded atapp/app.py#L15("youtube_transcript_analyzer_secret_key"), andGEMINI_API_KEYis hardcoded atL24('YOUR_GEMINI_API_KEY') — not loaded viapython-dotenvdespite it being inrequirements.txt. You must edit the file or addload_dotenv()before thegenai.configurecall. TheREADME.mdinstructsGEMINI_API_KEY=...in.env, but the current code ignores.env— a verified mismatch.get_processed_videos()atL130-L139scansdata/metadata/for*.jsonexcluding*_chunks.json— exactly how the home page builds its grid.- All
flash()categories ('success','danger') map to Bootstrapalert-{{ category }}inbase.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-L113via CDN. - Inline
<style>atL10-L69defines card hover lift (transform: translateY(-5px)L21),.video-thumbnail .play-iconoverlay atL39-L51,.time-markerblue bold, and.answer-boxleft-bordered atL62-L68. - Navbar with
Home/Add Video/Searchlinks atL82-L91and flash rendering atL97-L103.
index.html — app/templates/index.html
- Jumbotron with two CTAs (
Add New Video,Search Videos) atL11-L16. - Video cards in a
col-md-4grid: thumbnailhttps://img.youtube.com/vi/{{ video.video_id }}/maxresdefault.jpgatL27,play-iconoverlay,Processed+Chunkscounts,Details→/video/<id>andWatch→ YouTube atL38-L43. Empty stateNo videos... Add your first videoatL50-L53.
add_video.html — app/templates/add_video.html
POSTform withvideo_urlinput +input-group-textYouTube icon atL13-L26.- How It Works 6-step ordered list at
L35-L42mirrors 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#deleteModalwithPOSTto/api/delete_video/<id>atL128-L130) atL18-L26. Hidden#answerBox+#selectedChunksplaceholders atL30-L40. Accordion of all chunks atL43-L66withChunk {{ chunk_id+1 }}+ optionalMM:SSformatted via{{ '%02d:%02d' | format((start_time//60)|int, (start_time%60)|int) }}atL53. - Right sticky card:
Ask About This Videowith#questionInput+#askButton+#searchSpinneratL69-L88. - jQuery AJAX at
L138-L240:#askButtonclick →POST /api/search/<id>with{query, top_k:3}→ buildchunksHtmlcards withchunk_id+1,timeFormatted(Math.floor(start_time/60) + ':' + ...),Relevance: (1 - distance/10).toFixed(2)atL178→$('#selectedChunks').html(chunksHtml)→ second AJAXPOST /api/generate_responsewith{query, context: results}→$('#aiResponse').html(data.response)+$('#answerBox').removeClass('d-none').#searchButtonin the modal simply copies#searchQueryinto#questionInputand triggers#askButtonclick atL231-L238.
search.html + search_results.html
search.htmlprovides a largeform-control-lgPOST form atL14-L15plus tips list atL31-L36and a video grid.search_results.htmlregroupsresults[video_id].resultsper video atL28-L86, showsFound results in {{ results|length }} video(s)atL12, and inserts a Generate Answer button per video atL71that AJAX-posts the same query+context to/api/generate_responsevia the script atL124-L170(rendered inside a sticky sidebar of all videos atL94-L119).
Deep Dive 8: Notebooks & Pre-Built Artifacts
explanation/first_step.ipynb — Extraction → Index
Cell-by-cell (verified outputs):
video_url = "https://www.youtube.com/watch?v=oRynEUH8KQ0"+YouTubeTranscriptApi.get_transcript(video_id)→ singletranscriptstring (the Project Euler video).RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200, separators=[...])→Number of chunks: 15for that transcript — matches the length of the committedyoutube_chunks.json(15 strings, first starts"hello everyone and welcome...").genai.configure(api_key='YOUR_GEMINI_KEY')→get_embedding(chunk)viaretrieval_document→ prints a 768-dim float array like[-0.0148, -0.0534, ...].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
genai.configure(...)+faiss.read_index("youtube_embeddings.index")+json.load("youtube_chunks.json")→Index and text chunks loaded successfully!search_similar("What is the video about?", top_k=3)embeds the query,index.search(query_embedding, top_k)→ prints 3 best chunks withDistance: 0.616...— the same distances the Flask app uses.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 atapp/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
- User
GET /add_video→add_video.htmlform. - Submits
https://www.youtube.com/watch?v=oRynEUH8KQ0→POST /add_videoatapp/app.py#L211-L235:extract_video_id(url)→"oRynEUH8KQ0"via regex atL35get_video_metadata("oRynEUH8KQ0")probes transcript — ifNone, flashCould not retrieve...and redirectprocess_video("oRynEUH8KQ0")fetchestranscript_snippets, joins, splits into chunks, buildschunks_metadatawithstart_time, embeds each chunk, createsIndexFlatL2, writesdata/indices/oRynEUH8KQ0.index+ two JSONs, returnsTrue, metadata→ flashVideo processed successfully! 15 chunks created.→ redirectindex
GET /→get_processed_videos()scansdata/metadata/*.jsonatL133-L138→ renders card withmaxresdefault.jpgthumbnail +Chunks: 15atindex.html#L27-L36.GET /video/oRynEUH8KQ0→ accordion of 15 chunks withMM:SSbadges atvideo_detail.html#L52-L54.- Types
What is the recursive solution?inAsk About This Video→ clickAsk→ jQueryPOST /api/search/oRynEUH8KQ0atL150-L158with{query, top_k:3}→ Flasksearch_video()at L141 embeds query,index.search→ returns 3 closest chunks (e.g.,distance ~0.65) → JS renders#selectedChunkscards withRelevance: (1 - distance/10)atL178→ second AJAXPOST /api/generate_responseatL192with{query, context: results}→ Flaskgenerate_response()at L179 buildsUsing the following context...prompt →gemini-2.0-flash→response.text→#aiResponsein.answer-boxatL201-L202. POST /searchwithProject Euler→ loops per video atL264-L266→search_results.htmlgrouped cards + per-videoGenerate Answerthat re-uses the same two-call AJAX pattern atL126-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)atL20auto-creates storage on first run — no manualmkdir -p.- Needs Python 3.9+ and a video with captions enabled; otherwise
app.py#L128returnsFalse, str(e)→ flashError processing video: ....
Customization Ideas
- Batch embeddings: Replace the per-chunk
genai.embed_contentloop atL98-L104withgenai.embed_contentbatching orlangchain_google_genaibatch API to reduce rate-limit hits for long videos. - Fix
task_type: Change the query embedding atL156fromretrieval_documenttoretrieval_query/question_answeringper Gemini guidance. - Chunk tuning: Adjust
chunk_size=1000/chunk_overlap=200atL73-L74— smaller chunks improve timestamp precision but increase index size; larger chunks give more context togenerate_response. - Timestamp accuracy: Map each chunk’s character span back to
transcript_snippetstime ranges instead of the heuristictranscript_snippets[i].startatL86-L87— yields preciseMM:SSseeking. - Cache indices: Add
@lru_cacheor in-memory dict forfaiss.read_indexinsearch_video()at L145 to avoid disk reads per query. - Load
.envproperly: Addfrom dotenv import load_dotenv; load_dotenv(); genai.configure(api_key=os.getenv("GEMINI_API_KEY"))and.gitignoreforGEMINI_API_KEY— current hardcoded key atL24is the top config bug.
Known Limitations
- No auth / CSRF:
app.secret_keyis hardcoded atL15anddelete_videois a plainPOSTwithout CSRF token atL299. - 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()scansdata/metadata/*.jsonatL133; scaling to thousands of videos needs pagination. - English-centric:
YouTubeTranscriptApi.get_transcript(video_id)atL65uses default language (English); multilingual videos needlanguages=['en','fr',...]. - No pagination on chunks:
video_detailrenders all chunks in an accordion atL43-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()atL255-L275. - UI done accessibly: Bootstrap cards with YouTube thumbnails
maxresdefault.jpgatindex.html#L27, timestampMM:SSatvideo_detail.html#L53, andRelevancebadges 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.
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.