Portfolio

Étude de cas · écrite le 31 octobre 2026 · chaque lien vers le site a été vérifié ce jour-là par requête HTTP (sans navigateur), chaque chiffre vient d’un fichier du dépôt public ou d’une réponse du site.

Lex : un système de recherche juridique construit comme un RAG d’entreprise

Lex montre le droit luxembourgeois et le droit de l’UE tels qu’ils se lisaient à une date, article par article, avec l’empreinte du texte, sa source officielle et un permalien. Il ne génère rien : la réponse est une enveloppe typée, et quand il ne sait pas, il le dit avec un refus typé. Cette page lit Lex avec la grille des quatre briques d’un RAG d’entreprise : l’analyse des documents, l’analyse de la question, la recherche, la génération.

English summary: Lex is a public legal-research site for Luxembourg and EU law as it stood on a date. This case study reads it as an enterprise RAG: relational document parsing (Formex and Akoma Ntoso into SQLite), deterministic question parsing, structure-first retrieval with no vector store, and no generation at all: a typed answer envelope with twenty typed refusals. It then covers evaluation gates, reproducible signed releases, what broke, and how the same bricks would answer insurance questions. The assistant is built and switched off today.

1. En un écran

Ce que c’est

law.soufien.lu, ouvert au public depuis le 27 octobre 2026. Une recherche, une lecture à une date, le dossier d’un acte, l’historique d’un article, la comparaison de deux dates, les modifications d’une période, l’export avec citations vérifiables. Interface en français et en anglais. API REST et MCP sur la même origine (/api/v3, /mcp). Code source public : github.com/SFHAJJI/lex (branche v3/integration).

Ce qu’il tient aujourd’hui

La version servie le 31 octobre (première publication, construite le 26 octobre) : 1 730 actes luxembourgeois en 1 730 versions, 8 854 articles, index construit le 12 octobre 2026 ; un index UE de 1 857 membres (RGPD, règlement IA, CRR, traités…), en anglais et en français. La page Ce que ce site détient dit ces chiffres, classe par classe, et ce qui manque. La deuxième publication, construite et signée le 28 octobre, porte 152 031 articles luxembourgeois et 191 670 articles UE ; elle n’était pas encore promue le 31 octobre (section 4).

Ce qu’il ne fait jamais

Donner un conseil, appliquer le droit à une situation, fabriquer sa propre consolidation, laisser un modèle choisir l’acte ou classer les résultats, substituer une date en silence. Chaque page le dit en une phrase.

Liens réels, vérifiés le 31 octobre 2026 (HTTP 200)

2. Les quatre briques, dans Lex

Brique 1 : l’analyse des documents, relationnelle

Lex lit deux formats officiels : Formex, le XML de l’Office des publications de l’UE (src/Lex.V3.Ingest/Europe/EuFormex*.cs), et Akoma Ntoso, le XML de Legilux (src/Lex.V3.Ingest/Luxembourg/LuxembourgAkn*.cs). Il ne les découpe pas en morceaux de taille fixe : il en extrait la structure dans deux index SQLite (luxembourg-index.sqlite3, europe-index.sqlite3), tables STRICT, construits deux fois et comparés octet pour octet.

Les tables de l’index luxembourgeois (schéma 9), LuxembourgIndexBuilder.cs
TableCe qu’elle tient
membersChaque fichier acquis : son empreinte, son issue (outcome), sa disposition de droits (rights_disposition).
articlesUn article d’une expression : article_identity_sha256, publisher_id, applicability_date, language, rule_profile_sha256 (la règle de lecture qui l’a produit), searchable_text, tokens_json, document_position.
statesUne version d’un acte : work_key, applicability_date, state_sha256, state_kind (as_published, consolidation, code), date_role, applicable_to, boundary, text_status (held, withheld_rights, not_available_pdf…), article_identities_json.
intervalsLes intervalles de validité entre versions successives, typés : contiguous, same_date, hole, overlap, inclusive_suspected, invalid. Un trou n’est jamais comblé en silence.
work_titles, work_factsLes intitulés (avec leur forme normalisée) et les faits de l’éditeur (dates, licences), chacun avec l’empreinte de sa preuve.
relationsLes citations d’article à article (edge_type cites), affirmées par le texte de l’éditeur, jamais inférées.
events, observationsLe journal : première apparition d’une version, fichier remplacé, intervalle fermé, validité révisée ; chaque construction d’index comme une observation datée.
stampL’identité du schéma, l’empreinte du corpus, l’empreinte des lignes logiques, la version de SQLite et ses options : ce qui rend la construction reproductible.

