Danila (Dayfing)
Retour aux articles
3 036 mots16 min

RAG en production : recherche hybride, pgvector, ACL et qualité des réponses

Pourquoi une démo fonctionnelle ne suffit pas en production

La retrieval-augmented generation, ou RAG, est un produit de données et non une astuce de prompt. Une demande devient une ou plusieurs recherches, les résultats sont filtrés selon l'identité et le tenant, le modèle reçoit un contexte limité, puis la réponse revient avec des preuves. Chaque étape peut être correcte seule alors que la réponse finale est fausse. Le moteur peut trouver un paragraphe pertinent appartenant à un tenant que l'appelant ne doit pas voir. Un chunk peut contenir la phrase juste sans le titre qui lui donne son sens. Un modèle de langage peut produire une URL plausible qui ne faisait partie d'aucune source récupérée.

Le contrat de production doit être explicite. Stockez pour chaque chunk l'identité du document, la révision, l'emplacement source, l'ordre, les labels d'accès, le modèle d'embedding et la représentation lexicale. Séparez la révision courante des anciennes. Faites respecter la frontière du tenant par PostgreSQL et répétez le filtre d'autorisation. Une citation doit être un identifiant choisi parmi les lignes récupérées, pas un texte que le modèle peut inventer.

Cette conception tient dans une base PostgreSQL et permet plus tard de déplacer la recherche lexicale ou le reranker vers un service séparé. Le guide d'évaluation des agents IA détaille le jeu de tests, tandis que le guide d'observabilité des agents IA décrit la trace des étapes. Le guide sur l'injection de prompt et la sécurité MCP explique pourquoi le contenu récupéré ne doit jamais être traité comme une instruction.

Définir l'unité de recherche avant l'index

Le découpage en chunks est la première décision de qualité. Commencez par la structure de la source plutôt que par un nombre de caractères. Gardez un titre avec les paragraphes qui suivent, ne séparez pas une ligne de tableau de son libellé et préservez les limites des listes. Un chunk doit répondre seul à une petite question tout en gardant assez de contexte pour un reranker. Comptez les tokens avec le modèle d'embedding choisi, car une limite de caractères ne se traduit pas de manière constante entre les langues et le code.

Un chevauchement peut conserver une phrase qui franchit une limite, mais il duplique aussi les termes et augmente le travail d'embedding. Utilisez le plus petit chevauchement qui corrige les erreurs observées sur les frontières. Stockez source_start, source_end ou une ancre de source avec un hash normalisé du texte. Gardez un bloc de code intact lorsque sa syntaxe compte. Pour un long document, ajoutez le titre et le chemin des titres au texte envoyé à l'embedder.

Un embedding est une représentation versionnée du texte. Enregistrez le nom du modèle, la dimension, la règle de normalisation et l'heure de création. Modifier l'un de ces éléments peut changer l'ordre des voisins. Déclarer vector(1536) refuse une autre dimension et protège contre un mélange accidentel de modèles. Si plusieurs modèles coexistent, utilisez des colonnes ou tables avec des index distincts.

Un contrat de données PostgreSQL avec une frontière de tenant

Le schéma suivant conserve un pointeur vers la révision courante du document et toutes les métadonnées nécessaires à la recherche et aux citations. La configuration english est un exemple. Pour un corpus multilingue, utilisez une configuration de recherche par langue ou un service lexical qui sait gérer les langues du corpus.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE rag_documents (
  tenant_id uuid NOT NULL,
  document_id uuid NOT NULL,
  current_revision bigint NOT NULL,
  source_uri text NOT NULL,
  deleted_at timestamptz,
  PRIMARY KEY (tenant_id, document_id)
);

CREATE TABLE rag_chunks (
  chunk_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  tenant_id uuid NOT NULL,
  document_id uuid NOT NULL,
  revision bigint NOT NULL,
  chunk_no integer NOT NULL,
  title text NOT NULL,
  content text NOT NULL,
  source_start integer NOT NULL,
  source_end integer NOT NULL,
  acl text[] NOT NULL DEFAULT ARRAY['public']::text[],
  embedding vector(1536) NOT NULL,
  embedding_model text NOT NULL,
  search_tsv tsvector GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', content), 'B')
  ) STORED,
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, document_id, revision, chunk_no)
);

