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)