Accueil / Articles / Notes pratiques : Mon système RAG a manqué 80 % de mes données. Un seul changement a résolu le problème.

Notes pratiques : Mon système RAG a manqué 80 % de mes données. Un seul changement a résolu le problème.

Guide pas à pas fonctionnel des notes pratiques : Mon système RAG a manqué 80 % de mes données. Ce seul changement a résolu le problème : contrats, vérifications et emplacements pour du code à insérer destinés aux équipes utilisant ce modèle.

5233 mots

Ce guide reconstitue le parcours allant des matières premières à un système fonctionnel pour : Mon système RAG a manqué 80 % de mes données. Un seul changement a résolu le problème.. L’accent est mis sur des étapes opérationnelles, des vérifications explicites, ainsi que du code que vous pouvez intégrer directement dans un dépôt sans devoir deviner son intention. Pour l’étape d’aperçu, définissez les entrées, le responsable de l’étape et les critères de fin avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans avoir à deviner l’état caché. Gardez la configuration en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les indicateurs fonctionnels doivent être regroupés en un seul endroit que les opérateurs peuvent auditer sans devoir lire l’ensemble du système.

Qu’est-ce qui a vraiment changé avec Gemini Embedding 2

Lorsque vous travaillez sur l’étape « What Actually Changed With », notez d’abord le contrat : les entrées requises, le signal de succès, ainsi que ce qui se passe en cas d’échec partiel. Cette liste de contrôle permet de rester honnête lors des modifications ultérieures du code. Documentez ensemble le parcours normal et le parcours de récupération. Les tentatives répétées, les contrôles humains et la gestion des messages non livrés font partie intégrante du produit, et non d’améliorations ultérieures. Enregistrez l’ID de la demande, l’ID du modèle et le temps de latence pour chaque appel. Sans cette trace, les erreurs intermittentes du fournisseur ressemblent à des bugs de l’application.

Le problème de l’espace d’embedding unique

Lors de la phase « The Single Embedding Space », notez d’abord les exigences : entrées requises, signal de succès et conséquences en cas d’échec partiel. Cette liste de contrôle permet de rester honnête lors des modifications ultérieures du code. Préférez des unités petites et testables plutôt que des scripts complexes. Lorsqu’une étape échoue, l’échec doit indiquer une seule responsabilité et non un processus embrouillé. Évaluez le taux de rappel sur un ensemble de questions fixe avant d’ajuster les prompts. Changer fréquemment les prompts ne résout généralement pas un système de récupération insuffisant.

Ce que le modèle prend réellement en charge

Lors de la phase « What the Model Actually », écrivez d’abord le contrat : les entrées requises, le signal de succès et ce qui se passe en cas d’échec partiel. Cette liste de contrôle permet de garantir l’honnêteté des modifications ultérieures du code. Considérez cette phase comme un contrat entre les entrées et les sorties validées. Donnez des noms aux artefacts, définez des vérifications de succès et refusez les terminations partielles silencieuses. Cachez les instructions du système stable ainsi que les schémas des outils. L’envoi répété d’un préambule identique est une source fréquente de problèmes. Lors de la phase « What the Model Actually », écrivez d’abord le contrat : les entrées requises, le signal de succès et ce qui se passe en cas d’échec partiel. Cette liste de contrôle permet de garantir l’honnêteté des modifications ultérieures du code. Gardez la configuration en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les indicateurs fonctionnels doivent être regroupés en un seul endroit que les administrateurs peuvent auditer sans devoir lire l’ensemble du système.

L’architecture, expliquée en détail

L’Architecture Explained in stage fonctionne le mieux lorsqu’elle est considérée comme une surface mesurable. Capturez un cas réussi exemplaire, un cas d’échec et la note de rollback avant d’élargir le périmètre. Documentez en même temps le parcours optimal et le parcours de récupération. Les tentatives répétées, les contrôles humains et le traitement des messages non livrés font partie intégrante du produit, et non d’une mise en forme ultérieure. Séparez la politique de segmentation des données de la politique de récupération ; modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité évoluent.

Le pipeline d’ingestion

La phase du pipeline d’ingestion fonctionne le mieux lorsqu’elle est considérée comme une surface mesurable. Capturez un transcript idéal, un cas d’échec et la note de rollback avant d’élargir le périmètre. Préférez des unités petites et testables aux scripts complexes. Lorsqu’une étape échoue, l’erreur doit pointer vers une seule responsabilité plutôt que vers un pipeline embrouillé. Séparez la politique de segmentation de la politique de récupération. Modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité changent.

Le pipeline de requête

La phase du pipeline de requêtes fonctionne le mieux lorsqu’elle est considérée comme une entité mesurable. Capturez un exemplaire idéal, un cas d’échec et une note de réversion avant d’élargir le périmètre. Considérez cette phase comme un contrat entre les entrées et les sorties validées. Nommez les artefacts, définites des critères de succès et refusez toute mise à jour partielle silencieuse. Séparez la politique de segmentation des données de la politique de récupération. Modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité évoluent. La phase du pipeline de requêtes fonctionne le mieux lorsqu’elle est considérée comme une entité mesurable. Capturez un exemplaire idéal, un cas d’échec et une note de réversion avant d’élargir le périmètre. Gardez la configuration en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les indicateurs fonctionnels doivent être regroupés en un seul endroit que les opérateurs peuvent auditer sans avoir à lire l’ensemble du système.

