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.