Côté UE (EuropeIndexBuilder.cs) : members, articles (avec heading, wording_date, publisher_work_celex), article_sources (le paquet et l’URI officielle de chaque article), article_digests, states (chaque libellé consolidé avec la date de consolidation de l’éditeur), force_dates (entrée en vigueur, application, fin de validité, avec leur prédicat source) et corrigendum_lines. Le texte est lu une fois, à l’ingestion ; une requête lit des lignes typées, pas un PDF.

Le « dossier » d’un acte : analyser le classeur, pas seulement les PDF

Un acte n’est pas un document : c’est une famille. Le dossier de Lex liste, pour un acte, ses intitulés, ses versions détenues avec leurs intervalles, les versions consolidées que l’éditeur nomme et que Lex ne détient pas (avec leurs dates), les relations, la phrase d’authenticité qui s’applique, et une liste not_held qui dit ce que le dossier ne contient pas et pourquoi (pas de date de publication, pas de type de document, pas de drapeau « en vigueur »). C’est l’équivalent juridique d’une cohérence inter-documents : la version, l’acte publié, ses consolidations et son journal officiel sont joints par des identifiants, et l’absence d’un membre est une donnée.

Brique 2 : l’analyse de la question, déterministe

Avant de chercher un mot, Lex lit la question comme une référence exacte (src/Lex.V3.Api/ExactReference.cs, CitationDates.cs, ArticleNumbers.cs) : un ELI, un CELEX (formes consolidées comprises), une citation au Mémorial ou au Journal officiel, une adresse du site, un acte par son type et sa date, ou la citation d’un article d’un acte à une date. Vérifié le 28 octobre : la recherche « Art. 1er de la loi du 4 décembre 2019 relative à l’Office du Ducroire Luxembourg » n’est pas cherchée mot à mot ; elle est reconnue (outcome: opens, kind: citation) et ouvre l’article ; « 32024R1689 » ouvre le dossier du règlement IA. Les numéros d’article sont lus à une clé (1er, premier et 1 sont la même ; 42 et 42bis sont deux numéros ; jamais un numéro voisin). La langue est un paramètre, pas une devinette. Aucun modèle ne reformule la question.

Brique 3 : la recherche, la structure d’abord

  1. L’identifiant. resolve, dossier : l’acte ou la version qu’un identifiant exact nomme, ou identifier_unknown.
  2. La version à une date. as_of, in_force_on, timeline : la version qu’une date sélectionne dans les intervalles typés ; no_version_for_date ou ambiguous_version plutôt qu’un choix silencieux.
  3. L’article. evidence_bundle, article_history, diff : l’article nommé dans cette version, ou anchor_not_in_version avec les ancres voisines et do_not_fall_back_to_full_text_search: true.
  4. Les mots, en dernier. search : une correspondance lexicale sur le texte des articles, dans deux voies (stricte : la phrase telle que tapée ; relâchée : chaque mot), pliée depuis le 27 octobre : accents, casse, apostrophes typographiques, espaces insécables (src/Lex.V3.Contracts/Index/TextFolding.cs), avec un extrait de 80 caractères avant et 200 après la première correspondance, dont les parties matched sont marquées sur les caractères d’origine. Il n’y a pas de base vectorielle, et la réponse le dit elle-même : modes_not_held: ["bm25", "semantic"], ranking: "none", page_is: "the first hits in the stated order, not the best hits".