Pourquoi ces deux pipelines doivent rester séparés

Pour l’étape « Why These Two Pipelines », définissez les entrées, le responsable de l’étape et les critères de fin avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Documentez conjointement le parcours normal et le parcours de récupération. Les tentatives répétées, les contrôles humains et la gestion des messages non traités font partie intégrante du produit, et non d’une mise en forme ultérieure. Citez les passages qui justifient réellement la réponse. Sans citations, les opérateurs ne peuvent pas distinguer une hallucination d’un manque d’indexation.

Mise en place de l’environnement

Pendant l’étape de mise en place de l’environnement, définissez les entrées, le responsable de l’étape et les critères d’arrêt avant de modifier du code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Préférez des unités petites et testables aux scripts complexes. Lorsqu’une étape échoue, l’échec doit indiquer une seule responsabilité plutôt qu’un processus embrouillé. Citez les passages qui ont réellement servi de base à la réponse. Sans citations, les opérateurs ne peuvent pas distinguer une hallucination d’un manque d’indexation.

pip install google-genai chromadb google-generativeai python-dotenv ffmpeg-python
# config.py
import os
from google import genai
from google.genai import types

GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
EMBEDDING_MODEL = "gemini-embedding-2-preview"
GENERATION_MODEL = "gemini-2.5-pro"
# Output dimensionality options: 128, 256, 512, 768, 1024, 1536, 3072
# 1536 is the recommended default
EMBEDDING_DIMENSIONS = 1536
client = genai.Client(api_key=GEMINI_API_KEY)

Construction du pipeline d’ingestion

Pour l’étape de création du pipeline d’ingestion, définissez les entrées, le responsable de l’étape et les critères de fin avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Considérez cette étape comme un contrat entre les entrées et les sorties validées. Nommez les artefacts, définissez des vérifications de succès et refusez toute exécution partielle silencieuse. Citez les passages qui justifient réellement la réponse. Sans citations, les opérateurs ne peuvent pas distinguer une hallucination d’un manque d’indexation. Pour l’étape de création du pipeline d’ingestion, définissez les entrées, le responsable de l’étape et les critères de fin avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Conservez la configuration en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les flags fonctionnels doivent être regroupés en un seul endroit que les opérateurs peuvent auditer sans avoir à le lire.

toute la grille.

Étape 1 : Le client d’incorporation

Lors de l’exécution de l’étape 1 relative à l’incorporation, notez d’abord le contrat : les entrées requises, le signal de succès et ce qui se passe en cas d’échec partiel. Cette liste de contrôle permet de rester honnête lors des modifications ultérieures du code. Documentez ensemble le parcours normal et le parcours de récupération. Les tentatives répétées, les contrôles humains et la gestion des messages non traités font partie du produit, et non d’améliorations ultérieures. Mesurez le taux de rappel sur un ensemble fixe de questions avant d’ajuster les prompts. Le changement fréquent des prompts ne résout que rarement un système de récupération insuffisant.

# embedder.py
import time
from pathlib import Path
from google import genai
from google.genai import types
from config import client, EMBEDDING_MODEL, EMBEDDING_DIMENSIONS

def embed_text(text: str, task_type: str = "RETRIEVAL_DOCUMENT") -> list[float]:
    """Embed a plain text chunk."""
    result = client.models.embed_content(
        model=EMBEDDING_MODEL,
        contents=text,
        config=types.EmbedContentConfig(
            task_type=task_type,
            output_dimensionality=EMBEDDING_DIMENSIONS
        )
    )
    return result.embeddings[0].values

def _wait_for_file(uploaded, max_wait: int = 300):
    """Poll until a File API upload is done processing."""
    waited = 0
    poll_interval = 5
    while uploaded.state.name == "PROCESSING" and waited         time.sleep(poll_interval)
        waited += poll_interval
        uploaded = client.files.get(name=uploaded.name)
    if uploaded.state.name != "ACTIVE":
        raise RuntimeError(
            f"File never became ACTIVE. Final state: {uploaded.state.name}"
        )
    return uploaded

def embed_audio(audio_path: str) -> list[float]:
    """
    Embed an audio file natively. No transcription step.
    The model processes the audio signal directly and returns a
    semantic embedding that captures speech content, tone, and
    acoustic features. Max input: 80 seconds per file.
    """
    uploaded = client.files.upload(path=str(audio_path))
    uploaded = _wait_for_file(uploaded, max_wait=120)
    result = client.models.embed_content(
        model=EMBEDDING_MODEL,
        contents=uploaded,
        config=types.EmbedContentConfig(
            task_type="RETRIEVAL_DOCUMENT",
            output_dimensionality=EMBEDDING_DIMENSIONS
        )
    )
    # Clean up: uploaded files count against your quota
    client.files.delete(name=uploaded.name)
    return result.embeddings[0].values

def embed_video(video_path: str) -> list[float]:
    """
    Embed a video chunk natively. Gemini processes both the
    audio track and visual frames together in one pass.
    This is the key capability: visual demonstrations get captured
    in the embedding alongside what is being said. Max input: 128 seconds.
    """
    uploaded = client.files.upload(path=str(video_path))
    uploaded = _wait_for_file(uploaded, max_wait=300)
    result = client.models.embed_content(
        model=EMBEDDING_MODEL,
        contents=uploaded,
        config=types.EmbedContentConfig(
            task_type="RETRIEVAL_DOCUMENT",
            output_dimensionality=EMBEDDING_DIMENSIONS
        )
    )
    client.files.delete(name=uploaded.name)
    return result.embeddings[0].values