CREATE INDEX rag_chunks_search_tsv_idx
  ON rag_chunks USING gin (search_tsv);
CREATE INDEX rag_chunks_acl_idx
  ON rag_chunks USING gin (acl);
CREATE INDEX rag_chunks_tenant_idx
  ON rag_chunks (tenant_id);
CREATE INDEX rag_chunks_embedding_hnsw_idx
  ON rag_chunks USING hnsw (embedding vector_cosine_ops);

Si IVFFlat est l'index approximatif choisi, utilisez ses paramètres de listes et de probes à la place de HNSW :

CREATE INDEX rag_chunks_embedding_ivfflat_idx
  ON rag_chunks USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

Gardez un seul index approximatif pour la distance et le chemin d'accès d'une route, sauf si une migration ou une comparaison volontaire exige les deux.

La recherche plein texte de PostgreSQL représente les lexèmes normalisés par tsvector et la requête par tsquery. La colonne générée retire la préparation du chemin de requête, et un index GIN convient généralement aux recherches répétées. Si la sémantique BM25 exacte est nécessaire, utilisez un moteur lexical ou une extension qui expose BM25, puis transmettez son ensemble de candidats classés. ts_rank_cd de PostgreSQL est un rang lexical utile, mais ce n'est pas BM25. Il ne faut pas donner le même nom à deux scores différents.

Pour le rôle de l'application, activez la sécurité au niveau des lignes sur les deux tables et utilisez un paramètre de tenant local à la transaction, dérivé des claims authentifiés. Le rôle qui sert les requêtes ne devrait pas être propriétaire des tables. Sinon, forcez aussi l'application des politiques au propriétaire.

ALTER TABLE rag_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_documents FORCE ROW LEVEL SECURITY;
ALTER TABLE rag_chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_chunks FORCE ROW LEVEL SECURITY;

CREATE POLICY rag_documents_tenant ON rag_documents
  USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
  WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);

CREATE POLICY rag_chunks_tenant ON rag_chunks
  USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
  WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);

Au début de chaque transaction, définissez la valeur par un appel paramétré, puis exécutez la recherche et validez ou annulez la transaction. Une configuration absente ne correspond à aucun tenant. Une valeur authentifiée mal formée doit être rejetée avant d'ouvrir la transaction de base. L'application ajoute malgré tout les prédicats tenant_id, révision courante, deleted_at et ACL. La RLS est la dernière barrière, pas une raison de faire confiance à un tenant ID ou à une liste de rôles fournis par le client.

HNSW et IVFFlat sont des choix d'exploitation différents

Sans index approximatif, pgvector réalise une recherche exacte des plus proches voisins. Elle fournit une référence de rappel et peut rester pratique après un filtre sélectif. HNSW construit un graphe à plusieurs niveaux. Il offre généralement un compromis vitesse-rappel favorable, mais consomme davantage de mémoire et se construit plus lentement. Ses options m et ef_construction influencent la taille du graphe et le rappel.

IVFFlat divise les vecteurs en listes et sonde une partie des listes proches. Il utilise moins de mémoire et se construit plus vite, mais son rappel dépend du nombre de listes, de la distribution des données et du nombre de probes. Créez-le après avoir chargé des données représentatives. Le projet pgvector propose des heuristiques de départ pour choisir les listes et régler ivfflat.probes. Mesurez avec vos langues, vos filtres et votre fréquence de mise à jour.

Utilisez l'opérateur de distance correspondant au contrat d'embedding. La distance cosinus utilise <=>, le produit scalaire négatif utilise <#> et la distance L2 utilise <->. Un index HNSW cosinus doit utiliser vector_cosine_ops, comme dans le schéma. Ajoutez une clé secondaire déterministe au tri. Pendant une migration, construisez le nouvel index avec CREATE INDEX CONCURRENTLY pour ne pas bloquer les écritures ordinaires, en vous rappelant que cette commande ne peut pas être exécutée dans une transaction. Inspectez les plans avec EXPLAIN (ANALYZE, BUFFERS) sur des données représentatives et comparez les résultats approximatifs avec la recherche exacte.

