Embeddings : le concept fondamental
Un embedding, c’est la transformation d’un texte en un tableau de nombres flottants — un vecteur. Deux textes au sens proche produisent des vecteurs proches dans l’espace mathématique. C’est la brique de base de toute recherche sémantique.
La cosine similarity mesure la proximité angulaire entre deux vecteurs. La valeur va de -1 (sens opposé) à 1 (sens identique). L’avantage : elle est indépendante de la longueur du texte. Un paragraphe de 3 lignes et un document de 10 pages peuvent être comparés de la même manière.
Les modèles d’embedding varient en coût, qualité et dimensions :
text-embedding-3-small(OpenAI) — 1536 dimensions, économique, bon rapport qualité/prix pour démarrervoyage-3(Anthropic/Voyage) — meilleure qualité sur les benchmarks, adapté aux cas exigeantsnomic-embed— open source, self-hostable, pas de dépendance à un cloud provider
Les dimensions d’un vecteur déterminent sa capacité à capturer des nuances sémantiques. Plus de dimensions = plus de finesse, mais aussi plus de stockage et de calcul. Fourchette courante : 768 à 3072 selon les modèles.
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: 'Comment annuler ma commande ?'
})
const vector: number[] = response.data[0].embedding // 1536 flottants
function cosineSimilarity(a: number[], b: number[]): number {
const dot = a.reduce((sum, val, i) => sum + val * b[i], 0)
const normA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0))
const normB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0))
return dot / (normA * normB)
}
Chunking et indexation des documents
Avant d’embedder un document, il faut le découper en chunks. Un document entier embedé en un seul vecteur perd toute précision — le vecteur devient une moyenne floue de tous les sujets abordés. Un chunk trop petit perd le contexte nécessaire à la compréhension.
La taille recommandée : 300 à 600 tokens avec 50 à 100 tokens d’overlap.
L’overlap est le chevauchement entre deux chunks consécutifs. Sans overlap, une information à cheval sur deux chunks est perdue lors de la recherche. Avec un overlap de 50-100 tokens, chaque chunk contient le contexte de transition avec le précédent.
Le chunking sémantique est plus intelligent que le découpage à taille fixe : on coupe sur les titres, paragraphes ou sections du document. Le résultat est plus cohérent car chaque chunk correspond à une unité logique de contenu.
Chaque chunk doit embarquer des metadata : la source (nom du fichier, URL), le numéro de page, le titre de section, la date du document. Ces metadata sont indispensables pour citer les sources dans la réponse finale et pour débugger les cas où le RAG donne une mauvaise réponse.
type Chunk = {
content: string
metadata: {
source: string
page?: number
section?: string
chunkIndex: number
}
}
function chunkText(text: string, source: string, chunkSize = 500, overlap = 50): Chunk[] {
const words = text.split(' ')
const chunks: Chunk[] = []
for (let i = 0; i < words.length; i += chunkSize - overlap) {
const content = words.slice(i, i + chunkSize).join(' ')
if (content.trim().length === 0) continue
chunks.push({
content,
metadata: { source, chunkIndex: chunks.length }
})
}
return chunks
}
Vector store : stocker et requêter
Un vector store est une base de données optimisée pour stocker des vecteurs et effectuer des recherches par similarité. Contrairement à une base SQL classique qui cherche par correspondance exacte, un vector store trouve les vecteurs les plus proches d’un vecteur donné — c’est la recherche ANN (Approximate Nearest Neighbor).
Le “Approximate” est important : la recherche n’est pas parfaitement exacte, mais elle est x100 plus rapide qu’une recherche exhaustive. Sur des millions de vecteurs, la différence est négligeable en pratique.
Pinecone — Service cloud managé, scalable automatiquement. Le free tier offre 100k vecteurs, suffisant pour un MVP. API simple, pas d’infrastructure à gérer.
pgvector — Extension PostgreSQL. Si tu as déjà une base Postgres (via Supabase, Neon ou Railway), c’est la solution la plus simple : zéro service supplémentaire, tes vecteurs vivent à côté de tes données relationnelles.
Chroma — Solution open source légère, idéale pour le développement local et les POC. Peut tourner en mémoire ou avec un backend persistant.
Qdrant — Open source, performant, avec une API REST complète. Adapté aux déploiements on-premise pour les clients qui exigent le contrôle total des données.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content TEXT NOT NULL,
embedding VECTOR(1536),
source TEXT,
page INT,
section TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
SELECT content, source, page,
1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 5;
| Solution | Type | Pricing | Meilleur pour |
|---|---|---|---|
| Pinecone | Cloud managé | Free tier (100k vecteurs), puis payant | MVP rapide, scaling automatique |
| pgvector | Extension PostgreSQL | Gratuit (coût = ta base Postgres) | Tu as déjà Postgres (Supabase, Neon) |
| Chroma | Open source | Gratuit | Développement local, POC |
| Qdrant | Open source | Gratuit (self-hosted) | On-premise, clients exigeants |
Pipeline RAG complet
Le RAG (Retrieval-Augmented Generation) se décompose en trois phases distinctes. Comprendre cette séparation est essentiel pour débugger et optimiser chaque étape indépendamment.
Phase 1 — Indexation (offline)
Script exécuté une seule fois (puis à chaque mise à jour du corpus). Le pipeline : parsing des documents sources, chunking avec metadata, embedding de chaque chunk, stockage en vector DB. Cette phase peut prendre du temps sur un gros corpus mais n’impacte pas la latence utilisateur.
Phase 2 — Retrieval (runtime)
À chaque question utilisateur : on embedde la question avec le même modèle que l’indexation, on effectue une recherche ANN dans le vector store, et on récupère les K chunks les plus pertinents. Le choix de K (typiquement 3 à 8) est un compromis : trop peu = risque de manquer l’information, trop = bruit dans le contexte.
Phase 3 — Generation (runtime)
On construit un prompt augmenté qui injecte les chunks récupérés comme contexte, puis on appelle le LLM. Le template du prompt est critique : il doit explicitement demander au modèle de se baser uniquement sur le contexte fourni et de citer ses sources. La réponse est streamée à l’utilisateur avec les références.
async function ragQuery(userQuery: string): Promise<ReadableStream> {
// 1. Embedder la question
const queryVector = await embed(userQuery)
// 2. Récupérer les chunks pertinents
const chunks = await vectorSearch(queryVector, { topK: 5 })
// 3. Construire le contexte
const context = chunks
.map((c, i) => `[${i + 1}] Source: ${c.source}\n${c.content}`)
.join('\n\n---\n\n')
// 4. Prompt augmenté
const augmentedPrompt = `Tu es un assistant. Réponds uniquement à partir du contexte fourni.
Si l'information n'est pas dans le contexte, dis explicitement que tu ne la trouves pas.
Cite toujours la source entre crochets [1], [2], etc.
Contexte :
${context}
Question : ${userQuery}`
// 5. Streamer la réponse
return streamLLM(augmentedPrompt)
}
Evaluation et guardrails
Un RAG sans évaluation, c’est un système qui hallucine en silence. Trois métriques permettent de mesurer la qualité du pipeline de bout en bout.
Faithfulness — La réponse est-elle fidèle aux chunks récupérés ? Le LLM a-t-il inventé des informations qui ne sont pas dans le contexte ? Ce score peut être calculé automatiquement en utilisant un LLM juge qui compare la réponse aux sources.
Answer relevance — La réponse répond-elle vraiment à la question posée ? Une réponse techniquement correcte mais hors-sujet obtient un score bas. Cette métrique détecte les cas où le retrieval ramène des chunks liés au domaine mais pas à la question précise.
Context precision — Les chunks récupérés sont-ils pertinents pour la question ? Si sur 5 chunks, seul 1 est réellement utile, le retrieval est à améliorer. Un score bas ici indique un problème de chunking ou d’embedding, pas de generation.
Les outils d’évaluation automatisée : RAGAS (open source, le standard), Braintrust, LangSmith (LangChain).
| Métrique | Ce qu’elle mesure | Comment l’améliorer |
|---|---|---|
| Faithfulness | La réponse s’appuie sur les sources | Améliorer le prompt template, ajouter l’instruction “ne réponds que si l’info est dans le contexte” |
| Answer relevance | La réponse est pertinente pour la question | Améliorer le retrieval (meilleur embedding, re-ranking) |
| Context precision | Les chunks récupérés sont pertinents | Revoir le chunking (taille, overlap), tester un autre modèle d’embedding |
Les guardrails protègent le système en amont et en aval du LLM :
Input guardrail — Intercepte les questions avant l’appel LLM. Objectifs : détecter les questions hors domaine (le chatbot RH ne doit pas répondre sur la météo), bloquer les injections de prompt, filtrer le contenu inapproprié. Un simple classifieur LLM suffit pour la V1.
Output guardrail — Vérifie la réponse après génération. Objectifs : s’assurer que la réponse s’appuie sur le contexte fourni, détecter les données potentiellement inventées, bloquer les réponses qui contiendraient des informations sensibles.
async function isOnTopic(query: string, domain: string): Promise<boolean> {
const response = await llm.classify({
input: query,
prompt: `La question suivante est-elle liée au domaine "${domain}" ? Réponds uniquement par "oui" ou "non".`
})
return response === 'oui'
}
Projet : Chatbot support client
L’objectif est de construire un RAG complet et déployé : un chatbot qui répond aux questions des utilisateurs en s’appuyant sur des documents métier (PDF, Markdown, pages Notion). L’interface affiche les sources citées de manière cliquable. Le système est fonctionnel, pas un POC.
Etape 1 — Script d’indexation
Parser les documents sources (PDF, Markdown, Notion), les découper en chunks avec metadata, embedder chaque chunk avec text-embedding-3-small, stocker dans pgvector via Supabase. Le script doit être idempotent : ré-exécutable sans créer de doublons.
Etape 2 — API /chat avec pipeline RAG
Endpoint qui reçoit la question utilisateur, exécute le pipeline complet (embed question, vector search, prompt augmenté, appel LLM en streaming). La réponse inclut le texte généré et la liste des sources utilisées avec leur score de similarité.
Etape 3 — Interface chat avec sources
Interface web avec un champ de saisie et un historique de conversation. Chaque réponse affiche les sources cliquables sous forme de badges [1], [2], etc. Au clic, la source s’ouvre avec le passage pertinent surligné.
Etape 4 — Input guardrail
Avant chaque appel au pipeline RAG, un classifieur détecte les questions hors domaine et les tentatives d’injection de prompt. Les questions hors périmètre reçoivent une réponse pré-formatée qui redirige l’utilisateur.
Etape 5 — Dashboard admin
Interface protégée pour uploader de nouveaux documents. L’upload déclenche automatiquement le pipeline d’indexation (parsing, chunking, embedding, stockage). Affiche la liste des documents indexés avec le nombre de chunks.
Etape 6 — Déploiement
Déployer l’API sur Vercel ou Railway, l’interface sur Vercel, la base pgvector sur Supabase. Documenter le pipeline dans un README : architecture, variables d’environnement, commandes de setup, coût estimé par requête.