jeudi 29 septembre 2022
Ce billet a été vu pour la première fois sur le blog de Synbioz le 29 September 2022 sous licence CC BY-NC-SA.

Recherche plein texte avec PostgreSQL

J’ai récemment eu l’opportunité de travailler pour un client qui souhaitait mettre en place une recherche plus pertinente sur son logiciel. L’occasion rêvée de regarder du côté de la recherche plein texte (full-text) proposée nativement par PostgreSQL !

Révélez votre meilleur profil

La recherche était effectuée sur des profils : un intitulé, une description, rien de bien exotique.

Historiquement la recherche de profil se faisait très sommairement sur la base de mots-clés et remontait des résultats peu pertinents. On recherchait, via Ransack, le terme exact faisant tout ou partie d’un mot, dans l’intitulé et la description des profils.

Par ailleurs, les mots-clés n’étaient utilisés que pour filtrer les résultats, le tri, lui, était effectué selon le critère de tri choisi (par défaut : la date de publication, les plus récents en premier).

Exemple : une recherche avec le mot clé « app » fera ressortir les profils dans lesquels figure le mot app, mais aussi application, appétit ou encore rapport.

Recherche plein texte

On comprend dès lors que cela manque de précision et que la pertinence n’est pas au rendez-vous. C’est là que PostgreSQL entre en jeu, et notamment ce que l’on nomme la recherche plein texte. Voici ce que nous en dit la documentation :

La recherche plein texte (ou plus simplement la recherche de texte) permet de sélectionner des documents en langage naturel qui satisfont une requête et, en option, de les trier par intérêt suivant cette requête. Le type le plus fréquent de recherche concerne la récupération de tous les documents contenant les termes de recherche indiqués et de les renvoyer dans un ordre dépendant de leur similarité par rapport à la requête.

— Chapitre 12. Recherche plein texte

Il va donc nous être possible de formuler une requête SQL, un peu velue certes, qui nous permettra d’exprimer ce qui pour nous est digne d’intérêt.

Un peu de vocabulaire

À ce niveau, il est important de s’arrêter quelques instants sur le vocabulaire pour comprendre de quoi nous parlons exactement.

Un document est l’unité de recherche, c’est-à-dire ce sur quoi nous souhaitons effectuer notre recherche. Cela peut être un simple champ d’une table de la base de données, ou la concaténation de plusieurs champs, éventuellement issus de plusieurs tables. Dans notre exemple, ce qui nous intéresse c’est l’intitulé et la description des profils.

SELECT (coalesce("profiles"."label"::text, '') || coalesce("profiles"."description"::text, '')) AS document
FROM "profiles"

On utilise ici coalesce() pour éviter de manipuler NULL lors de la concaténation, ce qui conduirait à un résultat nul pour l’ensemble du document.

Une requête (tsquery) se fait sur un document (tsvector) à l’aide de l’opérateur @@.

SELECT to_tsvector('Portez ce vieux whisky au juge blond qui fume') @@ to_tsquery('vieux & juge')
+----------+
| ?column? |
|----------|
| True     |
+----------+

Remarquez que l’on utilise ici to_tsvector() et to_tsquery(). En effet, nous ne manipulons pas de simples textes. Un tsquery contient des termes de recherche qui doivent déjà être des lexèmes normalisés, et peut combiner plusieurs termes en utilisant les opérateurs AND, OR, NOT et FOLLOWED BY.

SELECT to_tsquery('vieux & whisky')
+--------------------+
| to_tsquery         |
|--------------------|
| 'vieux' & 'whiski' |
+--------------------+

Notez le mot whisky remplacé par le lexème whiski.

Pour pouvoir faire usage de ce tsquery, notre document lui sera présenté sous la forme d’un tsvector, c’est-à-dire une version pré-traitée et compacte de celui-ci.

SELECT to_tsvector('Portez ce vieux whisky au juge blond qui fume')
+-----------------------------------------------------------------------------------+
| to_tsvector                                                                       |
|-----------------------------------------------------------------------------------|
| 'au':5 'blond':7 'ce':2 'fume':9 'juge':6 'portez':1 'qui':8 'vieux':3 'whiski':4 |
+-----------------------------------------------------------------------------------+

