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 |
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;"
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) où 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 |
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`);
}
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;
}
"## 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% |
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)}...`);
});
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;
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
- Extension
vectoractivé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_memaugmenté 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.