pgvector — l'extension Postgres qui devient le défaut RAG en 2026
TL;DR Si tu as déjà Postgres en prod (et tu l'as),
pgvector0.8 sur Postgres 17 te donne du vector search jusqu'à ~10M vecteurs avec HNSW, dans la même transaction que ton métier. Pas de second système à opérer, pas de double-write, pas de cohérence à gérer. À mixer avectsvectorpour de l'hybrid search natif. Pour la souveraineté FR : Scaleway Database (Paris), Supabase EU (Frankfurt), Neon EU, RDS Paris. Tu sors de pgvector vers Qdrant uniquement si > 20M vecteurs, filtering ultra-complexe, ou multi-tenant > 500 tenants. Pricing freelance : audit + setup pgvector + HNSW + hybrid = 4-6 jours à 1200€/j, bien plus rentable qu'une migration Pinecone foireuse.
🧠 Mental model
Pourquoi pgvector et pas une "vraie" vector DB ?
Vector DB dédiée (Qdrant/Weaviate/Pinecone)
┌──────────────────────────────────────┐
│ │
Application ─┤ embeddings + filters + payload │
│ │
└──────────────────────────────────────┘
▲ ▲
│ search │ write
│ │
┌─────────┴───────────┴────────────────┐
│ Postgres (métier) │
│ users, orders, documents, ACL... │
└──────────────────────────────────────┘
vs
pgvector
┌──────────────────────────────────────┐
│ Postgres (métier + vectors) │
│ │
│ documents (id, content, embedding, │
│ tenant_id, tsvector,...) │
│ │
│ CREATE INDEX ... USING hnsw │
└──────────────────────────────────────┘
▲
│ une seule transaction
│ un seul backup, un seul ACLAnalogie : pgvector c'est le couteau suisse que tu sors dans 80% des cas freelance. Qdrant/Pinecone c'est la scie circulaire que tu sors quand tu coupes des planches de 10cm tous les jours. Si ton client traite 200K docs et fait des requêtes mixtes (RAG + métier + ACL + reporting), pgvector gagne à tous les coups : un seul système à backup, un seul à monitorer, un seul ACL Postgres pour gérer l'isolation tenant.
Le tradeoff à ne pas oublier
Volume vecteurs
│
100M ─ ─ ─ ─ ─ ─ ─ ─ ┤ ████ Qdrant / Vespa
│
20M ─ ─ ─ ─ ─ ─ ─ ─ ┤ ████ Qdrant / Weaviate
│
10M ─ ─ ─ ─ ─ ─ ─ ─ ┤ ▓▓▓▓ pgvector ok (HNSW bien tuné)
│
1M ─ ─ ─ ─ ─ ─ ─ ─ ┤ ░░░░ pgvector confortable
│
100K ─ ─ ─ ─ ─ ─ ─ ─ ┤ ░░░░ pgvector trivial
└──────────────────► temps
2024 2025 2026🛠️ Code minimal
-- Postgres 17 + pgvector 0.8
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
content TEXT NOT NULL,
embedding vector(1536), -- OpenAI text-embedding-3-small
content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('french', content)) STORED,
created_at TIMESTAMPTZ DEFAULT now()
);
-- HNSW index (default 2026, plus rapide que IVFFlat pour la plupart des workloads)
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Hybrid full-text
CREATE INDEX ON documents USING gin (content_tsv);
-- Partial index par tenant (multi-tenant SaaS)
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WHERE tenant_id = '11111111-1111-1111-1111-111111111111';# Python : requête de similarité
import psycopg
from openai import OpenAI
client = OpenAI()
conn = psycopg.connect("postgresql://...")
def search(query: str, tenant_id: str, k: int = 10) -> list[dict]:
emb = client.embeddings.create(
model="text-embedding-3-small",
input=query,
).data[0].embedding
with conn.cursor() as cur:
cur.execute("""
SELECT id, content, 1 - (embedding <=> %s::vector) AS score
FROM documents
WHERE tenant_id = %s
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (emb, tenant_id, emb, k))
return [{"id": r[0], "content": r[1], "score": r[2]} for r in cur.fetchall()]Trois opérateurs à mémoriser :
<->distance L2 (Euclidienne) →vector_l2_ops<=>distance cosinus →vector_cosine_ops(le plus courant pour les embeddings)<#>produit scalaire (négatif) →vector_ip_ops(si embeddings déjà normalisés)
🎬 Cas d'usage concrets
Cas 1 — Cabinet d'avocats parisien, 50K documents, RAG juridique
Contexte : Cabinet de 40 collaborateurs, Postgres 16 déjà en prod (CRM clients + dossiers + facturation). DSI refuse un nouveau système à backup/sauvegarder/RGPDifier. Demande : RAG sur 50K PDF de jurisprudence + contrats clients.
Décision : pgvector sur Postgres existant. Migration vers Postgres 17. Embeddings text-embedding-3-large (3072 dim) pour la qualité juridique. Index HNSW + ACL Postgres RLS (Row Level Security) pour l'étanchéité par dossier.
Résultat : prod en 3 semaines. Backup = backup Postgres existant. p95 latence search = 80ms sur 50K docs. Coût infra : 0€ supplémentaire (même instance).
TJM facturé : 1400€/j × 12 jours = 16 800€ HT (audit + dev + handover).
Cas 2 — E-commerce mode, hybrid pgvector + tsvector
Contexte : Marketplace mode B2C, 200K SKU, équipe data 3 personnes, Postgres RDS Paris. Problème : recherche full-text actuelle (Elasticsearch) loupe les requêtes "robe rouge bohème été" parce que "bohème" n'est dans aucune description.
Décision : ajouter pgvector à côté de tsvector. Embedding du titre + description + tags. Hybrid search RRF (Reciprocal Rank Fusion) en SQL pur. Sortie d'Elasticsearch → -380€/mois.
Résultat : nDCG@10 passe de 0.42 à 0.71. Conversion search → panier +14%. Stack simplifiée.
Cas 3 — SaaS B2B RH, 80 tenants, partial indexes
Contexte : SaaS ATS (Applicant Tracking System), 80 clients PME, ~10K CV par client en moyenne. Besoin : recherche sémantique CV par client, étanchéité totale (RGPD, deux clients ne doivent jamais cross-contaminer).
Décision : pgvector + partial HNSW index WHERE tenant_id = ... pour les 10 plus gros tenants, index global pour les autres. RLS Postgres en ceinture. ACL au niveau ligne, pas applicatif.
Résultat : auditeur RGPD valide en 1 réunion (vs 4 réunions pour la version "Pinecone + checks applicatifs"). Latence p95 < 60ms par tenant.
🛠️ Exemple end-to-end
Contexte : catalogue mode 200K SKU sur Scaleway Database (Postgres 17, instance DB-PLAY2-XS-4). Recherche par texte + image (CLIP) avec hybrid pgvector + tsvector.
-- migrations/001_schema.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE products (
sku TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
brand TEXT,
color TEXT,
price_cents INT,
-- text embedding (OpenAI text-embedding-3-small)
text_embedding vector(1536),
-- image embedding (CLIP ViT-L/14)
image_embedding vector(768),
-- full-text
title_tsv tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('french', coalesce(title,'')), 'A') ||
setweight(to_tsvector('french', coalesce(brand,'')), 'A') ||
setweight(to_tsvector('french', coalesce(description,'')), 'B')
) STORED,
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_products_text_emb ON products
USING hnsw (text_embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
CREATE INDEX idx_products_image_emb ON products
USING hnsw (image_embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
CREATE INDEX idx_products_tsv ON products USING gin (title_tsv);
CREATE INDEX idx_products_brand_color ON products (brand, color);# app/search.py
import os
import asyncio
import psycopg
from psycopg.rows import dict_row
from openai import AsyncOpenAI
from PIL import Image
import open_clip
import torch
oai = AsyncOpenAI()
DSN = os.environ["SCW_DSN"]
# CLIP local pour les images
_clip_model, _, _clip_preprocess = open_clip.create_model_and_transforms(
"ViT-L-14", pretrained="openai"
)
_clip_model.eval()
async def embed_text(text: str) -> list[float]:
r = await oai.embeddings.create(
model="text-embedding-3-small",
input=text,
)
return r.data[0].embedding
def embed_image(path: str) -> list[float]:
img = _clip_preprocess(Image.open(path)).unsqueeze(0)
with torch.no_grad():
v = _clip_model.encode_image(img)
v = v / v.norm(dim=-1, keepdim=True)
return v[0].tolist()
HYBRID_SQL = """
WITH text_hits AS (
SELECT sku,
1 - (text_embedding <=> %(q_emb)s::vector) AS vec_score,
ts_rank_cd(title_tsv, plainto_tsquery('french', %(q)s)) AS bm_score
FROM products
WHERE (%(brand)s IS NULL OR brand = %(brand)s)
AND (%(color)s IS NULL OR color = %(color)s)
ORDER BY text_embedding <=> %(q_emb)s::vector
LIMIT 200
), bm AS (
SELECT sku, ts_rank_cd(title_tsv, plainto_tsquery('french', %(q)s)) AS bm_score
FROM products
WHERE title_tsv @@ plainto_tsquery('french', %(q)s)
AND (%(brand)s IS NULL OR brand = %(brand)s)
AND (%(color)s IS NULL OR color = %(color)s)
ORDER BY bm_score DESC
LIMIT 200
), merged AS (
SELECT sku FROM text_hits
UNION
SELECT sku FROM bm
), ranked AS (
SELECT m.sku,
COALESCE(1 - (p.text_embedding <=> %(q_emb)s::vector), 0) AS vec,
COALESCE(ts_rank_cd(p.title_tsv, plainto_tsquery('french', %(q)s)), 0) AS bm
FROM merged m JOIN products p ON p.sku = m.sku
)
SELECT p.sku, p.title, p.brand, p.color, p.price_cents,
0.6 * r.vec + 0.4 * r.bm AS score
FROM ranked r JOIN products p ON p.sku = r.sku
ORDER BY score DESC
LIMIT %(k)s;
"""
async def search_text(
q: str, brand: str | None = None, color: str | None = None, k: int = 20
) -> list[dict]:
q_emb = await embed_text(q)
async with await psycopg.AsyncConnection.connect(DSN, row_factory=dict_row) as conn:
async with conn.cursor() as cur:
await cur.execute(
HYBRID_SQL,
{"q": q, "q_emb": q_emb, "brand": brand, "color": color, "k": k},
)
return await cur.fetchall()
IMAGE_SQL = """
SELECT sku, title, brand, color, price_cents,
1 - (image_embedding <=> %(q_emb)s::vector) AS score
FROM products
WHERE image_embedding IS NOT NULL
ORDER BY image_embedding <=> %(q_emb)s::vector
LIMIT %(k)s;
"""
async def search_by_image(path: str, k: int = 20) -> list[dict]:
q_emb = embed_image(path)
async with await psycopg.AsyncConnection.connect(DSN, row_factory=dict_row) as conn:
async with conn.cursor() as cur:
await cur.execute(IMAGE_SQL, {"q_emb": q_emb, "k": k})
return await cur.fetchall()
if __name__ == "__main__":
async def main():
print(await search_text("robe rouge bohème été", brand=None, color="rouge", k=5))
print(await search_by_image("/tmp/inspiration.jpg", k=5))
asyncio.run(main())# app/ingest.py — embedding pipeline batch
import asyncio
import psycopg
from openai import AsyncOpenAI
oai = AsyncOpenAI()
DSN = os.environ["SCW_DSN"]
BATCH = 100
async def reindex_text():
async with await psycopg.AsyncConnection.connect(DSN) as conn:
async with conn.cursor(name="cur_reindex") as cur:
await cur.execute("""
SELECT sku, title, brand, description
FROM products
WHERE text_embedding IS NULL
""")
batch = []
async for row in cur:
sku, title, brand, desc = row
payload = f"{brand} {title}. {desc or ''}"[:8000]
batch.append((sku, payload))
if len(batch) >= BATCH:
await _flush(conn, batch)
batch.clear()
if batch:
await _flush(conn, batch)
async def _flush(conn, batch: list[tuple[str, str]]):
inputs = [p for _, p in batch]
r = await oai.embeddings.create(model="text-embedding-3-small", input=inputs)
embs = [d.embedding for d in r.data]
async with conn.cursor() as cur:
for (sku, _), emb in zip(batch, embs):
await cur.execute(
"UPDATE products SET text_embedding = %s::vector WHERE sku = %s",
(emb, sku),
)
await conn.commit()
if __name__ == "__main__":
asyncio.run(reindex_text())-- tuning runtime (par session ou via SET pgvector params)
SET hnsw.ef_search = 100; -- recall vs latence : monter pour + recall
SET maintenance_work_mem = '4GB'; -- pour CREATE INDEX HNSW rapideSLA mesurés sur Scaleway DB-PLAY2-M (8 vCPU, 32 Go RAM) avec 200K SKU :
- ingestion full (re-embed 200K) : ~22 min (rate-limit OpenAI)
- CREATE INDEX HNSW (text 1536 dim) : ~4 min
- search hybrid p50 : 18 ms, p95 : 42 ms, p99 : 90 ms
🎯 Patterns courants
- Index HNSW par défaut, IVFFlat seulement si RAM serrée — HNSW est plus rapide en query et ne demande pas de re-train quand les données changent.
- Toujours stocker la vector dimension dans le type (
vector(1536)) — Postgres refuse les inserts de mauvaise dim. C'est un garde-fou gratuit. maintenance_work_memhaut avant CREATE INDEX — passer de 64MB à 4GB divise par 10 le temps de build.- Partial indexes par tenant quand un tenant est très gros et les autres petits. Les petits restent sur l'index global.
- Reranker en dehors de Postgres — pgvector renvoie ANN top-K, tu rerankes (Cohere Rerank 3 / Voyage rerank-2) sur les 50 candidats avant d'envoyer au LLM.
pgvector+pg_trgmpour les noms propres et SKU (matching exact + fuzzy + sémantique = combo gagnant).- Versioning d'embeddings : colonne
embedding_model TEXT+embedding_version INT. Migration model = backfill par lots, pas big bang. - RLS (Row Level Security) au lieu de filtres applicatifs pour le multi-tenant — étanchéité prouvable en audit.
🔄 Versions & écosystème 2026
| Composant | Version 2026 | Notes |
|---|---|---|
| Postgres | 17.x | Statistiques de planning améliorées pour vector ops |
| pgvector | 0.8.x | halfvec (float16, divise la taille par 2), bit (binary embeddings), iterative index scans |
| pgvectorscale | 0.5+ | DiskANN-like (StreamingDiskANN) pour > 50M vecteurs sur Postgres |
| Supabase | EU (eu-central-1) | pgvector 0.8 disponible, RLS prête-à-l'emploi |
| Neon EU | Frankfurt | Branches Postgres (test envs avec vectors) |
| Scaleway DB | Paris/Amsterdam | Postgres 17 + pgvector, souveraineté FR |
| RDS Paris | eu-west-3 | pgvector activable, IAM AWS pour ACL |
halfvec est un game-changer 2026 : passer de vector(1536) à halfvec(1536) divise la RAM par 2 avec une perte de recall < 1% sur la plupart des modèles modernes. À tester systématiquement sur les datasets > 1M.
ALTER TABLE documents ADD COLUMN embedding_h halfvec(1536);
UPDATE documents SET embedding_h = embedding::halfvec(1536);
CREATE INDEX ON documents USING hnsw (embedding_h halfvec_cosine_ops);⚠️ Pitfalls
- Oublier
maintenance_work_mem→ CREATE INDEX HNSW prend 4h au lieu de 20 min. Le client pense que pgvector est lent. C'est faux. vector(1536)sans VACUUM régulier → bloat HNSW, latence qui dérive. Ajouterautovacuum_vacuum_scale_factor = 0.05sur la table.- Lancer CREATE INDEX sur table live sans
CONCURRENTLY→ lock + downtime. UtiliserCREATE INDEX CONCURRENTLY(mais 2x plus lent). - Embeddings non normalisés +
vector_ip_ops→ résultats incohérents. Avec OpenAI, c'est déjà normalisé, donc cosinus == ip, mais avec d'autres modèles, vérifiernp.linalg.norm. - Pas de
LIMITavantORDER BY embedding <=>→ Postgres choisit un seq scan, l'index HNSW n'est pas utilisé. ToujoursORDER BY ... LIMIT kensemble. - Filtre WHERE trop sélectif + index HNSW → HNSW filtre après le top-K ANN. Si ton filtre exclut 99% des rows, tu obtiens 1% de k candidats. Soit augmenter
ef_search, soit partial index, soit pgvectorscale + StreamingDiskANN. - Multi-dim sur même colonne : impossible (un type, une dim). Si tu mixes OpenAI small (1536) et large (3072), deux colonnes.
- Embedding stockée mais jamais re-générée après changement de prompt de chunking → silent drift de qualité. Toujours un job qui versionne et permet le backfill.
text-embedding-3-large(3072 dim) par défaut "parce que meilleur" → coût RAM × 2, latence × 1.4. Souventsmall(1536) suffit pour 90% des cas. Mesurer avec un eval set, pas avec son intuition.- Backup Postgres 200 Go avec embeddings + pas de
pg_dumpfiltrée → 4h de backup, fenêtre dépassée. Soithalfvec, soit exclure la table pendant le dump et la re-générer.
🩺 Observabilité minimale en prod
Une mission pgvector "prod-grade" inclut systématiquement ces 6 métriques (Grafana + Postgres exporters) :
# grafana_dashboard.yaml (extrait)
panels:
- title: "p95 query latency embedding <=>"
query: histogram_quantile(0.95, sum(rate(pg_query_duration_bucket{query_kind="vector_search"}[5m])) by (le))
- title: "Index size (MB)"
query: pg_total_relation_size{indexname=~".*_hnsw_idx"} / 1024 / 1024
- title: "Autovacuum runs / hour on documents"
query: rate(pg_stat_user_tables_autovacuum_count{relname="documents"}[1h])
- title: "Bloat ratio embedding column"
query: pg_bloat_ratio{relname="documents"}
- title: "Cache hit ratio"
query: pg_stat_database_blks_hit / (pg_stat_database_blks_hit + pg_stat_database_blks_read)
- title: "Connections used vs max"
query: pg_stat_database_numbackends / pg_settings_max_connectionsSi tu vends une mission pgvector sans monitoring, le client revient dans 3 mois en panique parce que "ça ralentit" et tu n'as aucune donnée pour diagnostiquer. Toujours inclure 1 jour de monitoring setup dans ton devis.
💰 Pricing / ROI client
Coût infra (ordres de grandeur 2026)
| Volume | Stack | Coût mensuel |
|---|---|---|
| 100K docs (1536 dim) | Supabase Pro EU | ~30 € |
| 1M docs (1536 dim) | Neon EU / Scaleway DB-PLAY2-S | ~80-150 € |
| 10M docs (1536 dim) | Scaleway DB-PLAY2-M + pgvectorscale | ~400-700 € |
50M docs (halfvec 1536) | RDS r7g.2xlarge | ~1 500-2 200 € |
Comparaison vs Pinecone Serverless
- Pinecone Serverless ~ 0.33 $ / GB stocké + 8.25 $ / M write + 16.5 $ / M read
- pgvector sur Scaleway : ~70-150€/mois pour 1M vecteurs en illimité de queries
Sur 1M vecteurs et 5M queries/mois : Pinecone ~120€, pgvector ~100€. Sur 10M vecteurs et 50M queries/mois : Pinecone ~1 800€, pgvector ~500€.
Pricing freelance type
| Mission | Jours | TJM | Prix HT |
|---|---|---|---|
| Audit Postgres + ajout pgvector | 3 | 1200€ | 3 600€ |
| Setup HNSW + ingest pipeline + 1er endpoint search | 5 | 1300€ | 6 500€ |
| Hybrid search + reranker + eval set | 4 | 1400€ | 5 600€ |
| Migration Pinecone → pgvector (1-5M vecteurs) | 6-8 | 1500€ | 9 000-12 000€ |
ROI client typique : break-even infra en < 4 mois après une migration Pinecone → pgvector pour un dataset > 5M.
🧪 Testing / Eval
# tests/eval_search.py
import pytest, json, math
from app.search import search_text
# Eval set : 100 (query, ground_truth_skus) curatés par les vendeurs
EVAL = json.load(open("data/eval_search.json"))
def ndcg_at_k(predicted_ids: list[str], relevant_ids: list[str], k: int = 10) -> float:
rels = [1.0 if pid in relevant_ids else 0.0 for pid in predicted_ids[:k]]
dcg = sum(r / math.log2(i + 2) for i, r in enumerate(rels))
ideal = sorted(rels, reverse=True)
idcg = sum(r / math.log2(i + 2) for i, r in enumerate(ideal)) or 1.0
return dcg / idcg
@pytest.mark.asyncio
async def test_hybrid_beats_vector_only():
scores_hybrid = []
for item in EVAL:
hits = await search_text(item["q"], k=10)
scores_hybrid.append(ndcg_at_k([h["sku"] for h in hits], item["relevant"]))
avg = sum(scores_hybrid) / len(scores_hybrid)
assert avg >= 0.65, f"nDCG@10 régression : {avg:.3f}"À mesurer en continu :
- nDCG@10 sur eval set fixe
- p50/p95/p99 latence par endpoint
- Recall@k vs brute force (sous-échantillon 1K vecteurs, comparer HNSW vs seq scan)
- Coût $/1000 queries (embedding + DB)
🔗 Fermer la boucle RAG : retrieval → rerank → génération
pgvector n'est que la moitié du RAG. Le retrieval (pgvector + tsvector + rerank) sert un contexte ; c'est le LLM qui génère la réponse. Un staff engineer raisonne sur les deux étages de coût et de latence, pas seulement la DB. Le pipeline complet :
question ──▶ embed (OpenAI/Voyage) ──▶ pgvector ANN top-50 ──▶ rerank top-8 ──▶ LLM (Claude) ──▶ réponse citée
~30ms ~40ms p95 ~120ms ~1-3s (TTFT)Pourquoi rerank avant le LLM. pgvector renvoie un top-K ANN bruité (recall@10 ~0.95, mais l'ordre interne est imparfait). Le reranker (Cohere Rerank 3, Voyage rerank-2) re-score les 50 candidats par pertinence cross-encoder, tu n'envoies que les 8 meilleurs au LLM. Gain double : qualité (le LLM voit moins de bruit) et coût (8 chunks au lieu de 50 → ~6× moins de tokens d'input). C'est le levier #1 sur la facture LLM d'un RAG.
L'étage génération. Côté Anthropic 2026, le modèle par défaut est claude-opus-4-8 (flagship, 5 $ / 25 $ par M tokens input/output, contexte 1M) ; pour un RAG haut volume où la qualité suffit en milieu de gamme, claude-sonnet-4-6 (3 $ / 15 $) ou claude-haiku-4-5 (1 $ / 5 $) coupent la facture. Deux réflexes de prod :
- Prompt caching sur le préfixe stable (system prompt + instructions de citation) via
cache_control— les chunks récupérés changent à chaque requête, donc ils vont après le dernier breakpoint de cache. Lecture cache ≈ 0.1× le prix input : sur un RAG à fort trafic, c'est 80-90 % d'économie sur la partie système. - Streaming pour le TTFT perçu, et
AsyncAnthropiccôté serveur pour ne pas bloquer l'event loop pendant les 1-3s de génération.
# app/generate.py — l'étage LLM du RAG (les chunks viennent de pgvector + rerank)
import os
from anthropic import AsyncAnthropic
anthropic = AsyncAnthropic() # lit ANTHROPIC_API_KEY
SYSTEM = (
"Tu es un assistant juridique. Réponds UNIQUEMENT à partir des extraits fournis. "
"Cite chaque affirmation avec [doc_id]. Si l'info n'est pas dans les extraits, dis-le."
)
async def answer(question: str, chunks: list[dict]) -> str:
# chunks = sortie de search_text() rerankée : [{"id": ..., "content": ...}, ...]
context = "\n\n".join(f"[{c['id']}] {c['content']}" for c in chunks)
async with anthropic.messages.stream(
model="claude-opus-4-8",
max_tokens=2048,
thinking={"type": "adaptive"}, # adaptatif : pas de budget_tokens (supprimé sur 4.7/4.8)
system=[
{
"type": "text",
"text": SYSTEM,
"cache_control": {"type": "ephemeral"}, # préfixe stable → caché
}
],
messages=[
{
"role": "user",
"content": f"Extraits:\n{context}\n\nQuestion: {question}",
}
],
) as stream:
async for text in stream.text_stream:
print(text, end="", flush=True)
final = await stream.get_final_message()
# Observabilité coût : logguer le ratio cache_read pour vérifier que le caching mord
u = final.usage
print(f"\n[in={u.input_tokens} cache_read={u.cache_read_input_tokens} out={u.output_tokens}]")
return "".join(b.text for b in final.content if b.type == "text")⚠️ Garde-fou sur la syntaxe thinking. Sur Opus 4.7/4.8, l'ancienne forme
thinking={"type": "enabled", "budget_tokens": N}renvoie HTTP 400 — elle est supprimée. Utilisethinking={"type": "adaptive"}(+output_config={"effort": "low"|"medium"|"high"}si tu veux régler la profondeur).temperature/top_psont aussi retirés sur 4.7/4.8. Pour une extraction structurée des citations, préfèreclient.messages.parse()avec un schéma Pydantic plutôt que du parsing XML maison.
Le piège de coût classique. Un dev junior envoie les 50 chunks ANN bruts au LLM « pour ne rien rater » → input gonflé ×6, latence ×2, et le recall ne monte pas (le LLM se noie dans le bruit). Le bon réflexe : rerank dur, top-8, prompt caching sur le system, et mesurer cache_read_input_tokens à chaque appel.
📋 Runbook prod minimal
À livrer avec toute mission pgvector prod-grade :
# Runbook pgvector — collection `documents`
## Métriques de santé
- p95 query latency : voir Grafana dashboard `pgvector-docs`
- Index size : 12 GB (au 2026-05)
- Autovacuum activé : oui (scale_factor=0.05)
## Procédures
### CREATE INDEX sans downtime
1. `SET maintenance_work_mem = '4GB';`
2. `CREATE INDEX CONCURRENTLY documents_emb_hnsw_idx2 ON documents USING hnsw (embedding vector_cosine_ops) WITH (m=16, ef_construction=64);`
3. Tester recall sur sample
4. `BEGIN; DROP INDEX documents_emb_hnsw_idx; ALTER INDEX documents_emb_hnsw_idx2 RENAME TO documents_emb_hnsw_idx; COMMIT;`
### Migration de modèle d'embedding (small → large)
1. `ALTER TABLE documents ADD COLUMN embedding_v2 vector(3072);`
2. Job backfill par lots de 1000
3. `CREATE INDEX CONCURRENTLY` sur embedding_v2
4. Feature flag applicatif → bascule lectures sur _v2
5. Drop colonne v1 après 7 jours
### Recovery après crash autovacuum
1. `SELECT * FROM pg_stat_user_tables WHERE relname='documents';`
2. Si `n_dead_tup` > 30% : `VACUUM (VERBOSE, ANALYZE) documents;`
3. Si bloat persistant : `VACUUM FULL` (pendant fenêtre maintenance — lock)
### Restore depuis backup
1. `pg_restore --section=pre-data` (schémas + extensions)
2. `pg_restore --section=data` (en parallèle si possible)
3. `pg_restore --section=post-data` (index, triggers)
4. Vérifier recall sur sample queries
## Contacts
- DBA on-call : ...
- Freelance original : ...Un runbook qui tient debout vaut 1500€ HT supplémentaires sur la mission (1 jour facturé). Le client le sort en réunion comité de direction.
🔁 Quand utiliser / éviter
Utiliser pgvector quand :
- Postgres déjà en prod (90% des cas freelance)
- Volume < 10M vecteurs (ou < 50M avec pgvectorscale)
- Hybrid search (BM25 + vector) demandé
- Multi-tenant avec RLS / ACL fines
- Contraintes RGPD / souveraineté FR (Scaleway, Neon EU)
- L'équipe est petite (1-3 devs), pas de "ops dédié vector DB"
- Budget infra serré
Éviter / sortir vers Qdrant/Weaviate/Vespa quand :
20M vecteurs avec filtering complexe (multi-payload index)
- Multi-tenancy massive (> 500 tenants actifs, partial indexes ne scalent plus)
- Latence p99 < 20ms requise à 1000 QPS
- Sparse vectors natifs (SPLADE) requis
- Multivector / ColBERT obligatoire
- Équipe avec un ops dédié et budget infra confortable
🧰 Annexes : tuning HNSW et benchmarks
Paramètres HNSW à connaître par cœur
| Paramètre | Default pgvector | Recommandé prod | Effet |
|---|---|---|---|
m (max connections / layer) | 16 | 16-32 | + ⇒ + recall, + RAM, + index build time |
ef_construction | 64 | 64-200 | + ⇒ + qualité index, + build time |
ef_search (runtime) | 40 | 80-200 | + ⇒ + recall, + latence query |
Règle pragmatique : commencer m=16, ef_construction=64, ef_search=100. Mesurer recall@10 vs brute-force sur sample 1000 queries. Si recall < 0.95, monter ef_search. Si toujours pas, monter m (mais re-CREATE INDEX).
Choix de la distance
Modèle d'embedding | Sortie normalisée ? | Distance recommandée
───────────────────────────┼─────────────────────┼─────────────────────
OpenAI text-embedding-3-* | oui | cosine (<=>)
Voyage voyage-3 | oui | cosine
BGE-M3 | oui | cosine
Mistral mistral-embed | oui | cosine
Cohere embed-v3 | oui | cosine
Embeddings custom non-norm | non | L2 (<->)99% des cas : cosine. Si tu hésites, c'est cosine.
Cheat sheet SQL utile
-- Recall vs brute force sur sample 1000
SELECT AVG(
(SELECT COUNT(*) FROM (
SELECT id FROM documents
ORDER BY embedding <=> q.embedding LIMIT 10
) hnsw_top INNER JOIN (
SELECT /*+ SeqScan(documents) */ id FROM documents
ORDER BY embedding <=> q.embedding LIMIT 10
) brute_top USING (id))::float / 10.0
) AS recall_at_10
FROM eval_queries q;
-- Diagnostic : index utilisé ?
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM documents
ORDER BY embedding <=> '[0.1,0.2,...]'::vector
LIMIT 10;
-- → cherche "Index Scan using ..._hnsw_idx", pas "Seq Scan"
-- Taille de l'index
SELECT pg_size_pretty(pg_relation_size('documents_embedding_hnsw_idx'));Décision pgvector vs alternatives sur un projet réel
Avant de signer une mission, je pose 6 questions au client :
- Volume cible 12 mois ? < 5M → pgvector ferme. 5-20M → eval comparatif. > 20M → Qdrant/Weaviate.
- Postgres déjà en prod ? Oui = pgvector quasi-automatique. Non = Qdrant Cloud plus simple.
- Multi-tenant ? Avec RLS Postgres = pgvector parfait. 200+ tenants = Weaviate.
- Hybrid search obligatoire ? Oui = OK pgvector + tsvector. Sparse SPLADE = Qdrant/ES.
- Latence p99 cible ? < 50ms à fort QPS = vector DB dédiée. < 200ms = pgvector OK.
- Souveraineté FR stricte ? Scaleway DB + pgvector parfait.
🎤 En entretien
Questions que ce sujet attire en entretien senior/architecte AI, avec la réponse d'une ligne.
- « HNSW filtre-t-il avant ou après l'ANN ? Pourquoi c'est un piège ? » — Après : pgvector récupère le top-K ANN puis applique le
WHERE, donc un filtre très sélectif (99 % exclus) te laisse une poignée de candidats — il faut monteref_search, un partial index, ou passer à pgvectorscale/StreamingDiskANN. - « pgvector vs Qdrant, où est la vraie ligne de bascule ? » — Pas le volume seul : c'est le combo volume > 20M et filtering multi-payload complexe et multi-tenancy massive (> 500 tenants) ou p99 < 20ms à fort QPS ; en dessous, pgvector gagne sur l'opérabilité (un seul système, un backup, RLS native).
- « Comment garantis-tu l'étanchéité multi-tenant à un auditeur RGPD ? » — RLS Postgres au niveau ligne (pas un filtre applicatif qu'on peut oublier) + partial HNSW index par gros tenant ; l'isolation est prouvable dans le SGBD, pas dans le code.
- « Sur un RAG, où part vraiment l'argent et comment tu le coupes ? » — Sur l'étage LLM, pas pgvector : rerank dur (top-50 → top-8) pour diviser les tokens d'input par ~6, prompt caching sur le préfixe système (lecture ≈ 0.1× input), et choix de modèle (
claude-haiku-4-5/claude-sonnet-4-6au lieu d'claude-opus-4-8quand la qualité suffit). - « halfvec : qu'est-ce que tu gagnes, qu'est-ce que tu risques ? » — RAM ÷ 2 (float16) pour < 1 % de perte de recall sur les modèles modernes ; le risque est de l'appliquer sans eval — toujours mesurer recall@10 vs
vectorsur un sample avant de basculer en prod.
🏋️ Exercices
Du « fais marcher » au « défends le chiffre / casse-le puis répare ». Monte un Postgres 17 + pgvector 0.8 local (Docker) et un dataset jouet (≥ 200K vecteurs ; embeddings OpenAI text-embedding-3-small ou un modèle local type BGE-M3 si tu veux zéro coût).
Exercice 1 — Le retrieval qui n'utilise pas l'index
Objectif : prouver, EXPLAIN ANALYZE à l'appui, qu'une requête de similarité utilise (ou pas) l'index HNSW.
Écris une requête ORDER BY embedding <=> $1 LIMIT 10, vérifie l'Index Scan. Puis casse-la volontairement de trois façons : (a) enlève le LIMIT, (b) ajoute un WHERE qui exclut 99 % des lignes, (c) oublie maintenance_work_mem au moment du CREATE INDEX et chronomètre le build sur 200K vecteurs.
Indice/Solution : (a) → seq scan (l'optimiseur abandonne l'index sans borne haute) ; (b) → tu obtiens < 10 résultats car HNSW filtre après l'ANN, monte SET hnsw.ef_search = 400 ou passe en partial index ; (c) → build qui explose (4 GB → 64 MB ≈ ×10 plus lent). Le livrable est un tableau EXPLAIN avant/après pour chaque cas.
Exercice 2 — Hybrid search RRF en SQL pur
Objectif : implémenter une fusion vectoriel + BM25 et battre le vector-only sur un eval set.
Construis un eval set de 50 (query, doc_id pertinents). Implémente le hybrid par Reciprocal Rank Fusion (1/(k+rank), k=60) au lieu de la somme pondérée 0.6*vec + 0.4*bm. Compare nDCG@10 : RRF vs pondéré vs vector-only.
Indice/Solution : RRF est invariant à l'échelle des scores (pas besoin de normaliser cosinus vs ts_rank_cd), c'est pour ça qu'il généralise mieux que la somme pondérée que tu dois retuner par dataset. Calcule le rang de chaque doc dans chaque liste via ROW_NUMBER() OVER (ORDER BY ...), fusionne par FULL OUTER JOIN, somme les 1/(60+rank). Tu dois voir nDCG@10 monter (genre 0.62 → 0.71) sans aucun poids à régler.
Exercice 3 — Migration de modèle d'embedding sans downtime
Objectif : passer de text-embedding-3-small (1536) à -large (3072) sur table live, zéro lock long, rollback possible.
Ajoute une colonne embedding_v2 vector(3072), backfille par lots de 1000, crée l'index en CONCURRENTLY, bascule les lectures derrière un feature flag, et garde la v1 7 jours. Mesure le temps total et le pic de RAM.
Indice/Solution : le CREATE INDEX CONCURRENTLY est ~2× plus lent mais sans lock écriture ; le backfill doit committer par lot pour ne pas gonfler le WAL ; le rollback = re-flip le flag, pas un DROP COLUMN irréversible. Piège : -large = RAM ×2 et latence ×1.4 — défends avec un eval que le gain de qualité justifie le coût, sinon reste sur small.
Exercice 4 — Le filtre sélectif qui tue le recall (casse-le puis répare)
Objectif : reproduire le mode de défaillance « WHERE trop sélectif + HNSW » et le corriger de trois manières, chiffres à l'appui.
Crée un tenant avec 0,5 % des lignes. Lance une recherche filtrée WHERE tenant_id = $gros_perdant et mesure le recall@10 réel vs brute force. Tu vas voir le recall s'effondrer. Répare via (a) ef_search plus haut, (b) partial HNSW index WHERE tenant_id = ..., (c) pgvectorscale StreamingDiskANN. Compare recall ET latence des trois.
Indice/Solution : (a) marche mais coûte de la latence linéairement ; (b) restaure le recall à coût query nul mais ajoute des index à maintenir (ne scale pas au-delà de ~quelques dizaines de gros tenants) ; (c) la seule option qui tient à > 50M. Le livrable est la matrice recall/latence qui justifie quand tu sors de pgvector.
Exercice 5 — Optimiser la facture du RAG complet (défends le chiffre)
Objectif : prendre un pipeline RAG naïf (50 chunks bruts → LLM, pas de cache) et diviser son coût/1000 requêtes par ≥ 4 sans dégrader le nDCG ni la qualité de réponse.
Pars d'un baseline qui envoie les 50 candidats ANN à claude-opus-4-8. Ajoute : rerank top-8, prompt caching sur le system prompt, et un A/B opus vs sonnet/haiku. Mesure input_tokens / cache_read_input_tokens / output_tokens par appel et le coût réel.
Indice/Solution : top-8 au lieu de top-50 ≈ ×6 sur l'input ; le caching mord seulement si les chunks (volatils) sont après le dernier breakpoint et le system (stable) avant — vérifie cache_read_input_tokens > 0 sinon un invalidant silencieux traîne (timestamp/UUID dans le system). Défends que haiku-4-5 suffit en montrant un eval de qualité de réponse stable, pas une intuition. Attention : thinking={"type":"adaptive"}, jamais budget_tokens (400 sur 4.7/4.8).
Exercice 6 (boss) — Bench reproductible pgvector vs Qdrant
Objectif : produire un benchmark défendable en réunion comité de direction qui dit à quel volume tu dois quitter pgvector pour ce workload précis.
Sur le même dataset et le même hardware, mesure recall@10, p95/p99 latence, et coût mensuel pour pgvector (HNSW tuné + halfvec) et Qdrant, à 1M, 10M, et 50M vecteurs, avec un filtre multi-payload réaliste. Documente la méthodo (warm cache, ef_search égal en recall, mêmes filtres).
Indice/Solution : le piège est de comparer à ef_search fixe au lieu d'à recall fixe — aligne d'abord le recall, puis compare la latence. À 1M, pgvector gagne sur le coût total (un seul système) ; le point de bascule sur ce workload tombe quand le filtering multi-payload fait décrocher le recall HNSW sans que halfvec + partial index ne suffisent. Le livrable est un graphe à trois courbes avec la ligne de bascule annotée — exactement le genre d'artefact qui vaut un jour facturé de plus.
🔗 Liens
- pgvector repo : https://github.com/pgvector/pgvector
- pgvectorscale (Timescale) : https://github.com/timescale/pgvectorscale
- Supabase EU + pgvector : https://supabase.com/docs/guides/ai
- Neon EU : https://neon.tech
- Scaleway Database : https://www.scaleway.com/en/database/
- Hybrid search RRF paper : Cormack et al., 2009
- Comparatif latence pgvector vs Qdrant vs Weaviate : ANN-benchmarks 2026 update
- Timescale's blog "pgvector vs Pinecone benchmark" : https://www.timescale.com/blog/pgvector-vs-pinecone/
- Supabase blog "pgvector 0.7 → 0.8 perf improvements"
- AWS RDS pgvector docs : https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_PostgreSQL.html
→ Suite logique : 02-qdrant.md (quand pgvector ne suffit plus), 05-hybrid-search.md (BM25 + vector approfondi), 06-realtime-vectors.md (pipelines d'update).