Une recherche sur ce blog traverse six composants avant d'afficher quoi que ce soit. La documentation de Meilisearch s'arrête à son API, celle de Symfony UX s'arrête au composant, et personne ne raconte le milieu.
Ce billet trace les deux flux qui comptent : ce qui se passe quand un lecteur tape trois lettres, et ce qui se passe quand je publie un billet. L'essentiel du code cité vit dans src/Service/Search/, quinze fichiers au total.
La recommandation d'articles similaires, elle, repose sur la couche sémantique et elle a son propre billet. Ici, on ne parle que de recherche par mots-clés.
De la frappe au résultat
Le point d'entrée public est SearchAction, sur la route /recherche. Il ne cherche rien : il rend un LiveComponent, Content:Search, qui porte tout le comportement.
L'état de la recherche y est déclaré en propriétés liées à l'URL :
Sur le même sujet
Live Components : JavaScript, moi non plus
// src/Twig/Component/Content/Search.php
#[LiveProp(writable: true, url: true)]
public string $query = '';Ce url: true a une conséquence qu'on sous-estime : une recherche filtrée devient une adresse. Le lecteur peut la copier, la remettre en favori, revenir dessus par le bouton précédent du navigateur. Rien de tout ça ne demande une ligne de JavaScript.
Le composant appelle ensuite un contrat, pas une implémentation :
// src/Service/Search/SearchServiceInterface.php
public function search(
string $query,
int $limit = 20,
array $filters = [],
string $sort = 'relevance:desc',
): SearchResult;Cette interface n'existe pas par principe. Elle est consommée par deux appelants qui n'ont rien en commun : le composant de recherche public, et l'admin, qui s'en sert pour suggérer une cible de redirection 301 quand on renomme un billet. Le second n'a aucune raison de savoir quel moteur tourne derrière.
Pour aller plus loin
Recommandations de contenu avec Meilisearch
L'implémentation MeilisearchSearchService rend un SearchResult, un DTO final readonly qui transporte les hits déjà hydratés en entités, le total, les facettes, la requête, et le temps de traitement renvoyé par le moteur.
Ce dernier champ mérite d'être noté. La latence n'est pas une promesse commerciale collée en bas d'un article, c'est une valeur que le moteur renvoie à chaque requête et qu'on peut afficher.
Le comptage qui suit, et celui qui ne suit pas
Juste après avoir obtenu ses résultats, le composant fait une chose de plus :
$this->searchQueryRecorder->record($query, $result->totalHits);SearchQueryRecorder compte la requête normalisée et le fait qu'elle ait ramené zéro résultat. Aucun identifiant d'acteur, ni IP, ni session, ni horodatage fin. Une ligne par requête et par mois.
Le détail qui compte pour la cartographie est ailleurs : ce composant est le seul appelant. L'autocomplete et les recherches déclenchées depuis l'admin passent par d'autres chemins et ne sont donc jamais comptées. La statistique décrit les lecteurs, pas moi en train de travailler sur le site.
Meilisearch classe, PostgreSQL raconte
Un point de la chaîne surprend quand on le découvre : le moteur ne renvoie presque rien.
// src/Service/Search/MeilisearchSearchService.php
private function buildSearchParams(int $limit, array $filters, string $sort): array
{
$params = [
'limit' => $limit,
// On hydrate les entités depuis la DB : seul l'id est nécessaire ici.
'attributesToRetrieve' => ['id'],
'facets' => ['categoryTitle', 'proficiencyLevel'],
];
// ...
}L'index contient pourtant le titre, le résumé, le corps entier. On ne demande que les identifiants, et on repart chercher les billets dans PostgreSQL :
// src/Service/Search/PostHydrator.php
$posts = $this->postRepository->createQueryBuilder('p')
->leftJoin('p.category', 'c')
->addSelect('c')
->where('p.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult()
;Une seule requête, catégorie chargée au passage, puis l'appelant réordonne les entités selon l'ordre que Meilisearch a décidé.
Pour approfondir
Doctrine sous EXPLAIN : profiler ce blog jusqu'à l'index
Ce détour paraît coûteux et il ne l'est pas. Il évite surtout un problème classique : un index qui sert directement le contenu affiché devient une seconde source de vérité, avec sa propre fraîcheur et ses propres divergences. Ici, un billet corrigé il y a dix secondes s'affiche corrigé, même si sa réindexation n'est pas encore passée. Le moteur décide de l'ordre, la base fournit le texte.
PostHydrator est d'ailleurs partagé avec la recommandation d'articles similaires. Deux fonctionnalités très différentes, un seul chemin vers les entités.
Le document qu'on indexe
Côté écriture, PostDocumentFactory projette une entité Post en document plat. Seize champs, dont l'essentiel tient en trois catégories : ce qui est cherché, ce qui filtre, ce qui trie.
// src/Service/Search/PostDocumentFactory.php
return [
'id' => (string) $post->id,
'title' => $post->title,
'url' => '/billets/' . $slug,
'seoTitle' => $post->seo->title ?? '',
'seoKeywords' => $post->seo->keywords,
'categoryTitle' => $post->category instanceof Category ? $post->category->title : '',
'blocksText' => $post->plainTextContent,
'proficiencyLevel' => $post->proficiencyLevel->value,
'createdAt' => $post->createdAt?->getTimestamp() ?? 0,
'publishedAt' => $post->publishedAt?->getTimestamp(),
// ...
];Deux choix se lisent directement dans cet extrait.
Les dates partent en timestamps Unix plutôt qu'en chaînes ISO, parce que Meilisearch trie sur des entiers et qu'un tri lexicographique sur des dates formatées finit toujours par mentir.
Et l'URL est construite ici, en dur sur /billets/. Une factory de document n'a pas de contexte de requête : elle doit pouvoir tourner depuis une commande en ligne de commande, sans host, sans routeur.
Le corps du billet arrive via Post::$plainTextContent, qui parcourt les blocs paragraphe, titre, citation, callout, liste et code pour en extraire du texte. L'index ne voit jamais le HTML.
Publier, c'est écrire dans deux bases
MeilisearchSynchronizer tient la synchronisation, et sa règle principale est une soustraction :
// src/Service/Search/MeilisearchSynchronizer.php
public function index(Post $post): void
{
// Un post non publié ne reste jamais dans l'index.
if ($post->status !== ContentStatus::ONLINE) {
$this->remove($post);
return;
}
// ...
}Un billet repassé en brouillon n'est pas seulement « pas ajouté », il est retiré. C'est le genre de détail qu'on découvre autrement : en dépubliant un billet et en le retrouvant dans la recherche du site.
L'indexation elle-même est asynchrone, via IndexPostMessage dispatché sur le transport async. Publier ne doit pas attendre qu'un service externe réponde. Le worker s'en charge, avec les réessais de Messenger si le moteur est momentanément absent.
Pour la réindexation complète, reindexAll() travaille par lots de 500, et se pilote avec app:meili:reindex. La commande accepte une option qui n'applique que les réglages, sans retoucher aux documents, ce qui sert bien plus souvent qu'on ne l'imagine.
L'ordre des attributs est la configuration la plus importante
Voici le point que la documentation mentionne sans insister, et qui décide de la pertinence perçue.
// src/Service/Search/MeilisearchIndexConfigurator.php
private const array SEARCHABLE_ATTRIBUTES = [
'title',
'seoTitle',
'seoKeywords',
'categoryTitle',
'blocksText',
'seoDescription',
];Cette liste n'est pas un inventaire, c'est un classement. Un terme trouvé dans le titre pèse plus lourd que le même terme trouvé dans le corps. Changer l'ordre des lignes change les résultats, sans qu'aucune autre ligne de code ne bouge.
Les règles de classement portent une décision supplémentaire :
private const array RANKING_RULES = [
'words', 'typo', 'proximity', 'attribute', 'sort', 'exactness',
// Départage les ex-aequo en faveur du plus récent.
'publishedAt:desc',
];Les six premières sont les règles par défaut de Meilisearch. La septième est un ajout : à pertinence égale, le billet le plus récent passe devant. Sur un blog technique où le même sujet est traité plusieurs fois à des versions différentes, ce départage vaut mieux qu'un ordre arbitraire.
La tolérance aux fautes, enfin, est calibrée par longueur de mot :
'typoTolerance' => [
'enabled' => true,
'minWordSizeForTypos' => ['oneTypo' => 4, 'twoTypos' => 8],
],En dessous de quatre caractères, aucune faute n'est tolérée. Je l'ai réglé comme ça volontairement : sur un vocabulaire technique, php, sql, ssh et css sont à une faute les uns des autres, et une tolérance généreuse sur les mots courts transforme la recherche en générateur de surprises.
Un blog français qui parle anglais
C'est là que la configuration cesse d'être générique.
Les mots vides français sont retirés à l'indexation :
private function frenchStopWords(): array
{
return [
'le', 'la', 'les', 'un', 'une', 'des', 'du', 'de',
'et', 'ou', 'à', 'pour', 'dans', 'sur', 'avec', 'par',
'comment', 'pourquoi', 'est', 'sont', 'ce', 'cette', 'ces',
// ...
];
}Noter la présence de comment et pourquoi. Beaucoup de titres de billets techniques commencent par là, et les garder reviendrait à faire remonter la moitié du blog sur une requête qui n'en contient qu'un.
Viennent ensuite les synonymes, qui traitent le bilinguisme réel du lecteur :
private function synonymsMap(): array
{
return [
'sf' => ['symfony'],
'js' => ['javascript'],
'k8s' => ['kubernetes'],
'pg' => ['postgresql', 'postgres'],
'ci' => ['github actions', 'pipeline'],
// ...
];
}Personne ne tape kubernetes en entier. Sans cette table, une recherche k8s sur un blog qui parle de Kubernetes ne renvoie rien, et le lecteur en conclut que le sujet n'est pas traité.
Reste le problème le plus retors, et il n'a rien à voir avec la langue :
// Garde C++, C#, .NET, Node.js… comme tokens uniques.
'nonSeparatorTokens' => ['+', '#', '.'],
'dictionary' => ['C++', 'C#', '.NET', 'ASP.NET', 'Node.js', 'Vue.js', 'Next.js', 'Nuxt.js'],Pour un tokenizer, C++ n'est pas un mot : c'est un mot et deux ponctuations. Par défaut, +, # et . séparent, donc C++ devient c, C# devient c, et .NET devient net. Trois langages distincts s'effondrent sur la même lettre.
Ces deux réglages sont l'exemple type de ce qu'aucun tutoriel ne montre, parce qu'un tutoriel indexe des titres de films.
Les garde-fous, des deux côtés
Quatre constantes bornent le composant, et chacune évite un problème précis.
// src/Twig/Component/Content/Search.php
private const int MIN_QUERY_LENGTH = 3;
private const int MAX_QUERY_LENGTH = 100;
private const int RESULTS_LIMIT = 30;En dessous de trois caractères, aucune requête n'est envoyée. Un a isolé ramènerait le blog entier et coûterait une requête moteur à chaque frappe, sur un composant qui réagit à la saisie. Au-dessus de cent caractères, on tronque : ce n'est plus une recherche, c'est un copier-coller de paragraphe.
Côté index, deux réglages font le pendant :
// src/Service/Search/MeilisearchIndexConfigurator.php
'pagination' => ['maxTotalHits' => 200],
'searchCutoffMs' => 150,Le second est le plus intéressant. Passé cent cinquante millisecondes, Meilisearch rend ce qu'il a trouvé jusque-là au lieu de continuer à chercher. La réponse peut donc être incomplète, et c'est le compromis que j'assume : sur une recherche déclenchée à la frappe, un résultat approximatif tout de suite vaut mieux qu'un résultat parfait qu'on attend.
C'est aussi ce qui rend la latence prévisible sans avoir à la promettre. Le plafond est dans la configuration.
L'autocomplete n'emprunte pas le même chemin
La suggestion instantanée sous la barre de recherche a sa propre route, /api/recherche/autocomplete, son propre contrôleur et son propre service, MeilisearchAutocompleteService.
J'ai laissé cette duplication, et je la défends. Les deux usages n'ont ni les mêmes contraintes de latence, ni les mêmes besoins : l'autocomplete veut peu de champs et une réponse immédiate, la recherche complète veut des facettes, du tri et des entités hydratées. Les fusionner produirait un service qui fait mal les deux.
L'autocomplete est d'ailleurs absente des statistiques de recherche pour la même raison : c'est un chemin distinct.
Ce qui tient debout quand le moteur n'est pas là
Aucun des services ne suppose que Meilisearch répond. MeilisearchClientFactory rend null quand l'URL ou la clé sont absentes, ou quand la clé commence par dummy-. Tous les appelants traitent ce null en no-op silencieux.
La conséquence est qu'un environnement de test ou une machine fraîchement clonée n'a pas besoin du moteur pour faire tourner le site. La recherche ne renvoie rien, et rien d'autre ne casse.
Le dernier réglage est celui du serveur, dans compose.yaml :
image: getmeili/meilisearch:${MEILISEARCH_VERSION:-v1.48}
environment:
MEILI_DB_PATH: /meili_data/${MEILISEARCH_VERSION:-v1.48}Le chemin de données porte le numéro de version. Chaque montée de version démarre donc sur un répertoire vierge, et l'index se reconstruit depuis PostgreSQL avec app:meili:reindex. On échange une réindexation contre l'assurance de ne jamais se retrouver devant un moteur qui refuse de démarrer sur un format de données plus ancien.
C'est possible parce que l'index n'est la source de vérité de rien. Il est une projection, et une projection se rejoue.
Ce que la cartographie révèle
Chaque brique de cette chaîne est documentée quelque part. Le composant chez Symfony UX, les réglages chez Meilisearch, le transport chez Messenger. Ce qui n'est documenté nulle part, c'est le milieu : l'ordre des attributs, le retrait à la dépublication, les trois caractères qui empêchent C++ de devenir c.
Ces décisions décrivent des lecteurs : quelqu'un qui tape k8s, quelqu'un qui écrit symfny, quelqu'un qui cherche .NET sur un blog qui n'en parle jamais. La configuration d'un index est un portrait de son public, écrit en constantes de classe.
Et la vôtre ? Si vous ouvrez la configuration de votre propre moteur de recherche, combien de lignes y décrivent le vocabulaire réel de vos lecteurs, et combien sont restées telles que le tutoriel les a posées ?
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).