Récits de construction21 juillet 202650 min de lecture

Construire un système RAG de qualité production en Python avec FastAPI et LangChain

Un journal de construction en 14 parties pour un vrai service RAG : recherche hybride, reranking, multi-tenant JWT, tâches de fond, cache, et les bugs silencieux attrapés en chemin.

Le pipeline RAG : téléversement, découpage, embeddings, recherche hybride, reranking, réponse avec citations

La plupart des tutoriels RAG s'arrêtent à « charger un PDF, poser une question, afficher une réponse ». C'est une démo, pas un système. Il n'y a ni authentification, ni citations de sources dignes de confiance, aucun moyen de savoir si la recherche est bonne, et tout s'écroule dès que deux utilisateurs le partagent.

Cette série construit l'autre chose : un service RAG que vous pourriez réellement mettre devant des utilisateurs. Téléversez des fichiers PDF, DOCX ou TXT en arabe ou en anglais, posez des questions en langage naturel, et obtenez des réponses avec des citations qui pointent vers un vrai document et un vrai numéro de page. En chemin, vous ajouterez la recherche hybride, le reranking, l'authentification JWT multi-tenant, le traitement en arrière-plan, le cache et le tracing.

Nous le construirons dans l'ordre qu'un ingénieur expérimenté suivrait vraiment : d'abord faire fonctionner une tranche fine de bout en bout, puis améliorer chaque pièce une fois qu'on sent ce qui manque.

Tout ce qui suit est vérifié en l'exécutant. Là où un paramètre ne fait silencieusement rien, ou une bibliothèque vous rend discrètement la mauvaise réponse, je montre la sortie qui le prouve, parce que ce sont ces bugs-là qui survivent jusqu'en production.

Code : le projet fini est open source sur github.com/Helmo21/RAG. Clonez-le pour tout exécuter, ou suivez ci-dessous et construisez-le vous-même, partie par partie.

Sommaire

Ce que vous construisez

RAG signifie Retrieval-Augmented Generation (génération augmentée par la recherche). L'idée est simple : les modèles de langage ne connaissent pas vos documents privés, et les affiner sur vos documents est lent et coûteux. Alors, à la place, quand un utilisateur pose une question, vous récupérez la poignée de passages les plus pertinents pour cette question et vous les collez dans le prompt. Le modèle répond en utilisant le texte que vous lui avez donné.

Cette étape de « récupération des passages pertinents » est là où vit toute l'ingénierie, et c'est l'essentiel du sujet de cette série.

Voici l'architecture finale. Les parties 1 et 2 couvrent les deux premières boîtes :

Upload  ->  Load & chunk  ->  Embed  ->  Vector store
                                              |
Question  ->  Hybrid search (BM25 + vectors)  ->  Rerank  ->  LLM  ->  Answer + citations

La stack : FastAPI pour l'API, LangChain pour le chargement des documents et le chaînage, text-embedding-3-small pour les embeddings, Chroma pour le stockage vectoriel local, LangSmith pour le tracing, et RAGAS pour l'évaluation.

Prérequis

Il vous faudra :

  • Python 3.11 ou plus récent. Je suis en 3.12.
  • Une clé d'API OpenAI, depuis platform.openai.com. Les parties 1 et 2 n'appellent pas l'API, mais la partie 3 si, et nous mettons la clé en place dès maintenant.
  • De l'aisance avec Python. Vous n'avez besoin de rien savoir du RAG, des embeddings ou des bases vectorielles : c'est ce que ceci explique. Mais vous devez savoir lire une classe et une fonction.
  • Docker, à terme, pour la dernière partie. Pas nécessaire tout de suite.

Partie 1 : squelette de projet et configuration

Avant toute IA, il faut un endroit où la mettre. Cette partie construit un squelette FastAPI exécutable, avec une configuration et une gestion des secrets correctes. Elle est courte, mais deux des idées qu'elle contient évitent des bugs réellement pénibles plus tard.

Mise en place du projet

Créez le projet et un environnement virtuel :

mkdir rag && cd rag
python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install fastapi uvicorn pydantic-settings

Vous visez cette arborescence :

rag/
├── app/
│   ├── __init__.py
│   ├── main.py          # FastAPI app and routes
│   └── config.py        # settings loaded from the environment
├── .env                 # your real secrets - never committed
├── .env.example         # a template - safe to commit
├── .gitignore
└── requirements.txt

Un détail qui piège tout le monde : __init__.py prend deux tirets bas de chaque côté. Un seul (_init_.py) est presque identique à l'œil, et Python 3 semblera fonctionner quand même grâce aux packages à espace de noms implicite — jusqu'au moment où les imports entre vos modules se mettent à échouer pour des raisons incompréhensibles.

Gérer les secrets correctement

Votre clé OpenAI coûte de l'argent réel, et les clés fuitent en permanence par l'historique git. La convention qui l'évite tient en trois parties.

D'abord, .env contient vos vraies valeurs et n'est jamais commité :

RAG_OPENAI_API_KEY=sk-your-real-key-here

Ensuite, .env.example contient les mêmes clés sans aucune valeur, et est commité, pour que quiconque clone votre dépôt sache quoi remplir :

RAG_OPENAI_API_KEY=""

Enfin, .gitignore tient la vraie à l'écart :

.env
.venv
__pycache__/

Un piège subtil ici : n'ajoutez pas .gitignore à .gitignore. Ça paraît propre, mais .gitignore doit être commité : c'est ainsi que les règles d'exclusion parviennent à tous ceux qui clonent le dépôt. Si vous l'ignorez, il disparaît entièrement du champ de vision de git et n'apparaîtra même pas comme non suivi.

Configuration typée avec pydantic-settings

Vous pourriez lire la clé avec os.getenv("OPENAI_API_KEY") partout où vous en avez besoin. Ne le faites pas. Éparpillez os.getenv dans une base de code et une variable manquante devient None, qui se propage en aval et ressort en erreur déroutante, très loin de la cause réelle.

À la place, définissez la configuration une seule fois, avec des types, et laissez-la se valider au démarrage. Créez app/config.py :

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="RAG_",
        env_file=".env",
        env_file_encoding="utf-8",
    )

    openai_api_key: str


settings = Settings()

Petit fichier, quatre idées qui méritent d'être comprises :

  • `BaseSettings` est un modèle pydantic qui se remplit à partir des variables d'environnement au lieu d'arguments que vous lui passez.
  • `env_prefix="RAG_"` met vos variables dans un espace de noms. Le champ openai_api_key combiné au préfixe RAG_ correspond à la variable d'environnement RAG_OPENAI_API_KEY. Le préfixe évite que votre configuration entre en collision avec des variables sans rapport qui porteraient le même nom.
  • `env_file=".env"` est la ligne que les gens oublient. Sans elle, pydantic-settings ne lit que les vraies variables d'environnement et ignore complètement votre fichier .env. Votre clé est là, correctement écrite, et n'est jamais chargée.
  • `settings = Settings()` au niveau du module instancie la configuration exactement une fois, à l'import. C'est ce qui vous donne l'échec immédiat : une clé manquante lève tout de suite, au démarrage, au lieu de devenir un 401 déroutant au fond d'une bibliothèque, trois parties plus loin.

Remarquez que openai_api_key: str n'a besoin ni de Field(...) ni d'un nom de variable d'environnement explicite. Dans pydantic-settings v2, le nom de la variable est dérivé du nom du champ plus le préfixe. La simple annotation de type fait tout le travail.

Le piège du passage de pydantic v1 à v2

Cela mérite sa propre section, parce que sinon cela vous coûtera une heure.

Cherchez des exemples pydantic-settings sur le web et vous trouverez quantité de code de ce genre :

# This is pydantic v1 syntax. It does NOT work in v2.
openai_api_key: str = Field(..., env="OPENAI_API_KEY")

En v2, l'argument env= de Field est silencieusement ignoré : le nom vient du champ plus env_prefix. Les blogs, les vieilles réponses Stack Overflow et les modèles de langage entraînés sur les deux regorgent de syntaxe v1 présentée comme actuelle.

L'habitude qui vous sauve, et elle prend dix secondes : demandez à la bibliothèque ce qu'elle contient vraiment au lieu de faire confiance à un tutoriel :

python -c "import pydantic_settings; print([n for n in dir(pydantic_settings) if not n.startswith('_')])"
['AWSSecretsManagerSettingsSource', 'AzureKeyVaultSettingsSource', 'BaseSettings',
 'CliApp', 'DotEnvSettingsSource', 'EnvSettingsSource', 'SettingsConfigDict', ...]

Il y a BaseSettings et SettingsConfigDict, et aucune classe Settings à importer. Si l'autocomplétion de votre éditeur ou un assistant IA vous suggère un import qui n'est pas dans cette liste, il n'existe pas. Prenez l'habitude dès maintenant : elle compte beaucoup plus en partie 2, où la surface d'API de LangChain est énorme et change souvent.

Le endpoint de santé

app/main.py est délibérément minuscule :

from fastapi import FastAPI

from app.config import settings

app = FastAPI()


@app.get("/health")
async def root():
    return {"status": "ok"}

L'import de settings a l'air inutilisé, et c'est justement le point. L'importer force Settings() à s'exécuter au démarrage, si bien qu'une configuration cassée tue l'application immédiatement au lieu de rôder. Un endpoint de santé qui renvoie 200 alors que l'application est mal configurée est pire que pas de endpoint de santé du tout.

Notez que l'import est from app.config import settings et non from config import settings. Vous lancez l'application via app.main:app, ce qui fait de app la racine du package : les imports se résolvent donc à partir de là.

Vérifier la partie 1

Lancez-la :

uvicorn app.main:app --reload
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Application startup complete.
curl http://127.0.0.1:8000/health
{"status":"ok"}

Vérifiez maintenant ce qui compte vraiment : que la clé se charge bien depuis `.env` :

python -c "from app.config import settings; print('KEY LOADED:', bool(settings.openai_api_key))"
KEY LOADED: True

Affichez un booléen, jamais la clé elle-même, pas même les premiers caractères. Les fragments de clé dans les logs et l'historique du terminal sont un vrai vecteur de fuite.

Enfin, prouvez le comportement d'échec immédiat en exécutant depuis un répertoire sans .env et sans variable définie :

pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
openai_api_key
  Field required [type=missing, input_value={}, input_type=dict]

Voilà le bénéfice. Un déploiement mal configuré meurt au démarrage avec un message précis nommant le champ exact, au lieu d'échouer mystérieusement au milieu d'une requête.

Figez ce que vous avez, et commitez :

pip freeze > requirements.txt   # then trim it to your direct dependencies
git add . && git commit -m "Part 1: scaffold and config"
git show --stat HEAD            # confirm .env is NOT in the list

Partie 2 : transformer un PDF en fragments interrogeables

Le RAG commence maintenant. L'objectif de cette partie : un PDF entre, et une liste de fragments de texte sort, chacun sachant de quel fichier et de quelle page il provient. Pas encore d'embeddings, aucun appel à une IA.

Pourquoi le découpage existe

La question évidente d'abord : pourquoi ne pas encoder le document entier en un seul vecteur et en rester là ?

Les limites du modèle. text-embedding-3-small accepte environ 8 191 tokens. Un PDF de 50 pages n'y tient pas. C'est la raison ennuyeuse.

La précision de la recherche, la vraie raison. Un embedding comprime le sens en un point unique de l'espace. Encodez un rapport entier de 50 pages et vous obtenez le sens moyen de tout le rapport : un centroïde flou, vaguement proche de tout et précisément de rien. Demandez « quel était le chiffre d'affaires du T3 ? » et il ne peut pas vous montrer la phrase, parce que cette phrase a été moyennée avec 49 autres pages parlant de recrutement et de logistique. Les fragments restent spécifiques : un fragment sur le chiffre d'affaires du T3 se place près de cette question dans l'espace vectoriel.

Le coût et le contexte. Vous n'envoyez au modèle que les fragments pertinents, pas le livre entier. C'est la différence entre quelques centaines de tokens et quelques centaines de milliers.

Les citations. Vous ne pouvez pas citer « page 34 » si votre unité de recherche est le document entier. Le fragment est l'unité de citation. C'est pourquoi le travail sur les métadonnées dans cette partie compte tellement : c'est lui qui rend possibles les citations de la partie 4.