Une recherche approximative filtrée demande de la prudence. pgvector applique le filtre après le scan de l'index, donc une petite liste peut contenir trop peu de lignes pour un tenant ou une ACL. Augmentez le budget de candidats, activez les iterative scans quand la version installée les propose, ou ajoutez un index relationnel sélectif. Un index vectoriel partiel aide pour peu de valeurs. Pour beaucoup de tenants, le partitionnement isole les populations, mais des milliers de partitions augmentent la planification. La documentation pgvector avertit qu'un index partagé permet aux vecteurs d'un tenant d'influencer le rappel d'un autre.

Combiner l'intention lexicale et la similarité vectorielle

La recherche vectorielle gère les paraphrases et les concepts proches. La recherche lexicale protège les identifiants exacts, les codes d'erreur, les noms de produits, les phrases entre guillemets et les termes nouveaux qu'un embedding peut mal représenter. Un pipeline robuste exécute les deux recherches sur le même ensemble autorisé de révisions courantes, prend plus de candidats qu'il n'en affichera et fusionne les rangs plutôt que les scores bruts.

La requête ci-dessous utilise le rang plein texte PostgreSQL pour la branche lexicale. Avec un service BM25, remplacez text_hits par ses candidats en conservant chunk_id, text_rank, tenant, révision et ACL. La Reciprocal Rank Fusion évite de supposer qu'une distance cosinus et un score BM25 ont la même échelle.

WITH q AS (
  SELECT $1::vector AS embedding,
         websearch_to_tsquery('english', $2::text) AS tsq,
         $3::uuid AS tenant_id,
         $4::text[] AS roles
),
vector_hits AS (
  SELECT c.chunk_id,
         row_number() OVER (
           ORDER BY c.embedding <=> q.embedding, c.chunk_id
         ) AS vector_rank
  FROM rag_chunks c
  JOIN rag_documents d
    ON d.tenant_id = c.tenant_id
   AND d.document_id = c.document_id
   AND d.current_revision = c.revision
  CROSS JOIN q
  WHERE c.tenant_id = q.tenant_id
    AND d.deleted_at IS NULL
    AND c.acl && q.roles
  ORDER BY c.embedding <=> q.embedding, c.chunk_id
  LIMIT 50
),
text_hits AS (
  SELECT c.chunk_id,
         row_number() OVER (
           ORDER BY ts_rank_cd(c.search_tsv, q.tsq) DESC, c.chunk_id
         ) AS text_rank
  FROM rag_chunks c
  JOIN rag_documents d
    ON d.tenant_id = c.tenant_id
   AND d.document_id = c.document_id
   AND d.current_revision = c.revision
  CROSS JOIN q
  WHERE c.tenant_id = q.tenant_id
    AND d.deleted_at IS NULL
    AND c.acl && q.roles
    AND c.search_tsv @@ q.tsq
  ORDER BY ts_rank_cd(c.search_tsv, q.tsq) DESC, c.chunk_id
  LIMIT 50
),
ranked AS (
  SELECT chunk_id, vector_rank, NULL::bigint AS text_rank
  FROM vector_hits
  UNION ALL
  SELECT chunk_id, NULL::bigint AS vector_rank, text_rank
  FROM text_hits
),
candidate_scores AS (
  SELECT chunk_id,
         min(vector_rank) AS vector_rank,
         min(text_rank) AS text_rank
  FROM ranked
  GROUP BY chunk_id
)
SELECT c.chunk_id,
       c.document_id,
       c.chunk_no,
       c.title,
       c.content,
       d.source_uri,
       s.vector_rank,
       s.text_rank,
       coalesce(1.0 / (60.0 + s.vector_rank), 0.0) +
       coalesce(1.0 / (60.0 + s.text_rank), 0.0) AS rrf_score
FROM candidate_scores s
JOIN rag_chunks c ON c.chunk_id = s.chunk_id
JOIN rag_documents d
  ON d.tenant_id = c.tenant_id
 AND d.document_id = c.document_id
 AND d.current_revision = c.revision
WHERE d.deleted_at IS NULL
ORDER BY rrf_score DESC, c.chunk_id
LIMIT 8;

La liste de rôles est construite par la couche d'autorisation. La condition acl && roles signifie qu'au moins un label se recoupe. Si la politique exige tous les labels, le propriétaire ou une fenêtre temporelle, utilisez un autre opérateur ou une colonne is_public. Paramétrez le texte de la requête et l'embedding. websearch_to_tsquery tolère la syntaxe utilisateur, alors que to_tsquery attend des opérateurs valides et ne doit pas recevoir un texte non vérifié.