def embed_with_context(text: str, image_bytes: bytes = None) -> list[float]:
    """
    Embed text and an optional image together in a single call.
    When both are passed, the model returns one vector that
    represents the joint meaning. A query asking about a database
    schema can retrieve a screenshot of that schema.
    """
    contents = [text]
    if image_bytes:
        contents.append(
            types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
        )
    result = client.models.embed_content(
        model=EMBEDDING_MODEL,
        contents=contents,
        config=types.EmbedContentConfig(
            task_type="RETRIEVAL_DOCUMENT",
            output_dimensionality=EMBEDDING_DIMENSIONS
        )
    )
    return result.embeddings[0].values

Étape 2 : Le fragmentateur de médias

Lors de l’étape 2 « Les médias », notez d’abord le contrat : les entrées requises, le signal de succès et ce qui se passe en cas d’échec partiel. Cette liste de contrôle permet de rester honnête lors des modifications ultérieures du code. Préférez des unités petites et testables à des scripts complexes. Lorsqu’une étape échoue, l’échec doit indiquer une seule responsabilité plutôt qu’un processus embrouillé. Évaluez le taux de rappel sur un ensemble fixe de questions avant d’ajuster les prompts. Un changement fréquent des prompts ne résout que rarement un système de récupération insuffisant.

# chunker.py
import subprocess
import json
from pathlib import Path
from dataclasses import dataclass
from typing import List

@dataclass
class MediaChunk:
    file_path: str
    start_time: float
    end_time: float
    source_file: str
    modality: str
    chunk_index: int
    total_chunks: int  # Useful for progress reporting

def get_media_duration(file_path: str) -> float:
    """Get exact duration via ffprobe. Works for both audio and video."""
    cmd = [
        "ffprobe", "-v", "quiet",
        "-print_format", "json",
        "-show_streams", str(file_path)
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, check=True)
    data = json.loads(result.stdout)
    # Find the first stream with a duration value
    for stream in data.get("streams", []):
        if "duration" in stream:
            return float(stream["duration"])
    raise ValueError(f"Could not determine duration for: {file_path}")

def _run_ffmpeg_split(input_path: str, output_path: str,
                      start: float, duration: float):
    """Execute a single ffmpeg split operation."""
    cmd = [
        "ffmpeg", "-y",
        "-ss", str(start),
        "-i", str(input_path),
        "-t", str(duration),
        "-c", "copy",           # No re-encoding: much faster, no quality loss
        "-avoid_negative_ts", "make_zero",
        str(output_path)
    ]
    result = subprocess.run(cmd, capture_output=True)
    if result.returncode != 0:
        raise RuntimeError(
            f"ffmpeg failed: {result.stderr.decode()}"
        )

def chunk_video(
    video_path: str,
    chunk_duration: int = 90,
    overlap: int = 10,
    output_dir: str = "./chunks/video"
) -> List[MediaChunk]:
    """
    Split video into overlapping chunks within the 128-second limit.
    Default: 90-second chunks with 10-second overlap.
    Overlap ensures topic transitions are captured in at least one chunk.
    """
    Path(output_dir).mkdir(parents=True, exist_ok=True)
    total_duration = get_media_duration(video_path)
    source_name = Path(video_path).stem
    # Pre-calculate chunk boundaries
    boundaries = []
    start = 0.0
    while start         end = min(start + chunk_duration, total_duration)
        boundaries.append((start, end))
        start += (chunk_duration - overlap)
    chunks = []
    for idx, (start, end) in enumerate(boundaries):
        output_path = f"{output_dir}/{source_name}_{idx:04d}.mp4"
        _run_ffmpeg_split(video_path, output_path, start, end - start)
        chunks.append(MediaChunk(
            file_path=output_path,
            start_time=start,
            end_time=end,
            source_file=str(video_path),
            modality="video",
            chunk_index=idx,
            total_chunks=len(boundaries)
        ))
    return chunks

def chunk_audio(
    audio_path: str,
    chunk_duration: int = 60,
    overlap: int = 5,
    output_dir: str = "./chunks/audio"
) -> List[MediaChunk]:
    """
    Split audio into overlapping chunks within the 80-second limit.
    60 seconds per chunk gives a comfortable buffer under the 80-second cap.
    """
    Path(output_dir).mkdir(parents=True, exist_ok=True)
    total_duration = get_media_duration(audio_path)
    source_name = Path(audio_path).stem
    boundaries = []
    start = 0.0
    while start         end = min(start + chunk_duration, total_duration)
        boundaries.append((start, end))
        start += (chunk_duration - overlap)
    chunks = []
    for idx, (start, end) in enumerate(boundaries):
        output_path = f"{output_dir}/{source_name}_{idx:04d}.mp3"
        _run_ffmpeg_split(audio_path, output_path, start, end - start)
        chunks.append(MediaChunk(
            file_path=output_path,
            start_time=start,
            end_time=end,
            source_file=str(audio_path),
            modality="audio",
            chunk_index=idx,
            total_chunks=len(boundaries)
        ))
    return chunks

Étape 3 : Le stockage vectoriel

