Implémenter un moteur de recherche avec elasticsearch et Symfony (partie 3/3)
Publié le 16/11/2019 • Actualisé le 18/04/2020
Dans cette troisième et dernière partie, nous allons continuer à peaufiner notre moteur de recherche. Tout d'abord, nous allons améliorer notre stack elasticsearch en incorporant Kibana. Ensuite, nous implémenterons un autocomplete en utilisant la fonctionnalité de suggestion d'elasticsearch. C'est parti ! 😎
» Publié dans "Une semaine Symfonique 672" (du 11 au 17 novembre 2019).
Tutoriel
Cet article est le troisième et dernier du tutoriel : "Implémenter un moteur de recherche avec Elasticsearch et Symfony"
- Partie 1 : Paramétrer les composants Elasticsearch, installer FOSElastica, indexer les données, chercher et afficher les résultats.
- Partie 2 : Nettoyage et refactoring, utilisation d'un alias, création d'un fournisseur de données, ajustement de la pertinence et ajout de la pagination.
- Partie 3 : Ajout de Kibana à la stack, implémentation d'un auto-complete avec Elasticsearch.
Prérequis
Les prérequis sont les mêmes que pour les deux premières parties. Je vous recommande bien sûr de les lire (liens au dessus) avant d'attaquer celle-ci.
Configuration
- PHP 8.4
- Symfony 6.4.15
- elasticsearch 6.8
Installation de Kibana
Tout d'abord nous allons améliorer notre stack Elasticsearch. Jusqu'à maintenant, nous avons utilisé le plugin "head" pour gérer notre cluster. Mais cet outil de développement est assez ancien et n'est plus maintenu. Donc, ajoutons Kibana à notre hub docker. Kibana est un plugin open-source de visualisation de données pour Elasticsearch. Bien sûr, il permet aussi de faire les tâches de maintenance courantes que nous avions l'habitude de faire avec head : supprimer, fermer un index, créer et supprimer un alias, vérifier un document, vérifier le mapping des index, mais il permet bien plus encore ! La liste de ce qu'il est possible de faire est assez impressionnante (regardez le menu à gauche de la capture d'écran suivante). Ajoutons l'entrée correspondante dans le fichier docker-compose.yaml
:
kibana:
container_name: sb-kibana
image: docker.elastic.co/kibana/kibana:6.8.18
ports:
- "5601:5601"
environment:
- "ELASTICSEARCH_URL=http://sb-elasticsearch"
depends_on:
- elasticsearch
Cliquez ici pour voir le nouveau fichier docker-compose.yaml
complet.
# ./docker-compose.yaml
# DEV docker compose file ——————————————————————————————————————————————————————
# Check out: https://docs.docker.com/compose/gettingstarted/
version: '3.7'
services:
# Database ———————————————————————————————————————————————————————————————————
# MySQL server database (official image)
# https://docs.docker.com/samples/library/mysql/
db:
container_name: strangebuzz-db-1
image: mysql:5.7
platform: linux/x86_64
command: --default-authentication-plugin=mysql_native_password
ports:
- "3309:3306"
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_HOST: '%'
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -P 3306 -proot | grep 'mysqld is alive' || exit 1"]
interval: 10s
timeout: 30s
retries: 10
# Snippet L21+4 in templates/snippet/code/_133.html.twig
# —— Elasticsearch ———————————————————————————————————————————————————————————
# Elasticsearch server (official image)
# https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html
# https://hub.docker.com/_/elasticsearch/
# https://stackoverflow.com/questions/68877644/how-to-run-elasticsearch-6-on-an-apple-silicon-mac
elasticsearch:
container_name: strangebuzz-elasticsearch-1
# image: docker.elastic.co/elasticsearch/elasticsearch:7.17.9
# image: docker.elastic.co/elasticsearch/elasticsearch:7.1.
image: webhippie/elasticsearch:6.4
platform: linux/amd64
ports:
- "9209:9200"
- "9309:9300" # Important if you have multiple es instances running
environment:
- "http.port=9200"
- "discovery.type=single-node"
- "bootstrap.memory_lock=true"
- "ES_JAVA_OPTS=-Xms1G -Xmx1G"
- "xpack.security.enabled=false"
- "http.cors.enabled=true"
- "http.cors.allow-origin=*"
- "cluster.routing.allocation.disk.threshold_enabled=false" # https://www.elastic.co/guide/en/elasticsearch/reference/6.8/disk-allocator.html
healthcheck:
test: ["CMD-SHELL", "curl --silent --fail localhost:9200/_cluster/health?wait_for_status=yellow&timeout=30s || exit 1"]
interval: 10s
timeout: 30s
retries: 10
# Cache ——————————————————————————————————————————————————————————————————————
# Redis (official image)
# https://hub.docker.com/_/redis
redis:
container_name: strangebuzz-redis-1
image: redis:5.0.13-alpine
ports:
- '6389:6379'
healthcheck:
test: ["CMD-SHELL", "redis-cli -h 127.0.0.1 ping | grep 'PONG' || exit 1"]
interval: 10s
timeout: 30s
retries: 10
# Snippet L55+12 in templates/snippet/code/_236.html.twig
# PHP-FPM ————————————————————————————————————————————————————————————————————
# https://github.com/dunglas/symfony-docker
php:
container_name: strangebuzz-php-1
build:
context: .
target: symfony_php
args:
SYMFONY_VERSION: ${SYMFONY_VERSION:-}
SKELETON: ${SKELETON:-symfony/skeleton}
STABILITY: ${STABILITY:-stable}
restart: unless-stopped
volumes:
- php_socket:/var/run/php
healthcheck:
interval: 10s
timeout: 3s
retries: 3
start_period: 30s
environment:
# Run "composer require symfony/mercure-bundle" to install and configure the Mercure integration (@todo)
# MERCURE_URL: ${CADDY_MERCURE_URL:-http://caddy/.well-known/mercure}
# MERCURE_PUBLIC_URL: https://${SERVER_NAME:-localhost}/.well-known/mercure
# MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeMe!}
# new from legacy project (.env to transfer)
APP_ENV: prod
APP_SERVER: prod
APP_DEBUG: 0
DATABASE_URL: mysql://root:root@db:3306/strangebuzz?serverVersion=5.7
REDIS_URL: redis://redis:6379/1
ES_HOST: elasticsearch
ES_PORT: 9200
# Taken from .env
APP_SECRET: ${APP_SECRET}
APP_HTML5_VALIDATION: ${APP_HTML5_VALIDATION}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
SLACK_TOKEN: ${SLACK_TOKEN}
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN}
JWT_PASSPHRASE: ${JWT_PASSPHRASE}
# Caddy Web Server ———————————————————————————————————————————————————————————
# https://github.com/dunglas/symfony-docker
caddy:
container_name: strangebuzz-caddy-1
build:
context: .
target: symfony_caddy
depends_on:
- php
environment:
SERVER_NAME: ${SERVER_NAME:-localhost, caddy:80}
MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeMe!}
MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeMe!}
restart: unless-stopped
volumes:
- php_socket:/var/run/php
- caddy_data:/data
- caddy_config:/config
ports:
# HTTP
- target: 80
published: 80
protocol: tcp
# HTTPS
- target: 443
published: 443
protocol: tcp
# HTTP/3
- target: 443
published: 443
protocol: udp
volumes:
php_socket:
caddy_data:
caddy_config:
Comme vous pouvez le voir, nous passons l'URL du serveur Elasticsearch dont le nom d'hôte est celui du conteneur docker (sb-elasticsearch). Nous gardons le port standard 5601. Nous utilisons aussi la même version d'image (6.8.18) que nous avons utilisée pour Elasticsearch afin qu'il n'y ait pas de problème de compatibilité. Si vous redémarrez le hub docker, vous pouvez accéder à la page de gestion des index :
Voilà pour Kibana. Je vais m'arrêter ici pour cette partie, ça demanderait bien plus qu'un article pour présenter toutes les fonctionnalités. Accédez au site officiel pour plus d'informations. Kibana est très puissant, il peut aussi être utilisé pour consulter vos logs Symfony ! À ce sujet, je vous conseille la lecture de ce très intéressant article du blog JoliCode.
Ajout d'un autocomplete dans la barre de recherche
Comme vous pouvez le voir, j'ai mis un champ de recherche dans l'entête de ce site. Ça marche, mais si nous essayions de compléter la saisie de l'utilisateur afin de lui suggérer des termes qu'il peut trouver sur ce blog ? Voyons comment nous pouvons faire cela avec Elasticsearch, nous allons construire un index qui sera dédié à cette fonctionnalité.
Configuration du mapping
Jusqu'à maintenant nous n'avons utilisé que le type par défaut "text" pour toutes les propriétés du mapping. Pour ce nouvel index, nous allons utiliser un type spécial : completion. Nous allons ajouter une nouvelle configuration pour cet index "suggest" juste après le principal "app" que nous avons utilisé dans les articles précédents :
# config/packages/fos_elastica.yaml
fos_elastica:
clients:
default: { host: '%es_host%', port: '%es_port%' }
indexes:
app:
###
suggest:
use_alias: true
settings:
index:
analysis:
analyzer:
suggest_analyzer:
type: custom
tokenizer: standard
filter: [lowercase, asciifolding]
types:
keyword:
properties:
locale:
type: keyword
suggest:
type: completion
analyzer: suggest_analyzer
contexts:
- name: locale
type: category
path: locale
Cliquez ici pour voir le mapping YAML complet.
# config/packages/fos_elastica.yaml
fos_elastica:
clients:
default: { host: '%es_host%', port: '%es_port%' }
indexes:
app:
use_alias: true
types:
articles:
properties:
type: ~
keywordFr: { boost: 4 }
keywordEn: { boost: 4 }
# i18n
titleEn: { boost: 3 }
titleFr: { boost: 3 }
headlineEn: { boost: 2 }
headlineFr: { boost: 2 }
ContentEn: ~ # The default boost value is 1
ContentFr: ~
persistence:
driver: orm
model: App\Entity\Article
provider:
service: App\Elasticsearch\Provider\ArticleProvider
listener:
insert: false
update: false
delete: false
settings:
index:
analysis:
analyzer:
synonym:
tokenizer: "standard"
filter: [synonym]
filter:
synonym:
type: "synonym"
synonyms:
- "elastic,elastica => elasticsearch"
# L1->L29 snippet in templates/blog/posts/_48.html.twig
suggest:
use_alias: true
settings:
index:
analysis:
analyzer:
suggest_analyzer:
type: custom
tokenizer: standard
filter: [lowercase, asciifolding]
types:
keyword:
properties:
locale:
type: keyword
suggest:
type: completion
analyzer: suggest_analyzer
contexts:
- name: locale
type: category
path: locale
Quelques explications à propos de cet index et de son mapping. Avant de déclarer le type, j'ajoute un analyseur dans la section "setting". Le filtre asciifolding va nous permettre d'ignorer les accents pour permettre à la suggestion de fonctionner même si ceux-ci ne sont pas utilisés. Par exemple, si on saisit "element", le mot "élément" devrait être suggéré.
Ensuite, dans la section "type", on utilise aussi un alias tout comme l'index "app". Dans le mapping nous avons deux propriétés : suggest
qui est de type "completion". Nous avons besoin de ce type particulier pour utiliser le "suggester" Elasticsearch comme nous le verrons dans le chapitre suivant. Nous avons une seconde propriété locale
qui va nous permettre de filtrer les suggestions en fonction de la langue courante (en ou fr). On a ajouté un contexte au champ "suggest" et celui-ci est associé à la propriété "locale" (path: locale
).
Si nous relançons la commande d'indexation, le nouvel index est créé. À cette étape, nous avons désormais deux index dans le cluster Elasticsearch :
Peupler l'index de suggestion
Maintenant, nous allons peupler le nouvel index de suggestion. Comme il n'y a pas de modèle Doctrine associé, nous n'allons pas créer un fournisseur de données mais une commande Symfony. L'idée est d'extraire tous les mots qui ont été utilisés dans l'index app. Voilà la nouvelle commande Symfony : (quelques éclaircissements après le code 🤔)
<?php
declare(strict_types=1);
// src/Command/PopulateSuggestCommand.php (used by templates/blog/posts/_51.html.twig)
namespace App\Command;
use Doctrine\Inflector\Inflector;
use Doctrine\Inflector\NoopWordInflector;
use Elastica\Document;
use FOS\ElasticaBundle\Elastica\Index;
use FOS\ElasticaBundle\Finder\TransformedFinder;
use FOS\ElasticaBundle\HybridResult;
use FOS\ElasticaBundle\Paginator\FantaPaginatorAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function Symfony\Component\String\u;
/**
* Populate the suggest elasticsearch index.
*/
final class PopulateSuggestCommand extends BaseCommand
{
public static $defaultName = self::NAMESPACE.':populate';
protected static $defaultDescription = 'Populate the "suggest" Elasticsearch index';
private Inflector $inflector;
public function __construct(
private TransformedFinder $articlesFinder,
private Index $suggestIndex
) {
parent::__construct();
$this->inflector = new Inflector(new NoopWordInflector(), new NoopWordInflector());
}
protected function configure(): void
{
[$desc, $class] = [self::$defaultDescription, self::class];
$this->setHelp(
<<<EOT
$desc
COMMAND:
<comment>$class</comment>
<info>%command.full_name%</info>
EOT
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln((string) self::$defaultDescription);
$pagination = $this->findHybridPaginated($this->articlesFinder);
$nbPages = $pagination->getNbPages();
$keywords = [];
foreach (range(1, $nbPages) as $page) {
$pagination->setCurrentPage($page);
foreach ($pagination->getCurrentPageResults() as $result) {
if ($result instanceof HybridResult) {
foreach ($result->getResult()->getSource() as $property => $text) {
if ($property === 'type') {
continue;
}
$locale = explode('_', $this->inflector->tableize($property))[1] ?? 'en';
$text = strip_tags($text ?? '');
$words = str_word_count($text, 2, 'çéâêîïôûàèùœÇÉÂÊÎÏÔÛÀÈÙŒ'); // FGS dot not remove french accents! 🙃
$textArray = array_filter($words);
$keywords[$locale] = array_merge($keywords[$locale] ?? [], $textArray);
}
}
}
}
// Index by locale
foreach ($keywords as $locale => $localeKeywords) {
// Remove small words and remaining craps (emojis) 😖
$localeKeywords = array_unique(array_map('mb_strtolower', $localeKeywords));
$localeKeywords = array_filter($localeKeywords, static function ($v): bool {
return u($v)->trim()->length() > 2;
});
$documents = [];
foreach ($localeKeywords as $keyword) {
$documents[] = (new Document())
->setType('keyword')
->set('locale', $locale)
->set('suggest', $keyword);
}
$responseSet = $this->suggestIndex->addDocuments($documents);
$output->writeln(sprintf(' -> TODO: %d -> DONE: <info>%d</info>, "%s" keywords indexed.', \count($documents), $responseSet->count(), $locale));
}
return self::SUCCESS;
}
/**
* @return Pagerfanta<mixed>
*/
private function findHybridPaginated(TransformedFinder $articlesFinder): Pagerfanta
{
$paginatorAdapter = $articlesFinder->createHybridPaginatorAdapter('');
return new Pagerfanta(new FantaPaginatorAdapter($paginatorAdapter));
}
}
Quelques explications : 💡
- On lance une recherche joker pour récupérer le nombre total de pages.
- On itère sur chaque page pour récupérer les articles relatifs.
- Pour chaque article, on extrait toutes les clés du document Elasticsearch.
- Pour chaque clé, on extrait du texte tous les mots grâce à la fonction PHP str_word_count().
- On supprime tout ce qui est vide, les doublons et les mots trop petits.
- Pour chaque mot restant on crée un document Elasticsearch en spécifiant sa langue.
- Finalement, on lance le processus d'indexation avec la fonction
addDocuments
.
À l'heure ou je vous écris, il y a environ 3500 mots indexés. Voici la sortie console de la nouvelle tâche "populate" du MakeFile
:
/Users/coil/Sites/strangebuzz.com$ make populate
php bin/console fos:elastica:reset
Resetting app
Resetting suggest
Resetting app
53/53 [============================] 100%
Refreshing app
Populate the "suggest" elasticsearch index
-> TODO: 2167 -> DONE: 2167, "fr" keywords indexed.
-> TODO: 1549 -> DONE: 1549, "en" keywords indexed.
Le contenu de cette nouvelle entrée :
## —— elasticsearch 🔎 —————————————————————————————————————————————————————————
populate: ## Reset and populate the Elasticsearch index
#@$(SYMFONY) fos:elastica:reset
#@$(SYMFONY) fos:elastica:populate --index=app
@$(SYMFONY) strangebuzz:index-articles
Vous pouvez trouver mon MakeFile Symfony complet dans ce snippet. Maintenant que l'index est peuplé, voyons comment l'utiliser pour l'implémentation de la fonctionnalité autocomplete.
Implémentation de l'autocomplete
Le but ici va être d'ajouter une action qui va retourner via Ajax les suggestions pour le widget autocomplete alors que l'utilisateur saisit un mot-clé. Créons un nouveau contrôleur dédié à cette tâche :
<?php
declare(strict_types=1);
// src/Controller/SuggestController.php
namespace App\Controller;
use App\Elasticsearch\ElastiCoil;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
#[Route(path: '/{_locale}', requirements: ['_locale' => '%locales_requirements%'])]
final class SuggestController extends AbstractController
{
#[Route(path: ['en' => '/suggest', 'fr' => '/suggerer'], name: 'suggest')]
public function suggest(Request $request, string $_locale, ElastiCoil $elastiCoil): JsonResponse
{
$q = $request->query->get('q', '');
return $this->json($elastiCoil->getSuggestions($q, $_locale));
}
}
Et le service Elasticsearch personnalisé :
<?php
declare(strict_types=1);
// src/Elasticsearch/ElastiCoil.php
namespace App\Elasticsearch;
use Elastica\Query;
use Elastica\Suggest;
use Elastica\Suggest\Completion;
use Elastica\Util;
use FOS\ElasticaBundle\Elastica\Index;
final class ElastiCoil
{
public const SUGGEST_NAME = 'completion';
public const SUGGEST_FIELD = 'suggest';
public function __construct(
private readonly Index $suggestIndex
) {
}
/**
* Get the a suggest object for a keyword and locale.
*/
public function getSuggest(string $q, string $locale): Suggest
{
$completionSuggest = (new Completion(self::SUGGEST_NAME, self::SUGGEST_FIELD))
->setPrefix(Util::escapeTerm($q))
->setParam('context', ['locale' => $locale])
->setSize(5);
return new Suggest($completionSuggest);
}
/**
* Return suggestions for a keyword and locale as a simple array.
*
* @return array<string>
*/
public function getSuggestions(string $q, string $locale): array
{
$suggest = $this->getSuggest($q, $locale);
$query = (new Query())->setSuggest($suggest);
$suggests = $this->suggestIndex->search($query)->getSuggests();
return $suggests[self::SUGGEST_NAME][0]['options'] ?? [];
}
}
Quelques explications : 💡
- Comme l'action de recherche, nous récupérons la saisie de l'utilisateur par le paramètre GET "q".
- Ensuite nous créons un objet elastica
Suggest
avec le nom de la propriété du mapping à utiliser. - Juste en dessous, on ajoute un contexte qui va nous permettre de filtrer les mots retournés : dans ce cas on filtre selon la langue de la page en cours (en ou fr).
- Ensuite, on extrait les options retournées par la réponse Elasticsearch.
- Finalement, nous retournons une réponse de type JSON (
JsonResponse
) contenant un tableau simple avec les options à afficher à l'utilisateur.
Affichage des suggestions
Maintenant que l'action de suggestion est faite, nous pouvons mettre en place un widget autocomplete qui va l'utiliser. Vous pouvez essayer dans le formulaire ci-dessous. C'est exactement le formulaire qui nous avons utilisé dans les articles précédents (un peu de JavaScript a été ajouté pour récupérer les suggestions). Comme vous pouvez le voir, sur cette page, uniquement des mots français sont retournés (en fait pas tout à fait car j'utilise certains anglicismes dans les articles rédigés en français !). Si vous essayez sur la version anglaise, vous pourrez vérifier que seuls des mots anglais le sont. C'est la même action mais un filtre a été appliqué grâce au contexte que nous avons assigné à l'objet de suggestion Elasticsearch.
Pour afficher le widget j'utilise un composant vue.js. Juste un commentaire à propos de la route que nous utilisons. Nous n'avons pas à spécifier la langue : {{ path('suggest') }}
car le composant de routage s'en charge et l'ajoute automatiquement pour nous (sur cette page c'est fr). Cet autocomplete est aussi sur la page des résultats de recherche. Voic le code de l'inclusion du composant :
Cliquez ici pour voir le code du composant Vue.js.
{# https://github.com/BosNaufal/vue-autocomplete #}
{% set placeholder = placeholder is defined ? placeholder : 'enter_one_or_several_keywords'|trans({}, 'search') %}
<autocomplete
ref="autocomplete"
aria-describedby="qHelp"
url="{{ path('search_suggest') }}" {# current local is injected #}
anchor="text" {# not used, custom render #}
label="text"
:on-should-render-child="autocompleteRenderChild"
:required="true"
id="post-q"
name="q"
:classes="{ wrapper: 'form-wrapper', input: 'form-control', list: 'data-list', item: 'data-list-item' }"
placeholder="{{ placeholder }}"
init-value="{{ app.request.query.get('q') }}"
:options="[]"
:min="3"
:encode-params="true"
>
</autocomplete>
Conclusion
C'était la dernière partie de ce tutoriel Elasticsearch. C'était intéressant (mais très long !) de l'écrire en même temps que je développais ces fonctionnalités sur ce site web. Il y a encore beaucoup à faire, mais je suis déjà content avec ce qui a été mis en place 😊. Je me sers tous les jours de cette recherche pour retrouver rapidement certains articles ou snippets. Une bonne nouvelle : le bundle FOSElastica est en cours de mise à jour pour supporter elastica 7.0. Donc, dès que cette version sortira, je modifierai ce tutoriel pour utiliser la dernière version d'Elasticsearch, à savoir la 7.6. 😀
Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À bientôt ! COil. 😊
Lire la doc Plus sur le web Plus sur Stackoverflow
Ils m'ont donné leurs retours et m'ont aidé à corriger des erreurs et typos dans cet article, un grand merci à : greg0ire, jmsche, Nico.F (Slack Symfony). 😊
A vous de jouer !
Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )
- Me remonter des erreurs ou typos.
- Me remonter des choses qui pourraient être améliorées.
- Aimez et retweetez !
- Suivez moi sur Twitter Suivez moi sur Twitter
- Inscrivez-vous au flux RSS.
- Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.
Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉
[🇫🇷] 9ème article de l'année. C'est la dernière partie de mon tutoriel: "Comment implémenter un moteur de recherche avec #Elasticsearch et #symfony." https://t.co/3845qW7ZRW Retours, likes et retweets sont les bienvenus ! 😉 Objectif annuel : 9/12 (75%) #php #strangebuzz #blogging
— [SB] COil (@C0il) November 19, 2019