Intelligence Artificielle angularforall.com

- Embeddings et recherche sémantique : pgvector + OpenAI

Embeddings Pgvector Postgresql Openai Rag Recherche-Semantique Hnsw Vector-Search Ia-Generative Node-Js Chunking Rrf Knowledge-Base Search-Engine
Embeddings et recherche sémantique : pgvector + OpenAI

Construisez un moteur de recherche sémantique avec pgvector et l'API OpenAI : chunking, index HNSW, recherche hybride RRF et coûts maîtrisés en production.

Embeddings : intuition et cas d'usage

Un embedding transforme un texte en vecteur de nombres (typiquement 1536 dimensions chez OpenAI). La magie : deux textes proches en sens produisent des vecteurs proches géométriquement. "Comment réinitialiser mon mot de passe ?" et "j'ai oublié mes identifiants" n'ont aucun mot commun, mais leurs embeddings sont quasi-identiques.

Cette propriété ouvre une famille entière de fonctionnalités produit qu'il était impossible d'implémenter avec une recherche full-text classique : recherche sémantique, déduplication intelligente, recommandations, classification automatique, RAG (Retrieval-Augmented Generation), et clustering thématique.

Cas d'usage concrets

Cas d'usage Volume typique Latence cible Stack pertinent
Recherche FAQ / docs 1k - 100k chunks < 100 ms pgvector + ivfflat
RAG produit (chatbot support) 10k - 1M chunks < 200 ms pgvector + HNSW
Moteur recherche multi-tenant 10M+ chunks < 50 ms Qdrant / Pinecone
Recommandation produit 100k - 10M items < 100 ms pgvector + jointure SQL
Pourquoi pgvector d'abord ? Si vous avez déjà du Postgres en production, pgvector réutilise tout : transactions, sauvegardes, jointures, ACL. Les bases vectorielles dédiées sont une stack supplémentaire à opérer. Démarrez ici, migrez plus tard si vraiment nécessaire (10M+ vecteurs ou updates massives).

Installer pgvector dans Postgres

pgvector est une extension Postgres open source maintenue par pgvector.io. Disponible nativement sur RDS, Supabase, Neon, et installable en deux commandes sur un Postgres self-hosted.

Installation locale (Docker)

# Image officielle pgvector basée sur postgres:16
docker run -d --name pg-vector \
  -e POSTGRES_PASSWORD=secret \
  -p 5432:5432 \
  pgvector/pgvector:pg16

# Activation de l'extension dans la base
docker exec -it pg-vector psql -U postgres -c "CREATE EXTENSION vector;"

# Vérification
docker exec -it pg-vector psql -U postgres -c "SELECT extversion FROM pg_extension WHERE extname='vector';"
# extversion → 0.7.4 (ou supérieur)

Installation sur Postgres existant (Ubuntu)

# 1. Dépendances de build
sudo apt install postgresql-server-dev-16 build-essential git

# 2. Compilation depuis les sources
git clone --branch v0.7.4 https://github.com/pgvector/pgvector.git
cd pgvector
make && sudo make install

# 3. Activation dans la base cible
psql -U postgres -d ma_base -c "CREATE EXTENSION vector;"
Cloud managé : sur Supabase activez l'extension via Dashboard → Database → Extensions. Sur RDS, ajoutez vector dans le parameter group shared_preload_libraries. Sur Neon et Render, déjà activé par défaut.

Schéma de table et choix de dimension

pgvector ajoute le type natif vector(N)N est la dimension. La dimension doit correspondre exactement à celle du modèle d'embedding choisi — 1536 pour text-embedding-3-small, 3072 pour text-embedding-3-large.

Table de référence pour un FAQ produit

-- Schéma minimal pour un FAQ avec embeddings
CREATE TABLE faq_chunks (
  id          BIGSERIAL PRIMARY KEY,
  document_id BIGINT NOT NULL,                -- doc parent (pour join)
  position    INT NOT NULL,                   -- ordre dans le doc
  content     TEXT NOT NULL,                  -- texte original (pour citation)
  embedding   vector(1536) NOT NULL,          -- ← type pgvector
  metadata    JSONB DEFAULT '{}'::jsonb,      -- locale, tags, version, etc.
  created_at  TIMESTAMPTZ DEFAULT now(),
  updated_at  TIMESTAMPTZ DEFAULT now()
);

-- Indexes utiles
CREATE INDEX ON faq_chunks (document_id);
CREATE INDEX ON faq_chunks USING gin (metadata);
-- L'index vectoriel arrive en section 6 (HNSW)