Taille de fragment et recouvrement

Deux paramètres, et vous devriez pouvoir justifier les deux.

La taille de fragment arbitre entre contexte et précision :

  • Trop petit (disons 100 caractères) vous donne des fragments de phrase. Le contexte est perdu, et une réponse qui s'étend sur deux phrases se retrouve coupée entre des fragments qui ont chacun l'air hors sujet.
  • Trop grand (disons 5 000 caractères) ramène le problème de la moyenne floue, et vous brûlez des tokens à expédier du texte non pertinent au modèle.
  • Un point de départ raisonnable tourne autour de 1 000 caractères. Rien de sacré : un défaut à partir duquel mesurer.

Le recouvrement corrige un échec précis. Imaginez une frontière de fragment tombant en plein milieu d'une phrase :

chunk A: "...quarterly revenue grew to"
chunk B: "$4.2M in Q3."

Aucun des deux fragments ne peut répondre à « quel était le chiffre d'affaires du T3 ? » : le fait a été guillotiné. Le recouvrement répète la fin d'un fragment au début du suivant, de sorte que les idées à cheval sur une frontière survivent intactes dans au moins un fragment. Le coût est une légère duplication. Cela en vaut la peine.

Nous commencerons avec 1 000 et 200, puis nous mesurerons si le recouvrement est réel. Divulgâchage : le choix naïf vous en donne silencieusement zéro.

Installer les paquets d'ingestion

Trois paquets, et il vaut la peine de savoir pourquoi chacun existe : LangChain est délibérément découpé en petits morceaux pour ne pas vous faire installer le monde entier :

pip install pypdf langchain-community langchain-text-splitters
  • `pypdf` est le véritable analyseur de PDF. C'est lui qui fait le vrai travail de lire les octets et d'en extraire le texte. LangChain n'analyse pas les PDF lui-même ; il délègue.
  • `langchain-community` contient PyPDFLoader, un fin adaptateur qui enveloppe pypdf dans le format Document de LangChain.
  • `langchain-text-splitters` contient RecursiveCharacterTextSplitter. Il est autonome et n'a besoin d'aucun LLM.

Importez-les et vous remarquerez quelque chose :

DeprecationWarning: `langchain-community` is being sunset and is no longer actively
maintained. See ... for migration guidance toward standalone integration packages.

langchain-community est en voie de disparition, et à l'heure où j'écris il n'existe pas de paquet autonome de chargeur PDF pour le remplacer. Nous l'utiliserons donc, mais en l'enveloppant derrière notre propre fonction, pour que le remplacer plus tard ne touche qu'un seul fichier. C'est la réponse pratique à une dépréciation qu'on ne peut pas encore éviter.

Fabriquer un PDF d'exemple

Il vous faut un PDF où vous pouvez vérifier les numéros de page sans ambiguïté : mettez donc un fait distinctif sur chaque page. Ce script est une fixture de test, pas une partie de votre application :

pip install reportlab

scripts/make_sample_pdf.py :

"""Generate a small sample PDF for testing ingestion."""

from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate

OUTPUT = "sample.pdf"

PAGES = [
    (
        "PAGE ONE - COMPANY HISTORY",
        "The Zorblatt Corporation was founded in 1987 in Helsinki, Finland. "
        "It began as a small workshop producing precision instruments for the "
        "shipping industry. Over the following decade the company expanded "
        "steadily across the Nordic region, opening offices in Stockholm and "
        "Oslo. By 1999 it employed over four hundred people and had become a "
        "recognised supplier of navigation equipment. The founder, Erik "
        "Lindqvist, remained closely involved in daily operations until his "
        "retirement. The early culture of the company emphasised engineering "
        "rigour above rapid growth, a philosophy that shaped its later "
        "decisions and gave it a reputation for reliability among customers.",
    ),
    (
        "PAGE TWO - FINANCIAL RESULTS",
        "Zorblatt reported revenue of 4.2 million euros in Q3 2025, an "
        "increase of eleven percent compared with the same quarter of the "
        "previous year. Operating margin improved to nineteen percent, driven "
        "largely by the maritime sensors division. The board attributed the "
        "result to disciplined cost control and stronger demand in the Baltic "
        "market. Cash reserves at the end of the quarter stood at 8.9 million "
        "euros. Analysts noted that the company had reduced its debt load for "
        "the sixth consecutive quarter, leaving it well positioned to fund "
        "further research without external financing in the coming year.",
    ),
    (
        "PAGE THREE - LEADERSHIP",
        "The company's chief executive is Dr. Amina Rashid, appointed in 2023 "
        "after eight years leading the research division. She holds a doctorate "
        "in control systems engineering and has published widely on autonomous "
        "navigation. Under her leadership the company launched its first "
        "software subscription product, marking a shift away from pure hardware "
        "sales. The executive team was restructured in early 2024 to add a "
        "dedicated chief technology officer. Dr. Rashid has stated that her "
        "primary objective is to double recurring revenue by the end of 2027 "
        "while preserving the engineering culture the company was built on.",
    ),
]


def main() -> None:
    doc = SimpleDocTemplate(OUTPUT, pagesize=A4)
    styles = getSampleStyleSheet()

    story = []
    for index, (heading, body) in enumerate(PAGES):
        story.append(Paragraph(heading, styles["Heading1"]))
        story.append(Paragraph(body, styles["BodyText"]))
        if index < len(PAGES) - 1:
            story.append(PageBreak())

    doc.build(story)
    print(f"Wrote {OUTPUT} with {len(PAGES)} pages")


if __name__ == "__main__":
    main()

SimpleDocTemplate construit le fichier, Paragraph enveloppe chaque bloc de texte dans un style, et PageBreak force la page suivante : c'est ce qui garantit un fait par page.

python scripts/make_sample_pdf.py
Wrote sample.pdf with 3 pages

Trois pages, chacune avec un fait reconnaissable entre mille : la page 1 dit fondée en 1987, la page 2 donne un chiffre d'affaires de 4,2 millions, la page 3 nomme le PDG.

Regarder ce que le chargeur vous rend

Avant de découper quoi que ce soit, regardez la sortie brute du chargeur. C'est la chose la plus importante de la partie 2 :

from langchain_community.document_loaders import PyPDFLoader

docs = PyPDFLoader("sample.pdf").load()

print(f"Loader returned {len(docs)} Document objects")
for d in docs:
    print(f"metadata: {d.metadata}")
    print(f"text starts: {d.page_content[:55]!r}")
Loader returned 3 Document objects
metadata: {'producer': 'ReportLab PDF Library - (opensource)', 'creator': '(unspecified)',
 'creationdate': '2026-07-15T09:40:06+07:00', 'author': '(anonymous)', 'keywords': '',
 'moddate': '2026-07-15T09:40:06+07:00', 'subject': '(unspecified)', 'title': '(anonymous)',
 'trapped': '/False', 'source': 'sample.pdf', 'total_pages': 3, 'page': 0, 'page_label': '1'}
text starts: 'PAGE ONE - COMPANY HISTORY\nThe Zorblatt Corporation was'
...

Trois choses dans cette sortie.

Le chargeur renvoie un `Document` par page, pas un par fichier. Un PDF de 3 pages donne 3 Documents. C'est délibéré : le travail du chargeur est de préserver la structure naturelle du document et d'attacher les métadonnées avant tout découpage. L'identité de la page est capturée ici, ou perdue pour toujours.

`page` est indexé à partir de 0. Regardez bien : la page dont le texte dit littéralement PAGE ONE rapporte 'page': 0. Construisez vos citations sur ce champ et votre application dira aux utilisateurs, avec assurance, « voir page 0 », ou citera la page 2 pour quelque chose qu'un humain lit page 3. C'est plausible, silencieux et faux : le pire type de bug, parce que la réponse a l'air juste et que personne ne vérifie.

page_label est l'étiquette imprimée sur la page, ici la chaîne '1'. C'est une chaîne parce que les vrais documents ont des pages étiquetées 'iv', 'A-1' ou 'ix'. Les étiquettes de page ne sont pas toujours des nombres, et c'est exactement pour cela que le champ existe à côté de page.

Les métadonnées sont bruitées. producer, trapped, creationdate : des scories de la spécification PDF. Vous ne voulez pas voir cela traverser votre pipeline jusqu'au magasin de vecteurs. Nous ne garderons que ce dont nous avons besoin.

Découper en fragments

Faites maintenant entrer le découpeur et observez ce qui arrive aux métadonnées :

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=80)
chunks = splitter.split_documents(docs)

print(f"{len(docs)} pages -> {len(chunks)} chunks")
for i, c in enumerate(chunks):
    print(f'[{i}] page={c.metadata["page"]} len={len(c.page_content)}')
    print(f"    {c.page_content[:70]!r}")
3 pages -> 6 chunks
[0] page=0 len=398
    'PAGE ONE - COMPANY HISTORY\nThe Zorblatt Corporation was founded in 198'
[1] page=0 len=293
    'equipment. The founder, Erik Lindqvist, remained closely involved in d'
[2] page=1 len=326
    'PAGE TWO - FINANCIAL RESULTS\nZorblatt reported revenue of 4.2 million '
[3] page=1 len=301
    'and stronger demand in the Baltic market. Cash reserves at the end of '
...

Les métadonnées se propagent automatiquement. split_documents() copie les métadonnées de chaque page parente sur chacun de ses fragments enfants. Le fragment [3] est un morceau du milieu de la page 2 et sait toujours qu'il est page 2. Cette propagation est tout le mécanisme de citation : la partie 4 ne construira pas les citations, elle se contentera de lire ce que vous avez préservé ici.

Les tailles de fragments varient — 398, 293, 326, et non un uniforme 400. C'est le découpeur qui fait son travail : chunk_size est un plafond, pas une cible. C'est aussi le sens de « récursif » : il essaie d'abord le séparateur le plus grossier — paragraphe, puis phrase, puis mot — et ne descend à un plus fin que si un morceau est encore trop gros. L'effet est qu'il coupe sur des coutures sémantiques naturelles plutôt qu'aveuglément au caractère 400. Le simple CharacterTextSplitter ne fait pas cela, et c'est pourquoi le récursif est le choix par défaut raisonnable.

Les fragments ne chevauchent jamais deux pages, parce que nous découpons des Documents page par page. Chaque fragment a exactement une page, sans ambiguïté : conséquence gratuite du fait de charger avant de découper.

Le paramètre de recouvrement qui ne fait silencieusement rien

Regardez la frontière entre le fragment 0 et le fragment 1 ci-dessus. Le fragment 0 se termine par "...supplier of navigation" et le fragment 1 commence par "equipment. The founder...". C'est contigu, pas chevauchant, et nous avions demandé chunk_overlap=80.

Mesurez-le plutôt que de plisser les yeux. Ceci trouve le plus long suffixe de A qui est aussi un préfixe de B :

def overlap_len(a, b):
    for n in range(min(len(a), len(b)), 0, -1):
        if a[-n:] == b[:n]:
            return n
    return 0

for sz, ov in [(400, 80), (400, 0), (200, 100)]:
    ch = RecursiveCharacterTextSplitter(chunk_size=sz, chunk_overlap=ov).split_documents(docs)
    pairs = [(ch[i], ch[i+1]) for i in range(len(ch)-1)
             if ch[i].metadata["page"] == ch[i+1].metadata["page"]]
    print(f"size={sz} overlap={ov} -> {[overlap_len(a.page_content, b.page_content) for a, b in pairs]}")
size=400 overlap=80  -> [0, 0, 0]
size=400 overlap=0   -> [0, 0, 0]
size=200 overlap=100 -> [95, 96, 93, 84, 95, 96, 97, 97, 0, 0, 98, 96, 86, 91, 98, 92]

Demander 80 donne zéro. Demander 100 donne environ 95. Pourquoi ?

Regardez les longueurs de ligne que produit l'extraction PDF :

print([len(l) for l in docs[0].page_content.split("\n")])
[26, 95, 96, 93, 84, 95, 96, 100]

Le voilà. L'extraction de texte d'un PDF insère un retour à la ligne à chaque retour visuel, si bien que le texte arrive en lignes d'environ 95 caractères. Et voici le mécanisme crucial :

