De Dev Web a Ingenieur IA
De Dev Web à Ingénieur IA #5 — RAG Fondamentaux
Ingestion, embeddings, vector search : construis ton premier pipeline RAG avec Ollama et pgvector

Intro
L’article précédent nous a appris à donner des outils au LLM pour qu’il puisse calculer, consulter l’heure, ou simuler une météo. Mais il y a un problème : le LLM ne connaît que ce qu’il a appris pendant son entraînement. Pose-lui une question sur un document interne, un email récent, ou une procédure spécifique à ton entreprise, et il inventera une réponse (on appelle ça une hallucination).
Aujourd’hui on franchit une étape majeure : le RAG (Retrieval-Augmented Generation). Au lieu de répondre de mémoire, le LLM va recevoir des documents pertinents dans son contexte et répondre uniquement à partir de ceux-ci. C’est la technique qui permet aux chatbots d’entreprise de répondre sur la base de leur propre documentation.
Le concept : récupérer avant de générer
Le principe est simple :
Documents → chunking → embeddings → vector store
↑
Question → embedding → vector search → contexte → LLM → réponse
Deux phases distinctes :
-
Ingestion — on découpe nos documents en morceaux (chunks), on les transforme en vecteurs (embeddings), et on les stocke dans une base vectorielle.
-
Requête — on transforme la question de l’utilisateur en vecteur, on cherche les morceaux les plus proches dans la base, et on les donne au LLM pour qu’il réponde.
Un LLM seul peut répondre de travers sur des sujets précis. Le RAG lui fournit la bonne information pour répondre correctement.
Les données : des documents d’entreprise réels
Avant de coder, il nous fallait des données crédibles. Plutôt que d’inventer des documents de démonstration irréalistes, j’ai généré 8 fichiers textes représentant des documents qu’on trouve vraiment dans une PME :
datas/
├── document-01.txt — CR réunion d'équipe
├── document-02.txt — Note interne (imprimante étiquettes)
├── document-03.txt — Rapport d'activité mensuel
├── document-04.txt — Devis fournisseur
├── document-05.txt — Note RH (congés)
├── document-06.txt — Post-mortem incident livraison
├── document-07.txt — Échange email fournisseur
└── document-08.txt — Note de service (portail chargement)
Ce sont des fichiers .txt bruts, sans formatage, sans métadonnées structurées — exactement ce que tu obtiendrais après un copier-coller d’email ou un export texte.
Comment générer les tiens
J’ai utilisé le prompt suivant avec ChatGPT pour créer ces documents. Tu peux l’adapter à ton secteur :
Génère 8 documents professionnels en français (100-300 mots chacun) pour une PME fictive, par exemple dans l'agroalimentaire, la logistique ou l'artisanat. Les documents serviront de dataset pour une série d'articles techniques sur le RAG. Ils doivent couvrir ces types :
1. CR réunion équipe
2. Email interne
3. Rapport d'activité mensuel
4. Devis client
5. Note RH interne
6. Post-mortem incident (ex: livraison ratée)
7. Échange email fournisseur
8. Note de service
Écris en .txt brut, sans aucun formatage markdown, sans titres, sans métadonnées structurées — juste le texte réaliste d'entreprise avec des noms, dates, chiffres cohérents. Un fichier .txt par document, nommé document-01.txt à document-08.txt.

Les 8 documents sont cohérents entre eux : mêmes noms (Marie Dupont, Karim Petit), mêmes dates (juillet 2026), même secteur (logistique). C’est important pour que les requêtes RAG aient un sens.
Setup
Si tu viens de l’article 4, copie le dossier :
cp -r article-04-tool-calling article-05-rag-basics
cd article-05-rag-basics && rm -rf node_modules package-lock.json
npm install
Les données sont partagées entre tous les articles de la série depuis un dossier datas/ à la racine du projet. Vérifie qu’il est accessible depuis ton dossier :
ls ../datas
Si tu ne vois pas les 8 fichiers, crée le dossier datas/ à la racine de ton projet et utilise le prompt ChatGPT donné plus haut pour générer les documents.
Si tu démarres ici, voir l’article 1 pour le setup initial (Docker, Postgres, Ollama).
Nouveaux modèles : en plus de
qwen3:4bpour le chat, on a besoin d’un modèle d’embedding. On utiliseqwen3-embedding:4b. Si tu ne l’as pas encore :ollama pull qwen3-embedding:4bVérifie qu’il est bien disponible :
ollama list