Comparatif des dimensions OpenAI

Modèle Dimensions Prix / 1M tokens MTEB score Recommandation
text-embedding-3-small 1536 $0.02 62.3 Default — 95% des cas
text-embedding-3-large 3072 $0.13 64.6 Multilingue exigeant, recherche premium
text-embedding-ada-002 (legacy) 1536 $0.10 61.0 Legacy — migrer vers v3-small
Astuce dimension réduite : les modèles v3 supportent le paramètre dimensions qui tronque le vecteur sortant — text-embedding-3-large avec dimensions=1024 reste meilleur que small en 1536, pour un coût de stockage divisé par 3.

Générer des embeddings avec OpenAI

L'API /v1/embeddings accepte un texte ou un tableau de textes (jusqu'à 2048 entrées par requête). Toujours batcher pour minimiser la latence et le coût en overhead HTTP.

Service Node.js réutilisable

// src/embeddings.ts — wrapper OpenAI typé
import OpenAI from 'openai';

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const MODEL = 'text-embedding-3-small';
const BATCH_SIZE = 100; // OpenAI accepte 2048 mais 100 va plus vite en pratique

// Génère un embedding pour un seul texte
export async function embed(text: string): Promise<number[]> {
  const response = await client.embeddings.create({
    model: MODEL,
    input: text.replace(/\n/g, ' ').slice(0, 8000), // tokens max ~8191
  });
  return response.data[0].embedding;
}

// Batch : indispensable pour ingester un corpus
export async function embedBatch(texts: string[]): Promise<number[][]> {
  const all: number[][] = [];

  // Découpage par lots
  for (let i = 0; i < texts.length; i += BATCH_SIZE) {
    const batch = texts.slice(i, i + BATCH_SIZE).map(t => t.replace(/\n/g, ' '));

    const response = await client.embeddings.create({
      model: MODEL,
      input: batch,
    });

    // Les résultats sont retournés dans le même ordre que l'input
    all.push(...response.data.map(d => d.embedding));

    console.log(`[embed] ${i + batch.length}/${texts.length} (cost ~$${
      ((response.usage.total_tokens / 1_000_000) * 0.02).toFixed(4)
    })`);
  }

  return all;
}

Insertion dans pgvector

// src/ingest.ts — pipeline complet d'ingestion
import { Pool } from 'pg';
import { embedBatch } from './embeddings.js';

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

interface FaqEntry {
  document_id: number;
  position: number;
  content: string;
}

export async function ingest(entries: FaqEntry[]): Promise<void> {
  // 1. Génération des embeddings en batch
  const vectors = await embedBatch(entries.map(e => e.content));

  // 2. Insertion bulk via UNNEST (beaucoup plus rapide qu'INSERT en boucle)
  const sql = `
    INSERT INTO faq_chunks (document_id, position, content, embedding)
    SELECT * FROM UNNEST(
      $1::bigint[], $2::int[], $3::text[], $4::vector[]
    )
  `;

  await pool.query(sql, [
    entries.map(e => e.document_id),
    entries.map(e => e.position),
    entries.map(e => e.content),
    // pgvector accepte le format texte "[1,2,3]"
    vectors.map(v => `[${v.join(',')}]`),
  ]);

  console.log(`[ingest] ${entries.length} chunks insérés`);
}
Idempotence : ajoutez un hash MD5 du contenu en colonne et un UNIQUE(document_id, content_hash). Vous pourrez relancer l'ingestion sans recalculer ni dupliquer les chunks inchangés (économie 80%+ du coût d'embedding sur des refresh).

Chunker des documents longs

Un embedding sur un document de 50 pages perd toute spécificité — le vecteur capture une moyenne floue. La règle : embedder des chunks de 200 à 500 tokens, avec un léger chevauchement (50 tokens) pour préserver le contexte aux jointures.

Chunker basé sur la structure Markdown

// src/chunker.ts — découpe respectant les titres
import { encoding_for_model } from 'tiktoken';

const enc = encoding_for_model('text-embedding-3-small');

interface Chunk {
  content: string;
  position: number;
  heading: string | null;
}