`chunk_overlap` s'applique en unités entières, pas en caractères. Le découpeur casse le texte en unités selon le séparateur, ici "\n", puis fusionne les unités en fragments. Pour le recouvrement, il conserve les unités de fin du fragment précédent tant que leur total reste sous `chunk_overlap`.

  • Avec chunk_overlap=80 : une ligne fait environ 95 caractères. 95 > 80, donc pas même une seule ligne ne tient dans le budget. Tout est écarté, et vous obtenez zéro recouvrement. Vous aviez demandé 80 et vous avez silencieusement reçu rien.
  • Avec chunk_overlap=100 : une ligne tient, vous obtenez donc environ 95 caractères de recouvrement. Et remarquez que les valeurs mesurées [95, 96, 93, 84, ...] sont exactement les longueurs de ligne : le recouvrement arrive en lignes entières.

La leçon : `chunk_overlap` doit dépasser confortablement la granularité naturelle des unités de votre texte, sinon il se dégrade en rien du tout sans se plaindre. Votre application tourne toujours. La recherche est simplement moins bonne, en silence, et vous n'auriez aucune idée du pourquoi.

C'est pourquoi nous mesurons au lieu de recopier des chiffres trouvés sur un blog. Cela règle aussi nos paramètres : `chunk_size=1000`, `chunk_overlap=200`. Avec des lignes d'environ 95 caractères, 200 achète à peu près deux lignes entières de recouvrement réel.

Écrire le module d'ingestion

Passons aux choses sérieuses. app/ingestion.py :

"""Load documents from disk and split them into retrievable chunks."""

from pathlib import Path

from langchain_community.document_loaders import PyPDFLoader
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter

CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200


def _page_number(metadata: dict) -> int:
    """Return the human-facing 1-based page number."""
    label = metadata.get("page_label")
    if label is not None and str(label).isdigit():
        return int(label)
    return int(metadata.get("page", 0)) + 1


def load_and_chunk(path: str | Path) -> list[Document]:
    """Load a PDF and split it into chunks carrying citation metadata."""
    path = Path(path)
    if not path.exists():
        raise FileNotFoundError(f"No such document: {path}")
    if path.suffix.lower() != ".pdf":
        raise ValueError(f"Expected a .pdf file, got: {path.suffix or 'no extension'}")

    pages = PyPDFLoader(str(path)).load()

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE,
        chunk_overlap=CHUNK_OVERLAP,
    )
    chunks = splitter.split_documents(pages)

    cleaned: list[Document] = []
    for chunk in chunks:
        if not chunk.page_content.strip():
            continue
        cleaned.append(
            Document(
                page_content=chunk.page_content,
                metadata={
                    "source": path.name,
                    "page": _page_number(chunk.metadata),
                },
            )
        )
    return cleaned

Ce que fait chaque morceau :

  • `_page_number()` est le correctif du piège de l'indexation à partir de 0. Il préfère page_label, l'étiquette qu'un lecteur chercherait vraiment, et retombe sur page + 1 quand l'étiquette n'est pas numérique — parce que 'iv' et 'A-1' sont de vraies étiquettes de page et que int('iv') planterait.
  • La validation en amont, existence et extension, échoue tôt avec un message clair plutôt que de laisser pypdf lancer quelque chose de cryptique trois niveaux plus bas.
  • La boucle de reconstruction est là où les métadonnées sont nettoyées. Nous construisons un Document neuf avec exactement deux clés, source et page, pour qu'aucun bruit de la spécification PDF n'atteigne le magasin de vecteurs. Elle écarte aussi les fragments qui ne sont que des blancs, ce que les vrais PDF produisent plus souvent qu'on ne le voudrait.
  • `path.name` stocke sample.pdf, pas /home/vous/docs/sample.pdf. Une citation doit montrer un nom de fichier, pas l'arborescence de vos répertoires.

La frontière du module est l'autre moitié de la conception. `PyPDFLoader` n'est référencé qu'à un seul endroit. Quand langchain-community disparaîtra pour de bon, ou que vous déciderez que pypdf gère mal les tableaux et que vous voudrez PyMuPDF à la place, vous changez ce fichier et rien d'autre. En partie 6, DOCX et TXT entreront par cette même porte.

Vérifier la partie 2

Exécutez :

from app.ingestion import load_and_chunk

chunks = load_and_chunk("sample.pdf")
for i, c in enumerate(chunks):
    print(f"[{i}] {c.metadata}  len={len(c.page_content)}")
    print(f"    {c.page_content[:60]!r}")
[0] {'source': 'sample.pdf', 'page': 1}  len=692
    'PAGE ONE - COMPANY HISTORY\nThe Zorblatt Corporation was foun'
[1] {'source': 'sample.pdf', 'page': 2}  len=628
    'PAGE TWO - FINANCIAL RESULTS\nZorblatt reported revenue of 4.'
[2] {'source': 'sample.pdf', 'page': 3}  len=636
    "PAGE THREE - LEADERSHIP\nThe company's chief executive is Dr."

Le fragment dont le texte dit PAGE ONE rapporte maintenant 'page': 1. Le décalage d'une unité a disparu, et cela se vérifie à l'œil.

Mais ne vérifiez pas à l'œil. C'est exactement la classe de bug qui survit à un coup d'œil distrait : tracez donc les faits distinctifs :

facts = {"founded in 1987": 1, "4.2 million": 2, "Amina Rashid": 3}
for fact, expected in facts.items():
    hits = [c.metadata["page"] for c in chunks if fact in c.page_content]
    print(f"{'PASS' if hits == [expected] else 'FAIL'}  {fact!r} -> {hits}")
PASS  'founded in 1987'    -> [1]
PASS  '4.2 million'        -> [2]
PASS  'Amina Rashid'       -> [3]

Chaque fait tombe sur la page vers laquelle un humain se tournerait. C'est la propriété dont dépendent les citations de la partie 4, et elle est désormais fixée par un test plutôt que par un espoir.

Une réserve honnête : chaque page fait ici environ 650 caractères, confortablement sous le plafond de 1 000, donc chaque page produit exactement un fragment et rien ne se recouvre réellement dans cet exemple. Le réglage est le bon pour de vrais documents : essayez un PDF plus long si vous voulez le voir à l'œuvre.

Figer les dépendances d'après la réalité

Figez les versions que vous avez réellement exécutées, pas celles dont vous vous souvenez. Les versions saisies à la main dérivent, et un requirements.txt qui installe une version différente de celle sur laquelle vous avez développé anéantit tout l'intérêt du figeage :

pip list --format=freeze | grep -iE "^(fastapi|uvicorn|pydantic-settings|pypdf|langchain)"

requirements.txt :

# Web layer
fastapi==0.139.0
uvicorn==0.51.0
pydantic-settings==2.14.2

# Ingestion: PDF parsing + chunking
pypdf==6.14.2
langchain-core==1.4.9
langchain-text-splitters==1.1.2
langchain-community==0.4.2

reportlab est délibérément absent : il fabrique des fixtures de test et n'a rien à faire en production, où il ne ferait qu'ajouter du code d'analyse d'images à votre surface d'attaque. Sortez-le :

requirements-dev.txt :

-r requirements.txt

reportlab==5.0.0

Ajoutez aussi la fixture générée à .gitignore, puisque le script la reconstruit à la demande :

sample.pdf

Ce qu'il faut retenir de la partie 2

Vous avez maintenant un PDF qui devient des fragments propres et citables, la matière première de tout ce qui suit. Deux idées comptent plus que n'importe quel code présenté ici :

Les métadonnées attachées au moment du chargement sont ce qui rend les citations possibles. Toutes les parties suivantes ne font que les transmettre.

Les paramètres peuvent échouer en silence. chunk_overlap=80 ne faisait absolument rien, et la seule raison pour laquelle vous le savez, c'est que vous l'avez mesuré. Le RAG en est plein : le système tourne, les réponses sont simplement moins bonnes, sans bruit.

Avant de passer à la suite, essayez de lui donner un vrai PDF et vérifiez si les numéros de page correspondent toujours. Les documents avec page de garde et pages liminaires en chiffres romains sont là où page_label justifie son existence.

Partie 3 : les embeddings et le magasin de vecteurs

Des fragments de texte ne sont pas interrogeables par le sens. Cette partie les transforme en vecteurs, la représentation qui permet de demander « qu'ont-ils gagné ? » et de récupérer un passage qui n'emploie jamais le verbe « gagner ».

Ce qu'est réellement un embedding

Ôtez le mystère et un embedding est une liste de nombres :

from langchain_huggingface import HuggingFaceEmbeddings

emb = HuggingFaceEmbeddings(model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
v = emb.embed_query("What was the revenue?")

print("dimensions:", len(v))
print("first 8 values:", [round(x, 4) for x in v[:8]])
print("magnitude:", round(sum(x*x for x in v) ** 0.5, 4))
dimensions: 384
first 8 values: [0.0081, 0.2163, -0.3072, 0.0217, -0.2065, 0.0587, 0.5927, 0.4252]
magnitude: 5.6184

384 flottants. C'est tout. Un modèle d'embedding est entraîné de telle sorte que des textes de sens voisin produisent des vecteurs pointant dans des directions voisines. « Direction voisine » est quelque chose qui se calcule, et c'est ce qui rend le sens interrogeable.

Notez que la norme vaut 5,62, pas 1,0. Ce modèle renvoie des vecteurs non normalisés, alors que ceux d'OpenAI sont de norme unitaire. Ce détail va nous mordre bientôt, au moment de choisir une métrique de distance.

Voir la recherche sémantique fonctionner, et échouer

Voici le test qui compte. Comparez une requête à des phrases candidates avec la similarité cosinus, le cosinus de l'angle entre deux vecteurs, qui mesure la direction en ignorant la norme :

def cosine(a, b):
    dot = sum(x*y for x, y in zip(a, b))
    return dot / ((sum(x*x for x in a)**0.5) * (sum(x*x for x in b)**0.5))

Lancez cinq requêtes contre trois phrases candidates et regardez laquelle l'emporte :

'What was the revenue?'
    +0.3738  Zorblatt reported revenue of 4.2 million euros.   <-- top
    +0.2333  The corporation was established in Helsinki in 1987.
    -0.0112  My cat is sleeping on the sofa.

'How much money did they make?'
    +0.2432  Zorblatt reported revenue of 4.2 million euros.   <-- top
    +0.1291  The corporation was established in Helsinki in 1987.
    -0.0208  My cat is sleeping on the sofa.

'When was it founded?'
    -0.0360  Zorblatt reported revenue of 4.2 million euros.
    +0.2781  The corporation was established in Helsinki in 1987.  <-- top
    -0.0942  My cat is sleeping on the sofa.

'What did the company earn?'
    +0.2473  Zorblatt reported revenue of 4.2 million euros.
    +0.3534  The corporation was established in Helsinki in 1987.  <-- top
    -0.0797  My cat is sleeping on the sofa.

« How much money did they make ? » a trouvé « revenue » sans aucun mot en commun. « When was it founded ? » a trouvé « established ». Aucune recherche par mots-clés ne fait cela : c'est de la recherche sur le sens, et c'est la raison pour laquelle le RAG fonctionne tout court.

Regardez maintenant la quatrième requête. « What did the company earn ? » a classé la phrase sur la fondation au-dessus de celle sur le chiffre d'affaires. Le mot « company » a tiré fort vers « corporation » et a battu la vraie réponse.

Ne passez pas là-dessus trop vite. La recherche sémantique est puissante et peu fiable. Les embeddings compriment le sens en 384 nombres, et la compression perd des choses. Cet échec précis est la raison d'être des parties 7 et 8 : la recherche hybride réintroduit la correspondance par mots-clés, et le reranking rescore les candidats avec un modèle qui lit la requête et le passage ensemble au lieu de comparer deux résumés fabriqués indépendamment.

Tout tutoriel qui ne vous montre que les requêtes qui marchent est en train de vous vendre quelque chose.

Choisir un fournisseur : local ou hébergé

project.txt demande text-embedding-3-small. Mais la première fois que j'ai lancé ceci, j'ai obtenu :

openai.RateLimitError: Error code: 429 - insufficient_quota
'You exceeded your current quota, please check your plan and billing details'

Une erreur de facturation, pas une erreur de code : ma clé était valide, le compte n'avait simplement pas de crédit. Si vous êtes sur une clé de palier gratuit, vous tomberez exactement là-dessus.

C'est agaçant, mais cela pointe vers un vrai principe de conception : le modèle d'embedding que vous utilisez doit relever de la configuration, pas du code. La production veut le modèle hébergé ; le développement et l'intégration continue veulent quelque chose de gratuit et hors ligne. Coder l'un ou l'autre en dur est une erreur.

Nous prenons donc les deux en charge. Étendez app/config.py :

from typing import Literal

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="RAG_",
        env_file=".env",
        env_file_encoding="utf-8",
    )

    embedding_provider: Literal["local", "openai"] = "local"
    local_embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
    openai_embedding_model: str = "text-embedding-3-small"
    openai_api_key: str | None = None

    chroma_dir: str = ".chroma"
    chroma_collection: str = "documents"


