Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

RAG with Video: Visual Question-Answering over Video Frames

University of Kansas School of Business

This notebook demonstrates Retrieval-Augmented Generation (RAG) where the knowledge source is a video. Instead of searching documents or databases, we extract frames from a video, index them with CLIP embeddings, and retrieve the most relevant frames to answer natural-language questions.

How it works:

  1. Generate a short synthetic video (animated product showcase — no external data needed).

  2. Extract keyframes at regular intervals.

  3. Compute CLIP embeddings for each frame to create a searchable index.

  4. Given a natural-language question, retrieve the most relevant frame(s) via cosine similarity.

  5. Send retrieved frames to a multimodal LLM for a grounded, natural-language answer.

Learning goals:

  • Understand how RAG applies to temporal/video data

  • See how frame extraction converts video into an image retrieval problem

  • Practice grounding LLM output on visual evidence from specific moments in a video

  • Compare answers with and without video context

Store whichever API keys you have in Colab Secrets (or a local .env file): GEMINI_API_KEY, OLLAMA_API_KEY, XAI_API_KEY, GROQ_API_KEY, HF_TOKEN, COHERE_API_KEY, OPENROUTER_API_KEY

This notebook builds directly on the sibling image_rag.ipynb — once you convert a video into a set of keyframes, Video RAG reduces to Image RAG with a timestamp attached to every hit.

!pip install -q google-genai Pillow==11.0.0 transformers==4.46.3 matplotlib opencv-python-headless git+https://github.com/KarAnalytics/llm_cascade.git

1) Imports and Provider Helpers (7-Vendor Cascade)

We use OpenCV to create and read video, CLIP to embed frames and text queries into the same vector space, and PIL/Pillow for image display. If you have already worked through the Image RAG notebook, you will recognize most of these imports -- and that is the point. Video RAG is fundamentally the same pattern as Image RAG, applied to a different modality. The main new ingredient is OpenCV, which handles the mechanics of extracting individual frames from a video file.

# =============================================================================
# Imports + LLM Cascade
# =============================================================================

import math
import numpy as np
from pathlib import Path

import cv2
from PIL import Image, ImageDraw, ImageFont

from llm_cascade import get_cascade

llm = get_cascade()


def has_llm_provider():
    """True if llm_cascade has at least one provider configured."""
    return True  # get_cascade() already validates


def generate_text(prompt, system_prompt=None):
    """Send a text prompt through llm_cascade.

    Returns (response_text, provider_string).
    """
    resp = llm.generate(prompt, system_prompt=system_prompt)
    return resp.text, f"{resp.provider}/{resp.model}"


print("LLM cascade ready.")
LLM Cascade - available providers:
  + Gemini           model=gemini-2.5-flash
  + Ollama           model=kimi-k2.5:cloud
  + Groq             model=llama-3.3-70b-versatile
  + HuggingFace      model=meta-llama/Llama-3.3-70B-Instruct
  + Cohere           model=command-a-03-2025
  + OpenRouter       model=meta-llama/llama-3.3-70b-instruct:free
  + OpenAI           model=gpt-4o-mini
Not configured (skipped):
  - Grok (xAI)       (set XAI_API_KEY)
LLM cascade ready.

2) Generate a Synthetic Video

We create a short animated video that simulates a product showcase reel. The video has distinct scenes, each showing a different product with visual attributes that change over time.

This keeps the demo self-contained (no external video files needed) while providing enough visual variety to test retrieval.

Time (sec)SceneContent
0–3Title card“Acme Corp Product Showcase” on blue background
3–7Product 1Red circle — “Widget Alpha” with price
7–11Product 2Green triangle — “Gadget Beta” with price
11–15Product 3Blue square — “Tool Gamma” with price
15–19Product 4Orange diamond — “Device Delta” with price
19–22Closing card“Thank you! Visit acme-corp.example.com
VIDEO_PATH = "product_showcase.mp4"
FPS = 10
WIDTH, HEIGHT = 640, 480

