Aller au contenu principal

Intégration OCR + RAG

Mis à jour le 29 juillet 2026

Construire un système documentaire intelligent

Le RAG (Retrieval-Augmented Generation) est la technique qui rend interrogeables de grands corpus documentaires. En combinant l’OCR de Mistral, les embeddings et un vector store, vous créez un système capable de répondre à des questions portant sur vos archives — dix ans de rapports, un fonds de contrats, une bibliothèque technique numérisée.

Pourquoi passer par l’OCR plutôt que d’indexer les fichiers tels quels ? Parce que le problème fondamental du RAG documentaire est que les chunks indexés doivent être du texte. Les embeddings multimodaux ne sont pas encore assez performants pour comparer directement des questions textuelles avec des images de documents. L’OCR sert donc de pont : il convertit vos documents en texte structuré de haute qualité, que vous indexez ensuite dans votre vector store.

L’architecture en cinq temps

Tout commence par l’ingestion, c’est-à-dire la réception des documents, qu’ils arrivent en PDF, en images ou en DOCX. Vient ensuite l’OCR accompagné des annotations, qui produit à la fois le texte et les métadonnées structurées. Le chunking découpe ce texte en segments cohérents, l’embedding les vectorise et les range dans le vector store, et la requête referme la chaîne : recherche sémantique parmi les vecteurs, puis génération de la réponse. Chacun de ces temps conditionne le suivant, et l’extraction détermine tout le reste — ce que l’OCR n’a pas vu, aucune recherche ne le retrouvera.

Les sections qui suivent détaillent le code des quatre étapes que vous écrirez vous-même, l’ingestion dépendant de votre système de réception.

Étape 1 : une extraction qui ne perd rien

L’appel OCR est ici configuré pour le RAG, ce qui se lit dans trois choix. Le table_format="markdown" conserve les tableaux sous une forme que le LLM saura relire. L’annotation par bounding box décrit les images, et le code remplace ensuite chaque référence ![image](image) par sa description textuelle : un graphique de répartition du chiffre d’affaires devient une phrase indexable, au lieu d’un trou dans le texte. Enfin, include_image_base64=False évite de rapatrier des mégaoctets de pixels dont l’index n’a aucun usage.

import os
import base64
from mistralai import Mistral
from mistralai.extra import response_format_from_pydantic_model
from pydantic import BaseModel, Field
from typing import Optional

client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])

class DescriptionImage(BaseModel):
    type_image: str = Field(..., description="Type: graph, table, photo, logo")
    description: str = Field(..., description="Description du contenu")

def extraire_document_enrichi(chemin: str) -> list[dict]:
    """Extrait le contenu enrichi d'un document pour le RAG."""
    with open(chemin, "rb") as f:
        doc_b64 = base64.b64encode(f.read()).decode("utf-8")

    response = client.ocr.process(
        model="mistral-ocr-latest",
        document={
            "type": "document_base64",
            "document_base64": doc_b64
        },
        table_format="markdown",
        bbox_annotation_format=response_format_from_pydantic_model(
            DescriptionImage
        ),
        include_image_base64=False  # Pas besoin des images pour le RAG
    )

    pages = []
    for page in response.pages:
        contenu = page.markdown

        # Remplacer les références images par leurs descriptions
        for image in page.images:
            if image.annotation:
                desc = DescriptionImage.model_validate_json(
                    image.annotation
                )
                placeholder = f"![{image.id}]({image.id})"
                remplacement = (
                    f"[{desc.type_image}: {desc.description}]"
                )
                contenu = contenu.replace(placeholder, remplacement)

        pages.append({
            "page": page.index + 1,
            "contenu": contenu,
            "header": page.header or "",
            "footer": page.footer or "",
        })

    return pages

Étape 2 : découper là où le document le permet

Un découpage tous les mille caractères coupe au milieu d’une phrase et sépare un titre de son contenu. Puisque l’OCR vous rend du Markdown, autant s’appuyer sur sa structure : la fonction segmente d’abord sur les titres de niveau deux, ne conserve la section entière que si elle tient dans la taille maximale, et redescend au paragraphe seulement lorsqu’elle déborde. Chaque chunk garde ainsi son titre de section, ce qui améliore nettement la pertinence de la recherche sémantique.

def chunker_par_sections(
    texte: str,
    taille_max: int = 1000,
    chevauchement: int = 200
) -> list[str]:
    """Découpe le texte en respectant les sections Markdown."""
    sections = texte.split("\n## ")
    chunks = []

    for section in sections:
        if not section.strip():
            continue

        # Reconstituer le titre de section
        if not section.startswith("## "):
            section = "## " + section

        if len(section) <= taille_max:
            chunks.append(section)
        else:
            # Sous-découpage par paragraphes
            paragraphes = section.split("\n\n")
            chunk_courant = ""

            for para in paragraphes:
                if len(chunk_courant) + len(para) > taille_max:
                    if chunk_courant:
                        chunks.append(chunk_courant.strip())
                    chunk_courant = para
                else:
                    chunk_courant += "\n\n" + para

            if chunk_courant:
                chunks.append(chunk_courant.strip())

    return chunks