Lors de l’étape 3 « Le vecteur », écrivez d’abord le contrat : les entrées requises, le signal de succès et ce qui se passe en cas d’échec partiel. Cette liste de contrôle permet de garantir l’honnêteté des modifications ultérieures du code. Considérez cette étape comme un contrat entre les entrées et les sorties validées. Donnez des noms aux artefacts, définez des vérifications de succès et refusez les terminations partielles silencieuses. Mesurez le taux de rappel sur un ensemble fixe de questions avant d’ajuster les prompts. Le changement fréquent des prompts résout rarement un problème de capacité de récupération insuffisante. Lors de l’étape 3 « Le vecteur », écrivez d’abord le contrat : les entrées requises, le signal de succès et ce qui se passe en cas d’échec partiel. Cette liste de contrôle permet de garantir l’honnêteté des modifications ultérieures du code. Conservez la configuration en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les indicateurs fonctionnels doivent être regroupés en un seul endroit que les administrateurs peuvent auditer sans devoir lire l’ensemble du système.

# vector_store.py
import chromadb
from chromadb.config import Settings
from pathlib import Path

class MultimodalVectorStore:
    """
    Vector store wrapping ChromaDB for multimodal RAG.
    Stores embeddings + metadata for text, audio, and video chunks.
    """
    def __init__(self, persist_dir: str = "./chroma_db"):
        self.client = chromadb.PersistentClient(
            path=persist_dir,
            settings=Settings(anonymized_telemetry=False)
        )
        self.collection = self.client.get_or_create_collection(
            name="multimodal_rag",
            # cosine distance is standard for semantic similarity
            metadata={"hnsw:space": "cosine"}
        )
    def add_text_chunk(
        self,
        chunk_id: str,
        text: str,
        embedding: list[float],
        source_file: str,
        chunk_index: int,
        page: int = None
    ):
        self.collection.add(
            ids=[chunk_id],
            embeddings=[embedding],
            documents=[text],
            metadatas=[{
                "modality": "text",
                "source_file": source_file,
                "chunk_index": chunk_index,
                "page": page or 0,
                "preview": text[:250]
            }]
        )
    def add_media_chunk(
        self,
        chunk_id: str,
        embedding: list[float],
        source_file: str,
        start_time: float,
        end_time: float,
        modality: str,
        chunk_index: int
    ):
        """
        Store a video or audio chunk.
        Note: we store a formatted timestamp string in `documents`
        so ChromaDB has something to display. The actual retrieval
        quality comes entirely from the embedding, not this text.
        """
        ts_start = f"{int(start_time // 60):02d}:{int(start_time % 60):02d}"
        ts_end = f"{int(end_time // 60):02d}:{int(end_time % 60):02d}"
        display = (
            f"[{modality.upper()}] {Path(source_file).name} "
            f"from {ts_start} to {ts_end}"
        )
        self.collection.add(
            ids=[chunk_id],
            embeddings=[embedding],
            documents=[display],
            metadatas=[{
                "modality": modality,
                "source_file": source_file,
                "start_time": start_time,
                "end_time": end_time,
                "timestamp_start": ts_start,
                "timestamp_end": ts_end,
                "chunk_index": chunk_index,
                "preview": display
            }]
        )
    def search(
        self,
        query_embedding: list[float],
        n_results: int = 5,
        modality_filter: str = None
    ) -> list[dict]:
        """
        Retrieve top-k most similar chunks across all modalities.
        Optionally filter to a single modality for targeted search.
        """
        where_clause = {"modality": modality_filter} if modality_filter else None
        results = self.collection.query(
            query_embeddings=[query_embedding],
            n_results=n_results,
            where=where_clause,
            include=["documents", "metadatas", "distances"]
        )
        chunks = []
        for doc, meta, dist in zip(
            results["documents"][0],
            results["metadatas"][0],
            results["distances"][0]
        ):
            chunks.append({
                "content": doc,
                "metadata": meta,
                "modality": meta["modality"],
                # ChromaDB returns cosine distance; convert to similarity score
                "similarity": round(1.0 - dist, 4)
            })
        return sorted(chunks, key=lambda x: x["similarity"], reverse=True)
    def count(self) -> int:
        return self.collection.count()

Étape 4 : L’exécuteur d’ingestion

La phase 4, celle de l’ingestion, fonctionne le mieux lorsqu’elle est considérée comme une surface mesurable. Capturez un exemple réussi, un cas d’échec ainsi que la note de réversion avant d’élargir le périmètre. Documentez en même temps le parcours optimal et celui de récupération. Les tentatives répétées, les contrôles humains et le traitement des messages non livrés font partie intégrante du produit, et non d’une mise en forme ultérieure. Séparez la politique de segmentation des données de la politique de récupération ; modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité évoluent.

# ingest.py
import os
import hashlib
from pathlib import Path
from chunker import chunk_video, chunk_audio
from embedder import embed_text, embed_audio, embed_video
from vector_store import MultimodalVectorStore

store = MultimodalVectorStore(persist_dir="./chroma_db")

def make_chunk_id(source_path: str, chunk_index: int) -> str:
    """Stable, unique ID for any chunk. Same input always = same ID."""
    raw = f"{os.path.abspath(source_path)}:{chunk_index}"
    return hashlib.sha256(raw.encode()).hexdigest()[:20]