Ne rerankez que les candidats autorisés

Un cross-encoder ou un autre reranker lit la requête avec chaque candidat. Il peut ainsi mieux distinguer deux correspondances proches qu'un seul embedding. Il ajoute toutefois du calcul et une étape séquentielle. Récupérez un ensemble borné, appliquez les filtres tenant, révision, suppression et ACL avant le reranker, puis ne transmettez que les champs nécessaires au classement. N'envoyez jamais des lignes non autorisées à un modèle externe, même si la réponse finale doit les omettre.

Conservez les rangs vectoriels et lexicaux initiaux pour le diagnostic. Journalisez les IDs des candidats, le mode d'index, les paramètres de probes ou de recherche, la version du reranker et les IDs finaux, en masquant ou protégeant le texte sensible. Le guide sur l'injection de prompt et la sécurité MCP explique que le texte récupéré est une donnée et non une instruction. Un document peut contenir une injection, donc le prompt de génération doit préciser que les passages sont des preuves non fiables et ne peuvent modifier les outils, les permissions ou les règles système.

Les citations doivent être des données

Chaque citation affichée doit résoudre vers un document_id, une revision, un chunk_no, un source_uri et un offset ou un titre de source récupéré. Demandez au générateur de renvoyer les IDs de citation près des affirmations, puis validez ces IDs contre les lignes exactes transmises au contexte. Rendez le titre et l'URL depuis la base. Supprimez un ID inconnu ou marquez l'affirmation comme non étayée. Le modèle ne doit pas fabriquer une URL à partir d'un simple titre.

Transmettez assez de contexte voisin pour expliquer un résultat, mais conservez l'ID de chaque chunk et sa provenance. Quand une source change, une citation vers l'ancienne révision ne doit plus être résolue dans la réponse courante. Dans les domaines à risque, affichez la date de révision, la portée d'accès et un état « aucune réponse » lorsque les preuves manquent.

Mesurer séparément la recherche et la réponse

Construisez un jeu d'évaluation versionné à partir de questions réelles, de documents attendus, de cas « aucune réponse » acceptables, d'identités de tenants, de labels ACL et de langues. Ajoutez des recherches exactes, des paraphrases, des questions en plusieurs étapes, des documents obsolètes et des requêtes adversariales. Gardez un jeu de test privé afin que le réglage ne mémorise pas les exemples du développement.

Pour la recherche, mesurez le recall@k à plusieurs cutoffs, le reciprocal rank ou nDCG pour l'ordre et la part des résultats qui respecte le contrat d'autorisation. Pour la génération, mesurez la précision des affirmations ancrées, la précision et la couverture des citations, l'exhaustivité, la qualité du refus et la calibration de l'état sans réponse. Une revue humaine reste utile pour les questions ambiguës. Comparez la recherche exacte à HNSW ou IVFFlat sur le même snapshot afin de mesurer la perte de rappel plutôt que de l'inférer de la latence.

Exécutez des tests de sécurité négatifs. Un utilisateur ne doit pas recevoir le secret connu d'un autre tenant, une modification d'ACL doit s'appliquer à la requête suivante et un document supprimé ne doit pas réapparaître. Un bon score ne compense pas une fuite de chunk. Enregistrez le modèle d'embedding, la version du découpage, les paramètres d'index, la version du reranker, du prompt et du corpus afin de reproduire une régression.

Rendre les mises à jour et suppressions sûres

Utilisez des hashes de contenu pour rendre l'ingestion idempotente. Analysez et découpez le document, créez les embeddings par lots et écrivez la nouvelle révision avant de déplacer le pointeur du document. Le changement de pointeur peut être une transaction courte. Les lecteurs voient alors soit l'ancienne révision complète, soit la nouvelle. Enregistrez le modèle d'embedding dans chaque ligne et planifiez un backfill quand le modèle change. Ne mélangez pas des vecteurs de dimensions différentes dans une colonne.

BEGIN;
SELECT set_config('app.tenant_id', $1::text, true);