Notre document est ici découpé en lexèmes présentés dans l’ordre alphabétique et suivis des indices auxquels on les retrouve dans le document. Pourtant, ces lexèmes n’ont pas l’air si normalisés que ça… c’est parce que la recherche plein texte se base sur une configuration qui par défaut considère qu’il s’agit d’un document en anglais. Sans entrer trop vite dans le détail de la configuration, sachez qu’on peut préciser un argument supplémentaire à nos fonctions to_tsquery() et to_tsvector(), voyez :

-- to_tsvector([ config regconfig, ] document text) returns tsvector

SELECT to_tsvector('french', 'Portez ce vieux whisky au juge blond qui fume')
+---------------------------------------------------------+
| to_tsvector                                             |
|---------------------------------------------------------|
| 'blond':7 'fum':9 'jug':6 'port':1 'vieux':3 'whisky':4 |
+---------------------------------------------------------+

On comprend à présent que l’opérateur @@ cherchera dans ce tsvector la présence (ou l’absence) de certains lexèmes décrits par notre tsquery. L’intérêt de passer par des lexèmes normalisés est de pouvoir découvrir les différentes formes d’un même mot sans avoir à toutes les préciser.

Les plus attentifs d’entre vous auront remarqué la disparition des termes « ce », « au » et « qui ». Il s’agit là d’un des effets de la configuration choisie qui ignore tout simplement certains mots jugés trop génériques et non pertinents.

Une recherche aux petits oignons

Mais alors, comment fonctionne cette configuration ?

En interne, la fonction to_tsvector appelle un analyseur qui casse le texte en jetons et affecte un type à chaque jeton. Pour chaque jeton, une liste de dictionnaires est consultée, liste pouvant varier suivant le type de jeton. Le premier dictionnaire qui reconnaît le jeton émet un ou plusieurs lexèmes pour représenter le jeton. Le choix de l’analyseur, des dictionnaires et des types de jetons à indexer est déterminé par la configuration de recherche plein texte sélectionnée. Il est possible d’avoir plusieurs configurations pour la même base, et des configurations prédéfinies sont disponibles pour différentes langues.

— 12.3. Contrôler la recherche plein texte

Ainsi, il nous est possible de choisir une configuration préexistante, comme french dans l’exemple précédent, mais aussi d’élaborer une configuration spécialement adaptée à nos besoins. Et cela tombe bien, car nous en aurons justement besoin ! Prenons un exemple.

Cas particulier : C++, C#, .net

La recherche par dictionnaire, lorsque celle-ci est effectuée à l’aide de l’une des configurations mises à notre disposition, ignore un certain nombre de symboles (espaces, ponctuation) et les mots jugés non pertinents (stopwords ; particules, mots de liaison). Or, certains langages de programmation, qui peuvent très bien faire l’objet d’une recherche, contiennent l’un ou l’autre, voire les deux !

Pour palier cela, il nous faut constituer un thésaurus personnalisé, le porter à la connaissance de PostgreSQL, le lier à un dictionnaire lui aussi personnalisé, car il ne devra pas discriminer les stopwords, et enfin altérer la configuration du français pour les caractères ASCII et les symboles.

Reprenons. Les dictionnaires sont utilisés pour éliminer les mots qui ne devraient pas être considérés dans une recherche et pour normaliser des mots qui peuvent prendre des formes diverses. Il existe différents types de dictionnaires :

  1. Termes courants (stopwords)
  2. Dictionnaire simple
  3. Dictionnaire des synonymes
  4. Dictionnaire thésaurus
  5. Dictionnaire Ispell
  6. Dictionnaire Snowball

Une configuration définira ainsi la correspondance entre un type de jeton et un ou plusieurs dictionnaires. En ce qui nous concerne, nous avons besoin d’un thésaurus pour pouvoir associer un lexème à un ensemble de jetons.

Notre thésaurus devra se trouver dans /usr/local/share/postgresql/tsearch_data/ et nous le nommerons prog_thesaurus.ths. Sa syntaxe est plutôt transparente, voyez vous-même :

