- La recherche plein texte de PostgreSQL est une approche qui permet de construire une fonctionnalité de recherche sans moteur dédié, en combinant
tsvector,tsquery,@@,ts_ranket les index GIN - Les termes recherchés et les documents sont normalisés au niveau des lexèmes, et les requêtes peuvent être exprimées avec des opérateurs comme AND, OR, NOT et FOLLOWED BY, ce qui permet d’implémenter une grande partie de la syntaxe de recherche courante
- Dans l’environnement d’exemple, l’index GIN a réduit le temps de recherche de plus de 200 ms à environ 4 ms, mais lorsque le nombre de résultats augmente, le coût de classement et de tri de
ts_rankpeut devenir un goulot d’étranglement - L’ajustement de la pertinence se fait en ajoutant à l’expression de tri des signaux comme le poids du titre, le nombre de votes, la note, le genre ou la fraîcheur, ou en attribuant des poids aux colonnes avec
setweight - La tolérance aux fautes de frappe, la recherche à facettes, l’autocomplétion, la recherche d’expressions exactes et la recherche hybride sont aussi possibles, mais avec PostgreSQL il faut assembler soi-même les composants, et vérifier les limites de performance sur de grands jeux de données
L’approche de la recherche plein texte dans PostgreSQL
- PostgreSQL fournit des composants de bas niveau pour la recherche plein texte, que l’on peut combiner pour créer des fonctionnalités de moteur de recherche
- Cette approche est flexible, mais demande davantage de travail d’implémentation que des solutions dont la recherche plein texte est l’usage principal, comme Elasticsearch, Typesense ou Meilisearch
- Les requêtes d’exemple utilisent le jeu de données Wikipedia Movie Plots de Kaggle
- Il contient 34 000 titres de films
- Sa taille au format CSV est d’environ 81 Mo
Composants clés
- La recherche plein texte de PostgreSQL s’articule autour des éléments suivants
tsvector: stocke le texte à rechercher sous forme de liste de lexèmes normaliséstsquery: représente une requête de recherche normalisée@@: opérateur de correspondance qui vérifie si unetsquerycorrespond à untsvectorts_rank,ts_rank_cd: calculent le score de pertinence des résultats de recherche- Index GIN : index inversé permettant d’interroger efficacement un
tsvector
tsvector et configuration de recherche
tsvectorstocke une liste triée de lexèmes- Un lexème ressemble à un token, mais c’est une chaîne normalisée afin que plusieurs formes d’un même mot soient ramenées à une forme commune
- Avec la configuration anglaise, les majuscules sont converties en minuscules et les suffixes sont supprimés lors de la normalisation
- Lorsque
to_tsvectoranalyse une phrase anglaise, les mots vides comme “I”, “to” ou “an” sont supprimés- “refuse” et “Refusing” sont tous deux transformés en
refus - La ponctuation est ignorée
- La position des mots dans le texte original et leur poids sont également enregistrés
- “refuse” et “Refusing” sont tous deux transformés en
- En utilisant la configuration
simpleau lieu de la configuration de rechercheenglish, les mots sont inclus tels qu’ils apparaissent dans le texte- “refuse” et “refusing” restent des lexèmes distincts
- La configuration
simpleest particulièrement utile pour les colonnes contenant des libellés ou des tags
- PostgreSQL fournit des configurations de recherche intégrées pour plusieurs langues, mais pas pour les langues CJK (chinois, japonais, coréen)
- Pour les langues non prises en charge, la configuration
simplepeut fonctionner de manière pratique - Il n’est toutefois pas certain qu’elle soit suffisante pour le CJK
- Pour les langues non prises en charge, la configuration
tsquery et expression des requêtes
tsqueryest un type de données qui représente une requête de recherche normalisée- Les termes recherchés doivent déjà être des lexèmes normalisés
- Plusieurs termes peuvent être combinés avec les opérateurs AND, OR, NOT et FOLLOWED BY
to_tsquery,plainto_tsqueryetwebsearch_to_tsqueryaident à convertir le texte saisi par l’utilisateur entsqueryappropriée- Leur rôle principal est de normaliser les mots contenus dans le texte saisi
websearch_to_tsquerypermet de créer des requêtes proches de celles d’un champ de recherche classiquedarth vaderest traité comme un AND logique exigeant que les deux mots soient présents dans le document- Les recherches OR et l’exclusion de mots sont également possibles
- La recherche d’expression représente une forme où les mots se suivent dans l’ordre
- Avec la configuration anglaise, les mots vides comme “the” sont supprimés, si bien que dans certaines recherches d’expression, presque toute l’expression peut disparaître
- Dans ce cas, la configuration
simplepeut donner le résultat attendu
- Dans ce cas, la configuration
- L’opérateur
@@est utilisé pour vérifier si unetsquerycorrespond à untsvector
Index GIN et performances de recherche
- GIN signifie Generalized Inverted Index ; c’est un type d’index conçu pour les requêtes cherchant des valeurs d’éléments contenues dans des valeurs composées
- GIN peut être utilisé non seulement pour la recherche textuelle, mais aussi pour les requêtes JSON
- On peut créer une colonne
tsvectorqui combine plusieurs colonnes recherchables, puis créer un index GIN sur cette colonne - Dans l’environnement d’exemple, l’index GIN a réduit le temps de recherche de plus de 200 ms à environ 4 ms
Classement et calcul de pertinence
- Pour offrir une bonne expérience de recherche, les résultats doivent être triés par pertinence
- PostgreSQL fournit deux fonctions de classement prédéfinies,
ts_ranketts_rank_cd- Les deux prennent en compte la fréquence d’apparition des termes recherchés
ts_rank_cdtient aussi compte de la proximité entre les lexèmes correspondants
- La pertinence dépend fortement de la nature de l’application
- Les fonctions de classement par défaut sont plutôt un point de départ ; selon les besoins, on peut créer sa propre fonction de classement ou les combiner avec d’autres facteurs
ts_rankdoit accéder à la colonnesearchde chaque résultat- Si la condition
WHEREcorrespond à de nombreuses lignes, PostgreSQL doit toutes les parcourir pour calculer le classement et effectuer le tri - Dans l’environnement d’exemple, une requête était renvoyée en 5 à 7 ms, mais une requête comme
darth OR vader, qui doit classer plus de 1 000 résultats, prenait environ 80 ms
- Si la condition
Tuning de la pertinence
- La pertinence basée sur la fréquence des mots est un bon réglage par défaut, mais certaines données peuvent contenir des signaux plus importants que la fréquence
- Dans le jeu de données de films, les signaux suivants peuvent être intégrés à la pertinence
- Considérer les résultats correspondant dans le titre comme plus importants que ceux correspondant dans la description ou le synopsis
- Faire remonter les films plus populaires selon la note ou le nombre de votes
- Placer les comédies plus haut si l’utilisateur préfère ce genre
- Considérer les titres récents comme plus pertinents que les titres anciens
- Les moteurs de recherche dédiés offrent des fonctionnalités permettant de faire influencer le classement par différentes colonnes ou champs
- Pour des exemples de documentation associée, voir Elastic, Typesense et Meilisearch
Boosting basé sur les nombres, les dates et les valeurs exactes
- PostgreSQL ne fournit pas directement de boosting basé sur d’autres colonnes, mais le classement est au final une expression de tri, on peut donc y ajouter ses propres signaux
- Pour tenir compte du nombre de votes, on peut l’implémenter en ajoutant au score de classement un boost basé sur ce nombre
- Dans l’exemple, un logarithme est utilisé pour en adoucir l’impact
- Le coefficient
0.01aligne le booster sur une échelle proche de celle du score de classement
- On peut aussi créer une fonction plus complexe qui ne booste la note que lorsque le nombre de votes atteint un certain seuil
- Pour faire remonter un genre précis, on peut utiliser une fonction comme
valueBooster, qui ne renvoie un coefficient que lorsque la valeur correspond à une valeur précise de la colonne
Poids des colonnes
- Les lexèmes d’un
tsvectorpeuvent recevoir un poids - PostgreSQL prend en charge quatre poids : A, B, C et D
- A est le poids le plus élevé
- D est le plus faible et la valeur par défaut
- La fonction
setweightpermet de contrôler le poids lors de la création d’une colonnetsvector - En donnant un poids plus élevé à la colonne titre, les films dont le titre contient le terme recherché remontent en haut des résultats et leur score de classement augmente
- Le fait qu’il n’existe que quatre classes de poids est une limite, et les poids doivent être appliqués au moment du calcul du
tsvector
Tolérance aux fautes et recherche floue
- PostgreSQL ne prend pas directement en charge la recherche floue ni la tolérance aux fautes de frappe avec
tsvectorettsquery - En partant du principe que la requête contient une faute de frappe, on peut l’implémenter ainsi
- Indexer tous les lexèmes du contenu dans une table séparée
- Pour chaque mot de la requête, rechercher des mots candidats par similarité ou distance de Levenshtein
- Modifier la requête pour inclure les mots trouvés
- Exécuter la recherche avec la requête modifiée
- L’exemple utilise la distance de Levenshtein, car c’est une méthode utilisée par les moteurs de recherche pour la recherche floue
- Une fois la liste de mots candidats obtenue, il faut ajuster la requête pour inclure tous ces mots
Recherche à facettes
- La recherche à facettes est largement utilisée, notamment sur les sites d’e-commerce, pour permettre aux utilisateurs de restreindre progressivement leur recherche
- Dans PostgreSQL, on peut l’implémenter en définissant manuellement des catégories puis en les ajoutant aux conditions
WHEREde la recherche - Il est aussi possible de créer des catégories de manière algorithmique à partir des données existantes
- Dans l’exemple, une facette “Decade” est créée à partir de l’année des films
- On peut également calculer le nombre de correspondances par décennie et l’afficher entre parenthèses
- Pour récupérer plusieurs facettes en une seule requête, on peut combiner des CTE
- Cette approche peut bien fonctionner sur de petits à moyens jeux de données, mais devenir lente sur de très grands jeux de données
Portée et limites d’un moteur de recherche PostgreSQL
- En combinant les composants de recherche plein texte de PostgreSQL, on peut créer un moteur de recherche assez avancé
- Une recherche basée sur PostgreSQL prend aussi en charge les jointures et les transactions ACID
- Ce sont des fonctionnalités que les autres moteurs de recherche n’ont généralement pas
- Parmi les sujets avancés de recherche qui mériteraient aussi d’être traités
- Suggestions et autocomplétion
- Correspondance exacte d’expressions
- Recherche hybride combinée avec
pg-vector
- Ces fonctionnalités sont possibles avec PostgreSQL, mais il faut assembler soi-même les composants
- Dans certains cas, les performances peuvent se dégrader sur de très grands jeux de données
- L’article suivant, part 2, compare l’implémentation de la recherche dans PostgreSQL à l’ajout d’Elasticsearch dans l’infrastructure avec synchronisation des données
1 commentaires
Avis sur Hacker News
Il ne faut pas essayer de faire ça en imitant les fonctionnalités d'Elasticsearch
Dans les années 2000, j'ai déjà construit avec MySQL 3.x un moteur de recherche qui analysait les données EXIF d'images pour indexer une taxonomie à 3 niveaux avec des comptages ; même plusieurs vendeurs très coûteux n'y arrivaient pas correctement, et Autonomy n'arrivait même pas à la classification de plus haut niveau
J'ai fini par y arriver au bout de 6 semaines à écrire du SQL fragile dont les performances changeaient rien qu'en modifiant l'ordre des colonnes dans un
SELECT, mais je n'ai aucune envie de recommencer. Les bases de données restent fondamentalement assez similaires, mais les moteurs de recherche ont beaucoup plus progresséOn peut le faire par curiosité intellectuelle, mais la recherche ne se résume pas à tokenizer puis s'arrêter là. Très vite arrivent des exigences comme la navigation par catégories, le multilingue, les synonymes automatiques, les suggestions orthographiques « Did you mean », les performances à grande échelle, et on se retrouve coincé dans une impasse. Pour sa santé mentale, mieux vaut utiliser un moteur de recherche, et il existe aussi des outils comme ZomboDB ou PGSync pour synchroniser PG et ES
À l'inverse, si un analyste data ou un développeur veut rechercher dans les colonnes texte d'une grosse base de données avec plus de souplesse que
LIKE/ILIKE, il peut être plus simple et plus rapide d'aller à 90 % du besoin en créant un index/une table de recherche full-text dans la même base de donnéesJ'attends avec intérêt la 2e partie comparant Postgres et Elasticsearch. Une application de l'entreprise utilise PG pour le CRUD des objets et Elastic pour la recherche, mais nous avons complètement sous-estimé l'effort nécessaire pour synchroniser les deux stockages, et nous envisageons concrètement de supprimer Elasticsearch
Toutes les 5 minutes, un cron cherche en base les objets à indexer pour lesquels
last_modified_at > last_indexing_started_timestamp, les indexe dans Elasticsearch, puis met à jourlast_indexing_started_timestampavec l'heure de début de la synchronisation. Ainsi, les objets modifiés entre le début et la fin d'une exécution seront repris à l'exécution suivanteS'il faut reconstruire Elasticsearch, il suffit de vider le dernier horodatage d'indexation et de resynchroniser depuis le début ; le système peut ainsi s'auto-réparer sans que la synchronisation ne dérive
Comme toujours avec les traitements asynchrones, il faut de la surveillance et des tentatives de reprise, mais ES était stable et rapide, donc les problèmes étaient rares. En revanche, nos exigences de cohérence étaient souples : il suffisait que l'état le plus récent de PG arrive dans ES dans un délai raisonnable ; avec d'autres exigences, ce pourrait être différent
Après être passés à la recherche full-text de Postgres avec pondération des index et des requêtes, nous avons pu gérer tout ce dont nous avions besoin uniquement dans Postgres, avec des triggers de mise à jour et des requêtes de recherche très rapides
Globalement, ce fut un succès, et la baisse de la charge d'exploitation a largement remboursé le temps d'ingénierie investi, mais ce n'est pas quelque chose à entreprendre à la légère
Selon les besoins, des vues matérialisées, des vues normales et des triggers peuvent être préférables. La recherche textuelle intégrée peut aussi ne pas convenir au cas d'usage, et construire une alternative n'est pas forcément si difficile
Sans temps de consultation p50/p99 sous une charge réaliste, il est difficile de considérer que quoi que ce soit est démontré. Un moteur de recherche qui renvoie des résultats en 1 minute n'a rien d'« avancé », et une base relationnelle comme Postgres peut bien sûr le faire sur le papier
Je suis globalement d'accord : à grande échelle, par exemple au-delà de quelques millions d'enregistrements, il y a de fortes chances que je n'utilise pas cette approche. Mon intérêt principal était de voir jusqu'où on peut répliquer les fonctionnalités
Pour les petits cas d'usage de recherche, cela présente des avantages comme moins d'infrastructure à maintenir, une cohérence forte et les jointures. Chez Xata, on réfléchit aussi à une transition fluide consistant à utiliser Postgres à petite échelle puis à passer à Elasticsearch avec un minimum de changements cassants
Vu sous cet angle, la rapidité des résultats de recherche n'a rien de si surprenant. On peut maintenir les résultats en cache en tâche de fond et les servir tels quels quand une requête arrive. Cette question de mise en cache et de temps de réponse me semble distincte de la vitesse réelle de calcul des résultats de recherche
Certains utilisent des connexions lentes, et un terme particulier comme un séisme peut aussi exploser seulement dans une région donnée
Si les requêtes sont trop aléatoires, le cache n'aura pas de résultats et les mesures seront pires que la réalité ; si elles ne le sont pas assez, elles seront meilleures que la réalité
Par conséquent, les avantages d'ES et consorts se situent plutôt dans la montée en charge horizontale sur plusieurs nœuds ou dans les fonctionnalités additionnelles posées au-dessus de l'index principal
ts_rankseul est parfait, mais en pratique on a souvent envie d'ajuster le classement avec d'autres indicateurs de pertinence. Une fois ce problème résolu — le fait de ne pas pouvoir utiliser cet indicateur comme critère de classement principal — les résultats sont aussi rapides que des requêtes ordinaires sur une table de base de données indexéeÀ l’adolescence, j’ai essayé d’en construire un à partir de zéro sans vraiment bien connaître ni les moteurs de recherche ni les bases de données. Je voulais voir jusqu’où on pouvait aller, à quelle vitesse on pouvait renvoyer des résultats de recherche raisonnablement pertinents
Construire rapidement une base de données et un moteur de recherche basiques est sans doute assez facile, même pour un programmeur amateur. En comprenant les algorithmes fondamentaux d’informatique ainsi que l’usage des systèmes d’exploitation et du matériel, on peut en faire un en un ou deux mois
Même avec un langage de haut niveau, les performances n’étaient pas mauvaises, autour de 250 k QPS sur un portable de 2003. Avec du sharding, la scalabilité n’est pas un énorme problème non plus. L’indexation, le verrouillage et la cohérence sont plus complexes que le stockage et la lecture
Le vrai gros problème, c’est la subjectivité de la recherche. Dès qu’on se demande ce que l’utilisateur veut vraiment trouver, comment chercher quand il ne sait même pas lui-même ce qu’il cherche, comment empêcher les abus du système, ou comment gérer des requêtes et des jeux de données complexes, la difficulté augmente de plusieurs ordres de grandeur
Je me demande si c’était vraiment une base de données généraliste et si ce chiffre est exact. J’aimerais savoir quelles techniques ont été utilisées pour dépasser ces moteurs
Au-delà, il faut des solutions créatives, et c’est là que commencent les différents compromis
Quand on fait de la recherche dans Postgres, l’un des nombreux problèmes est que c’est une tâche très consommatrice en CPU, et on va de plus en plus vers l’utilisation du GPU aussi. Idéalement, on voudrait réserver le CPU de la base de données aux mises à jour transactionnelles du modèle de données principal
J’ai souvent vu des clusters ES et Solr tourner à 100 % sur plus de 10 nœuds pendant une réindexation, ou utiliser 30 à 50 % sur plus de 10 nœuds en régime normal. La base de données correspondante peut, par exemple, être une instance AWS L/XL avec 50 à 100 Go de données et une utilisation CPU d’environ 30 %
Si on déplace toute la charge CPU de recherche vers la base principale, il faut alors shard la DB. Cela dit, pour des side projects, les extensions PG pour la recherche, les jointures récursives, les vecteurs, etc., sont agréables parce qu’elles sont amusantes et simples
Je l’ai déjà fait en pratique il y a quelque temps, et c’était assez rapide
https://austingwalters.com/fast-full-text-search-in-postgres...
Le site actuel est https://askhn.ai
[1]: https://en.wikipedia.org/wiki/Kerning
En combinant ce genre de technique avec pgvector, on peut aussi retrouver du contenu pertinent via des embeddings. Ça m’a semblé presque magique
Pour info, ce n’est pas « Dark » Vader mais Darth Vader. Moi aussi, quand j’étais jeune, je pensais que c’était « Dark »
Très bon article, clair aussi. SQLite propose également des fonctions d’indexation avancées et de stemming via des plugins standard
Pour l’anglais, SQLite fonctionne aussi très bien
https://github.com/daitangio/knowledge
Ça vaut le coup d’essayer. C’est assez puissant
Bon article, mais le passage disant que PostgreSQL ne prend pas en charge la recherche floue est en partie faux. L’extension
pg_trgmet les index trigrammes GIN couvrent des cas d’usage de recherche floue comme ceux donnés en exemple dans l’articlehttps://www.postgresonline.com/article_pfriendly/169.html
Les requêtes peuvent devenir beaucoup plus rapides, mais au prix d’une consommation mémoire plus élevée et d’un surcoût de traitement lors des mises à jour