IA locale pour PME suisse, guide décisionnel et procédures

Architectures IA locales pour PME suisses : RTX Spark, DGX Spark, RTX PRO 6000, H100. TCO réel, ingénierie RAG, conformité nLPD. Procédures opérationnelles publiées progressivement.

View the Project on GitHub doit4everyone/ia-locale-pme-suisse

§8 Fiabilité : hallucinations et contrôle d’ancrage

Retour au sommaire Section précédente : §7 Pipelines n8n Section suivante : §9 Sécurité et durcissement

Statut : validé en lab sur VM-RAG-LAB avec corpus Axonix SA, RAG API et Open WebUI, septembre 2026. Tous les cas de test documentés ont été reproduits en session, y compris le contrôle 3 (format de citation confirmé, voir §8.4).

Principes et essais historiques. Les mécanismes décrits ici (prompt, juge, citations, recherche hybride) restent en place. Les modèles et les temps cités sont ceux des essais de septembre 2026 sur CPU (qwen2.5:14b, juge qwen3:4b, temps GPU alors estimés). La configuration retenue et les temps mesurés sur GPU sont au §10.


Ce que cette section documente : un pipeline RAG peut produire des réponses incorrectes qui ressemblent à des réponses correctes. Cette section décrit comment les détecter, comment les prévenir côté utilisateur, et comment les intercepter côté technique. Les exemples sont issus de sessions de validation réelles sur le corpus Axonix SA, un corpus de documents fictifs créés pour le lab (voir §4, corpus de validation Axonix SA). Les noms de clients, montants et références de fichiers qui apparaissent dans cette section sont inventés.


§8.1 Deux types de réponses : ancrée vs hallucinée

Un pipeline RAG peut produire deux types de réponses. La différence n’est pas toujours visible à l’oeil nu.

Réponse ancrée : le modèle a récupéré des chunks correspondant à la question, les a lus, et en a extrait les informations. Les citations pointent vers des documents réels. Le contenu est vérifiable.

Réponse hallucinée : le modèle n’a pas trouvé de document correspondant, mais a produit une réponse plausible en s’appuyant sur la structure de la question ou d’une réponse précédente. Les citations pointent vers des documents inexistants. Le contenu est inventé mais cohérent.


§8.2 Cas documenté : hallucination par analogie lors de la validation initiale

Les deux exemples suivants ont été obtenus lors de la même session de validation, avec le même modèle (qwen2.5:14b), le même corpus (12 documents Axonix SA indexés dans Qdrant via indexer.py), et sans modification de configuration entre les deux questions.

Cas 1 : réponse correcte (question directe)

Question posée :

Quelles sont les conditions du contrat de maintenance d’Axonix SA avec Baumont Industries ?

Comportement observé : La RAG API a récupéré les chunks du document 21_Contrat_Maintenance_Baumont_Industries.docx et produit une réponse avec des citations structurées.

Extrait de la réponse :

Le contrat couvre l’infogérance de 35 postes Windows 11 Pro, l’infogérance d’un serveur Windows Server 2022 et d’un NAS Synology, le réseau (Firewall OPNsense, 3 switches, WiFi industriel). Forfait mensuel : CHF 4 200 HT. [SharePoint : 21_Contrat_Maintenance_Baumont_Industries.docx]

Toutes les données sont exactes et vérifiables dans le document source.

Cas 2 : hallucination par analogie (“fais pareil”)

Question posée immédiatement après :

Fais pareil pour l’étude Rochat

Comportement observé : le document 03_Contrat_Maintenance_Etude_Rochat.docx existe dans le corpus. La RAG API ne l’a pas trouvé lors de la recherche. Le modèle a produit une réponse en reprenant la structure du Cas 1 et en inventant les données.

Ce que le modèle a inventé :

