Sur un blog technique, garder les lecteurs engagés après la lecture d'un article représente un défi constant. Les systèmes de recommandation traditionnels nécessitent souvent des algorithmes complexes, des données comportementales volumineuses, ou des services tiers coûteux. Meilisearch offre une alternative élégante : utiliser la recherche full-text comme moteur de similarité sémantique.
Voici comment j'ai implémenté un système de recommandation sur ce blog Symfony, avec une architecture en cascade qui garantit des suggestions pertinentes, même quand le moteur de recherche tombe.
Le principe : la similarité par le titre
L'idée centrale repose sur une observation simple : le titre d'un article concentre ses concepts clés. En recherchant ce titre dans l'index Meilisearch, on obtient naturellement les articles qui partagent un vocabulaire similaire — et donc probablement une thématique proche.
Un article intitulé "Optimiser les performances de Doctrine avec le cache de second niveau" remontera logiquement d'autres contenus sur Doctrine, le cache, ou les performances PHP. Sans machine learning, sans historique utilisateur, sans matrice de similarité précalculée.
Architecture en trois niveaux de fallback
La robustesse du système repose sur sa capacité à dégrader gracieusement :
┌─────────────────────────────────────────────────────────┐
│ Niveau 1 : Meilisearch (similarité sémantique) │
│ ↓ si indisponible ou résultats insuffisants │
├─────────────────────────────────────────────────────────┤
│ Niveau 2 : Même catégorie (proximité thématique) │
│ ↓ si toujours insuffisant │
├─────────────────────────────────────────────────────────┤
│ Niveau 3 : Articles récents (fraîcheur du contenu) │
└─────────────────────────────────────────────────────────┘
Cette cascade garantit que l'utilisateur voit toujours des recommandations, quelle que soit la santé de l'infrastructure.
Implémentation du service de navigation
Le PostNavigationService orchestre cette logique. C'est un service readonly — pattern privilégié sur ce projet Symfony 8 — qui injecte le repository et le service de recherche :
final readonly class PostNavigationService
{
public function __construct(
private PostRepository $postRepository,
private MeilisearchSearchService $meilisearchSearchService,
) {
}
La méthode getRelatedPosts implémente la cascade :
public function getRelatedPosts(Post $post, int $limit = 3): array
{
$relatedPosts = [];
$excludeIds = [];
if ($post->id instanceof Uuid) {
$excludeIds[] = $post->id->toRfc4122();
}
// Niveau 1 : Meilisearch
try {
if ($this->meilisearchSearchService->isAvailable()
&& trim($post->title) !== '') {
$searchResult = $this->meilisearchSearchService->search(
$post->title,
$limit,
['exclude_ids' => $excludeIds],
);
foreach ($searchResult->hits as $hit) {
$entity = $hit['entity'];
if (!$entity instanceof Post) {
continue;
}
$relatedPosts[] = $entity;
if ($entity->id instanceof Uuid) {
$excludeIds[] = $entity->id->toRfc4122();
}
}
}
} catch (\Exception) {
// Meilisearch en échec silencieux
}
if (\count($relatedPosts) >= $limit) {
return \array_slice($relatedPosts, 0, $limit);
}
// Niveau 2 : Même catégorie
if ($post->category instanceof Category) {
$needed = $limit - \count($relatedPosts);
$categoryPosts = $this->postRepository->findRecentByCategoryExcluding(
$post->category,
$post,
$needed + 2, // Marge pour dédoublonnage
);
foreach ($categoryPosts as $categoryPost) {
if (\count($relatedPosts) >= $limit) {
break;
}
$catPostId = $categoryPost->id?->toRfc4122();
if ($catPostId && !\in_array($catPostId, $excludeIds, true)) {
$relatedPosts[] = $categoryPost;
$excludeIds[] = $catPostId;
}
}
}
// Niveau 3 : Articles récents
if (\count($relatedPosts) < $limit) {
$needed = $limit - \count($relatedPosts);
$recentPosts = $this->postRepository->findRecentExcluding(
$excludeIds,
$needed
);
foreach ($recentPosts as $recentPost) {
$relatedPosts[] = $recentPost;
}
}
return $relatedPosts;
}
Le dédoublonnage par UUID empêche qu'un même article apparaisse plusieurs fois si Meilisearch et la catégorie le remontent tous deux.
Configuration de l'index Meilisearch
La pertinence des recommandations dépend directement de la configuration de l'index. Le MeilisearchIndexConfigurator définit les paramètres critiques :
private const array SEARCHABLE_ATTRIBUTES = [
'title',
'content',
'excerpt',
'category',
];
private const array RANKING_RULES = [
'words', // Nombre de mots correspondants
'typo', // Tolérance aux fautes
'proximity', // Distance entre les termes
'attribute', // Poids des attributs
'sort', // Tri explicite
'exactness', // Correspondance exacte
];
L'ordre des SEARCHABLE_ATTRIBUTES compte : le titre pèse plus que le contenu, qui pèse plus que l'excerpt. Les RANKING_RULES définissent comment Meilisearch ordonne les résultats ex aequo.
Stop words et synonymes techniques
Pour un blog francophone sur le développement, deux ajustements font la différence :
private function getFrenchStopWords(): array
{
return [
'le', 'la', 'les', 'un', 'une', 'des', 'du', 'de',
'et', 'ou', 'à', 'pour', 'dans', 'sur', 'avec', 'par',
'comment', 'pourquoi', 'est', 'sont', 'ce', 'cette', 'ces',
// ...
];
}
private function getTechSynonyms(): array
{
return [
'sf' => ['symfony'],
'symfony' => ['sf'],
'js' => ['javascript'],
'javascript' => ['js'],
'ts' => ['typescript'],
'typescript' => ['ts'],
'api' => ['rest', 'graphql'],
'db' => ['database', 'sql', 'postgresql', 'mysql'],
// ...
];
}
Les stop words évitent que des mots vides polluent le score de similarité. Les synonymes techniques permettent à un article sur "Symfony" de matcher avec un contenu mentionnant "SF".
L'interface SearchableContentInterface
Pour indexer n'importe quel type de contenu (Post, Page...), une interface contractualise ce que Meilisearch attend :
interface SearchableContentInterface
{
public function getId(): Uuid|int|string|null;
public function getSearchableTitle(): string;
public function getSearchableContent(): string;
public function getSearchableUrl(): string;
public function getSearchableType(): string;
public function getSearchableAttributes(): array;
}
L'entité Post l'implémente en exposant ses propriétés pertinentes :
class Post extends AbstractContent implements SearchableContentInterface
{
public function getSearchableTitle(): string
{
return $this->title;
}
public function getSearchableContent(): string
{
return $this->content ?? '';
}
public function getSearchableAttributes(): array
{
return [
'category' => $this->category?->title ?? 'Non classé',
'proficiencyLevel' => $this->proficiencyLevel->getLabel(),
'readingTime' => $this->readingTime,
'createdAt' => $this->displayDate->getTimestamp(),
'excerpt' => $this->listingSummary,
];
}
}
Synchronisation et conversion Markdown
Le MeilisearchSynchronizer gère l'indexation. Point notable : le contenu brut est en Markdown, mais Meilisearch indexe du texte brut. Le service convertit automatiquement :
private function mapDocument(SearchableContentInterface $content): array
{
return array_merge([
'id' => (string) $content->getId(),
'title' => $content->getSearchableTitle(),
'content' => $this->prepareContentForIndexing(
$content->getSearchableContent()
),
'url' => $content->getSearchableUrl(),
'type' => $content->getSearchableType(),
], $content->getSearchableAttributes());
}
private function prepareContentForIndexing(?string $content): string
{
if ($content === null || trim($content) === '') {
return '';
}
return $this->markdownService->toPlainText($content);
}
Cette conversion élimine le bruit syntaxique (#, **, `) qui fausserait les scores de similarité.
Le composant Twig
Côté affichage, un composant Symfony UX encapsule la logique :
#[AsTwigComponent(
name: 'Content:PostRecommendations',
template: 'components/Content/PostRecommendations.html.twig',
)]
#[WithMonologChannel('content')]
final class PostRecommendations
{
public ?Post $post = null;
public array $recommendations = [];
public function __construct(
private readonly PostNavigationService $postNavigationService,
private readonly LoggerInterface $logger,
) {
}
public function mount(?Post $post = null): void
{
$post ??= $this->post;
if ($post instanceof Post) {
try {
$this->recommendations = $this->postNavigationService
->getRelatedPosts($post);
$this->logger->info('PostRecommendations loaded', [
'post_id' => $post->id?->toRfc4122(),
'recommendations_count' => \count($this->recommendations),
]);
} catch (\Exception $exception) {
$this->logger->error('Failed to load recommendations', [
'error' => $exception->getMessage(),
]);
$this->recommendations = [];
}
}
}
}
L'utilisation dans un template devient triviale :
<twig:Content:PostRecommendations :post="post" />
Le template du composant ajoute une couche de cache Fragment pour éviter les appels répétés :
{% set cacheKey = (post.slug ?? 'unknown')|cache_key('post_recommendations') %}
{% cache cacheKey ttl(600) tags(['post_recommendations']) %}
{% if recommendations|length > 0 %}
<section class="mt-24">
<h2>Poursuivre la lecture</h2>
<twig:Content:PostGrid :posts="recommendations"/>
</section>
{% endif %}
{% endcache %}
Fallback SQL intégré au service de recherche
Le MeilisearchSearchService intègre son propre fallback SQL. Si Meilisearch échoue ou n'est pas joignable, une requête Doctrine prend le relais :
public function search(string $query, int $limit = 20, array $filters = []): SearchResult
{
try {
return $this->searchWithMeilisearch($query, $limit, $filters);
} catch (ApiException $e) {
$this->logger->warning('Meilisearch failed, falling back to SQL', [
'error' => $e->getMessage(),
]);
return $this->fallbackToSqlSearch($query, $limit);
}
}
private function fallbackToSqlSearch(string $query, int $limit): SearchResult
{
$result = $this->postRepository->createQueryBuilder('p')
->where('p.status = :status')
->andWhere(
'LOWER(p.title) LIKE LOWER(:search)
OR LOWER(p.content) LIKE LOWER(:search)'
)
->setParameter('status', ContentStatusEnum::ONLINE)
->setParameter('search', '%'.$query.'%')
->setMaxResults($limit)
->getQuery()
->getResult();
return new SearchResult(
array_map(fn(Post $post) => ['entity' => $post, 'highlight' => []], $result),
\count($result),
0,
[],
$query
);
}
Le LIKE SQL n'égale pas la pertinence de Meilisearch, mais garantit un fonctionnement dégradé acceptable.
Mise en production
Variables d'environnement
MEILISEARCH_URL=http://meilisearch:7700
MEILISEARCH_API_KEY=your-master-key
Indexation initiale
# Configurer l'index (attributs, stop words, synonymes)
bin/console app:search:configure
# Indexer tout le contenu publié
bin/console app:search:index
Monitoring
La méthode isAvailable() permet de vérifier la santé de Meilisearch :
public function isAvailable(): bool
{
try {
return $this->meilisearchClientFactory
->createClient()
->isHealthy();
} catch (\Exception) {
return false;
}
}
Un check de santé Symfony peut l'exposer pour vos sondes de monitoring.
Limites et améliorations possibles
Ce système fonctionne bien pour un blog de quelques centaines d'articles. Pour aller plus loin :
-
Embeddings vectoriels : Meilisearch 1.3+ supporte la recherche vectorielle. Générer des embeddings du contenu avec un modèle comme
all-MiniLM-L6-v2améliorerait significativement la pertinence sémantique. -
Pondération par engagement : Tracker les clics sur les recommandations et booster les articles qui convertissent.
-
Filtrage par niveau : Un débutant lisant un article pour débutants gagnerait à voir des recommandations de même niveau (
proficiencyLevel). -
Exclusion temporelle : Éviter de recommander des articles obsolètes sur des versions dépréciées de frameworks.
Le mot de la fin
La force de cette implémentation tient à sa simplicité. Pas de service tiers payant, pas de collecte de données comportementales, pas d'algorithme opaque. Juste une recherche full-text bien configurée, avec des fallbacks qui garantissent la disponibilité.
Meilisearch offre des temps de réponse inférieurs à 50ms sur des index de taille modeste. Couplé au cache Fragment de Symfony, l'impact sur les performances reste négligeable.
Pour un blog technique qui publie quelques articles par mois, c'est la solution idéale : efficace, maintenable, et entièrement sous contrôle.