# Scene definitions
SCENES = [
    {
        "name": "title",
        "start": 0, "end": 3,
        "label": "Acme Corp\nProduct Showcase 2026",
        "bg_color": (40, 60, 120),
        "shape": None,
        "product": None,
    },
    {
        "name": "widget_alpha",
        "start": 3, "end": 7,
        "label": "Widget Alpha — $12.99",
        "bg_color": (255, 245, 238),
        "shape": "circle",
        "shape_color": (231, 76, 60),
        "product": {"name": "Widget Alpha", "price": 12.99, "color": "red", "shape": "circle"},
    },
    {
        "name": "gadget_beta",
        "start": 7, "end": 11,
        "label": "Gadget Beta — $8.75",
        "bg_color": (240, 255, 240),
        "shape": "triangle",
        "shape_color": (46, 204, 113),
        "product": {"name": "Gadget Beta", "price": 8.75, "color": "green", "shape": "triangle"},
    },
    {
        "name": "tool_gamma",
        "start": 11, "end": 15,
        "label": "Tool Gamma — $34.99",
        "bg_color": (235, 245, 255),
        "shape": "square",
        "shape_color": (52, 152, 219),
        "product": {"name": "Tool Gamma", "price": 34.99, "color": "blue", "shape": "square"},
    },
    {
        "name": "device_delta",
        "start": 15, "end": 19,
        "label": "Device Delta — $19.25",
        "bg_color": (255, 248, 230),
        "shape": "diamond",
        "shape_color": (230, 126, 34),
        "product": {"name": "Device Delta", "price": 19.25, "color": "orange", "shape": "diamond"},
    },
    {
        "name": "closing",
        "start": 19, "end": 22,
        "label": "Thank you!\nVisit acme-corp.example.com",
        "bg_color": (40, 60, 120),
        "shape": None,
        "product": None,
    },
]