Pourquoi pas de vecteurs ? Parce que la question d’un juriste porte presque toujours sur un objet identifié (cet acte, cet article, cette date), et qu’un rang par similarité y est un bruit : la fiche d’évaluation enregistre d’ailleurs un résultat négatif explicite (la recherche hybride n’est pas activée tant qu’un jeu d’or étiqueté par des personnes ne montre pas son gain ; section 4).

Brique 4 : la génération : aucune

Chaque réponse est une enveloppe (lex-v3-envelope/1) liée par empreinte au registre des opérations (registry_sha256), avec un verdict, un result typé ou un refusal typé, et un contexte qui nomme le corpus (snapshot_sha256), le code qui sert (serving_runtime_source_commit) et la fraîcheur par éditeur (age_days, maximum_data_age_days: 30). Le registre (src/Lex.V3.Contracts/Platform/V3OperationRegistry.cs) ferme l’ensemble des 27 opérations et des 20 codes de refus, chacun avec les champs que sa charge utile doit porter :

RefusCharge utile obligatoire
advice_boundarydescriptive_maximum, handoff
ambiguous_identifierrequested_identifier, candidates
ambiguous_versionrequested_date, candidates
anchor_not_in_versionrequested_anchor, nearest_anchors, do_not_fall_back_to_full_text_search
derivation_refusedderivation, reason
format_not_availablerequested_format, available_formats
identifier_unknownrequested_identifier, official_search_actions, what_would_answer
language_not_availablerequested_language, available_languages
no_corpus_mountedrequired_corpus
no_version_for_daterequested_date, history_begins, nearest_earlier, nearest_later
not_transposableinstrument_type, explanation
out_of_corpus_scoperequested_identifier, official_source, reason, who_to_ask
pinned_digest_mismatchrequested_digest, current_digest, stable_coordinate, current_hash_pinned_url
profiles_differleft_profile, right_profile
rate_limitedretry_after, retryable
retrieval_mode_unavailablerequested_mode, available_modes
snapshot_unknownsnapshot_id, what_would_answer
text_not_availableofficial_identity, official_source, retained_transport_evidence
text_withheldofficial_identity, official_link, content_sha256
upstream_unreachableupstream, retryable

Un refus porte toujours asserts_absence_of_law: false : un trou dans Lex n’est jamais une affirmation sur le monde. Un article cité porte text_sha256, body_sha256, l’ELI officiel et un permalien qui fige la date et l’empreinte de la version ; l’opération verify rejoue ce permalien. Les champs sont vérifiés contre l’entrée : il n’y a nulle part où inventer.

3. L’assistant : un contrat de réponse typé poussé à sa limite

État au 31 octobre 2026 : construit et éteint. L’opération ask du site public répond une carte assistant_v3_unavailable (vérifié par requête POST ce jour-là) et renvoie aux quatre opérations déterministes. Le code de l’assistant existe sur une branche du dépôt, revu mais pas encore fusionné ; l’interrupteur est éteint par défaut, l’interrupteur d’arrêt de l’opérateur l’emporte toujours, et en production aucun planificateur n’est câblé. Il ne s’allumera que quand ses contrôles passeront (ci-dessous).

Le principe (spécification de référence, règles R30 à R45) : le modèle ne fait que proposer un plan. Il reçoit la question masquée (adresses électroniques, téléphones, IBAN et numéros nationaux remplacés par des points de même longueur, pour que les offsets tiennent) et des références opaques produites par les analyseurs déterministes (i1 un identifiant, d1 une date, r1 une période, a1 un article). Il rend un plan JSON d’une grammaire fermée : une ou deux parties de la question (par offsets), un verdict proposé, une à quatre étapes sur une liste blanche d’opérations (resolve, search, dossier, as_of, timeline, article_history, diff, evidence_bundle, changes_in_period), chaque argument étant un span de la question, une référence, le résultat d’une étape, un choix dans un ensemble fermé ou « aujourd’hui ». Il n’y a aucun champ de texte libre dans le plan : il ne peut pas porter de prose.

