LOXODATA

Recherche multilingue en texte intégral avec PostgreSQL (partie 1)

2025-01-16   1823 mots, 9 minutes de lecture   Hervé Lefebvre

Principes et configurations

Historique

En 2000, des développements pour PostgreSQL basés sur OpenFTS ont débuté. Ce projet était alors nommé Tsearch. En 2003 le projet est devenu Tsearch2, utilisant le nouveau type de données tsvector et les index GiN/GiST de PostgreSQL 8.2 ainsi que l’UTF8. La recherche en texte intégral (ou FTS pour Full-Text Search) était proposée dans une contribution séparée nommée tsearch2 et développée par Oleg Bartunov et Teodor Sigaev. La contribution a été pleinement intégrée dans PostgreSQL à partir de la version 8.3. L’objet de ce document est donc de présenter la recherche en plein texte disponible avec la distribution standard de PostgreSQL.

Principe général

Décomposition des documents

La recherche en texte intégral diffère de la simple recherche basée sur les chaînes de caractères. Il s’agit en effet d’effectuer une recherche sémantique et non pas une simple recherche d’expression régulière. PostgreSQL est doté d’outils puissants permettant de travailler sur les chaînes de caractères, évaluer la similarité entre deux chaînes, etc. On peut citer par exemple l’extension pg_trgm (trigrammes) particulièrement utile pour de telles recherches.

On peut résumer la recherche sémantique en quelques étapes simples (on nomme “document” le texte qui fera ultérieurement l’objet d’une recherche) :

  • Décomposition du document en éléments lexicographiques simples (mots, nombres, adresses…);
  • Factorisation des éléments lexicographiques simples en lexèmes;
  • Transformation du document en un vecteur de lexèmes.

Voici un exemple simple de transformation d’un texte en vecteur de lexèmes en guise d’illustration :

loxodata_text=# select to_tsvector('english','I love postgres, but she loves shopping with a $100 banknote');
                     to_tsvector
------------------------------------------------------
 '100':10 'banknot':11 'love':2,6 'postgr':3 'shop':7
(1 row)

On peut voir que le texte a été décomposé en 5 lexèmes, et que le lexème “love” est présent sur deux positions : 2 et 6. La fonction ts_debug() permet d’avoir plus de détails sur cette transformation :

loxodata_text=# select * from ts_debug('english', 'I love postgres, but she loves shopping with a $100 banknote');
   alias   |   description    |  token   |  dictionaries  |  dictionary  |  lexemes
-----------+------------------+----------+----------------+--------------+-----------
 asciiword | Word, all ASCII  | I        | {english_stem} | english_stem | {}
 blank     | Space symbols    |          | {}             |              |
 asciiword | Word, all ASCII  | love     | {english_stem} | english_stem | {love}
 blank     | Space symbols    |          | {}             |              |
 asciiword | Word, all ASCII  | postgres | {english_stem} | english_stem | {postgr}
 blank     | Space symbols    | ,        | {}             |              |
 asciiword | Word, all ASCII  | but      | {english_stem} | english_stem | {}
 blank     | Space symbols    |          | {}             |              |
 asciiword | Word, all ASCII  | she      | {english_stem} | english_stem | {}
 blank     | Space symbols    |          | {}             |              |
 asciiword | Word, all ASCII  | loves    | {english_stem} | english_stem | {love}
 blank     | Space symbols    |          | {}             |              |
 asciiword | Word, all ASCII  | shopping | {english_stem} | english_stem | {shop}
 blank     | Space symbols    |          | {}             |              |
 asciiword | Word, all ASCII  | with     | {english_stem} | english_stem | {}
 blank     | Space symbols    |          | {}             |              |
 asciiword | Word, all ASCII  | a        | {english_stem} | english_stem | {}
 blank     | Space symbols    |  $       | {}             |              |
 uint      | Unsigned integer | 100      | {simple}       | simple       | {100}
 blank     | Space symbols    |          | {}             |              |
 asciiword | Word, all ASCII  | banknote | {english_stem} | english_stem | {banknot}
(21 rows)

La sortie de ts_debug() indique que les caractères virgule et dollar (",$") ont été considérés comme des espaces, « 100 » est bien détecté comme un entier non signé, et les mots « I,but,she,with,a » n’ont pas été convertis en lexèmes. Ces derniers sont en effet des « stop words », c’est-à-dire des mots trop courants pour être significatifs, ils sont donc éliminés du vecteur de lexèmes.

Requête de recherche

La requête de recherche va suivre le même principe : les éléments recherchés vont être décomposés en lexèmes, et PostgreSQL va chercher des éléments communs entre les deux vecteurs.