Champ Réponse hallucinée Valeur réelle dans le document
Nom du fichier 22_Contrat_Maintenance_Étude_Rochat.docx 03_Contrat_Maintenance_Etude_Rochat.docx
Site SharePoint Finances Direction
Date de début 1er janvier 2025 1er janvier 2026
Postes 20 Windows 10 Pro 20 Windows 11 Pro
Serveur Windows Server 2019 Windows Server 2025
Pare-feu pfSense OPNsense
Forfait mensuel CHF 3 000 HT CHF 3 840 HT

La réponse était bien formatée, complète, et indiscernable d’une réponse correcte sans vérification.

Les deux signaux visibles sans juge LLM

Signal 1 : format des citations. Une réponse ancrée produit des objets de citation structurés avec le nom du fichier. Une réponse hallucinée produit des URLs en texte brut répétées à chaque affirmation, signe que le modèle reproduit le format sans avoir de chunks à citer.

Signal 2 : identifiant de document inventé. Dans ce cas, l’identifiant halluciné contenait le GUID 56601275-318D-47CD-960B-D94C762ED9C0, absent de tout document SharePoint du tenant. C’est le signal le plus net d’hallucination : le modèle a inventé un identifiant structurellement plausible mais inexistant. Dans un pipeline file server SMB (§5), l’équivalent est un nom de fichier cité entre crochets mais absent des chunks récupérés par Qdrant : c’est ce que détecte le contrôle 3 de la RAG API.


§8.3 Architecture de contrôle : RAG API et Open WebUI

La réponse architecturale est de ne pas laisser le LLM gérer seul la génération sans guardrails. La RAG API déployée en §3 implémente quatre couches de contrôle entre la récupération et l’affichage.

Utilisateur (navigateur, HTTPS par Nginx, §9.8)
    ↓
Open WebUI (identité signée)
    ↓
RAG API (port 8080, réseau interne)
    ├── Retriever Qdrant : chunks du document le plus pertinent
    ├── Génération : qwen2.5:14b avec prompt strict
    ├── Contrôles déterministes (coût nul)
    ├── Groundedness check : qwen3:4b
    └── Journalisation nLPD
    ↓
Réponse affichée (endpoint /v1) ou bloquée avec HTTP 422 (endpoint /query)

Comportement selon l’endpoint : sur /v1/chat/completions (chemin Open WebUI), la réponse est toujours renvoyée à l’utilisateur, quel que soit le résultat du groundedness check. Le champ ancree est journalisé pour l’audit nLPD mais ne bloque pas l’affichage : bloquer sur /v1 casserait la compatibilité OpenAI et afficherait une erreur dans Open WebUI. Sur /query (endpoint machine à machine), une réponse non ancrée retourne HTTP 422 avec la réponse dans reponse_bloquee. C’est un choix délibéré documenté : la supervision humaine reste la mitigation principale sur le chemin utilisateur.

Open WebUI reçoit la réponse via l’endpoint /v1/chat/completions de la RAG API, compatible OpenAI. Open WebUI est l’interface utilisateur retenue depuis la v2.0 : l’indexation est assurée par indexer.py et les ACL par acl_resolver.py (voir §5).


§8.4 Contrôles déterministes

Quatre contrôles s’exécutent avant le juge LLM, sans coût de calcul.

Contrôle 1 : aucun chunk récupéré. Si Qdrant ne retourne aucun chunk, il n’y a aucune source possible. Le prompt strict déclenche la réponse de refus standard (“Cette information ne figure pas dans les documents disponibles.”), qui passe le contrôle 2 et est renvoyée à l’utilisateur.

Contrôle 2 : réponse de refus standard. Si la réponse est courte (moins de 200 caractères) et contient “ne figure pas dans les documents disponibles”, c’est un refus légitimé par le prompt strict. La vérification passe. La limite de 200 caractères est importante : une réponse longue qui contient cette phrase en passant n’est pas un refus.

