Recherche textuelle: un dictionnaire personnalisé pour affiner l'index
La recherche plein texte se base sur une transformation
en type tsvector
du texte brut initial. Par exemple:
test=> select to_tsvector('french', 'Voici notre texte à indexer.');
to_tsvector
-----------------------------
'index':5 'text':3 'voic':1
Ce résultat est une suite ordonnée de lexèmes, avec leurs positions relatives dans le texte initial, qui est obtenue schématiquement par cette chaîne de traitement:
Texte brut => Analyseur lexical (parser) => Dictionnaires configurables => tsvector
Dès qu’on a un volume significatif, on indexe aussi ces vecteurs avec un index GIN ou GIST pour accélérer les recherches.
En SQL on peut inspecter le travail de cette chaîne dans le détail
avec la fonction ts_debug
:
test=> select * from ts_debug('french', 'Voici notre texte à indexer.');
alias | description | token | dictionaries | dictionary | lexemes
-----------+-------------------+---------+---------------+-------------+---------
asciiword | Word, all ASCII | Voici | {french_stem} | french_stem | {voic}
blank | Space symbols | | {} | |
asciiword | Word, all ASCII | notre | {french_stem} | french_stem | {}
blank | Space symbols | | {} | |
asciiword | Word, all ASCII | texte | {french_stem} | french_stem | {text}
blank | Space symbols | | {} | |
word | Word, all letters | à | {french_stem} | french_stem | {}
blank | Space symbols | | {} | |
asciiword | Word, all ASCII | indexer | {french_stem} | french_stem | {index}
blank | Space symbols | . | {} | |
L’analyseur lexical a découpé le texte en jetons (colonne token), qui ont chacun un type (colonne alias). Puis ces jetons, en fonction de leurs types, sont soumis en entrée à des dictionnaires, qui en ressortent des lexèmes, qui peuvent être vides pour éliminer le terme du vecteur final.
Dans l’exemple ci-dessus, les espaces et la ponctuation ont été
éliminés parce qu’ils ne sont pas associés à un dictionnaire, les
termes communs (“à”, “notre”) ont été éliminés par le dictionnaire
french_stem
, et “Voici”, “texte” et “indexer” ont été
normalisés et réduits à leur racine supposée par ce même dictionnaire.
Quid du contenu indésirable?
Parfois on a affaire à du texte brut qui n’est pas “propre”, qui contient des bribes de texte parasites. Par exemple dans l’indexation de messages e-mail, des messages mal formatés peuvent laisser apparaître des parties encodées en base64 dans ce qui devrait être du texte en clair. Et quand il s’agit d’une pièce jointe, ça peut occuper une place considérable. Si on examine le passage d’une ligne de contenu de ce genre dans la chaîne de traitement plein texte, voici ce qu’on peut voir:
=# \x
=# select * from ts_debug('french', 'Q29uc2lzdGVuY3kgYWxzbyBtZWFucyB0aGF0IHdoZW4gQWxpY2UgYW5kIEJvYiBhcmUgcnVubmlu');
-[ RECORD 1 ]+-------------------------------------------------------------------------------
alias | numword
description | Word, letters and digits
token | Q29uc2lzdGVuY3kgYWxzbyBtZWFucyB0aGF0IHdoZW4gQWxpY2UgYW5kIEJvYiBhcmUgcnVubmlu
dictionaries | {simple}
dictionary | simple
lexemes | {q29uc2lzdgvuy3kgywxzbybtzwfucyb0agf0ihdozw4gqwxpy2ugyw5kiejvyibhcmugcnvubmlu}
Ce texte est donc analysé comme un seul jeton de type numword, et produit un seul long lexème, vu que ce jeton est associé au dictionnaire simple qui se contente de convertir le terme en minuscules. Se pose la question de savoir si on ne pourrait pas éviter de polluer le vecteur avec ces termes inutiles.
Une idée simple est de considérer que les jetons de grande taille sont inintéressants et supprimables systématiquement. Même s’il y a des mots exceptionnellement longs dans certaines langues comme l’allemand Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz avec ses 63 caractères (!), on peut imaginer se fixer une limite de taille au-delà de laquelle la probabilité que le mot soit un parasite est suffisante pour l’éliminer de l’espace de recherche.
Filtrage des mots par leur taille
Une solution relativement facile à mettre en oeuvre est de créer un dictionnaire qui
va filtrer ces termes comme si c’étaient des “stop words”.
Ce dictionnaire prend concrètement la forme de deux fonctions à
écrire en langage C, et de quelques ordres SQL pour déclarer et assigner notre
nouveau dictionnaire à une configuration de recherche
(ALTER TEXT SEARCH CONFIGURATION
).
Dans le code source de PostgreSQL, il y a plusieurs exemples de dictionnaires dont on peut s’inspirer:
- dict_simple qui est installé de base.
- dict_int, module additionnel (contrib) pour éliminer les nombres au-delà d’un certain nombre de chiffres, très proche de ce qu’on cherche ici.
- dict_xsyn, module additionnel qui permet de déclarer des synonymes, pour générer plusieurs lexèmes à partir d’un seul terme.
- unaccent, module additionnel qui expose la suppression d’accents en tant que fonction SQL, mais aussi en dictionnaire filtrant, c’est-à-dire qui peut injecter un lexème passé aux dictionnaires d’après.
On peut démarrer en copier-collant un de ces exemples, car la partie vraiment distinctive de notre dictionnaire personnalisé tout simple s’exprime en seulement quelques lignes en C, comme on va le voir plus bas.
Les deux fonctions à produire sont:
-
une fonction d’initialisation
INIT
qui reçoit les paramètres de configuration du dictionnaire. On en profite pour rendre notre longueur maximale configurable via ce mécanisme, plutôt que de la coder en dur dans le source. -
une fonction
LEXIZE
reçevant un terme (le texte associé au jeton) et chargée d’émettre zéro, un ou plusieurs lexèmes correspondant à ce terme. Les lexèmes émis peuvent être passés ou non au reste des dictionnaires de la chaîne s’il y en a d’autres, au choix de cette fonction. Dans le cas qui nous intéresse ici, on veut éliminer le terme s’il est trop long, et sinon le passer tel quel.
On va appeler ce dictionnaire dictmaxlen
et son paramètre length
.
En suivant le modèle et les conventions des modules additionnels de
contrib/
, on peut l’encapsuler dans une
extension Postgres, dans un répertoire dédié avec un Makefile et
un fichier de déclaration SQL spécifiques.
Pour être précis dans la terminologie, ces définitions créent un
modèle de dictionnaire (template) et non directement un dictionnaire.
Le dictionnaire à proprement parler est instancié à partir d’un modèle
par CREATE TEXT SEARCH DICTIONARY (TEMPLATE = ...)
, avec les valeurs
des éventuels paramètres.
Voici les déclarations SQL des fonctions et du modèle:
La seule chose spécifique ici est la racine de nommage dictmaxlen
, sinon
ce seront les mêmes déclarations pour n’importe quel modèle de dictionnaire.
Fonctions en C
Instantiation du dictionnaire
Génération des lexèmes
Encapsulation dans une extension
Comme pour la plupart des modules de contrib, l’empaquetage de ce code dans une extension est la manière la plus efficace pour le distribuer et le déployer.
La création d’une extension est assez simple en utilisant PGXS,
qui est fourni avec Postgres (pour les distributions Linux, il faudra installer
un paquet de développement, comme postgresql-server-dev-11
pour Debian).
Une extension a besoin d’un fichier de contrôle. Le contenu
ci-dessous fait l’affaire (fichier dict_maxlen.control
):
# dict_maxlen extension
comment = 'text search template for a dictionary filtering out long words'
default_version = '1.0'
module_pathname = '$libdir/dict_maxlen'
relocatable = true
Grâce à PGXS on peut se contenter d’un Makefile simplifié qui va
inclure automatiquement les déclarations pour les chemins où
Postgres est installé, les bibliothèques dont il a besoin, etc.
Ci-dessous un Makefile complètement fonctionnel, qui permet de compiler
et installer l’extension avec make && make install
:
EXTENSION = dict_maxlen
EXTVERSION = 1.0
PG_CONFIG = pg_config
MODULE_big = dict_maxlen
OBJS = dict_maxlen.o
DATA = $(wildcard *.sql)
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
Utilisation
Une fois les fichiers de l’extension compilés et installés, on peut la créer dans une base et y instantier le dictionnaire.
Ensuite il faut associer ce dictionnaire à des types de jetons (produits
par l’analyseur lexical), via ALTER TEXT SEARCH CONFIGURATION ... ALTER MAPPING
.
Plus haut on a vu sur un exemple que le type de jeton qui ressortait sur
du contenu encodé était numword
, mais on peut aussi vouloir
associer notre dictionnaire aux jetons ne contenant que des lettres:
word
et asciiword
, ou à n’importe quels autres des 23 types de
jeton
que l’analyseur peut générer actuellement.
Avec psql
on peut visualiser ces associations avec \dF+
. Pour french
,
par défaut on a:
=# \dF+ french
Text search configuration "pg_catalog.french"
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
On peut créer une configuration de texte spécifique, pour éviter de perturber celles qui existent déjà, et lui associer ce dictionnaire:
Vérification faite avec psql:
=# \dF+ mytsconf
Text search configuration "public.mytsconf"
Parser: "pg_catalog.default"
Token | Dictionaries
-----------------+------------------------
asciihword | french_stem
asciiword | dictmaxlen,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 | dictmaxlen,simple
sfloat | simple
uint | simple
url | simple
url_path | simple
version | simple
word | dictmaxlen,french_stem
Et voilà, il n’y a plus qu’à vérifier avec un numword dépassant les 40 caractères:
Le jeton indésirable a bien été écarté.
En peut utiliser cette configuration mytsconf
en la passant en
argument explicite aux fonctions de recherche et indexation plein
texte, mais aussi la mettre par défaut:
Pour la session:
SET default_text_search_config TO 'mytsconf';
Pour la base (permanent):
ALTER DATABASE nombase SET default_text_search_config TO 'mytsconf';
Le code source de cet exemple de dictionnaire est disponible sur github.