export function chunkMarkdown(
  markdown: string,
  maxTokens = 400,
  overlap = 50,
): Chunk[] {
  const chunks: Chunk[] = [];

  // 1. Découpage par titre h2/h3 — chaque section devient une unité
  const sections = markdown.split(/\n(?=##\s)/);
  let position = 0;

  for (const section of sections) {
    // Extraire le titre de la section (utilisé comme métadonnée)
    const headingMatch = section.match(/^##\s+(.+)/m);
    const heading = headingMatch ? headingMatch[1].trim() : null;

    // 2. Si la section dépasse maxTokens, découpe sur les paragraphes
    const tokens = enc.encode(section);
    if (tokens.length <= maxTokens) {
      chunks.push({ content: section, position: position++, heading });
      continue;
    }

    // Sliding window avec chevauchement
    let start = 0;
    while (start < tokens.length) {
      const slice = tokens.slice(start, start + maxTokens);
      const text = new TextDecoder().decode(enc.decode(slice));
      chunks.push({ content: text, position: position++, heading });
      start += maxTokens - overlap;
    }
  }

  return chunks;
}
Heading dans le contenu embeddé : préfixez chaque chunk par son titre de section avant l'embedding ("## Authentification\n\n..."). Le vecteur capture mieux le sujet global, et la requête de l'utilisateur (qui contient souvent le sujet général) match mieux.

Index HNSW : performance à grande échelle

Sans index, pgvector fait un scan séquentiel : OK pour 10k vecteurs, catastrophique au-delà. Deux types d'index sont disponibles : ivfflat (rapide à construire, moins précis) et hnsw (plus lent à construire, meilleure précision et latence). Pour de la production, choisissez HNSW par défaut.

Création d'un index HNSW

-- Index HNSW pour similarité cosinus
-- m : nombre max de connexions par nœud (16 = défaut, augmenter pour précision)
-- ef_construction : qualité de la construction (64 = défaut)
CREATE INDEX faq_chunks_embedding_hnsw
  ON faq_chunks
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

-- Au moment de la requête, ajustez ef_search (qualité vs latence)
SET hnsw.ef_search = 40;  -- défaut, monter à 100+ pour meilleure recall

Comparatif index pgvector

Type Build time (1M vecteurs) Latence query Recall@10 Espace disque
Pas d'index (seq scan) ~3000 ms 100% Base seulement
ivfflat ~30 s ~50 ms ~88% +10%
hnsw (m=16) ~5-10 min ~10 ms ~95% +30%
hnsw (m=32, ef_search=100) ~15 min ~15 ms ~99% +50%
maintenance_work_mem : avant CREATE INDEX sur un gros volume, augmentez SET maintenance_work_mem = '4GB'. La construction HNSW est très gourmande en RAM — sans ça, elle peut prendre 5x plus de temps.

Requêtes de similarité cosine

pgvector expose trois opérateurs de distance — <=> (cosinus), <-> (L2), <#> (produit scalaire négatif). Pour OpenAI v3, la distance cosinus est recommandée car les vecteurs sont normalisés.

Recherche top-k avec filtre métier

// src/search.ts — recherche sémantique
import { pool } from './db.js';
import { embed } from './embeddings.js';

interface SearchHit {
  id: number;
  document_id: number;
  content: string;
  score: number; // 0 (identique) à 2 (opposé)
}

export async function search(
  query: string,
  options: { limit?: number; locale?: string } = {},
): Promise<SearchHit[]> {
  // 1. Embedding de la requête utilisateur
  const queryVec = await embed(query);
  const queryStr = `[${queryVec.join(',')}]`;

  // 2. Requête SQL avec filtre métier sur metadata
  // Le `<=>` retourne la distance cosinus (0 = identique)
  // ORDER BY embedding <=> queryStr est l'opération qui utilise l'index HNSW
  const sql = `
    SELECT
      id,
      document_id,
      content,
      embedding <=> $1::vector AS score
    FROM faq_chunks
    WHERE ($2::text IS NULL OR metadata->>'locale' = $2)
    ORDER BY embedding <=> $1::vector
    LIMIT $3
  `;

  const result = await pool.query(sql, [
    queryStr,
    options.locale ?? null,
    options.limit ?? 10,
  ]);

  return result.rows;
}

// Usage
const hits = await search('comment réinitialiser mon mot de passe', {
  limit: 5,
  locale: 'fr',
});

hits.forEach(h => {
  console.log(`[score=${h.score.toFixed(3)}] doc#${h.document_id} : ${h.content.slice(0, 80)}...`);
});
Seuil de pertinence : rejetez les résultats avec score > 0.5 (cosine distance) — ils sont sémantiquement trop éloignés. Mieux vaut renvoyer "aucun résultat" qu'un faux positif qui pollue le contexte d'un RAG. Calibrez le seuil sur 50 requêtes annotées manuellement.

Recherche hybride : sémantique + keyword

La recherche purement sémantique rate parfois les requêtes contenant des termes techniques exacts (codes erreur, noms de produits, références). La recherche full-text Postgres (tsvector) reste meilleure pour ça. La combinaison des deux donne le meilleur des deux mondes.

Reciprocal Rank Fusion (RRF)

-- Recherche hybride : combine top-N sémantique et top-N full-text
-- avec RRF (Reciprocal Rank Fusion) pour fusionner les rangs
WITH semantic AS (
  SELECT id, content, ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS rank
  FROM faq_chunks
  ORDER BY embedding <=> $1::vector
  LIMIT 30
),
keyword AS (
  SELECT id, content, ROW_NUMBER() OVER (
    ORDER BY ts_rank_cd(to_tsvector('french', content), plainto_tsquery('french', $2)) DESC
  ) AS rank
  FROM faq_chunks
  WHERE to_tsvector('french', content) @@ plainto_tsquery('french', $2)
  LIMIT 30
)
SELECT
  COALESCE(s.id, k.id)         AS id,
  COALESCE(s.content, k.content) AS content,
  -- RRF score : k=60 est la constante usuelle
  COALESCE(1.0 / (60 + s.rank), 0) + COALESCE(1.0 / (60 + k.rank), 0) AS score
FROM semantic s
FULL OUTER JOIN keyword k USING (id)
ORDER BY score DESC
LIMIT 10;
Pourquoi RRF : les scores cosine et BM25 sont sur des échelles différentes — additionner sans normalisation donne du bruit. RRF ne dépend que du rang (1, 2, 3…) et fusionne robustement deux classements de natures différentes. C'est la méthode de référence des systèmes hybrides.

Coûts, latence et comparatif Pinecone/Qdrant

Trois axes pour décider : coût d'ingestion (embeddings), coût de stockage (DB), latence query. pgvector gagne sur les deux premiers tant qu'on reste sous 10M vecteurs. Au-delà, la dégradation de la latence pousse vers une base dédiée.

Calcul de coût pour 1M chunks (~500 tokens chacun)

Stack Embedding initial Stockage / mois Query latence p95 Total an (~10M req)
pgvector (Postgres existant) $10 (one-shot) $0 (déjà payé) 15-30 ms ~$10 + embeddings query
Pinecone Standard $10 $70 (s1.x1 pod) 10-20 ms ~$850 + embeddings
Qdrant Cloud $10 $50 (1 GB cluster) 8-15 ms ~$610 + embeddings

Quand migrer vers une base dédiée ?

  • Volume > 10M vecteurs avec latence p95 critique < 50 ms
  • Updates massives (réindexer tous les jours) — HNSW est lent à reconstruire
  • Multi-tenancy strict : chaque client = collection isolée (Qdrant)
  • Filtrage géographique ou hybride avec metadata complexe à grande échelle
Checklist mise en production
  • Extension vector activée et version >= 0.7
  • Dimension de la colonne identique au modèle d'embedding
  • Index HNSW avec vector_cosine_ops (pas L2)
  • maintenance_work_mem augmenté avant CREATE INDEX
  • Idempotence : hash de contenu + contrainte UNIQUE
  • Batching à l'ingestion (100 textes / requête OpenAI)
  • Seuil de pertinence calibré sur dataset annoté
  • Recherche hybride (RRF) si requêtes mixent sémantique et termes exacts
  • Monitoring : latence query, recall, coût mensuel embeddings
  • Stratégie re-embedding si changement de modèle (versioning)

Conclusion

Embeddings + pgvector forment le socle pragmatique d'à peu près n'importe quelle feature IA moderne : recherche sémantique, RAG, recommandations, déduplication. Tant que vous restez sous quelques millions de vecteurs, votre Postgres existant fait le travail — et vous gardez la puissance des jointures SQL pour combiner sémantique et logique métier.

Trois leviers de qualité : chunking respectueux de la structure (titres, paragraphes), index HNSW correctement paramétré (m, ef_construction, ef_search), recherche hybride avec RRF pour les requêtes contenant du jargon technique. Le reste — choix du modèle, dimension réduite, idempotence — est de l'optimisation incrémentale.

Pour aller plus loin : couplez ce moteur de recherche à un LLM (Claude, GPT-4) pour faire du RAG complet, ajoutez un re-ranker (Cohere Rerank ou modèle local) entre retrieval et génération pour gagner 10-15% de précision, et instrumentez vos requêtes avec un dataset d'évaluation continu pour mesurer la dérive.

Partager