def ingest_text_file(file_path: str):
    with open(file_path, "r", encoding="utf-8") as f:
        text = f.read()
    # Sliding window chunking: 800 chars with 100-char overlap
    chunk_size, overlap = 800, 100
    raw_chunks = []
    start = 0
    while start         end = min(start + chunk_size, len(text))
        raw_chunks.append(text[start:end])
        start += chunk_size - overlap
    for i, chunk_text in enumerate(raw_chunks):
        embedding = embed_text(chunk_text, task_type="RETRIEVAL_DOCUMENT")
        store.add_text_chunk(
            chunk_id=make_chunk_id(file_path, i),
            text=chunk_text,
            embedding=embedding,
            source_file=file_path,
            chunk_index=i
        )
    print(f"    Stored {len(raw_chunks)} text chunks from {Path(file_path).name}")

def ingest_video_file(file_path: str):
    print(f"    Chunking: {Path(file_path).name}")
    chunks = chunk_video(file_path, chunk_duration=90, overlap=10)
    for chunk in chunks:
        print(
            f"    Embedding chunk {chunk.chunk_index + 1}/{chunk.total_chunks} "
            f"({chunk.start_time:.0f}s to {chunk.end_time:.0f}s)"
        )
        try:
            embedding = embed_video(chunk.file_path)
            store.add_media_chunk(
                chunk_id=make_chunk_id(file_path, chunk.chunk_index),
                embedding=embedding,
                source_file=file_path,
                start_time=chunk.start_time,
                end_time=chunk.end_time,
                modality="video",
                chunk_index=chunk.chunk_index
            )
        except Exception as e:
            print(f"    WARNING: Failed to embed chunk {chunk.chunk_index}: {e}")
        finally:
            # Always clean up temp files, even on failure
            if os.path.exists(chunk.file_path):
                os.remove(chunk.file_path)
    print(f"    Done. {len(chunks)} video chunks stored.")

def ingest_audio_file(file_path: str):
    print(f"    Chunking: {Path(file_path).name}")
    chunks = chunk_audio(file_path, chunk_duration=60, overlap=5)
    for chunk in chunks:
        try:
            embedding = embed_audio(chunk.file_path)
            store.add_media_chunk(
                chunk_id=make_chunk_id(file_path, chunk.chunk_index),
                embedding=embedding,
                source_file=file_path,
                start_time=chunk.start_time,
                end_time=chunk.end_time,
                modality="audio",
                chunk_index=chunk.chunk_index
            )
        except Exception as e:
            print(f"    WARNING: Failed to embed chunk {chunk.chunk_index}: {e}")
        finally:
            if os.path.exists(chunk.file_path):
                os.remove(chunk.file_path)
    print(f"    Done. {len(chunks)} audio chunks stored.")

def ingest_directory(directory: str):
    handlers = {
        ".txt": ingest_text_file,
        ".md": ingest_text_file,
        ".mp4": ingest_video_file,
        ".mov": ingest_video_file,
        ".mp3": ingest_audio_file,
        ".wav": ingest_audio_file,
    }
    all_files = list(Path(directory).rglob("*"))
    media_files = [f for f in all_files if f.suffix.lower() in handlers]
    print(f"Found {len(media_files)} files to ingest\n")
    for file_path in media_files:
        print(f"Processing: {file_path.name}")
        handler = handlers[file_path.suffix.lower()]
        handler(str(file_path))
        print()
    print(f"Ingestion complete. Total chunks indexed: {store.count()}")

if __name__ == "__main__":
    ingest_directory("./knowledge_base")

Construction du pipeline de requêtes

La phase de construction du pipeline de requêtes fonctionne le mieux lorsqu’elle est considérée comme une entité mesurable. Capturez un exemple parfait, un cas d’échec et la note de réversion avant d’élargir le périmètre. Préférez des unités petites et testables aux scripts complexes. Lorsqu’une étape échoue, l’erreur doit indiquer une seule responsabilité plutôt qu’un pipeline embrouillé. Séparez la politique de segmentation de la politique de récupération. Modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité changent.

# query.py
import os
from pathlib import Path
import google.generativeai as genai
from embedder import embed_text
from vector_store import MultimodalVectorStore
from config import GENERATION_MODEL

store = MultimodalVectorStore(persist_dir="./chroma_db")

def format_context_for_llm(chunks: list[dict]) -> str:
    """
    Format retrieved chunks into a context block for the generative model.
    We include modality, source, and similarity score so the model
    can calibrate its confidence and cite sources accurately.
    """
    parts = []
    for rank, chunk in enumerate(chunks, start=1):
        meta = chunk["metadata"]
        modality = chunk["modality"]
        score = chunk["similarity"]
        if modality == "text":
            parts.append(
                f"[SOURCE {rank} | TEXT | {Path(meta['source_file']).name} "
                f"| chunk {meta['chunk_index']} | similarity {score}]\n"
                f"{chunk['content']}"
            )
        elif modality == "video":
            parts.append(
                f"[SOURCE {rank} | VIDEO | {Path(meta['source_file']).name} "
                f"| {meta['timestamp_start']} to {meta['timestamp_end']} "
                f"| similarity {score}]\n"
                f"Video segment covering this time range."
            )
        elif modality == "audio":
            parts.append(
                f"[SOURCE {rank} | AUDIO | {Path(meta['source_file']).name} "
                f"| {meta['timestamp_start']} to {meta['timestamp_end']} "
                f"| similarity {score}]\n"
                f"Audio segment covering this time range."
            )
    return "\n\n---\n\n".join(parts)

