Accueil / Articles

Recherche hybride dans Postgres pour un assistant de santé : schéma, segmentation, RRF et évaluations.

Recherche hybride dans Postgres pour un assistant sanitaire : segmentation en fonction de la structure, pgvector avec RRF, quantification binaire, seuils de score et une évaluation appropriée.

Éditeur : REACTAPP.TOP19 min de lecture4283 mots

Sources: RAG in Postgres: What I Learned Building a Health Assistant Where Being Wrong Is Expensive

Les démos d’une « intelligence artificielle pour la santé » ont tendance à ressembler toutes identiques. Un ensemble de données public est stocké dans un entrepôt vectoriel, une instruction indique que le modèle est un assistant médical utile, et les trois premières questions reçoivent de belles réponses. Puis vient la quatrième question.

Celle-ci contient une négation. Ou sa bonne réponse dépend d’un document révisé il y a dix-huit mois, qui a remplacé un autre écrit en 2011. Ou deux sources publiques fiables sont en désaccord, et le comportement correct consiste à le mentionner et à expliquer pourquoi c’est important, plutôt que de choisir un gagnant. Ou la réponse honnête est « Je ne sais pas, demandez à un clinicien », mais un modèle entraîné pour être utile écrit quand même un paragraphe plein de confiance.

C’est avec cette quatrième question que le produit trouve réellement son utilité, et presque aucun des efforts nécessaires pour y répondre ne se produit au sein du modèle de langage. Cela relève plutôt du système de récupération des données, qui, dans ce cas, est Postgres. Ce qui suit décrit cette couche : schéma, segmentation en blocs, requête de recherche hybride, quantification ainsi que des outils d’évaluation qui empêchent l’envoi de résultats défectueux. Ce système est indépendant de toute source de données, et ces principes s’appliquent à tout corpus où une réponse erronée a de lourdes conséquences.

Quatre façons dont les textes médicaux contrecarrent le RAG naïf