settings = Settings()

Quatre décisions là-dedans :

  • `Literal["local", "openai"]` signifie que pydantic rejette une faute de frappe comme "openia" au démarrage, avec un message clair, au lieu d'échouer plus tard au fond d'une fabrique.
  • `openai_api_key` est désormais optionnelle. En partie 1 elle était obligatoire, et c'était juste quand OpenAI était le seul chemin. Le fournisseur local n'a besoin d'aucune clé : en exiger une bloquerait la voie gratuite. Nous ne perdons pas la propriété d'échec immédiat, nous la déplaçons dans la fabrique, qui lève si vous choisissez openai sans clé. Validez ce qui est réellement requis, au moment où c'est réellement requis.
  • `openai_embedding_model` est explicite parce que OpenAIEmbeddings utilise par défaut text-embedding-ada-002, le modèle hérité. Omettez model= et vous obtenez silencieusement un embedding plus ancien, moins bon et plus cher. Encore un défaut discret.
  • Le modèle local est multilingue. La prise en charge de l'arabe est une exigence en partie 6, et choisir maintenant un modèle uniquement anglais obligerait à tout ré-indexer plus tard.

Installez les deux chemins :

pip install langchain-chroma langchain-openai
pip install langchain-huggingface sentence-transformers   # pulls in torch: large

Ce sont des paquets d'intégration autonomes, pas langchain-community : exactement la cible de migration que l'avertissement de dépréciation de la partie 2 désignait.

La fabrique d'embeddings

app/embeddings.py transforme la configuration en modèle, et c'est le seul endroit qui nomme un fournisseur :

"""Build the embedding model named by configuration."""

from functools import lru_cache

from langchain_core.embeddings import Embeddings

from app.config import settings


@lru_cache(maxsize=1)
def get_embeddings() -> Embeddings:
    provider = settings.embedding_provider

    if provider == "local":
        # Imported lazily so the openai path never pays for importing torch.
        from langchain_huggingface import HuggingFaceEmbeddings

        return HuggingFaceEmbeddings(model_name=settings.local_embedding_model)

    if provider == "openai":
        from langchain_openai import OpenAIEmbeddings

        if not settings.openai_api_key:
            raise ValueError(
                "embedding_provider='openai' requires RAG_OPENAI_API_KEY to be set"
            )
        return OpenAIEmbeddings(
            model=settings.openai_embedding_model,
            api_key=settings.openai_api_key,
        )

    raise ValueError(f"Unknown embedding_provider: {provider!r}")

Trois choses y font un vrai travail :

  • Le type de retour est `Embeddings`, l'interface de LangChain, pas HuggingFaceEmbeddings ni OpenAIEmbeddings. Tout l'aval dépend de l'interface : rien d'autre dans la base de code ne sait ni ne se soucie de quel fournisseur est actif.
  • `@lru_cache(maxsize=1)` compte plus qu'il n'y paraît. Un modèle local charge plusieurs centaines de mégaoctets de poids ; le reconstruire à chaque requête serait ruineux. Une instance par processus.
  • Les imports sont à l'intérieur des branches. import torch coûte des secondes et des centaines de Mo de RAM. Quelqu'un qui utilise le fournisseur OpenAI ne devrait jamais le payer.

Stocker les vecteurs dans Chroma

Un magasin de vecteurs fait un seul travail : conserver des vecteurs et trouver vite les plus proches d'un vecteur de requête. Un balayage exhaustif compare votre requête à chaque fragment : très bien avec 3 fragments, sans espoir avec un million. Chroma utilise un index approché (HNSW) pour chercher dans une grande collection en temps sous-linéaire, échangeant un peu d'exactitude contre beaucoup de vitesse.

app/vectorstore.py :

"""Persistent Chroma vector store: index chunks, search them back out."""

import hashlib
from functools import lru_cache

from langchain_chroma import Chroma
from langchain_core.documents import Document

from app.config import settings
from app.embeddings import get_embeddings


@lru_cache(maxsize=1)
def get_vectorstore() -> Chroma:
    return Chroma(
        collection_name=settings.chroma_collection,
        embedding_function=get_embeddings(),
        persist_directory=settings.chroma_dir,
        collection_metadata={"hnsw:space": "cosine"},
    )


def _chunk_id(chunk: Document) -> str:
    """Derive a stable id from the chunk's content and origin."""
    source = chunk.metadata.get("source", "")
    page = chunk.metadata.get("page", "")
    payload = f"{source}|{page}|{chunk.page_content}".encode("utf-8")
    return hashlib.sha256(payload).hexdigest()


def index_chunks(chunks: list[Document]) -> list[str]:
    """Embed chunks and upsert them into the collection. Returns their ids."""
    if not chunks:
        return []
    ids = [_chunk_id(c) for c in chunks]
    get_vectorstore().add_documents(documents=chunks, ids=ids)
    return ids


def search(query: str, k: int = 4) -> list[tuple[Document, float]]:
    """Return the k chunks nearest to the query, each with its distance."""
    return get_vectorstore().similarity_search_with_score(query, k=k)


def count() -> int:
    return get_vectorstore()._collection.count()

`persist_directory` est ce qui fait survivre tout cela à un redémarrage. Omettez-le et Chroma tourne purement en mémoire : vous ré-encodez chaque document à chaque démarrage, ce qui est lent en local et coûteux chez un fournisseur hébergé.

Remarquez que vous n'appelez jamais vous-même une fonction « embed ». Vous donnez à Chroma une embedding_function à la construction, et add_documents encode le texte à l'entrée pendant que similarity_search encode la requête à la sortie. C'est le magasin de vecteurs qui en est propriétaire.

Le piège des doublons

_chunk_id mérite une explication, parce que le comportement par défaut ici va vous faire mal.

Chroma attribue un UUID aléatoire à chaque document ajouté sans identifiant explicite. Des identifiants aléatoires ne sont jamais égaux : ré-indexer un document déjà indexé ne remplace donc rien, cela ajoute une seconde copie complète.

Regardez la chose se produire :

chunks = load_and_chunk("sample.pdf")

print("count before:", count())
index_chunks(chunks); print("after re-index #1:", count())
index_chunks(chunks); print("after re-index #2:", count())

store = get_vectorstore()
store.add_documents(documents=chunks)   # no ids= -> random uuids
print("after add without ids:", count())
store.add_documents(documents=chunks)
print("after add without ids again:", count())
count before: 3
after re-index #1: 3
after re-index #2: 3

--- now WITHOUT stable ids (the default behaviour) ---
after add without ids: 6
after add without ids again: 9

3 → 6 → 9. Chaque nouveau téléversement duplique le document entier, en silence.

Hacher source | page | contenu donne à chaque fragment un identifiant dérivé de ce qu'il est : le même fragment retombe donc toujours sur le même identifiant, et une ingestion répétée devient un upsert au lieu d'un doublon. Un utilisateur qui reverse un fichier — et il le fera — ne vous coûte rien.

Pourquoi ce n'est pas qu'une question de propreté : la recherche renvoie vos k meilleurs fragments. Si un passage existe trois fois, il peut occuper vos trois premières places et évincer d'autres éléments pertinents. Vos réponses se dégradent et rien, nulle part, ne lève d'erreur.

C'est la même forme que le bug de recouvrement de la partie 2. Les échecs dangereux, en RAG, sont les silencieux.

Choisir une métrique de distance

Lancez une recherche et regardez les nombres de près :

dist=19.7961  page=2  PAGE TWO - FINANCIAL RESULTS
dist=22.2743  page=1  PAGE ONE - COMPANY HISTORY

Des distances de 19 et 22 ? La distance cosinus vit entre 0 et 2. Chroma n'utilise donc pas le cosinus. Demandez-lui :

print(get_vectorstore()._collection.configuration_json["hnsw"])
{'space': 'l2', 'ef_construction': 100, 'ef_search': 100, ...}

Chroma utilise `l2` par défaut, la distance euclidienne au carré. C'est très bien pour des vecteurs normalisés, où L2 et cosinus classent à l'identique. Mais souvenez-vous de la norme de 5,62 de notre modèle local : ces vecteurs ne sont pas normalisés. L2 réagit à la norme et à la direction, et dans les embeddings de texte la norme suit souvent des choses accessoires comme la longueur du texte plutôt que le sens.

Définissez-la explicitement :

collection_metadata={"hnsw:space": "cosine"}

Soyons honnêtes sur les preuves : sur ce corpus de 3 fragments, cosinus et L2 ont tous deux eu 5/5 requêtes justes. La métrique n'a fait aucune différence mesurable à cette échelle. Le cosinus reste le meilleur défaut : il est robuste à la norme quand les documents varient en longueur, c'est la convention pour les embeddings de texte, et ses distances sont interprétables (0,37 se lit « proche » ; 17,34 ne se lit pas).

Une réserve : hnsw:space est figé à la création de la collection. En changer implique de supprimer .chroma/ et de tout ré-indexer.

Vérifier la partie 3

from app.ingestion import load_and_chunk
from app.vectorstore import index_chunks, count, search

chunks = load_and_chunk("sample.pdf")
index_chunks(chunks)
print("count:", count(), "chunks:", len(chunks))

for q in ["How much money did the company make?", "Who is the CEO?", "When was it founded?"]:
    doc, dist = search(q, k=1)[0]
    print(f"{q!r} -> page {doc.metadata['page']}  dist={dist:.4f}")
count: 3 chunks: 3
'How much money did the company make?' -> page 2  dist=0.5308
'Who is the CEO?' -> page 3  dist=0.3741
'When was it founded?' -> page 1  dist=0.4712

Chaque question récupère la bonne page, et les métadonnées ont survécu à l'aller-retour par le magasin de vecteurs — c'est ce que liront les citations de la partie 4.

Voici maintenant la propriété qu'achète persist_directory. Dans un processus neuf, sans indexation :

from app.vectorstore import count, search
print("count from disk:", count())
doc, dist = search("Who is the chief executive?", k=1)[0]
print(f"page={doc.metadata['page']} dist={dist:.4f}")
count from disk: 3
page=3 dist=0.3500

Les vecteurs sont revenus du disque. Chroma les stocke dans chroma.sqlite3 plus un répertoire contenant l'index HNSW. Ajoutez-le à .gitignore : c'est binaire, régénérable, et cela grossit :

.chroma/

Mettez .env.example à jour pour que les nouveaux réglages soient découvrables :

RAG_EMBEDDING_PROVIDER="local"
RAG_OPENAI_API_KEY=""
RAG_CHROMA_DIR=".chroma"
RAG_CHROMA_COLLECTION="documents"

Basculer vers le modèle hébergé, une fois votre compte crédité, tient désormais en une ligne dans .env :

RAG_EMBEDDING_PROVIDER="openai"

Supprimez .chroma/ et ré-indexez quand vous le faites. Les embeddings de modèles différents ne sont pas comparables : ce sont des dimensions différentes dans un espace différent, et les mélanger produit du n'importe quoi plutôt qu'une erreur.

Partie 4 : des réponses avec citations

La recherche trouve des passages. La partie 4 les transforme en une réponse que l'utilisateur peut vérifier.

Choisir un LLM

project.txt demande OpenAI. Mon compte n'avait pas de crédit, cette série utilise donc Ollama llama3.1:8b, exécuté localement et gratuit :

curl -fsSL https://ollama.com/install.sh | sh
ollama pull llama3.1:8b
pip install langchain-ollama

app/llm.py reproduit exactement app/embeddings.py, le même patron de fabrique : le LLM est donc lui aussi un choix de configuration :