def answer_query(
    query: str,
    n_results: int = 5,
    modality_filter: str = None,
    similarity_threshold: float = 0.6
) -> dict:
    """
    Full RAG pipeline: embed the query, retrieve chunks, generate answer.
    similarity_threshold: chunks below this score are dropped before generation.
    Prevents low-quality matches from polluting the context.
    """
    query_embedding = embed_text(query, task_type="RETRIEVAL_QUERY")
    retrieved = store.search(
        query_embedding=query_embedding,
        n_results=n_results,
        modality_filter=modality_filter
    )
    # Filter out weak matches
    filtered = [c for c in retrieved if c["similarity"] >= similarity_threshold]
    if not filtered:
        return {
            "answer": (
                "No sufficiently relevant content was found in the knowledge base. "
                "The most similar content had a similarity score below the threshold."
            ),
            "sources": retrieved,
            "query": query
        }
    context = format_context_for_llm(filtered)
    system_prompt = """You are a helpful assistant with access to a multimodal
knowledge base that contains text documents, video recordings, and audio files.
When citing a source, reference it by its label (e.g., SOURCE 1, SOURCE 2).
For video and audio sources, always include the timestamp so the user can
navigate to the exact moment in the recording.
If the retrieved context does not contain enough information to answer
confidently, say so clearly rather than guessing."""
    user_message = (
        f"Using only the sources below, answer this question:\n\n"
        f"Question: {query}\n\n"
        f"Sources:\n{context}"
    )
    genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
    model = genai.GenerativeModel(GENERATION_MODEL)
    response = model.generate_content(
        user_message,
        generation_config={"temperature": 0.1}
    )
    return {
        "answer": response.text,
        "sources": filtered,
        "query": query,
        "chunks_retrieved": len(retrieved),
        "chunks_used": len(filtered)
    }

Améliorer la précision de la récupération

La phase d’amélioration de la précision du retrait fonctionne le mieux lorsqu’elle est considérée comme une surface mesurable. Capturez un transcript idéal, un cas d’échec et la note de réversion avant d’élargir le périmètre. Traitez cette phase comme un contrat entre les entrées et les sorties validées. Nommez les artefacts, définites des vérifications de succès et refusez toute complétion partielle silencieuse. Séparez la politique de segmentation des données de la politique de retrait. Modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité évoluent. La phase d’amélioration de la précision du retrait fonctionne le mieux lorsqu’elle est considérée comme une surface mesurable. Capturez un transcript idéal, un cas d’échec et la note de réversion avant d’élargir le périmètre. Conservez les configurations en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les indicateurs fonctionnels doivent être regroupés en un seul endroit que les opérateurs peuvent auditer sans avoir à lire l’ensemble du système.

1. Utilisez le bon type de tâche, à chaque fois

Pour l’étape 1 « Utiliser la bonne méthode », définissez les entrées, le responsable de l’étape et les critères de fin avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Documentez ensemble le parcours idéal et le parcours de récupération. Les tentatives répétées, les contrôles humains et la gestion des messages non traités font partie du produit, et non d’une mise en forme ultérieure. Citez les passages qui ont réellement servi de base à la réponse. Sans citations, les opérateurs ne peuvent pas distinguer une hallucination d’un manque d’indexation.

2. Ajouter l’expansion de la requête avant l’incorporation

Pour l’étape 2 « Add Query Expansion », définissez les entrées, le responsable de cette étape ainsi que les critères d’arrêt avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Préférez des unités petites et testables à des scripts complexes. Lorsqu’une étape échoue, l’échec doit indiquer une seule responsabilité et non un processus embrouillé. Citez les passages qui ont réellement servi de base à la réponse. Sans citations, les opérateurs ne peuvent pas distinguer une hallucination d’un manque d’indexation.

# query_expander.py
import google.generativeai as genai
import os

genai.configure(api_key=os.getenv("GEMINI_API_KEY"))

def expand_query(raw_query: str) -> str:
    """
    Rewrite a short user query into a more detailed retrieval query.
    Returns the expanded version. Falls back to original on failure.
    """
    model = genai.GenerativeModel("gemini-2.0-flash")
    prompt = (
        "Rewrite the following search query to be more detailed and specific. "
        "Add relevant context, related terminology, and clarify the intent. "
        "Keep it as a single question. Do not add facts not implied by the original.\n\n"
        f"Original query: {raw_query}\n\n"
        "Expanded query:"
    )
    try:
        response = model.generate_content(
            prompt,
            generation_config={"temperature": 0.2, "max_output_tokens": 200}
        )
        return response.text.strip()
    except Exception:
        return raw_query  # Graceful fallback

# Usage in query pipeline:
# expanded = expand_query("API limits engineering review")
# query_embedding = embed_text(expanded, task_type="RETRIEVAL_QUERY")

3. Exécuter plusieurs requêtes en parallèle