def draw_frame_pil(scene, t, width=WIDTH, height=HEIGHT):
    """Draw a single video frame for a given scene and time offset using PIL."""
    bg = scene["bg_color"]
    img = Image.new("RGB", (width, height), bg)
    draw = ImageDraw.Draw(img)

    # Load font
    try:
        font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 28)
        font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 22)
    except (OSError, IOError):
        try:
            font_large = ImageFont.truetype("arial.ttf", 28)
            font_small = ImageFont.truetype("arial.ttf", 22)
        except (OSError, IOError):
            font_large = ImageFont.load_default()
            font_small = font_large

    cx, cy = width // 2, height // 2

    if scene["shape"] is None:
        # Title or closing card — centered text
        text_color = (255, 255, 255)
        lines = scene["label"].split("\n")
        total_h = len(lines) * 40
        y_start = cy - total_h // 2
        for j, line in enumerate(lines):
            bbox = draw.textbbox((0, 0), line, font=font_large)
            tw = bbox[2] - bbox[0]
            draw.text(((width - tw) // 2, y_start + j * 40), line, fill=text_color, font=font_large)
    else:
        # Product scene — animated shape (gentle bounce)
        bounce = int(15 * math.sin(t * 3))
        shape_cy = cy - 30 + bounce
        r = 80
        color = scene["shape_color"]

        if scene["shape"] == "circle":
            draw.ellipse([cx - r, shape_cy - r, cx + r, shape_cy + r], fill=color, outline=(0, 0, 0), width=3)
        elif scene["shape"] == "square":
            draw.rectangle([cx - r, shape_cy - r, cx + r, shape_cy + r], fill=color, outline=(0, 0, 0), width=3)
        elif scene["shape"] == "triangle":
            pts = [(cx, shape_cy - r), (cx - r, shape_cy + r), (cx + r, shape_cy + r)]
            draw.polygon(pts, fill=color, outline=(0, 0, 0), width=3)
        elif scene["shape"] == "diamond":
            pts = [(cx, shape_cy - r), (cx + r, shape_cy), (cx, shape_cy + r), (cx - r, shape_cy)]
            draw.polygon(pts, fill=color, outline=(0, 0, 0), width=3)

        # Product label at top
        bbox = draw.textbbox((0, 0), scene["label"], font=font_small)
        tw = bbox[2] - bbox[0]
        draw.text(((width - tw) // 2, 20), scene["label"], fill=(30, 30, 30), font=font_small)

        # Timestamp at bottom-right
        ts_text = f"t={t:.1f}s"
        draw.text((width - 90, height - 30), ts_text, fill=(120, 120, 120), font=font_small)

    return img


def generate_video(path, scenes, fps, width=WIDTH, height=HEIGHT):
    """Generate an MP4 video from scene definitions."""
    fourcc = cv2.VideoWriter_fourcc(*"mp4v")
    writer = cv2.VideoWriter(path, fourcc, fps, (width, height))

    for scene in scenes:
        duration = scene["end"] - scene["start"]
        n_frames = int(duration * fps)
        for i in range(n_frames):
            t = scene["start"] + i / fps
            pil_img = draw_frame_pil(scene, t, width, height)
            # Convert PIL (RGB) -> OpenCV (BGR)
            frame = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
            writer.write(frame)

    writer.release()


generate_video(VIDEO_PATH, SCENES, FPS)
total_duration = SCENES[-1]["end"]
file_size_kb = Path(VIDEO_PATH).stat().st_size / 1024
print(f"Video generated: '{VIDEO_PATH}'")
print(f"  Duration: {total_duration}s | FPS: {FPS} | Resolution: {WIDTH}x{HEIGHT} | Size: {file_size_kb:.0f} KB")
Video generated: 'product_showcase.mp4'
  Duration: 22s | FPS: 10 | Resolution: 640x480 | Size: 260 KB

3) Extract Keyframes from the Video

Here is the crucial insight behind Video RAG: we do not try to process the video as a continuous stream. Instead, we sample keyframes at regular intervals and treat each one as an independent image. This converts the temporal video retrieval problem into the familiar image retrieval problem we already solved in the Image RAG notebook.

Why not process the full video? A 22-second video at 10 FPS contains 220 frames, most of which are nearly identical to their neighbors. Processing every frame would waste compute and storage without improving retrieval quality. By extracting one frame per second, we capture each scene while keeping our index compact. For longer or faster-paced videos you would adjust this sampling rate -- the tradeoff is always between coverage (catching brief but important moments) and efficiency (keeping the index manageable).

def extract_keyframes(video_path, interval_sec=1.0):
    """Extract frames from a video at a fixed time interval.

    Returns a list of dicts: {"timestamp": float, "image": PIL.Image}
    """
    cap = cv2.VideoCapture(video_path)
    fps = cap.get(cv2.CAP_PROP_FPS)
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    duration = total_frames / fps
    frame_interval = int(fps * interval_sec)

    frames = []
    frame_idx = 0
    while True:
        cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
        ret, frame = cap.read()
        if not ret:
            break
        # Convert BGR -> RGB -> PIL
        rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        pil_img = Image.fromarray(rgb)
        timestamp = frame_idx / fps
        frames.append({"timestamp": timestamp, "image": pil_img})
        frame_idx += frame_interval

    cap.release()
    return frames, duration


keyframes, video_duration = extract_keyframes(VIDEO_PATH, interval_sec=1.0)
print(f"Extracted {len(keyframes)} keyframes from {video_duration:.1f}s video (1 frame/sec)")
print(f"Timestamps: {[f'{kf["timestamp"]:.1f}s' for kf in keyframes]}")
Extracted 22 keyframes from 22.0s video (1 frame/sec)
Timestamps: ['0.0s', '1.0s', '2.0s', '3.0s', '4.0s', '5.0s', '6.0s', '7.0s', '8.0s', '9.0s', '10.0s', '11.0s', '12.0s', '13.0s', '14.0s', '15.0s', '16.0s', '17.0s', '18.0s', '19.0s', '20.0s', '21.0s']

Preview Keyframes

The grid below shows every other keyframe from our 22-second video. You can see the distinct scenes: a title card, four product showcases (each with a different colored shape), and a closing card. Each of these frames will become an entry in our CLIP index, tagged with its timestamp so we can tell the user not just what was found but when in the video it appeared.

import matplotlib.pyplot as plt

# Show a subset of keyframes (every other frame to save space)
display_frames = keyframes[::2]
n = len(display_frames)
cols = min(n, 6)
rows = math.ceil(n / cols)

fig, axes = plt.subplots(rows, cols, figsize=(4 * cols, 3.5 * rows))
axes_flat = axes.flat if hasattr(axes, 'flat') else [axes]
for i, ax in enumerate(axes_flat):
    if i < n:
        ax.imshow(display_frames[i]["image"])
        ax.set_title(f"t = {display_frames[i]['timestamp']:.1f}s", fontsize=10)
    ax.axis("off")
fig.suptitle("Keyframes from Product Showcase Video (every 2s)", fontsize=13, fontweight="bold")
plt.tight_layout()
plt.show()
<Figure size 2400x700 with 12 Axes>

4) Build the Frame Index with CLIP Embeddings

Just like in Image RAG, we use CLIP to embed each keyframe into a 512-dimensional vector. At query time we embed the user’s text query with the same model and find the closest frame(s) by cosine similarity. The embedding and retrieval code is nearly identical to what we wrote for static images -- because once you extract keyframes, a video is just a collection of images.

The key difference from Image RAG is that each frame also carries a timestamp. This temporal metadata transforms a simple “which image matches?” answer into a richer “at what point in the video can you find this?” answer. Timestamps are what make Video RAG genuinely useful for applications like meeting recording search, surveillance review, or training video navigation.

from transformers import CLIPProcessor, CLIPModel
import torch

CLIP_MODEL_NAME = "openai/clip-vit-base-patch32"
clip_model = CLIPModel.from_pretrained(CLIP_MODEL_NAME)
clip_processor = CLIPProcessor.from_pretrained(CLIP_MODEL_NAME)

print(f"CLIP model loaded: {CLIP_MODEL_NAME}")
print(f"Embedding dimension: {clip_model.config.projection_dim}")
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
CLIP model loaded: openai/clip-vit-base-patch32
Embedding dimension: 512
def embed_frames(keyframes, model, processor):
    """Compute CLIP embeddings for a list of keyframe images."""
    images = [kf["image"].convert("RGB") for kf in keyframes]
    inputs = processor(images=images, return_tensors="pt", padding=True)
    with torch.no_grad():
        embeddings = model.get_image_features(**inputs)
    embeddings = embeddings / embeddings.norm(dim=-1, keepdim=True)
    return embeddings.numpy()


def embed_text(query, model, processor):
    """Compute CLIP embedding for a text query."""
    inputs = processor(text=[query], return_tensors="pt", padding=True, truncation=True)
    with torch.no_grad():
        embeddings = model.get_text_features(**inputs)
    embeddings = embeddings / embeddings.norm(dim=-1, keepdim=True)
    return embeddings.numpy()


# Build the frame index
frame_embeddings = embed_frames(keyframes, clip_model, clip_processor)
print(f"Frame index built: {frame_embeddings.shape[0]} frames, {frame_embeddings.shape[1]}-dim embeddings")
Frame index built: 22 frames, 512-dim embeddings

Our frame index is now ready: 22 keyframes, each represented as a 512-dimensional CLIP vector. Notice how the code above is almost identical to the image embedding code from the Image RAG notebook -- the only difference is that our “images” come from video frames rather than from files on disk. This reuse of the same CLIP infrastructure across modalities is a powerful design pattern: once you can convert any data source into images, you get cross-modal search for free.

5) Frame Retrieval — the “Retrieval” in Video RAG

Given a text query, we:

  1. Embed the query with CLIP’s text encoder.

  2. Compute cosine similarity against all frame embeddings.

  3. Return the top-k most similar frames with their timestamps.

The timestamp tells us when in the video the answer can be found.

def get_scene_for_timestamp(timestamp, scenes):
    """Look up which scene a timestamp belongs to."""
    for scene in scenes:
        if scene["start"] <= timestamp < scene["end"]:
            return scene
    return scenes[-1]  # default to last scene


def retrieve_frames(query, frame_embeddings, keyframes, model, processor, top_k=3):
    """Retrieve the top-k most relevant video frames for a text query."""
    query_emb = embed_text(query, model, processor)
    similarities = (query_emb @ frame_embeddings.T).flatten()
    top_indices = similarities.argsort()[::-1][:top_k]

    results = []
    for idx in top_indices:
        ts = keyframes[idx]["timestamp"]
        scene = get_scene_for_timestamp(ts, SCENES)
        results.append({
            "rank": len(results) + 1,
            "frame_index": int(idx),
            "timestamp": ts,
            "scene_name": scene["name"],
            "product": scene.get("product"),
            "image": keyframes[idx]["image"],
            "similarity": float(similarities[idx]),
        })
    return results


def show_retrieved_frames(query, results):
    """Display retrieved frames side by side."""
    n = len(results)
    fig, axes = plt.subplots(1, n, figsize=(6 * n, 5))
    if n == 1:
        axes = [axes]
    for ax, r in zip(axes, results):
        ax.imshow(r["image"])
        label = f"#{r['rank']} t={r['timestamp']:.1f}s\n{r['scene_name']}\nsim={r['similarity']:.3f}"
        ax.set_title(label, fontsize=10)
        ax.axis("off")
    fig.suptitle(f'Query: "{query}"', fontsize=13, fontweight="bold")
    plt.tight_layout()
    plt.show()


# Test retrieval
test_query = "red circle product"
results = retrieve_frames(test_query, frame_embeddings, keyframes, clip_model, clip_processor)

print(f"Query: '{test_query}'\n")
for r in results:
    prod = r['product']
    prod_str = f"{prod['name']} ({prod['color']} {prod['shape']})" if prod else "(no product)"
    print(f"  #{r['rank']} t={r['timestamp']:.1f}s | {r['scene_name']} | {prod_str} | sim={r['similarity']:.4f}")

show_retrieved_frames(test_query, results)
Query: 'red circle product'

  #1 t=4.0s | widget_alpha | Widget Alpha (red circle) | sim=0.2704
  #2 t=6.0s | widget_alpha | Widget Alpha (red circle) | sim=0.2670
  #3 t=5.0s | widget_alpha | Widget Alpha (red circle) | sim=0.2658
<Figure size 1800x500 with 3 Axes>

6) Answer Generation — Multimodal LLM with Retrieved Frames

