Analyse approfondie
Détecter les conflits sémantiques entre documents : un pipeline pragmatique
Un pipeline en quatre étapes pour trouver où deux documents se contredisent, pas seulement où ils se recoupent : chunking et embedding, pré-filtrage cosinus, déduplication de section et expansion des voisins, puis détection d'inversion par NLI ou LLM. Inclut un déploiement sans GPU (CPU seul) et un modèle de coût sous dix centimes sur une charge réaliste.
Note par : Rémi Viau (mainteneur Anatoly), avec Claude (Anthropic Opus 4.7) comme partenaire d'analyse.
Comparer deux documents pour trouver ce qu'ils disent en commun est un problème résolu. Comparer deux documents pour trouver où ils se contredisent — même sujet, affirmation opposée — ne l'est pas. L'instinct naïf consiste à dégainer la similarité cosinus sur des embeddings, et elle vous trahira discrètement dès le premier jour.
Cet article déroule un pipeline qui fonctionne réellement, explique pourquoi chaque étape existe, et que faire quand vous n'avez ni GPU ni budget LLM illimité. L'architecture ci-dessous est celle que nous livrons quand un client nous demande de signaler les conflits entre, disons, un brouillon de contrat et sa version précédente, ou entre deux documents de spécification concurrents.
1. Le problème dont personne ne vous prévient#
Vous avez deux documents — appelons-les A et B. Ils couvrent un terrain qui se recoupe. Vous voulez un système qui fait remonter les passages où ils sont en désaccord, pas les passages où ils parlent simplement de la même chose.
Une première approche paraît trompeusement simple :
- Embedder les deux documents.
- Pour chaque chunk de
A, trouver les chunks les plus similaires dansBvia la similarité cosinus. - Faire remonter les paires.
Lancez ça sur un corpus réel et vous heurtez immédiatement deux modes de défaillance.
Le premier est que la similarité cosinus est aveugle à la négation. Les deux phrases ci-dessous ont une similarité cosinus bien au-dessus de 0,95 avec n'importe quel modèle d'embedding moderne :
A: The contract expires on December 31, 2025.
B: The contract does not expire on December 31, 2025.Ce sont des contradictions directes. La similarité cosinus les signale comme quasi identiques parce que l'espace d'embedding est dominé par le sujet et le vocabulaire, pas par les conditions de vérité. Votre seau de haute similarité — celui que vous voulez inspecter pour des conflits — sera donc rempli de paraphrases, de quasi-doublons, et d'une petite minorité de vraies contradictions, toutes indiscernables par le score seul.
Le second mode de défaillance est l'inverse. Deux passages peuvent se contredire sans aucun recouvrement lexical :
A: All employees are entitled to four weeks of paid leave.
B: Contractors are excluded from the paid leave program.Que ce soit une contradiction dépend de si les contractors sont considérés comme des employees dans le texte environnant. Les embeddings ne vous aideront pas ici. La cosinus non plus. Il vous faut du contexte, et il vous faut une vraie inférence.
Le pipeline doit donc faire trois choses : trouver les bonnes paires de passages à comparer, décider si chaque paire se contredit réellement, et faire les deux assez bon marché pour tourner sur de vrais documents.
2. Vue d'ensemble du pipeline#
L'architecture vers laquelle nous avons convergé a quatre étapes, chacune d'autant moins chère que la suivante est coûteuse :
┌──────────────┐ ┌─────────────┐ ┌────────────┐ ┌──────────────┐
│ Chunk + │───▶│ Cosine │───▶│ Section │───▶│ Inversion │
│ embed both │ │ similarity │ │ expansion │ │ detection │
│ documents │ │ (filter) │ │ │ │ (NLI / LLM) │
└──────────────┘ └─────────────┘ └────────────┘ └──────────────┘
~free, batch ~free, vector free, set the only
once per doc math at query arithmetic expensive callChaque étape existe pour réduire l'espace de candidats de la suivante. La similarité cosinus est un filtre permissif, pas un décideur. Au moment où l'on arrive à l'étape d'inversion coûteuse, on devrait passer des dizaines de paires par comparaison de documents, pas des milliers.
3. Étape 1 — Chunking et embedding#
Le chunking compte plus que le modèle d'embedding. Nous découpons les deux documents en fenêtres chevauchantes d'environ 200 à 400 tokens, avec ~50 tokens de recouvrement. Des chunks plus petits rendent la similarité cosinus plus tranchante mais perdent le contexte ; des chunks plus grands diluent le signal.
Trois règles qui ont tenu d'un projet à l'autre :
- Découper sur des frontières sémantiques, pas des comptes de caractères. Paragraphes, items de liste et titres de section doivent s'aligner sur les bords de chunk autant que possible. Couper en plein milieu d'une phrase casse l'inférence NLI en aval.
- Stocker l'ID de section parente avec chaque chunk. Vous en aurez besoin à l'étape 3 pour fusionner les résultats en quelque chose de lisible par un humain.
- Embedder une fois par version de document, mettre en cache agressivement. L'étape d'embedding est celle au plus fort débit mais ne tourne que quand un document change.
Pour le modèle d'embedding lui-même, n'importe laquelle des options standard fonctionne — text-embedding-3-small d'OpenAI, voyage-3 de Voyage AI, ou un bge-small-en-v1.5 local si vous avez besoin d'une souveraineté totale. Les différences à ce stade sont plus petites que ce que l'on prétend ; le filtre est grossier à dessein.
4. Étape 2 — La similarité cosinus comme filtre grossier#
C'est là que la plupart des équipes s'arrêtent, et là que la plupart des équipes se trompent. Le but de la similarité cosinus ici n'est pas de décider si deux passages se contredisent. Le but est d'écarter les 99 % de paires manifestement sans rapport, pour que le modèle coûteux en aval ne voie que des candidats plausibles.
Un seuil raisonnable se situe entre 0,75 et 0,85, selon votre modèle d'embedding et votre corpus. Sous 0,75, vous gardez trop de bruit ; au-dessus de 0,85, vous commencez à perdre de vraies contradictions dont la surface lexicale diverge. Calibrez sur un jeu d'évaluation ou vous devinerez indéfiniment.
Deux améliorations à considérer avant de livrer :
Retrieval hybride. La cosinus seule rate les contradictions formulées dans un vocabulaire différent. Ajouter un retriever lexical (BM25) et fusionner les ensembles de résultats — ou faire passer un reranker sur l'union — récupère des cas comme l'exemple « employees vs contractors » ci-dessus. Le coût est modeste ; le gain de rappel est réel.
Une passe de reranker. Un reranker cross-encoder (bge-reranker-v2-m3 est le cheval de trait gratuit du moment) score les paires ensemble plutôt qu'indépendamment, et capte des relations sémantiques que la similarité bi-encoder rate. Si vous pouvez vous permettre la latence, glissez-le entre la cosinus et l'étape d'inversion. Nous pesons le même reranker comme baseline de retrieval dans la note PageIndex vs RAG Anatoly.
5. Étape 3 — Déduplication de section et expansion des voisins#
Après le filtrage cosinus vous avez une liste de paires de chunks. Deux problèmes à les passer directement au modèle d'inversion.
D'abord, les chunks issus de la même section parente apparaissent de façon répétée à travers les paires. Une section de 5000 mots découpée en 15 chunks peut générer des dizaines de paires similaires qui pointent toutes vers le même désaccord sous-jacent. Dédupliquez en refusionnant les chunks dans leurs sections parentes.
Ensuite, les chunks individuels portent rarement assez de contexte pour juger une contradiction à eux seuls. La négation dans A peut vivre dans une phrase deux paragraphes plus haut. L'exception dans B peut être définie trois items de liste plus bas. Nous étendons chaque section retenue pour inclure ses voisins immédiats — la section avant et après, ou un paragraphe de chaque côté, selon la structure du document.
L'ordre compte : étendre d'abord, dédupliquer ensuite. Si vous dédupliquez d'abord, vous risquez de fusionner des chunks dont les expansions de voisins pointent dans des directions différentes, mélangeant deux discussions sans rapport en une seule paire bruitée. Étendez, puis effondrez sur l'identité de section parente.
La sortie de cette étape est un ensemble de candidats paire-de-sections, chacun portant assez de contexte pour être jugé indépendamment.
6. Étape 4 — Détection d'inversion#
C'est là que la vraie décision se joue. Deux options dominent en pratique.
6.1 Modèles d'inférence en langage naturel (NLI)#
Le NLI est exactement la tâche voulue : étant donné une prémisse et une hypothèse, classer la paire en entailment, contradiction ou neutral. Des modèles comme MoritzLaurer/DeBERTa-v3-large-mnli-fever-anli-ling-wanli sont conçus pour ça et surpassent les LLM généralistes sur la tâche étroite, pour une fraction du coût.
Avantages :
- Gratuit à faire tourner si vous avez un GPU, quasi gratuit sur CPU avec les variantes quantifiées.
- Sorties déterministes avec probabilités calibrées.
- Pas de prompt engineering, pas d'échecs de parsing.
- Aucune donnée ne quitte votre infrastructure.
Inconvénients :
- Fenêtre de contexte petite (~512 tokens). Les longues sections sont tronquées.
- Pas d'explication en langage naturel du pourquoi une paire a été signalée.
- Plus faible sur le raisonnement multi-étapes (« les contractors sont-ils des employees ? ») qu'un LLM frontier.
- Légèrement plus sensible à la formulation de surface qu'un LLM.
6.2 LLM en mode NLI#
Vous pouvez prompter un petit LLM pour produire la même classification ternaire. Le pattern est direct :
Given two passages, answer ONLY with one word:
- ENTAILMENT if passage B follows from A
- CONTRADICTION if B contradicts A
- NEUTRAL otherwise
Passage A: {section_a}
Passage B: {section_b}
Answer:Avec max_tokens: 3 et temperature: 0, ça marche bien avec n'importe quel petit LLM moderne — Claude Haiku, Gemini Flash, Llama 3.1 8B via Groq, DeepSeek. Le coût par paire est de fractions de centime. La latence est plus élevée que le NLI mais le raisonnement est plus robuste.
6.3 Quand choisir quoi#
| Contrainte | Choisir |
|---|---|
| GPU disponible, fort volume, pas besoin d'explications | Modèle NLI, hébergé localement |
| Pas de GPU, volume modeste, livrer vite | Petit LLM via API (Haiku, Flash, Groq) |
| Besoin d'explications en langage naturel pour l'utilisateur | LLM, ou NLI + LLM en cascade |
| Documents confidentiels, aucune API externe autorisée | NLI sur CPU avec ONNX + quantification INT8 |
| Précision maximale, coût indifférent | NLI pour filtrer, puis LLM frontier pour confirmer |
Le pattern cascade de cette dernière ligne est ce que nous recommandons en production : NLI sur chaque paire candidate comme filtre bon marché, puis un LLM frontier (Sonnet, classe GPT-4) sur les survivants pour confirmer et générer l'explication qu'un humain lira réellement. Vous obtenez le rappel du NLI et la précision et la verbosité du LLM, en ne payant les prix frontier que sur la poignée de paires qui comptent.
7. Déployer sans GPU#
Le rappel à la réalité le plus fréquent : la production tourne sur une machine CPU seulement. Trois choses changent.
Utilisez des modèles NLI quantifiés. Un DeBERTa-v3-base exporté en ONNX et quantifié en INT8 tourne à 50–150 ms par paire sur un CPU correct, contre 500 ms+ pour la version PyTorch non quantifiée. Utilisez la librairie optimum pour convertir ; la perte de précision sur les tâches NLI est typiquement sous 1 %.
Batchez agressivement. Envoyer 32 paires dans le modèle en une seule passe avant est radicalement plus rapide que 32 appels séquentiels. Si votre pipeline peut bufferiser les candidats et les traiter par rafales, faites-le.
Envisagez l'inférence hébergée. Si vous ne voulez pas gérer de serveur d'inférence du tout, Hugging Face Inference Endpoints hébergera un modèle NLI sur une petite instance GPU pour quelques dollars par jour — moins cher que les heures-ingénieur que vous passerez en optimisation CPU si votre trafic est intermittent. Replicate et Modal répondent au même besoin avec une facturation serverless.
OpenRouter n'est pas la réponse pour du vrai NLI. Il agrège des LLM, pas des encoders. Vous pouvez l'utiliser pour appeler un petit LLM en mode NLI, mais vous ne pouvez pas obtenir un NLI de classe DeBERTa par lui. Si votre décision est « OpenRouter ou Hugging Face Endpoints », cette décision est en réalité « petit LLM ou vrai NLI », et la bonne réponse dépend des arbitrages du §6.3.
8. Analyse de coût sur une charge réaliste#
Supposons une comparaison entre deux documents de 50 pages, ~50 chunks chacun, ~2500 paires candidates après produit cartésien, ~150 paires survivant au filtrage cosinus au seuil 0,80, ~40 paires de sections uniques après déduplication et expansion.
| Étape | Volume | Outil | Coût par comparaison |
|---|---|---|---|
| Embedding | 100 chunks une fois | text-embedding-3-small |
~0,001 $ |
| Cosinus + filtre | 2500 paires | maths vectorielles locales | ~0 $ |
| Dédup + expansion | 150 → 40 paires | code applicatif | ~0 $ |
| Inversion (NLI) | 40 paires | DeBERTa-base local | ~0 $ |
| Inversion (LLM) | 40 paires | Haiku à ~500 tokens/paire | ~0,02 $ |
| Explication (LLM) | ~5 paires confirmées | Sonnet à ~2000 tokens/paire | ~0,06 $ |
Total par comparaison de documents : moins de dix centimes avec le pattern cascade, quasi nul avec le NLI seul. Tout l'intérêt du pipeline est précisément de ne rien dépenser sur les 99 % de paires qui ne comptent pas, et de ne payer les prix frontier que sur les rares qui comptent.
9. Ce que nous avons appris à la dure#
Quelques points qui ne sont pas évidents tant que vous n'avez pas livré ça :
- Calibrez les seuils sur un jeu labellisé tôt. Les seuils cosinus et NLI « raisonnables » dérivent avec le type de document. Construisez un jeu d'évaluation de 50 paires le jour où vous démarrez ; revisitez-le chaque fois que vous changez de modèle. La même démarche empirique a produit le résultat sur la discipline de concision.
- Affichez des scores, pas des verdicts, dans l'UI. Un binaire « conflit détecté » masque l'incertitude du modèle. Montrer un score de confiance laisse les relecteurs trier et vous laisse ajuster les seuils sur l'usage réel.
- Cachez les embeddings par hash de contenu, pas par ID de document. Les documents sont renommés, re-uploadés, édités. Hasher directement le contenu du chunk évite d'invalider tout un cache d'embeddings sur une correction de coquille.
- Testez avec des paires adverses. Construisez un jeu de régression incluant paraphrases, quasi-doublons avec négation d'un seul mot, et les contradictions qui exigent un raisonnement multi-saut. Si ces trois catégories ne scorent pas toutes correctement, votre pipeline n'est pas prêt.
- Prévoyez l'étape d'explication dès le premier jour. Les relecteurs ne feront pas confiance à une sortie boîte noire « ça se contredit ». Le LLM au bout de la cascade n'est pas une UX optionnelle — c'est la différence entre un outil que les gens utilisent et un outil qu'ils ignorent.
10. Où aller ensuite#
Le pipeline ci-dessus est livrable. Il a été validé sur des paires de contrats, des révisions de spécifications et des documents de politique dans trois langues. La frontière suivante — celle que nous explorons activement — est la détection de contradiction implicite : les cas où le conflit n'émerge qu'après que le système a raisonné conjointement sur les deux documents, plutôt que sur des paires de sections individuelles.
Ce problème ressemble davantage à une tâche de planification qu'à une tâche de retrieval, et les techniques commencent à recouper le travail documenté dans l'index recherche. Si vous travaillez sur le même problème, l'index recherche est un bon point de départ.
Pour les questions d'implémentation, ouvrez une discussion sur le dépôt GitHub ou contactez l'auteur directement. Le contexte marché plus large autour de l'audit et de la review de code est dans Audit IA vs Review IA de code en 2026.