Pour l’étape 3 « Exécuter plusieurs requêtes », définissez les entrées, le responsable de l’étape et les critères d’arrêt avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Considérez cette étape comme un contrat entre les entrées et les sorties validées. Donnez des noms aux artefacts, définites des vérifications de succès et refusez les terminations partielles silencieuses. Citez les passages qui ont réellement servi de base à la réponse. Sans citations, les opérateurs ne peuvent pas distinguer une hallucination d’un manque d’indexation. Pour l’étape 3 « Exécuter plusieurs requêtes », définissez les entrées, le responsable de l’étape et les critères d’arrêt avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Conservez la configuration en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les indicateurs fonctionnels doivent être regroupés en un seul endroit que les opérateurs peuvent auditer sans avoir à lire l’ensemble du système.

# multi_query.py
import concurrent.futures
from query_expander import expand_query
from embedder import embed_text
from vector_store import MultimodalVectorStore

store = MultimodalVectorStore(persist_dir="./chroma_db")

def generate_query_variants(query: str) -> list[str]:
    """Generate multiple phrasings for a single question."""
    import google.generativeai as genai
    import os
    genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
    model = genai.GenerativeModel("gemini-2.0-flash")
    prompt = (
        f"Generate 3 different ways to search for information about: {query}\n\n"
        "Return exactly 3 queries, one per line, no numbering or bullets."
    )
    response = model.generate_content(prompt)
    variants = [line.strip() for line in response.text.strip().split("\n") if line.strip()]
    return ([query] + variants)[:4]  # Always include original, cap at 4 total

def multi_query_search(query: str, n_per_query: int = 4) -> list[dict]:
    """
    Search with multiple query variants and deduplicate results.
    Returns unique chunks ranked by their best similarity score.
    """
    variants = generate_query_variants(query)
    def search_one(variant: str) -> list[dict]:
        embedding = embed_text(variant, task_type="RETRIEVAL_QUERY")
        return store.search(embedding, n_results=n_per_query)
    # Run all variants in parallel
    all_results = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
        futures = {executor.submit(search_one, v): v for v in variants}
        for future in concurrent.futures.as_completed(futures):
            all_results.extend(future.result())
    # Deduplicate by source file + chunk index, keeping best similarity score
    seen = {}
    for chunk in all_results:
        meta = chunk["metadata"]
        key = f"{meta['source_file']}:{meta['chunk_index']}"
        if key not in seen or chunk["similarity"] > seen[key]["similarity"]:
            seen[key] = chunk
    return sorted(seen.values(), key=lambda x: x["similarity"], reverse=True)

4. Réclasser les fragments récupérés

Lors de la phase de réclassification des fragments récupérés, notez d’abord les exigences : entrées requises, signal de succès et conséquences en cas d’échec partiel. Cette liste de contrôle permet de rester cohérent lors des modifications ultérieures du code. Documentez à la fois le parcours normal et les scénarios de récupération. Les tentatives répétées, les contrôles humains et la gestion des messages non traités font partie intégrante du produit, et non d’améliorations ultérieures. Mesurez le taux de rappel sur un ensemble de questions fixe avant d’ajuster les prompts ; le simple changement de prompts ne résout que rarement un système de récupération insuffisant.

# reranker.py
from sentence_transformers import CrossEncoder

# This model runs locally, no API cost, fast inference
_reranker = None

def get_reranker():
    global _reranker
    if _reranker is None:
        _reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
    return _reranker

def rerank_chunks(query: str, chunks: list[dict], top_k: int = 5) -> list[dict]:
    """
    Re-rank retrieved chunks using a cross-encoder model.
    Cross-encoders read both query and chunk together, giving much
    more precise relevance scores than embedding cosine similarity.
    Only practical on a small candidate set (10-20 chunks).
    """
    reranker = get_reranker()
    # For video/audio, we use the metadata preview as the text input.
    # For text chunks, we use the actual content.
    pairs = []
    for chunk in chunks:
        if chunk["modality"] == "text":
            doc_text = chunk["content"]
        else:
            meta = chunk["metadata"]
            doc_text = (
                f"{chunk['modality']} recording: {meta['source_file']} "
                f"at {meta.get('timestamp_start', '')} to {meta.get('timestamp_end', '')}"
            )
        pairs.append([query, doc_text])
    scores = reranker.predict(pairs)
    for chunk, score in zip(chunks, scores):
        chunk["rerank_score"] = float(score)
    return sorted(chunks, key=lambda x: x["rerank_score"], reverse=True)[:top_k]

# Usage:
# candidates = store.search(query_embedding, n_results=20)  # Retrieve wide
# final = rerank_chunks(query, candidates, top_k=5)         # Re-rank narro

5. Définir un seuil de similarité et s’y tenir

Lors de la phase 5 « Définir une similarité », notez d’abord les éléments essentiels du contrat : les entrées requises, le signal de succès et ce qui se passe en cas d’échec partiel. Cette liste de contrôle permet de rester honnête lors des modifications ultérieures du code. Préférez des unités petites et testables à des scripts complexes. Lorsqu’une étape échoue, l’échec doit indiquer une seule responsabilité et non un processus embrouillé. Évaluez le taux de rappel sur un ensemble de questions fixe avant d’ajuster les prompts. Changer fréquemment les prompts ne résout que rarement un système de récupération insuffisant.

Les résultats