Avant de lancer le script : assure-toi que PostgreSQL est bien démarré via Docker :
docker compose up -dVérifie que le conteneur tourne :
docker ps
Le code
import { readFileSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
const { Pool } = pg;
// ─── Configuration
const CHAT_MODEL = "qwen3:4b";
const EMBED_MODEL = "qwen3-embedding:4b";
const OLLAMA_BASE = process.env.OLLAMA_HOST ?? "http://localhost:11434";
const DATAS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "datas");
const EMBED_DIM = 2560; // dimension par défaut de qwen3-embedding:4b (configurable 32..4096)
// ─── PostgreSQL
const pool = new Pool({
host: process.env.PGHOST ?? "localhost",
port: Number(process.env.PGPORT ?? 5432),
user: process.env.PGUSER ?? "blog",
password: process.env.PGPASSWORD ?? "blog",
database: process.env.PGDATABASE ?? "blog",
});
async function ensureTable() {
await pool.query(`
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS documents (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(${EMBED_DIM})
);
`);
}
async function resetTable() {
await pool.query("DROP TABLE IF EXISTS documents CASCADE");
await pool.query(`
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS documents (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(${EMBED_DIM})
);
`);
}
// ─── Embeddings via Ollama
async function embed(text: string): Promise<number[]> {
const res = await fetch(`${OLLAMA_BASE}/api/embed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: EMBED_MODEL, input: text }),
});
if (!res.ok) throw new Error(`Embedding HTTP ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { embeddings: number[][] };
return data.embeddings[0];
}
// ─── Ingestion
async function ingest() {
console.log("📄 Ingestion des documents...\n");
const files = readdirSync(DATAS_DIR).filter((f) => f.endsWith(".txt"));
for (const file of files) {
const content = readFileSync(join(DATAS_DIR, file), "utf-8").trim();
if (!content) continue;
// Chunking simple : on découpe par double saut de ligne
const chunks = content.split(/\n\n+/).map((c) => c.trim()).filter(Boolean);
for (const chunk of chunks) {
const vector = await embed(chunk);
await pool.query(
"INSERT INTO documents (filename, content, embedding) VALUES ($1, $2, $3)",
[file, chunk, `[${vector.join(",")}]`],
);
console.log(` ✓ ${file} → chunk (${chunk.length} chars)`);
}
}
console.log("\n✅ Ingestion terminée");
}
// ─── Chat via Ollama
async function chat(system: string, user: string): Promise<string> {
const res = await fetch(`${OLLAMA_BASE}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: CHAT_MODEL,
messages: [
{ role: "system", content: system },
{ role: "user", content: user },
],
stream: false,
}),
});
if (!res.ok) throw new Error(`Chat HTTP ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { message: { content: string } };
return data.message.content;
}
// ─── Requête RAG
async function query(question: string) {
console.log(`\n❓ Question : ${question}\n`);
// 1. Embedding de la question
const qVector = await embed(question);
// 2. Recherche vectorielle (distance cosinus avec <=>)
const result = await pool.query(
`SELECT content FROM documents ORDER BY embedding <=> $1::vector LIMIT 3`,
[`[${qVector.join(",")}]`],
);
if (result.rows.length === 0) {
console.log(" Aucun document trouvé.");
return;
}
// 3. Construction du contexte
const context = result.rows.map((r) => r.content).join("\n\n---\n\n");
console.log(`📚 Contexte récupéré (${result.rows.length} chunks) :\n`);
console.log(context.slice(0, 300) + "...");
// 4. Génération de la réponse
const systemPrompt = `Tu es un assistant spécialisé dans les documents d'entreprise. Réponds UNIQUEMENT à partir du contexte fourni ci-dessous. Si le contexte ne contient pas la réponse, dis-le honnêtement. Ne cite pas le contexte. Réponds en français.\n\nContexte :\n${context}`;
const answer = await chat(systemPrompt, question);
console.log(`\n🤖 Réponse :\n${answer}\n`);
}
// ─── Spinner
async function withSpinner<T>(label: string, fn: () => Promise<T>): Promise<T> {
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let i = 0;
const interval = setInterval(() => {
process.stdout.write(`\r${frames[i]} ${label}`);
i = (i + 1) % frames.length;
}, 80);
try {
const result = await fn();
process.stdout.write(`\r✓ ${label}\n`);
return result;
} catch (e) {
process.stdout.write(`\r✗ ${label}\n`);
throw e;
} finally {
clearInterval(interval);
}
}
// ─── Main
async function main() {
await ensureTable();
// ── 1. INGESTION ──
console.log("");
console.log("=== Phase 1 : Ingestion ===\n");
await withSpinner("Création de la table...", () => resetTable());
await ingest();
// ── 2. REQUÊTE RAG ──
console.log("");
console.log("=== Phase 2 : Requête RAG ===\n");
await query("Quelles sont les ventes de juin et quelles actions ont été décidées en réunion ?");
// ── 3. QUESTION HORS CONTEXTE ──
await query("Quel est le chiffre d'affaires prévu pour 2027 ?");
// ── 4. QUESTION SUR UN INCIDENT ──
await query("Que s'est-il passé avec la livraison du client Boulangerie Martin ?");
}
main().catch((err) => {
console.error(`\nErreur : ${err.message}`);
process.exit(1);
});
Exécution
npm start
Voici le résultat complet du script :
=== Phase 1 : Ingestion ===
✓ Nettoyage de la table...
📄 Ingestion des documents...
✓ document-01.txt → chunk (456 chars)
✓ document-02.txt → chunk (393 chars)
✓ document-03.txt → chunk (481 chars)
✓ document-04.txt → chunk (421 chars)
✓ document-05.txt → chunk (390 chars)
✓ document-06.txt → chunk (531 chars)
✓ document-07.txt → chunk (377 chars)
✓ document-08.txt → chunk (398 chars)
✅ Ingestion terminée
=== Phase 2 : Requête RAG ===
❓ Question : Quelles sont les ventes de juin et quelles actions ont été décidées en réunion ?
📚 Contexte récupéré (3 chunks) :
Compte rendu de la réunion d'équipe du 2 juillet 2026. Étaient présents
: Marie Dupont, Karim Petit, Sophie Laurent et Julien Morel. Les ventes
de juin atteignent 182 000 euros, soit +6 % par rapport à mai. Les
retards de préparation concernent principalement les commandes
supérieures à 200 colis. Il est décidé...
🤖 Réponse :
Les ventes de juin s'élèvent à 182 000 euros, soit une hausse de 6 %
par rapport à mai. Il a été décidé d'ajouter un créneau de préparation
de 16 h à 18 h pendant trois semaines, de mettre à jour les procédures
avant le 8 juillet et de suivre les indicateurs de délai quotidiennement.
---
❓ Question : Quel est le chiffre d'affaires prévu pour 2027 ?
📚 Contexte récupéré (3 chunks) :
Le mois de juin 2026 s'est terminé avec un chiffre d'affaires de 182 000
euros pour la société. 1 246 commandes ont été expédiées avec un taux de
livraison dans les délais de 97,4 %. Trois réclamations clients ont
nécessité un avoir total de 640 euros...
🤖 Réponse :
Le contexte fourni ne contient aucune information sur le chiffre
d'affaires prévu pour 2027.
---
❓ Question : Que s'est-il passé avec la livraison du client Boulangerie Martin ?
📚 Contexte récupéré (3 chunks) :
Le 28 juin 2026, une livraison destinée au client Boulangerie Martin est
arrivée avec 24 heures de retard. L'analyse montre une erreur
d'affectation du transporteur après une modification de tournée
effectuée à 18 h 40. Aucun contrôle final n'a été réalisé avant le
départ...
🤖 Réponse :
La livraison pour le client Boulangerie Martin est arrivée avec 24
heures de retard suite à une erreur d'affectation du transporteur après
une modification de tournée à 18 h 40, sans contrôle final. Cela a
entraîné une pénalité commerciale de 250 euros et l'insatisfaction du
client. Des actions ont été décidées : validation systématique des
changements de tournée par un second opérateur, mise en place d'une
alerte automatique et revue hebdomadaire des incidents.

Analyse test par test
Test 1 — Ventes et actions : le LLM répond correctement en citant le chiffre de 182 000 €, la hausse de 6 %, et les actions décidées. Il a trouvé l’information dans le document de la réunion (document-01). C’est la puissance du RAG : une information présente dans un seul document est retrouvée et utilisée.
Test 2 — Chiffre d’affaires 2027 : le contexte récupéré contient le CA de juin 2026 (document-03) mais rien sur 2027. Le LLM répond honnêtement qu’il ne peut pas répondre. Pas d’hallucination — c’est l’autre grand bénéfice du RAG.
Test 3 — Incident Boulangerie Martin : le LLM détaille correctement l’incident et les actions correctives, toutes issues du document-06. Il a même synthétisé les conséquences et les décisions.
Comment ça marche
Embeddings : on utilise l’API /api/embed d’Ollama avec qwen3-embedding:4b. Ce modèle transforme n’importe quel texte en un vecteur de 2560 nombres (la dimension). Plus la dimension est élevée, plus le modèle peut capturer de nuances sémantiques — mais ça prend plus de place et de calcul. 2560, c’est le réglage par défaut de ce modèle (configurable de 32 à 4096).
Tous les modèles d’embedding n’ont pas la même dimension : all-minilm fait 384, nomic-embed-text fait 768, text-embedding-3-small fait 1536. Si on change de modèle d’embedding, il faut recréer la table avec la bonne dimension — c’est pour ça qu’on a un resetTable() qui drop et recrée plutôt qu’un simple DELETE.
Deux textes similaires auront des vecteurs proches, quelle que soit la dimension.
Stockage vectoriel : PostgreSQL avec l’extension pgvector. La colonne embedding est de type vector(2560). On insère chaque chunk avec son vecteur.
Recherche : l’opérateur <=> calcule la distance cosinus entre le vecteur de la question et tous les vecteurs stockés. On prend les 3 plus proches avec ORDER BY embedding <=> $1 LIMIT 3. C’est la clé : la question est transformée en vecteur, et on cherche les chunks les plus proches sémantiquement.
Génération : on injecte les chunks trouvés dans un prompt système avec la consigne “réponds UNIQUEMENT à partir du contexte”. Le LLM n’a pas le droit d’utiliser sa mémoire — seulement les documents fournis.
Pourquoi ça marche (et pourquoi ça peut échouer)
Quand la réponse est dans les documents, le RAG est précis. Quand elle n’y est pas, le LLM avoue son ignorance. C’est le comportement idéal.
Mais le RAG n’est pas parfait :
-
Chunking trop grossier : un chunk peut contenir plusieurs sujets, noyant l’information pertinente
-
Mauvais embedding : si la question et le chunk ne partagent pas les mêmes mots-clés, la recherche peut les rater
-
Top-K trop petit : si on ne prend que 3 chunks, on peut manquer un document pertinent
On verra comment améliorer tout ça dans les articles suivants (chunking, hybrid search, reranking).
Pendant ce temps, sous le capot
Pendant que le script tourne, Ollama charge deux modèles en mémoire :
qwen3:4b — ~2.5 Go RAM pour le chat
qwen3-embedding:4b — ~1.5 Go RAM pour les embeddings
Soit environ 4 Go au total. Sur un MacBook Air 16 Go, ça tient très bien. L’embedding est beaucoup plus rapide que le chat (quelques millisecondes par appel).
Tu peux vérifier la mémoire avec Activity Monitor (cmd+espace → “Activity Monitor” → onglet Mémoire).
Le tableau récapitulatif :
| Situation | Résultat |
|---|---|
| Question avec info dans les documents | Réponse correcte, sourcée |
| Question sans info dans les documents | “Je ne sais pas” — pas d’hallucination |
| Question factuelle précise | Synthèse exacte du document |
Ce qu’on a appris
-
RAG — au lieu de faire répondre le LLM de mémoire, on lui fournit des documents pertinents et il répond à partir de ceux-ci
-
Ingestion — on découpe les documents en chunks, on les vectorise avec un modèle d’embedding, on les stocke dans pgvector
-
Recherche — on transforme la question en vecteur et on cherche les chunks les plus proches avec la distance cosinus
-
Génération — les chunks sont injectés dans le contexte du LLM via un prompt système qui limite sa réponse au contexte fourni
-
Pas d’hallucination — si l’information n’est pas dans les documents, le LLM répond honnêtement qu’il ne sait pas
Prochain article
De Dev Web à Ingénieur IA #6 — Chunking Stratégies — Fixed-size, sémantique, structure-aware : comment découper les documents pour optimiser la recherche RAG.