Les corpus médicaux ouverts présentent quatre caractéristiques qui compromettent silencieusement le processus standard consistant à segmenter les données, à les intégrer dans des vecteurs, à utiliser la similarité cosinus et à les inclure dans la prompt.

  1. Le sens n’a pas de forme de paragraphe. Une monographie de médicament comporte des sections distinctes pour les contre-indications, les interactions et les dosages. En la divisant en fenêtres glissantes de 512 tokens, certaines parties contiendront la moitié d’une contre-indication et la moitié d’un tableau de dosages. Ces parties sont récupérées avec succès, affichant une forte similarité cosinus, mais une faible valeur de vérité. C’est l’échec le plus dangereux dans cette chaîne, car les métriques de récupération semblent encore bonnes.
  • Le vocabulaire est bimodal. Les utilisateurs décrivent des brûlures d’estomac après les repas, tandis que le corpus utilise l’expression « douleur épigastrique postprandiale ». La recherche sémantique comble bien cette lacune. Cependant, les utilisateurs saisissent également des identifiants précis, tels que le nom d’un médicament, un code ou un composé, et dans ce cas les embeddings performent moins bien qu’un index de mots-clés. Un modèle qui place le métoprolol et le métoprolol succinate à des positions voisines peut inciter quelqu’un à commettre une erreur. Les deux modes de récupération sont nécessaires, à fusionner plutôt qu’à choisir l’un plutôt que l’autre.
  • La fraîcheur est une question sémantique. Ailleurs, un document ancien est considéré comme légèrement moins bon. Dans le domaine de la santé, cela peut être activement nocif, car les directives sont révisées justement parce que la version précédente était erronée. La fraîcheur ne peut pas être un critère de décision ajouté en fin de processus ; elle doit être un champ de premier plan sur lequel tant la couche de récupération que la couche de réponse prennent en compte leurs décisions.
  • Les réponses doivent respecter une structure légale. Certaines questions doivent être orientées vers des procédures prédéfinies plutôt que répondues de manière détaillée : les symptômes d’urgence, les décisions relatives aux doses pour une personne spécifique, ainsi que tout ce qui revient à un diagnostic. Ces cas nécessitent une procédure fixe et basée sur des règles, qui ne peut être influencée par les impressions subjectives du modèle.
  • Chacun de ces cas constitue un problème lié à la couche de récupération des informations, ce qui conduit à des décisions monotones qui finissent pourtant par avoir le plus d’impact.

    Pourquoi Postgres et non une base de données vectorielle

    Le compromis à prendre mérite d’être clairement exposé, car l’argument selon lequel « on n’a pas besoin d’une base de données vectorielle » est devenu trop simpliste, alors que des cas concrets existent où elle est indispensable.

    Une base de données vectorielle dédiée offre un meilleur taux de rappel par milliseconde à une échelle très élevée, des types d’index plus riches, une recherche filtrée conçue pour filtrer dès le départ, ainsi qu’une scalabilité horizontale qui ne nécessite aucune réflexion supplémentaire. Avec des centaines de millions de vecteurs sous une charge de requêtes importante, c’est un véritable avantage qui doit être exploité.

    Postgres ne propose qu’une seule frontière de transaction. Cela peut sembler anodin, mais ce n’est pas le cas. Dans un corpus en évolution, une architecture à deux systèmes entraîne des coûts réels :

    • Lorsqu’un document source est modifié, ses anciens fragments doivent être supprimés, de nouveaux fragments insérés, les embeddings recomputés et les métadonnées mises à jour, tout cela de manière atomique. Avec un stockage vectoriel séparé, l’atomicité n’existe pas. Dans le pire des cas, l’index des mots-clés et l’index vectoriel ne sont pas d’accord sur ce qui existe, ce qui amène l’assistant à citer un document qui ne correspond plus à ce pour quoi il a été cité.
    • Le filtrage par licence, juridiction, autorité de source, date de révision et langue, suivi d’un classement selon la distance vectorielle, correspond à une clause WHERE dans Postgres. Sur deux systèmes, cela signifie soit une réplique de métadonnées maintenue en synchronisation, soit une routine de récupération préalable suivie d’un filtrage qui renvoie silencieusement moins de résultats que demandé.
    • Identifier pourquoi une réponse donnée a été récupérée il y a six semaines revient à effectuer une jointure.

    Le plafond de scalabilité existe bel et bien, mais il est plus élevé que ce que l’on imagine : il est possible d’atteindre des dizaines de millions de blocs sur une instance bien optimisée avec pgvector actuel. Une règle sensée consiste à commencer par Postgres et à considérer toute migration ultérieure comme une étape de succès et non comme un objectif de conception. Un projet qui n’atteint jamais ce plafond évite complètement les problèmes liés aux systèmes distribués. Un projet qui l’atteint, en revanche, dispose déjà d’un produit fonctionnel, de journaux de requêtes réels et d’une vision claire de ses besoins.

    Les versions sont importantes. pgvector 0.7.0 a introduit halfvec, sparsevec, l’indexation de type binaire et binary_quantize. La version 0.8.0 a ajouté des balayages d’index itératifs, qui facilitent le problème de filtrage décrit ci-dessous, et la série 0.8.x a ajouté la prise en charge de Postgres 18. Avec 0.6.x, la plupart des éléments suivants ne fonctionneront pas tel qu’écrit.

    Schéma

    Quatre tables constituent la structure. La règle est que l’origine des données est structurée, jamais sous forme de blob JSON : tout ce que la couche de réponse doit citer, filtrer ou analyser dispose de sa propre colonne.

    CREATE EXTENSION IF NOT EXISTS vector;
    CREATE EXTENSION IF NOT EXISTS pg_trgm;
    -- Where knowledge comes from, and how much we trust it.
    CREATE TABLE source (
      id bigserial PRIMARY KEY,
      slug text UNIQUE NOT NULL,
      license text NOT NULL, -- redistribution rules are a query concern
      authority smallint NOT NULL CHECK (authority BETWEEN 1 AND 5),
      base_url text
    );
    -- One row per upstream document, at whatever granularity upstream uses.
    CREATE TABLE document (
      id bigserial PRIMARY KEY,
      source_id bigint NOT NULL REFERENCES source(id),
      external_id text NOT NULL, -- the stable ID assigned upstream
      title text NOT NULL,
      url text,
      published_at date,
      revised_at date, -- recency is a column, not a vibe
      superseded_by bigint REFERENCES document(id),
      lang text NOT NULL DEFAULT 'en',
      raw_hash bytea NOT NULL, -- skip work on unchanged re-ingests
      meta jsonb NOT NULL DEFAULT '{}',
      UNIQUE (source_id, external_id)
    );
    -- Retrieval unit. Note heading_path: structure survives chunking.
    CREATE TABLE chunk (
      id bigserial PRIMARY KEY,
      document_id bigint NOT NULL REFERENCES document(id) ON DELETE CASCADE,
      ord int NOT NULL,
      heading_path text[], -- {Contraindications,"Renal impairment"}
      body text NOT NULL,
      body_tsv tsvector GENERATED ALWAYS AS
        (to_tsvector('english', body)) STORED,
      token_count int NOT NULL,
      content_hash bytea NOT NULL,
      lang text NOT NULL, -- denormalized from document
      is_current boolean NOT NULL, -- denormalized: superseded_by IS NULL
      UNIQUE (document_id, ord)
    );
    CREATE INDEX chunk_tsv_idx ON chunk USING gin (body_tsv);
    CREATE INDEX chunk_trgm_idx ON chunk USING gin (body gin_trgm_ops);
    -- Embeddings are versioned by model. This is not optional.
    -- lang and is_current are DENORMALIZED from document -- see below.
    CREATE TABLE embedding (
      chunk_id bigint NOT NULL REFERENCES chunk(id) ON DELETE CASCADE,
      model text NOT NULL,
      vec halfvec(1536) NOT NULL,
      lang text NOT NULL,
      is_current boolean NOT NULL,
      PRIMARY KEY (chunk_id, model)
    );
    

    Quatre détails sont porteurs de charge.

    Les embeddings sont identifiés par morceau et modèle. Les modèles d’embeddings changeront. Lorsque cela se produira, les deux versions doivent être en ligne en même temps afin de pouvoir être testées en mode A/B sur du trafic réel et être remplacées sans interruption de service. Intégrez le modèle dans la clé primaire dès le premier jour et créez un index partiel par modèle pour que chacun reste compact :

    CREATE INDEX embedding_hnsw_v3_en
      ON embedding USING hnsw (vec halfvec_cosine_ops)
      WITH (m = 16, ef_construction = 128)
      WHERE model = 'emb-v3' AND lang = 'en' AND is_current;
    

    Le langage et le drapeau actuel sont délibérément copiés tant dans le chunk que dans l’embedding. C’est le seul endroit où la normalisation a été intentionnellement brisée, et il a fallu un temps embarrassantement long pour en comprendre la raison. Un prédicat d’index partiel dans Postgres ne peut faire référence qu’aux colonnes de la table indexée. Un index sur l’embedding ne peut pas contenir WHERE document.is_current ; Postgres le rejette catégoriquement, y compris les sous-requêtes. Afin d’intégrer des filtres importants dans l’index vectoriel, ce qui est souhaité pour les raisons expliquées dans le piège de filtrage ci-dessous, ces colonnes doivent physiquement se trouver dans la ligne d’embedding.

    Assurer l’exactitude des copies est complexe, et la première tentative a commis deux erreurs. Le drapeau actuel n’est pas défini lors de l’ingestion proprement dite du document. Il change lorsque un autre document le remplace, de sorte que la mise à jour s’effectue au sein de la transaction du document de remplacement et doit atteindre chaque morceau ainsi que chaque ligne d’incorporation appartenant au document obsolète. Il convient de regrouper cette propagation dans une seule fonction avec un seul point d’appel, et d’écrire un test vérifiant que les valeurs dénormalisées correspondent toujours à celles du document parent. Lorsque les deux mécanismes de recherche ne s’accordent pas sur les lignes actuelles, la fusion mélange les positions provenant de deux corpus différents, ce qui entraîne une dégradation silencieuse des résultats sans qu’aucune erreur ne soit signalée.

    Cela n’est pas non plus gratuit. Inverser l’état du drapeau supprime ces lignes des deux index HNSW partiels, et HNSW ne libère de l’espace qu’au cours d’une opération de vidage, ce qui signifie que des modifications fréquentes entraînent inévitablement un gonflement progressif des index.

    La colonne vectorielle est de type halfvec, et non vector. Le stockage de nombres flottants à 16 bits réduit la taille du chargement brut de 6 152 octets à 3 076 pour 1 536 dimensions, et l’index HNSW diminue encore davantage car les tuples de petite taille s’empilent plus compactement dans les pages. Ici, la récupération n’a besoin de fournir que quelques 50 candidats au reranker, et lors des tests l’écart de rappel est resté dans les limites du bruit de fond. Avantage supplémentaire : halfvec peut être indexé jusqu’à 4 000 dimensions, contre 2 000 pour les vecteurs classiques, ce qui est utile pour les grands modèles d’embedding.

    La suppression est stockée sous forme de pointeur vers la même table, et non sous forme de booléen. Un simple marqueur obsolète ne donne que trop peu d’informations à l’assistant. Savoir quel document a remplacé celui-ci lui permet d’expliquer que les directives ont changé et de faire un lien vers la version actuelle. Il s’agit d’une jointure, possible uniquement si le lien a été enregistré au moment de l’importation.

    C’est le chunking qui apporte la plus grande valeur

    Si l’on doit en tirer une leçon : le découpeur de texte est plus important que le modèle d’incorporation. Un meilleur modèle permet d’obtenir quelques points supplémentaires en termes de rappel. Un découpeur qui respecte la structure permet de distinguer un assistant qui a raison d’un autre qui se trompe de manière flagrante. Trois règles s’appliquent.

    La structure en premier, la taille en second. Analysez chaque document pour en faire un arbre de titres et conservez chaque morceau de texte au sein d’une seule section de niveau supérieur. Lorsqu’une section est plus petite que la taille cible, laissez-la telle quelle plutôt que de la fusionner avec sa voisine : une contrainte de 60 tokens signifie simplement que c’est plus utile qu’un morceau de 500 tokens où il disparaît dans le flot. Les limites de tokens ne doivent être utilisées qu’en dernier recours lorsque la structure ne suffit plus, et non comme règle principale.

    N’essayez jamais de diviser une table. Les tables relatives à la santé sont généralement des grilles de doses, des matrices d’interactions ou des critères d’éligibilité, des contenus où la moitié des lignes ne vaut rien du tout. Si le budget est dépassé, divisez-la en groupes de lignes, répétez l’intitulé pour chaque groupe, puis transformez le résultat en phrases avant de l’incorporer. Une ligne du type « Âge de 6 à 11 ans : 5 mg une fois par jour » s’intègre bien mieux qu’une grille séparée par des barres.

    Placer le contexte en tête avant d’incorporer le texte. Le texte intégré ne doit pas être le fragment brut, mais plutôt le fragment avec son historique restauré : une ligne d’en-tête indiquant la source, le titre du document et la date de révision, suivie du chemin de la section comme « Contre-indications », puis « Insuffisance rénale », et enfin le corps du texte. Intégrez cela, et conservez le corps brut pour l’affichage et les citations. Un fragment tel que « ne pas utiliser chez les patients présentant une clairance réduite » ne dit presque rien en tant que vecteur isolé, car il ne mentionne aucun médicament, aucune condition ni aucun contexte. Il ne devient consultable que lorsque l’en-tête indique au modèle de quoi il s’agit. Ce seul changement a amélioré le taux de rappel plus que n’importe quel remplacement de modèle essayé, et il ne coûte que des tokens au moment de l’indexation.

    Deux notes d’implémentation suivent. Calculez le hash du contenu sur le texte contextualisé, afin qu’une ré-ingestion ne modifiant qu’un titre déclenche correctement une nouvelle insertion. De plus, effectuez l’ensemble du processus de remplacement du document en blocs en embedding au sein d’une seule transaction, de sorte que le corpus ne soit jamais partiellement mis à jour :

    BEGIN;
      DELETE FROM chunk WHERE document_id = $1; -- cascades to embedding
      -- insert new chunks + embeddings
    COMMIT;
    

    Cette transaction est la raison principale pour laquelle ce système est hébergé dans Postgres.

    Récupération hybride avec fusion des rangs réciproques

    Chaque requête effectue une recherche sémantique et une recherche par mots-clés séparément, puis les fusionne en fonction du rang, et non en fonction du score brut. La fusion des scores ne fonctionne pas : la distance cosinus et ts_rank_cd relèvent de échelles différentes, ne sont pas comparables entre les requêtes, et tout poids appliqué à un ensemble de requêtes entraînera un surajustement. La fusion des rangs réciproques contourne complètement ce problème en ne se basant que sur les positions.

    WITH params AS (
      SELECT $1::halfvec(1536) AS qvec,
             $2::text AS qtext,
             60::int AS rrf_k, -- damping constant
             50::int AS pool
    ),
    -- Two rules in this CTE, both non-obvious, both explained below:
    -- 1. qvec comes from a SCALAR subquery, never a joined relation.
    -- 2. Filters live INSIDE each arm, not in the outer query.
    semantic AS (
      SELECT chunk_id, row_number() OVER (ORDER BY dist) AS rank
      FROM (
        SELECT e.chunk_id,
               e.vec <=> (SELECT qvec FROM params) AS dist
        FROM embedding e
        WHERE e.model = 'emb-v3'
          AND e.lang = 'en'
          AND e.is_current
        ORDER BY e.vec <=> (SELECT qvec FROM params)
        LIMIT (SELECT pool FROM params)
      ) s
    ),
    lexical AS (
      SELECT chunk_id, row_number() OVER (ORDER BY score DESC) AS rank
      FROM (
        SELECT c.id AS chunk_id,
               ts_rank_cd(c.body_tsv,
                          websearch_to_tsquery('english',
                            (SELECT qtext FROM params))) AS score
        FROM chunk c
        WHERE c.body_tsv @@ websearch_to_tsquery('english',
                              (SELECT qtext FROM params))
          AND c.lang = 'en' -- same predicate as the semantic arm,
          AND c.is_current -- from the same denormalized source
        ORDER BY score DESC
        LIMIT (SELECT pool FROM params)
      ) l
    ),
    fused AS (
      SELECT chunk_id,
             SUM(1.0 / ((SELECT rrf_k FROM params) + rank)) AS score
      FROM (
        SELECT chunk_id, rank FROM semantic
        UNION ALL
        SELECT chunk_id, rank FROM lexical
      ) u
      GROUP BY chunk_id
    )
    SELECT c.id, d.title, d.url, d.revised_at, s.slug, s.authority,
           c.heading_path, c.body, f.score
    FROM fused f
    JOIN chunk c ON c.id = f.chunk_id
    JOIN document d ON d.id = c.document_id
    JOIN source s ON s.id = d.source_id
    ORDER BY f.score DESC, s.authority DESC, d.revised_at DESC NULLS LAST
    LIMIT 20;
    

    Un élément classé troisième en termes de sens et septième en termes de mots-clés surpasse celui pour lequel un seul critère est arrivé en première position. C’est intentionnel, car deux signaux indépendants concordants ont plus de poids qu’un seul signal strident. websearch_to_tsquery est le analyseur le plus adapté, car il accepte les entrées réelles des utilisateurs, les phrases entre guillemets ainsi que les exclusions indiquées par un trait d’union, sans échouer face à du texte mal formaté. ts_rank_cd est ici supérieur à ts_rank classique, car la densité de couverture favorise les termes qui apparaissent proches les uns des autres, ce qui convient aux questions combinant plusieurs concepts.

    Deux caractéristiques de cette requête semblent être des choix stylistiques, mais ce n’est pas le cas.

    Piège numéro un : le vecteur de requête doit être une sous-requête scalaire

    Dans la branche sémantique, le vecteur de requête arrive sous forme de sous-requête scalaire, et non en tant que colonne provenant d’une relation de paramètres jointe. C’est la différence entre un milliseconde et une minute.

    La version intuitive sélectionne à la fois dans le tableau d’incorporation et dans la relation des paramètres, puis classe les résultats en fonction de la distance par rapport à la colonne des paramètres. Elle fonctionne bien, est valide et retourne les lignes correctes, ce qui explique précisément pourquoi elle passe les audits de code. Le problème, c’est que le planificateur ne peut utiliser un index de voisin le plus proche que lorsque l’instruction ORDER BY compare la colonne indexée à une valeur fixe pour toute l’opération de balayage. Une valeur obtenue via une jointure ne remplit pas ces conditions, ce qui signifie qu’il n’y a pas de chemin ordonné : Postgres lit alors chaque ligne et les classe manuellement.

    Avec cinquante mille blocs, cela coûte quelques centaines de millisecondes, un temps suffisamment lent pour être perceptible et suffisamment ambigu pour être attribué au réseau. Avec cinq millions de blocs, on atteint un délai d’exécution maximal.

    Vérifiez plutôt que de faire confiance. Expliquez la requête de récupération dans CI et assurez-vous que le plan comprend un balayage d’index sur l’index HNSW avec une ligne Order By. Un balayage séquentiel suivi d’un tri signifie que l’index vectoriel n’a qu’une fonction décorative. Un test qui échoue lors de la compilation dans ce cas en vaut la peine, car une refonte bien intentionnée peut réintroduire le problème sans produire une seule réponse erronée, seulement des réponses lentes, à terme, en production.

    Deuxième piège : le filtrage après le LIMIT

    Les filtres, à savoir actuel, langue et non remplacé, doivent figurer à l’intérieur de la sous-requête de chaque branche, avant le LIMIT. La première version les plaçait dans la requête externe, là où ils devraient être, mais c’était la mauvaise solution.

    HNSW renvoie des voisins les plus proches approximatifs. En prenant les 50 meilleurs selon la distance, puis en les filtrant, seuls 12 survivent généralement. Le groupe de 50 devient silencieusement 12, la fusion des classements fonctionne désormais sur un ensemble contaminé par des lignes destinées à être éliminées, et un LIMIT 20 final renvoie discrètement 9 résultats. Les effets de colapsage ne sont pas enregistrés dans les journaux. Chaque résultat individuel semble toujours plausible, ce qui empêche quiconque de signaler un bug. L’assistant se dégrade simplement de manière subtile pour les questions dont le corpus a été filtré de manière inégale, ce qui, dans le cas d’un corpus de santé, signifie des questions portant sur du matériel révisé récemment.

    Trois corrections, dans l’ordre de préférence :

    1. Intégrez le filtre dans un index partiel. C’est pour cette raison que la langue et le drapeau actuel ont été copiés dans la ligne d’incorporation. Une fois que le prédicat de l’index correspond à la clause WHERE de la requête, l’index ne contient plus que les lignes filtrées et le problème disparaît. Cette méthode convient aux dimensions à faible cardinalité et à évolution lente comme la langue, la monnaie ou le niveau de source, mais elle ne convient pas aux éléments ayant de nombreuses valeurs, car cela signifierait de devoir gérer une centaine d’index.
    2. Les balayages d’index itératifs (pgvector 0.8.0 et versions ultérieures) prennent en charge les filtres trop dynamiques pour être intégrés directement :
    SET hnsw.iterative_scan = relaxed_order;
    SET hnsw.max_scan_tuples = 100000; -- default is 20000
    SET hnsw.scan_mem_multiplier = 4; -- raise if the above is not enough
    

    Avec cette fonction activée, l’index continue de rechercher de nouveaux candidats jusqu’à ce que suffisamment d’entre eux passent le filtre. Deux limites s’appliquent : le filtre doit être appliqué au même niveau de requête que la balayage de l’index, sinon rien ne change, et le balayage a une limite, ce qui peut entraîner un retour de moins de lignes que demandé. Cela atténue le problème sans pour autant le résoudre. Choisir relaxed_order au lieu de strict_order améliore le taux de rappel au détriment de résultats qui ne sont pas exactement triés par distance, ce qui est acceptable lorsqu’un réordonneur est utilisé par la suite.

    Vitesse grâce à la quantification binaire

    À grande échelle, la solution la plus économique est une récupération en deux étapes : sélectionner des candidats à partir d’un index binaire en fonction de la distance de Hamming, puis réévaluer précisément cette liste restreinte.

    CREATE INDEX embedding_bit_idx ON embedding
      USING hnsw ((binary_quantize(vec)::bit(1536)) bit_hamming_ops)
      WHERE model = 'emb-v3' AND lang = 'en' AND is_current;
    SELECT chunk_id
    FROM (
      SELECT chunk_id, vec
      FROM embedding
      WHERE model = 'emb-v3' AND lang = 'en' AND is_current
      ORDER BY binary_quantize(vec)::bit(1536)
               <~> binary_quantize($1::halfvec(1536))
      LIMIT 400
    ) candidates
    ORDER BY vec <=> $1::halfvec(1536)
    LIMIT 50;
    

    La première étape doit fournir une quantité généreusement supérieure, environ huit fois celle du pool final. La réévaluation précise en deuxième étape corrige le classement, de sorte que l’étape binaire n’a besoin que de la présence des candidats, et non de leurs rangs exacts.

    La précision concernant les économies réalisées est importante, car Internet est peu rigoureux à leur égard. Avec 1 536 dimensions, un vecteur binaire occupe environ 200 octets, contre environ 3 KB pour un halfvec, ce qui représente une réduction de 16 fois des données brutes. Le chiffre de 32 fois cité partout est basé sur un vecteur à précision complète, que ce schéma n’utilise pas.

    L’index ne diminue pas de 16 fois, et la raison est édifiante. Chaque tuple HNSW comporte des frais fixes d’environ quelques centaines de octets, des liens entre nœuds du graphe ainsi qu’un en-tête de tuple, indépendamment du contenu véritable. Avec 3 KB de données halfvec, ces frais supplémentaires ne représentent qu’un bruit ; avec un vecteur binaire de 200 octets, ils dépassent même la taille du contenu lui-même. En ajoutant les pertes liées au regroupement des pages, la compression réelle reste bien en deçà du ratio théorique. Sur ces données, l’index binaire s’est avéré environ 4 à 5 fois plus petit que celui basé sur halfvec. Cela détermine néanmoins si un index tient dans la RAM, ce qui est l’objectif principal, mais toute personne qui s’attend à une compression 32 fois supérieure sera déçue. Mesurez la taille avec pg_relation_size avant de planifier la capacité, et utilisez des vecteurs réalistes : une étude de cas a perdu toute une après-midi parce que les vecteurs synthétiques étaient tous positifs, binary_quantize a généré des valeurs identiques à 1, et Postgres a compressé tout le contenu jusqu’à l’absence totale de données.

    Le reste est une procédure standard bien documentée. Accordez plus d’espace à maintenance_work_mem avant de créer des index HNSW, sinon le processus s’écrit sur le disque et prend des heures au lieu de minutes. Choisissez la valeur hnsw.ef_search selon la requête : entre 100 et 200 lorsque les erreurs sont coûteuses, autour de 40 pour l’autocomplétion. Créez les index de production EN PARALLÈLE. Testez-les avec vos propres données, car les chiffres de rappel publiés ne sont que rarement transférables.

    Le réclassement et le courage de ne rien retourner

    Fusion restitue une liste des 20 meilleurs résultats plutôt que des 5 meilleurs, or la requête ne permet que 5 résultats. Un réclasseur à codage croisé, qui traite la requête et les candidats comme un couple plutôt que de comparer des vecteurs stockés, apporte la plus grande amélioration en termes de qualité après une recherche hybride. Il est lent, ce qui explique pourquoi il ne prend en compte que 20 candidats et non un million.

    Ce que les gens négligent : utiliser les scores du réclassificateur comme seuil absolu, et non simplement pour classer. Si le meilleur candidat est en deçà d’un certain seuil, la réponse appropriée n’est pas le meilleur parmi des options médiocres. Cela revient à admettre que le corpus ne contient rien de fiable sur le sujet.

    C’est là que la plupart des assistants de santé échouent, et la solution réside dans le choix d’un produit dont la fonctionnalité est implémentée dans le code. Un système conçu pour répondre à tout inventera des réponses aux questions rares. Si le réclassage se contente de classer, le modèle reçoit toujours une réponse, et un modèle fiable en écrit toujours une en retour. Un seuil de score transforme un échec de recherche en refus clair plutôt qu’en fabrication fluide. Définissez ce seuil à l’aide de questions étiquetées dont on sait que les réponses manquent dans le corpus, et maintenez-le lorsque les utilisateurs se plaignent de trop de refus.

    Comprendre d’abord la requête

    Avant la récupération des résultats, il y a une étape qui ajoute un délai : un modèle compact et rapide qui reformule la question avant même que l’index ne la voie.

    • Étendre et normaliser. Traduire le langage courant en utilisant le vocabulaire du corpus sans perdre le sens original. Les deux versions entrent dans la requête hybride : l’original est envoyé à la composante lexicale, tandis que l’édition étendue est envoyée à la composante sémantique.
    • Séparer les questions composées. Une question demandant si deux médicaments peuvent être combinés pendant la grossesse correspond en réalité à trois recherches distinctes : chacun des médicaments, ainsi que l’aspect lié à la grossesse. Il faut les exécuter séparément, fusionner les classements, puis réclasser l’ensemble combiné.
    • Résoudre le contexte. Une question complémentaire, comme celle portant sur une dose plus élevée, n’a de sens que dans le contexte global. Il faut intégrer la question précédente et la reformuler en tant que question indépendante avant de l’incorporer, car les embeddings ne conservent aucune mémoire.
  • Classer et diriger. Les signaux d’urgence, les demandes de dosage personnel et tout élément de nature diagnostique sont exemptés de récupération et suivent un chemin de réponse déterministe. Le classificateur doit être très réactif. Un faux positif se traduit par un message légèrement agaçant indiquant « veuillez consulter un clinicien », tandis qu’un faux négatif représente l’échec qui met fin au fonctionnement du produit.
  • Ancrage grâce à des citations structurées

    La phase de génération est brève. La majeure partie des efforts est consacrée au contrat de sortie, et non à la formulation de l’instruction. Étiquetez chaque fragment récupéré dans l’instruction avec une étiquette stable et exigez une sortie structurée où chaque affirmation indique les étiquettes sur lesquelles elle s’appuie :

    {
      "claims": [
        { "text": "…", "sources": [2, 5] },
        { "text": "…", "sources": [1] }
      ],
      "uncertain": true,
      "route_to_clinician": false
    }
    

    Vérifiez le résultat dans le code, et non dans la demande. Une affirmation dont la liste de sources est vide est supprimée avant affichage, et si trop d’affirmations sont concernées, toute la réponse est rejetée et le processus de refus prend le relais. Cela sépare la demande de justification de sa garantie : une demande n’est qu’un espoir, tandis qu’un validateur représente une règle. Le taux d’élimination sert également de métrique de production gratuite, un indicateur en temps réel des hallucinations pouvant déclencher des alertes sans que personne ne lise les transcriptions.

    Deux autres choix importants ont été pris. Afficher la date de révision du document à côté de chaque citation permet aux utilisateurs d’évaluer eux-mêmes la fraîcheur des informations. De plus, lorsque les sources consultées sont en conflit, il convient de les présenter telles quelles plutôt que de les résoudre. L’indication « La source A indique X, la source B indique Y » est plus utile et plus honnête qu’une moyenne synthétisée.

    Évaluation, ou devinette

    Changer les paramètres de livraison sur une intuition n’est pas une option ici. Les modes de défaillance sont subtils, la surface d’analyse est vaste, et chaque paramètre affecte tous les autres. Le système de soutien qui fonctionne bien comporte plusieurs couches.

    Seulement la récupération des informations, sans LLM. Quelques centaines de requêtes avec des identifiants de segments pertinents étiquetés manuellement, permettant d’évaluer le taux de rappel@20 et le nDCG@10. Le processus s’effectue en quelques secondes, détecte la plupart des dégradations et, ce qui est crucial, sépare la qualité de la récupération de celle de la génération. Lorsque les réponses se détériorent, il indique immédiatement si le problème vient du corpus ou de l’instruction donnée.

    Fidélité. Pour les réponses générées, il faut vérifier que chaque affirmation est étayée par les segments qu’elles citent. L’évaluation des modèles fonctionne assez bien à condition que l’évaluateur ait une tâche simple : une phrase, un paragraphe, et une réponse oui ou non concernant le soutien fourni.

    Exactitude du refus. Un ensemble distinct contient les questions dont le traitement correct consiste en un refus ou en un redirigeant, afin que les changements de seuils ne puissent pas éroder silencieusement ce comportement.