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:

CREATE FUNCTION dictmaxlen_init(internal)
        RETURNS internal
        AS 'MODULE_PATHNAME'
        LANGUAGE C STRICT;

CREATE FUNCTION dictmaxlen_lexize(internal, internal, internal, internal)
        RETURNS internal
        AS 'MODULE_PATHNAME'
        LANGUAGE C STRICT;

CREATE TEXT SEARCH TEMPLATE dictmaxlen_template (
        LEXIZE = dictmaxlen_lexize,
	INIT   = dictmaxlen_init
);

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

Datum
dictmaxlen_init(PG_FUNCTION_ARGS)
{
  List        *options = (List *) PG_GETARG_POINTER(0);
  DictMaxLen  *d;
  ListCell    *l;

  d = (DictMaxLen *) palloc0(sizeof(DictMaxLen));
  d->maxlen = 50;        /* 50 caracteres par defaut */

  foreach(l, options)
  {
    DefElem    *defel = (DefElem *) lfirst(l);

    if (strcmp(defel->defname, "length") == 0)
      d->maxlen = atoi(defGetString(defel));
    else
    {
      ereport(ERROR,
          (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
           errmsg("unrecognized dictionary parameter: \"%s\"",
              defel->defname)));
    }
  }

  PG_RETURN_POINTER(d);
}

Génération des lexèmes

Datum
dictmaxlen_lexize(PG_FUNCTION_ARGS)
{
  DictMaxLen  *d = (DictMaxLen *) PG_GETARG_POINTER(0);
  char        *token = (char *) PG_GETARG_POINTER(1);
  int         byte_length = PG_GETARG_INT32(2);

  if (pg_mbstrlen_with_len(token, byte_length) > d->maxlen)
  {
    /* 
     * Si le mot est plus grand que notre limite, renvoie un
     * tableau sans lexeme.
     */
     TSLexeme   *res = palloc0(sizeof(TSLexeme));
     PG_RETURN_POINTER(res);     
  }
  else
  {
    /* Si le mot est court, on le laisse passer en mode "non reconnu" */
    PG_RETURN_POINTER(NULL);
  }

}

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.

CREATE EXTENSION  dict_maxlen;

CREATE TEXT SEARCH DICTIONARY dictmaxlen (
  TEMPLATE = dictmaxlen_template,
  LENGTH = 40 -- par exemple
);

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:

CREATE TEXT SEARCH CONFIGURATION mytsconf ( COPY = pg_catalog.french );

ALTER TEXT SEARCH CONFIGURATION mytsconf
 ALTER MAPPING FOR asciiword, word
  WITH dictmaxlen,french_stem;

ALTER TEXT SEARCH CONFIGURATION mytsconf
 ALTER MAPPING FOR numword
  WITH dictmaxlen,simple;

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:

=# select to_tsvector('mytsconf', 'Un lexème trop long: Q29uc2lzdGVuY3kgYWxzbyBtZWFucyB0aGF0IHdoZW4');
         to_tsvector         
-----------------------------
 'lexem':2 'long':4 'trop':3
(1 row)

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.