Étape 3 : vectoriser et rechercher

L’indexation vectorise chaque chunk avec mistral-embed et conserve, à côté du vecteur, le nom du document et l’identifiant du chunk — sans ces métadonnées, vous pourrez répondre mais pas citer vos sources. La recherche vectorise la question de la même façon, puis calcule une similarité cosinus normalisée par les normes des deux vecteurs, ce qui évite qu’un chunk long l’emporte simplement parce qu’il est long.

import numpy as np

def creer_index(documents: dict[str, list[dict]]) -> list[dict]:
    """Crée un index vectoriel à partir des documents extraits."""
    index = []

    for nom_doc, pages in documents.items():
        # Concaténer toutes les pages
        texte_complet = "\n\n".join(p["contenu"] for p in pages)

        # Chunking
        chunks = chunker_par_sections(texte_complet)

        for i, chunk in enumerate(chunks):
            # Embedding
            response = client.embeddings.create(
                model="mistral-embed",
                inputs=[chunk]
            )

            index.append({
                "document": nom_doc,
                "chunk_id": i,
                "texte": chunk,
                "embedding": np.array(response.data[0].embedding),
                "page": pages[0]["page"] if pages else 0,
            })

    return index


def rechercher_chunks(
    question: str,
    index: list[dict],
    top_k: int = 5
) -> list[dict]:
    """Recherche les chunks les plus pertinents."""
    response = client.embeddings.create(
        model="mistral-embed",
        inputs=[question]
    )
    q_emb = np.array(response.data[0].embedding)

    scores = []
    for item in index:
        similarite = np.dot(q_emb, item["embedding"]) / (
            np.linalg.norm(q_emb) * np.linalg.norm(item["embedding"])
        )
        scores.append((similarite, item))

    scores.sort(key=lambda x: x[0], reverse=True)
    return [item for _, item in scores[:top_k]]

Étape 4 : générer une réponse sourcée

La dernière brique assemble les cinq meilleurs chunks en un contexte, chacun préfixé de son document et de son numéro, et impose au modèle une consigne stricte : répondre uniquement à partir du contexte fourni et citer les sources. C’est ce système prompt qui distingue un assistant documentaire d’un modèle qui improvise, et c’est la raison pour laquelle vous avez conservé les métadonnées à l’indexation.

def repondre_question(question: str, index: list[dict]) -> str:
    """Répond à une question en utilisant le RAG."""
    # Recherche des chunks pertinents
    resultats = rechercher_chunks(question, index, top_k=5)

    # Construction du contexte
    contexte = "\n\n---\n\n".join(
        f"[Source: {r['document']}, Chunk {r['chunk_id']}]\n{r['texte']}"
        for r in resultats
    )

    # Génération de la réponse
    response = client.chat.complete(
        model="mistral-small-latest",
        messages=[
            {
                "role": "system",
                "content": (
                    "Vous êtes un assistant documentaire. Répondez "
                    "aux questions en vous basant uniquement sur le "
                    "contexte fourni. Citez les sources."
                )
            },
            {
                "role": "user",
                "content": (
                    f"Contexte :\n{contexte}\n\n"
                    f"Question : {question}"
                )
            }
        ]
    )

    return response.choices[0].message.content

Du prototype au vector store

L’index en mémoire tient tant que vous restez sous quelques milliers de chunks. Au-delà, la recherche linéaire s’effondre et rien ne survit à un redémarrage : passez à un vrai vector store (Pinecone, Weaviate, Qdrant, ChromaDB). La bascule est peu coûteuse, comme le montre cette variante ChromaDB où seuls le stockage et la requête changent — vos fonctions d’extraction et de chunking, elles, restent intactes.

# Exemple avec ChromaDB
import chromadb

chroma_client = chromadb.Client()
collection = chroma_client.create_collection(name="documents")

# Indexation
for item in index:
    collection.add(
        ids=[f"{item['document']}_{item['chunk_id']}"],
        embeddings=[item["embedding"].tolist()],
        documents=[item["texte"]],
        metadatas=[{"document": item["document"]}]
    )

# Recherche
resultats = collection.query(
    query_embeddings=[q_emb.tolist()],
    n_results=5
)

Points clés à retenir

  • Le pipeline OCR + RAG convertit vos documents en une base de connaissances interrogeable
  • L’enrichissement par annotations (descriptions d’images) évite la perte d’information
  • Le chunking intelligent respecte les sections et paragraphes du document
  • Utilisez un vrai vector store en production (ChromaDB, Pinecone, Qdrant)
  • La qualité de l’extraction OCR détermine la qualité des réponses RAG