Contrôle 3 : sources citées inexistantes. Le prompt strict demande au modèle de citer les documents entre crochets : [nom_du_fichier.docx]. Chaque citation est comparée aux sources réellement récupérées par Qdrant. Une citation absente des chunks signale une hallucination.

def verifier_citations(answer: str, chunks: list[dict]) -> list[str]:
    sources_reelles = {c["source"] for c in chunks}
    prefixes_reels = {os.path.splitext(s)[0] for s in sources_reelles}
    citees = set(re.findall(r'\[([^\]]{5,100})\]', answer))
    inventees = []
    for citee in citees:
        # Nettoyer le préfixe "Document N :" résiduel (format obsolète)
        # Le format actuel de build_context() est "→ filename" mais la regex
        # est conservée pour rétrocompatibilité avec d'anciens chunks indexés.
        citee_clean = re.sub(r'^Document\s+\d+\s*:\s*', '', citee).strip()
        citee_sans_ext = os.path.splitext(citee_clean)[0]
        if citee_clean in sources_reelles:
            continue
        if citee_sans_ext in prefixes_reels:
            continue
        if any(citee_clean in s or s in citee_clean for s in sources_reelles):
            continue
        if '.' not in citee_clean and '_' not in citee_clean:
            continue
        inventees.append(citee_clean)
    return sorted(inventees)

Contrôle 4 : réponse longue sans citation. Si une réponse dépasse 200 caractères sans aucune citation entre crochets, le modèle a répondu sans ancrer ses affirmations. Ce contrôle alimente le champ ancree: false dans le journal nLPD. Sur /v1, la réponse est affichée avec ce statut journalisé. Sur /query, elle est bloquée avec HTTP 422.

Résultats de validation

Ces contrôles ont été testés sur le corpus Axonix SA en septembre 2026 :

Question ancree Sources retournées Contrôle déclenchant
Conditions contrat Baumont Industries true 21_Contrat_Maintenance_Baumont_Industries.docx (×2) Aucun
Conditions contrat ClientB true 03_Contrat_Maintenance_Etude_Rochat.docx (×2) Aucun
Chiffrage migration Azure Sarrasin true 04_Reponse_AO_Migration_Azure_Sarrasin.docx (×3) Aucun
“Fais pareil pour Baumont” (hors contexte) false Aucun chunk Baumont pertinent Contrôle 4 : réponse sans citation. Sur /v1 : journalisé, affiché. Sur /query : HTTP 422.

Format de citation confirmé en lab, septembre 2026. Le modèle produit des citations entre crochets du type [CLIENTS/test-deny-explicite.docx], format que verifier_citations() sait lire. Le contrôle 3 est donc fonctionnel sur ce format. Il n’a pas déclenché lors des sessions de test car aucune réponse n’a cité de document inexistant : les réponses incorrectes ont été interceptées par le contrôle 1 (aucun chunk récupéré) ou par le contrôle 4 (réponse longue sans citation). L’hallucination documentée en §8.2 a été observée lors de la phase de validation initiale, avant l’activation des contrôles d’ancrage de la RAG API.


§8.5 Groundedness check : juge LLM

Les contrôles déterministes interceptent les cas grossiers. Pour les hallucinations subtiles (modèle qui cite un vrai document en lui faisant dire autre chose), un second appel LLM vérifie affirmation par affirmation.

Modèle juge : qwen3:4b. Appel via /api/chat avec "think": false et "format": "json".

curl http://<IP-HOTE-OLLAMA>:11434/api/chat \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen3:4b",
    "stream": false,
    "format": "json",
    "think": false,
    "options": {"temperature": 0},
    "messages": [{"role": "user", "content": "...prompt du juge..."}]
  }'

Paramètres critiques : "think": false est requis pour Qwen3. Sans ce paramètre, la réponse JSON arrive dans le champ thinking au lieu de message.content et ne peut pas être parsée. L’endpoint /api/chat est requis : /api/generate ne supporte pas ce paramètre correctement avec Qwen3.