Le code valide (taille, JSON, champs connus, énumérations, liste blanche, sources admises par paramètre, spans dans les bornes et hors des masques, plafonds : au plus 6 opérations, 10 ans de comparaison, 366 jours de modifications, 5 citations de 8 000 caractères) puis exécute à travers la même dispatch que REST et MCP, de sorte que les garde-fous de périmètre et de droits s’appliquent. Puis le code, et non le modèle, calcule le verdict, biaisé vers le refus. Chaque phrase visible du dossier de réponse est l’une de trois choses :

Les verdicts, avec leurs libellés français :

Trois contrôles machine sont dans le chemin du code et ont chacun un contrôle d’échec délibéré : la fidélité des citations (un vérificateur indépendant relit chaque article et recalcule l’empreinte : la citation doit être la sous-chaîne exacte à son offset, seuil 1,0) ; zéro phrase hors gabarit (chaque texte visible est re-rendu depuis son gabarit, ou égal au span, ou une citation vérifiée) ; le rejeu octet pour octet de la trace sur le même instantané. Rien n’est stocké côté serveur : la trace part chez le lecteur, qui peut la rejouer. Avant l’allumage restent des choses que seules des personnes peuvent faire : une lecture juridique écrite (RGPD, règlement IA art. 50), un modèle hébergé dans l’UE sans rétention, la relecture des gabarits et du jeu d’or par un avocat luxembourgeois, un jeu de 320 questions écrites par des personnes avec un second annotateur, et la signature du propriétaire. Jusque-là : éteint, et dit comme tel.

L’assistant du guide « Vivre au Luxembourg » applique ce même contrat : réponse typée vérifiée contre le guide, passage surligné, lien, et rien d’inventé ; le modèle y est facultatif.

4. Évaluation et production

Deux publications existent. La première (v3-20261026T180319Z-8958ec5fa63f, construite le 26 octobre) est celle que le site sert le 31 octobre. La deuxième (v3-20261028T142935Z-7583e4d0032d, construite le 28 octobre à partir du commit 7583e4d0) a passé ses contrôles, a été construite deux fois à l’identique et signée ; sa promotion n’était pas enregistrée le 31 octobre. Les chiffres ci-dessous disent de laquelle ils parlent.

Les contrôles machine (fiche d’évaluation, lex-v3-evaluation-card/1)

Les cas sont dérivés de l’index monté lui-même et posés au vrai gestionnaire de requêtes, pas à une maquette. Chaque jeu est accompagné d’un contrôle par permutation : les dates sont décalées de la médiane des écarts entre dates détenues (129 jours pour le Luxembourg, 252 pour l’UE), les verdicts ou les jugements de pertinence sont mélangés avec une graine fixe ; le contrôle doit faire échouer la porte, sinon la porte ne mesure rien.

Deuxième publication (28 octobre, evaluation-card.json) ; entre parenthèses, la fiche servie aujourd’hui par la première
JeuPorteCasValeurWilson 95 %Permutation
Temporel LU, as_of fraexactitude temporelle1 228 (120)1,00,9969 à 1609 cas, détectée
Temporel LU, in_force_on fraexactitude temporelle1 228 (120)1,00,9969 à 1802 cas, détectée
Temporel LU, sans langue (2 arms)exactitude temporelle1 231 (120)1,00,9969 à 1détectée
Temporel UE, as_of eng / fra / sans langueexactitude temporelle498 (562)1,00,9923 à 1302 / 296 / 295 cas, détectée
Refusverdict exact26 (21)1,00,8713 à 1exact tombé à 0, détectée
RecherchenDCG@10 de l’ancre531,0règle de trois : < 0,0566nDCG tombé à 0, détectée
Rechercheexactitude des non-réponses241,00,862 à 1détectée
Rechercheexactitude du résolveur211,00,8454 à 1détectée

