Mis à jour le 1er novembre 2026. Article réécrit : requête réelle de la recherche native sur WordPress 7.1.3, comparaison des moteurs, support vectoriel de MariaDB et MySQL, coût d’indexation aux tarifs du 1er novembre 2026, code de fusion testé.
Beaucoup de projets de « recherche IA » sur WordPress corrigent en réalité deux défauts du moteur intégré : il exige tous les mots saisis et ignore les synonymes. Cet article part de la requête SQL que WordPress génère, puis décrit les étapes possibles : un index lexical classé par BM25, une recherche vectorielle, puis la combinaison des deux.
Ce que fait la recherche native de WordPress
Nous avons fait générer la requête d’une recherche « veste pluie » par WP_Query sur notre installation de test en WordPress 7.1.3, sans rien modifier sur le site. Voici la requête obtenue, raccourcie :
SELECT wp_posts.ID FROM wp_posts
WHERE 1=1
AND (((post_title LIKE '%veste%') OR (post_excerpt LIKE '%veste%') OR (post_content LIKE '%veste%'))
AND ((post_title LIKE '%pluie%') OR (post_excerpt LIKE '%pluie%') OR (post_content LIKE '%pluie%')))
AND (post_password = '')
AND ((post_type = 'post' AND (post_status = 'publish')))
ORDER BY (CASE WHEN post_title LIKE '%veste pluie%' THEN 1
WHEN post_title LIKE '%veste%' AND post_title LIKE '%pluie%' THEN 2
WHEN post_title LIKE '%veste%' OR post_title LIKE '%pluie%' THEN 3
WHEN post_excerpt LIKE '%veste pluie%' THEN 4
WHEN post_content LIKE '%veste pluie%' THEN 5
ELSE 6 END), post_date DESC
LIMIT 0, 10
La lecture du code de class-wp-query.php confirme ce que montre cette requête :
- Tous les mots sont obligatoires. Un article « Tenue de pluie » et une fiche « Veste de randonnée » ne remontent ni l’un ni l’autre pour « veste pluie ».
- La correspondance est une sous-chaîne.
LIKE '%pluie%'trouve aussi « parapluie ». Ni racinisation ni synonymes ; seuls les mots vides sont écartés. - Le classement est un barème fixe de 1 à 6, puis la date, sans pondération par fréquence ou rareté. Au-delà de 9 termes, la saisie entière est cherchée comme une phrase.
- Trois colonnes seulement. Le filtre
post_search_columns(WordPress 6.2) restreint la recherche au titre, à l’extrait ou au contenu, sans pouvoir ajouter les champs personnalisés ni les taxonomies. - Les contenus protégés par mot de passe sont exclus pour les visiteurs non connectés.
- Aucun index n’est utilisé. Un
LIKEqui commence par%impose de parcourir toute la table : invisible sur quelques centaines d’articles, lourd sur un gros catalogue.
Les quatre niveaux de recherche
| Niveau | Exemple d’outil | Ce qu’il corrige | Ce qu’il coûte |
|---|---|---|---|
| Natif | WP_Query et LIKE | Rien à installer | Mots obligatoires, table parcourue |
| Index lexical en base | Relevanssi 4.28.8 | Pertinence tf*idf, correspondance partielle, champs personnalisés | Index d’environ trois fois la taille de wp_posts, selon l’éditeur |
| Moteur externe BM25 | ElasticPress 5.3.5 et Elasticsearch | BM25, filtres, autocomplétion | Un service à héberger, ou un abonnement |
| Vectoriel puis hybride | Modèle d’embeddings et index vectoriel | Synonymes, langage naturel | Appels au modèle, stockage, réindexation |
Versions relevées sur wordpress.org le 1er novembre 2026 : Relevanssi 4.28.8 (30 octobre 2026), ElasticPress 5.3.5 (10 septembre 2026). Aucune des deux pages ne mentionne de recherche vectorielle.
BM25 : le score lexical de référence
BM25 attribue à chaque document un score qui augmente avec la fréquence des termes de la requête, plafonne cette fréquence (paramètre k1) et corrige l’effet de la longueur du document (paramètre b). Les termes rares pèsent plus que les termes courants. C’est l’algorithme de similarité par défaut d’Elasticsearch, avec k1 = 1,2 et b = 0,75.
BM25 excelle sur les références, les codes et les noms propres, mais ne voit pas les synonymes. Pour un blog de quelques centaines d’articles, un index lexical comme Relevanssi apporte souvent l’essentiel du gain, sans service externe.
Embeddings : le score sémantique
Un modèle d’embeddings transforme un texte en vecteur ; deux textes de sens voisin donnent des vecteurs proches, mesurés le plus souvent par la similarité cosinus. La recherche vectorielle retrouve « tenue de pluie » pour « veste imperméable », mais rate parfois une référence exacte comme « VX-210 ».
Deux règles d’exploitation : les vecteurs de deux modèles ne sont pas comparables, donc changer de modèle oblige à tout recalculer ; et un article long se découpe en passages, chacun vectorisé avec l’identifiant de l’article, qui reçoit le score de son meilleur passage.
Fusionner les deux classements
- La fusion pondérée : score = α × sémantique + (1 − α) × lexical, après normalisation, car BM25 n’a pas de borne supérieure alors que le cosinus est compris entre −1 et 1. α se règle sur un jeu de requêtes réelles.
- La Reciprocal Rank Fusion (RRF) : chaque liste contribue
1 / (k + rang)pour chaque document qu’elle contient. Seuls les rangs comptent, sans normalisation. Elasticsearch fixekà 60 par défaut.
Pour démarrer, RRF est le choix le plus robuste.
Un exemple testé
Ce script n’a aucune dépendance. Les vecteurs sont fictifs ; en production, ils viennent de votre modèle d’embeddings.
import math, re
from collections import Counter
def jetons(texte):
return re.findall(r"\w+", texte.lower())
class BM25:
def __init__(self, documents, k1=1.2, b=0.75):
self.docs = [jetons(d) for d in documents]
self.k1, self.b, self.n = k1, b, len(self.docs)
self.moy = sum(len(d) for d in self.docs) / self.n
self.df = Counter(t for d in self.docs for t in set(d))
def scores(self, requete):
resultat = []
for doc in self.docs:
tf, s = Counter(doc), 0.0
for t in jetons(requete):
f = tf.get(t, 0)
if f:
idf = math.log(1 + (self.n - self.df[t] + 0.5) / (self.df[t] + 0.5))
norme = 1 - self.b + self.b * len(doc) / self.moy
s += idf * f * (self.k1 + 1) / (f + self.k1 * norme)
resultat.append(s)
return resultat
def cosinus(a, b):
return sum(x * y for x, y in zip(a, b)) / (math.hypot(*a) * math.hypot(*b))
def rrf(listes_de_scores, k=60):
total = Counter()
for scores in listes_de_scores:
# Un document sans score positif ne figure pas dans la liste.
ordre = sorted((i for i, s in enumerate(scores) if s > 0), key=lambda i: -scores[i])
for rang, doc in enumerate(ordre, start=1):
total[doc] += 1 / (k + rang)
return total.most_common()
textes = ["Veste imperméable de randonnée, référence VX-210",
"Tenue de pluie légère pour la marche",
"T-shirt de running respirant"]
vecteurs = [[0.9, 0.1, 0.0], [0.8, 0.3, 0.1], [0.1, 0.9, 0.2]]
lexical = BM25(textes).scores("veste pluie")
semantique = [cosinus([0.85, 0.2, 0.05], v) for v in vecteurs]
print(rrf([lexical, semantique]))
Exécuté avec Python 3.12, il donne des scores BM25 de 0,94, 0,94 et 0 : chaque document ne contient qu’un des deux mots, et la recherche native n’en aurait renvoyé aucun. La fusion classe la veste, la tenue de pluie, puis le t-shirt, retenu par le seul signal sémantique.
Où stocker les vecteurs
| Option | État relevé le 1er novembre 2026 | À retenir |
|---|---|---|
| MariaDB | Type VECTOR et index VECTOR INDEX (HNSW modifié) stables depuis la version LTS 11.8, apparus en 11.7 | L’index n’est utilisé qu’avec ORDER BY VEC_DISTANCE_*(…) croissant et un LIMIT ; un simple seuil en WHERE parcourt toute la table |
| MySQL 9.7 | Type VECTOR (jusqu’à 16 383 dimensions) disponible | La fonction DISTANCE() est réservée à HeatWave sur OCI et à MySQL AI : elle n’existe pas dans les éditions Community et Commercial |
| Elasticsearch ou OpenSearch | Champs vectoriels et recherche hybride dans le même moteur | Bonne option si vous utilisez déjà ElasticPress |
| Fichier ou mémoire dans un service à part | Suffisant pour quelques milliers de passages | Simple à prototyper, à reconstruire au démarrage |
Le point MySQL compte pour la plupart des hébergements mutualisés : stocker des vecteurs y est possible, mais calculer la distance en SQL ne l’est pas. Si votre hébergeur fournit MariaDB 11.8 ou plus récent, la recherche vectorielle peut rester dans la base du site ; sinon, prévoyez un service séparé. Pour les besoins serveur d’un tel service, voir notre guide de performance serveur des CMS.
Ce que coûte l’indexation
Ordre de grandeur pour un blog de 2 000 articles d’environ 1 000 jetons chacun, soit 2 millions de jetons, avec les tarifs relevés le 1er novembre 2026 :
| Service | Tarif publié | Indexation initiale |
|---|---|---|
Google, gemini-embedding-001 (offre payante) | 0,15 $ par million de jetons, 0,075 $ en lot | Environ 0,30 $ |
Cloudflare Workers AI, @cf/baai/bge-m3 | 1 075 neurones par million de jetons ; 10 000 neurones gratuits par jour | Environ 2 150 neurones, dans l’allocation gratuite |
| Modèle exécuté sur votre serveur | Aucun tarif à l’appel | Temps de calcul de votre machine, à mesurer |
Le coût d’appel est rarement le problème ; les vrais coûts sont l’hébergement de l’index, la latence ajoutée à chaque recherche et la maintenance. Attention aux offres gratuites : selon les conditions de l’API Gemini, les données envoyées au niveau gratuit servent à améliorer les produits de Google, sauf pour les utilisateurs de l’Espace économique européen, de la Suisse et du Royaume-Uni, à qui s’appliquent les conditions de l’offre payante.
Brancher le résultat sur WordPress
Le plus simple : un service externe renvoie des identifiants classés, que WordPress affiche dans cet ordre. Ce code se place dans une extension maison :
function genisoft_ids_hybrides( $requete ) {
$reponse = wp_remote_post( 'http://127.0.0.1:8081/recherche', array(
'timeout' => 1.5,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode( array( 'q' => $requete, 'k' => 50 ) ),
) );
if ( is_wp_error( $reponse ) || 200 !== wp_remote_retrieve_response_code( $reponse ) ) {
return null; // Service indisponible : la recherche native reprend la main.
}
$ids = json_decode( wp_remote_retrieve_body( $reponse ), true );
return is_array( $ids ) ? array_map( 'absint', $ids ) : null;
}
add_action( 'pre_get_posts', function ( $query ) {
if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {
return;
}
$ids = genisoft_ids_hybrides( $query->get( 's' ) );
if ( null === $ids ) {
return;
}
$query->set( 'post__in', $ids ? $ids : array( 0 ) );
$query->set( 'orderby', 'post__in' );
$query->set( 'genisoft_hybride', true );
} );
add_filter( 'posts_search', function ( $search, $query ) {
return $query->get( 'genisoft_hybride' ) ? '' : $search;
}, 10, 2 );
Sur WordPress 7.1.3, la requête produite ne contient plus aucun LIKE, se termine par ORDER BY FIELD(wp_posts.ID, …) dans l’ordre fourni, et is_search() reste vrai, donc le gabarit de recherche du thème s’applique. Effet de bord à connaître : la condition post_password = '' disparaît avec le filtre. Votre index ne doit donc contenir ni brouillons ni contenus protégés.
Pour la synchronisation, les crochets save_post, wp_trash_post et before_delete_post couvrent l’enregistrement, la corbeille et la suppression ; écartez révisions et sauvegardes automatiques, et confiez la vectorisation à une file d’attente traitée par un cron système, jamais à la requête d’enregistrement.
Mesurer avant de choisir
- Exportez les recherches internes des trois derniers mois et calculez la part sans résultat.
- Constituez un jeu de 30 à 50 requêtes réelles, avec les contenus attendus.
- Comparez la recherche native, Relevanssi puis un prototype hybride : le contenu attendu figure-t-il dans les cinq premiers résultats ?
- Mesurez la latence du prototype sur votre serveur, cache vide.
Si Relevanssi règle la majorité des échecs, arrêtez-vous là. L’hybride se justifie pour les requêtes en langage naturel et les vocabulaires variés (catalogue technique, documentation).
Données personnelles et limites
Les requêtes contiennent parfois un nom, une adresse e-mail ou un numéro de commande : ne les envoyez pas à un tiers sans nécessité et limitez la conservation des journaux. Un vecteur n’est pas une anonymisation. Un modèle exécuté sur votre serveur, comme dans notre guide du RAG local sous WordPress, évite le transfert. La même démarche appliquée à un catalogue est détaillée dans notre comparatif recherche vectorielle et SQL pour PrestaShop.
Questions fréquentes
Pourquoi la recherche de WordPress ne trouve-t-elle rien alors que le contenu existe ?
Parce que tous les mots saisis doivent figurer dans le titre, l’extrait ou le contenu, et que les champs personnalisés ne sont pas lus. Une requête de trois mots dont un seul est absent ne renvoie rien. Un index lexical comme Relevanssi assouplit cette règle.
Faut-il des embeddings pour un blog de quelques centaines d’articles ?
Rarement. Commencez par mesurer les recherches sans résultat, puis testez un index lexical. Les embeddings deviennent utiles quand les échecs viennent de synonymes ou de formulations en langage naturel.
Peut-on faire de la recherche vectorielle avec MySQL ?
MySQL 9.7 sait stocker des vecteurs, mais la fonction DISTANCE() n’existe que dans HeatWave sur OCI et MySQL AI. MariaDB 11.8 LTS propose en revanche un type VECTOR, un index et les fonctions de distance.
Quelle méthode de fusion choisir pour débuter ?
La Reciprocal Rank Fusion, avec k = 60. Elle ne demande aucune normalisation des scores. La fusion pondérée peut donner de meilleurs résultats, mais seulement si vous réglez α sur un jeu de requêtes réelles.
Sources
- Code de WordPress 7.1.3, lu localement :
wp-includes/class-wp-query.php(méthodesparse_search()etparse_search_order()), requête générée le 1er novembre 2026. - WordPress.org, extension Relevanssi (version 4.28.8, relevée le 1er novembre 2026) : https://wordpress.org/plugins/relevanssi/
- WordPress.org, extension ElasticPress (version 5.3.5, relevée le 1er novembre 2026) : https://wordpress.org/plugins/elasticpress/
- Elastic, Similarity settings : https://www.elastic.co/docs/reference/elasticsearch/index-settings/similarity
- Elastic, Reciprocal rank fusion : https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion
- MariaDB, Vector Overview : https://mariadb.com/docs/server/reference/sql-structure/vectors/vector-overview
- MySQL 9.7, The VECTOR Type et Vector Functions : https://dev.mysql.com/doc/refman/9.7/en/vector.html, https://dev.mysql.com/doc/refman/9.7/en/vector-functions.html
- Google, Gemini Developer API pricing (mise à jour du 30 octobre 2026) et Gemini API Additional Terms of Service : https://ai.google.dev/gemini-api/docs/pricing, https://ai.google.dev/gemini-api/terms
- Cloudflare, Workers AI pricing (mise à jour du 20 octobre 2026) : https://developers.cloudflare.com/workers-ai/platform/pricing/