Accueil / Articles PiApplications. / Les bases de données relationelles. / PostgreSQL.

PostgreSQL : recherche FTS.

Recherche FTS signifie ici recherche plein-texte (Full Text Search).

Survol.

Avec la version 9.6, le moteur FTS de PostgreSQL s'enrichit d'une capacité de recherche d'extrait de texte ce qui le rend très attractif. Une recherche FTS suppose plusieurs étapes préalables :

  1. Extraction du texte contenu par un fichier quel que soit son format initial.
  2. Décomposition du texte en lexèmes (ou "jetons").
  3. Indexation des lexèmes avec la référence des fichiers d'où ils sont issus.

La requête elle-même doit respecter un "vocabulaire" et une "syntaxe" qui dépendent du système de recherche utilisé.

PostgreSQL ne fait pas exception à la règle. Sur le premier point, PostgreSQL ne peut rien pour nous, l'extraction de texte est obligatoirement un procédé externe (à moins de l'intégrer en tant qu'extension du moteur).

Pour le second point, le moteur nous fournit la fonction ts_vector. Cette fonction analyse le texte et produit une liste (un vecteur) qui retient la "racine" de chaque mot "utile" en la faisant suivre des indices auxquels on trouve ce mot dans le texte. "Utile" signifie que chaque mot est comparé à une liste de mots constituant le "bruit" du texte (articles, pré-positions, etc.). Ces "bruits" permettent de structurer les phrases en leur donnant du "sens" (sémantique) mais que l'ordinateur ne peut comprendre. Dans ce bruit on trouve aussi les caractères de ponctuation et les séparateurs de mots. La "racine" constitue la base invariante du mot lorsqu'on l'accorde (adjectif, nom, etc.) ou qu'on le conjugue (verbe). Il peut également être substitué à un autre mot au regard d'un dictionnaire de synonymes.

Le premier constat est que l'étape de décomposition du texte en lexèmes est fortement dépendante de la langue utilisée voir du domaine concerné. C'est pour cela que le moteur est livré avec plusieurs configurations. En général la configuration par défaut est soit l'anglais, soit la langue utilisée par le système (cas de l'installateur EntrepriseDB par exemple).

Pour ce qui est du deuxième point, PostgreSQL met à notre disposition plusieurs types d'index qui permettent de trouver très rapidement le ou les vecteurs dont est issu un lexème et notamment les index GIN.

Enfin, pour l'exécution des requêtes de recherche FTS, le moteur fournit 3 fonctions : to_tsquery, plainto_tsquery et phraseto_tsquery. Ces fonctions décompose leurs arguments puis effectue les comparaison en s'appuyant sur l'index. Il est possible d'utiliser l'opérateur @@ qui peut se traduire pas a des correspondances avec. Par exemple SELECT to_tsvector('fat cats ate fat rats') @@ to_tsquery('fat & rat'); signifie en langage naturel : Est-ce que les lexèmes de la phrase "fat cats ate fat rats" ont des correspondances avec la requête 'fat & rat'. Le résultat est un booléen affiché sur la console sous la forme d'une lettre 't' ou 'f'. L'opérateur @@ supporte aussi une entrée de type text, permettant l'oubli de conversions explicites de text vers tsvector ou tsquery dans les cas simples. Les variantes disponibles sont :

Le fait d'obtenir la liste des vecteurs qui correspondent à une requête n'est généralement pas suffisant. On sait que dans la liste résultante, certains vecteurs sont plus "pertinents" que d'autres. Il serait commode d'obtenir une mesure de cette pertinence. Malheureusement la notion de pertinence est une notion essentiellement sémantique tout à fait incompréhensible pour une machine. On substitue alors à la notion de pertinence celle de "score" qui est d'autant plus élevé que la fréquence du ou des mots est élevée dans le texte. Le "score" est donc une mesure statistique et non sémantique ce qui fait que les meilleurs classements ne sont pas toujours les plus pertinents. La fonction ts_rank prend en arguments le vecteur à mesurer et la requête pour délivrer une valeur numérique qui représente la fréquence des lexèmes de la requête dans el vecteur.

