Intelligence Artificielle

PostgreSQL pgvector pour RAG IA : tutoriel 2026

12 min de lecture

Construire un système RAG (Retrieval Augmented Generation) avec PostgreSQL + pgvector + Claude/OpenAI en 2026 (informations vérifiées en avril 2026, susceptibles d’évoluer).

Voir notre guide extensions PG.

Architecture RAG

  1. Indexation : documents → chunks → embeddings → pgvector
  2. Query : question utilisateur → embedding → similarity search → top K chunks
  3. Génération : Claude/GPT-4 reçoit la question + chunks → réponse contextualisée

Schéma SQL

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id serial PRIMARY KEY,
  source varchar(255),
  title text,
  created_at timestamp DEFAULT now()
);

CREATE TABLE chunks (
  id serial PRIMARY KEY,
  document_id integer REFERENCES documents(id) ON DELETE CASCADE,
  content text NOT NULL,
  embedding vector(1536) NOT NULL,
  metadata jsonb,
  created_at timestamp DEFAULT now()
);

-- Index HNSW pour recherche rapide
CREATE INDEX chunks_embedding_idx ON chunks
USING hnsw (embedding vector_cosine_ops);

Indexation (Node.js)

import OpenAI from "openai";
import { db } from "./db";

const openai = new OpenAI();

async function chunkText(text: string, maxLen = 1000): Promise<string[]> {
  const sentences = text.split(/(?<=[.!?])\s+/);
  const chunks: string[] = [];
  let current = "";
  for (const s of sentences) {
    if ((current + s).length > maxLen) {
      chunks.push(current);
      current = s;
    } else {
      current += " " + s;
    }
  }
  if (current) chunks.push(current);
  return chunks;
}

async function indexDocument(source: string, title: string, text: string) {
  const [doc] = await db.insert(documents).values({source, title}).returning();
  
  const chunks = await chunkText(text);
  
  const embeddings = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: chunks,
  });
  
  for (let i = 0; i < chunks.length; i++) {
    await db.insert(chunksTable).values({
      document_id: doc.id,
      content: chunks[i],
      embedding: embeddings.data[i].embedding,
    });
  }
}

Query RAG

import Anthropic from "@anthropic-ai/sdk";

const claude = new Anthropic();

async function ragQuery(question: string): Promise<string> {
  // 1. Embed la question
  const qEmbed = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: question,
  });

  // 2. Top 5 chunks similaires
  const results = await db.execute(sql`
    SELECT content, document_id
    FROM chunks
    ORDER BY embedding <=> ${qEmbed.data[0].embedding}::vector
    LIMIT 5
  `);

  const context = results.map(r => r.content).join("\n\n---\n\n");

  // 3. Génération avec contexte
  const response = await claude.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    messages: [{
      role: "user",
      content: `Contexte :\n${context}\n\nQuestion : ${question}\n\nRéponds uniquement avec les informations du contexte. En français.`,
    }],
  });

  return response.content[0].text;
}

Cas d’usage Afrique

  • FAQ intelligente sur catalogue produits PME
  • Assistant comptable qui répond depuis vos manuels comptables
  • Support client basé sur historique tickets
  • Recherche dans documents juridiques BCEAO/UEMOA

Dans la continuité

Étape 1 : comprendre le pattern RAG

RAG signifie Retrieval-Augmented Generation. Plutôt que d’envoyer toute votre documentation dans le prompt d’un LLM, vous indexez d’abord le contenu sous forme d’embeddings vectoriels, puis vous récupérez à la volée les 3 à 5 passages les plus pertinents pour chaque question utilisateur. Le LLM répond avec ces passages comme contexte. Avantages : réponses fondées sur vos données réelles (catalogue produits, FAQ banque, règlement intérieur), coût d’inférence réduit, mise à jour instantanée du corpus sans réentraîner le modèle.

