Aller au contenu principal

Recommandations de contenu avec Meilisearch.

Implémentez un système de recommandation d'articles sur votre blog Symfony en utilisant Meilisearch comme moteur de similarité sémantique.

Pierre 8 min de lecture
Sommaire · 15 0%

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 :

PHP
final readonly class PostNavigationService
{
    public function __construct(
        private PostRepository $postRepository,
        private MeilisearchSearchService $meilisearchSearchService,
    ) {
    }

La méthode getRelatedPosts implémente la cascade :

PHP
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 :

PHP
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 :

PHP
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 :

PHP
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 :

PHP
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 :

PHP
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 :

PHP
#[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
<twig:Content:PostRecommendations :post="post" />

Le template du composant ajoute une couche de cache Fragment pour éviter les appels répétés :

Twig
{% 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, ainsi, si Meilisearch est en erreur ou n'est pas joignable, une requête Doctrine prend le relais :

PHP
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

Terminal
# 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 :

PHP
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-v2 amé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 mais 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.

Cet article vous a-t-il aidé ?

Vos réactions ne sont pas encore enregistrées — bientôt disponible.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques anonymes via Umami Cloud (hébergement UE) : pages vues, source du trafic, navigateur. Pas de cookie tiers, pas de profilage, pas de partage commercial.