The multimodal LLM receives:

  • The retrieved frames (as images)

  • Metadata about each frame (timestamp, scene, product info)

  • The user’s question

It generates an answer grounded on what it can see in the frames and the associated timestamps.

We pass the frames through llm_cascade’s generate_multimodal() method, which handles the serialization differences between providers (Gemini uses Part.from_bytes; OpenAI-compatible providers use base64 data URIs) and automatically falls back if the first provider is rate-limited or overloaded. A “without RAG” variant below answers with no video context at all, so we can compare.

The cascade here sends frames, not the whole video file — that is the path that works across all four vision-capable providers. See the Optional section at the end of the notebook for a direct Gemini API call that takes the entire MP4 file as a single input. Gemini is the only provider in the cascade that accepts video bytes natively, which is why generate_multimodal() is frame-based for parity.

def build_frame_context(results):
    """Build a text description of retrieved frames."""
    lines = ["Retrieved video frames (ranked by visual similarity):"]
    for r in results:
        prod = r["product"]
        prod_str = (
            f"{prod['name']}: {prod['color']} {prod['shape']}, ${prod['price']:.2f}"
            if prod else "(title/closing card)"
        )
        lines.append(
            f"  #{r['rank']} timestamp={r['timestamp']:.1f}s, scene={r['scene_name']}, "
            f"product={prod_str}, similarity={r['similarity']:.3f}"
        )
    return "\n".join(lines)