Le composant clé est la base vectorielle. PostgreSQL avec pgvector 0.8 fait le travail aussi bien que Pinecone ou Weaviate, sans dépendance SaaS, sans coût additionnel, avec la garantie ACID que vous connaissez. Pour une équipe à Dakar ou Cotonou qui héberge déjà PostgreSQL, c’est la solution pragmatique.

Étape 2 : préparer la base PostgreSQL

Sur PostgreSQL 17 avec pgvector 0.8 installé (voir notre tutoriel extensions), créez la table de stockage des passages.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE knowledge_chunks (
  id bigserial PRIMARY KEY,
  source text NOT NULL,
  chunk_index integer NOT NULL,
  content text NOT NULL,
  embedding vector(1024),
  metadata jsonb DEFAULT '{}',
  created_at timestamptz DEFAULT now()
);

CREATE INDEX ON knowledge_chunks USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

CREATE INDEX ON knowledge_chunks USING gin (metadata);

La dimension 1024 correspond aux modèles d’embeddings modernes (Voyage-3, Cohere embed-multilingual-v3, BGE-M3). Si vous utilisez OpenAI text-embedding-3-small, ajustez à 1536. Le choix de la dimension est critique : impossible de la changer sans tout réindexer.

Étape 3 : découper la documentation en chunks

Un chunk est un passage de 200 à 500 mots. Trop court, il manque de contexte. Trop long, l’embedding moyenne trop de sujets et la pertinence chute. Voici un découpeur Node.js simple :

function chunkText(text, targetWords = 350, overlap = 50) {
  const words = text.split(/\s+/);
  const chunks = [];
  for (let i = 0; i < words.length; i += targetWords - overlap) {
    chunks.push(words.slice(i, i + targetWords).join(' '));
  }
  return chunks;
}

L’overlap de 50 mots évite de couper une idée en deux entre deux chunks consécutifs. Pour des documents structurés (Markdown, HTML), préférez un découpage par sections (h2, h3) qui respecte la sémantique. Outil prêt à l’emploi : langchain propose RecursiveCharacterTextSplitter qui gère ce cas.

Étape 4 : générer les embeddings avec Voyage AI

Voyage AI fournit un modèle multilingue (français, wolof, anglais) à 0,12 USD le million de tokens, soit environ 75 FCFA pour 10 000 chunks de 350 mots. Inscription, récupération de la clé API, puis appel REST :

async function embed(texts) {
  const r = await fetch('https://api.voyageai.com/v1/embeddings', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.VOYAGE_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      input: texts,
      model: 'voyage-3',
      input_type: 'document'
    })
  });
  const j = await r.json();
  return j.data.map(d => d.embedding);
}

Le paramètre input_type doit valoir document pour les chunks indexés et query au moment de la recherche : Voyage applique des projections différentes qui améliorent la précision de 5 à 8 %. Lot maximum : 128 textes par appel.

Étape 5 : insérer les vecteurs en base

import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

async function ingest(source, chunks) {
  const embeddings = await embed(chunks);
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    for (let i = 0; i < chunks.length; i++) {
      await client.query(
        'INSERT INTO knowledge_chunks (source, chunk_index, content, embedding) VALUES ($1, $2, $3, $4)',
        [source, i, chunks[i], JSON.stringify(embeddings[i])]
      );
    }
    await client.query('COMMIT');
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
}

La transaction garantit que vous n’avez jamais une source partiellement indexée. Sur 5 000 chunks, le pipeline complet (chunking + embeddings + insert) prend environ 4 minutes depuis un VPS Contabo à Francfort.

Étape 6 : requête de recherche sémantique

async function search(query, limit = 5) {
  const [queryEmbedding] = await embed([query]);
  const { rows } = await pool.query(`
    SELECT id, source, content,
           1 - (embedding <=> $1::vector) AS score
    FROM knowledge_chunks
    ORDER BY embedding <=> $1::vector
    LIMIT $2
  `, [JSON.stringify(queryEmbedding), limit]);
  return rows;
}