La fiche dit aussi ce qu’elle ne mesure pas : huit jeux étiquetés par des personnes (D1 à D8 : pertinence, fidélité de dérivation, refus, verdicts de l’assistant, support par span, pont lexical, temporel, accord du juge) sont not_yet_labelled et gouvernent une étape ultérieure ; et un résultat négatif est consigné : la recherche hybride « n’est pas livrée », faute de mesure sur D6, avec la condition chiffrée qui renverserait la décision (recall@10 ≥ 0,7 en anglais et en allemand, ≥ 0,5 en luxembourgeois).

Les contrôles d’une publication à l’autre : rien de perdu sans explication

Avant qu’une image soit construite, release-checks (src/Lex.V3.Ingest/Releases/) compare le montage candidat à celui de la publication qui sert, éditeur par éditeur, classe par classe (release-comparison.json). Une version perdue, une empreinte de permalien changée, une empreinte de texte d’article changée, un article perdu d’une version servie des deux côtés ou une annexe UE perdue exige une raison typée (derivation_rule_changed, rights_withdrawn, withdrawn_by_publisher, defect_corrected), chacune vérifiée contre les deux montages ; toute perte inexpliquée refuse la publication. Pour la deuxième publication : 1 730 éléments listés, 0 inexpliqué, tous du même type (version_identity_changed : la règle de lecture du contenu est passée de /2 à /3, chaque version garde son texte et prend une nouvelle empreinte), 0 article perdu, aucune baisse au-delà de dix pour cent. Le contrôle R86(2) liste les fichiers consolidés admis sans texte : 6 (4 UE sans corps XML principal, 2 LU typés), 0 inexpliqué.

Deuxième publication contre la première, release-comparison.json (articles servis)
ClasseGardésAjoutésPerdusServis avant → après
UE, consolidations171 6649 9120171 664 → 181 576
UE, textes originaux2 1567 93802 156 → 10 094
LU, codes0121 11900 → 121 119
LU, lois publiées2 8286 26702 828 → 9 095
LU, lois consolidées06 45400 → 6 454
LU, règlements publiés5 0497 06805 049 → 12 117
LU, règlements consolidés03 24600 → 3 246

Des images reproductibles et signées

Le rapport de montage (mount-report.json, deuxième publication) : 262 092 membres de corpus, builtTwiceAndEqual: true, le commit de l’outil qui a construit l’index, la liste des cinq fichiers du montage avec leur taille et leur SHA-256, et la déclaration que le chemin de dérivation « ne tient aucune session éditeur et refuse de finir avec une activité réseau ». L’image OCI (3 369 974 784 octets) et le manifeste de publication sont signés par le signataire lex-v3-release (clé P-256 dans un coffre Azure ; seule la moitié publique est sur disque) ; la page de couverture, un manifest-diff.md et les signatures sont publiés avec les actifs. Une sonde de promotion refuse une publication dont une porte ne passe pas, dont un contrôle a manqué sa permutation, ou dont les données dépassent 30 jours.

Les noms de personnes privées, retenus

Un détecteur (src/Lex.V3.Ingest/Names/) lit les intitulés et le texte des actes : sur la deuxième publication, 974 actes du catalogue luxembourgeois (954 naturalisations, 8 concessions à une personne nommée, 4 biens d’une personne nommée, 8 intitulés nommant une personne) sont retenus par classe ; 0 acte servi a été signalé ; les signaux faibles (une mention de naissance dans 66 actes, un lieu de naissance dans 2) sont comptés sans retenir. Un acte retenu répond text_withheld avec la raison names_private_individuals, reste hors recherche et hors moteurs de recherche, et renvoie à l’éditeur. La précision sur texte servi n’est pas mesurée : c’est écrit.

Les licences, fichier par fichier