VIDEO_RAG_SYSTEM_PROMPT = (
    "You are a helpful video analyst. You are given keyframes extracted from a product "
    "showcase video, along with their timestamps and metadata. Answer the user's question "
    "based on what you can SEE in the frames and the provided metadata. Reference specific "
    "timestamps when relevant (e.g., 'at t=5.0s'). If the frames do not contain enough "
    "information, say so. Do not invent products or details not shown."
)


def generate_answer_with_frames(question, results):
    """Send retrieved frames + metadata to a multimodal LLM via llm_cascade."""
    context_text = build_frame_context(results)
    prompt = f"{context_text}\n\nQUESTION: {question}"
    # Pass PIL frames directly; the cascade handles per-provider encoding.
    frames = [r["image"] for r in results]
    resp = llm.generate_multimodal(
        prompt, images=frames, system_prompt=VIDEO_RAG_SYSTEM_PROMPT)
    return resp.text, f"{resp.provider}/{resp.model}"


def generate_answer_without_rag(question):
    """Direct LLM answer with NO video frames."""
    return generate_text(question)


print("Answer generation functions ready (llm_cascade).")
Answer generation functions ready (llm_cascade).

7) Full Video RAG Pipeline

Putting it all together into a single callable function. The pipeline mirrors what we built for Image RAG -- embed the query, retrieve relevant frames, generate an answer -- but now each result includes a timestamp that grounds the answer in the video’s timeline. This is the complete Video RAG loop: from a natural-language question to a temporally grounded, visually informed answer.