keep_alive et déchargement du juge : Ollama décharge un modèle de la mémoire après 5 minutes d’inactivité par défaut. Pour qwen3:4b, ce déchargement ajoute 30 à 90 secondes au premier groundedness check suivant une période d’inactivité. La variable JUDGE_KEEP_ALIVE (défaut 2h dans main.py, transmise par le Compose) contrôle cette durée. qwen3:4b occupe environ 2,5 Go en Q4 : le cumul avec qwen2.5:14b (~9 Go) tient en RAM système sur LABO-G9. Passer à -1 une fois le GPU installé pour maintenir les deux modèles en VRAM indéfiniment.

Prompt du juge :

juge_prompt = """Tu es un vérificateur de faits strict.

Sources :
{sources}

Réponse à vérifier :
{reponse}

Instructions :
- Une affirmation est sourcée si elle est directement tirée des sources ou en est une reformulation fidèle.
- Une affirmation est NON sourcée si elle contient un chiffre, une date, un nom ou un fait précis absent des sources.
- Une affirmation est NON sourcée si elle attribue une fonctionnalité ou une action au mauvais sujet : si la source dit que A fait X, la réponse ne peut pas dire que B fait X.
- Si la source documente un outil construit autour d'un produit, la réponse ne peut pas présenter cet outil comme une fonctionnalité native du produit.
- Ne valide pas une affirmation si tu ne la trouves pas dans les sources ou si l'attribution est incorrecte.

Réponds uniquement en JSON :
{"ancree": true ou false, "affirmations_non_sourcees": ["liste des affirmations avec des faits précis absents des sources"]}"""

Limite du juge sur CPU : qwen3:4b détecte les hallucinations grossières (chiffres, dates, noms inventés) mais rate les mauvaises attributions subtiles : si une source documente un outil construit autour d’un produit, le juge peut valider une réponse qui présente cet outil comme une fonctionnalité native du produit. Les deux règles d’attribution ajoutées ci-dessus réduisent ce risque, mais ne l’éliminent pas. Une validation fiable des attributions nécessite un modèle juge plus grand (qwen2.5:14b sur GPU, en réutilisant le modèle principal déjà chargé en VRAM).

En cas d’échec du juge, la réponse est laissée passer pour ne pas bloquer le service. L’échec est journalisé avec le champ verification: non_effectuee pour l’audit nLPD. Un juge qui tombe pendant une semaine devient visible dans les logs, pas silencieux.

Coût en temps d’inférence

Étape CPU LABO-G9 (i7-14700, sans GPU) GPU RTX 5060 Ti 16 Go (estimé)
Retriever Qdrant < 1 s < 1 s
Génération qwen2.5:14b ~3 à 5 min ~10 à 20 s
Groundedness check qwen3:4b 2,4 s (cas validé) à 8 s (cas complexe) ~1 à 3 s
Total ~5 à 8 min ~15 à 25 s

Les valeurs CPU sont mesurées. Les valeurs GPU sont des estimations à mesurer après installation du GPU sur LABO-G9.


§8.6 Retrieval hybride BM25 et paramètres clés

Le retrieval combine deux approches fusionnées par Reciprocal Rank Fusion (RRF) :

Approche Avantage Limite
Vectorielle (Qdrant) Similarité sémantique, reformulations Moins efficace sur les termes exacts
BM25 (mots-clés) Termes exacts : noms de fichiers, acronymes, commandes Ne comprend pas le sens

L’index BM25 est construit en mémoire au démarrage du conteneur et reconstruit après chaque synchronisation. Il couvre les deux collections (documents et documentation). Chaque chunk BM25 inclut son chunk_index : si un chunk BM25 gagne le RRF, l’extension de contexte peut récupérer ses voisins par chunk_index ± radius au lieu de faire un scroll aléatoire.