@lru_cache(maxsize=1)
def get_llm() -> BaseChatModel:
    if settings.llm_provider == "ollama":
        from langchain_ollama import ChatOllama
        return ChatOllama(
            model=settings.ollama_model,
            base_url=settings.ollama_base_url,
            temperature=0,
        )
    if settings.llm_provider == "openai":
        from langchain_openai import ChatOpenAI
        if not settings.openai_api_key:
            raise ValueError("llm_provider='openai' requires RAG_OPENAI_API_KEY to be set")
        return ChatOpenAI(model=settings.openai_chat_model, api_key=settings.openai_api_key, temperature=0)
    raise ValueError(f"Unknown llm_provider: {settings.llm_provider!r}")

`temperature=0` partout. Le RAG est une tâche d'extraction, pas une tâche créative. Vous voulez que le même contexte produise la même réponse, et que le modèle répète le texte récupéré plutôt que de l'améliorer. La température est le bouton qui décide s'il enjolive.

Comment les citations fonctionnent réellement

Voici l'astuce, et elle est plus simple qu'on ne l'imagine : numérotez les passages et demandez au modèle de citer les numéros.

def format_context(docs: list[Document]) -> str:
    blocks = []
    for i, d in enumerate(docs, start=1):
        source = d.metadata.get("source", "unknown")
        page = d.metadata.get("page", "?")
        blocks.append(f"[{i}] (source: {source}, page: {page})\n{d.page_content}")
    return "\n\n".join(blocks)

Le modèle voit [1], [2], [3] et écrit [2] après une affirmation. C'est vous qui remappez [2] vers un nom de fichier et une page : le modèle n'a jamais à reproduire "sample.pdf" correctement, puisqu'il ne manipule qu'un petit entier. C'est toute la conception : donnez au modèle le travail le plus facile possible et faites la comptabilité vous-même.

Le prompt

SYSTEM_PROMPT = """You answer questions using only the numbered passages provided.

Rules:
1. Use only the passages. Never use outside knowledge, even if you are confident.
2. After every claim, cite the passage it came from in square brackets: [1], [2].
   A claim drawn from two passages cites both: [1][3].
3. If the passages do not contain the answer, reply with exactly this and nothing
   else: {no_answer}
4. Do not invent a citation. Only cite passage numbers that appear below.
5. Be concise. Do not repeat the question."""

La règle 3 est celle qui compte. Sans porte de sortie explicite, un modèle à qui l'on pose une question à laquelle il ne peut pas répondre répondra quand même : c'est ce pour quoi il a été entraîné. Lui donner une phrase exacte à produire fait passer « je ne sais pas » du statut d'échec à celui de sortie valide.

Composer avec LCEL

LCEL, c'est le LangChain Expression Language : l'opérateur | compose des runnables en un pipeline. prompt | llm | StrOutputParser() signifie : rendre le prompt, appeler le modèle, prendre le texte.

Mais un naïf retriever | prompt | llm jette quelque chose dont nous avons besoin :

return (
    RunnableParallel(
        question=RunnableLambda(lambda x: x["question"]),
        docs=RunnableLambda(lambda x: _retrieve(x["question"], x["tenant_id"])),
    )
    .assign(context=lambda x: format_context(x["docs"]))
    .assign(answer=RunnableLambda(timed_generate))
)

`docs` est porté jusqu'au bout. Le texte de la réponse dit [2], mais seule la liste des documents récupérés sait ce qu'était [2]. Envoyez les documents directement dans le prompt puis jetez-les, et vous obtenez une réponse dont les marqueurs ne pointent vers rien.

Ne faire confiance qu'aux vraies citations

def _used_citations(answer: str, docs: list[Document]) -> list[Citation]:
    markers = {int(m) for m in re.findall(r"\[(\d+)\]", answer)}
    citations = []
    for marker in sorted(markers):
        if 1 <= marker <= len(docs):     # <- the important line
            meta = docs[marker - 1].metadata
            citations.append(Citation(marker=marker, source=meta["source"], page=meta["page"]))
    return citations

La vérification des bornes n'est pas du rembourrage défensif. Un modèle à qui l'on donne 4 passages écrira parfois [7]. Sans la vérification, soit vous plantez sur une erreur d'index, soit — pire — vous affichez une citation vers un document qui n'a jamais été récupéré. Les marqueurs inexistants sont écartés silencieusement.

Est-ce que ça marche ?

Q: What was the revenue in Q3 2025?
A: 4.2 million euros [1]
   citations: [('sample.pdf', 2)]

Q: Who is the CEO?
A: Dr. Amina Rashid [1].
   citations: [('sample.pdf', 3)]

Q: What is the refund policy?
A: I don't know based on the provided documents.
   citations: []

Bonnes réponses, bonnes pages, et un refus propre. Poussez plus loin, essayez de lui faire utiliser des connaissances extérieures :

Q: Who founded Microsoft?
A: I don't know based on the provided documents.

Q: What is the capital of France?
A: I don't know based on the provided documents.

llama3.1:8b connaît ces deux réponses. Il a refusé quand même. C'est la règle 1 qui fonctionne, et c'est le comportement le plus important d'un système RAG : un système qui répond depuis ses données d'entraînement en ayant l'air de répondre depuis vos documents est pire que pas de système du tout.

La citation multi-sources fonctionne aussi :

Q: Where is the company based and what was its Q3 margin?
A: The Zorblatt Corporation was founded in Helsinki, Finland [1].
   Zorblatt reported an operating margin of nineteen percent in Q3 2025 [2].
   citations: [('sample.pdf', 1), ('sample.pdf', 2)]

Une précaution de lecture : c'est l'ordre de récupération qui définit les numéros de marqueurs. Pour une autre requête, l'ordre était [1] -> page 3, [2] -> page 1, et la réponse citait [2] pour la date de fondation, ce qui a l'air faux jusqu'à ce que vous vérifiiez la correspondance et trouviez que c'est exactement juste. Les numéros sont des positions dans la liste de candidats, pas des numéros de page.

Partie 5 : le mettre derrière une API

Deux endpoints enveloppent le pipeline. Les parties intéressantes ne sont pas les routes.

@app.post("/documents", response_model=IngestResponse)
async def upload_document(file: UploadFile = File(...)):
    filename = file.filename or ""
    suffix = Path(filename).suffix.lower()
    if suffix not in ALLOWED_SUFFIXES:
        raise HTTPException(status_code=400, detail=f"Unsupported file type {suffix or '(none)'}")

    # The loaders read from a path, not a stream, so the upload must land on disk.
    with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
        shutil.copyfileobj(file.file, tmp)
        tmp.flush()
        chunks = load_and_chunk(tmp.name)

    # load_and_chunk recorded the temp file's name. Overwrite it with the real
    # one, or every citation points at /tmp/tmpab12cd.pdf.
    for chunk in chunks:
        chunk.metadata["source"] = filename

    index_chunks(chunks)
    return IngestResponse(source=filename, chunks_indexed=len(chunks))

Deux choses à voler :

Le fichier temporaire. PyPDFLoader prend un chemin, et un téléversement est un flux. Il faut bien toucher le disque. Le bloc with garantit le nettoyage même si l'ingestion lève une exception.

Écraser `source`. load_and_chunk enregistre fidèlement /tmp/tmpab12cd.pdf, le nom temporaire, différent à chaque téléversement. Si on n'y touche pas, chaque citation nomme un fichier qui n'existe plus. C'est une correction d'une ligne, et un bug impossible à corriger si vous ne le remarquez pas.

Le bug qui n'apparaît que sous un serveur

L'API fonctionnait en test, puis a planté dès que je l'ai lancée pour de vrai :

RuntimeError: CUDA error: CUBLAS_STATUS_ALLOC_FAILED when calling `cublasCreate(handle)`
$ nvidia-smi --query-gpu=memory.total,memory.used,memory.free --format=csv
6141 MiB, 5171 MiB, 603 MiB

Ollama avait pris le GPU. llama3.1:8b occupe environ 5 Go d'une carte de 6 Go, et sentence-transformers a tenté d'allouer du CUDA par-dessus les 603 Mo restants. Cela n'était jamais apparu en test en ligne de commande parce qu'Ollama déchargeait le modèle entre deux exécutions ; sous un serveur vivant, le LLM reste résident.

Le correctif est celui que vous voudriez de toute façon :

embedding_device: str = "cpu"

Le modèle d'embedding est un MiniLM à 384 dimensions : le CPU est assez rapide. Le LLM de 8 milliards de paramètres, lui, a réellement besoin du GPU. Si vous faites tourner un LLM local et des embeddings locaux sur une même machine, ils se disputent la VRAM, et c'est au petit modèle de céder.

$ curl -X POST -F "file=@sample.pdf" localhost:8000/documents
{"source":"sample.pdf","chunks_indexed":3}

$ curl -X POST -H "Content-Type: application/json" \
    -d '{"question":"What was the revenue in Q3 2025 and who is the CEO?"}' localhost:8000/query
{
  "answer": "The revenue in Q3 2025 was 4.2 million euros [1]. The company's chief executive is Dr. Amina Rashid [2].",
  "citations": [
    {"marker": 1, "source": "sample.pdf", "page": 2},
    {"marker": 2, "source": "sample.pdf", "page": 3}
  ]
}

Partie 6 : formats multiples et arabe

Une seule porte pour tous les formats

La partie 2 avait placé PyPDFLoader derrière load_and_chunk. Cette frontière justifie maintenant son existence : ajouter deux formats ne touche qu'une fonction :

def _load_pages(path: Path) -> list[Document]:
    suffix = path.suffix.lower()
    if suffix == ".pdf":
        return PyPDFLoader(str(path)).load()
    if suffix == ".docx":
        return Docx2txtLoader(str(path)).load()
    if suffix in {".txt", ".md"}:
        # autodetect_encoding matters for Arabic: a file written as UTF-16 or
        # windows-1256 would otherwise raise, or decode to mojibake.
        return TextLoader(str(path), autodetect_encoding=True).load()
    raise ValueError(f"Unsupported file type {suffix or '(none)'}")

Seuls les PDF ont des pages. Les DOCX et les TXT se chargent en un seul Document et rapportent la page 1. C'est honnête : il n'y a pas de page à citer dans un fichier texte, et en inventer une fait de la citation un mensonge.

Normalisation Unicode

def _normalise(text: str) -> str:
    return unicodedata.normalize("NFC", text)

L'arabe peut encoder le même caractère visible de deux façons : une lettre de base plus un signe combinant, ou un seul point de code précomposé. Ils sont identiques à l'œil et se comparent comme inégaux. Sans normalisation, le même mot se hache différemment, se disperse entre les embeddings, et casse silencieusement la correspondance par mots-clés dont dépend la partie 7. NFC choisit une forme, systématiquement.

Choisir un modèle multilingue, par la mesure

La partie 3 avait signalé que paraphrase-multilingual-MiniLM était faible en arabe. Plutôt que de deviner un remplaçant, j'en ai comparé trois : requêtes arabes contre documents arabes, anglais contre anglais, et arabe contre anglais :

model                                    dims   AR->AR   EN->EN   AR->EN
paraphrase-multilingual-MiniLM-L12-v2     384     3/4      3/3      1/2
multilingual-e5-small                     384     4/4      3/3      2/2
paraphrase-multilingual-mpnet-base-v2     768     4/4      3/3      2/2

`multilingual-e5-small` gagne, à taille égale. Scores parfaits en 384 dimensions, aucun surcoût par rapport au modèle qu'il remplace, et il corrige aussi la recherche translingue.

Le piège des préfixes e5

Les modèles e5 ont besoin de préfixes asymétriques : les questions sont marquées "query: ", les documents "passage: ". Cette asymétrie est la raison de leur qualité en recherche : le modèle est entraîné à distinguer une question d'un contenu.

HuggingFaceEmbeddings a des points d'entrée séparés exactement pour cela :

return HuggingFaceEmbeddings(
    model_name=settings.local_embedding_model,
    model_kwargs={"device": settings.embedding_device},
    encode_kwargs={"prompt": settings.local_embedding_passage_prefix},       # documents
    query_encode_kwargs={"prompt": settings.local_embedding_query_prefix},   # questions
)

Vérifiez que cela s'applique vraiment, plutôt que de faire confiance au nom du paramètre :

embed_query matches manual 'query: ' prefix?   1.0
embed_query matches NO prefix?                 0.992942
embed_documents matches manual 'passage: '?    1.0

