1 # SPDX-License-Identifier: GPL-2.0-only
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2022 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Tokenizer implementing normalisation as used before Nominatim 4.
10 from typing import Optional, Sequence, List, Tuple, Mapping, Any, Callable, \
11 cast, Dict, Set, Iterable
12 from collections import OrderedDict
14 from pathlib import Path
17 from textwrap import dedent
19 from icu import Transliterator
21 import psycopg2.extras
23 from nominatim.db.connection import connect, Connection
24 from nominatim.config import Configuration
25 from nominatim.db import properties
26 from nominatim.db import utils as db_utils
27 from nominatim.db.sql_preprocessor import SQLPreprocessor
28 from nominatim.data.place_info import PlaceInfo
29 from nominatim.errors import UsageError
30 from nominatim.tokenizer.base import AbstractAnalyzer, AbstractTokenizer
32 DBCFG_NORMALIZATION = "tokenizer_normalization"
33 DBCFG_MAXWORDFREQ = "tokenizer_maxwordfreq"
35 LOG = logging.getLogger()
37 def create(dsn: str, data_dir: Path) -> 'LegacyTokenizer':
38 """ Create a new instance of the tokenizer provided by this module.
40 return LegacyTokenizer(dsn, data_dir)
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
49 The function detects when the installation is run from the
50 build directory. It doesn't touch the module in that case.
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
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)
62 # In any other case install the module in the project directory.
63 if not module_dir.exists():
66 destfile = module_dir / 'nominatim.so'
67 shutil.copy(str(src_dir / 'nominatim.so'), str(destfile))
70 LOG.info('Database module installed at %s', str(destfile))
72 return str(module_dir)
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.
79 with conn.cursor() as cur:
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
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.
97 def __init__(self, dsn: str, data_dir: Path) -> None:
99 self.data_dir = data_dir
100 self.normalization: Optional[str] = None
103 def init_new_db(self, config: Configuration, init_db: bool = True) -> None:
104 """ Set up a new tokenizer for the database.
106 This copies all necessary data in the project directory to make
107 sure the tokenizer remains stable even over updates.
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')
114 self.normalization = config.TERM_NORMALIZATION
116 self._install_php(config, overwrite=True)
118 with connect(self.dsn) as conn:
119 _check_module(module_dir, conn)
120 self._save_config(conn, config)
124 self.update_sql_functions(config)
125 self._init_db_tables(config)
128 def init_from_project(self, config: Configuration) -> None:
129 """ Initialise the tokenizer from the project directory.
131 assert config.project_dir is not None
133 with connect(self.dsn) as conn:
134 self.normalization = properties.get_property(conn, DBCFG_NORMALIZATION)
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')
141 self._install_php(config, overwrite=False)
143 def finalize_import(self, config: Configuration) -> None:
144 """ Do any required postprocessing to make the tokenizer data ready
147 with connect(self.dsn) as conn:
148 sqlp = SQLPreprocessor(conn, config)
149 sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_indices.sql')
152 def update_sql_functions(self, config: Configuration) -> None:
153 """ Reimport the SQL functions for this tokenizer.
155 assert config.project_dir is not None
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)
167 def check_database(self, _: Configuration) -> Optional[str]:
168 """ Check that the tokenizer is set up correctly.
171 The Postgresql extension nominatim.so was not correctly loaded.
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?
181 with connect(self.dsn) as conn:
182 with conn.cursor() as cur:
184 out = cur.scalar("SELECT make_standard_name('a')")
185 except psycopg2.Error as err:
186 return hint.format(error=str(err))
189 return hint.format(error='Unexpected result for make_standard_name()')
194 def migrate_database(self, config: Configuration) -> None:
195 """ Initialise the project directory of an existing database for
196 use with this tokenizer.
198 This is a special migration function for updating existing databases
199 to new software versions.
201 assert config.project_dir is not None
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')
208 with connect(self.dsn) as conn:
209 _check_module(module_dir, conn)
210 self._save_config(conn, config)
213 def update_statistics(self) -> None:
214 """ Recompute the frequency of full words.
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")
233 def update_word_tokens(self) -> None:
234 """ No house-keeping implemented for the legacy tokenizer.
236 LOG.info("No tokenizer clean-up available.")
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
245 with tokenizer.name_analyzer() as analyzer:
249 When used outside the with construct, the caller must ensure to
250 call the close() function before destructing the analyzer.
252 Analyzers are not thread-safe. You need to instantiate one per thread.
254 normalizer = Transliterator.createFromRules("phrase normalizer",
256 return LegacyNameAnalyzer(self.dsn, normalizer)
259 def _install_php(self, config: Configuration, overwrite: bool = True) -> None:
260 """ Install the php script for the tokenizer.
262 php_file = self.data_dir / "tokenizer.php"
264 if not php_file.exists() or overwrite:
265 php_file.write_text(dedent(f"""\
267 @define('CONST_Max_Word_Frequency', {config.MAX_WORD_FREQUENCY});
268 @define('CONST_Term_Normalization_Rules', "{config.TERM_NORMALIZATION}");
269 require_once('{config.lib_dir.php}/tokenizer/legacy_tokenizer.php');
270 """), encoding='utf-8')
273 def _init_db_tables(self, config: Configuration) -> None:
274 """ Set up the word table and fill it with pre-computed word
277 with connect(self.dsn) as conn:
278 sqlp = SQLPreprocessor(conn, config)
279 sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_tables.sql')
282 LOG.warning("Precomputing word tokens")
283 db_utils.execute_file(self.dsn, config.lib_dir.data / 'words.sql')
286 def _save_config(self, conn: Connection, config: Configuration) -> None:
287 """ Save the configuration that needs to remain stable for the given
288 database as database properties.
290 assert self.normalization is not None
292 properties.set_property(conn, DBCFG_NORMALIZATION, self.normalization)
293 properties.set_property(conn, DBCFG_MAXWORDFREQ, config.MAX_WORD_FREQUENCY)
296 class LegacyNameAnalyzer(AbstractAnalyzer):
297 """ The legacy analyzer uses the special Postgresql module for
300 Each instance opens a connection to the database to request the
304 def __init__(self, dsn: str, normalizer: Any):
305 self.conn: Optional[Connection] = connect(dsn).connection
306 self.conn.autocommit = True
307 self.normalizer = normalizer
308 psycopg2.extras.register_hstore(self.conn)
310 self._cache = _TokenCache(self.conn)
313 def close(self) -> None:
314 """ Free all resources used by the analyzer.
321 def get_word_token_info(self, words: Sequence[str]) -> List[Tuple[str, str, int]]:
322 """ Return token information for the given list of words.
323 If a word starts with # it is assumed to be a full name
324 otherwise is a partial name.
326 The function returns a list of tuples with
327 (original word, word token, word id).
329 The function is used for testing and debugging only
330 and not necessarily efficient.
332 assert self.conn is not None
333 with self.conn.cursor() as cur:
334 cur.execute("""SELECT t.term, word_token, word_id
335 FROM word, (SELECT unnest(%s::TEXT[]) as term) t
336 WHERE word_token = (CASE
337 WHEN left(t.term, 1) = '#' THEN
338 ' ' || make_standard_name(substring(t.term from 2))
340 make_standard_name(t.term)
342 and class is null and country_code is null""",
345 return [(r[0], r[1], r[2]) for r in cur]
348 def normalize(self, phrase: str) -> str:
349 """ Normalize the given phrase, i.e. remove all properties that
350 are irrelevant for search.
352 return cast(str, self.normalizer.transliterate(phrase))
355 def normalize_postcode(self, postcode: str) -> str:
356 """ Convert the postcode to a standardized form.
358 This function must yield exactly the same result as the SQL function
359 'token_normalized_postcode()'.
361 return postcode.strip().upper()
364 def update_postcodes_from_db(self) -> None:
365 """ Update postcode tokens in the word table from the location_postcode
368 assert self.conn is not None
370 with self.conn.cursor() as cur:
371 # This finds us the rows in location_postcode and word that are
372 # missing in the other table.
373 cur.execute("""SELECT * FROM
374 (SELECT pc, word FROM
375 (SELECT distinct(postcode) as pc FROM location_postcode) p
377 (SELECT word FROM word
378 WHERE class ='place' and type = 'postcode') w
380 WHERE pc is null or word is null""")
385 for postcode, word in cur:
387 to_delete.append(word)
389 to_add.append(postcode)
392 cur.execute("""DELETE FROM WORD
393 WHERE class ='place' and type = 'postcode'
397 cur.execute("""SELECT count(create_postcode_id(pc))
398 FROM unnest(%s) as pc
403 def update_special_phrases(self, phrases: Iterable[Tuple[str, str, str, str]],
404 should_replace: bool) -> None:
405 """ Replace the search index for special phrases with the new phrases.
407 assert self.conn is not None
409 norm_phrases = set(((self.normalize(p[0]), p[1], p[2], p[3])
412 with self.conn.cursor() as cur:
413 # Get the old phrases.
414 existing_phrases = set()
415 cur.execute("""SELECT word, class, type, operator FROM word
416 WHERE class != 'place'
417 OR (type != 'house' AND type != 'postcode')""")
418 for label, cls, typ, oper in cur:
419 existing_phrases.add((label, cls, typ, oper or '-'))
421 to_add = norm_phrases - existing_phrases
422 to_delete = existing_phrases - norm_phrases
426 """ INSERT INTO word (word_id, word_token, word, class, type,
427 search_name_count, operator)
428 (SELECT nextval('seq_word'), ' ' || make_standard_name(name), name,
430 CASE WHEN op in ('in', 'near') THEN op ELSE null END
431 FROM (VALUES %s) as v(name, class, type, op))""",
434 if to_delete and should_replace:
436 """ DELETE FROM word USING (VALUES %s) as v(name, in_class, in_type, op)
437 WHERE word = name and class = in_class and type = in_type
438 and ((op = '-' and operator is null) or op = operator)""",
441 LOG.info("Total phrases: %s. Added: %s. Deleted: %s",
442 len(norm_phrases), len(to_add), len(to_delete))
445 def add_country_names(self, country_code: str, names: Mapping[str, str]) -> None:
446 """ Add names for the given country to the search index.
448 assert self.conn is not None
450 with self.conn.cursor() as cur:
452 """INSERT INTO word (word_id, word_token, country_code)
453 (SELECT nextval('seq_word'), lookup_token, %s
454 FROM (SELECT DISTINCT ' ' || make_standard_name(n) as lookup_token
456 WHERE NOT EXISTS(SELECT * FROM word
457 WHERE word_token = lookup_token and country_code = %s))
458 """, (country_code, list(names.values()), country_code))
461 def process_place(self, place: PlaceInfo) -> Mapping[str, Any]:
462 """ Determine tokenizer information about the given place.
464 Returns a JSON-serialisable structure that will be handed into
465 the database via the token_info field.
467 assert self.conn is not None
469 token_info = _TokenInfo(self._cache)
474 token_info.add_names(self.conn, names)
476 if place.is_country():
477 assert place.country_code is not None
478 self.add_country_names(place.country_code, names)
480 address = place.address
482 self._process_place_address(token_info, address)
484 return token_info.data
487 def _process_place_address(self, token_info: '_TokenInfo', address: Mapping[str, str]) -> None:
488 assert self.conn is not None
492 for key, value in address.items():
493 if key == 'postcode':
494 # Make sure the normalized postcode is present in the word table.
495 if re.search(r'[:,;]', value) is None:
496 norm_pc = self.normalize_postcode(value)
497 token_info.set_postcode(norm_pc)
498 self._cache.add_postcode(self.conn, norm_pc)
499 elif key in ('housenumber', 'streetnumber', 'conscriptionnumber'):
501 elif key == 'street':
502 token_info.add_street(self.conn, value)
504 token_info.add_place(self.conn, value)
505 elif not key.startswith('_') \
506 and key not in ('country', 'full', 'inclusion'):
507 addr_terms.append((key, value))
510 token_info.add_housenumbers(self.conn, hnrs)
513 token_info.add_address_terms(self.conn, addr_terms)
518 """ Collect token information to be sent back to the database.
520 def __init__(self, cache: '_TokenCache') -> None:
522 self.data: Dict[str, Any] = {}
525 def add_names(self, conn: Connection, names: Mapping[str, str]) -> None:
526 """ Add token information for the names of the place.
528 with conn.cursor() as cur:
529 # Create the token IDs for all names.
530 self.data['names'] = cur.scalar("SELECT make_keywords(%s)::text",
534 def add_housenumbers(self, conn: Connection, hnrs: Sequence[str]) -> None:
535 """ Extract housenumber information from the address.
538 token = self.cache.get_housenumber(hnrs[0])
539 if token is not None:
540 self.data['hnr_tokens'] = token
541 self.data['hnr'] = hnrs[0]
544 # split numbers if necessary
545 simple_list: List[str] = []
547 simple_list.extend((x.strip() for x in re.split(r'[;,]', hnr)))
549 if len(simple_list) > 1:
550 simple_list = list(set(simple_list))
552 with conn.cursor() as cur:
553 cur.execute("SELECT * FROM create_housenumbers(%s)", (simple_list, ))
554 result = cur.fetchone()
555 assert result is not None
556 self.data['hnr_tokens'], self.data['hnr'] = result
559 def set_postcode(self, postcode: str) -> None:
560 """ Set or replace the postcode token with the given value.
562 self.data['postcode'] = postcode
564 def add_street(self, conn: Connection, street: str) -> None:
565 """ Add addr:street match terms.
567 def _get_street(name: str) -> Optional[str]:
568 with conn.cursor() as cur:
569 return cast(Optional[str],
570 cur.scalar("SELECT word_ids_from_name(%s)::text", (name, )))
572 tokens = self.cache.streets.get(street, _get_street)
573 self.data['street'] = tokens or '{}'
576 def add_place(self, conn: Connection, place: str) -> None:
577 """ Add addr:place search and match terms.
579 def _get_place(name: str) -> Tuple[List[int], List[int]]:
580 with conn.cursor() as cur:
581 cur.execute("""SELECT make_keywords(hstore('name' , %s))::text,
582 word_ids_from_name(%s)::text""",
584 return cast(Tuple[List[int], List[int]], cur.fetchone())
586 self.data['place_search'], self.data['place_match'] = \
587 self.cache.places.get(place, _get_place)
590 def add_address_terms(self, conn: Connection, terms: Sequence[Tuple[str, str]]) -> None:
591 """ Add additional address terms.
593 def _get_address_term(name: str) -> Tuple[List[int], List[int]]:
594 with conn.cursor() as cur:
595 cur.execute("""SELECT addr_ids_from_name(%s)::text,
596 word_ids_from_name(%s)::text""",
598 return cast(Tuple[List[int], List[int]], cur.fetchone())
601 for key, value in terms:
602 items = self.cache.address_terms.get(value, _get_address_term)
603 if items[0] or items[1]:
607 self.data['addr'] = tokens
611 """ Least recently used cache that accepts a generator function to
612 produce the item when there is a cache miss.
615 def __init__(self, maxsize: int = 128):
616 self.data: 'OrderedDict[str, Any]' = OrderedDict()
617 self.maxsize = maxsize
620 def get(self, key: str, generator: Callable[[str], Any]) -> Any:
621 """ Get the item with the given key from the cache. If nothing
622 is found in the cache, generate the value through the
623 generator function and store it in the cache.
625 value = self.data.get(key)
626 if value is not None:
627 self.data.move_to_end(key)
629 value = generator(key)
630 if len(self.data) >= self.maxsize:
631 self.data.popitem(last=False)
632 self.data[key] = value
638 """ Cache for token information to avoid repeated database queries.
640 This cache is not thread-safe and needs to be instantiated per
643 def __init__(self, conn: Connection):
645 self.streets = _LRU(maxsize=256)
646 self.places = _LRU(maxsize=128)
647 self.address_terms = _LRU(maxsize=1024)
649 # Lookup houseunumbers up to 100 and cache them
650 with conn.cursor() as cur:
651 cur.execute("""SELECT i, ARRAY[getorcreate_housenumber_id(i::text)]::text
652 FROM generate_series(1, 100) as i""")
653 self._cached_housenumbers: Dict[str, str] = {str(r[0]): r[1] for r in cur}
655 # For postcodes remember the ones that have already been added
656 self.postcodes: Set[str] = set()
658 def get_housenumber(self, number: str) -> Optional[str]:
659 """ Get a housenumber token from the cache.
661 return self._cached_housenumbers.get(number)
664 def add_postcode(self, conn: Connection, postcode: str) -> None:
665 """ Make sure the given postcode is in the database.
667 if postcode not in self.postcodes:
668 with conn.cursor() as cur:
669 cur.execute('SELECT create_postcode_id(%s)', (postcode, ))
670 self.postcodes.add(postcode)