def answer_with_video_rag(question, top_k=3):
    """Full Video RAG: extract frames -> CLIP retrieval -> multimodal LLM answer."""
    print(f"  [Step 1] Embedding query with CLIP ...")
    results = retrieve_frames(
        question, frame_embeddings, keyframes, clip_model, clip_processor, top_k=top_k,
    )
    print(f"  [Step 2] Retrieved {len(results)} frame(s):")
    for r in results:
        prod_name = r['product']['name'] if r['product'] else '(card)'
        print(f"           #{r['rank']} t={r['timestamp']:.1f}s — {prod_name} (sim={r['similarity']:.3f})")

    print(f"  [Step 3] Generating multimodal answer ...")
    answer, provider = generate_answer_with_frames(question, results)
    print(f"  [Step 4] Answer generated (provider: {provider})")
    return answer, results, provider


print("Video RAG pipeline ready.")
Video RAG pipeline ready.

8) Run End-to-End Examples: With RAG vs. Without RAG

We test questions about the product showcase video and compare:

  • With Video RAG: CLIP retrieves the relevant frame(s), multimodal LLM answers with visual + temporal grounding

  • Without RAG: LLM answers with no video context — it knows nothing about our product showcase

Watch how the without-RAG answers either refuse to answer (no context) or hallucinate product details.

questions = [
    "What is the red product shown in the video and how much does it cost?",
    #"At what point in the video does the green triangle appear?",
    #"What is the most expensive product in this showcase video?",
    #"What shape is the orange product?",
    "What text appears at the beginning of the video?",
]


def preview(text, max_len=800):
    text = text or ""
    return text[:max_len] + ("..." if len(text) > max_len else "")


if not has_llm_provider():
    print("Error: No LLM API key configured. Set at least one API key in Colab Secrets.")
else:
    for i, q in enumerate(questions, start=1):
        print("\n" + "=" * 80)
        print(f"Q{i}. {q}")
        print("=" * 80)

        # --- WITH VIDEO RAG ---
        print("\n--- WITH VIDEO RAG ---")
        try:
            answer_rag, retrieved, prov = answer_with_video_rag(q)
            show_retrieved_frames(q, retrieved)
            print(f"\n  Answer (provider: {prov}):")
            print(preview(answer_rag))
        except Exception as e:
            print(f"  RAG error: {e}")

        # --- WITHOUT RAG ---
        print("\n--- WITHOUT RAG (LLM knowledge only) ---")
        try:
            answer_direct, prov_direct = generate_answer_without_rag(q)
            print(f"  Answer (provider: {prov_direct}):")
            print(preview(answer_direct))
        except Exception as e:
            print(f"  Direct error: {e}")

        print()

================================================================================
Q1. What is the red product shown in the video and how much does it cost?
================================================================================

--- WITH VIDEO RAG ---
  [Step 1] Embedding query with CLIP ...
  [Step 2] Retrieved 3 frame(s):
           #1 t=6.0s — Widget Alpha (sim=0.255)
           #2 t=4.0s — Widget Alpha (sim=0.251)
           #3 t=5.0s — Widget Alpha (sim=0.250)
  [Step 3] Generating multimodal answer ...
  [Multimodal response from Gemini / gemini-2.5-flash]
  [Step 4] Answer generated (provider: Gemini/gemini-2.5-flash)
<Figure size 1800x500 with 3 Axes>

  Answer (provider: Gemini/gemini-2.5-flash):
The red product shown in the video is a "Widget Alpha", as indicated by the metadata and text overlay in the frames (e.g., at t=6.0s). It costs $12.99.