a + : aplus
a # : asharp
c - - : cminusminus
c + + : cplusplus
c/c + + : cplusplus
c # : csharp
. net : dotnet
f # : fsharp
f * : fstar
j + + : jplusplus
j # : jsharp
m # : msharp
q # : qsharp
r + + : rplusplus
xbase + + : xbaseplusplus
x + + : xplusplus
x # : xsharp
z + + : zplusplus

Il nous faut maintenant instruire PostgreSQL de l’existence de ce thésaurus. Mais nous allons faire face à un petit souci : certains des jetons utilisés dans notre thésaurus sont des stopwords ! Pour que notre thésaurus puisse les prendre en considération, il faut que ceux-ci ne soient pas ignorés par le dictionnaire de base. Ce dictionnaire (Snowball) est basé sur un algorithme de stemming qui sait comment réduire les variantes standard d’un mot vers une base, ou stem, en rapport avec la langue.

Observons tout d’abord le comportement actuel.

SELECT * FROM ts_debug('french', 'c++');
+-----------+-----------------+-------+---------------+-------------+---------+
| alias     | description     | token | dictionaries  | dictionary  | lexemes |
|-----------+-----------------+-------+---------------+-------------+---------|
| asciiword | Word, all ASCII | c     | {french_stem} | french_stem | []      |
| blank     | Space symbols   | +     | {}            | <null>      | <null>  |
| blank     | Space symbols   | +     | {}            | <null>      | <null>  |
+-----------+-----------------+-------+---------------+-------------+---------+
SELECT * FROM plainto_tsquery('french','c++')
NOTICE:  text-search query contains only stop words or does not contain lexemes, ignored

+-----------------+
| plainto_tsquery |
|-----------------|
|                 |
+-----------------+

Effectivement, nous nous trouvons là dans une situation cocasse où l’ensemble des jetons de notre requête sont ignorés : le premier étant un stopword, les suivants des symboles.

Créons donc un nouveau dictionnaire Snowball sans stopwords :

CREATE TEXT SEARCH DICTIONARY public.french_strigo_stem (
    TEMPLATE = pg_catalog.snowball,
    LANGUAGE = 'french'
);

Déclarons à présent notre thésaurus qui s’appuiera sur notre nouveau dictionnaire french_strigo_stem :

CREATE TEXT SEARCH DICTIONARY public.prog_thesaurus (
    TEMPLATE = pg_catalog.thesaurus,
    DICTFILE = 'prog_thesaurus',
    DICTIONARY = 'public.french_strigo_stem'
);

Pour éviter toute mauvaise surprise, clonons la configuration french ; c’est cette réplique que nous altèrerons par la suite :

CREATE TEXT SEARCH CONFIGURATION public.french_strigo (
  COPY = french
);

Voyons quels dictionnaires sont définis par notre configuration :

\dF+ french_strigo

Text search configuration "pg_catalog.french_strigo"
Parser: "pg_catalog.default"
+-----------------+--------------+
| Token           | Dictionaries |
|-----------------+--------------|
| asciihword      | french_stem  |
| asciiword       | french_stem  |
| email           | simple       |
| file            | simple       |
| float           | simple       |
| host            | simple       |
| hword           | french_stem  |
| hword_asciipart | french_stem  |
| hword_numpart   | simple       |
| hword_part      | french_stem  |
| int             | simple       |
| numhword        | simple       |
| numword         | simple       |
| sfloat          | simple       |
| uint            | simple       |
| url             | simple       |
| url_path        | simple       |
| version         | simple       |
| word            | french_stem  |
+-----------------+--------------+

Modifions à présent notre configuration pour y lier les types de jetons de notre choix à notre thésaurus. L’ordre de déclaration des dictionnaires a ici une importance, on prendra garde à positionner notre thésaurus en tête de liste :

ALTER TEXT SEARCH CONFIGURATION public.french_strigo
  ALTER MAPPING FOR asciiword, asciihword, hword_asciipart, word
  WITH prog_thesaurus, french_strigo_stem;

ALTER TEXT SEARCH CONFIGURATION public.french_strigo
  DROP MAPPING IF EXISTS FOR blank;