INSERT INTO rag_chunks (
  tenant_id, document_id, revision, chunk_no, title, content,
  source_start, source_end, acl, embedding, embedding_model
)
VALUES ($1::uuid, $2::uuid, $3::bigint, $4::integer, $5::text, $6::text,
        $7::integer, $8::integer, $9::text[], $10::vector, $11::text)
ON CONFLICT (tenant_id, document_id, revision, chunk_no) DO UPDATE
SET title = EXCLUDED.title,
    content = EXCLUDED.content,
    source_start = EXCLUDED.source_start,
    source_end = EXCLUDED.source_end,
    acl = EXCLUDED.acl,
    embedding = EXCLUDED.embedding,
    embedding_model = EXCLUDED.embedding_model,
    updated_at = now();

INSERT INTO rag_documents (
  tenant_id, document_id, current_revision, source_uri, deleted_at
)
VALUES ($1::uuid, $2::uuid, $3::bigint, $12::text, NULL)
ON CONFLICT (tenant_id, document_id) DO UPDATE
SET current_revision = EXCLUDED.current_revision,
    source_uri = EXCLUDED.source_uri,
    deleted_at = NULL;

COMMIT;

Cet exemple insère un chunk. Un loader réel insère chaque chunk de la révision avant de mettre à jour le pointeur et vérifie le nombre de lignes attendu. Lors d'une suppression, rendez d'abord le document invisible pour la recherche en définissant deleted_at dans la même frontière d'autorisation. Supprimez physiquement les chunks après la rétention exigée par le produit et la conformité. Nettoyez les anciennes révisions par lots, surveillez les dead tuples et lancez VACUUM (ANALYZE) si nécessaire. Après beaucoup de rotations, un index HNSW peut rendre vacuum coûteux. Un reindex concurrent de l'index touché avant vacuum peut réduire le coût, mais mesurez-le sur la table réelle.

Budgéter latence et coût par étape

Suivez les latences p50, p95 et p99 séparément pour la normalisation, l'embedding, la recherche lexicale, la recherche vectorielle, la fusion, le reranking, la génération et la validation des citations. Enregistrez aussi le nombre de candidats, le nombre de lignes filtrées, les tokens, les retries et les cache hits. Une requête vectorielle rapide peut être masquée par un timeout du fournisseur d'embedding. Un retriever peu coûteux devient cher s'il envoie trop de passages au reranker ou au générateur.

Créez les embeddings de documents par lots et répétez les erreurs avec un backoff borné. Ne mettez en cache que des artefacts stables et non sensibles, avec la version du modèle, la normalisation et le périmètre du tenant dans la clé. Le cache d'embeddings de requêtes demande une revue de confidentialité, car une requête répétée peut révéler l'intérêt d'utilisateurs. Choisissez HNSW ou IVFFlat selon rappel, mémoire, temps de construction, écritures et latence de queue, jamais selon un benchmark générique. Rendez les limites de candidats et le budget du reranker configurables.

Un tableau de bord de production doit montrer le taux de résultats vides, le taux de réponses sans citations, les échecs de validation, les refus ACL, les hits de révisions anciennes, l'état de construction des index, la santé de vacuum et des échantillons de rappel approximatif contre exact. Ainsi, l'observabilité des agents IA rejoint la suite d'évaluation des agents IA. Alertez sur les changements de ces taux, pas seulement sur le CPU de la base.

Une séquence de mise en service pratique

Commencez par une recherche vectorielle exacte et la recherche plein texte PostgreSQL sur un corpus réduit. Ajoutez le pointeur de révision et la RLS avant d'inviter des tenants. Figez un jeu d'évaluation, puis comparez HNSW et IVFFlat aux résultats exacts. Ajoutez RRF, reranking et citations étape par étape afin que chaque changement ait un effet mesurable. Avant le déploiement, testez mises à jour, suppressions, ACL, réutilisation du pool de connexions et échecs de construction d'index.

La documentation officielle de pgvector décrit les types vectoriels, les opérateurs de distance, HNSW, IVFFlat, la recherche filtrée, les iterative scans, le partitionnement et la maintenance. La documentation PostgreSQL couvre la recherche plein texte, les index GIN et GiST pour le texte, les politiques de sécurité des lignes, la création d'index, le partitionnement et MVCC. Le SQL et les compromis présentés suivent ces interfaces. Ils ne promettent aucun résultat universel de latence, de rappel ou de coût.

Plus d’articles