Construire ton premier pipeline RAG avec Claude et pgvector
- RAG
- Claude
- pgvector
- PostgreSQL
- TypeScript
La plupart des articles sur le RAG te poussent vers une nouvelle base vectorielle, un nouveau framework, un nouvel orchestrateur. Tu n’as besoin de rien de tout ça pour livrer une v1. Postgres avec pgvector plus l’API Claude, ça suffit pour passer en prod. Et c’est aussi ce que je déploie chez les clients 9 fois sur 10.
Voici le pipeline exact que je livre quand une équipe a besoin d’une feature de Q&R sur un domaine au-dessus d’un Postgres existant.
Pourquoi pgvector et pas une base vectorielle dédiée
Trois raisons :
- Tu fais déjà tourner Postgres. Aucun service supplémentaire à opérer, monitorer, sauvegarder.
- Garanties transactionnelles. Tes vecteurs vivent à côté de tes données métier, même base, mêmes permissions, même stratégie de backup.
- Ça scale plus loin que ce que les gens prétendent. Des millions de vecteurs avec un index HNSW tournent très bien sur un Postgres de taille moyenne.
Je ne sors une base vectorielle managée qu’au-delà de 50M de vecteurs ou quand j’ai besoin de retrieval sub-50ms en multi-régions. En dessous, pgvector gagne sur la simplicité. À chaque fois.
Le pipeline en quatre étapes
- Ingérer les documents et les découper en chunks.
- Embedder chaque chunk et stocker le vecteur.
- Récupérer les top-k chunks pour une requête utilisateur.
- Générer une réponse avec Claude, ancrée sur les chunks récupérés.
Voyons chaque étape.
1. Schéma et index
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL,
source TEXT NOT NULL,
title TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
chunk_index INT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536),
token_count INT
);
CREATE INDEX chunks_embedding_hnsw
ON chunks USING hnsw (embedding vector_cosine_ops);
CREATE INDEX chunks_document_id ON chunks(document_id);
Utilise 1536 dimensions si tu pairs avec OpenAI text-embedding-3-small. Claude n’a pas encore d’embeddings first-party, donc je combine Claude pour la génération avec OpenAI ou Voyage pour les embeddings. Pas d’idéologie, juste ce qui marche.
2. Chunking et embedding
Le chunking, c’est là que la plupart des pipelines RAG meurent en silence. Ne coupe pas sur des comptes de caractères arbitraires. Découpe par paragraphes avec overlap, ou utilise un splitter sémantique. Teste tes découpes sur de vrais documents avant d’écrire la moindre ligne de code de retrieval.
import OpenAI from "openai";
import { db } from "./db";
const openai = new OpenAI();
export async function ingestDocument(docId: string, text: string) {
const chunks = splitByParagraph(text, { maxTokens: 500, overlap: 50 });
const embeddings = await openai.embeddings.create({
model: "text-embedding-3-small",
input: chunks.map((c) => c.content),
});
await db.transaction(async (tx) => {
for (let i = 0; i < chunks.length; i++) {
await tx.insert(chunksTable).values({
document_id: docId,
chunk_index: i,
content: chunks[i].content,
embedding: embeddings.data[i].embedding,
token_count: chunks[i].tokens,
});
}
});
}
Batche les appels d’embedding. Un seul appel avec 100 chunks est radicalement moins cher et plus rapide que 100 appels séquentiels. Si tu sautes ça, ton job d’ingestion va ramper.
3. Retrieval avec une nuance
Le retrieval naïf renvoie les plus proches voisins par similarité cosinus. C’est la baseline. Deux améliorations comptent en pratique :
- Filtrage sur métadonnées avant la recherche vectorielle. Si l’utilisateur est restreint à un projet, filtre d’abord sur
document.project_id. Sauter ça, c’est comme ça qu’on fuite les données d’un tenant chez un autre. - Reranking après le retrieval. Le top-10 de la recherche vectorielle est souvent bruité. Un reranker (Cohere, Voyage, ou un cross-encoder) réordonne en fonction de la requête réelle. Pas cher, gros bond en qualité.
export async function retrieve(query: string, projectId: string, k = 5) {
const embeddingResponse = await openai.embeddings.create({
model: "text-embedding-3-small",
input: [query],
});
const queryVec = embeddingResponse.data[0].embedding;
// Recherche vectorielle avec filtre sur métadonnées
const candidates = await db.query(
`
SELECT c.id, c.content, d.title
FROM chunks c
JOIN documents d ON d.id = c.document_id
WHERE d.project_id = $1
ORDER BY c.embedding <=> $2::vector
LIMIT 20
`,
[projectId, queryVec],
);
// Reranking pour une meilleure précision
return rerank(query, candidates, k);
}
4. Génération avec Claude
Claude brille ici grâce à sa longue fenêtre de contexte et sa capacité à suivre les instructions de grounding. Passe les chunks récupérés, donne des instructions nettes, exige des citations.
import Anthropic from "@anthropic-ai/sdk";
const claude = new Anthropic();
export async function answer(query: string, chunks: Chunk[]) {
const context = chunks
.map((c, i) => `[${i + 1}] ${c.title}\n${c.content}`)
.join("\n\n---\n\n");
const response = await claude.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 1024,
system: `You are a precise assistant. Answer the user's question using ONLY the provided context. Cite sources with [1], [2] markers. If the context does not contain the answer, say so.`,
messages: [
{
role: "user",
content: `Context:\n${context}\n\nQuestion: ${query}`,
},
],
});
return response.content[0].type === "text" ? response.content[0].text : "";
}
Deux règles de prompt que je ne lâche jamais :
- Grounding explicite : « use ONLY the provided context ».
- Fallback explicite : « if the context does not contain the answer, say so ». Saute ça et Claude va combler le vide avec des bêtises assénées avec aplomb.
Quoi mesurer en production
Une fois en prod, suis ces quatre métriques :
- Taux de hit du retrieval. Sur un échantillon de vraies requêtes, la bonne réponse est-elle dans le top-k ? Sinon, le problème c’est le chunking ou les embeddings, pas le modèle.
- Groundedness. Les réponses sont-elles vraiment basées sur les chunks récupérés ? LLM-as-judge plus inspections humaines hebdomadaires.
- Décomposition de la latence. Appel d’embedding, recherche vectorielle, génération. Optimise le plus lent.
- Coût par requête. Tokens d’embeddings plus tokens de génération. Un system prompt bavard peut doubler ta facture du jour au lendemain.
La suite
Une fois la v1 en route, les upgrades classiques sont la recherche hybride (BM25 + vecteurs), la réécriture de requête (transformer une entrée utilisateur vague en meilleure requête de retrieval) et le retrieval agentique (laisser Claude décider quoi chercher). Les trois en valent la peine — mais seulement après que tu aies une v1 ennuyeuse, ancrée, en production, avec de vraies données d’usage. Livre d’abord, optimise ensuite sur preuves.
Si tu veux un deuxième regard sur ton architecture RAG, je tiens des appels de cadrage de 30 minutes. Apporte un vrai log de requêtes.