--- WITHOUT RAG (LLM knowledge only) ---
  [Response from Gemini / gemini-2.5-flash]
  Answer (provider: Gemini/gemini-2.5-flash):
I'm sorry, I cannot answer the question about the red product and its cost. The provided context does not include any information about a video or a red product. Therefore, I'm unable to identify what you're referring to or provide details about its cost.


================================================================================
Q2. What text appears at the beginning of the video?
================================================================================

--- WITH VIDEO RAG ---
  [Step 1] Embedding query with CLIP ...
  [Step 2] Retrieved 3 frame(s):
           #1 t=6.0s — Widget Alpha (sim=0.258)
           #2 t=4.0s — Widget Alpha (sim=0.257)
           #3 t=5.0s — Widget Alpha (sim=0.255)
  [Step 3] Generating multimodal answer ...
  [Multimodal response from Gemini / gemini-2.5-flash]
  [Step 4] Answer generated (provider: Gemini/gemini-2.5-flash)
<Figure size 1800x500 with 3 Axes>

  Answer (provider: Gemini/gemini-2.5-flash):
The provided frames show the text "Widget Alpha $12.99" at the top of the screen at timestamps t=4.0s, t=5.0s, and t=6.0s. However, these frames do not capture the very beginning of the video, so I cannot determine what text appeared at the absolute beginning.

--- WITHOUT RAG (LLM knowledge only) ---
  [Response from Gemini / gemini-2.5-flash]
  Answer (provider: Gemini/gemini-2.5-flash):
The video begins with the text "How do humans learn to speak?"

Checkpoint: Reflection Questions

  1. Temporal grounding: Did the RAG answers include timestamps? How useful is knowing when in the video something appears?

  2. Without-RAG failure: The LLM has never seen our synthetic video — what kind of responses did it give? Refusal? Hallucination?

  3. Frame sampling: We sampled 1 frame/second. What would happen with 1 frame every 5 seconds? Every 0.5 seconds?

  4. Comparison with Image RAG: What extra complexity does the temporal dimension add over static Image RAG?

9) Interactive Query (Optional)

Ask your own questions about the product showcase video.

# Change this to any question about the product showcase video
my_question = "How many products are shown in total, and what are their prices?"

if has_llm_provider():
    print(f"Question: {my_question}\n")

    print("=" * 60)
    print("WITH VIDEO RAG:")
    print("=" * 60)
    try:
        answer, retrieved, provider = answer_with_video_rag(my_question, top_k=4)
        show_retrieved_frames(my_question, retrieved)
        print(f"\nAnswer ({provider}):\n{answer}")
    except Exception as e:
        print(f"Error: {e}")

    print("\n" + "=" * 60)
    print("WITHOUT RAG:")
    print("=" * 60)
    try:
        answer_direct, prov_direct = generate_answer_without_rag(my_question)
        print(f"\nAnswer ({prov_direct}):\n{answer_direct}")
    except Exception as e:
        print(f"Error: {e}")
else:
    print("No LLM API key configured. Set at least one API key in Colab Secrets.")
Question: How many products are shown in total, and what are their prices?

============================================================
WITH VIDEO RAG:
============================================================
  [Step 1] Embedding query with CLIP ...
  [Step 2] Retrieved 4 frame(s):
           #1 t=14.0s — Tool Gamma (sim=0.246)
           #2 t=11.0s — Tool Gamma (sim=0.243)
           #3 t=13.0s — Tool Gamma (sim=0.243)
           #4 t=12.0s — Tool Gamma (sim=0.241)
  [Step 3] Generating multimodal answer ...
  [Gemini] unavailable, trying next...
Error: Error code: 404 - {'error': {'message': 'model "qwen2.5-vl:cloud" not found', 'type': 'not_found_error', 'param': None, 'code': None}}

============================================================
WITHOUT RAG:
============================================================
  [Response from Gemini / gemini-2.5-flash]

Answer (Gemini/gemini-2.5-flash):
Let's count them! There are 5 products shown in total.

Here are the products and their prices:

1.  **Hand Cream:** $15
2.  **Body Wash:** $25
3.  **Body Lotion:** $22
4.  **Body Scrub:** $30
5.  **Bath Bomb Set:** $18

