Si vous avez déjà tapé une requête “comment accélérer mon site” et obtenu un article sur “optimiser les images” qui n’a pas le mot “accélérer” une seule fois, vous avez touché la limite de la recherche plein texte classique. La recherche sémantique corrige ça en comparant le sens, pas les mots.
Le besoin / Le cas d’usage
WordPress 6.9.4 sait faire une recherche basique via WP_Query et le paramètre s. Ça marche pour des mots-clés exacts, moins pour des synonymes, des formulations longues, ou des contenus hétérogènes (guides, FAQ, docs, recettes, pages d’atterrissage).
Les embeddings (vecteurs) permettent de représenter un texte sous forme numérique. Deux textes “proches” sémantiquement ont des vecteurs proches. Résultat : vous pouvez retrouver un article même si la requête et le contenu n’ont aucun mot en commun.
Cas où je vois ça fonctionner immédiatement :
- Blogs techniques (WordPress, dev, SEO) avec beaucoup de contenus “comment faire” et de variantes de vocabulaire.
- Sites multi-auteurs où chacun écrit avec son propre lexique.
- Base de connaissances (support produit) où les utilisateurs décrivent un problème avec leurs mots.
- Sites multilingues (si vous embeddiez dans la langue de l’utilisateur ou avec un modèle multilingue).
À la fin, vous aurez :
- un pipeline WordPress qui calcule et stocke des embeddings OpenAI pour vos posts ;
- un endpoint de recherche qui transforme la requête en embedding ;
- un WP_Query custom qui retourne les posts les plus proches, triés par score ;
- un cache via Transients et un rate limiting simple côté serveur.
Résumé rapide
- On génère un embedding OpenAI pour chaque post (titre + extrait + contenu nettoyé) et on le stocke en post meta.
- À la recherche, on génère l’embedding de la requête, puis on calcule une similarité cosinus côté PHP.
- On récupère d’abord un pool de candidats via
WP_Query(filtre post_type, statut, taxonomies), puis on rerank par score sémantique. - On met en cache les embeddings de requêtes avec
set_transient()pour réduire les coûts. - On sécurise : clé en
wp-config.php, aucune clé côté JS, sanitization, nonces si vous exposez un endpoint. - On prévoit les pannes : timeouts, quotas, fallback sur recherche classique.
Quand utiliser l’IA pour ça
Utilisez des embeddings quand votre problème est la pertinence, pas la performance brute.
- Votre recherche interne génère des pages vues faibles parce que les gens ne trouvent pas.
- Vous avez beaucoup de contenu (à partir de quelques centaines d’articles), et les tags/catégories ne suffisent plus.
- Vous voulez une recherche qui tolère les paraphrases (“mettre en cache” vs “cache”, “lenteur” vs “performances”).
- Vous avez une contrainte UX : proposer des résultats “intelligents” dès 2–3 mots.
Dans mon expérience, le meilleur ROI arrive quand vous combinez : pool de candidats via WP_Query + rerank sémantique. Vous évitez de scorer 20 000 posts à chaque frappe clavier.
Quand ne PAS utiliser l’IA
Ne payez pas une API externe si une solution locale suffit.
- Moins de 100 contenus : améliorez d’abord le moteur natif (extraits, titres, taxonomies) ou installez un moteur dédié.
- Recherche “exacte” : références produit, codes, SKU, numéros de facture. Ici, le plein texte ou un index dédié est supérieur.
- Contrainte données sensibles : si vos contenus contiennent des données que vous ne pouvez pas envoyer à un tiers (même partiellement), n’externalisez pas.
- Budget serré : si vous avez 50 000 requêtes/mois et zéro cache, la facture grimpe vite.
Alternative classique : un moteur local (ou managé) type Elasticsearch/OpenSearch/Meilisearch, ou un plugin de recherche avancée. Pour rester 100% WordPress natif, vous pouvez aussi améliorer le ranking via posts_search / posts_clauses, mais vous resterez sur du lexical.
Prérequis
Versions et environnement
- WordPress 6.9.4 (avril 2026) ou supérieur.
- PHP 8.1+.
- HTTPS sortant autorisé vers l’API OpenAI.
Clé API OpenAI (stockage sécurisé)
Stockez la clé dans wp-config.php, jamais en dur dans un plugin ou un snippet.
/** Clé API OpenAI (ne pas committer ce fichier) */
define('OPENAI_API_KEY', 'sk-proj_xxxxxxxxxxxxxxxxxxxxx');
Si vous gérez plusieurs environnements, j’ai souvent vu une meilleure hygiène avec une variable d’environnement injectée par le serveur, puis lue dans wp-config.php.
Liens officiels utiles
- wp_remote_post() (WordPress Developer Resources)
- Transients API
- WP_Query
- OpenAI Embeddings API
- hash_hmac() (PHP)
Architecture de la solution
Voici ce qui se passe en coulisses, en version “schéma texte” :
Indexation
WordPress (save_post / WP-CLI) → préparation du texte →wp_remote_post()→ OpenAI Embeddings → stockage enpost_meta(vecteur + hash + modèle)Recherche
Formulaire → requête utilisateur → (transient cache ?) → embedding requête via OpenAI →WP_Querypour pool candidats → calcul similarité cosinus → tri → rendu (template / shortcode / widget)
Décisions techniques (et pourquoi)
- Stockage en post meta : simple à déployer, pas de table custom. Limite : taille et perf si vous scalez très haut.
- Pool candidats via WP_Query : vous gardez les filtres WP (post_type, taxonomies, langues, permissions). Puis vous rerankez.
- Similarité cosinus en PHP : robuste et facile. Pour un gros volume, vous passerez à une table custom + index vectoriel externe.
- Cache Transients : vous évitez de payer l’embedding de la même requête 100 fois.
Le code complet — étape par étape
Je vous propose un plugin minimaliste (sans dépendances) qui :
- indexe les posts (embedding stocké en meta) ;
- expose une fonction
bpcab_semantic_search()qui retourne des IDs triés ; - fournit un shortcode
[semantic_search]pour tester vite.
Vous pouvez le mettre en mu-plugin pour éviter qu’un éditeur le désactive par erreur. La doc mu-plugins est sur developer.wordpress.org.
1) Un client OpenAI via wp_remote_post()
On appelle l’API Embeddings. Pas de SDK, pas de Composer. On gère timeout, erreurs HTTP, JSON invalide.
/**
* Appel OpenAI Embeddings via wp_remote_post().
* Compatible WordPress 6.9.4+ / PHP 8.1+
*
* @param string $input Texte à vectoriser.
* @param string $model Modèle embeddings (à ajuster selon votre compte OpenAI).
* @return array{vector: float[], model: string, raw: array}|WP_Error
*/
function bpcab_openai_embeddings(string $input, string $model = 'text-embedding-3-small') {
if (!defined('OPENAI_API_KEY') || !OPENAI_API_KEY) {
return new WP_Error('bpcab_no_api_key', 'Constante OPENAI_API_KEY manquante.');
}
$input = trim($input);
if ($input === '') {
return new WP_Error('bpcab_empty_input', 'Texte vide : impossible de générer un embedding.');
}
$body = wp_json_encode([
'model' => $model,
'input' => $input,
], JSON_UNESCAPED_UNICODE);
if (!$body) {
return new WP_Error('bpcab_json_encode_failed', 'Échec encodage JSON pour la requête embeddings.');
}
$args = [
'method' => 'POST',
'timeout' => 20, // J'ai souvent vu 5s trop court sur des hébergements mutualisés
'headers' => [
'Authorization' => 'Bearer ' . OPENAI_API_KEY,
'Content-Type' => 'application/json',
],
'body' => $body,
];
$response = wp_remote_post('https://api.openai.com/v1/embeddings', $args);
if (is_wp_error($response)) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code($response);
$raw = wp_remote_retrieve_body($response);
if ($code < 200 || $code >= 300) {
return new WP_Error(
'bpcab_openai_http_error',
'Erreur HTTP OpenAI embeddings: ' . $code,
['body' => $raw]
);
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return new WP_Error('bpcab_openai_bad_json', 'Réponse JSON invalide côté OpenAI.', ['body' => $raw]);
}
$vector = $data['data'][0]['embedding'] ?? null;
if (!is_array($vector) || empty($vector)) {
return new WP_Error('bpcab_openai_no_vector', 'Aucun vecteur embedding dans la réponse.', ['data' => $data]);
}
// Normalisation de type : on force en float
$vector = array_map('floatval', $vector);
return [
'vector' => $vector,
'model' => (string) ($data['model'] ?? $model),
'raw' => $data,
];
}
2) Préparer le texte d’un post (sans envoyer n’importe quoi)
Le piège classique : envoyer le HTML complet, les shortcodes Divi/Elementor, ou des blocs Gutenberg sérialisés. Ça pollue l’embedding et ça coûte.
Je préfère :
- titre + extrait ;
- contenu rendu puis nettoyé ;
- limite de longueur (pour maîtriser les tokens).
/**
* Prépare un texte "propre" pour embedding à partir d'un post.
* Attention : on évite d'envoyer des infos inutiles (shortcodes, scripts, etc.).
*
* @param WP_Post $post
* @return string
*/
function bpcab_prepare_post_text_for_embedding(WP_Post $post): string {
$title = get_the_title($post);
$excerpt = has_excerpt($post) ? $post->post_excerpt : '';
// On rend le contenu via the_content pour gérer les blocs, puis on nettoie.
$content = apply_filters('the_content', $post->post_content);
// Retire les balises, compacte les espaces.
$content = wp_strip_all_tags($content, true);
$content = preg_replace('/s+/u', ' ', $content ?? '');
$content = trim((string) $content);
$text = trim($title . "nn" . $excerpt . "nn" . $content);
// Limite raisonnable : évite d'envoyer 30 000 caractères par post.
// Ajustez selon votre modèle et votre budget.
$max_chars = 8000;
if (mb_strlen($text, 'UTF-8') > $max_chars) {
$text = mb_substr($text, 0, $max_chars, 'UTF-8');
}
return $text;
}
3) Stocker l’embedding en post meta (avec hash de contenu)
On veut éviter de recalculer si le post n’a pas changé. Le hash sert de “fingerprint” sur le texte envoyé.
/**
* Calcule et stocke l'embedding d'un post si nécessaire.
*
* Meta stockées :
* - _bpcab_embedding_vector : JSON du vecteur
* - _bpcab_embedding_model : modèle utilisé
* - _bpcab_embedding_hash : hash du texte source
* - _bpcab_embedding_updated_at : timestamp
*
* @param int $post_id
* @param bool $force
* @return true|WP_Error
*/
function bpcab_index_post_embedding(int $post_id, bool $force = false) {
$post = get_post($post_id);
if (!$post || $post->post_status !== 'publish') {
return new WP_Error('bpcab_post_not_indexable', 'Post non indexable (inexistant ou non publié).');
}
// Évitez de vectoriser les révisions/auto-drafts.
if (wp_is_post_revision($post_id) || $post->post_status === 'auto-draft') {
return new WP_Error('bpcab_skip_revision', 'Révision/auto-draft : indexation ignorée.');
}
$text = bpcab_prepare_post_text_for_embedding($post);
// Hash stable pour détecter les changements.
$hash = hash('sha256', $text);
$existing_hash = (string) get_post_meta($post_id, '_bpcab_embedding_hash', true);
$existing_vec = (string) get_post_meta($post_id, '_bpcab_embedding_vector', true);
if (!$force && $existing_hash === $hash && $existing_vec !== '') {
return true; // Rien à faire
}
$result = bpcab_openai_embeddings($text, 'text-embedding-3-small');
if (is_wp_error($result)) {
return $result;
}
$vector_json = wp_json_encode($result['vector']);
if (!$vector_json) {
return new WP_Error('bpcab_vector_json_failed', 'Impossible d’encoder le vecteur en JSON.');
}
update_post_meta($post_id, '_bpcab_embedding_vector', $vector_json);
update_post_meta($post_id, '_bpcab_embedding_model', sanitize_text_field($result['model']));
update_post_meta($post_id, '_bpcab_embedding_hash', $hash);
update_post_meta($post_id, '_bpcab_embedding_updated_at', time());
return true;
}
4) Hook d’indexation sur save_post (avec garde-fous)
Piège fréquent : indexer pendant l’autosave, ou sur un hook qui tourne avant que le contenu final soit disponible. Autre piège : boucler (update_post_meta déclenche des hooks).
/**
* Indexation à la sauvegarde.
* Note : pour les gros sites, préférez WP-CLI + cron, pas à chaque save en admin.
*/
function bpcab_on_save_post(int $post_id, WP_Post $post, bool $update): void {
// Permissions
if (!current_user_can('edit_post', $post_id)) {
return;
}
// Autosave
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
// Types ciblés
$allowed_post_types = ['post', 'page'];
if (!in_array($post->post_type, $allowed_post_types, true)) {
return;
}
// On évite d’indexer tout de suite si le post n’est pas publié.
if ($post->post_status !== 'publish') {
return;
}
// Vous pouvez basculer en asynchrone via wp_schedule_single_event().
// Ici, on fait simple mais fiable.
$res = bpcab_index_post_embedding($post_id, false);
if (is_wp_error($res)) {
error_log('[bpcab] Indexation embedding échouée post ' . $post_id . ' : ' . $res->get_error_message());
}
}
add_action('save_post', 'bpcab_on_save_post', 20, 3);
5) Similarité cosinus (avec normalisation)
On calcule un score entre -1 et 1. Plus c’est proche de 1, plus c’est similaire.
/**
* Similarité cosinus entre deux vecteurs.
*
* @param float[] $a
* @param float[] $b
* @return float
*/
function bpcab_cosine_similarity(array $a, array $b): float {
$lenA = count($a);
if ($lenA === 0 || $lenA !== count($b)) {
return 0.0;
}
$dot = 0.0;
$normA = 0.0;
$normB = 0.0;
for ($i = 0; $i < $lenA; $i++) {
$ai = (float) $a[$i];
$bi = (float) $b[$i];
$dot += $ai * $bi;
$normA += $ai * $ai;
$normB += $bi * $bi;
}
if ($normA == 0.0 || $normB == 0.0) {
return 0.0;
}
return $dot / (sqrt($normA) * sqrt($normB));
}
6) Embedding de requête + cache transient
La requête utilisateur est souvent répétée (“cache”, “seo”, “elementor”). Cachez l’embedding de requête 24h, et vous économisez tout de suite.
/**
* Récupère (ou calcule) l'embedding d'une requête utilisateur avec cache transient.
*
* @param string $query
* @return float[]|WP_Error
*/
function bpcab_get_query_embedding(string $query) {
$query = trim($query);
if ($query === '') {
return new WP_Error('bpcab_empty_query', 'Requête vide.');
}
// Clé stable, courte et safe en DB
$key = 'bpcab_qe_' . substr(hash('sha256', $query), 0, 24);
$cached = get_transient($key);
if (is_array($cached) && !empty($cached)) {
return array_map('floatval', $cached);
}
$res = bpcab_openai_embeddings($query, 'text-embedding-3-small');
if (is_wp_error($res)) {
return $res;
}
$vector = $res['vector'];
// Cache 24h (ajustez selon votre trafic)
set_transient($key, $vector, DAY_IN_SECONDS);
return $vector;
}
7) WP_Query custom : pool candidats puis rerank sémantique
Vous ne voulez pas scorer toute la base. On fait une requête WordPress “classique” pour limiter à, par exemple, 200 candidats. Puis on score ces candidats seulement.
Le pool peut être :
- les posts récents ;
- un post_type précis ;
- une catégorie ;
- un sous-site (multisite) ;
- une langue (Polylang / WPML via tax/meta selon votre setup).
/**
* Recherche sémantique : retourne des IDs de posts triés par score.
*
* @param string $query Texte utilisateur.
* @param array $args {
* @type int $limit Nombre de résultats finaux.
* @type int $candidate_pool Taille du pool candidat WP_Query.
* @type array $wp_query_args Arguments WP_Query supplémentaires.
* @type float $min_score Score minimum (filtre).
* }
* @return array{ids:int[], scores: array<int,float>, took_ms:int}|WP_Error
*/
function bpcab_semantic_search(string $query, array $args = []) {
$limit = isset($args['limit']) ? (int) $args['limit'] : 10;
$candidate_pool = isset($args['candidate_pool']) ? (int) $args['candidate_pool'] : 200;
$min_score = isset($args['min_score']) ? (float) $args['min_score'] : 0.20;
$start = microtime(true);
$qvec = bpcab_get_query_embedding($query);
if (is_wp_error($qvec)) {
return $qvec;
}
$wp_query_args = $args['wp_query_args'] ?? [];
if (!is_array($wp_query_args)) {
$wp_query_args = [];
}
// Base query : ajustez à vos besoins
$base = [
'post_type' => ['post', 'page'],
'post_status' => 'publish',
'posts_per_page' => $candidate_pool,
'no_found_rows' => true,
'ignore_sticky_posts' => true,
'fields' => 'ids',
'orderby' => 'date',
'order' => 'DESC',
'meta_query' => [
[
'key' => '_bpcab_embedding_vector',
'compare' => 'EXISTS',
],
],
];
$q = new WP_Query(array_merge($base, $wp_query_args));
$scores = [];
foreach ($q->posts as $post_id) {
$vec_json = (string) get_post_meta($post_id, '_bpcab_embedding_vector', true);
if ($vec_json === '') {
continue;
}
$vec = json_decode($vec_json, true);
if (!is_array($vec) || empty($vec)) {
continue;
}
$score = bpcab_cosine_similarity($qvec, array_map('floatval', $vec));
if ($score >= $min_score) {
$scores[(int) $post_id] = $score;
}
}
// Tri décroissant par score
arsort($scores, SORT_NUMERIC);
$ids = array_slice(array_keys($scores), 0, max(1, $limit));
$took_ms = (int) round((microtime(true) - $start) * 1000);
return [
'ids' => $ids,
'scores' => $scores,
'took_ms' => $took_ms,
];
}
8) Rendu : shortcode de test (sans exposer la clé)
Le shortcode vous permet de tester sur une page “sandbox”. Ne faites pas un front qui appelle OpenAI depuis le navigateur : la clé finirait copiée en 30 secondes.
/**
* Shortcode [semantic_search]
* Usage : [semantic_search]
*/
function bpcab_semantic_search_shortcode($atts): string {
// Formulaire minimal, sans JS
$q = isset($_GET['bpcab_q']) ? sanitize_text_field(wp_unslash($_GET['bpcab_q'])) : '';
$html = '';
$html .= '<form method="get">';
$html .= '<input type="text" name="bpcab_q" value="' . esc_attr($q) . '" placeholder="Votre recherche..." /> ';
$html .= '<button type="submit">Rechercher</button>';
$html .= '</form>';
if ($q === '') {
return $html;
}
$res = bpcab_semantic_search($q, [
'limit' => 10,
'candidate_pool' => 200,
'min_score' => 0.20,
]);
if (is_wp_error($res)) {
return $html . '<p><strong>Erreur :</strong> ' . esc_html($res->get_error_message()) . '</p>';
}
$html .= '<p>Temps : ' . esc_html((string) $res['took_ms']) . ' ms</p>';
$html .= '<ol>';
foreach ($res['ids'] as $post_id) {
$title = get_the_title($post_id);
$link = get_permalink($post_id);
$score = $res['scores'][$post_id] ?? 0;
$html .= '<li><a href="' . esc_url($link) . '">' . esc_html($title) . '</a>';
$html .= ' <span>(' . esc_html(number_format_i18n((float) $score, 3)) . ')</span></li>';
}
$html .= '</ol>';
return $html;
}
add_shortcode('semantic_search', 'bpcab_semantic_search_shortcode');
Le code assemblé complet
Copiez-collez ce fichier en wp-content/mu-plugins/bpcab-semantic-search.php (créez le dossier si besoin). Si vous le mettez en plugin standard, ajoutez un header de plugin.
<?php
/**
* Plugin Name: BPCAB Semantic Search (Embeddings OpenAI)
* Description: Recherche sémantique WordPress via embeddings OpenAI + WP_Query custom (pool + rerank).
* Version: 1.0.0
* Requires at least: 6.9
* Requires PHP: 8.1
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Appel OpenAI Embeddings via wp_remote_post().
*
* @param string $input
* @param string $model
* @return array{vector: float[], model: string, raw: array}|WP_Error
*/
function bpcab_openai_embeddings(string $input, string $model = 'text-embedding-3-small') {
if (!defined('OPENAI_API_KEY') || !OPENAI_API_KEY) {
return new WP_Error('bpcab_no_api_key', 'Constante OPENAI_API_KEY manquante.');
}
$input = trim($input);
if ($input === '') {
return new WP_Error('bpcab_empty_input', 'Texte vide : impossible de générer un embedding.');
}
$body = wp_json_encode([
'model' => $model,
'input' => $input,
], JSON_UNESCAPED_UNICODE);
if (!$body) {
return new WP_Error('bpcab_json_encode_failed', 'Échec encodage JSON pour la requête embeddings.');
}
$args = [
'method' => 'POST',
'timeout' => 20,
'headers' => [
'Authorization' => 'Bearer ' . OPENAI_API_KEY,
'Content-Type' => 'application/json',
],
'body' => $body,
];
$response = wp_remote_post('https://api.openai.com/v1/embeddings', $args);
if (is_wp_error($response)) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code($response);
$raw = wp_remote_retrieve_body($response);
if ($code < 200 || $code >= 300) {
return new WP_Error(
'bpcab_openai_http_error',
'Erreur HTTP OpenAI embeddings: ' . $code,
['body' => $raw]
);
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return new WP_Error('bpcab_openai_bad_json', 'Réponse JSON invalide côté OpenAI.', ['body' => $raw]);
}
$vector = $data['data'][0]['embedding'] ?? null;
if (!is_array($vector) || empty($vector)) {
return new WP_Error('bpcab_openai_no_vector', 'Aucun vecteur embedding dans la réponse.', ['data' => $data]);
}
$vector = array_map('floatval', $vector);
return [
'vector' => $vector,
'model' => (string) ($data['model'] ?? $model),
'raw' => $data,
];
}
/**
* Prépare un texte "propre" pour embedding à partir d'un post.
*
* @param WP_Post $post
* @return string
*/
function bpcab_prepare_post_text_for_embedding(WP_Post $post): string {
$title = get_the_title($post);
$excerpt = has_excerpt($post) ? $post->post_excerpt : '';
$content = apply_filters('the_content', $post->post_content);
$content = wp_strip_all_tags($content, true);
$content = preg_replace('/s+/u', ' ', $content ?? '');
$content = trim((string) $content);
$text = trim($title . "nn" . $excerpt . "nn" . $content);
$max_chars = 8000;
if (mb_strlen($text, 'UTF-8') > $max_chars) {
$text = mb_substr($text, 0, $max_chars, 'UTF-8');
}
return $text;
}
/**
* Calcule et stocke l'embedding d'un post si nécessaire.
*
* @param int $post_id
* @param bool $force
* @return true|WP_Error
*/
function bpcab_index_post_embedding(int $post_id, bool $force = false) {
$post = get_post($post_id);
if (!$post || $post->post_status !== 'publish') {
return new WP_Error('bpcab_post_not_indexable', 'Post non indexable (inexistant ou non publié).');
}
if (wp_is_post_revision($post_id) || $post->post_status === 'auto-draft') {
return new WP_Error('bpcab_skip_revision', 'Révision/auto-draft : indexation ignorée.');
}
$text = bpcab_prepare_post_text_for_embedding($post);
$hash = hash('sha256', $text);
$existing_hash = (string) get_post_meta($post_id, '_bpcab_embedding_hash', true);
$existing_vec = (string) get_post_meta($post_id, '_bpcab_embedding_vector', true);
if (!$force && $existing_hash === $hash && $existing_vec !== '') {
return true;
}
$result = bpcab_openai_embeddings($text, 'text-embedding-3-small');
if (is_wp_error($result)) {
return $result;
}
$vector_json = wp_json_encode($result['vector']);
if (!$vector_json) {
return new WP_Error('bpcab_vector_json_failed', 'Impossible d’encoder le vecteur en JSON.');
}
update_post_meta($post_id, '_bpcab_embedding_vector', $vector_json);
update_post_meta($post_id, '_bpcab_embedding_model', sanitize_text_field($result['model']));
update_post_meta($post_id, '_bpcab_embedding_hash', $hash);
update_post_meta($post_id, '_bpcab_embedding_updated_at', time());
return true;
}
/**
* Indexation à la sauvegarde.
*/
function bpcab_on_save_post(int $post_id, WP_Post $post, bool $update): void {
if (!current_user_can('edit_post', $post_id)) {
return;
}
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
$allowed_post_types = ['post', 'page'];
if (!in_array($post->post_type, $allowed_post_types, true)) {
return;
}
if ($post->post_status !== 'publish') {
return;
}
$res = bpcab_index_post_embedding($post_id, false);
if (is_wp_error($res)) {
error_log('[bpcab] Indexation embedding échouée post ' . $post_id . ' : ' . $res->get_error_message());
}
}
add_action('save_post', 'bpcab_on_save_post', 20, 3);
/**
* Similarité cosinus entre deux vecteurs.
*
* @param float[] $a
* @param float[] $b
* @return float
*/
function bpcab_cosine_similarity(array $a, array $b): float {
$lenA = count($a);
if ($lenA === 0 || $lenA !== count($b)) {
return 0.0;
}
$dot = 0.0;
$normA = 0.0;
$normB = 0.0;
for ($i = 0; $i < $lenA; $i++) {
$ai = (float) $a[$i];
$bi = (float) $b[$i];
$dot += $ai * $bi;
$normA += $ai * $ai;
$normB += $bi * $bi;
}
if ($normA == 0.0 || $normB == 0.0) {
return 0.0;
}
return $dot / (sqrt($normA) * sqrt($normB));
}
/**
* Récupère (ou calcule) l'embedding d'une requête utilisateur avec cache transient.
*
* @param string $query
* @return float[]|WP_Error
*/
function bpcab_get_query_embedding(string $query) {
$query = trim($query);
if ($query === '') {
return new WP_Error('bpcab_empty_query', 'Requête vide.');
}
$key = 'bpcab_qe_' . substr(hash('sha256', $query), 0, 24);
$cached = get_transient($key);
if (is_array($cached) && !empty($cached)) {
return array_map('floatval', $cached);
}
$res = bpcab_openai_embeddings($query, 'text-embedding-3-small');
if (is_wp_error($res)) {
return $res;
}
$vector = $res['vector'];
set_transient($key, $vector, DAY_IN_SECONDS);
return $vector;
}
/**
* Recherche sémantique : retourne des IDs de posts triés par score.
*
* @param string $query
* @param array $args
* @return array{ids:int[], scores: array<int,float>, took_ms:int}|WP_Error
*/
function bpcab_semantic_search(string $query, array $args = []) {
$limit = isset($args['limit']) ? (int) $args['limit'] : 10;
$candidate_pool = isset($args['candidate_pool']) ? (int) $args['candidate_pool'] : 200;
$min_score = isset($args['min_score']) ? (float) $args['min_score'] : 0.20;
$start = microtime(true);
$qvec = bpcab_get_query_embedding($query);
if (is_wp_error($qvec)) {
return $qvec;
}
$wp_query_args = $args['wp_query_args'] ?? [];
if (!is_array($wp_query_args)) {
$wp_query_args = [];
}
$base = [
'post_type' => ['post', 'page'],
'post_status' => 'publish',
'posts_per_page' => $candidate_pool,
'no_found_rows' => true,
'ignore_sticky_posts' => true,
'fields' => 'ids',
'orderby' => 'date',
'order' => 'DESC',
'meta_query' => [
[
'key' => '_bpcab_embedding_vector',
'compare' => 'EXISTS',
],
],
];
$q = new WP_Query(array_merge($base, $wp_query_args));
$scores = [];
foreach ($q->posts as $post_id) {
$vec_json = (string) get_post_meta($post_id, '_bpcab_embedding_vector', true);
if ($vec_json === '') {
continue;
}
$vec = json_decode($vec_json, true);
if (!is_array($vec) || empty($vec)) {
continue;
}
$score = bpcab_cosine_similarity($qvec, array_map('floatval', $vec));
if ($score >= $min_score) {
$scores[(int) $post_id] = $score;
}
}
arsort($scores, SORT_NUMERIC);
$ids = array_slice(array_keys($scores), 0, max(1, $limit));
$took_ms = (int) round((microtime(true) - $start) * 1000);
return [
'ids' => $ids,
'scores' => $scores,
'took_ms' => $took_ms,
];
}
/**
* Shortcode de test : [semantic_search]
*/
function bpcab_semantic_search_shortcode($atts): string {
$q = isset($_GET['bpcab_q']) ? sanitize_text_field(wp_unslash($_GET['bpcab_q'])) : '';
$html = '';
$html .= '<form method="get">';
$html .= '<input type="text" name="bpcab_q" value="' . esc_attr($q) . '" placeholder="Votre recherche..." /> ';
$html .= '<button type="submit">Rechercher</button>';
$html .= '</form>';
if ($q === '') {
return $html;
}
$res = bpcab_semantic_search($q);
if (is_wp_error($res)) {
return $html . '<p><strong>Erreur :</strong> ' . esc_html($res->get_error_message()) . '</p>';
}
$html .= '<p>Temps : ' . esc_html((string) $res['took_ms']) . ' ms</p>';
$html .= '<ol>';
foreach ($res['ids'] as $post_id) {
$title = get_the_title($post_id);
$link = get_permalink($post_id);
$score = $res['scores'][$post_id] ?? 0;
$html .= '<li><a href="' . esc_url($link) . '">' . esc_html($title) . '</a>';
$html .= ' <span>(' . esc_html(number_format_i18n((float) $score, 3)) . ')</span></li>';
}
$html .= '</ol>';
return $html;
}
add_shortcode('semantic_search', 'bpcab_semantic_search_shortcode');
Explication du code
Pourquoi stocker le vecteur en JSON en meta ?
Parce que c’est le chemin le plus court vers un POC exploitable. Vous avez une migration DB minimale, un rollback simple, et vous restez dans les API WP standard (get_post_meta(), update_post_meta()).
Le revers : la meta n’est pas un index vectoriel. Donc la stratégie “pool + rerank” est volontaire. Vous utilisez WordPress pour filtrer, puis vous calculez en PHP sur un sous-ensemble.
Pourquoi rendre le contenu via the_content ?
Sur des sites Gutenberg/Divi/Elementor, le contenu brut contient des structures qui ne ressemblent pas au texte final. En passant par apply_filters('the_content', ...), vous récupérez un rendu proche de ce que l’utilisateur lit, puis vous nettoyez.
Edge case : certains builders injectent beaucoup de “chrome” (titres, boutons, CTA). Si votre embedding devient trop “marketing”, réduisez la source à titre + excerpt + quelques blocs sélectionnés (voir variantes).
Pourquoi un hash SHA-256 ?
Parce que recalculer des embeddings à chaque sauvegarde coûte. Le hash vous donne un test “le texte source a changé” sans stocker le texte source en DB.
WP_Query custom : pourquoi pas un ORDER BY score SQL ?
Parce que le score n’est pas en SQL : il dépend d’un calcul vectoriel. Vous pourriez le faire en SQL avec des colonnes dédiées, mais WordPress + MySQL/MariaDB n’offre pas un vecteur natif stable et portable pour ça.
La bonne approche WordPress : WP_Query pour sélectionner, puis PHP pour scorer.
Sanitization : où et comment ?
Pour les embeddings, on ne réinjecte pas du HTML OpenAI dans la page. Le risque XSS est faible ici, mais vous devez quand même :
- sanitizer l’entrée utilisateur (
sanitize_text_field()) ; - échapper la sortie (
esc_html(),esc_url()).
Si vous ajoutez ensuite une étape “résumé IA” ou “extrait IA”, là vous devrez filtrer la réponse avec wp_kses_post() (et je vous conseille de rester sur du texte brut).
Coûts API et optimisation
Les coûts varient selon le modèle embeddings et la tarification OpenAI au moment où vous lisez ceci. Référez-vous à la page officielle de pricing de votre compte. Ce qui compte pour estimer : tokens envoyés et nombre d’appels.
Estimation simple (ordre de grandeur)
- Indexation : 1 appel embeddings par post publié (puis seulement quand le contenu change).
- Recherche : 1 appel embeddings par requête utilisateur (mais cache 24h recommandé).
Exemple réaliste que je vois souvent :
- 2 000 posts, indexés une fois : 2 000 appels (ponctuel).
- 10 000 recherches/mois, mais seulement 2 000 requêtes uniques grâce au cache : 2 000 appels/mois.
Optimisations qui font vraiment baisser la facture
- Cache query embeddings (déjà fait) : c’est le levier n°1.
- Réduire la longueur du texte indexé : 8 000 caractères max est une base, mais vous pouvez descendre à 2 000–4 000 si votre contenu est long.
- Indexer en batch : WP-CLI + cron, pas à chaque save en admin (évite des appels inutiles pendant l’édition).
- Pool candidats plus petit : 200 → 100 si votre site est bien segmenté par taxonomies.
Variantes et cas d’usage avancés
1) Indexation asynchrone (éviter de bloquer l’éditeur)
Sur des sites avec beaucoup d’auteurs, j’ai souvent vu l’admin “ramer” parce que l’appel OpenAI part pendant le save_post. La solution : planifier un événement et indexer en arrière-plan.
/**
* Planifie l'indexation 30 secondes après la sauvegarde.
*/
function bpcab_schedule_indexing(int $post_id): void {
if (!wp_next_scheduled('bpcab_index_post_event', [$post_id])) {
wp_schedule_single_event(time() + 30, 'bpcab_index_post_event', [$post_id]);
}
}
add_action('save_post', function($post_id, $post, $update) {
if ($post instanceof WP_Post && $post->post_status === 'publish') {
bpcab_schedule_indexing((int) $post_id);
}
}, 30, 3);
add_action('bpcab_index_post_event', function($post_id) {
$res = bpcab_index_post_embedding((int) $post_id, false);
if (is_wp_error($res)) {
error_log('[bpcab] Indexation async échouée post ' . (int) $post_id . ' : ' . $res->get_error_message());
}
}, 10, 1);
2) WP_Query “intelligent” : filtrer par taxonomie avant rerank
Si vous avez des catégories fortes (“WooCommerce”, “SEO”, “Performance”), filtrez d’abord. Vous augmentez la pertinence et vous baissez le CPU.
$res = bpcab_semantic_search($q, [
'limit' => 10,
'candidate_pool' => 120,
'wp_query_args' => [
'tax_query' => [
[
'taxonomy' => 'category',
'field' => 'slug',
'terms' => ['performance'],
],
],
],
]);
3) Compatibilité Divi 5 / Elementor / Avada
Le point sensible n’est pas “la recherche” mais la source textuelle que vous envoyez à l’embedding.
- Divi 5 : le contenu peut contenir des shortcodes/structures. En passant par
the_contentpuiswp_strip_all_tags, vous récupérez généralement un texte exploitable. Si vous avez beaucoup de modules “boutons”/CTA, baissez le$max_charset privilégiez titre + excerpt. - Elementor : Elementor stocke une partie de la structure en meta. Le rendu final via
the_contentest souvent correct si la page est construite proprement. Sur certains sites, j’ai dû exclure les pages “landing” trop décoratives de l’index sémantique (post meta flag). - Avada (Fusion Builder) : même logique. Le rendu final passe, mais attention aux pages très courtes (beaucoup de layout, peu de texte) : l’embedding devient bruité. Filtrez ces pages ou enrichissez avec des champs ACF (ex : “résumé” éditorial).
Variante utile : ajouter un champ meta _bpcab_embedding_source éditable (ou ACF) pour contrôler le texte exact indexé sur des pages builder.
Sécurité et bonnes pratiques
Ne jamais exposer la clé API côté client
Pas de fetch JS vers OpenAI depuis le navigateur. Même avec un proxy, si vous renvoyez trop d’infos de debug, vous finirez par exposer des détails sensibles. Tout appel OpenAI doit rester serveur via wp_remote_post().
Validation et limitation des entrées
- Limitez la longueur de la requête utilisateur (ex : 200–500 caractères).
- Refusez les requêtes vides ou composées d’un seul caractère.
- Ajoutez un rate limiting par IP (transient) si vous ouvrez la recherche à des visiteurs non authentifiés.
/**
* Rate limiting simple (par IP) pour éviter l'abus.
* @return true|WP_Error
*/
function bpcab_rate_limit_check(): mixed {
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$key = 'bpcab_rl_' . substr(hash('sha256', $ip), 0, 16);
$count = (int) get_transient($key);
if ($count >= 60) { // 60 requêtes / 10 minutes
return new WP_Error('bpcab_rate_limited', 'Trop de requêtes, réessayez plus tard.');
}
set_transient($key, $count + 1, 10 * MINUTE_IN_SECONDS);
return true;
}
RGPD / confidentialité
Vous envoyez du contenu à un tiers (OpenAI). Sur un site européen, ça implique :
- cartographier exactement ce qui part (contenu public ? données utilisateurs ?),
- documenter la finalité (amélioration de la recherche),
- vérifier la base légale et les clauses contractuelles,
- éviter d’envoyer des contenus privés (brouillons, données de formulaires, emails).
Pratique que j’applique : indexer uniquement le contenu publié (déjà le cas) et exclure certains post_types (commandes, profils, tickets).
Comment tester et déboguer
1) Activez les logs WordPress proprement
Dans wp-config.php :
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
Référez-vous à la doc officielle : Debugging in WordPress.
2) Vérifiez l’indexation d’un post
- Éditez un post publié, modifiez une phrase, mettez à jour.
- Regardez en base si
_bpcab_embedding_vectorest présent (via phpMyAdmin ou WP-CLI). - Surveillez
wp-content/debug.logsi l’appel OpenAI échoue.
3) Testez la recherche avec le shortcode
- Créez une page “Test recherche sémantique”.
- Ajoutez
[semantic_search]. - Lancez des requêtes synonymes (ex : “ralentissement”, “site lent”, “TTFB”).
4) Débogage HTTP
Si vous suspectez un souci réseau, loggez le code HTTP et un extrait du body en cas d’erreur (déjà inclus dans WP_Error). Attention : ne loggez pas la clé API.
Si ça ne marche pas
Voici les pannes que je croise le plus souvent, avec une méthode de vérification rapide.
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| Résultats vides | Embeddings non indexés | Vérifier la meta _bpcab_embedding_vector sur quelques posts |
Forcer une réindexation (WP-CLI ou $force=true), vérifier le hook save_post |
| Erreur “Constante OPENAI_API_KEY manquante” | Clé non définie ou mauvais fichier | Contrôler wp-config.php et le chargement (mu-plugin) |
Définir OPENAI_API_KEY, purger opcode cache si besoin |
| Timeout HTTP | Timeout trop court / blocage sortant | Logs, tester un curl serveur vers api.openai.com |
Augmenter timeout, autoriser la sortie HTTPS, vérifier firewall |
| Erreur 401/403 | Clé invalide / droits insuffisants | Code HTTP dans WP_Error |
Regénérer la clé, vérifier le projet OpenAI |
| Erreur 429 | Quota dépassé / rate limit OpenAI | Body de réponse | Ajouter cache, réduire appels, backoff, surveiller usage |
| Fatal error après copier-coller | Point-virgule manquant / accolade | Logs PHP / écran blanc | Coller dans un vrai fichier plugin, passer le code dans un linter, vérifier la version PHP 8.1+ |
| Le shortcode s’affiche mais ne trouve rien | min_score trop élevé |
Descendre temporairement min_score à 0.05 |
Ajuster le seuil, augmenter le pool, améliorer le texte indexé |
Erreurs “humaines” à éviter (vraies causes de tickets)
- Coller le code dans functions.php d’un thème parent : mise à jour = code perdu. Préférez mu-plugin.
- Tester en production sans sauvegarde : une parenthèse oubliée et vous cassez le front.
- Utiliser un plugin de snippets qui exécute le code trop tôt : si vous référencez des classes non chargées, vous aurez des fatals. Ici on reste sur des fonctions, donc risque réduit.
- Oublier de publier le post : le code n’indexe que
publish(volontaire). - Conflit avec un cache serveur : vous modifiez le code mais rien ne change. Videz OPcache / cache applicatif.
Ressources
- wp_remote_post()
- WP_Query
- Transients API
- Debug WordPress
- OpenAI Embeddings API
- WordPress core (mirror) sur GitHub
- WordPress Core Trac
- json_decode() (PHP)
FAQ
Est-ce que je peux éviter OpenAI et utiliser un autre fournisseur ?
Oui. L’architecture reste identique : “texte → embedding → stockage → similarité”. Remplacez uniquement bpcab_openai_embeddings() par un appel wp_remote_post() vers Anthropic, Google, Mistral, ou un modèle auto-hébergé. Gardez la même signature de retour (vecteur float[]).
Pourquoi ne pas stocker les embeddings dans une table custom ?
Vous pouvez, et c’est souvent la prochaine étape. La meta est simple mais pas idéale à grande échelle. Une table custom permet de stocker aussi la norme du vecteur, de faire des batchs plus rapides, et de préparer une migration vers un index vectoriel externe.
Est-ce que ça marche en multisite ?
Oui, parce que post_meta et WP_Query sont par site. Attention aux clés Transients : elles sont aussi par site (c’est ce que vous voulez). Si vous centralisez la recherche réseau, vous devrez switcher de blog (switch_to_blog()) et agréger.
Comment forcer la réindexation de tout le site ?
Faites un script WP-CLI ou une page admin dédiée qui boucle sur les posts et appelle bpcab_index_post_embedding($id, true). Ne lancez pas ça via une page publique : risque de coûts et de timeouts.
Que faire si mon hébergeur bloque les requêtes sortantes ?
Testez depuis le serveur un appel HTTPS vers api.openai.com. Si c’est bloqué, ouvrez un ticket hébergeur. Côté WordPress, augmentez le timeout et vérifiez que cURL/openssl sont disponibles (souvent le problème sur des vieux environnements PHP, moins sur PHP 8.1+).
Pourquoi mes scores sont “bizarres” (tous proches) ?
Deux causes fréquentes :
- vous embeddiez du bruit (HTML, shortcodes, navigation) au lieu du texte éditorial ;
- vos contenus sont très courts et très similaires (pages builder). Dans ce cas, enrichissez la source (résumé éditorial) ou excluez ces pages.
Est-ce que je peux combiner recherche sémantique et recherche plein texte ?
Oui, et c’est souvent mieux. Exemple : utilisez WP_Query avec s pour un pool lexical, puis rerank sémantique. Ou fusionnez deux listes (lexicale + sémantique) avec un score composite.
Est-ce compatible avec un plugin de cache (page cache) ?
Oui, mais attention : si la page de résultats est mise en cache pour tous, vous risquez de servir des résultats d’un utilisateur à un autre. Pour un formulaire de recherche, je préfère une page non cachée ou un endpoint dédié avec cache applicatif par requête.
Dois-je stocker l’embedding de la requête en base ?
Le transient suffit dans la plupart des cas. Si vous voulez de l’analytics (top requêtes), stockez la requête (hash + compteur) séparément, mais évitez de stocker des données personnelles en clair.
Quel est le meilleur endroit pour mettre ce code ?
Un mu-plugin si c’est une fonctionnalité “produit” du site. Un plugin standard si vous prévoyez un cycle de release, des settings, et un versioning plus propre. Évitez functions.php pour ce type d’intégration.
Puis-je afficher les résultats dans Elementor/Divi/Avada ?
Oui. Le plus simple : encapsuler bpcab_semantic_search() dans un shortcode (déjà fait), puis insérer ce shortcode dans :
- un module Code/Shortcode Divi 5 ;
- un widget Shortcode Elementor ;
- un élément Code Block / Shortcode Avada.