loxodata_text=# select to_tsquery('english','shops & banknotes');
     to_tsquery
--------------------
 'shop' & 'banknot'
(1 row)

Comme vous l’aurez deviné, le & est un opérateur logique signifiant ET. Les différents opérateurs logiques pour une requête en plein texte sont :

  • & ET logique
  • | OU logique
  • ! NON logique
  • <-> Précédence

Et enfin, l’opérateur @@ teste la correspondance entre un vecteur et une requête.

On peut ainsi tester, par exemple, une requête shops & (banknotes | credit <-> card), c’est à dire « contient ‘shop’ ET (soit (‘banknotes’) soit (‘credit’ suivi de ‘card’)) » avec différentes phrases :

loxodata_text=# WITH docs as (SELECT unnest(ARRAY['I love postgres, but she loves shopping with a $100 banknote','I love postgres, but she loves shopping with a credit card', 'I love postgres, but she loves shopping with a card for credit', 'I love $100 banknotes']) as sentence)
SELECT to_tsquery('english','shops & (banknotes | credit <-> card)') @@ to_tsvector('english',sentence) result, sentence FROM docs;
 result |                            sentence
--------+----------------------------------------------------------------
 t      | I love postgres, but she loves shopping with a $100 banknote
 t      | I love postgres, but she loves shopping with a credit card
 f      | I love postgres, but she loves shopping with a card for credit
 f      | I love $100 banknotes
(4 rows)

La requête retourne False pour la troisième phrase car le mot ‘card’ ne suit pas immédiatement ‘credit’, et la quatrième phrase retourne également False du fait de l’absence du lexème ‘shop’.

Il faut noter que les positions enregistrées dans le vecteur tiennent compte de la présence de stop words. Ainsi ‘credit for card’ ne correspondra pas à ‘credit <-> card’ bien que ‘for’ soit un stop word.

Configurations

Tout d’abord une précision : les configurations FTS se font base par base, elles ne sont pas globales au cluster (i.e. instance).

Différentes langues

Dans ces premiers exemples, nous avons utilisé uniquement l’anglais pour une raison très simple : PostgreSQL est livré bien configuré pour l’anglais. Cependant pour le français, même si le support est présent, quelques ajustements sont nécessaires.

Commençons par faire une copie de la configuration par défaut :

loxodata_text=# CREATE TEXT SEARCH CONFIGURATION french_custom ( COPY=french );
CREATE TEXT SEARCH CONFIGURATION

Nous pouvons examiner les différents éléments lexicographiques définis dans cette configuration :

loxodata_text=# \dF+ french_custom
Text search configuration "public.french_custom"
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

Un premier test rapide nous montre une difficulté liée à la langue française : les accents.

loxodata_text=# select title,to_tsvector('french',title) from pages where id=186;
         title         |        to_tsvector
-----------------------+----------------------------
 Imprimeur  Wikipédia | 'imprimeur':1 'wikipédi':2
(1 row)

Cela poserait un problème, par exemple, avec les participes passés, puisque “mange” et “mangé” seraient des lexèmes différents. On peut donc utiliser l’extension standard UNACCENT et l’ajouter à notre configuration :

loxodata_text=# CREATE EXTENSION IF NOT EXISTS unaccent;
NOTICE:  extension "unaccent" already exists, skipping
CREATE EXTENSION

loxodata_text=# ALTER TEXT SEARCH CONFIGURATION french_custom
    ALTER MAPPING FOR hword, hword_part, word WITH unaccent, french_stem;
ALTER TEXT SEARCH CONFIGURATION

Vérifions la prise en compte de unaccent sur notre configuration :

loxodata_text=# \dF+ french_custom
Text search configuration "public.french_custom"
Parser: "pg_catalog.default"
      Token      |     Dictionaries
-----------------+----------------------
 asciihword      | french_stem
 asciiword       | french_stem
 email           | simple
 file            | simple
 float           | simple
 host            | simple
 hword           | unaccent,french_stem
 hword_asciipart | french_stem
 hword_numpart   | simple
 hword_part      | unaccent,french_stem
 int             | simple
 numhword        | simple
 numword         | simple
 sfloat          | simple
 uint            | simple
 url             | simple
 url_path        | simple
 version         | simple
 word            | unaccent,french_stem

Nous pouvons faire un test rapide avec ts_debug() pour voir le comportement :

loxodata_text=# select ts_debug('french_custom','Cet article est écrit en 2024 pour être publié sur le blog de Loxodata (https://www.loxodata.fr/post/)');
                                   ts_debug
