]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/tokenizer/legacy_tokenizer.py
Merge pull request #2731 from lonvia/cleanup-special-phrases
[nominatim.git] / nominatim / tokenizer / legacy_tokenizer.py
1 # SPDX-License-Identifier: GPL-2.0-only
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2022 by the Nominatim developer community.
6 # For a full list of authors see the git log.
7 """
8 Tokenizer implementing normalisation as used before Nominatim 4.
9 """
10 from collections import OrderedDict
11 import logging
12 import re
13 import shutil
14 from textwrap import dedent
15
16 from icu import Transliterator
17 import psycopg2
18 import psycopg2.extras
19
20 from nominatim.db.connection import connect
21 from nominatim.db import properties
22 from nominatim.db import utils as db_utils
23 from nominatim.db.sql_preprocessor import SQLPreprocessor
24 from nominatim.errors import UsageError
25 from nominatim.tokenizer.base import AbstractAnalyzer, AbstractTokenizer
26
27 DBCFG_NORMALIZATION = "tokenizer_normalization"
28 DBCFG_MAXWORDFREQ = "tokenizer_maxwordfreq"
29
30 LOG = logging.getLogger()
31
32 def create(dsn, data_dir):
33     """ Create a new instance of the tokenizer provided by this module.
34     """
35     return LegacyTokenizer(dsn, data_dir)
36
37
38 def _install_module(config_module_path, src_dir, module_dir):
39     """ Copies the PostgreSQL normalisation module into the project
40         directory if necessary. For historical reasons the module is
41         saved in the '/module' subdirectory and not with the other tokenizer
42         data.
43
44         The function detects when the installation is run from the
45         build directory. It doesn't touch the module in that case.
46     """
47     # Custom module locations are simply used as is.
48     if config_module_path:
49         LOG.info("Using custom path for database module at '%s'", config_module_path)
50         return config_module_path
51
52     # Compatibility mode for builddir installations.
53     if module_dir.exists() and src_dir.samefile(module_dir):
54         LOG.info('Running from build directory. Leaving database module as is.')
55         return module_dir
56
57     # In any other case install the module in the project directory.
58     if not module_dir.exists():
59         module_dir.mkdir()
60
61     destfile = module_dir / 'nominatim.so'
62     shutil.copy(str(src_dir / 'nominatim.so'), str(destfile))
63     destfile.chmod(0o755)
64
65     LOG.info('Database module installed at %s', str(destfile))
66
67     return module_dir
68
69
70 def _check_module(module_dir, conn):
71     """ Try to use the PostgreSQL module to confirm that it is correctly
72         installed and accessible from PostgreSQL.
73     """
74     with conn.cursor() as cur:
75         try:
76             cur.execute("""CREATE FUNCTION nominatim_test_import_func(text)
77                            RETURNS text AS %s, 'transliteration'
78                            LANGUAGE c IMMUTABLE STRICT;
79                            DROP FUNCTION nominatim_test_import_func(text)
80                         """, (f'{module_dir}/nominatim.so', ))
81         except psycopg2.DatabaseError as err:
82             LOG.fatal("Error accessing database module: %s", err)
83             raise UsageError("Database module cannot be accessed.") from err
84
85
86 class LegacyTokenizer(AbstractTokenizer):
87     """ The legacy tokenizer uses a special PostgreSQL module to normalize
88         names and queries. The tokenizer thus implements normalization through
89         calls to the database.
90     """
91
92     def __init__(self, dsn, data_dir):
93         self.dsn = dsn
94         self.data_dir = data_dir
95         self.normalization = None
96
97
98     def init_new_db(self, config, init_db=True):
99         """ Set up a new tokenizer for the database.
100
101             This copies all necessary data in the project directory to make
102             sure the tokenizer remains stable even over updates.
103         """
104         module_dir = _install_module(config.DATABASE_MODULE_PATH,
105                                      config.lib_dir.module,
106                                      config.project_dir / 'module')
107
108         self.normalization = config.TERM_NORMALIZATION
109
110         self._install_php(config, overwrite=True)
111
112         with connect(self.dsn) as conn:
113             _check_module(module_dir, conn)
114             self._save_config(conn, config)
115             conn.commit()
116
117         if init_db:
118             self.update_sql_functions(config)
119             self._init_db_tables(config)
120
121
122     def init_from_project(self, config):
123         """ Initialise the tokenizer from the project directory.
124         """
125         with connect(self.dsn) as conn:
126             self.normalization = properties.get_property(conn, DBCFG_NORMALIZATION)
127
128         if not (config.project_dir / 'module' / 'nominatim.so').exists():
129             _install_module(config.DATABASE_MODULE_PATH,
130                             config.lib_dir.module,
131                             config.project_dir / 'module')
132
133         self._install_php(config, overwrite=False)
134
135     def finalize_import(self, config):
136         """ Do any required postprocessing to make the tokenizer data ready
137             for use.
138         """
139         with connect(self.dsn) as conn:
140             sqlp = SQLPreprocessor(conn, config)
141             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_indices.sql')
142
143
144     def update_sql_functions(self, config):
145         """ Reimport the SQL functions for this tokenizer.
146         """
147         with connect(self.dsn) as conn:
148             max_word_freq = properties.get_property(conn, DBCFG_MAXWORDFREQ)
149             modulepath = config.DATABASE_MODULE_PATH or \
150                          str((config.project_dir / 'module').resolve())
151             sqlp = SQLPreprocessor(conn, config)
152             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer.sql',
153                               max_word_freq=max_word_freq,
154                               modulepath=modulepath)
155
156
157     def check_database(self, _):
158         """ Check that the tokenizer is set up correctly.
159         """
160         hint = """\
161              The Postgresql extension nominatim.so was not correctly loaded.
162
163              Error: {error}
164
165              Hints:
166              * Check the output of the CMmake/make installation step
167              * Does nominatim.so exist?
168              * Does nominatim.so exist on the database server?
169              * Can nominatim.so be accessed by the database user?
170              """
171         with connect(self.dsn) as conn:
172             with conn.cursor() as cur:
173                 try:
174                     out = cur.scalar("SELECT make_standard_name('a')")
175                 except psycopg2.Error as err:
176                     return hint.format(error=str(err))
177
178         if out != 'a':
179             return hint.format(error='Unexpected result for make_standard_name()')
180
181         return None
182
183
184     def migrate_database(self, config):
185         """ Initialise the project directory of an existing database for
186             use with this tokenizer.
187
188             This is a special migration function for updating existing databases
189             to new software versions.
190         """
191         self.normalization = config.TERM_NORMALIZATION
192         module_dir = _install_module(config.DATABASE_MODULE_PATH,
193                                      config.lib_dir.module,
194                                      config.project_dir / 'module')
195
196         with connect(self.dsn) as conn:
197             _check_module(module_dir, conn)
198             self._save_config(conn, config)
199
200
201     def update_statistics(self):
202         """ Recompute the frequency of full words.
203         """
204         with connect(self.dsn) as conn:
205             if conn.table_exists('search_name'):
206                 with conn.cursor() as cur:
207                     cur.drop_table("word_frequencies")
208                     LOG.info("Computing word frequencies")
209                     cur.execute("""CREATE TEMP TABLE word_frequencies AS
210                                      SELECT unnest(name_vector) as id, count(*)
211                                      FROM search_name GROUP BY id""")
212                     cur.execute("CREATE INDEX ON word_frequencies(id)")
213                     LOG.info("Update word table with recomputed frequencies")
214                     cur.execute("""UPDATE word SET search_name_count = count
215                                    FROM word_frequencies
216                                    WHERE word_token like ' %' and word_id = id""")
217                     cur.drop_table("word_frequencies")
218             conn.commit()
219
220
221     def update_word_tokens(self):
222         """ No house-keeping implemented for the legacy tokenizer.
223         """
224         LOG.info("No tokenizer clean-up available.")
225
226
227     def name_analyzer(self):
228         """ Create a new analyzer for tokenizing names and queries
229             using this tokinzer. Analyzers are context managers and should
230             be used accordingly:
231
232             ```
233             with tokenizer.name_analyzer() as analyzer:
234                 analyser.tokenize()
235             ```
236
237             When used outside the with construct, the caller must ensure to
238             call the close() function before destructing the analyzer.
239
240             Analyzers are not thread-safe. You need to instantiate one per thread.
241         """
242         normalizer = Transliterator.createFromRules("phrase normalizer",
243                                                     self.normalization)
244         return LegacyNameAnalyzer(self.dsn, normalizer)
245
246
247     def _install_php(self, config, overwrite=True):
248         """ Install the php script for the tokenizer.
249         """
250         php_file = self.data_dir / "tokenizer.php"
251
252         if not php_file.exists() or overwrite:
253             php_file.write_text(dedent(f"""\
254                 <?php
255                 @define('CONST_Max_Word_Frequency', {config.MAX_WORD_FREQUENCY});
256                 @define('CONST_Term_Normalization_Rules', "{config.TERM_NORMALIZATION}");
257                 require_once('{config.lib_dir.php}/tokenizer/legacy_tokenizer.php');
258                 """), encoding='utf-8')
259
260
261     def _init_db_tables(self, config):
262         """ Set up the word table and fill it with pre-computed word
263             frequencies.
264         """
265         with connect(self.dsn) as conn:
266             sqlp = SQLPreprocessor(conn, config)
267             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_tables.sql')
268             conn.commit()
269
270         LOG.warning("Precomputing word tokens")
271         db_utils.execute_file(self.dsn, config.lib_dir.data / 'words.sql')
272
273
274     def _save_config(self, conn, config):
275         """ Save the configuration that needs to remain stable for the given
276             database as database properties.
277         """
278         properties.set_property(conn, DBCFG_NORMALIZATION, self.normalization)
279         properties.set_property(conn, DBCFG_MAXWORDFREQ, config.MAX_WORD_FREQUENCY)
280
281
282 class LegacyNameAnalyzer(AbstractAnalyzer):
283     """ The legacy analyzer uses the special Postgresql module for
284         splitting names.
285
286         Each instance opens a connection to the database to request the
287         normalization.
288     """
289
290     def __init__(self, dsn, normalizer):
291         self.conn = connect(dsn).connection
292         self.conn.autocommit = True
293         self.normalizer = normalizer
294         psycopg2.extras.register_hstore(self.conn)
295
296         self._cache = _TokenCache(self.conn)
297
298
299     def close(self):
300         """ Free all resources used by the analyzer.
301         """
302         if self.conn:
303             self.conn.close()
304             self.conn = None
305
306
307     def get_word_token_info(self, words):
308         """ Return token information for the given list of words.
309             If a word starts with # it is assumed to be a full name
310             otherwise is a partial name.
311
312             The function returns a list of tuples with
313             (original word, word token, word id).
314
315             The function is used for testing and debugging only
316             and not necessarily efficient.
317         """
318         with self.conn.cursor() as cur:
319             cur.execute("""SELECT t.term, word_token, word_id
320                            FROM word, (SELECT unnest(%s::TEXT[]) as term) t
321                            WHERE word_token = (CASE
322                                    WHEN left(t.term, 1) = '#' THEN
323                                      ' ' || make_standard_name(substring(t.term from 2))
324                                    ELSE
325                                      make_standard_name(t.term)
326                                    END)
327                                  and class is null and country_code is null""",
328                         (words, ))
329
330             return [(r[0], r[1], r[2]) for r in cur]
331
332
333     def normalize(self, phrase):
334         """ Normalize the given phrase, i.e. remove all properties that
335             are irrelevant for search.
336         """
337         return self.normalizer.transliterate(phrase)
338
339
340     def normalize_postcode(self, postcode):
341         """ Convert the postcode to a standardized form.
342
343             This function must yield exactly the same result as the SQL function
344             'token_normalized_postcode()'.
345         """
346         return postcode.strip().upper()
347
348
349     def update_postcodes_from_db(self):
350         """ Update postcode tokens in the word table from the location_postcode
351             table.
352         """
353         with self.conn.cursor() as cur:
354             # This finds us the rows in location_postcode and word that are
355             # missing in the other table.
356             cur.execute("""SELECT * FROM
357                             (SELECT pc, word FROM
358                               (SELECT distinct(postcode) as pc FROM location_postcode) p
359                               FULL JOIN
360                               (SELECT word FROM word
361                                 WHERE class ='place' and type = 'postcode') w
362                               ON pc = word) x
363                            WHERE pc is null or word is null""")
364
365             to_delete = []
366             to_add = []
367
368             for postcode, word in cur:
369                 if postcode is None:
370                     to_delete.append(word)
371                 else:
372                     to_add.append(postcode)
373
374             if to_delete:
375                 cur.execute("""DELETE FROM WORD
376                                WHERE class ='place' and type = 'postcode'
377                                      and word = any(%s)
378                             """, (to_delete, ))
379             if to_add:
380                 cur.execute("""SELECT count(create_postcode_id(pc))
381                                FROM unnest(%s) as pc
382                             """, (to_add, ))
383
384
385
386     def update_special_phrases(self, phrases, should_replace):
387         """ Replace the search index for special phrases with the new phrases.
388         """
389         norm_phrases = set(((self.normalize(p[0]), p[1], p[2], p[3])
390                             for p in phrases))
391
392         with self.conn.cursor() as cur:
393             # Get the old phrases.
394             existing_phrases = set()
395             cur.execute("""SELECT word, class, type, operator FROM word
396                            WHERE class != 'place'
397                                  OR (type != 'house' AND type != 'postcode')""")
398             for label, cls, typ, oper in cur:
399                 existing_phrases.add((label, cls, typ, oper or '-'))
400
401             to_add = norm_phrases - existing_phrases
402             to_delete = existing_phrases - norm_phrases
403
404             if to_add:
405                 cur.execute_values(
406                     """ INSERT INTO word (word_id, word_token, word, class, type,
407                                           search_name_count, operator)
408                         (SELECT nextval('seq_word'), ' ' || make_standard_name(name), name,
409                                 class, type, 0,
410                                 CASE WHEN op in ('in', 'near') THEN op ELSE null END
411                            FROM (VALUES %s) as v(name, class, type, op))""",
412                     to_add)
413
414             if to_delete and should_replace:
415                 cur.execute_values(
416                     """ DELETE FROM word USING (VALUES %s) as v(name, in_class, in_type, op)
417                         WHERE word = name and class = in_class and type = in_type
418                               and ((op = '-' and operator is null) or op = operator)""",
419                     to_delete)
420
421         LOG.info("Total phrases: %s. Added: %s. Deleted: %s",
422                  len(norm_phrases), len(to_add), len(to_delete))
423
424
425     def add_country_names(self, country_code, names):
426         """ Add names for the given country to the search index.
427         """
428         with self.conn.cursor() as cur:
429             cur.execute(
430                 """INSERT INTO word (word_id, word_token, country_code)
431                    (SELECT nextval('seq_word'), lookup_token, %s
432                       FROM (SELECT DISTINCT ' ' || make_standard_name(n) as lookup_token
433                             FROM unnest(%s)n) y
434                       WHERE NOT EXISTS(SELECT * FROM word
435                                        WHERE word_token = lookup_token and country_code = %s))
436                 """, (country_code, list(names.values()), country_code))
437
438
439     def process_place(self, place):
440         """ Determine tokenizer information about the given place.
441
442             Returns a JSON-serialisable structure that will be handed into
443             the database via the token_info field.
444         """
445         token_info = _TokenInfo(self._cache)
446
447         names = place.name
448
449         if names:
450             token_info.add_names(self.conn, names)
451
452             if place.is_country():
453                 self.add_country_names(place.country_code, names)
454
455         address = place.address
456         if address:
457             self._process_place_address(token_info, address)
458
459         return token_info.data
460
461
462     def _process_place_address(self, token_info, address):
463         hnrs = []
464         addr_terms = []
465
466         for key, value in address.items():
467             if key == 'postcode':
468                 # Make sure the normalized postcode is present in the word table.
469                 if re.search(r'[:,;]', value) is None:
470                     self._cache.add_postcode(self.conn,
471                                              self.normalize_postcode(value))
472             elif key in ('housenumber', 'streetnumber', 'conscriptionnumber'):
473                 hnrs.append(value)
474             elif key == 'street':
475                 token_info.add_street(self.conn, value)
476             elif key == 'place':
477                 token_info.add_place(self.conn, value)
478             elif not key.startswith('_') \
479                  and key not in ('country', 'full', 'inclusion'):
480                 addr_terms.append((key, value))
481
482         if hnrs:
483             token_info.add_housenumbers(self.conn, hnrs)
484
485         if addr_terms:
486             token_info.add_address_terms(self.conn, addr_terms)
487
488
489
490 class _TokenInfo:
491     """ Collect token information to be sent back to the database.
492     """
493     def __init__(self, cache):
494         self.cache = cache
495         self.data = {}
496
497
498     def add_names(self, conn, names):
499         """ Add token information for the names of the place.
500         """
501         with conn.cursor() as cur:
502             # Create the token IDs for all names.
503             self.data['names'] = cur.scalar("SELECT make_keywords(%s)::text",
504                                             (names, ))
505
506
507     def add_housenumbers(self, conn, hnrs):
508         """ Extract housenumber information from the address.
509         """
510         if len(hnrs) == 1:
511             token = self.cache.get_housenumber(hnrs[0])
512             if token is not None:
513                 self.data['hnr_tokens'] = token
514                 self.data['hnr'] = hnrs[0]
515                 return
516
517         # split numbers if necessary
518         simple_list = []
519         for hnr in hnrs:
520             simple_list.extend((x.strip() for x in re.split(r'[;,]', hnr)))
521
522         if len(simple_list) > 1:
523             simple_list = list(set(simple_list))
524
525         with conn.cursor() as cur:
526             cur.execute("SELECT * FROM create_housenumbers(%s)", (simple_list, ))
527             self.data['hnr_tokens'], self.data['hnr'] = cur.fetchone()
528
529
530     def add_street(self, conn, street):
531         """ Add addr:street match terms.
532         """
533         def _get_street(name):
534             with conn.cursor() as cur:
535                 return cur.scalar("SELECT word_ids_from_name(%s)::text", (name, ))
536
537         tokens = self.cache.streets.get(street, _get_street)
538         if tokens:
539             self.data['street'] = tokens
540
541
542     def add_place(self, conn, place):
543         """ Add addr:place search and match terms.
544         """
545         def _get_place(name):
546             with conn.cursor() as cur:
547                 cur.execute("""SELECT make_keywords(hstore('name' , %s))::text,
548                                       word_ids_from_name(%s)::text""",
549                             (name, name))
550                 return cur.fetchone()
551
552         self.data['place_search'], self.data['place_match'] = \
553             self.cache.places.get(place, _get_place)
554
555
556     def add_address_terms(self, conn, terms):
557         """ Add additional address terms.
558         """
559         def _get_address_term(name):
560             with conn.cursor() as cur:
561                 cur.execute("""SELECT addr_ids_from_name(%s)::text,
562                                       word_ids_from_name(%s)::text""",
563                             (name, name))
564                 return cur.fetchone()
565
566         tokens = {}
567         for key, value in terms:
568             items = self.cache.address_terms.get(value, _get_address_term)
569             if items[0] or items[1]:
570                 tokens[key] = items
571
572         if tokens:
573             self.data['addr'] = tokens
574
575
576 class _LRU:
577     """ Least recently used cache that accepts a generator function to
578         produce the item when there is a cache miss.
579     """
580
581     def __init__(self, maxsize=128, init_data=None):
582         self.data = init_data or OrderedDict()
583         self.maxsize = maxsize
584         if init_data is not None and len(init_data) > maxsize:
585             self.maxsize = len(init_data)
586
587     def get(self, key, generator):
588         """ Get the item with the given key from the cache. If nothing
589             is found in the cache, generate the value through the
590             generator function and store it in the cache.
591         """
592         value = self.data.get(key)
593         if value is not None:
594             self.data.move_to_end(key)
595         else:
596             value = generator(key)
597             if len(self.data) >= self.maxsize:
598                 self.data.popitem(last=False)
599             self.data[key] = value
600
601         return value
602
603
604 class _TokenCache:
605     """ Cache for token information to avoid repeated database queries.
606
607         This cache is not thread-safe and needs to be instantiated per
608         analyzer.
609     """
610     def __init__(self, conn):
611         # various LRU caches
612         self.streets = _LRU(maxsize=256)
613         self.places = _LRU(maxsize=128)
614         self.address_terms = _LRU(maxsize=1024)
615
616         # Lookup houseunumbers up to 100 and cache them
617         with conn.cursor() as cur:
618             cur.execute("""SELECT i, ARRAY[getorcreate_housenumber_id(i::text)]::text
619                            FROM generate_series(1, 100) as i""")
620             self._cached_housenumbers = {str(r[0]): r[1] for r in cur}
621
622         # For postcodes remember the ones that have already been added
623         self.postcodes = set()
624
625     def get_housenumber(self, number):
626         """ Get a housenumber token from the cache.
627         """
628         return self._cached_housenumbers.get(number)
629
630
631     def add_postcode(self, conn, postcode):
632         """ Make sure the given postcode is in the database.
633         """
634         if postcode not in self.postcodes:
635             with conn.cursor() as cur:
636                 cur.execute('SELECT create_postcode_id(%s)', (postcode, ))
637             self.postcodes.add(postcode)