Performances du FTS Postgres

Dans cet article, je vous propose une première astuce pour avoir de meilleures performances FTS avec Postgres.

Contrairement à ElasticSearch, Postgres n'optimise pas automatiquement votre schéma. La philosophie est que l'optimisation dépend de l'utilisation de la base : fréquence d'écriture, prédicats dans les requêtes, jointures, etc. À vous d'anticiper l'utilisation de votre base pour concevoir le schéma le plus performant.

Comme vu dans mon introduction à FTS dans Postgres, il faut lemmatiser un texte dans un tsvector pour la recherche plein texte. Lemmatiser est coûteux en cycles CPU, on va donc rapidement se poser la question des performances.

Reprenons notre exemple naïf:

$ docker run --detach --rm --publish 5432:5432 postgres:10-alpine
$ psql -h localhost -U postgres
psql (10.1)
Saisissez « help » pour l'aide.

postgres=# CREATE TABLE textes AS VALUES
('Chuck Norris a gagné la guerre du Golf, en 18 trous.'),
('Google, c''est le seul endroit où tu peux taper Chuck Norris...'),
('Chuck Norris mange ses oranges tout rond: Chuck Norris fait pas de quartier.');
SELECT 3
postgres=#

À première vue, on pourrait se dire : il suffit d'indexer to_tsvector('french', column1). Malheureusement, c'est une fausse piste. Cette solution écrit en dur le choix de la configuration dans l'index. Par exemple, lorsque nous ajouterons des citations en anglais, nous indexerons to_tsvector('french', column1) sur des textes anglais. Cela chargera l'index pour rien.

Et que se passe-t-il si on change la configuration ? L'index est maintenu à l'insertion ou à la mise-à-jour de la table, et non de la configuration. Il faudrait tout réindexer en une fois avec REINDEX. Ça ne donne pas envie.

En fait, il ne faut pas utiliser l'index comme cache de to_tsvector(). Une meilleure solution est mettre en cache le tsvector dans une colonne dédiée.

postgres=# ALTER TABLE textes ADD COLUMN texte_vector tsvector;
ALTER TABLE
postgres=# UPDATE textes SET texte_vector = to_tsvector('french', column1) WHERE texte_vector IS NULL;
UPDATE 3
postgres=#

Notez que cette requête UPDATE peut être réutilisée à la demande pour réindexer la table. On pourra réindexer par tranche, sans arrêter le service. On peut maintenant réécrire notre requête FTS en:

postgres=# SELECT column1 FROM textes WHERE plainto_tsquery('french', 'gagner') @@ texte_vector;
                       column1
------------------------------------------------------
 Chuck Norris a gagné la guerre du Golf, en 18 trous.
(1 ligne)

postgres=#

Ok ça fonctionne sans surprise. Comparons ce que nous dit le planificateur pour les deux variantes:

postgres=# EXPLAIN SELECT column1 FROM textes WHERE plainto_tsquery('french', 'gagner') @@ to_tsvector('french', column1);
                                  QUERY PLAN
------------------------------------------------------------------------------
 Seq Scan on textes  (cost=0.00..241.00 rows=4 width=32)
   Filter: ('''gagn'''::tsquery @@ to_tsvector('french'::regconfig, column1))
(2 lignes)

postgres=# EXPLAIN SELECT column1 FROM textes WHERE plainto_tsquery('french', 'gagner') @@ texte_vector;
                       QUERY PLAN
--------------------------------------------------------
 Seq Scan on textes  (cost=0.00..21.00 rows=4 width=32)
   Filter: ('''gagn'''::tsquery @@ texte_vector)
(2 lignes)

postgres=#

Le coût passe de 241 à 21... pas mal ! Un EXPLAIN ne vaut pas un benchmark, mais ça donne une idée du coût de to_tsvector.

Conclusion

Cette colonne cache réduit sensiblement le coût d'une recherche dans la table sans mener la maintenance de la configuration dans une impasse.

À retenir donc, c'est que stocker un tsvector dans une table est une bonne idée. Même si à première vue on ne voudrait pas copier la donnée.

Deuxième intérêt, un index ne tracera que ce qui est réellement recherché. Nous verrons plus tard comment indexer les tsvector pour plus de performances.