Les droits ne viennent pas de l’index. L’index est un instantané : entre deux reconstructions, il garde les droits du moment où il a été construit. Depuis la 2.21.3, chaque candidat trouvé par mots-clés est relu dans Qdrant avant d’être utilisé, et les droits sont contrôlés sur cette lecture ; un extrait disparu ou devenu interdit est écarté (§16.5.1).

Note sur les PDF avec tables des matières

indexer.py nettoie les suites de points répétitifs (......) avant l’appel à Ollama. Ces patterns, fréquents dans les tables des matières PDF, provoquent une erreur 500 sur nomic-embed-text. Le nettoyage est appliqué dans get_embedding() et n’affecte pas la qualité des embeddings.

Paramètres validés en lab

Paramètre Valeur Justification
CANDIDATS 30 Candidats retenus par la recherche avant la construction du contexte (§8.9). Remplace TOP_K, qui tronquait la liste fusionnée à 20 et écartait des documents pertinents
CONTEXT_THRESHOLD 0.01 Les scores RRF sont dans [0, ~0.033] avec k=60 et deux listes (vectorielle + BM25). 0.01 déclenche l’extension de contexte sur presque toutes les questions.
MAX_CONTEXT_CHUNKS 14 Plafond d’extraits envoyés au modèle quand aucun document n’est nettement en tête
PRINCIPAL_MAX, CONTEXT_OTHER_DOCS, EXTRAITS_PAR_COMPLEMENT 9, 6, 4 Répartition du contexte entre le document principal et les documents complémentaires (§8.9) ; 4 extraits consécutifs par complément depuis la v2.18.0 (§10.6)

Limite sur les très gros fichiers

Un fichier de 100+ chunks (ex. 11-correlations-yaml.md à 189 chunks, 09-pipeline-llm.md à 117 chunks) est trop dilué pour que ses chunks pertinents remontent systématiquement dans le top 20. Les questions génériques sur ces fichiers peuvent échouer. Les questions précises avec les bons termes techniques réussissent.

Note sur la température de génération

La température est fixée à 0.2 (validé en lab). En dessous de 0.1, les réponses sont courtes et répétitives. Au-dessus de 0.3, le taux de hallucination augmente sur les données factuelles. 0.2 est le bon compromis entre fidélité aux sources et fluidité pour un RAG documentaire soumis à la nLPD. Ne pas dépasser 0.3 sur ce type de corpus.

Correctif documentaire recommandé : découper les très gros fichiers en sections thématiques séparées. 11-correlations-yaml.md gagnerait à être découpé en 6 fichiers par série de règles (W, WD, S, L, M, A). C’est un travail sur le file server, pas dans le code.


§8.7 Citations enrichies avec chemin UNC

Depuis la v2.6.0, enrichir_citations() remplace chaque citation [nom.docx] par une citation enrichie avec le chemin UNC du dossier parent, sous cette forme :

[nom.docx : `\\\\SERVEUR\\Partage\\Dossier\\`]

Le chemin est affiché dans un bloc code inline : Open WebUI l’affiche tel quel et un clic le copie dans le presse-papiers. L’utilisateur colle ensuite le chemin dans la barre d’adresse de l’Explorateur Windows.

Cette opération est effectuée après tous les contrôles : verifier_citations() et le juge voient la citation brute [nom.docx], pas la citation enrichie.

§8.8 Règles de formation utilisateurs

La formation est la première ligne de défense, gratuite et sans développement.

Formulations à proscrire

Formulation Pourquoi elle est dangereuse
“Fais pareil pour X” Demande implicitement la même structure, que le document existe ou non
“Même chose pour Y” Identique
“Compare X et Y” Si l’un des documents n’existe pas, le modèle invente
“Liste tous les contrats de…” Si la liste n’est pas exhaustive dans les chunks, le modèle complète
“Résume tout ce que tu sais sur…” sans préciser le domaine La recherche peut privilégier un document qui emploie le mot sans traiter le sujet (§8.9)

Formulations recommandées

