2 Tokenizer implementing normalisation as used before Nominatim 4.
4 from collections import OrderedDict
9 from icu import Transliterator
11 import psycopg2.extras
13 from nominatim.db.connection import connect
14 from nominatim.db import properties
15 from nominatim.db import utils as db_utils
16 from nominatim.db.sql_preprocessor import SQLPreprocessor
17 from nominatim.errors import UsageError
19 DBCFG_NORMALIZATION = "tokenizer_normalization"
20 DBCFG_MAXWORDFREQ = "tokenizer_maxwordfreq"
22 LOG = logging.getLogger()
24 def create(dsn, data_dir):
25 """ Create a new instance of the tokenizer provided by this module.
27 return LegacyTokenizer(dsn, data_dir)
30 def _install_module(config_module_path, src_dir, module_dir):
31 """ Copies the PostgreSQL normalisation module into the project
32 directory if necessary. For historical reasons the module is
33 saved in the '/module' subdirectory and not with the other tokenizer
36 The function detects when the installation is run from the
37 build directory. It doesn't touch the module in that case.
39 # Custom module locations are simply used as is.
40 if config_module_path:
41 LOG.info("Using custom path for database module at '%s'", config_module_path)
42 return config_module_path
44 # Compatibility mode for builddir installations.
45 if module_dir.exists() and src_dir.samefile(module_dir):
46 LOG.info('Running from build directory. Leaving database module as is.')
49 # In any other case install the module in the project directory.
50 if not module_dir.exists():
53 destfile = module_dir / 'nominatim.so'
54 shutil.copy(str(src_dir / 'nominatim.so'), str(destfile))
57 LOG.info('Database module installed at %s', str(destfile))
62 def _check_module(module_dir, conn):
63 """ Try to use the PostgreSQL module to confirm that it is correctly
64 installed and accessible from PostgreSQL.
66 with conn.cursor() as cur:
68 cur.execute("""CREATE FUNCTION nominatim_test_import_func(text)
69 RETURNS text AS '{}/nominatim.so', 'transliteration'
70 LANGUAGE c IMMUTABLE STRICT;
71 DROP FUNCTION nominatim_test_import_func(text)
72 """.format(module_dir))
73 except psycopg2.DatabaseError as err:
74 LOG.fatal("Error accessing database module: %s", err)
75 raise UsageError("Database module cannot be accessed.") from err
78 class LegacyTokenizer:
79 """ The legacy tokenizer uses a special PostgreSQL module to normalize
80 names and queries. The tokenizer thus implements normalization through
81 calls to the database.
84 def __init__(self, dsn, data_dir):
86 self.data_dir = data_dir
87 self.normalization = None
90 def init_new_db(self, config):
91 """ Set up a new tokenizer for the database.
93 This copies all necessary data in the project directory to make
94 sure the tokenizer remains stable even over updates.
96 module_dir = _install_module(config.DATABASE_MODULE_PATH,
97 config.lib_dir.module,
98 config.project_dir / 'module')
100 self.normalization = config.TERM_NORMALIZATION
102 with connect(self.dsn) as conn:
103 _check_module(module_dir, conn)
104 self._save_config(conn, config)
107 self.update_sql_functions(config)
108 self._init_db_tables(config)
111 def init_from_project(self):
112 """ Initialise the tokenizer from the project directory.
114 with connect(self.dsn) as conn:
115 self.normalization = properties.get_property(conn, DBCFG_NORMALIZATION)
118 def update_sql_functions(self, config):
119 """ Reimport the SQL functions for this tokenizer.
121 with connect(self.dsn) as conn:
122 max_word_freq = properties.get_property(conn, DBCFG_MAXWORDFREQ)
123 modulepath = config.DATABASE_MODULE_PATH or \
124 str((config.project_dir / 'module').resolve())
125 sqlp = SQLPreprocessor(conn, config)
126 sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer.sql',
127 max_word_freq=max_word_freq,
128 modulepath=modulepath)
131 def migrate_database(self, config):
132 """ Initialise the project directory of an existing database for
133 use with this tokenizer.
135 This is a special migration function for updating existing databases
136 to new software versions.
138 module_dir = _install_module(config.DATABASE_MODULE_PATH,
139 config.lib_dir.module,
140 config.project_dir / 'module')
142 with connect(self.dsn) as conn:
143 _check_module(module_dir, conn)
144 self._save_config(conn, config)
147 def name_analyzer(self):
148 """ Create a new analyzer for tokenizing names and queries
149 using this tokinzer. Analyzers are context managers and should
153 with tokenizer.name_analyzer() as analyzer:
157 When used outside the with construct, the caller must ensure to
158 call the close() function before destructing the analyzer.
160 Analyzers are not thread-safe. You need to instantiate one per thread.
162 normalizer = Transliterator.createFromRules("phrase normalizer",
164 return LegacyNameAnalyzer(self.dsn, normalizer)
167 def _init_db_tables(self, config):
168 """ Set up the word table and fill it with pre-computed word
171 with connect(self.dsn) as conn:
172 sqlp = SQLPreprocessor(conn, config)
173 sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_tables.sql')
176 LOG.warning("Precomputing word tokens")
177 db_utils.execute_file(self.dsn, config.lib_dir.data / 'words.sql')
180 def _save_config(self, conn, config):
181 """ Save the configuration that needs to remain stable for the given
182 database as database properties.
184 properties.set_property(conn, DBCFG_NORMALIZATION, self.normalization)
185 properties.set_property(conn, DBCFG_MAXWORDFREQ, config.MAX_WORD_FREQUENCY)
188 class LegacyNameAnalyzer:
189 """ The legacy analyzer uses the special Postgresql module for
192 Each instance opens a connection to the database to request the
196 def __init__(self, dsn, normalizer):
197 self.conn = connect(dsn).connection
198 self.conn.autocommit = True
199 self.normalizer = normalizer
200 psycopg2.extras.register_hstore(self.conn)
202 self._cache = _TokenCache(self.conn)
209 def __exit__(self, exc_type, exc_value, traceback):
214 """ Free all resources used by the analyzer.
221 def normalize(self, phrase):
222 """ Normalize the given phrase, i.e. remove all properties that
223 are irrelevant for search.
225 return self.normalizer.transliterate(phrase)
228 def add_postcodes_from_db(self):
229 """ Add postcodes from the location_postcode table to the word table.
231 with self.conn.cursor() as cur:
232 cur.execute("""SELECT count(create_postcode_id(pc))
233 FROM (SELECT distinct(postcode) as pc
234 FROM location_postcode) x""")
237 def update_special_phrases(self, phrases):
238 """ Replace the search index for special phrases with the new phrases.
240 norm_phrases = set(((self.normalize(p[0]), p[1], p[2], p[3])
243 with self.conn.cursor() as cur:
244 # Get the old phrases.
245 existing_phrases = set()
246 cur.execute("""SELECT word, class, type, operator FROM word
247 WHERE class != 'place'
248 OR (type != 'house' AND type != 'postcode')""")
249 for label, cls, typ, oper in cur:
250 existing_phrases.add((label, cls, typ, oper or '-'))
252 to_add = norm_phrases - existing_phrases
253 to_delete = existing_phrases - norm_phrases
256 psycopg2.extras.execute_values(
258 """ INSERT INTO word (word_id, word_token, word, class, type,
259 search_name_count, operator)
260 (SELECT nextval('seq_word'), make_standard_name(name), name,
262 CASE WHEN op in ('in', 'near') THEN op ELSE null END
263 FROM (VALUES %s) as v(name, class, type, op))""",
267 psycopg2.extras.execute_values(
269 """ DELETE FROM word USING (VALUES %s) as v(name, in_class, in_type, op)
270 WHERE word = name and class = in_class and type = in_type
271 and ((op = '-' and operator is null) or op = operator)""",
274 LOG.info("Total phrases: %s. Added: %s. Deleted: %s",
275 len(norm_phrases), len(to_add), len(to_delete))
278 def add_country_names(self, country_code, names):
279 """ Add names for the given country to the search index.
281 with self.conn.cursor() as cur:
283 """INSERT INTO word (word_id, word_token, country_code)
284 (SELECT nextval('seq_word'), lookup_token, %s
285 FROM (SELECT ' ' || make_standard_name(n) as lookup_token
287 WHERE NOT EXISTS(SELECT * FROM word
288 WHERE word_token = lookup_token and country_code = %s))
289 """, (country_code, names, country_code))
292 def process_place(self, place):
293 """ Determine tokenizer information about the given place.
295 Returns a JSON-serialisable structure that will be handed into
296 the database via the token_info field.
298 token_info = _TokenInfo(self._cache)
300 names = place.get('name')
303 token_info.add_names(self.conn, names)
305 country_feature = place.get('country_feature')
306 if country_feature and re.fullmatch(r'[A-Za-z][A-Za-z]', country_feature):
307 self.add_country_names(country_feature.lower(), list(names.values()))
309 address = place.get('address')
314 for key, value in address.items():
315 if key == 'postcode':
316 self._add_postcode(value)
317 elif key in ('housenumber', 'streetnumber', 'conscriptionnumber'):
319 elif key == 'street':
320 token_info.add_street(self.conn, value)
322 token_info.add_place(self.conn, value)
323 elif not key.startswith('_') and \
324 key not in ('country', 'full'):
325 addr_terms.append((key, value))
328 token_info.add_housenumbers(self.conn, hnrs)
331 token_info.add_address_terms(self.conn, addr_terms)
333 return token_info.data
336 def _add_postcode(self, postcode):
337 """ Make sure the normalized postcode is present in the word table.
339 def _create_postcode_from_db(pcode):
340 with self.conn.cursor() as cur:
341 cur.execute('SELECT create_postcode_id(%s)', (pcode, ))
343 if re.search(r'[:,;]', postcode) is None:
344 self._cache.postcodes.get(postcode.strip().upper(), _create_postcode_from_db)
348 """ Collect token information to be sent back to the database.
350 def __init__(self, cache):
355 def add_names(self, conn, names):
356 """ Add token information for the names of the place.
358 with conn.cursor() as cur:
359 # Create the token IDs for all names.
360 self.data['names'] = cur.scalar("SELECT make_keywords(%s)::text",
364 def add_housenumbers(self, conn, hnrs):
365 """ Extract housenumber information from the address.
368 token = self.cache.get_housenumber(hnrs[0])
369 if token is not None:
370 self.data['hnr_tokens'] = token
371 self.data['hnr'] = hnrs[0]
374 # split numbers if necessary
377 simple_list.extend((x.strip() for x in re.split(r'[;,]', hnr)))
379 if len(simple_list) > 1:
380 simple_list = list(set(simple_list))
382 with conn.cursor() as cur:
383 cur.execute("SELECT (create_housenumbers(%s)).* ", (simple_list, ))
384 self.data['hnr_tokens'], self.data['hnr'] = cur.fetchone()
387 def add_street(self, conn, street):
388 """ Add addr:street match terms.
390 def _get_street(name):
391 with conn.cursor() as cur:
392 return cur.scalar("SELECT word_ids_from_name(%s)::text", (name, ))
394 self.data['street'] = self.cache.streets.get(street, _get_street)
397 def add_place(self, conn, place):
398 """ Add addr:place search and match terms.
400 def _get_place(name):
401 with conn.cursor() as cur:
402 cur.execute("""SELECT (addr_ids_from_name(%s)
403 || getorcreate_name_id(make_standard_name(%s), ''))::text,
404 word_ids_from_name(%s)::text""",
406 return cur.fetchone()
408 self.data['place_search'], self.data['place_match'] = \
409 self.cache.places.get(place, _get_place)
412 def add_address_terms(self, conn, terms):
413 """ Add additional address terms.
415 def _get_address_term(name):
416 with conn.cursor() as cur:
417 cur.execute("""SELECT addr_ids_from_name(%s)::text,
418 word_ids_from_name(%s)::text""",
420 return cur.fetchone()
423 for key, value in terms:
424 tokens[key] = self.cache.address_terms.get(value, _get_address_term)
426 self.data['addr'] = tokens
430 """ Least recently used cache that accepts a generator function to
431 produce the item when there is a cache miss.
434 def __init__(self, maxsize=128, init_data=None):
435 self.data = init_data or OrderedDict()
436 self.maxsize = maxsize
437 if init_data is not None and len(init_data) > maxsize:
438 self.maxsize = len(init_data)
440 def get(self, key, generator):
441 """ Get the item with the given key from the cache. If nothing
442 is found in the cache, generate the value through the
443 generator function and store it in the cache.
445 value = self.data.get(key)
446 if value is not None:
447 self.data.move_to_end(key)
449 value = generator(key)
450 if len(self.data) >= self.maxsize:
451 self.data.popitem(last=False)
452 self.data[key] = value
458 """ Cache for token information to avoid repeated database queries.
460 This cache is not thread-safe and needs to be instantiated per
463 def __init__(self, conn):
465 self.streets = _LRU(maxsize=256)
466 self.places = _LRU(maxsize=128)
467 self.address_terms = _LRU(maxsize=1024)
469 # Lookup houseunumbers up to 100 and cache them
470 with conn.cursor() as cur:
471 cur.execute("""SELECT i, ARRAY[getorcreate_housenumber_id(i::text)]::text
472 FROM generate_series(1, 100) as i""")
473 self._cached_housenumbers = {str(r[0]) : r[1] for r in cur}
475 # Get postcodes that are already saved
476 postcodes = OrderedDict()
477 with conn.cursor() as cur:
478 cur.execute("""SELECT word FROM word
479 WHERE class ='place' and type = 'postcode'""")
481 postcodes[row[0]] = None
482 self.postcodes = _LRU(maxsize=32, init_data=postcodes)
484 def get_housenumber(self, number):
485 """ Get a housenumber token from the cache.
487 return self._cached_housenumbers.get(number)