Aller au contenu principal

QnA multi-documents

Mis à jour le 29 juillet 2026

Quand la réponse est répartie sur plusieurs fichiers

Dans de nombreux cas professionnels, la réponse à une question ne se trouve pas dans un seul document. Vous devez comparer des contrats, croiser des factures avec des bons de commande, ou analyser une série de rapports. Trois stratégies existent, et le bon réflexe consiste à choisir en fonction du volume plutôt que de partir d’emblée sur la plus sophistiquée.

Tout envoyer dans un seul appel

La méthode la plus directe consiste à empiler plusieurs blocs document_url dans le même message. Le modèle voit les deux contrats côte à côte et peut réellement les comparer, ce qu’aucun traitement document par document ne permettrait. On passe ici sur mistral-large-latest parce que la comparaison relève du raisonnement, pas de la simple lecture.

import os
from mistralai import Mistral

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

response = client.chat.complete(
    model="mistral-large-latest",
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": (
                        "Compare les conditions de paiement "
                        "de ces deux contrats."
                    )
                },
                {
                    "type": "document_url",
                    "document_url": "https://exemple.com/contrat-A.pdf"
                },
                {
                    "type": "document_url",
                    "document_url": "https://exemple.com/contrat-B.pdf"
                }
            ]
        }
    ]
)

print(response.choices[0].message.content)

La contrainte apparaît vite : la somme des documents ne doit pas dépasser la fenêtre de contexte du modèle. Deux plaquettes de dix pages passent sans difficulté ; deux contrats-cadres de cent pages, non.

Séparer l’extraction de l’interrogation

Pour les gros volumes, la stratégie recommandée dissocie les deux temps. Vous passez chaque document à l’OCR, vous conservez le Markdown obtenu, puis vous construisez vous-même le contexte envoyé au LLM. L’intérêt est le contrôle : la troncature à 5 000 caractères visible dans l’exemple, le nom de chaque document conservé en titre de section, le message système qui campe le rôle d’assistant juridique. Vous décidez précisément de ce qui entre dans la fenêtre de contexte, au lieu de le subir.

import os
from mistralai import Mistral

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

def extraire_texte(url: str) -> str:
    """Extrait le texte d'un document via OCR."""
    response = client.ocr.process(
        model="mistral-ocr-latest",
        document={"type": "document_url", "document_url": url}
    )
    return "\n\n".join(p.markdown for p in response.pages)


# Extraction OCR de chaque document
documents = {
    "Contrat A": extraire_texte("https://exemple.com/contrat-A.pdf"),
    "Contrat B": extraire_texte("https://exemple.com/contrat-B.pdf"),
    "Contrat C": extraire_texte("https://exemple.com/contrat-C.pdf"),
}

# Construction du prompt avec le contexte
contexte = "\n\n---\n\n".join(
    f"## {nom}\n\n{texte[:5000]}"  # Limiter si nécessaire
    for nom, texte in documents.items()
)

response = client.chat.complete(
    model="mistral-large-latest",
    messages=[
        {
            "role": "system",
            "content": (
                "Vous êtes un assistant juridique. Analysez les "
                "documents fournis et répondez aux questions."
            )
        },
        {
            "role": "user",
            "content": (
                f"Voici les documents :\n\n{contexte}\n\n"
                f"Question : Quel contrat offre les meilleures "
                f"conditions de résiliation anticipée ?"
            )
        }
    ]
)

Passer au RAG documentaire

Au-delà de quelques dizaines de documents, aucune fenêtre de contexte ne suivra. Il faut alors ne plus envoyer que les passages pertinents, ce qui suppose de découper les textes, de les vectoriser avec mistral-embed, puis de rechercher par similarité au moment de la question. Le découpage avec chevauchement de 200 caractères évite qu’une phrase coupée en deux perde son sens entre deux chunks, et le top_k détermine combien de passages accompagneront finalement la question.