ALTER TEXT SEARCH CONFIGURATION public.french_strigo
  ALTER MAPPING FOR file, host
  WITH prog_thesaurus, simple;

Pourquoi altérer file, me direz-vous ? Parce que c/c dans « c/c++ » est considéré comme un jeton de type file.

Si l’on observe notre configuration, elle ressemble à présent à ceci :

\dF+ french_strigo

Text search configuration "public.french_strigo"
Parser: "pg_catalog.default"
+-----------------+------------------------------------+
| Token           | Dictionaries                       |
|-----------------+------------------------------------|
| asciihword      | prog_thesaurus,french_strigo_stem  |
| asciiword       | prog_thesaurus,french_strigo_stem  |
| email           | simple                             |
| file            | prog_thesaurus,simple              |
| float           | simple                             |
| host            | prog_thesaurus,simple              |
| hword           | french_stem                        |
| hword_asciipart | prog_thesaurus,french_strigo_stem  |
| hword_numpart   | simple                             |
| hword_part      | french_stem                        |
| int             | simple                             |
| numhword        | simple                             |
| numword         | simple                             |
| sfloat          | simple                             |
| uint            | simple                             |
| url             | simple                             |
| url_path        | simple                             |
| version         | simple                             |
| word            | prog_thesaurus,french_strigo_stem  |
+-----------------+------------------------------------+

Parfait ! Si l’on teste à présent notre nouvelle configuration :

SELECT * FROM ts_debug('french_strigo', 'c++');
+-------+-------------------+-------+-------------------------------------+----------------+---------------+
| alias | description       | token | dictionaries                        | dictionary     | lexemes       |
|-------+-------------------+-------+-------------------------------------+----------------+---------------|
| word  | Word, all letters | c++   | {prog_thesaurus,french_strigo_stem} | prog_thesaurus | ['cplusplus'] |
+-------+-------------------+-------+-------------------------------------+----------------+---------------+
SELECT * FROM plainto_tsquery('french_strigo','c++')
+-----------------+
| plainto_tsquery |
|-----------------|
| 'cplusplus'     |
+-----------------+

Excellent ! Nous observons à présent que notre thésaurus a reconnu « c++ » comme étant un jeton et lui a substitué le lexème « cplusplus ».

Stop ou encore ?

Je tiens à attirer votre attention sur le fait qu’ignorer purement et simplement les stopwords n’est peut-être pas souhaitable en conditions réelles. En effet, les stopwords ont leur intérêt dans la mesure de pertinence des résultats retournés. Mais rien ne nous empêche de déclarer notre propre dictionnaire de stopwords en y excluant ceux qui entrent en conflit avec notre thésaurus !

Pour cela on peut s’inspirer du dictionnaire french.stop.

cd /usr/local/share/postgresql/tsearch_data/
cp french.stop french_strigo.stop
# modifier french_strigo.stop

Il nous suffit alors de préciser le dictionnaire stopwords à utiliser :

CREATE TEXT SEARCH DICTIONARY public.french_strigo_stem (
  TEMPLATE = pg_catalog.snowball,
  LANGUAGE = 'french',
  STOPWORDS = 'french_strigo'
);

Si vous êtes amené à mettre à jour l’un de vos dictionnaires, pensez bien à recharger votre configuration ! Pour cela, voici une petite astuce :

ALTER TEXT SEARCH DICTIONARY public.prog_thesaurus ( dummy );
ALTER TEXT SEARCH DICTIONARY public.french_strigo_stem ( dummy );

Et maintenant ?

Nous venons de voir les bases de la recherche plein texte et de sa configuration. Cela fait déjà de nombreuses notions à assimiler, et encore, nous n’avons fait que les survoler ! Pour approfondir cela, je vous invite à consulter la documentation officielle ou sa version française, riches d’exemples et de détails.

Dans un prochain article, nous aborderons un autre aspect important de la recherche plein texte : la pondération. S’ensuivra un dernier article pour clore cette série, il mettra l’accent sur l’indexation et pg_search, une gem Ruby nous permettant de créer des scopes ActiveRecord qui tirent parti de la recherche plein texte de PostgreSQL.