La recherche sémantique n'est plus un luxe réservé aux géants. Avec pgvector et OpenAI embeddings, je l'implémente en une après-midi sur n'importe quel projet PHP. Voici le guide complet.

Pourquoi pas Elasticsearch ?

Si vous n'avez pas déjà ES, ne le déployez pas pour ça. pgvector sur Postgres couvre 95 % des besoins jusqu'à 10 millions de vecteurs, avec un seul serveur à gérer.

Installation

# Postgres 15+ avec pgvector
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE docs (
  id bigserial PRIMARY KEY,
  content text NOT NULL,
  embedding vector(1536) NOT NULL,
  metadata jsonb DEFAULT '{}'
);

CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops);

Générer les embeddings

function embed(string $text, string $apiKey): array {
    $ch = curl_init('https://api.openai.com/v1/embeddings');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . $apiKey,
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'model' => 'text-embedding-3-small',
            'input' => $text,
        ]),
    ]);
    $res = json_decode(curl_exec($ch), true);
    curl_close($ch);
    return $res['data'][0]['embedding'];
}

Indexation

function index(PDO $db, string $content, array $metadata, array $embedding): void {
    $stmt = $db->prepare('INSERT INTO docs (content, embedding, metadata) VALUES (?, ?, ?)');
    $vector = '[' . implode(',', $embedding) . ']';
    $stmt->execute([$content, $vector, json_encode($metadata)]);
}

Recherche sémantique

function search(PDO $db, string $query, int $k = 5, string $apiKey): array {
    $queryEmbedding = embed($query, $apiKey);
    $vector = '[' . implode(',', $queryEmbedding) . ']';
    $sql = 'SELECT id, content, metadata,
        1 - (embedding <=> :vec) AS similarity
      FROM docs
      ORDER BY embedding <=> :vec
      LIMIT :k';
    $stmt = $db->prepare($sql);
    $stmt->bindValue(':vec', $vector);
    $stmt->bindValue(':k', $k, PDO::PARAM_INT);
    $stmt->execute();
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

Voilà. Vous avez une recherche sémantique. L'opérateur <=> est la distance cosinus de pgvector.

Hybride lexical + sémantique

Postgres gère aussi la recherche full-text. Combinez les deux via Reciprocal Rank Fusion pour des résultats qui battent chaque méthode prise isolément :

WITH semantic AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> :vec) AS rnk
  FROM docs ORDER BY embedding <=> :vec LIMIT 20
),
lexical AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank(tsv, query) DESC) AS rnk
  FROM docs, plainto_tsquery(:q) query
  WHERE tsv @@ query LIMIT 20
)
SELECT d.id, d.content,
  SUM(1.0/(60 + COALESCE(s.rnk, 1000))) +
  SUM(1.0/(60 + COALESCE(l.rnk, 1000))) AS score
FROM docs d
LEFT JOIN semantic s USING (id)
LEFT JOIN lexical l USING (id)
WHERE s.id IS NOT NULL OR l.id IS NOT NULL
GROUP BY d.id ORDER BY score DESC LIMIT 10;

Coûts

text-embedding-3-small : 0,02 $ / million de tokens. Indexer 100k documents de 500 tokens = 1 $. Inhumble.

Points d'attention

  • Ré-embedder tout quand vous changez de modèle (pas rétrocompatible).
  • Normaliser les scores entre 0 et 1 avant d'exposer.
  • HNSW index est plus rapide que IVFFlat pour la plupart des cas.

Besoin d'aide pour intégrer ça dans votre SaaS ?