Le 1.0 confirme que cela fonctionne. Mais regardez le nombre du milieu : 0,9929 entre texte préfixé et non préfixé. Oubliez le préfixe et rien ne casse : la recherche devient simplement moins bonne, en silence. Encore un échec silencieux, le troisième de cette série.

Changer de modèle d'embedding invalide votre index. Supprimez .chroma/ et ré-ingérez. Les vecteurs de deux modèles ne sont pas comparables, et les mélanger produit du n'importe quoi, pas une erreur.

Ça fonctionne, dans les deux langues

[EN] Q: What is the warranty period?
     A: Thirty-six months from date of installation [1].
     cites: [('sample.docx', 1)]

[EN] Q: What is the refund window?
     A: Thirty days [1].
     cites: [('sample.txt', 1)]

[AR] Q: ما هي إيرادات الشركة في الربع الثالث؟
     A: بلغت إيرادات شركة زوربلات 4.2 مليون يورو في الربع الثالث من عام 2025 [1][2].
     cites: [('sample_ar.docx', 1), ('sample.pdf', 2)]

La question en arabe a reçu une réponse en arabe citant à la fois le document arabe et le PDF anglais : la capacité translingue d'e5 a retrouvé le même fait dans les deux.

Une lacune assumée : les PDF en arabe

Ma fixture de PDF arabe s'extrayait à l'envers :

original : الصفحة الأولى - تاريخ الشركة
extracted: ةكرشلا خيرات - ىلوألا ةحفصلا
reversed(extracted) == original?  True

Une inversion exacte des caractères. La cause est reportlab, mon générateur de fixtures, qui n'a aucune prise en charge du texte bidirectionnel et met l'arabe de gauche à droite. C'est un bug du générateur, pas de l'application : les PDF arabes produits par Word ou InDesign portent une couche de texte en ordre logique correcte. Le chemin arabe est donc testé avec DOCX et TXT, qui stockent de l'Unicode en ordre logique, et les PDF arabes restent non testés. Mieux vaut le dire que faire semblant.

Partie 7 : la recherche hybride

La partie 3 a montré la recherche sémantique associant « money did they make » à « revenue ». Voici ce qu'elle ne sait pas faire.

L'embedding de "6672WN5504" ne porte aucun sens : le tokenizer le déchiquette en fragments qui ne veulent rien dire. La recherche sémantique est quasi inutile sur les références de pièces, les SKU, les codes d'erreur et les noms de famille. BM25, le classement classique par mots-clés, les trouve exactement, et reste aveugle aux synonymes. Chacun couvre l'angle mort de l'autre.

Fusionner sans scores comparables

BM25 renvoie des sommes de fréquences de termes non bornées ; Chroma renvoie une distance cosinus entre 0 et 2. Vous ne pouvez pas en faire la moyenne. Normaliser suppose de connaître la distribution de chacun, qui change à chaque requête.

La fusion par rang réciproque (Reciprocal Rank Fusion) contourne le problème en jetant les scores et en n'utilisant que le rang :

def reciprocal_rank_fusion(rankings, k=60):
    scores, docs = {}, {}
    for ranking in rankings:
        for rank, doc in enumerate(ranking, start=1):
            key = _doc_key(doc)
            docs.setdefault(key, doc)
            scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank)
    ordered = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
    return [(docs[key], score) for key, score in ordered]

Quinze lignes, aucune dépendance. (L'EnsembleRetriever de LangChain vit dans langchain_classic, le paquet de compatibilité hérité. Construire sur un paquet en fin de vie pour échapper à un autre paquet en fin de vie est un mauvais marché.)

Le `k=60` amortit les premiers rangs. Sans lui, le rang 1 vaut 1,0 et le rang 2 vaut 0,5 : la confiance d'un seul moteur domine. À k=60, l'écart entre les rangs 1 et 2 est faible, et c'est l'accord entre les moteurs qui devient le signal.

Le bug de tokenizer

Première mesure, sur des recherches de SKU exacts :

semantic: 4/8 top-1     bm25: 1/5     hybrid: 4/8

BM25 à 1/5 sur une recherche de code exact n'est pas un résultat faible, c'est un résultat cassé. C'est la seule chose que BM25 ne peut pas rater. Donc :

>>> from langchain_community.retrievers.bm25 import default_preprocessing_func
>>> inspect.getsource(default_preprocessing_func)
def default_preprocessing_func(text: str) -> List[str]:
    return text.split()

>>> default_preprocessing_func("What is the warranty for the SR-33?")
['What', 'is', 'the', 'warranty', 'for', 'the', 'SR-33?']       # <- note the '?'
>>> default_preprocessing_func("Model SR-33 The SR-33 is a sonar receiver")
['Model', 'SR-33', 'The', 'SR-33', 'is', 'a', 'sonar', 'receiver']

`'SR-33?' != 'SR-33'`. Le tokenizer BM25 par défaut de LangChain est littéralement text.split() : pas de passage en minuscules, pas de retrait de la ponctuation. Un point d'interrogation final détruit la correspondance exacte. Il ne lève pas d'erreur ; il ne renvoie simplement rien d'utile.

_TOKEN_RE = re.compile(r"[^\W_]+(?:-[^\W_]+)*", re.UNICODE)

def tokenize(text: str) -> list[str]:
    return _TOKEN_RE.findall(text.lower())

La règle sur les traits d'union internes garde SR-33 en un seul token au lieu de sr et 33. \w tient compte d'Unicode en Python, donc l'arabe se tokenise aussi.

BM25Retriever.from_documents(corpus, preprocess_func=tokenize)

Ce correctif a fait passer BM25 de 1/5 à 8/8.

Là où l'hybride justifie son coût

Sur des codes porteurs de sens comme NX-40, le sémantique fait 8/8 : e5 est assez bon pour que l'hybride n'apporte rien. La différence apparaît sur les identifiants à forte entropie, sans aucun contenu sémantique :

         sku | semantic |  bm25 | hybrid    (rank of correct chunk)
  8829XQ4471 |        2 |     2 |      1
  6672WN5504 |     None |     2 |      5    <- semantic missed it ENTIRELY
  1096FT9938 |        4 |     2 |      2
-------------------------------------------
       TOP-1 |    4/8   |  0/8  |    6/8

Le sémantique tombe de 8/8 à 4/8 et en rate un complètement. L'hybride fait 6/8.

Méfiez-vous de tout tutoriel affirmant que l'hybride gagne toujours. Sur un corpus de 6 fragments, je n'ai mesuré aucune différence ; il a fallu 20 produits presque identiques aux SKU arbitraires pour rendre l'écart visible. Le bénéfice est réel et étroit : il apparaît exactement là où les tokens exacts comptent.

Une note d'architecture : BM25 est reconstruit à chaque requête à partir des fragments du tenant, parce que, contrairement au magasin de vecteurs, il n'a pas d'index sur disque. Très bien à quelques milliers de fragments, faux à quelques millions : à ce stade, BM25 a sa place dans Elasticsearch.

Partie 8 : le reranking par cross-encodeur

Le branchement de la recherche hybride a produit ceci :

Q: Which product has SKU 6672WN5504?
A: I don't know based on the provided documents.

L'hybride classe ce fragment 5e. retrieval_k=4 le coupe. Le fragment est trouvé, puis jeté avant que le LLM ne le voie. Un bon fragment au rang 5 vaut exactement autant qu'un fragment jamais récupéré : rien.

Bi-encodeur contre cross-encodeur

Tout ce qui précède utilise un bi-encodeur : requête et document sont encodés séparément, sans jamais se rencontrer, puis comparés par distance vectorielle. C'est ce qui le rend rapide — les vecteurs de documents sont précalculés — mais le modèle ne lit jamais la paire ensemble.

Un cross-encodeur prend (requête, document) comme une seule entrée et fait tourner un transformeur sur les deux, si bien que l'attention relie directement la question au passage. Bien plus précis. Bien trop lent pour être passé sur tout un corpus : rien ne peut être précalculé, chaque candidat coûte donc une passe avant.

Ils se composent : récupérer largement avec le modèle bon marché, rescorer la liste courte avec le modèle coûteux.

def rerank(query: str, docs: list[Document], top_n: int) -> list[tuple[Document, float]]:
    if not docs:
        return []
    pairs = [(query, doc.page_content) for doc in docs]
    scores = get_cross_encoder().predict(pairs)
    ranked = sorted(zip(docs, scores), key=lambda ds: ds[1], reverse=True)
    return [(doc, float(score)) for doc, score in ranked[:top_n]]

Le choix du modèle n'est pas gratuit ici. Le défaut courant est cross-encoder/ms-marco-MiniLM-L-6-v2, uniquement anglais. L'employer défairait silencieusement la prise en charge de l'arabe de la partie 6. mmarco-mMiniLMv2 est MS MARCO traduit en 14 langues, arabe compris.

La mesure

         sku | hybrid | reranked
  6672WN5504 |      5 |        1    <- rescued
  1096FT9938 |      2 |        1
-------------------------------------
       TOP-1 |   6/8  |      8/8

rerank latency: mean 1545ms over 20 candidates

6/8 → 8/8, et le fragment de rang 5 qui était jeté se retrouve au rang 1 :

Q: Which product has SKU 6672WN5504?
A: The PB-01 has stock keeping unit (SKU) 6672WN5504 [1].
   cites: [('catalog.txt', 1)]

Le coût est réel : environ 1,5 seconde par requête pour 20 candidats sur CPU. C'est le marché : le GPU est occupé par Ollama, le cross-encodeur tourne donc sur CPU. Moins de candidats, un modèle plus petit ou un GPU disponible sont vos boutons de réglage.

Partie 9 : le mesurer

Tout ce qui précède était de l'anecdote. Place aux chiffres.

Pourquoi pas RAGAS

project.txt prescrit RAGAS. Il ne s'installe pas :

ModuleNotFoundError: No module named 'langchain_community.chat_models.vertexai'

ragas==0.4.3 (le plus récent) importe un module que langchain-community 0.4.x a supprimé, dans le cadre de la fin de vie que nous esquivons depuis la partie 2. ragas.evaluate et ragas.metrics échouent tous deux à l'import. Rétrograder langchain-community mettrait en péril les chargeurs, BM25 et Chroma qui fonctionnent : échanger un runtime opérationnel contre un outil d'évaluation est un mauvais marché.

eval/ implémente donc les métriques directement. Il se trouve que cela apprend davantage, puisque l'enjeu est de comprendre ce qu'elles mesurent.

Deux sortes de métriques

Les métriques déterministes se comparent à des faits consignés dans le jeu d'évaluation. Gratuites, instantanées, reproductibles, et elles ne peuvent pas mentir. Toutes évaluent la recherche, là où vivent la plupart des échecs du RAG.

def hit_at_k(rank, k):
    """A cliff: chunks below k never reach the LLM."""
    return 1.0 if rank is not None and rank <= k else 0.0

def reciprocal_rank(rank):
    """A slope: rank 1 -> 1.0, rank 2 -> 0.5, rank 4 -> 0.25."""
    return 1.0 / rank if rank else 0.0

def citation_accuracy(citations, expected_source, expected_page):
    """The metric this whole project rests on."""
    if expected_source is None:
        return None
    if not citations:
        return 0.0
    return 1.0 if any(c["source"] == expected_source and c["page"] == expected_page
                      for c in citations) else 0.0

L'exactitude des citations est celle qui compte le plus. Une réponse peut être juste et citer la mauvaise page, ce qui est pire qu'une erreur évidente : l'utilisateur vérifie, ne trouve rien, et cesse de faire confiance au système.

Les métriques jugées par un LLM demandent à un modèle de noter une réponse. Elles mesurent ce qu'aucune comparaison de chaînes ne peut mesurer — mais le juge est un modèle faillible.

Le jeu d'évaluation

Quatorze cas écrits à la main dans eval/dataset.py, dont trois sans réponse possible :

{
    "question": "Who founded Microsoft?",
    "ground_truth": "",
    "unanswerable": True,
},

Un système RAG qui obtient de bons scores sur les questions répondables et hallucine sur celles-ci est pire qu'inutile : il a tort avec assurance. Noter le refus séparément empêche aussi de tricher : sinon, un système qui refuse tout aurait l'air parfait en fidélité.

Petit et écrit à la main, délibérément. Un jeu d'évaluation n'est pas une fixture de test : chaque entrée encode un jugement sur ce que le système devrait faire, et ce jugement doit venir de quelqu'un qui connaît les documents. Douze questions soignées valent mieux que mille questions générées.

Les résultats

config               hit@k     MRR  cite_acc  refusal    secs
semantic only         0.91    0.86      0.91     1.00    38.4
hybrid                1.00    0.89      1.00     1.00    29.9
hybrid + rerank       1.00    0.91      1.00     1.00    75.9

L'hybride fait passer hit@k et l'exactitude des citations de 0,91 à 1,00. Le reranking ajoute du MRR, 0,89 → 0,91 : il classe le bon fragment plus haut, et coûte 2,5 fois plus de temps pour le faire. Le refus est à 1,00 partout : llama3.1:8b n'a jamais halluciné sur les questions sans réponse.

Quand le juge est le bug

Les chiffres de fidélité étaient incohérents :

semantic only    hit@k 0.91   cite_acc 0.91   faithful 0.80
hybrid + rerank  hit@k 1.00   cite_acc 1.00   faithful 0.73   <- perfect retrieval, worse score?

Ma première hypothèse fut le bruit du juge. Faux : noter trois fois des réponses identiques a donné 0/11 verdicts inversés. Le juge est parfaitement stable à température 0.

J'ai donc regardé ce qu'il disait réellement :

'UNSUPPORTED. The answer claims "Thirty days", but this refers to a refund period
 for unused licenses, which is stated as thirty days of purchase in the SUPPORT
 POLICY passage. However, it does not appear in the Model NX-40 or MARITIME
 SENSORS DIVISION passages.'

Le juge trouvait l'affirmation étayée, puis la marquait UNSUPPORTED parce qu'elle n'était pas dans les quatre passages. Mon prompt disait « est-ce que chaque affirmation apparaît dans les passages ci-dessus ? » et le modèle a lu « dans chaque passage ». Comme la recherche renvoie toujours k=4 fragments et qu'un fait vit dans un seul, toutes les réponses échouaient par construction.* La métrique mesurait l'ambiguïté de mon prompt.

Question: is every factual claim in the answer supported by AT LEAST ONE of the
passages above?

Important:
- A claim only needs to appear in ONE passage. It does NOT need to appear in all
  of them. The passages cover different topics and most will be irrelevant.

Cela a corrigé l'essentiel (0,73 → 0,91), mais un cas échoue encore : "Thirty days [1].", juste, et littéralement dans le contexte. Un modèle de 8 milliards de paramètres n'est pas fiable pour juger des réponses de deux mots ; il lui faut de la prose sur laquelle raisonner.

La conclusion honnête : les métriques déterministes sont dignes de confiance ; la métrique jugée a un faux négatif connu sur les réponses lapidaires. C'est exactement pour cela que RAGAS s'appuie par défaut sur des modèles juges puissants. Jugez avec le meilleur modèle que vous pouvez vous offrir, et jamais avec le modèle qui est noté.

Partie 10 : authentification et multi-tenant

Un bug ici, et un client lit les documents d'un autre.

Le tenant vit dans le token

payload = {
    "sub": user["username"],
    "tenant_id": user["tenant_id"],
    "exp": expire,
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)

L'identifiant de tenant vient du token signé, jamais du corps de la requête. Si un client pouvait envoyer son propre identifiant de tenant, tous les contrôles d'isolation ci-dessous ne seraient que décoration : n'importe qui pourrait lire les documents de n'importe qui en modifiant un champ JSON.

payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])

