Groundline
Questions-réponses documentaires ancrées, qui refusent de deviner
Répond à des questions sur un corpus privé et décline tout ce qu'il ne peut pas citer. L'ingestion, le découpage et le calcul des embeddings s'exécutent en tâches de fond, la recherche repose sur pgvector, et chaque affirmation d'une réponse est vérifiée contre un passage du document source avant qu'un seul token ne soit diffusé au client.
Qualité de la recherche, ancrage imposé et évaluation traitée comme une suite de tests.
- .NET 10
- ASP.NET Core
- Claude API
- PostgreSQL + pgvector
- Dapper
- Hangfire
- SignalR
- OpenTelemetry
Projet personnel — pas encore de dépôt public
Le problème qui vaut la peine d'être résolu
La génération augmentée par la recherche est facile à démontrer et difficile à faire confiance. Découpez quelques documents, calculez leurs embeddings, entassez les cinq meilleurs passages dans un prompt, et vous obtenez une réponse le plus souvent juste — qui invente de temps à autre une politique qui n'existe pas. Pour tout ce sur quoi une personne va agir, « le plus souvent juste » est la mauvaise cible.
L'exigence est donc inversée. Au lieu de chercher à rendre l'hallucination rare, le système est construit pour qu'une affirmation non étayée ne puisse pas atteindre le client : une affirmation sans citation est un bug que le pipeline attrape, et une question à laquelle le corpus ne peut pas répondre obtient un refus.
Un système qui répond correctement à 80 % des questions et refuse le reste est utile. Un système qui répond correctement à 95 % et devine le reste ne l'est pas, parce qu'on ne peut pas distinguer les deux.
Pourquoi ceci, et pas davantage de DeskPilot
DeskPilot place un modèle sur un chemin de requête, sous pression de coût et de latence. Groundline en place un derrière un système de recherche, où le problème difficile se situe entièrement en amont du modèle — si la recherche renvoie les mauvais passages, aucun prompt ne sauve la réponse.
| DeskPilot | Groundline | |
|---|---|---|
| Le point dur | Coût, latence, débit | Qualité de la recherche et ancrage |
| Rôle du modèle | Classer et rédiger | Résumer uniquement ce qu'on lui a remis |
| Où commence l'échec | Le fournisseur | Le découpeur |
| Bon mode de défaillance | Router vers un humain | Refuser de répondre |
| Forme des données | Un ticket court | Un corpus qui change sous vos pieds |
Le chemin d'ingestion
L'index de passages est ce qui rend la citation possible. Chaque fragment conserve l'identifiant du document, la page et les offsets de caractères dont il provient : une citation résout donc vers une plage exacte dans l'original, et non vers une paraphrase que le lecteur devrait aller vérifier à la main.
Le découpage est le plafond de qualité
Presque toutes les mauvaises réponses d'un système comme celui-ci remontent à une frontière de fragment. Coupez au milieu d'un tableau et les chiffres perdent leurs en-têtes. Coupez à nombre de tokens fixe et une clause perd la condition qui la qualifiait. Aucun travail sur les prompts ne réparera un fragment arrivé sans son contexte.
- Découper d'abord selon la structure du document — titres et sections — avant de se rabattre sur des limites de taille.
- Ne jamais couper un tableau ; un tableau trop grand devient son propre fragment, sa légende attachée.
- Chaque fragment porte son chemin de titres en préfixe : lu isolément, il dit encore de quoi il parle.
- Du recouvrement entre fragments voisins, pour qu'un fait situé près d'une frontière reste retrouvable des deux côtés.
Recherche hybride, puis reclassement
La recherche vectorielle est bonne sur le sens et mauvaise sur les tokens exacts. Demandez un code d'erreur, une référence de pièce ou un numéro de clause et les embeddings renverront volontiers quelque chose de sémantiquement voisin et littéralement faux. La recherche par mots-clés a la défaillance inverse. Exécuter les deux et fusionner n'est pas une précaution : c'est couvrir deux faiblesses distinctes et bien comprises.
| Étape | Outil | Ce qu'elle rattrape |
|---|---|---|
| Recherche dense | pgvector, cosinus sur les embeddings | Paraphrases et recouvrement conceptuel |
| Recherche creuse | Recherche plein texte de PostgreSQL | Identifiants exacts, codes, termes rares |
| Fusion | Reciprocal rank fusion | Fusionne les deux sans régler un poids par requête |
| Reclassement | Cross-encodage des meilleurs candidats | La précision dans la petite fenêtre qui atteint le modèle |
pgvector plutôt qu'une base vectorielle dédiée, parce que le corpus se mesure en centaines de milliers de fragments, pas en milliards. Garder les vecteurs dans PostgreSQL, c'est un seul entrepôt de données, une seule stratégie de sauvegarde, une seule transaction — et filtrer par locataire ou par document devient une clause WHERE au lieu d'une demande de fonctionnalité de filtrage sur métadonnées. Cette réponse change à une échelle que ce MVP n'atteindra pas.
L'ancrage est imposé, pas demandé
Demander à un modèle de citer ses sources produit des citations. Cela ne produit pas des citations correctes, et une citation fabriquée est pire qu'aucune, parce qu'elle a l'air d'un travail sérieux. Le contrôle est donc mécanique, et il s'exécute après la génération.
- 1Répondre en affirmations, pas en proseLe modèle renvoie une liste d'affirmations via un schéma d'outil, chacune accompagnée des identifiants de fragments dont elle provient — et non un paragraphe auquel on aurait greffé des notes de bas de page.
- 2Résoudre chaque citationChaque identifiant de fragment cité doit exister dans l'ensemble effectivement retrouvé pour cette question. Une citation vers autre chose est une fabrication et invalide la réponse entière.
- 3Vérifier l'appuiUne seconde passe, peu coûteuse, confronte chaque affirmation au seul passage qu'elle cite et répond à une question : ce texte étaie-t-il cette affirmation ? Les affirmations non étayées sont écartées.
- 4Refuser plutôt qu'amincirSi écarter des affirmations laisse la réponse incomplète, celle-ci devient un « impossible à répondre à partir de ces documents » explicite, avec les passages retrouvés les plus proches affichés. Le refus est une fonctionnalité.
Diffuser une réponse que l'on n'a pas fini de vérifier
Vérifier après la génération et diffuser pendant la génération sont en conflit direct : on ne peut pas diffuser un token que l'on risque de retirer ensuite. C'est la tension la plus vive de la conception, et la résolution consiste à diffuser la part déjà sûre.
- Les sources retrouvées sont diffusées en premier, immédiatement : le lecteur voit quels documents sont utilisés pendant que la réponse s'écrit encore.
- Les affirmations sont diffusées une par une, à mesure qu'elles passent la vérification, au lieu que la réponse entière arrive d'un bloc.
- Une affirmation écartée n'apparaît jamais : rien n'est donc visiblement retiré en cours de diffusion.
- SignalR transporte le tout, et un client qui se reconnecte récupère la réponse terminée plutôt que de reprendre un flux partiel.
Le résultat est plus lent à produire la première phrase complète qu'un système non ancré, et plus rapide à produire le premier signal utile, parce que les sources arrivent presque instantanément. L'échange est meilleur qu'il n'y paraît : savoir quels documents sont consultés est souvent l'essentiel de ce que le lecteur voulait.
L'évaluation, découpée en trois
Un chiffre unique de justesse est inutile pour un système comme celui-ci, parce qu'il ne dit pas quelle étape a régressé. La recherche et la génération sont mesurées séparément, et le refus est mesuré pour lui-même.
| Ce qui est mesuré | Métrique | Ce qu'elle dit |
|---|---|---|
| Recherche | Rappel@k sur des paires question/fragment annotées | Le bon passage est-il entré dans la fenêtre, tout simplement |
| Ancrage | Part des affirmations disposant d'un passage d'appui vérifié | Le modèle reste-t-il dans ce qu'on lui a remis |
| Refus | Comportement sur un jeu de questions volontairement sans réponse | Décline-t-il au lieu d'improviser |
C'est le jeu de refus qui compte le plus, et c'est celui qui manque d'habitude. Sans lui, toute modification qui rend le système plus enclin à répondre ressemble à une amélioration, et la régression n'apparaît que le jour où quelqu'un agit sur une invention formulée avec assurance.
Un corpus qui change sous vos pieds
Les documents sont révisés, remplacés, retirés. Un passage cité qui n'existe plus transforme une citation en lien mort, et une réponse ancrée dans une politique retirée est pire qu'une absence de réponse.
- Les documents sont versionnés ; les fragments appartiennent à une version, pas à un nom de fichier.
- La réingestion est incrémentale — seules les sections modifiées sont revectorisées, parce que recalculer chaque nuit les embeddings d'un corpus inchangé est du pur coût.
- Les versions remplacées restent interrogeables mais sont signalées, pour qu'une ancienne réponse puisse encore être expliquée après coup.
- Chaque réponse enregistre les versions de documents qu'elle a utilisées : c'est ce qui rend un audit possible, tout simplement.
La stack, et la raison de chaque brique
| Besoin | Choix | Pourquoi celui-ci |
|---|---|---|
| Accès au modèle | Microsoft.Extensions.AI | Une seule abstraction IChatClient pour la génération et la vérification, avec un pipeline de middlewares pour la télémétrie |
| Vecteurs | PostgreSQL + pgvector | Un seul entrepôt de données, de vraies transactions, le filtrage par locataire comme clause WHERE |
| Lectures | Dapper | La recherche hybride est par nature du SQL écrit à la main ; un ORM n'y apporte rien |
| Écritures | EF Core 10 | Gestion des documents et des versions, là où le suivi des changements se rentabilise |
| Ingestion | Hangfire | Des tâches longues, réessayables et observables, sans utilisateur qui attend |
| Résilience | Microsoft.Extensions.Http.Resilience | Pipeline standard first-party plutôt que des stratégies Polly assemblées à la main |
| Streaming | SignalR | Les sources d'abord, puis les affirmations vérifiées — uniquement côté client |
| Documentation d'API | Scalar + OpenAPI intégré | ASP.NET Core génère lui-même le document ; pas besoin de Swashbuckle |
| Observabilité | Serilog + OpenTelemetry | Rappel de la recherche, taux d'ancrage et taux de refus comme métriques, pas comme lignes de logs |
| Tests | xUnit v3 + Testcontainers | Un vrai PostgreSQL avec pgvector ; simuler un index vectoriel ne teste rien |
Les compromis que j'accepte
| Décision | Coût |
|---|---|
| Passe de vérification | Deux appels de modèle par réponse : plus lent, et environ le double du coût |
| Refuser quand rien n'étaie | Un taux de réponse plus faible, et des utilisateurs qui voulaient une estimation la trouveront inutile |
| pgvector plutôt qu'une base vectorielle | Un plafond de taille de corpus qu'un déploiement plus vaste atteindrait |
| Recherche hybride plus reclassement | Trois étapes à régler et à mesurer au lieu d'une |
| Des affirmations plutôt que de la prose | Des réponses qui se lisent de façon plus hachée qu'un paragraphe fluide |
Envie d'en discuter la conception ?
C'est une conception : elle est donc encore discutable. Si vous pensez que l'un des compromis ci-dessus est mauvais, je préfère l'entendre maintenant plutôt qu'après la construction.