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.
Catégorie

PHP

Le PHP de 2025 n'a plus rien à voir avec celui d'il y a 10 ans. Êtes-vous sûr d'utiliser tout le potentiel des dernières versions pour écrire un code moderne et ultra-performant ?

Lecture
8 min
Niveau
Intermédiaire
févr 1 2026
Partager

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. Si Meilisearch échoue 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

Env
MEILISEARCH_URL=http://meilisearch:7700
MEILISEARCH_API_KEY=your-master-key

Indexation initiale

Bash
# 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. 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.

Poursuivre la lecture

Sélectionné avec soin pour vous.

PHP

Git : les vérités que personne ne vous dit

Découvrez les techniques Git avancées : rebase interactif, reflog, worktree, bisect pour transformer votre workflow de développement.

8 min de lecture
PHP

PHP 8.4 : rattrapage de retard ou véritable modernisation ?

PHP 8.4 introduit les Property Hooks, la visibilité asymétrique et un parser HTML5 natif. Découvrez les nouveautés majeures de cette version attendue depuis des années.

4 min de lecture
PHP

PHP 8.5 : pipe operator et nouvelles fonctionnalités

PHP 8.5 introduit le pipe operator, array_first(), array_last() et l'attribut NoDiscard. Découvrez les nouveautés qui modernisent le langage.

5 min de lecture