Formulation dangereuse Formulation correcte
“Fais pareil pour l’étude Rochat” “Quelles sont les conditions du contrat de maintenance avec l’étude Rochat ?”
“Même chose pour Sarrasin” “Quel est le chiffrage de la migration Azure pour Sarrasin Fiduciaire ?”
“Compare les deux contrats” “Quelles sont les différences de SLA entre le contrat Baumont et le contrat Rochat ?”
“Résume tout ce que tu sais sur les clients” “Résume les contrats de maintenance des clients d’Axonix SA”

La règle générale : toujours poser une question directe sur un sujet précis. Ne jamais demander au modèle de reproduire une structure ou de compléter une liste.


§8.9 Diversité des sources et réponses partielles

Statut : validé en lab sur CPU, octobre 2026, avec qwen2.5:14b et nomic-embed-text. À remesurer au §10.

Le problème : un contexte construit pour les questions précises

Jusqu’à la v2.17.1, le contexte était construit autour d’un seul document : le meilleur extrait désignait le document principal, dont jusqu’à 14 extraits voisins étaient envoyés au modèle, complétés par 3 extraits quelconques. C’est le bon réglage pour une question précise (une durée de contrat, un code), mauvais pour une question large (« fais-moi un résumé sur les clients ») :

Résultat observé en lab : une question large sur les clients, qui obtenait une synthèse de trois contrats avant l’indexation de SharePoint, était refusée ensuite.

La correction

Élément Avant Après
Candidats après fusion 20 30
Document principal jusqu’à 14 extraits jusqu’à 9 extraits
Complément 3 extraits quelconques 6 documents distincts, 2 extraits chacun (4 extraits consécutifs depuis la v2.18.0, §10.6)
Copies non détectées écartées : texte identique, ou même nom de fichier qu’un document retenu
Volume envoyé au modèle environ 17 extraits environ 21 extraits

La sélection ne travaille que sur des extraits déjà autorisés (filtres d’accès et Purview) : elle choisit parmi eux, elle n’en ajoute aucun.

Le prompt a été assoupli sur un seul point, la réponse partielle :

Les tests qui protègent la correction

Assouplir le refus ouvre un risque d’invention : il se mesure. Deux questions doivent toujours être refusées :

Question Ce qu’elle vérifie Résultat en lab
« Quelle est la capitale de l’Australie ? » Le modèle n’utilise pas ses connaissances générales Refusée
« Quel est le montant mensuel du contrat de maintenance de la société Dupont Logistique SA ? » (client inexistant) Des contrats proches existent : aucun montant ne doit être emprunté à un autre Refusée

Et la question large « fais-moi un résumé sur tout ce que tu sais sur les clients axonix » obtient une synthèse ancrée sur deux contrats, avec leurs références, et une phrase sur ce que les documents ne permettent pas de dire. La procédure de test complète est fournie dans le dépôt (tests-apres-modification.md).

Limites constatées


§8.10 Résumé des couches de mitigation

Couche Mécanisme Coût Interface
1. Prompt strict “Réponds uniquement à partir des documents, ignore les chunks hors sujet” Nul RAG API
2. Formation utilisateurs Proscrire “fais pareil”, questions directes Nul Toutes interfaces
3. Contrôles déterministes Citations inventées, réponse sans source Nul RAG API uniquement
4. Groundedness check Juge qwen3:4b, affirmation par affirmation ~2 à 8 s CPU RAG API uniquement

Les couches 1 et 2 s’appliquent à toute interface sans développement spécifique. Les couches 3 et 4 nécessitent la RAG API déployée en §3.


Retour au sommaire Section précédente : §7 Pipelines n8n Section suivante : §9 Sécurité et durcissement

Validé en lab sur VM-RAG-LAB, corpus Axonix SA + DOIT4EVERYONE (45 fichiers, 970 chunks), RAG API avec qwen2.5:14b et qwen3:4b, Open WebUI, septembre 2026.