PostgreSQL est également capable d'affecter des poids (lettre de A à D) aux lexèmes extrait d'un texte via la fonction setweight. Ces poids, lorsqu'il sont affectés aux lexèmes modifient le score des résultats.

Lorsque l'on souhaite utiliser le moteur FTS de PostgreSQL, la première chose à faire et de vérifier la configuration par défaut du moteur. Pour cela, on recherche dans le fichier postgresql.conf la variable default_text_search_config. Pour un fonctionnement par défaut en langue française, le configuration par défaut est 'pg_catalog.french'. Notez que le commutateur \dF retourne la liste des configurations intégrées au moteur (de base, il y en a généralement plus d'une quinzaine). Si on enrichi ce commutateur avec le symbole + suivi de la langue (\dF+ french par exemple), on obtient le détail de la configuration considéré. En ajoutant le commutateur p sans mention de langue (\dFp+ par exemple) on obtient la liste des différents types de jetons supportés par la configuration par défaut. Cela signifie que lorsqu'un fragment de texte correspond à un type de jeton, il sera directement traduit en lexème (adresse courriel par exemple).

Exemple d'utilisation.

Nous souhaitons utiliser le moteur FTS de Postgres pour effectuer des recherches sur des fichiers dont les méta-données sont enregistrée dans une table nommée t_document. Cette table contient les colonnes c_sender, c_subject, c_referencec_lang et c_content qui décrivent le document. La colonne c_content de type text contient le texte extrait du fichier par un moyen externe. Nous souhaitons utiliser la recherche FTS sur le contenu (c_content, l'objet c_subject) et la référence publique (c_reference).

Lors de l'insertion d'un nouveau tuple, on pourrait imaginer appliquer la fonction ts_vector sur chaque donnée des colonnes à enregistrer puis de mettre à jour la colonne c_lexeme en fonction des résultats. Le même procédé devrait ensuite être appliqué lors des mises à jour. Ce n'est pas très performant !

Idéalement, il faudrait que lors de chaque insertion ou mise à jour concernant une des colonnes concernés, la mise à jour de la colonne c_lexeme se fasse automatiquement. C'est exactement ce que peut nous offrir un trigger (déclencheur). PostgreSQL dispose même d'une procédure stockée pour en faciliter la définition :

CREATE TRIGGER trg_document_lexeme
  BEFORE INSERT OR UPDATE ON t_document
    FOR EACH ROW
      EXECUTE PROCEDURE tsvector_update_trigger('c_lexeme','pg_catalog.french','c_sender','c_subject','c_reference','c_content');

L'inconvénient de cette définition est que les lexèmes de chaque colonne ont les même poids. Généralement, les lexèmes ont une importance décroissante en fonction de l'objet, de l'émetteur, de la référence et enfin du contenu. Pour intégrer ces importances relatives, il nous faut affecter des poids aux lexèmes des colonnes via un code du style. Cela se fait en substituant à la procédure stockée notre propre procédure (nommée ici fnc_weights) :

CREATE FUNCTION fnc_weights() RETURNS trigger AS $$
begin
  new._clexeme := setweight(to_tsvector('pg_catalog.french', coalesce(new.c_subject,'')), 'A') ||
                  setweight(to_tsvector('pg_catalog.french', coalesce(new.c_sender,'')), 'B') ||
                  setweight(to_tsvector('pg_catalog.french', coalesce(new.c_reference,'')), 'C') ||
                  setweight(to_tsvector('pg_catalog.french', coalesce(new.c_content,'')), 'D');
  return new;
end
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_document_lexeme
  BEFORE INSERT OR UPDATE ON t_document
    FOR EACH ROW
      EXECUTE PROCEDURE fnc_weights();

La requête SQL :

INSERT INTO t_document(c_id,c_sender,c_subject,c_reference,c_content)
  VALUES
    (1,'Jean-Marie et Nadine','Document lié à un éléphant.','123/456/DEF/DAUPHIN','Un éléphant bleu saute au-dessus du malheureux dauphin.');

alimente directement la colonne c_lexeme via le trigger qui lui est associé :

'123/456/def/dauphin':11C 'au-dessus':17 'bleu':15 'dauphin':22 'dessus':19 'docu':1A 'jean':7B 'jean-mar':6B 'li':2A 'malheur':21 \
'mar':8B 'nadin':10B 'phant':5A,14 'saut':16

On constate que la référence a été prise comme un seul lexème donc le lexème associé à 'dauphin' n'apparaît qu'un seule fois. On voit également que les 'poids' ont été affectés aux lexèmes en fonction de leur origine dans les colonnes. On peut également observer que le mot 'éléphant' produit la racine 'phant' via la configuration par défaut. On devine par ailleurs que se mot de trouve en 5 ème position dans l'objet (5A) et en 14ème position (14) dans le contenu. S cela est vrai pour l'objet, cela est faux pour le contenu où il est en première position. On constate donc que l'analyse porte sur la concaténation des colonnes en un seul texte. La concaténation est effectuée dans l'ordre déclaré dans la fonction associée au trigger.

Pour améliorer de façon significative les performances ultérieures des recherches, il est nécessaire d'indexer notre colonne c_lexeme. Notez que cette indexation n'est toutefois pas obligatoire.

Il existe deux sortes d'index : GIN et GiST. Les index GIN sont le type d'index préféré pour la recherche plein texte. En tant qu'index inversé, ils contiennent une entrée d'index pour chaque mot (lexème), avec une liste compressée des emplacements correspondants. Les recherches multi-mots peuvent trouver la première correspondance, puis utiliser l'index pour supprimer les lignes qui ne disposent pas des autres mots recherchés. Les index GIN stockent uniquement les mots (lexèmes) des valeurs de type tsvector, et non pas les labels de poids. De ce fait, une vérification de la ligne de table est nécessaire quand une recherche implique les poids. Toute colonne d'un index GIN doit être de type tsvector.

Un index GiST est à perte, signifiant que l'index peut produire des faux positifs et il est nécessaire de vérifier la ligne de la table pour les éliminer. PostgreSQL le fait automatiquement si nécessaire. Les index GiST sont à perte car chaque document est représenté dans l'index par une signature à longueur fixe. La signature est générée par le hachage de chaque mot en un bit aléatoire dans une chaîne à n bit, tous ces bits étant assemblés dans une opération OR qui produit une signature du document sur n bits. Quand deux hachages de mots sont identiques, nous avons un faux positif. Si tous les mots de la requête ont une correspondance (vraie ou fausse), alors la ligne de la table doit être récupérée pour voir si la correspondance est correcte. Une colonne d'un index GiST peut être de type tsvector ou tsquery.

Notez que le temps de construction de l'index GIN peut souvent être amélioré en augmentant maintenance_work_memalors qu'un index GiST n'est pas sensible à ce paramètre.

Dans notre cas, nous allons créer un index GIN :

CREATE INDEX idx_document_lexeme ON t_document USING gin (c_lexeme)

Un certain nombre d'extensions peuvent également être installées pour faciliter les recherches plein-texte : dict_xsyn, pg_trm et unaccent. D'autres une autre extension peut être utile s'il est nécessaire de calculer la similarité et la distance (Levenshtein) entre chaînes (fuzzystrmatch).

On peut ensuite effectuer des recherches via des requêtes spécifiques du style :

SELECT c_id,c_subject,ts_rank(c_lexeme, query, 32) AS c_rank
  FROM t_document,to_tsquery('french', 'dauphin') query
    WHERE (c_lexeme @@ query) ORDER BY c_rank DESC;

La base de données PostgreSQL est extrêmement éprouvée et sa longévité est le signe même de sa robustesse et de sa capacité à évoluer. Elle offre de nombreux autres moyens d'enrichir la recherche plein texte par la mise en place de dictionnaires, de dictionnaire de synonymes, et de thesaurus. Si cela ne suffit pas vous pouvez encore spécialiser vos recherches en dotant la base de données d'extensions sur mesure (à réaliser en langage C). Dans notre exemple simple et généraliste, nous n'avons pas besoin de recourir à une forme aussi évoluée d'analyse et de recherche.

(c) PiApplications 2015