import os
from mistralai import Mistral

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

def creer_embeddings(texte: str) -> list[float]:
    """Crée les embeddings d'un texte."""
    response = client.embeddings.create(
        model="mistral-embed",
        inputs=[texte]
    )
    return response.data[0].embedding


def chunker(texte: str, taille: int = 1000, chevauchement: int = 200) -> list[str]:
    """Découpe un texte en chunks avec chevauchement."""
    chunks = []
    debut = 0
    while debut < len(texte):
        fin = debut + taille
        chunk = texte[debut:fin]
        chunks.append(chunk)
        debut = fin - chevauchement
    return chunks


# Pipeline RAG simplifié
def indexer_document(nom: str, texte: str) -> list[dict]:
    """Indexe un document pour la recherche."""
    chunks = chunker(texte)
    index = []

    for i, chunk in enumerate(chunks):
        embedding = creer_embeddings(chunk)
        index.append({
            "document": nom,
            "chunk_id": i,
            "texte": chunk,
            "embedding": embedding
        })

    return index


def rechercher(question: str, index: list[dict], top_k: int = 5) -> list[dict]:
    """Recherche les chunks les plus pertinents."""
    q_embedding = creer_embeddings(question)

    # Calcul de similarité cosinus
    import numpy as np
    scores = []
    for item in index:
        score = np.dot(q_embedding, item["embedding"])
        scores.append((score, item))

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

Résumons le raisonnement de sélection. L’envoi de plusieurs documents dans un seul appel reste imbattable de simplicité et convient à deux ou trois documents courts. L’OCR séparé suivi du QnA apporte la flexibilité et vous rend maître de ce qui est envoyé au LLM, au prix d’un peu de code. Le RAG documentaire, plus lourd à mettre en place, devient indispensable dès que le corpus grossit, parce qu’il est le seul à passer à l’échelle.

Un workflow de comparaison de bout en bout

Les deux techniques se marient : rien n’interdit d’extraire d’abord des données structurées par annotation, puis de confier ces données au LLM pour l’analyse. Sur un lot de factures, chaque document est réduit à trois champs — fournisseur, montant, date — avant qu’une seule requête ne demande au modèle d’y repérer tendances et anomalies. Le coût en tokens devient négligeable, et l’analyse porte sur des chiffres extraits proprement plutôt que sur du texte interprété au vol.

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

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

class ResumeFacture(BaseModel):
    fournisseur: str = Field(..., description="Nom du fournisseur")
    montant_ttc: float = Field(..., description="Montant TTC")
    date: str = Field(..., description="Date (YYYY-MM-DD)")

def comparer_factures(chemins: list[str]) -> str:
    """Compare plusieurs factures et retourne une analyse."""
    import base64

    factures = []
    for chemin in chemins:
        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
            },
            document_annotation_format=response_format_from_pydantic_model(
                ResumeFacture
            )
        )

        for page in response.pages:
            if page.document_annotation:
                facture = ResumeFacture.model_validate_json(
                    page.document_annotation
                )
                factures.append(facture)

    # Analyse comparative via LLM
    resume = "\n".join(
        f"- {f.fournisseur}: {f.montant_ttc}€ ({f.date})"
        for f in factures
    )

    response = client.chat.complete(
        model="mistral-small-latest",
        messages=[
            {
                "role": "user",
                "content": (
                    f"Voici {len(factures)} factures :\n{resume}\n\n"
                    f"Analyse les tendances et anomalies."
                )
            }
        ]
    )

    return response.choices[0].message.content

Points clés à retenir

  • Trois approches pour le QnA multi-documents : inline, OCR séparé, RAG
  • L’approche inline convient pour 2-3 documents courts
  • L’OCR séparé + QnA offre plus de contrôle sur les tokens consommés
  • Le RAG est indispensable pour les corpus importants (dizaines de documents)
  • Combinez les annotations structurées avec le QnA pour des analyses précises