`algorithms=` n'est pas optionnel. Omettez-le et un token peut désigner son propre algorithme, y compris "none", qui signifie « pas de signature », et PyJWT l'honorera.

En faire une dépendance FastAPI signifie qu'une route y adhère explicitement en réclamant un Principal :

CurrentPrincipal = Annotated[Principal, Depends(get_current_principal)]

@app.post("/query", response_model=QueryResponse)
async def query(principal: CurrentPrincipal, request: QueryRequest):
    result = ask(request.question, tenant_id=principal["tenant_id"])

Oublier l'authentification exige désormais de supprimer activement un paramètre, plutôt que de simplement oublier d'ajouter une ligne. Rendez le choix sûr structurel.

L'isolation à la seule porte

def search(query: str, tenant_id: str, k: int = 4):
    return get_vectorstore().similarity_search_with_score(
        query, k=k, filter={"tenant_id": tenant_id}
    )

tenant_id est un argument obligatoire, pas une option : une route ne peut pas l'oublier. Et le filtre s'applique pendant la recherche, pas après : filtrer après récupération laisserait les fragments d'un tenant voisin consommer les places du top-k et affamerait silencieusement les résultats de l'appelant.

BM25 exige d'appliquer la frontière autrement. Il n'a pas de filtre — c'est un index en mémoire — la portée du tenant doit donc s'appliquer au moment de choisir ce qui y entre :

raw = get_vectorstore()._collection.get(
    where={"tenant_id": tenant_id}, include=["documents", "metadatas"]
)

Construisez BM25 sur les fragments de tous les tenants puis filtrez après, et vous avez laissé fuiter leur texte.

Deux détails plus petits qui méritent d'être copiés :

payload = f"{tenant_id}|{source}|{page}|{chunk.page_content}".encode("utf-8")

tenant_id est dans le hachage de l'identifiant de fragment. Deux tenants téléversant un fichier identique doivent obtenir des lignes distinctes, sinon l'un écrase l'autre et supprimer le document d'un tenant supprime celui de l'autre.

if user is None:
    hash_password(password)   # hash anyway before failing
    return None

Le timing. Répondre immédiatement pour un utilisateur inconnu rend le chemin « l'utilisateur existe » mesurablement plus lent, et cette différence suffit à énumérer les noms d'utilisateur valides.

Le prouver

POST /query without token      -> HTTP 401
POST /documents without token  -> HTTP 401
wrong password                 -> HTTP 401

alice: sub=alice tenant_id=acme
bob:   sub=bob   tenant_id=globex

alice uploads sample.pdf  -> {"chunks_indexed": 3}
bob   uploads sample.txt  -> {"chunks_indexed": 1}
{"tenant_id":"acme","chunks":3}
{"tenant_id":"globex","chunks":1}

Le test qui compte :

ALICE asks about HER OWN doc  -> "4.2 million euros [1]"  cites: [('sample.pdf', 2)]
ALICE asks about BOB'S doc    -> "I don't know based on the provided documents."
BOB   asks about HIS OWN doc  -> "Thirty days [1]."       cites: [('sample.txt', 1)]
BOB   asks about ALICE'S doc  -> "I don't know based on the provided documents."

Et les attaques :

Bob edits his token to claim tenant 'acme' -> HTTP 401   (signature check)
alg:none token                             -> HTTP 401   (algorithms= pin)

Encore un, parce que l'échec le plus probable n'est pas un attaquant :

DEV_JWT_SECRET = "dev-only-insecure-change-me"

def _warn_insecure_defaults() -> None:
    secret = settings.jwt_secret
    if not secret or not secret.strip():
        warnings.warn("RAG_JWT_SECRET is empty. Tokens can be forged by anyone.")
    elif secret == DEV_JWT_SECRET:
        warnings.warn("RAG_JWT_SECRET is the built-in development default.")
    elif len(secret) < 32:
        warnings.warn(f"RAG_JWT_SECRET is {len(secret)} characters; HS256 wants at least 32.")

Trois cas, pas un. .env.example livre RAG_JWT_SECRET="" : le copier produit donc un secret vide qui signe les tokens tout à fait volontiers, et qui serait passé en silence devant une vérification portant seulement sur la valeur par défaut. L'erreur la plus probable est celle que la vérification naïve ne voit pas.

Partie 11 : les tâches en arrière-plan

Encoder un gros PDF prend des minutes. Maintenir une connexion HTTP ouverte pendant ce temps invite les délais d'attente côté client et bloque un worker pour toute la durée.

@app.post("/documents", status_code=status.HTTP_202_ACCEPTED)
async def upload_document(principal: CurrentPrincipal, background_tasks: BackgroundTasks, file: UploadFile = File(...)):
    ...
    with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
        shutil.copyfileobj(file.file, tmp)
        tmp_path = tmp.name

    job = create_job(tenant_id=principal["tenant_id"], source=filename)
    background_tasks.add_task(_ingest_job, job["job_id"], tmp_path, filename, principal["tenant_id"])
    return IngestAcceptedResponse(job_id=job["job_id"], status=job["status"], source=filename)

`202 Accepted`, pas `200 OK`. Le travail a été mis en file, pas fait. Le code de statut fait partie du contrat.

`delete=False` désormais, et c'est la tâche de fond qui prend en charge le nettoyage. La partie 5 utilisait delete=True, ce qui était juste tant que l'ingestion était synchrone. Gardez-le et le fichier est supprimé dès que le gestionnaire rend la main, avant que la tâche ne le lise.

Les validations peu coûteuses restent synchrones : une requête manifestement mauvaise reçoit un 400 immédiatement, au lieu de devenir une tâche qu'il faut interroger pour découvrir qu'elle a été rejetée.

La règle du travail en arrière-plan

def _ingest_job(job_id, tmp_path, filename, tenant_id):
    try:
        update_job(job_id, status=JobStatus.PROCESSING)
        chunks = load_and_chunk(tmp_path)
        if not chunks:
            raise ValueError("No extractable text found. Scanned PDFs need OCR first.")
        ...
        update_job(job_id, status=JobStatus.DONE, chunks_indexed=len(chunks))
    except Exception as exc:
        update_job(job_id, status=JobStatus.FAILED, error=f"{type(exc).__name__}: {exc}")
    finally:
        Path(tmp_path).unlink(missing_ok=True)

Rien ici ne doit lever d'exception. Ce code s'exécute après l'envoi de la réponse : une exception disparaît donc dans le log du serveur et laisse la tâche bloquée en PROCESSING pour toujours — le client interroge une tâche qui ne peut jamais finir. Attraper Exception est en général une mauvaise odeur ; ici, c'est l'exigence. Chaque échec doit atterrir sur la tâche.

_LOCK protège le dictionnaire des tâches : BackgroundTasks s'exécute dans un pool de threads, et le cycle lire-modifier-écrire d'une tâche n'est pas atomique, même sous le GIL.

Vérifié

=== upload returns immediately (202 + job_id)? ===
{"job_id":"8311f225-...","status":"queued","source":"catalog.txt"}
HTTP:202
  request returned in .010026632s

  poll 1: processing None
  ...
  poll 6: done 20

10 millisecondes pour un travail qui en a pris environ 11 secondes. Et les chemins d'échec :

corrupt PDF -> {"status":"failed","error":"PdfStreamError: Stream has ended unexpectedly"}
empty file  -> {"status":"failed","error":"ValueError: No extractable text found."}
bob GET alice's job -> HTTP 404

Le dernier compte : les identifiants de tâche sont des UUID, mais « indevinable » n'est pas un contrôle d'accès. Sans la vérification du tenant, un tenant pourrait lire la tâche d'un autre, ce qui divulgue ses noms de fichiers.

Le périmètre, dit clairement : _JOBS est un dictionnaire en mémoire du processus. Correct pour un worker, faux pour plusieurs avec --workers 4 : une tâche créée dans le worker 1 est invisible pour celui qui sert l'interrogation de statut, et le client reçoit un 404 pour une tâche qui tourne parfaitement bien. C'est un compromis délibéré pour garder le modèle de tâche visible sans dépendance à Redis. L'interface est celle dont la production a besoin ; seul le stockage change.

Partie 12 : le cache

La clé, c'est toute la conception

def cache_key(tenant_id: str, question: str) -> str:
    payload = f"{tenant_id}|{normalise_question(question)}".encode("utf-8")
    return hashlib.sha256(payload).hexdigest()

`tenant_id` dans la clé, c'est tout l'enjeu. Deux tenants posant une question identique doivent obtenir des réponses différentes, parce qu'ils ont des documents différents. Une clé fondée sur la seule question sert le contenu du document privé d'un tenant à un autre : une violation de données causée par un cache, et que vos tests d'isolation n'attraperaient jamais, puisque la fuite ne se produit qu'à la deuxième requête.

def normalise_question(question: str) -> str:
    text = unicodedata.normalize("NFC", question)
    text = re.sub(r"\s+", " ", text)
    return text.strip().lower()