Les droits luxembourgeois ont deux canaux : le catalogue de l’éditeur (SPARQL) et le bloc de licence de chaque fichier. La politique A n’admet un texte que lorsque les deux concordent ; tout le reste est retenu avec une raison typée et compté dans le rendement par classe (textYield du rapport de montage) : conflict, sparql_cc_by_in_file_silent (le fichier ne dit rien), …names_other_identity (le bloc nomme un autre fichier), …own_block_empty, catalogue_licence_scl (6 129 lois et 10 201 règlements jamais acquis). Trois décisions du propriétaire (L1, L1b, L2) sont construites et éteintes, chacune mesurée (ensemble : +3 270 fichiers, +85 059 articles) ; elles portent, si elles s’allument, une mention de droits visible par fichier jusque dans les exports.

5. Ce qui a marché, ce qui a cassé, ce que je ferais autrement

Ce qui a marché

Ce qui a cassé

Ce que je ferais autrement

6. Les mêmes briques sur les sujets d’assurance

Un sinistre habitation est-il couvert par les conditions générales en ligne ?

Analyse des documents : les conditions générales sont un texte structuré comme une loi : titres, chapitres, articles numérotés, définitions, exclusions, franchises, plafonds, et des éditions datées. On les lit comme Lex lit un acte : un index relationnel par clause (clauses : édition, chapitre, numéro, intitulé, texte, empreinte), des tables de définitions et d’exclusions liées à la clause qui les porte, un « dossier » par produit joignant les éditions aux conditions particulières. Analyse de la question : la déclaration de sinistre devient des faits typés (type d’événement, bien, date, lieu, montant) par des dictionnaires d’abord (dégât des eaux, vol, bris de glace…), un modèle ne servant au plus qu’à proposer ces champs, que le code vérifie contre le texte. Recherche : la structure d’abord : l’édition en vigueur à la date du sinistre, puis les clauses de garantie, d’exclusion, de condition et de plafond pour ce type d’événement, par leur numéro ; le lexical plié en dernier. Réponse : un verdict typé couvert / non couvert / ambigu, avec, pour chacun, les clauses citées mot pour mot (plusieurs spans, chacun avec son empreinte et sa page), les conditions non vérifiables listées comme absentes du dossier (une franchise dont le montant est dans les conditions particulières non fournies est une absence enregistrée, pas une supposition), et ambigu quand deux clauses se croisent, en disant lesquelles. Le gestionnaire reste l’autorité ; le système traite le volume et trouve les passages.

Un assistant pour les nouveaux arrivants, à partir d’un site statique

C’est l’assistant du guide « Vivre au Luxembourg » (code) : intégré au guide public pour les nouveaux arrivants, il reconnaît la fiche que le lecteur lit, ouvre la bonne fiche quand la réponse est ailleurs, et surligne le passage qui la porte. Quatre briques : les tables du guide (fiche, section, bloc, FAQ) ; la question typée par un dictionnaire expert ; la recherche comme filtrage, avec un arbitre ; une réponse typée vérifiée contre le guide avant affichage, l’absence justifiée. Pas de base vectorielle ; le modèle est facultatif sur chaque chemin.

Excel, ligne par ligne

Une feuille est un document structuré : on l’analyse relationnellement (feuille, en-têtes typés, ligne, cellule, formule) et chaque réponse cite la cellule par sa coordonnée et sa valeur telle quelle, une ligne vide ou une colonne manquante étant une absence enregistrée plutôt qu’une valeur devinée.

Extraction SQL

Comme le plan de l’assistant de Lex : le modèle propose une requête sur un schéma fermé, le code la valide (lecture seule, tables et colonnes autorisées, délai, plafond de lignes), l’exécute, et c’est la requête exacte et ses lignes qui sont la réponse et la provenance, jamais la prose.

Sources de cette page : le dépôt public SFHAJJI/lex (schémas d’index, registre des opérations, lecteurs, contrôles de publication, fichiers d’état), les actifs de la publication du 28 octobre 2026 (evaluation-card.json, release-comparison.json, mount-report.json, manifest-diff.md, names-measurement.json) et les réponses du site le 28 et le 31 octobre 2026. Les textes de loi affichés par Lex restent ceux de leurs éditeurs officiels ; cette page n’en reproduit aucun.