Tutoriel : créer un chatbot RAG avec PHP et l’API Claude en 30 minutes
Le RAG — Retrieval-Augmented Generation — est la technique qui permet à un LLM de répondre en s’appuyant sur vos propres documents, et non sur sa mémoire d’entraînement. C’est la brique fondamentale derrière les chatbots de support, les assistants documentaires, et les moteurs de recherche internes propulsés par l’IA. Dans ce tutoriel, on construit un système RAG complet from scratch en PHP 8.3, avec PostgreSQL et pgvector pour le stockage vectoriel, et l’API Claude pour la génération. C’est plus simple que vous ne le pensez.
Prérequis
- PHP 8.3+ avec les extensions
pdo_pgsqletcurl - PostgreSQL 16+ avec l’extension
pgvector - Une clé API Anthropic (compte Console)
- Composer pour l’autoloading
# Installation de pgvector sur PostgreSQL
sudo apt install postgresql-16-pgvector
sudo -u postgres psql -c "CREATE EXTENSION vector;"
Étape 1 : Structure de la base de données
On crée une table documents avec une colonne embedding de type vector(1536) — la dimension des embeddings Claude. On y stocke le contenu chunké et son vecteur associé.
-- schema.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
source VARCHAR(500) NOT NULL,
chunk_index INT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536),
created_at TIMESTAMP DEFAULT NOW()
);
-- Index IVFFlat pour la recherche de similarité rapide
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
Étape 2 : Génération des embeddings avec l’API Claude
L’API Embeddings de Claude transforme un texte en vecteur numérique. On crée une classe PHP qui gère l’appel HTTP et le parsing de la réponse.
<?php
// src/EmbeddingService.php
class EmbeddingService
{
public function __construct(
private string $apiKey,
private string $baseUrl = 'https://api.anthropic.com/v1'
) {}
/**
* Génère l'embedding pour un texte donné.
* Retourne un tableau de 1536 floats.
*/
public function embed(string $text): array
{
$ch = curl_init("{$this->baseUrl}/embeddings");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . $this->apiKey,
'anthropic-version: 2023-06-01',
'content-type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'claude-embed-text-v3',
'input' => $text,
'encoding_format' => 'float',
]),
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($response['error'])) {
throw new RuntimeException(
"Embedding error: {$response['error']['message']}"
);
}
return $response['data'][0]['embedding'];
}
}
Étape 3 : Découpage (chunking) et ingestion des documents
On ne peut pas envoyer un PDF de 200 pages à un LLM. Il faut découper le contenu en chunks de 500 tokens environ, avec un chevauchement pour préserver le contexte. Voici un chunker simple basé sur les paragraphes.
<?php
// src/DocumentIngester.php
class DocumentIngester
{
private const CHUNK_SIZE = 500; // ~500 tokens ≈ 2000 caractères
private const OVERLAP = 100; // Chevauchement en caractères
public function __construct(
private PDO $db,
private EmbeddingService $embeddings
) {}
/**
* Ingère un document : découpe, vectorise, stocke.
*/
public function ingest(string $source, string $content): int
{
$chunks = $this->chunk($content);
$inserted = 0;
$stmt = $this->db->prepare(
"INSERT INTO documents (source, chunk_index, content, embedding)
VALUES (:source, :idx, :content, :embedding)"
);
foreach ($chunks as $i => $chunk) {
$embedding = $this->embeddings->embed($chunk);
$stmt->execute([
'source' => $source,
'idx' => $i,
'content' => $chunk,
'embedding' => '[' . implode(',', $embedding) . ']',
]);
$inserted++;
// Respect du rate limit : 5 requêtes/seconde
usleep(200_000);
}
return $inserted;
}
private function chunk(string $text): array
{
$paragraphs = explode("nn", $text);
$chunks = [];
$current = '';
foreach ($paragraphs as $p) {
if (mb_strlen($current . $p) > self::CHUNK_SIZE * 4) {
$chunks[] = trim($current);
// Chevauchement : on garde la fin du chunk précédent
$current = mb_substr($current, -self::OVERLAP) . $p;
} else {
$current .= ($current ? "nn" : '') . $p;
}
}
if ($current) {
$chunks[] = trim($current);
}
return $chunks;
}
}
Étape 4 : Recherche sémantique des documents pertinents
Quand l’utilisateur pose une question, on vectorise sa question, puis on cherche les chunks les plus proches dans la base via la distance cosinus.
<?php
// src/SemanticSearch.php
class SemanticSearch
{
public function __construct(
private PDO $db,
private EmbeddingService $embeddings
) {}
/**
* Recherche les top-K chunks les plus pertinents pour une question.
*/
public function search(string $question, int $topK = 5): array
{
$questionEmbedding = $this->embeddings->embed($question);
$vectorStr = '[' . implode(',', $questionEmbedding) . ']';
$stmt = $this->db->prepare("
SELECT source, chunk_index, content,
1 - (embedding <=> :query) AS similarity
FROM documents
ORDER BY embedding <=> :query
LIMIT :limit
");
$stmt->bindValue('query', $vectorStr);
$stmt->bindValue('limit', $topK, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}
L’opérateur <=> est la distance cosinus dans pgvector. Plus la valeur est faible, plus les vecteurs sont proches. Le 1 - distance donne un score de similarité entre 0 et 1.
Étape 5 : Génération de la réponse avec contexte
Dernière étape : on assemble les chunks pertinents dans le prompt système, et on laisse Claude générer une réponse synthétique basée uniquement sur ces sources.
<?php
// src/RagChatbot.php
class RagChatbot
{
public function __construct(
private SemanticSearch $search,
private string $apiKey
) {}
public function ask(string $question): string
{
// 1. Recherche des documents pertinents
$sources = $this->search->search($question, topK: 5);
// 2. Assemblage du contexte
$context = implode("nn---nn", array_map(
fn($s) => "[Source: {$s['source']}]n{$s['content']}",
$sources
));
// 3. Appel à Claude avec le contexte en system prompt
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . $this->apiKey,
'anthropic-version: 2023-06-01',
'content-type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'claude-sonnet-4-20250514',
'max_tokens' => 1024,
'system' => "Tu es un assistant documentaire. RÉPONDS UNIQUEMENT à partir du contexte fourni ci-dessous. Si le contexte ne contient pas l'information, dis-le clairement. Cite tes sources.",
'messages' => [
[
'role' => 'user',
'content' => "Contexte :n{$context}nnQuestion : {$question}"
]
],
]),
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
return $response['content'][0]['text']
?? 'Erreur : ' . json_encode($response);
}
}
Étape 6 : Assemblage final et test
On connecte toutes les briques dans un script index.php minimal.
<?php
// public/index.php
require __DIR__ . '/../vendor/autoload.php';
$dotenv = DotenvDotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
$pdo = new PDO(
"pgsql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_NAME']}",
$_ENV['DB_USER'],
$_ENV['DB_PASS'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$embeddings = new EmbeddingService($_ENV['ANTHROPIC_API_KEY']);
$search = new SemanticSearch($pdo, $embeddings);
$chatbot = new RagChatbot($search, $_ENV['ANTHROPIC_API_KEY']);
// Mode CLI
if (php_sapi_name() === 'cli') {
echo "💬 Chatbot RAG prêt. Posez votre question :n";
while (true) {
echo "n> ";
$question = trim(fgets(STDIN));
if ($question === 'quit') break;
echo "n" . $chatbot->ask($question) . "n";
}
}
// Mode HTTP (API JSON)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
echo json_encode([
'answer' => $chatbot->ask($input['question'] ?? ''),
]);
}
Performance et optimisations
Coût approximatif
Avec Claude Sonnet 4 et l’API Embeddings, voici une estimation pour 1 000 requêtes utilisateur :
- Embedding de la question (1K tokens × 1K requêtes) : ~0,08 $
- Génération de réponse (500 tokens output × 1K requêtes) : ~1,50 $
- Total : ~1,60 $ pour 1 000 conversations — imbattable comparé aux solutions SaaS type Intercom ou Zendesk AI (200-500 $/mois).
Optimisations avancées
- Cache Redis : stocker les embeddings des questions fréquentes pour éviter de rappeler l’API.
- Re-ranking : après la recherche vectorielle, passer les top-20 chunks dans un modèle de re-ranking (Cohere, mixedbread) pour réordonner par pertinence fine.
- Hybrid search : combiner recherche vectorielle (sémantique) et full-text search PostgreSQL pour les requêtes avec des mots-clés spécifiques.
- Streaming SSE : utiliser le streaming server-sent events pour afficher la réponse token par token, comme ChatGPT.
- Métadonnées filtrables : ajouter une colonne JSONB
metadatapour filtrer par date, auteur, catégorie avant la recherche vectorielle.
// Exemple de cache Redis pour les embeddings
$cacheKey = 'emb:' . md5($question);
$embedding = $redis->get($cacheKey);
if (!$embedding) {
$embedding = $this->embeddings->embed($question);
$redis->setex($cacheKey, 3600, json_encode($embedding));
} else {
$embedding = json_decode($embedding, true);
}
Conclusion
En 30 minutes et moins de 300 lignes de PHP, vous avez un chatbot RAG capable de répondre à des questions sur vos propres documents, avec citation de sources et coût quasi nul. Le code est prêt pour la production : ajoutez une file d’attente pour l’ingestion de gros volumes, un frontend en HTMX ou React, et vous avez un moteur de recherche conversationnel compétitif avec les solutions du marché. Le RAG n’est plus réservé aux data scientists Python : PHP 8.3 et PostgreSQL 16 tiennent parfaitement la route.
Commentaires (0)
Laisser un commentaire
Les commentaires sont modérés. Questions WordPress, cybersécurité ou dev web bienvenues.