-------------------------------------------------------------------------------
 (asciiword,"Word, all ASCII",Cet,{french_stem},french_stem,{cet})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",article,{french_stem},french_stem,{articl})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",est,{french_stem},french_stem,{})
 (blank,"Space symbols"," ",{},,)
 (word,"Word, all letters",écrit,"{unaccent,french_stem}",unaccent,{ecrit})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",en,{french_stem},french_stem,{})
 (blank,"Space symbols"," ",{},,)
 (uint,"Unsigned integer",2024,{simple},simple,{2024})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",pour,{french_stem},french_stem,{})
 (blank,"Space symbols"," ",{},,)
 (word,"Word, all letters",être,"{unaccent,french_stem}",unaccent,{etre})
 (blank,"Space symbols"," ",{},,)
 (word,"Word, all letters",publié,"{unaccent,french_stem}",unaccent,{publie})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",sur,{french_stem},french_stem,{})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",le,{french_stem},french_stem,{})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",blog,{french_stem},french_stem,{blog})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",de,{french_stem},french_stem,{})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",Loxodata,{french_stem},french_stem,{loxodat})
 (blank,"Space symbols"," (",{},,)
 (protocol,"Protocol head",https://,{},,)
 (url,URL,"www.loxodata.fr/post/)",{simple},simple,"{www.loxodata.fr/post/)}")
 (host,Host,www.loxodata.fr,{simple},simple,{www.loxodata.fr})
 (url_path,"URL path","/post/)",{simple},simple,"{/post/)}")
(32 rows)

Sur les 32 éléments lexicographiques, 6 ont été éliminés (est, en, pour…) car figurant dans les “stop words”. La plupart des éléments sont des “mots”, nous avons également un nombre entier (2024) et une URL. Les accents ont bien été supprimés des lexèmes (“etre”, “publie”…)

Choix des éléments lexicographiques à traiter

Selon les types de documents que nous souhaitons traiter, nous pouvons éliminer des éléments à traiter. Ainsi nous pouvons éliminer les valeurs numériques, les URLs et les adresses emails de nos configurations français et anglais :

loxodata_text=# ALTER TEXT SEARCH CONFIGURATION french_custom
    DROP MAPPING FOR email, sfloat, float, int, uint, url, host, url_path;
ALTER TEXT SEARCH CONFIGURATION

loxodata_text=# ALTER TEXT SEARCH CONFIGURATION  english_custom DROP MAPPING FOR email, sfloat, float, int, uint, url, host, url_path;
ALTER TEXT SEARCH CONFIGURATION
loxodata_text=#

Un nouveau test avec ts_debug() montre que ces éléments lexicographiques (nombre et url) sont désormais ignorés lors de l’utilisation de la configuration french_custom :

loxodata_text=# select ts_debug('french_custom','Cet article est écrit en 2024 pour être publié sur le blog de Loxodata (https://www.loxodata.fr/post/)');
                                   ts_debug
------------------------------------------------------------------------------
 (asciiword,"Word, all ASCII",Cet,{french_stem},french_stem,{cet})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",article,{french_stem},french_stem,{articl})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",est,{french_stem},french_stem,{})
 (blank,"Space symbols"," ",{},,)
 (word,"Word, all letters",écrit,"{unaccent,french_stem}",unaccent,{ecrit})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",en,{french_stem},french_stem,{})
 (blank,"Space symbols"," ",{},,)
 (uint,"Unsigned integer",2024,{},,)
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",pour,{french_stem},french_stem,{})
 (blank,"Space symbols"," ",{},,)
 (word,"Word, all letters",être,"{unaccent,french_stem}",unaccent,{etre})
 (blank,"Space symbols"," ",{},,)
 (word,"Word, all letters",publié,"{unaccent,french_stem}",unaccent,{publie})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",sur,{french_stem},french_stem,{})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",le,{french_stem},french_stem,{})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",blog,{french_stem},french_stem,{blog})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",de,{french_stem},french_stem,{})
 (blank,"Space symbols"," ",{},,)
 (asciiword,"Word, all ASCII",Loxodata,{french_stem},french_stem,{loxodat})
 (blank,"Space symbols"," (",{},,)
 (protocol,"Protocol head",https://,{},,)
 (url,URL,"www.loxodata.fr/post/)",{},,)
 (host,Host,www.loxodata.fr,{},,)
 (url_path,"URL path","/post/)",{},,)
(32 rows)

Stop-words

On peut constater que le fichier des stop-words français fourni par défaut est très succinct :

loxodata_text=# select to_tsvector('french_custom','le la les ce ça');
  to_tsvector
---------------
 'ca':5 'le':3
(1 row)

“ça” et “les” n’y figurent pas par exemple.

Mais nous pouvons télécharger un fichier plus complet et le placer dans la configuration tsearch (le fichier doit obligatoirement avoir le suffixe .stop) :

~/loxodata_text$ wget https://raw.githubusercontent.com/stopwords-iso/stopwords-fr/refs/heads/master/stopwords-fr.txt
~/loxodata_text$ sudo cp stopwords-fr.txt /opt/pgsql/16/share/tsearch_data/french_custom.stop

Puis modifier la configuration du dictionnaire français pour utiliser ce nouveau fichier :

loxodata_text=# alter TEXT SEARCH DICTIONARY french_stem(STOPWORDS = french_custom );
ALTER TEXT SEARCH DICTIONARY

Et maintenant, vérifions la prise en compte de cette nouvelle configuration :

loxodata_text=# select to_tsvector('french_custom','le la les ce ça');
 to_tsvector
-------------
 'ca':5
(1 row)

Si “les” est bien maintenant considéré comme un stop-word, il n’en est pas de même pour “ça” ce qui peut surprendre, car ce mot figure bien dans notre fichier :

~/loxodata_text$ grep -n "ça" stopwords-fr.txt
676:ça

L’explication est simple, les stop-words sont appliqués après le filtre “unaccent”. Il convient donc de modifier ce fichier afin d’en retirer également les accents. Le fichier de stop-words étant un simple fichier texte, il est aisé de le modifier :

~/loxodata_text$ sudo bash -c "sed -i -e 's/ç/c/g' /opt/pgsql/16/share/tsearch_data/french_custom.stop"

Ensuite, il faut refaire notre ALTER TEXT SEARCH DICTIONARY afin de recharger le fichier de stop-words :

loxodata_text=# alter TEXT SEARCH DICTIONARY french_stem(STOPWORDS = french_custom );
ALTER TEXT SEARCH DICTIONARY
loxodata_text=# select to_tsvector('french_custom','le la les ce ça');
 to_tsvector
-------------

(1 row)

“ça” est donc bien maintenant un stop-word. Il faudrait bien évidemment effectuer la substitution pour chaque type de caractère accentué ( ’s/é/e/g' etc. ) la commande tr ne pouvant être utilisée car elle n’est pas compatible avec l’UTF8.

Synonymes

Il peut être utile dans le cadre de la recherche en texte intégral de disposer d’un dictionnaire de synonymes. Là encore, c’est une configuration spécifique à chaque langue qui doit être effectuée. Bien évidemment le dictionnaire à utiliser dépendra beaucoup de la nature des documents à indexer (documentation technique, juridique, etc.).

Comme pour les stop-words, il faut placer un fichier de synonymes dans le répertoire de configuration tsearch_data. Ce dictionnaire doit obligatoirement avoir le suffixe .syn. J’ai donc ainsi créé un fichier french_custom.syn :

~/loxodata_text$ cat /opt/pgsql/16/share/tsearch_data/french_custom.syn
FISC            DGFIP
domicile        maison
auto            voiture
aimer           adorer
bosser          travailler
copain          ami
joli            beau
étudiant        élève
scrameustache   alien

Le principe étant que le mot situé à gauche sera substitué par celui de droite.

Il nous faut donc créer ce dictionnaire de synonymes :

loxodata_text=# create  TEXT SEARCH DICTIONARY syn_fr (template=synonym, synonyms='french_custom');
CREATE TEXT SEARCH DICTIONARY

Et dans la foulée, tester le bon fonctionnement de ce dictionnaire :

loxodata_text=# select ts_lexize('syn_fr', 'FISC');
 ts_lexize
-----------
 {dgfip}
(1 row)

loxodata_text=# select ts_lexize('syn_fr', 'maison');
 ts_lexize
-----------

(1 row)

loxodata_text=# select ts_lexize('syn_fr', 'domicile');
 ts_lexize
-----------
 {maison}
(1 row)

Il nous reste à modifier le mapping pour les mots afin d’ajouter ce dictionnaire :

loxodata_text=# ALTER TEXT SEARCH CONFIGURATION french_custom ALTER MAPPING FOR asciiword WITH syn_fr,french_stem;
ALTER TEXT SEARCH CONFIGURATION
loxodata_text=# ALTER TEXT SEARCH CONFIGURATION french_custom ALTER MAPPING FOR hword, hword_part, word WITH unaccent,syn_fr, french_stem;
ALTER TEXT SEARCH CONFIGURATION

Puis à en vérifier la prise en compte :

loxodata_text=# select to_tsvector('french_custom','domicile adoré');
     to_tsvector
---------------------
 'ador':2 'maison':1
(1 row)

C’est moins musical, mais cela fonctionne bien.

Limites et dict_xsyn

Le problème, c’est que le dictionnaire des synonymes passe AVANT le stemmer. Nous avons donc encore affaire à des chaînes de caractères, et non pas des lexèmes. Par conséquent “domicile” et “domiciles” sont des mots différents. On pourrait être tenté de faire passer d’abord le stemmer, puis ensuite le dictionnaire de synonymes, mais cela ne fonctionne pas. L’extension dict_xsyn est livrée en standard et répond (au moins partiellement) à ce problème en permettant de faire un dictionnaire de synonymes plus élaboré.

Commençons par créer l’extension :

loxodata_text=# create extension if not exists dict_xsyn;
CREATE EXTENSION

Ensuite il faut placer un fichier .rules, comme pour le fichier de synonymes :

~/loxodata_text$ cat /opt/pgsql/16/share/tsearch_data/french_custom.rules
maison  domicile domiciles
aimer   adore adores adoree adorees adorer adorons adorez adorent
DGFIP   FISC MINEFI
numerique       digital
ko      kb
octet   byte bytes

L’utilisation habituelle de ce fichier est de mettre à gauche un mot, puis ses synonymes à droite. Mais xsyn permet d’inverser ce fonctionnement (la liste de synonymes est la source, et le premier mot est la cible). Là encore, il faut écrire les mots de manière non accentuée puisque unaccent passera AVANT le dictionnaire xsyn. Il faut donc maintenant créer le dictionnaire :

loxodata_text=# CREATE TEXT SEARCH DICTIONARY xsyn_fr (template=xsyn_template, rules='french_custom');

Faisons un rapide test :

loxodata_text=# SELECT ts_lexize('xsyn_fr', 'domicile');
 ts_lexize
-----------

(1 row)

Time: 0,253 ms
loxodata_text=# SELECT ts_lexize('xsyn_fr', 'maison');
      ts_lexize
----------------------
 {domicile,domiciles}
(1 row)

Le résultat est logique vu notre fichier .rules de synonymes, mais c’est en fait exactement l’inverse du but recherché puisque ce que l’on souhaite c’est que “domicile” et “domiciles” soient transformés en “maison”. Mais comme je l’ai dit, des options booléennes sont disponibles pour les dictionnaires xsyn :

  • KEEPORIG : Indique s’il faut mettre en sortie le mot le plus à gauche.
  • MATCHORIG : Indique si la règle est appliquée quand on rencontre le mot le plus à gauche.
  • KEEPSYNONYMS : Indique s’il faut mettre en sortie les synonymes (tous les mots de droite).
  • MATCHSYNONYMS : Indique si la règle est appliquée quand on rencontre un des synonymes (un des mots de droite).

Dans notre cas, nous voulons remplacer par le mot de gauche (original) n’importe quel mot de droite (synonyme), donc :

  • KEEPORIG : True. (Nous voulons garder le mot de gauche).
  • MATCHORIG : False. (Inutile d’appliquer la règle lorsqu’on rencontre le mot de gauche).
  • KEEPSYNONYMS : False. (Nous ne voulons pas garder les mots de droite)
  • MATCHSYNONYMS : True. (La règle est appliquée lorsqu’on rencontre un des mots de droite).
loxodata_text=# ALTER TEXT SEARCH DICTIONARY xsyn_fr (rules='french_custom', KEEPORIG=true, MATCHORIG=false, KEEPSYNONYMS=false, MATCHSYNONYMS=true);
ALTER TEXT SEARCH DICTIONARY

Il nous reste à modifier les mappings pour le traitement des éléments lexicographiques :

loxodata_text=# ALTER TEXT SEARCH CONFIGURATION french_custom ALTER MAPPING FOR asciiword WITH xsyn_fr,french_stem;
ALTER TEXT SEARCH CONFIGURATION
loxodata_text=# ALTER TEXT SEARCH CONFIGURATION french_custom ALTER MAPPING FOR  hword, hword_part, word WITH unaccent,xsyn_fr, french_stem;
ALTER TEXT SEARCH CONFIGURATION

Et à tester le fonctionnement :

loxodata_text=# select to_tsvector('french_custom','domiciles adorés');
     to_tsvector
----------------------
 'aimer':2 'maison':1
(1 row)