Délibérément conservateur : cache à correspondance exacte après nettoyage, pas de cache sémantique. Traiter « What is the revenue ? » et « How much did they earn ? » comme une seule clé demande une comparaison d'embeddings et un seuil de similarité, et un seuil un peu trop lâche sert une réponse fausse avec assurance à une question que personne n'a posée.

Le bénéfice

cache MISS:    12140 ms
cache HIT :      0.0 ms
same answer: True

--- normalisation: these must share one entry ---
     0.0 ms  'what was the revenue in q3 2025?'
     0.0 ms  '  What Was The Revenue In Q3 2025?  '
     0.0 ms  'What  was the   revenue in Q3 2025?'
stats: {'entries': 1, 'hits': 4, 'misses': 1, 'hit_rate': 0.8}

Les trois variantes partagent une seule entrée : entries reste à 1.

Le bug qui atteint les utilisateurs

1. ask                    -> "I don't know based on the provided documents."   (cached)
2. upload the answering document
3. ask again              -> "I don't know based on the provided documents."   <- THE BUG
4. invalidate, ask again  -> "Thirty days [1]."  cites: [('sample.txt', 1)]

Sans invalidation, téléverser un document semble ne rien faire. Une réponse en cache est une affirmation portant sur un ensemble de documents ; dès que cet ensemble change, l'affirmation peut devenir fausse. La forme la plus visible est un refus mis en cache pour la question même à laquelle le téléversement répond.

index_chunks(chunks, tenant_id=tenant_id)
dropped = cache.invalidate_tenant(tenant_id)

L'invalidation de tout le tenant est brutale : un téléversement jette toutes les réponses en cache de ce tenant, y compris les valides. Déterminer précisément quelles réponses un nouveau document affecte suppose de savoir lesquelles le récupéreraient désormais, ce qui coûte à peu près autant que de répondre à nouveau. Brutal et juste vaut mieux que malin et faux.

Deux détails de plus : les refus sont mis en cache (ils coûtent autant à produire que les réponses, et l'invalidation gère la péremption), et le banc d'évaluation passe use_cache=False — mesurer un changement de configuration n'a aucun sens si la seconde configuration rejoue les réponses en cache de la première.

Partie 13 : l'observabilité

La recherche a manqué le passage, le prompt était mal formé, le modèle a ignoré son contexte, et c'est indiscernable de l'extérieur. Tout ce que vous voyez, c'est une mauvaise réponse.

Deux couches, parce qu'elles répondent à des questions différentes. LangSmith répond à « qu'a réellement vu le modèle ? » : prompt rendu, passages récupérés, comptes de tokens. Il demande un compte, il est donc optionnel :

os.environ["LANGSMITH_TRACING"] = "true"
os.environ["LANGSMITH_API_KEY"] = settings.langsmith_api_key
os.environ["LANGSMITH_PROJECT"] = settings.langsmith_project

C'est toute l'intégration. Chaque chaîne, chaque retriever et chaque appel de modèle est tracé sans toucher au code de la chaîne : le bénéfice d'avoir construit sur LCEL plutôt que d'avoir bricolé le pipeline à la main.

Le chronomètre local par étape répond à « où sont passées les 12 secondes ? » et fonctionne hors ligne, en intégration continue, sans compte :

_current_trace: ContextVar[dict | None] = ContextVar("current_trace", default=None)

Une ContextVar, pas une globale : FastAPI sert les requêtes en parallèle, et une globale entrelacerait les mesures de deux requêtes en une seule trace absurde.

L'instrumentation était fausse, et la trace l'a montré

{"stages": {"retrieve": 96.0, "rerank": 6663.3, "generate": 9718.1}, "total_ms": 9718.3}

`generate` égale le total. Il enveloppait build_chain().invoke(), qui appelle _retrieve à l'intérieur : « generate » comptait donc silencieusement deux fois les deux autres étapes. Une étape qui contient les autres étapes est pire que pas d'étape du tout : elle fait passer le LLM pour le goulot d'étranglement alors qu'il ne l'est peut-être pas. Le chronomètre doit entourer le seul appel au LLM :

def timed_generate(inputs: dict) -> str:
    with observability.stage("generate"):
        return generate.invoke(inputs)
{
  "stages": {"retrieve": 8868.6, "rerank": 8017.8, "generate": 2901.3},
  "cache": "miss", "candidates": 20, "retrieval_mode": "hybrid",
  "total_ms": 19879.7
}
  sum of stages: 19787.7 ms
  total_ms     : 19879.7 ms
  unaccounted  :    92.0 ms

Les étapes s'additionnent maintenant jusqu'au total, et les chiffres renversent l'hypothèse évidente. Le LLM pèse 2 901 ms sur 19 879 ms : l'étape la plus rapide. Le temps est dans le chargement des modèles (c'est un démarrage à froid ; à chaud, retrieve prend 96 ms) et dans le reranking. Tout le monde suppose que le modèle est lent. Mesurez-le.

Un succès de cache, par contraste :

{"stages": {}, "cache": "hit", "total_ms": 0.1}

Partie 14 : Docker et la mise en production

L'image

COPY requirements.txt .
RUN pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.txt
COPY --chown=appuser:appuser app/ ./app/

Les dépendances avant les sources. Docker met les couches en cache par contenu : une modification de source réutilise donc la couche des dépendances au lieu de réinstaller torch — 10 secondes de reconstruction au lieu de 5 minutes.

La roue torch pour CPU. Celle par défaut embarque environ 2,5 Go de runtime CUDA que cette image n'utilisera jamais : le conteneur est CPU seul, et le GPU appartient à Ollama sur l'hôte.

RUN useradd --create-home --uid 1000 appuser
USER appuser

Un processus de conteneur tournant en root qui s'échappe de son espace de noms est root sur l'hôte.

Le conteneur a trouvé un bug que mon venv cachait

ModuleNotFoundError: No module named 'jwt'

requirements.txt était périmé. J'avais installé pyjwt, bcrypt, python-multipart, rank_bm25, langchain-ollama et docx2txt au fil des parties 4 à 12, et je n'en avais ajouté aucun. Ma machine fonctionnait parfaitement. Un checkout propre, non.

C'est le meilleur argument, à lui seul, pour construire l'image. Docker part de rien : c'est donc le seul environnement qui dit la vérité sur vos dépendances. Votre venv accumule des paquets et vous mentira indéfiniment.

Le piège réseau d'Ollama

$ ss -tlnp | grep 11434
LISTEN 127.0.0.1:11434

$ docker exec ragtest getent hosts host.docker.internal
172.17.0.1      host.docker.internal

Deux problèmes empilés :

  1. Dans un conteneur, `localhost` désigne le conteneur. Pointer vers localhost:11434 donne un refus de connexion qui ressemble exactement à un Ollama éteint alors qu'il tourne très bien. host.docker.internal résout vers l'hôte (sous Linux, il faut une entrée extra_hosts pour qu'il existe).
  2. Ollama écoute sur `127.0.0.1` par défaut, donc uniquement en local sur l'hôte. Adresser correctement l'hôte échoue quand même, parce que rien n'écoute sur l'adresse docker0.

Corrigez le second avec :

sudo systemctl edit ollama
# [Service]
# Environment="OLLAMA_HOST=0.0.0.0"

Comprenez-le d'abord : cela expose Ollama à votre réseau, pas seulement à Docker.

Ou, sous Linux, partagez la pile réseau de l'hôte et contournez les deux :

docker run --network=host -e RAG_OLLAMA_BASE_URL=http://localhost:11434 \
  -e RAG_JWT_SECRET=$(openssl rand -hex 32) rag-api
=== status: Up 30 seconds (healthy)
=== health === {"status":"ok"}
=== runs as user: appuser
=== query (container -> host Ollama) ===
{
  "answer": "The warranty for the NX-40 is 36 months from the date of installation [1].",
  "citations": [{"marker": 1, "source": "catalog.txt", "page": 1}]
}

Les volumes ne sont pas optionnels

volumes:
  - chroma-data:/app/.chroma        # or re-embed every document on restart
  - model-cache:/home/appuser/.cache # or re-download ~500MB every restart

La suite

Vous avez le système promis en introduction : téléverser un document en arabe ou en anglais, poser une question, obtenir une réponse avec une citation vérifiable, derrière une authentification, isolée par tenant, ingérée en arrière-plan, mise en cache, tracée, et dans un conteneur.

config               hit@k     MRR  cite_acc  refusal
semantic only         0.91    0.86      0.91     1.00
hybrid                1.00    0.89      1.00     1.00
hybrid + rerank       1.00    0.91      1.00     1.00

La leçon, une seule

Presque tous les bugs de ces quatorze parties étaient silencieux. Pas un seul n'a levé d'exception :

Ce qui a casséÀ quoi cela ressemblait
chunk_overlap=8080 demandés, 0 obtenus. Les fragments ne se recouvraient tout simplement pas.
Identifiants Chroma absentsLe corpus triplait au re-téléversement. 3 → 6 → 9.
page indexé à partir de 0Citations décalées d'une unité. Plausiblement, et vérifiablement, fausses.
Préfixe e5 oublié0,99 de cosinus avec le bon vecteur. Juste un peu moins bon.
text.split() de BM25'SR-33?' != 'SR-33'. La correspondance exacte n'est jamais partie.
OpenAIEmbeddings()L'ada-002 hérité au lieu du modèle demandé.
Métrique Chroma par défautL2 sur des vecteurs non normalisés.
Refus mis en cacheTéléverser un document semblait ne rien faire.
Ambiguïté du prompt de jugeToutes les réponses notées UNSUPPORTED, par construction.
Chronomètre mal étiquetéLe LLM avait l'air d'être le goulot. C'était l'étape la plus rapide.
requirements.txt périméMarchait sur ma machine. Aurait échoué sur la vôtre.

Chacun a produit un système qui tournait, renvoyait une sortie plausible, et était discrètement moins bon. C'est ce qui rend le RAG différent du travail back-end ordinaire. Une requête SQL cassée lève une erreur. Un pipeline de recherche cassé renvoie quatre fragments et un paragraphe assuré, et vous ne pouvez pas le voir à l'œil.

Il n'y a qu'une défense, et c'est l'habitude que toute cette série enseignait réellement : mesurez ce que vous avez supposé. Comptez le recouvrement. Vérifiez la page contre la page physique. Demandez à la bibliothèque ce qu'elle exporte. Construisez le conteneur. Chaque chiffre de ce tutoriel existe parce que supposer aurait donné faux.

Où l'emmener

Comblez les lacunes que cette construction a laissées ouvertes. Elles sont listées honnêtement dans le README : tâches et cache en mémoire du processus (Redis est le remplaçant direct), BM25 reconstruit à chaque requête (Elasticsearch à l'échelle), PDF arabes non testés, un modèle juge trop faible pour noter son propre travail, des utilisateurs de démonstration en dur, aucune limitation de débit.

Passez aux modèles hébergés et remesurez. Mettez RAG_EMBEDDING_PROVIDER=openai et RAG_LLM_PROVIDER=openai, supprimez .chroma/, ré-indexez, et lancez python -m eval.run. Tout l'intérêt des fabriques de fournisseurs est que cela tienne en deux lignes. Est-ce que text-embedding-3-small bat e5 sur vos documents ? Vous avez maintenant un banc capable de répondre, au lieu du benchmark d'un vendeur.

Faites grossir le jeu d'évaluation. Quatorze cas suffisent à attraper les régressions et sont trop peu pour se fier à un petit écart. Ajoutez les questions que vos utilisateurs posent réellement, surtout celles que votre système rate. Un jeu d'évaluation fait d'échecs réels vaut mieux que mille questions générées.

Puis cassez-le exprès. Donnez-lui un PDF scanné sans couche de texte. Un document de 500 pages. Un rapport financier plein de tableaux où les nombres vivent dans des cellules qui s'aplatissent en charabia. Une question dont la réponse s'étend sur deux documents. Chacun échouera d'une manière intéressante, et chaque échec est la prochaine chose qui mérite d'être corrigée.

contact

Décrivez votre situation en quelques lignes, sans engagement. Un échange suffit pour savoir s'il y a un système qui vaut la peine d'être construit, et par quelle porte commencer. Ou envoyez-moi un message.

Helmo ©2026