L’opérateur <=> calcule la distance cosinus. Le score (1 – distance) varie entre 0 et 1, 1 étant la similarité parfaite. En pratique, un score supérieur à 0,7 indique un chunk fortement pertinent. En dessous de 0,5, ne l’envoyez pas au LLM, vous risquez d’introduire du bruit.

Étape 7 : assembler le prompt et appeler Claude

async function answer(question) {
  const chunks = await search(question, 5);
  const context = chunks
    .filter(c => c.score > 0.5)
    .map((c, i) => `[Source ${i+1} : ${c.source}]\n${c.content}`)
    .join('\n\n');

  const r = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.ANTHROPIC_API_KEY,
      'anthropic-version': '2023-06-01',
      'content-type': 'application/json'
    },
    body: JSON.stringify({
      model: 'claude-haiku-4-5',
      max_tokens: 1024,
      system: 'Tu réponds uniquement à partir du contexte fourni. Si la réponse n\'y est pas, dis-le.',
      messages: [{
        role: 'user',
        content: `Contexte :\n${context}\n\nQuestion : ${question}`
      }]
    })
  });
  return (await r.json()).content[0].text;
}

L’instruction system bloque l’hallucination : le modèle ne répond qu’à partir des chunks fournis. Sur Claude Haiku 4.5, le coût d’une requête typique (3 000 tokens en entrée, 300 en sortie) est d’environ 0,002 USD soit 1,3 FCFA.

Étape 8 : évaluer la qualité du RAG

Construisez un jeu de 50 questions de référence avec leurs réponses attendues, validées par un expert métier. Mesurez deux métriques. Recall@5 : la bonne source est-elle dans les 5 chunks retournés ? Visez 90 %. Faithfulness : la réponse générée est-elle ancrée dans les chunks ou hallucine-t-elle ? Évaluez avec un LLM-as-judge ou manuellement sur 20 cas. Sous 80 %, ajustez le découpage ou le modèle d’embedding avant de mettre en production.

À lire ensuite

Combinez ce pipeline avec notre guide des extensions PostgreSQL et notre tutoriel Docker multi-stage pour un déploiement reproductible.

Étape 9 : reranking pour booster la précision

Les embeddings vectoriels sont rapides mais imparfaits : ils confondent parfois deux chunks qui partagent le vocabulaire sans partager le sens. Le reranking est une seconde passe qui réordonne les résultats avec un modèle plus lourd. Voyage AI propose rerank-2 à 0,05 USD le million de tokens.

async function rerank(query, candidates) {
  const r = await fetch('https://api.voyageai.com/v1/rerank', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.VOYAGE_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      query,
      documents: candidates.map(c => c.content),
      model: 'rerank-2',
      top_k: 5
    })
  });
  const j = await r.json();
  return j.data.map(d => ({ ...candidates[d.index], rerank_score: d.relevance_score }));
}

Le pattern courant : récupérer 25 candidats par recherche vectorielle, les passer au reranker pour ne garder que les 5 meilleurs. La précision Recall@5 monte typiquement de 82 % à 94 % sur des corpus francophones. Le coût additionnel est négligeable : 0,5 FCFA par requête.

Étape 10 : recherche hybride vector + keyword

Sur des corpus contenant des codes-produits, références SAP, ou identifiants techniques, la recherche par embedding seul rate les correspondances exactes. Combinez avec une recherche plein texte PostgreSQL :

WITH vector_search AS (
  SELECT id, content, 1 - (embedding <=> $1::vector) AS vec_score
  FROM knowledge_chunks
  ORDER BY embedding <=> $1::vector LIMIT 25
),
text_search AS (
  SELECT id, content, ts_rank(to_tsvector('french', content), plainto_tsquery('french', $2)) AS text_score
  FROM knowledge_chunks
  WHERE to_tsvector('french', content) @@ plainto_tsquery('french', $2)
  ORDER BY text_score DESC LIMIT 25
)
SELECT id, content, COALESCE(vec_score, 0) * 0.7 + COALESCE(text_score, 0) * 0.3 AS hybrid_score
FROM vector_search FULL OUTER JOIN text_search USING (id, content)
ORDER BY hybrid_score DESC LIMIT 10;

