Recommander des articles proches demande de répondre à une question simple et pénible : de quoi parle ce texte ? Les réponses classiques sont un modèle entraîné, un historique de navigation, ou une matrice de similarité précalculée. Aucune n'est raisonnable pour un blog.
Meilisearch en propose une quatrième depuis qu'il sait appeler lui-même un fournisseur d'embeddings. Ce billet décrit ce que ça change concrètement, et surtout ce que la documentation ne dit pas et qu'on découvre en production.
Pour aller plus loin
Ce qui se passe entre la barre de recherche et l'index
Un embedder se déclare dans les réglages d'index
Le premier renversement est qu'il n'y a aucun code de vectorisation à écrire. Pas de service, pas de message asynchrone, pas de table de vecteurs. L'embedder est un réglage d'index, au même titre que les synonymes :
// src/Service/Search/MeilisearchIndexConfigurator.php
return [
'gemini' => [
'source' => 'rest',
'dimensions' => GeminiEmbedding::DIMENSIONS,
'url' => GeminiEmbedding::BATCH_URL,
'headers' => [
'x-goog-api-key' => $this->geminiApiKey,
],
'request' => [/* ... */],
'response' => [/* ... */],
'documentTemplate' => '{{ doc.title }}. {{ doc.seoDescription }} {{ doc.blocksText | truncate: 6000 }}',
],
];À partir de là, chaque fois qu'un document est poussé dans l'index, Meilisearch appelle l'API d'embeddings tout seul, stocke le vecteur, et le maintient à jour. L'application ne voit jamais un seul flottant.
La conséquence pratique est inhabituelle : la clé d'API vit dans les réglages d'un index. Meilisearch la chiffre dans son volume, mais elle n'est plus seulement dans l'environnement de l'application, elle est aussi dans l'état du moteur. C'est un déplacement de responsabilité qu'on ne mesure pas en lisant la documentation, et qui compte au moment de faire tourner une clé.
Ce que le gabarit de document décide
La ligne la plus lourde de conséquences est la dernière : le documentTemplate.
C'est elle qui choisit ce qui est vectorisé. Pas le document entier : le titre, la description SEO, et le corps tronqué à six mille caractères. Ce n'est pas une valeur arbitraire, elle vient d'une contrainte du modèle, qui plafonne son entrée en tokens.
Au-delà, il faudrait découper chaque billet en sections et produire un vecteur par section. C'est la bonne réponse, je l'ai écartée pour l'instant : elle change la nature de l'index, où un billet cesse d'être un document pour devenir plusieurs.
En attendant, un billet long est vectorisé sur son début. Ses dernières sections ne pèsent pas dans sa similarité. C'est une limite réelle, et il vaut mieux la connaître que la découvrir en s'étonnant qu'un billet de quatre mille mots soit mal recommandé.
/similar prend un identifiant de document
Deuxième chose que la documentation énonce sans insister, et qui structure tout le reste.
// src/Service/Search/MeilisearchSimilarPostsService.php
$query = new SimilarDocumentsQuery($post->id->toRfc4122(), 'gemini');
$query->setLimit(max(1, $limit + \count($excludeIds) + 2));
$query->setAttributesToRetrieve(['id']);
$result = $client->index(MeilisearchClientFactory::INDEX_POSTS)
->searchSimilarDocuments($query)
;L'entrée est un identifiant de document, pas un texte. On ne demande pas « qu'est-ce qui ressemble à cette phrase », on demande « qu'est-ce qui ressemble à ce billet ». Le nom de l'embedder est passé explicitement, parce qu'un index peut en déclarer plusieurs.
Cette signature a une implication immédiate : un billet qui n'est pas dans l'index n'a pas de voisins. Un brouillon, un billet fraîchement publié dont la vectorisation n'est pas revenue, un billet publié pendant que le moteur était éteint. Dans tous ces cas, la réponse est vide, et ce n'est pas une erreur.
On notera aussi la demande volontairement excédentaire. On réclame plus de résultats que nécessaire, parce que certains seront écartés ensuite : un billet peut avoir disparu de PostgreSQL alors qu'il est encore dans l'index. L'écart entre les deux est normal, il est temporaire, et le code le budgète au lieu de le nier.
Comme pour la recherche, seuls les identifiants sont demandés. Les entités sont rechargées depuis PostgreSQL par PostHydrator, partagé avec la recherche par mots-clés.
Trois niveaux de repli, jamais un bloc vide
Une recommandation sémantique tombe pour au moins quatre raisons ordinaires : moteur éteint, clé d'API absente, billet jamais vectorisé, appel en erreur. Le service les traite toutes de la même façon.
} catch (\Throwable $throwable) {
$this->logger->error('Meilisearch /similar failed', [
'post_id' => $post->id->toRfc4122(),
'exception' => $throwable,
]);
return [];
}Une liste vide, jamais une exception. La décision de quoi faire ensuite n'appartient pas à ce service, elle appartient à son appelant, et c'est là que vit la vraie cascade :
// src/Service/Content/Navigation/PostNavigationService.php
// 1. Similarité sémantique via Meilisearch /similar
$similarPosts = $this->similarPostsService->findSimilar($post, $limit, $excludeIds);
// ...
// 2. Même catégorie
// 3. Billets récentsTrois niveaux, chacun complétant le précédent jusqu'à atteindre le compte demandé, sans doublon. Le bloc de recommandations n'est donc jamais vide, même sur une installation où Meilisearch n'a jamais tourné.
C'est ce qui rend la fonctionnalité déployable. Sans ce filet, activer la recommandation sémantique voudrait dire ajouter une dépendance dure à un service externe pour un bloc décoratif en bas de page.
Ce qu'on met en cache, et ce qu'on n'y met pas
Le calcul coûte un appel réseau et plusieurs requêtes SQL. Il est donc caché, avec une précaution qui mérite le détour :
$orderedIds = $this->cache->get(
\sprintf('related_posts_%s_%d', $postId->toRfc4122(), $limit),
function (ItemInterface $item) use ($post, $limit): array {
$item->expiresAfter(self::RELATED_POSTS_TTL);
$item->tag(CacheTag::PostRecommendations->value);
// On ne persiste que les UUIDs ordonnés
// ...
},
);Ce qui est caché n'est pas la liste des billets, c'est la liste ordonnée de leurs identifiants. Les entités sont rechargées à chaque affichage.
Ça paraît être un détour de plus, c'en est un, et il achète une propriété précise : un billet dépublié ou supprimé entre l'écriture du cache et sa lecture n'est plus retrouvé, donc il disparaît simplement de la liste. Un cache d'entités, lui, aurait continué à afficher un billet devenu invisible, jusqu'à expiration.
Ce qui vide le cache
Un cache de recommandations pose une question que sa durée de vie ne résout pas : que se passe-t-il quand on publie un nouveau billet ? Il devrait apparaître dans les suggestions des autres, et attendre l'expiration serait une réponse paresseuse.
D'où l'étiquette posée sur l'entrée :
$item->tag(CacheTag::PostRecommendations->value);Elle est consommée à l'autre bout de l'application. ContentChangeDoctrineSubscriber écoute les changements de contenu et invalide ce tag, ce qui purge d'un coup toutes les recommandations calculées. Le prochain lecteur déclenche un recalcul, avec le nouveau billet dans le corpus.
La durée de vie ne fait que borner l'entrée, dix minutes ici. La fraîcheur vient d'un événement, pas d'une horloge.
Côté affichage, le composant Content:PostRecommendations reçoit le billet courant, appelle le service dans son mount(), et rend une grille de trois cartes sous le titre « Vous aimerez aussi ». Aucun cache à ce niveau : il vit entièrement dans le service, à l'endroit où le coût est réel.
Ce que ça dit du reste
Il y a dix ans, cette fonctionnalité aurait demandé un modèle, une infrastructure pour l'exécuter, et une table de vecteurs à maintenir. Elle tient aujourd'hui dans un réglage d'index, un appel par identifiant et une cascade de repli.
Ce qui a bougé n'est pas la difficulté du problème, c'est l'endroit où il est résolu. Le moteur de recherche a absorbé la vectorisation comme il avait absorbé la tolérance aux fautes : ce qui était un projet est devenu une clé de configuration.
Le prix de ce déplacement est discret. Une clé d'API dans l'état d'un index, un gabarit qui décide silencieusement de ce qui compte dans un texte, et une limite de tokens qui tronque les billets longs sans prévenir. Rien de tout ça n'apparaît dans un tutoriel de démarrage.
Alors la question à se poser avant d'activer ce genre de fonctionnalité n'est pas « est-ce que ça marche ». Ça marche. C'est plutôt : qu'est-ce qui décide, à ma place, de ce que mon contenu veut dire ?
Une coquille, une erreur dans ce billet ? Signale-la-moi.
Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).