Skip to content

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), pgvector 0.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 avec tsvector pour 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 ACL

Analogie : 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

sql
-- 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
# 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.

sql
-- 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);
python
# 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())
python
# 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())
sql
-- 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 rapide

SLA 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

  1. 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.
  2. Toujours stocker la vector dimension dans le type (vector(1536)) — Postgres refuse les inserts de mauvaise dim. C'est un garde-fou gratuit.
  3. maintenance_work_mem haut avant CREATE INDEX — passer de 64MB à 4GB divise par 10 le temps de build.
  4. Partial indexes par tenant quand un tenant est très gros et les autres petits. Les petits restent sur l'index global.
  5. 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.
  6. pgvector + pg_trgm pour les noms propres et SKU (matching exact + fuzzy + sémantique = combo gagnant).
  7. Versioning d'embeddings : colonne embedding_model TEXT + embedding_version INT. Migration model = backfill par lots, pas big bang.
  8. RLS (Row Level Security) au lieu de filtres applicatifs pour le multi-tenant — étanchéité prouvable en audit.

🔄 Versions & écosystème 2026

ComposantVersion 2026Notes
Postgres17.xStatistiques de planning améliorées pour vector ops
pgvector0.8.xhalfvec (float16, divise la taille par 2), bit (binary embeddings), iterative index scans
pgvectorscale0.5+DiskANN-like (StreamingDiskANN) pour > 50M vecteurs sur Postgres
SupabaseEU (eu-central-1)pgvector 0.8 disponible, RLS prête-à-l'emploi
Neon EUFrankfurtBranches Postgres (test envs avec vectors)
Scaleway DBParis/AmsterdamPostgres 17 + pgvector, souveraineté FR
RDS Pariseu-west-3pgvector 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.

sql
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

  1. Oublier maintenance_work_mem → CREATE INDEX HNSW prend 4h au lieu de 20 min. Le client pense que pgvector est lent. C'est faux.
  2. vector(1536) sans VACUUM régulier → bloat HNSW, latence qui dérive. Ajouter autovacuum_vacuum_scale_factor = 0.05 sur la table.
  3. Lancer CREATE INDEX sur table live sans CONCURRENTLY → lock + downtime. Utiliser CREATE INDEX CONCURRENTLY (mais 2x plus lent).
  4. 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érifier np.linalg.norm.
  5. Pas de LIMIT avant ORDER BY embedding <=> → Postgres choisit un seq scan, l'index HNSW n'est pas utilisé. Toujours ORDER BY ... LIMIT k ensemble.
  6. 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.
  7. Multi-dim sur même colonne : impossible (un type, une dim). Si tu mixes OpenAI small (1536) et large (3072), deux colonnes.
  8. 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.
  9. text-embedding-3-large (3072 dim) par défaut "parce que meilleur" → coût RAM × 2, latence × 1.4. Souvent small (1536) suffit pour 90% des cas. Mesurer avec un eval set, pas avec son intuition.
  10. Backup Postgres 200 Go avec embeddings + pas de pg_dump filtrée → 4h de backup, fenêtre dépassée. Soit halfvec, 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) :

yaml
# 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_connections

Si 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)

VolumeStackCoû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

MissionJoursTJMPrix HT
Audit Postgres + ajout pgvector31200€3 600€
Setup HNSW + ingest pipeline + 1er endpoint search51300€6 500€
Hybrid search + reranker + eval set41400€5 600€
Migration Pinecone → pgvector (1-5M vecteurs)6-81500€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

python
# 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 :

  1. 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.
  2. Streaming pour le TTFT perçu, et AsyncAnthropic côté serveur pour ne pas bloquer l'event loop pendant les 1-3s de génération.
python
# 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. Utilise thinking={"type": "adaptive"} (+ output_config={"effort": "low"|"medium"|"high"} si tu veux régler la profondeur). temperature/top_p sont aussi retirés sur 4.7/4.8. Pour une extraction structurée des citations, préfère client.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 :

markdown
# 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ètreDefault pgvectorRecommandé prodEffet
m (max connections / layer)1616-32+ ⇒ + recall, + RAM, + index build time
ef_construction6464-200+ ⇒ + qualité index, + build time
ef_search (runtime)4080-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

sql
-- 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 :

  1. Volume cible 12 mois ? < 5M → pgvector ferme. 5-20M → eval comparatif. > 20M → Qdrant/Weaviate.
  2. Postgres déjà en prod ? Oui = pgvector quasi-automatique. Non = Qdrant Cloud plus simple.
  3. Multi-tenant ? Avec RLS Postgres = pgvector parfait. 200+ tenants = Weaviate.
  4. Hybrid search obligatoire ? Oui = OK pgvector + tsvector. Sparse SPLADE = Qdrant/ES.
  5. Latence p99 cible ? < 50ms à fort QPS = vector DB dédiée. < 200ms = pgvector OK.
  6. 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 monter ef_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-6 au lieu d'claude-opus-4-8 quand 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 vector sur 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

→ Suite logique : 02-qdrant.md (quand pgvector ne suffit plus), 05-hybrid-search.md (BM25 + vector approfondi), 06-realtime-vectors.md (pipelines d'update).

Bibliothèque tech perso — Achref