sql: new FTS mechanism with fuzzy match #1201

Merged
pducroquet merged 3 commits from wip/86527-better-fts into main 2024-04-29 09:27:59 +02:00
6 changed files with 266 additions and 3 deletions

View File

@ -429,6 +429,9 @@ def test_backoffice_submission_formdef_list_search(pub, local_user, access, auth
resp = get_url('/api/formdefs/?backoffice-submission=on&q=test')
assert len(resp.json['data']) == 2
resp = get_url('/api/formdefs/?backoffice-submission=on&q=tes')
assert len(resp.json['data']) == 2
resp = get_url('/api/formdefs/?backoffice-submission=on&q=xyz')
assert len(resp.json['data']) == 0
@ -441,6 +444,15 @@ def test_backoffice_submission_formdef_list_search(pub, local_user, access, auth
resp = get_url('/api/formdefs/?backoffice-submission=on&q=abc')
assert len(resp.json['data']) == 2
FormDef.wipe()
formdef = FormDef()
formdef.name = 'Intervention du service hygiène, salubrité et environnement'
formdef.backoffice_submission_roles = [role.id]
formdef.fields = []
formdef.store()
resp = get_url('/api/formdefs/?backoffice-submission=on&q=salubrité')
assert len(resp.json['data']) == 1
def test_formdef_schema(pub, access):
Workflow.wipe()

View File

@ -1185,6 +1185,51 @@ def test_sql_criteria_fts(pub):
assert data_class.select([st.FtsMatch(formdata1.id_display)])[0].id_display == formdata1.id_display
def test_search_tokens_purge(pub):
_, cur = sql.get_connection_and_cursor()
def token_exists(token):
cur.execute('SELECT count(*) FROM wcs_search_tokens WHERE token=%s;', (token,))
return cur.fetchone()[0] == 1
# purge garbage from other tests
FormDef.wipe()
sql.purge_obsolete_search_tokens()
# make sure the existing situation is clean for the test
assert not (token_exists('tableselectftstoken'))
assert not (token_exists('foofortokensofcours'))
assert not (token_exists('chaussettefortokensofcours'))
# define a new table
test_formdef = FormDef()
test_formdef.name = 'tableSelectFTStokens'
test_formdef.fields = [fields.StringField(id='3', label='string')]
test_formdef.store()
data_class = test_formdef.data_class(mode='sql')
assert token_exists('tableselectftstoken')
t = data_class()
t.data = {'3': 'foofortokensofcourse'}
t.just_created()
t.store()
assert token_exists('foofortokensofcours')
t.data = {'3': 'chaussettefortokensofcourse'}
t.store()
# one additional element
assert token_exists('foofortokensofcours')
assert token_exists('chaussettefortokensofcours')
sql.purge_obsolete_search_tokens()
assert not (token_exists('foofortokensofcours'))
assert token_exists('chaussettefortokensofcours')
def table_exists(cur, table_name):
cur.execute(
'''SELECT COUNT(*) FROM information_schema.tables

View File

@ -497,6 +497,7 @@ class WcsPublisher(QommonPublisher):
for _formdef in FormDef.select() + CardDef.select():
sql.do_formdef_tables(_formdef)
sql.migrate_global_views(conn, cur)
sql.init_search_tokens()
cur.close()
def record_deprecated_usage(self, *args, **kwargs):

View File

@ -713,6 +713,11 @@ class QommonPublisher(Publisher):
for error in self.loggederror_class.select(clause=clauses):
self.loggederror_class.remove_object(error.id)
def clean_search_tokens(self, **kwargs):
from wcs import sql
sql.purge_obsolete_search_tokens()
@classmethod
def register_cronjobs(cls):
cls.register_cronjob(CronJob(cls.clean_sessions, minutes=[0], name='clean_sessions'))
@ -725,6 +730,9 @@ class QommonPublisher(Publisher):
cls.register_cronjob(
CronJob(cls.clean_loggederrors, hours=[3], minutes=[0], name='clean_loggederrors')
)
cls.register_cronjob(
CronJob(cls.clean_search_tokens, weekdays=[0], hours=[1], minutes=[0], name='clean_search_tokens')
)
_initialized = False

View File

@ -96,6 +96,20 @@ SQL_TYPE_MAPPING = {
}
def _table_exists(cur, table_name):
cur.execute('SELECT 1 FROM pg_class WHERE relname = %s', (table_name,))
pducroquet marked this conversation as resolved Outdated

Je profite de l'occasion pour demander le sens des point-virgules en fin de ligne; ça n'est pas quelque chose que je mettais, est-ce important ?

Je profite de l'occasion pour demander le sens des point-virgules en fin de ligne; ça n'est pas quelque chose que je mettais, est-ce important ?

Vieille habitude de ma part, ça ne change rien normalement, je peux le virer si tu préfères.

Vieille habitude de ma part, ça ne change rien normalement, je peux le virer si tu préfères.

Non ok pour moi, j'étais juste curieux de peut-être un effet.

Non ok pour moi, j'étais juste curieux de peut-être un effet.
rows = cur.fetchall()
return len(rows) > 0
def _trigger_exists(cur, table_name, trigger_name):
cur.execute(
'SELECT 1 FROM pg_trigger WHERE tgrelid = %s::regclass AND tgname = %s', (table_name, trigger_name)
)
rows = cur.fetchall()
return len(rows) > 0
class LoggingCursor(psycopg2.extensions.cursor):
# keep track of (number of) queries, for tests and cron logging and usage summary.
queries = None
@ -1627,6 +1641,8 @@ def do_global_views(conn, cur):
% (name, category.id)
)
init_search_tokens_triggers(cur)
def clean_global_views(conn, cur):
# Purge of any dead data
@ -1719,11 +1735,182 @@ def init_global_table(conn=None, cur=None):
endpoint_status=endpoint_status_filter,
)
)
init_search_tokens_data(cur)
if own_conn:
cur.close()

Je crois totalement que ça rend l'affaire plus simple à écrire mais je ne trouve pas de référence à tsquery_or dans la documentation de postgresql. Peut-être que ça serait utile d'expliciter ici en commentaire ce que font tsquery_agg_or et tsquery_agg_and.

Je crois totalement que ça rend l'affaire plus simple à écrire mais je ne trouve pas de référence à tsquery_or dans la documentation de postgresql. Peut-être que ça serait utile d'expliciter ici en commentaire ce que font tsquery_agg_or et tsquery_agg_and.

Documentation améliorée avec info sur l'origine des fonctions dans PG, dis moi si c'est mieux

Documentation améliorée avec info sur l'origine des fonctions dans PG, dis moi si c'est mieux
def init_search_tokens(conn=None, cur=None):
"""Initialize the search_tokens mechanism.
It's based on three parts:
- a token table
- triggers to feed this table from the tsvectors used in the database
- a search function that will leverage these tokens to extend the search query.
So far, the sources used are wcs_all_forms and searchable_formdefs.
Example: let's say the sources texts are "Tarif d'école" and "La cantine".
This gives the following tsvectors: ('tarif', 'écol') and ('cantin')
Our tokens table will have these three words.
When the search function is launched, it splits the search query and will
replace unavailable tokens by those close, if available.
The search query 'tari' will be expanded to 'tarif'.
The search query 'collège' will remain unchanged (and return nothing)
If several tokens match or are close enough, the query will be expanded to

Ok, donc sur un formdef ("Intervention du service hygiène, salubrité et environnement lorem ipsum plop plop plop") qui a été indexé avec :

'environ':7A 'hygien':4A 'intervent':1A 'ipsum':9A 'lorem':8A 'plop':10A,11A,12A 'salubrit':5A 'servic':3A

Il y a un match sur hien j'imagine parce que hygien, parce que match "partial", qui se voit ainsi :

# select * from wcs_search_tokens WHERE token % 'hien';
 token
--------
 chien
 hygien

Je n'ai pas trouvé de description de l'opérateur % dans la documentation de postgresql. (pas le caractère le plus facile à chercher, certes).


Sinon dans le code je n'arrive pas à suivre ce qu'est w, je n'arrive même pas à dire si c'est tout le temps la même chose. J'encouragerais à allonger un peu le nom de cette variable, par exemple si c'était "token" tout le temps (si ça a du sens) ça pourrait être plus clair, modulo confusion avec le nom de la colonne, donc allonger encore, searched_token.


À reprendre pour essayer de comprendre comment tout ça fonctionne, je n'explique pas ceci, toujours ma démarche "Intervention du service hygiène, salubrité et environnement ..."

 select * from wcs_search_tokens where token like 'salub%';
  token
----------
 salubrit

mais

select wcs_tsquery('salubrite');
 wcs_tsquery
-------------
 'salubr'

vs

select plainto_tsquery('salubrite');
 plainto_tsquery
-----------------
 'salubrit'

(l'indexation et la recherche lancée depuis wcs ramènent à de l'ascii, cf FtsMatch.get_fts_value())

En suivant, ça semble venir du dernier cas ("otherwise..."), plainto_tsquery(w), qui serait appelé avec "salubrit", ce qui le raccourcit à nouveau et tombe sur

auquo_local=# select wcs_tsquery('salubrit');
   wcs_tsquery
------------------
 'sal' | 'salubr'

Je ne sais que faire de tout ça mais j'espère que ça peut t'aider à voir quelque chose.

Ok, donc sur un formdef ("Intervention du service hygiène, salubrité et environnement lorem ipsum plop plop plop") qui a été indexé avec : > 'environ':7A 'hygien':4A 'intervent':1A 'ipsum':9A 'lorem':8A 'plop':10A,11A,12A 'salubrit':5A 'servic':3A Il y a un match sur hien j'imagine parce que hygien, parce que match "partial", qui se voit ainsi : ``` # select * from wcs_search_tokens WHERE token % 'hien'; token -------- chien hygien ``` Je n'ai pas trouvé de description de l'opérateur % dans la documentation de postgresql. (pas le caractère le plus facile à chercher, certes). ---- Sinon dans le code je n'arrive pas à suivre ce qu'est w, je n'arrive même pas à dire si c'est tout le temps la même chose. J'encouragerais à allonger un peu le nom de cette variable, par exemple si c'était "token" tout le temps (si ça a du sens) ça pourrait être plus clair, modulo confusion avec le nom de la colonne, donc allonger encore, searched_token. ---- À reprendre pour essayer de comprendre comment tout ça fonctionne, je n'explique pas ceci, toujours ma démarche "Intervention du service hygiène, salubrité et environnement ..." ``` select * from wcs_search_tokens where token like 'salub%'; token ---------- salubrit ``` mais ``` select wcs_tsquery('salubrite'); wcs_tsquery ------------- 'salubr' ``` vs ``` select plainto_tsquery('salubrite'); plainto_tsquery ----------------- 'salubrit' ``` (l'indexation et la recherche lancée depuis wcs ramènent à de l'ascii, cf FtsMatch.get_fts_value()) En suivant, ça semble venir du dernier cas ("otherwise..."), plainto_tsquery(w), qui serait appelé avec "salubrit", ce qui le raccourcit à nouveau et tombe sur ``` auquo_local=# select wcs_tsquery('salubrit'); wcs_tsquery ------------------ 'sal' | 'salubr' ``` Je ne sais que faire de tout ça mais j'espère que ça peut t'aider à voir quelque chose.

J'ai corrigé le w en word dans la requête, pour ne pas confondre avec un token de recherche justement (c'est avant la transformation finalement), et supprimé un order by inutile en l'état. J'ai ajouté un lien vers la documentation de pgtrgm pour l'opérateur %.

Concernant ton problème, je n'arrive pas à le reproduire, je cherche

J'ai corrigé le w en word dans la requête, pour ne pas confondre avec un token de recherche justement (c'est avant la transformation finalement), et supprimé un order by inutile en l'état. J'ai ajouté un lien vers la documentation de pgtrgm pour l'opérateur %. Concernant ton problème, je n'arrive pas à le reproduire, je cherche
an OR.
"""
own_cur = False
if cur is None:
own_cur = True
conn, cur = get_connection_and_cursor()
# Create table
cur.execute('CREATE TABLE IF NOT EXISTS wcs_search_tokens(token TEXT PRIMARY KEY);')
# Create triggers
init_search_tokens_triggers(cur)
# Fill table
init_search_tokens_data(cur)
# Index at the end, small performance trick... not that useful, but it's free...
cur.execute('CREATE EXTENSION IF NOT EXISTS pg_trgm;')
cur.execute(
'CREATE INDEX IF NOT EXISTS wcs_search_tokens_trgm ON wcs_search_tokens USING gin(token gin_trgm_ops);'
)
# And last: functions to use this brand new table
# These two aggregates make the search query far simpler to write, allowing writing an OR/AND of search terms
# directly as an SQL aggregation.
# They use the tsquery_or and tsquery_and functions that are included in PostgreSQL since 8.3, but documented
# under their operator names || and &&.
cur.execute('CREATE OR REPLACE AGGREGATE tsquery_agg_or (tsquery) (sfunc=tsquery_or, stype=tsquery);')
cur.execute('CREATE OR REPLACE AGGREGATE tsquery_agg_and (tsquery) (sfunc=tsquery_and, stype=tsquery);')
cur.execute(
r"""CREATE OR REPLACE FUNCTION public.wcs_tsquery(text)
RETURNS tsquery
LANGUAGE sql
STABLE
AS $function$
WITH
tokenized AS (SELECT unnest(regexp_split_to_array($1, '\s+')) word),
super_tokenized AS (
-- perfect: tokens that are found as is in table, thus no OR required
pducroquet marked this conversation as resolved Outdated

À regarder le commit dans mon terminal je vois qu'il y a des lignes qui comme celle-ci se terminent par un caractère espace; il faudrait les retirer. (j'imagine qu'on pourrait trouver un hook pre-commit pour assurer ça).

À regarder le commit dans mon terminal je vois qu'il y a des lignes qui comme celle-ci se terminent par un caractère espace; il faudrait les retirer. (j'imagine qu'on pourrait trouver un hook pre-commit pour assurer ça).
-- partial: tokens found using distance search on tokens table (note: numbers are excluded here)
-- distance search is done using pg_trgm, https://www.postgresql.org/docs/current/pgtrgm.html
-- otherwise: token as is and likely no search result later
SELECT word,
coalesce((select perfect.token::tsquery FROM wcs_search_tokens AS perfect WHERE perfect.token = (tsvector_to_array(to_tsvector(word)))[1]),
tsquery_agg_or(partial.token::tsquery),
plainto_tsquery(word)) AS tokens
FROM tokenized
LEFT JOIN wcs_search_tokens AS partial ON partial.token % plainto_tsquery('simple', word)::text AND word not similar to '%[0-9]{2,}%'
GROUP BY word)
SELECT tsquery_agg_and(tokens) FROM super_tokenized;
$function$;"""
)
if own_cur:
cur.close()
def init_search_tokens_triggers(cur):
# We define only appending triggers, ie on INSERT and UPDATE.
# It would be far heavier to maintain deletions here, and having extra data has
# no or marginal side effect on search performances, and absolutely no impact
# on search results.
# Instead, a weekly cron job will delete obsolete entries, thus making it sure no
# personal data is kept uselessly.
# First part: the appending function
cur.execute(
"""CREATE OR REPLACE FUNCTION wcs_search_tokens_trigger_fn ()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
INSERT INTO wcs_search_tokens SELECT unnest(tsvector_to_array(NEW.fts)) ON CONFLICT(token) DO NOTHING;
RETURN NEW;
END;
$function$;"""
)
if not (_table_exists(cur, 'wcs_search_tokens')):
# abort trigger creation if tokens table doesn't exist yet
return
if _table_exists(cur, 'wcs_all_forms') and not _trigger_exists(
cur, 'wcs_all_forms', 'wcs_all_forms_fts_trg_upd'
):
# Second part: insert and update triggers for wcs_all_forms
cur.execute(
"""CREATE TRIGGER wcs_all_forms_fts_trg_ins
AFTER INSERT ON wcs_all_forms
FOR EACH ROW WHEN (NEW.fts IS NOT NULL)
EXECUTE PROCEDURE wcs_search_tokens_trigger_fn();"""
)
cur.execute(
"""CREATE TRIGGER wcs_all_forms_fts_trg_upd
AFTER UPDATE OF fts ON wcs_all_forms
FOR EACH ROW WHEN (NEW.fts IS NOT NULL)
EXECUTE PROCEDURE wcs_search_tokens_trigger_fn();"""
)
if _table_exists(cur, 'searchable_formdefs') and not _trigger_exists(
cur, 'searchable_formdefs', 'searchable_formdefs_fts_trg_upd'
):
# Third part: insert and update triggers for searchable_formdefs
cur.execute(
"""CREATE TRIGGER searchable_formdefs_fts_trg_ins
AFTER INSERT ON searchable_formdefs
FOR EACH ROW WHEN (NEW.fts IS NOT NULL)
EXECUTE PROCEDURE wcs_search_tokens_trigger_fn();"""
)
cur.execute(
"""CREATE TRIGGER searchable_formdefs_fts_trg_upd
AFTER UPDATE OF fts ON searchable_formdefs
FOR EACH ROW WHEN (NEW.fts IS NOT NULL)
EXECUTE PROCEDURE wcs_search_tokens_trigger_fn();"""
)
def init_search_tokens_data(cur):
if not (_table_exists(cur, 'wcs_search_tokens')):
# abort table data initialization if tokens table doesn't exist yet
return
if _table_exists(cur, 'wcs_all_forms'):
cur.execute(
"""INSERT INTO wcs_search_tokens
SELECT unnest(tsvector_to_array(fts)) FROM wcs_all_forms
ON CONFLICT(token) DO NOTHING;"""
)
if _table_exists(cur, 'searchable_formdefs'):
cur.execute(
"""INSERT INTO wcs_search_tokens
SELECT unnest(tsvector_to_array(fts)) FROM searchable_formdefs
ON CONFLICT(token) DO NOTHING;"""
)
def purge_obsolete_search_tokens(cur=None):
own_cur = False
if cur is None:
own_cur = True
_, cur = get_connection_and_cursor()
cur.execute(
"""DELETE FROM wcs_search_tokens
WHERE token NOT IN (SELECT unnest(tsvector_to_array(fts)) FROM wcs_all_forms)
AND token NOT IN (SELECT unnest(tsvector_to_array(fts)) FROM searchable_formdefs);"""
)
if own_cur:
cur.close()
class SqlMixin:
_table_name = None
_numerical_id = True
@ -4897,7 +5084,6 @@ class SearchableFormDef(SqlMixin):
% (cls._table_name, cls._table_name)
)
cls.do_indexes(cur)
cur.close()
from wcs.carddef import CardDef
from wcs.formdef import FormDef
@ -4906,6 +5092,8 @@ class SearchableFormDef(SqlMixin):
CardDef.select(ignore_errors=True), FormDef.select(ignore_errors=True)
):
cls.update(obj=objectdef)
init_search_tokens(cur)
cur.close()
@classmethod
def update(cls, obj=None, removed_obj_type=None, removed_obj_id=None):
@ -4943,7 +5131,7 @@ class SearchableFormDef(SqlMixin):
def search(cls, obj_type, string):
_, cur = get_connection_and_cursor()
cur.execute(
'SELECT object_id FROM searchable_formdefs WHERE fts @@ plainto_tsquery(%s)',
'SELECT object_id FROM searchable_formdefs WHERE fts @@ wcs_tsquery(%s)',
(FtsMatch.get_fts_value(string),),
)
ids = [x[0] for x in cur.fetchall()]
@ -5208,7 +5396,7 @@ def get_period_total(
# latest migration, number + description (description is not used
# programmaticaly but will make sure git conflicts if two migrations are
# separately added with the same number)
SQL_LEVEL = (107, 'add test_uuid column to users table')
SQL_LEVEL = (108, 'new fts mechanism with tokens table')
def migrate_global_views(conn, cur):
@ -5545,6 +5733,10 @@ def migrate():
for formdef in FormDef.select() + CardDef.select():
do_formdef_tables(formdef, rebuild_views=False, rebuild_global_views=False)
if sql_level < 108:
# 108: new fts mechanism with tokens table
init_search_tokens()
if sql_level != SQL_LEVEL[0]:
cur.execute(
'''UPDATE wcs_meta SET value = %s, updated_at=NOW() WHERE key = %s''',

View File

@ -379,6 +379,11 @@ class FtsMatch(Criteria):
return 'fts @@ plainto_tsquery(%%(c%s)s)' % id(self.value)
class WcsFtsMatch(FtsMatch):
def as_sql(self):
return 'fts @@ wcs_tsquery(%%(c%s)s)' % id(self.value)
class ElementEqual(Criteria):
def __init__(self, attribute, key, value, **kwargs):
super().__init__(attribute, value)