10) Teaching Notes and Exercises

Key takeaways:

  • Video RAG = frame extraction + Image RAG + temporal metadata. The video is decomposed into keyframes that are indexed and retrieved just like static images.

  • Timestamps add a temporal dimension that Image RAG lacks — answers can reference when in the video something occurs.

  • Frame sampling rate is a key design choice: too sparse and you miss content; too dense and you waste compute and storage.

  • The same CLIP + multimodal LLM stack from Image RAG works here — the main new component is the frame extraction step.

  • Without-RAG comparison is especially stark for video: the LLM has never seen the video, so it either refuses or hallucinates.

Exercises:

  • Download a short YouTube clip (e.g., a product review) and replace the synthetic video. How does CLIP handle real-world video frames?

  • Experiment with different frame sampling rates (0.5s, 2s, 5s) and measure the impact on retrieval accuracy.

  • Add scene change detection instead of fixed-interval sampling — extract frames only when the visual content changes significantly.

  • Combine video frames with audio transcription (e.g., Whisper) for a multimodal RAG that uses both visual and spoken content.

  • Discuss: In what business scenarios is Video RAG most valuable? (e.g., surveillance review, training video search, meeting recording Q&A, sports analytics)

Key takeaways

  • Video RAG reduces to Image RAG once you extract keyframes -- the same CLIP + cosine-similarity pipeline handles retrieval.

  • Frame sampling rate trades coverage for efficiency; one frame per second is a reasonable default for most content.

  • Timestamps attached to each frame let the LLM ground its answer in when something happens, not just what appears.

  • Multimodal LLMs receive the actual retrieved frames as images, enabling answers that read both visuals and on-screen text.

  • Without RAG, the model has never seen your video and will refuse or hallucinate -- retrieval is what supplies the evidence.

Optional: Raw Gemini Video API Call

The llm.generate_multimodal() method sends frames to whichever vision-capable provider is available — Gemini, Ollama Cloud, HuggingFace, or OpenRouter. But Gemini has a unique capability: it can accept the entire video file as input (not just extracted frames) and do its own internal frame sampling. This works because Gemini’s native API supports video MIME types like video/mp4 directly.

The code below shows how that looks. It’s Gemini-only — OpenAI-compatible endpoints do not accept video bytes, which is why the cascade restricts video inputs to Gemini. You might prefer this approach for short videos where letting Gemini choose its own frames beats a fixed one-frame-per-second strategy.

# Direct Gemini call on the whole video file -- Gemini-only feature that the
# cascade does not exercise for other providers.
from google import genai
from google.genai import types as genai_types
from llm_cascade.providers import _get_key

gemini_key = _get_key("GEMINI_API_KEY")
if not gemini_key:
    print("Skipping: GEMINI_API_KEY not configured.")
else:
    question = "What products appear in this video and in what order?"
    video_bytes = Path(VIDEO_PATH).read_bytes()

    client = genai.Client(api_key=gemini_key)
    parts = [
        genai_types.Part.from_text(text=question),
        genai_types.Part.from_bytes(data=video_bytes, mime_type="video/mp4"),
    ]
    resp = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=parts,
        config=genai_types.GenerateContentConfig(
            thinking_config=genai_types.ThinkingConfig(thinking_budget=0),
        ),
    )
    print(f"Question: {question}")
    print(f"Video:    {VIDEO_PATH} ({Path(VIDEO_PATH).stat().st_size // 1024} KB)")
    print(f"\nAnswer:\n{resp.text}")
Question: What products appear in this video and in what order?
Video:    product_showcase.mp4 (260 KB)

Answer:
Here are the products that appear in the video, in order of appearance:

1.  **Widget Alpha** (a red circle)
2.  **Gadget Beta** (a green triangle)
3.  **Tool Gamma** (a blue square)
4.  **Device Delta** (an orange diamond)

Run the code

To run this notebook, copy the URL below into your browser’s address bar. The link opens the notebook directly in Google Colab. (If your PDF viewer makes the URL clickable and lands on a broken page, copy the full text manually -- the viewer may have truncated the link at a line break.)

https://colab.research.google.com/github/KarAnalytics/code_demos/blob/main/Video_RAG.ipynb