La pondération 70/30 favorise la recherche sémantique mais laisse la recherche lexicale rattraper les codes exacts. Pour un catalogue de produits Jumia ou un manuel technique avec références, cette hybridation est indispensable.

Étape 11 : filtres metadata et multitenant

Stockez la langue, la catégorie, ou l’ID client dans la colonne metadata JSONB. Filtrez avant ou après la recherche vectorielle :

SELECT id, content, 1 - (embedding <=> $1::vector) AS score
FROM knowledge_chunks
WHERE metadata->>'tenant_id' = $2
  AND metadata->>'lang' = 'fr'
ORDER BY embedding <=> $1::vector LIMIT 5;

Le filtre tenant_id garantit qu’un client A ne voit jamais les données d’un client B, point critique pour une plateforme SaaS multi-clients hébergée à Abidjan ou Dakar. L’index GIN sur metadata accélère ces filtres jusqu’à plusieurs millions de chunks.

Étape 12 : ingestion incrémentale et hash de contenu

Quand vous mettez à jour un document, évitez de réembedder les chunks inchangés. Stockez un hash SHA-256 du contenu :

ALTER TABLE knowledge_chunks ADD COLUMN content_hash text;
CREATE UNIQUE INDEX ON knowledge_chunks (source, chunk_index, content_hash);

Avant d’embedder, calculez le hash et faites un SELECT. Si le hash existe déjà, sautez l’embedding et l’insertion. Sur un corpus mis à jour quotidiennement avec 5 % de changements, cette astuce divise par 20 le coût mensuel d’embeddings, soit environ 1 800 FCFA d’économie sur un projet moyen.

Étape 13 : observabilité et logs des requêtes

Loggez chaque requête RAG avec la question, les top-5 sources retournées, leurs scores, et la réponse générée. Stockez dans une table rag_logs :

CREATE TABLE rag_logs (
  id bigserial PRIMARY KEY,
  ts timestamptz DEFAULT now(),
  question text NOT NULL,
  retrieved_ids bigint[],
  retrieved_scores real[],
  response text,
  user_feedback text
);

Analysez chaque semaine les logs avec un score moyen inférieur à 0,5 : ce sont les questions où votre corpus est insuffisant. Ajoutez de la documentation ciblée plutôt que de bricoler le prompt. Cette boucle de feedback améliore la qualité du système RAG plus vite que toute optimisation algorithmique.

Étape 14 : déploiement et coûts mensuels réalistes

Pour un système RAG servant 10 000 requêtes par mois avec un corpus de 20 000 chunks, le budget cloud se décompose ainsi. VPS Contabo VPS S 4 vCPU / 8 Go RAM / 200 Go SSD à 7 EUR/mois (4 590 FCFA) pour PostgreSQL et l’API Node. Voyage AI embeddings : ingestion initiale 50 FCFA, requêtes mensuelles 200 FCFA. Voyage AI rerank : 100 FCFA par mois. Claude Haiku 4.5 : 13 000 FCFA pour 10 000 réponses. Total : environ 18 000 FCFA par mois pour un service production-grade. À titre de comparaison, une instance Pinecone équivalente coûte 70 USD soit 46 000 FCFA, sans la base SQL relationnelle pour le reste de l’application. PostgreSQL+pgvector reste le choix le plus économique tant que le corpus tient sous 10 millions de chunks.

Bilan : un RAG qui tient en production

Le pipeline détaillé ici couvre tous les briques nécessaires pour un service RAG francophone fiable, mesurable, et économique. Découpage propre, embeddings adaptés au français et au wolof, recherche hybride pour les codes exacts, reranking pour la précision, observabilité pour l’amélioration continue. Avec ces fondations, votre équipe peut lancer un assistant interne ou un chatbot client en deux semaines, et l’améliorer mois après mois sur la base de signaux mesurés et non d’intuitions.

Partager