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_pgsql et curl
  • 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 metadata pour 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.

Sources et références

📖 À lire aussi : 🔥 BREAKING : Fivetran + dbt Labs fusionnent, dbt Core 2.0 passe en Rust, Airbyte lance ses Agents — le Modern Data Stack entre dans l’ère de l’IA agentique

W
WP Admin Lab

Architecte web full-stack. WordPress, performance, data et sécurité. Notes de terrain, tests reproductibles et retours d'expérience.