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