Lors de la phase des Résultats, écrivez d’abord le contrat : les entrées requises, le signal de succès et ce qui se passe en cas d’échec partiel. Cette liste de contrôle garantit l’honnêteté des modifications ultérieures du code. Considérez cette phase comme un contrat entre les entrées et les sorties validées. Donnez des noms aux artefacts, définez des vérifications de succès et refusez les terminations partielles silencieuses. Mesurez le taux de rappel sur un ensemble fixe de questions avant d’ajuster les prompts. Un changement fréquent des prompts ne résout que rarement un système de récupération insuffisant. Lors de la phase des Résultats, écrivez d’abord le contrat : les entrées requises, le signal de succès et ce qui se passe en cas d’échec partiel. Cette liste de contrôle garantit l’honnêteté des modifications ultérieures du code. Gardez la configuration en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les indicateurs fonctionnels doivent être regroupés en un seul endroit que les administrateurs peuvent auditer sans devoir lire l’ensemble du système.

Que signifient les dimensions Matryoshka pour votre infrastructure

Le stade « What the Matryoshka Dimensions » fonctionne le mieux lorsqu’il est considéré comme une surface mesurable. Capturez un enregistrement réussi, un cas d’échec ainsi que la note de réversion avant d’élargir le périmètre. Documentez en même temps le parcours optimal et le parcours de récupération. Les tentatives répétées, les contrôles humains et la gestion des messages non livrés font partie intégrante du produit, et non d’une mise en forme ultérieure. Séparez la politique de segmentation de la politique de récupération : modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité évoluent.

Que vous pouvez construire avec cela

La phase « What You Can Build » fonctionne le mieux lorsqu’elle est considérée comme une surface mesurable. Recueillez un exemple réussi, un cas d’échec et la note de réversion avant d’élargir le périmètre. Préférez des unités petites et testables aux scripts complexes. Lorsqu’une étape échoue, l’erreur doit pointer vers une seule responsabilité plutôt que vers un processus embrouillé. Séparez la politique de segmentation de la politique de récupération. Modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité changent.

Conseils pratiques

La phase des « Actionable Takeaways » fonctionne le mieux lorsqu’elle est considérée comme une surface mesurable. Capturez un transcript idéal, un cas d’échec et la note de réversion avant d’élargir le périmètre. Traitez cette phase comme un contrat entre les entrées et les sorties validées. Donnez des noms aux artefacts, définez des critères de succès et refusez toute mise en œuvre partielle silencieuse. Séparez la politique de segmentation des données de la politique de récupération. Modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité évoluent. La phase des « Actionable Takeaways » fonctionne le mieux lorsqu’elle est considérée comme une surface mesurable. Capturez un transcript idéal, un cas d’échec et la note de réversion avant d’élargir le périmètre. Conservez les configurations en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les indicateurs fonctionnels doivent être regroupés en un seul endroit que les opérateurs peuvent auditer sans devoir lire l’ensemble du système.

Qu’est-ce qui manque encore ?

Pour l’étape « Qu’est-ce qui manque encore », définissez les entrées, le responsable de l’étape et les critères de fin avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Documentez ensemble le parcours idéal et le parcours de récupération. Les tentatives répétées, les contrôles humains et la gestion des messages non traités font partie du produit, et non d’une mise en forme ultérieure. Citez les passages qui ont réellement servi de base à la réponse. Sans citations, les opérateurs ne peuvent pas distinguer une hallucination d’un manque d’indexation.

Continuons à apprendre ensemble

Pour l’étape « Let’s Keep Learning », définissez les entrées, le responsable de l’étape et les critères d’arrêt avant de modifier du code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Préférez des unités petites et testables à des scripts complexes. Lorsqu’une étape échoue, l’échec doit indiquer une seule responsabilité plutôt qu’un processus embrouillé. Citez les passages qui ont réellement servi de base à la réponse. Sans citations, les opérateurs ne peuvent pas distinguer une hallucination d’un manque d’indexation.

Liste de contrôle opérationnelle

L’étape de la liste de contrôle opérationnelle fonctionne le mieux lorsqu’elle est considérée comme une surface mesurable. Capturez un enregistrement exemplaire, un cas d’échec et une note de réversion avant d’élargir le périmètre.

Enregistrez les temps de traitement ainsi que le coût en tokens ou en requêtes à côté des résultats fonctionnels. Une visibilité précoce des coûts évite les factures inattendues lorsque le processus passe de l’environnement de démonstration à des environnements partagés.

Séparez la politique de segmentation de la politique de récupération. Modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité évoluent.

Ajoutez un test de base qui met en œuvre le chemin critique dans l’environnement d’intégration continue à l’aide de fichiers de configuration, et non d’API payantes en ligne, chaque fois que le budget le permet.

Gardez la configuration en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les indicateurs fonctionnels doivent être regroupés en un seul endroit que les administrateurs peuvent auditer sans devoir lire l’ensemble du système.

Séparez la politique de segmentation de la politique de récupération. Modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité évoluent.

Au préalable de promouvoir l’ensemble du système, figez les versions, créez une version référence pour le chemin critique et vérifiez les étapes de réversion. Les environnements partagés nécessitent des limites de débit, des contrôles d’attribution et un responsable clair pour la rotation des identifiants secrets. Préférez une fiabilité solide à de brillantes démonstrations ponctuelles.

Note de lot pour b3567bc23c05 : ne pas inclure les clés du fournisseur dans le répertoire, fixer une limite pour les tokens par session, et stocker les transcriptions à côté des fichiers d’évaluation afin que les remplacements ultérieurs de modèles restent comparables.