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 but using
9 libICU instead of the PostgreSQL module.
15 from textwrap import dedent
17 from nominatim.db.connection import connect
18 from nominatim.db.utils import CopyBuffer
19 from nominatim.db.sql_preprocessor import SQLPreprocessor
20 from nominatim.indexer.place_info import PlaceInfo
21 from nominatim.tokenizer.icu_rule_loader import ICURuleLoader
22 from nominatim.tokenizer.base import AbstractAnalyzer, AbstractTokenizer
24 DBCFG_TERM_NORMALIZATION = "tokenizer_term_normalization"
26 LOG = logging.getLogger()
28 def create(dsn, data_dir):
29 """ Create a new instance of the tokenizer provided by this module.
31 return LegacyICUTokenizer(dsn, data_dir)
34 class LegacyICUTokenizer(AbstractTokenizer):
35 """ This tokenizer uses libICU to covert names and queries to ASCII.
36 Otherwise it uses the same algorithms and data structures as the
37 normalization routines in Nominatim 3.
40 def __init__(self, dsn, data_dir):
42 self.data_dir = data_dir
46 def init_new_db(self, config, init_db=True):
47 """ Set up a new tokenizer for the database.
49 This copies all necessary data in the project directory to make
50 sure the tokenizer remains stable even over updates.
52 self.loader = ICURuleLoader(config)
54 self._install_php(config.lib_dir.php)
58 self.update_sql_functions(config)
59 self._init_db_tables(config)
62 def init_from_project(self, config):
63 """ Initialise the tokenizer from the project directory.
65 self.loader = ICURuleLoader(config)
67 with connect(self.dsn) as conn:
68 self.loader.load_config_from_db(conn)
71 def finalize_import(self, config):
72 """ Do any required postprocessing to make the tokenizer data ready
75 with connect(self.dsn) as conn:
76 sqlp = SQLPreprocessor(conn, config)
77 sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_indices.sql')
80 def update_sql_functions(self, config):
81 """ Reimport the SQL functions for this tokenizer.
83 with connect(self.dsn) as conn:
84 sqlp = SQLPreprocessor(conn, config)
85 sqlp.run_sql_file(conn, 'tokenizer/icu_tokenizer.sql')
88 def check_database(self, config):
89 """ Check that the tokenizer is set up correctly.
91 # Will throw an error if there is an issue.
92 self.init_from_project(config)
95 def update_statistics(self):
96 """ Recompute frequencies for all name words.
98 with connect(self.dsn) as conn:
99 if conn.table_exists('search_name'):
100 with conn.cursor() as cur:
101 cur.drop_table("word_frequencies")
102 LOG.info("Computing word frequencies")
103 cur.execute("""CREATE TEMP TABLE word_frequencies AS
104 SELECT unnest(name_vector) as id, count(*)
105 FROM search_name GROUP BY id""")
106 cur.execute("CREATE INDEX ON word_frequencies(id)")
107 LOG.info("Update word table with recomputed frequencies")
108 cur.execute("""UPDATE word
109 SET info = info || jsonb_build_object('count', count)
110 FROM word_frequencies WHERE word_id = id""")
111 cur.drop_table("word_frequencies")
115 def _cleanup_housenumbers(self):
116 """ Remove unused house numbers.
118 with connect(self.dsn) as conn:
119 if not conn.table_exists('search_name'):
121 with conn.cursor(name="hnr_counter") as cur:
122 cur.execute("""SELECT word_id, word_token FROM word
124 AND NOT EXISTS(SELECT * FROM search_name
125 WHERE ARRAY[word.word_id] && name_vector)
126 AND (char_length(word_token) > 6
127 OR word_token not similar to '\\d+')
129 candidates = {token: wid for wid, token in cur}
130 with conn.cursor(name="hnr_counter") as cur:
131 cur.execute("""SELECT housenumber FROM placex
132 WHERE housenumber is not null
133 AND (char_length(housenumber) > 6
134 OR housenumber not similar to '\\d+')
137 for hnr in row[0].split(';'):
138 candidates.pop(hnr, None)
139 LOG.info("There are %s outdated housenumbers.", len(candidates))
141 with conn.cursor() as cur:
142 cur.execute("""DELETE FROM word WHERE word_id = any(%s)""",
143 (list(candidates.values()), ))
148 def update_word_tokens(self):
149 """ Remove unused tokens.
151 LOG.warning("Cleaning up housenumber tokens.")
152 self._cleanup_housenumbers()
153 LOG.warning("Tokenizer house-keeping done.")
156 def name_analyzer(self):
157 """ Create a new analyzer for tokenizing names and queries
158 using this tokinzer. Analyzers are context managers and should
162 with tokenizer.name_analyzer() as analyzer:
166 When used outside the with construct, the caller must ensure to
167 call the close() function before destructing the analyzer.
169 Analyzers are not thread-safe. You need to instantiate one per thread.
171 return LegacyICUNameAnalyzer(self.dsn, self.loader.make_sanitizer(),
172 self.loader.make_token_analysis())
175 def _install_php(self, phpdir):
176 """ Install the php script for the tokenizer.
178 php_file = self.data_dir / "tokenizer.php"
179 php_file.write_text(dedent(f"""\
181 @define('CONST_Max_Word_Frequency', 10000000);
182 @define('CONST_Term_Normalization_Rules', "{self.loader.normalization_rules}");
183 @define('CONST_Transliteration', "{self.loader.get_search_rules()}");
184 require_once('{phpdir}/tokenizer/icu_tokenizer.php');"""))
187 def _save_config(self):
188 """ Save the configuration that needs to remain stable for the given
189 database as database properties.
191 with connect(self.dsn) as conn:
192 self.loader.save_config_to_db(conn)
195 def _init_db_tables(self, config):
196 """ Set up the word table and fill it with pre-computed word
199 with connect(self.dsn) as conn:
200 sqlp = SQLPreprocessor(conn, config)
201 sqlp.run_sql_file(conn, 'tokenizer/icu_tokenizer_tables.sql')
205 class LegacyICUNameAnalyzer(AbstractAnalyzer):
206 """ The legacy analyzer uses the ICU library for splitting names.
208 Each instance opens a connection to the database to request the
212 def __init__(self, dsn, sanitizer, token_analysis):
213 self.conn = connect(dsn).connection
214 self.conn.autocommit = True
215 self.sanitizer = sanitizer
216 self.token_analysis = token_analysis
218 self._cache = _TokenCache()
222 """ Free all resources used by the analyzer.
229 def _search_normalized(self, name):
230 """ Return the search token transliteration of the given name.
232 return self.token_analysis.search.transliterate(name).strip()
235 def _normalized(self, name):
236 """ Return the normalized version of the given name with all
237 non-relevant information removed.
239 return self.token_analysis.normalizer.transliterate(name).strip()
242 def get_word_token_info(self, words):
243 """ Return token information for the given list of words.
244 If a word starts with # it is assumed to be a full name
245 otherwise is a partial name.
247 The function returns a list of tuples with
248 (original word, word token, word id).
250 The function is used for testing and debugging only
251 and not necessarily efficient.
256 if word.startswith('#'):
257 full_tokens[word] = self._search_normalized(word[1:])
259 partial_tokens[word] = self._search_normalized(word)
261 with self.conn.cursor() as cur:
262 cur.execute("""SELECT word_token, word_id
263 FROM word WHERE word_token = ANY(%s) and type = 'W'
264 """, (list(full_tokens.values()),))
265 full_ids = {r[0]: r[1] for r in cur}
266 cur.execute("""SELECT word_token, word_id
267 FROM word WHERE word_token = ANY(%s) and type = 'w'""",
268 (list(partial_tokens.values()),))
269 part_ids = {r[0]: r[1] for r in cur}
271 return [(k, v, full_ids.get(v, None)) for k, v in full_tokens.items()] \
272 + [(k, v, part_ids.get(v, None)) for k, v in partial_tokens.items()]
276 def normalize_postcode(postcode):
277 """ Convert the postcode to a standardized form.
279 This function must yield exactly the same result as the SQL function
280 'token_normalized_postcode()'.
282 return postcode.strip().upper()
285 def _make_standard_hnr(self, hnr):
286 """ Create a normalised version of a housenumber.
288 This function takes minor shortcuts on transliteration.
290 return self._search_normalized(hnr)
292 def update_postcodes_from_db(self):
293 """ Update postcode tokens in the word table from the location_postcode
297 with self.conn.cursor() as cur:
298 # This finds us the rows in location_postcode and word that are
299 # missing in the other table.
300 cur.execute("""SELECT * FROM
301 (SELECT pc, word FROM
302 (SELECT distinct(postcode) as pc FROM location_postcode) p
304 (SELECT word FROM word WHERE type = 'P') w
306 WHERE pc is null or word is null""")
308 with CopyBuffer() as copystr:
309 for postcode, word in cur:
311 to_delete.append(word)
313 copystr.add(self._search_normalized(postcode),
317 cur.execute("""DELETE FROM WORD
318 WHERE type ='P' and word = any(%s)
321 copystr.copy_out(cur, 'word',
322 columns=['word_token', 'type', 'word'])
325 def update_special_phrases(self, phrases, should_replace):
326 """ Replace the search index for special phrases with the new phrases.
327 If `should_replace` is True, then the previous set of will be
328 completely replaced. Otherwise the phrases are added to the
329 already existing ones.
331 norm_phrases = set(((self._normalized(p[0]), p[1], p[2], p[3])
334 with self.conn.cursor() as cur:
335 # Get the old phrases.
336 existing_phrases = set()
337 cur.execute("SELECT word, info FROM word WHERE type = 'S'")
338 for word, info in cur:
339 existing_phrases.add((word, info['class'], info['type'],
340 info.get('op') or '-'))
342 added = self._add_special_phrases(cur, norm_phrases, existing_phrases)
344 deleted = self._remove_special_phrases(cur, norm_phrases,
349 LOG.info("Total phrases: %s. Added: %s. Deleted: %s",
350 len(norm_phrases), added, deleted)
353 def _add_special_phrases(self, cursor, new_phrases, existing_phrases):
354 """ Add all phrases to the database that are not yet there.
356 to_add = new_phrases - existing_phrases
359 with CopyBuffer() as copystr:
360 for word, cls, typ, oper in to_add:
361 term = self._search_normalized(word)
363 copystr.add(term, 'S', word,
364 json.dumps({'class': cls, 'type': typ,
365 'op': oper if oper in ('in', 'near') else None}))
368 copystr.copy_out(cursor, 'word',
369 columns=['word_token', 'type', 'word', 'info'])
375 def _remove_special_phrases(cursor, new_phrases, existing_phrases):
376 """ Remove all phrases from the databse that are no longer in the
379 to_delete = existing_phrases - new_phrases
382 cursor.execute_values(
383 """ DELETE FROM word USING (VALUES %s) as v(name, in_class, in_type, op)
384 WHERE type = 'S' and word = name
385 and info->>'class' = in_class and info->>'type' = in_type
386 and ((op = '-' and info->>'op' is null) or op = info->>'op')
389 return len(to_delete)
392 def add_country_names(self, country_code, names):
393 """ Add names for the given country to the search index.
395 # Make sure any name preprocessing for country names applies.
396 info = PlaceInfo({'name': names, 'country_code': country_code,
397 'rank_address': 4, 'class': 'boundary',
398 'type': 'administrative'})
399 self._add_country_full_names(country_code,
400 self.sanitizer.process_names(info)[0])
403 def _add_country_full_names(self, country_code, names):
404 """ Add names for the given country from an already sanitized
409 norm_name = self._search_normalized(name.name)
411 word_tokens.add(norm_name)
413 with self.conn.cursor() as cur:
415 cur.execute("""SELECT word_token FROM word
416 WHERE type = 'C' and word = %s""",
418 word_tokens.difference_update((t[0] for t in cur))
420 # Only add those names that are not yet in the list.
422 cur.execute("""INSERT INTO word (word_token, type, word)
423 (SELECT token, 'C', %s
424 FROM unnest(%s) as token)
425 """, (country_code, list(word_tokens)))
427 # No names are deleted at the moment.
428 # If deletion is made possible, then the static names from the
429 # initial 'country_name' table should be kept.
432 def process_place(self, place):
433 """ Determine tokenizer information about the given place.
435 Returns a JSON-serializable structure that will be handed into
436 the database via the token_info field.
438 token_info = _TokenInfo(self._cache)
440 names, address = self.sanitizer.process_names(place)
443 fulls, partials = self._compute_name_tokens(names)
445 token_info.add_names(fulls, partials)
447 #if place.is_country():
448 # self._add_country_full_names(place.country_code, names)
451 self._process_place_address(token_info, address)
453 return token_info.data
456 def _process_place_address(self, token_info, address):
461 if item.kind == 'postcode':
462 self._add_postcode(item.name)
463 elif item.kind == 'housenumber':
464 norm_name = self._make_standard_hnr(item.name)
467 elif item.kind == 'street':
468 streets.extend(self._retrieve_full_tokens(item.name))
469 elif item.kind == 'place':
471 token_info.add_place(self._compute_partial_tokens(item.name))
472 elif not item.kind.startswith('_') and not item.suffix and \
473 item.kind not in ('country', 'full'):
474 addr_terms.append((item.kind, self._compute_partial_tokens(item.name)))
477 token_info.add_housenumbers(self.conn, hnrs)
480 token_info.add_address_terms(addr_terms)
483 token_info.add_street(streets)
486 def _compute_partial_tokens(self, name):
487 """ Normalize the given term, split it into partial words and return
488 then token list for them.
490 norm_name = self._search_normalized(name)
494 for partial in norm_name.split():
495 token = self._cache.partials.get(partial)
499 need_lookup.append(partial)
502 with self.conn.cursor() as cur:
503 cur.execute("""SELECT word, getorcreate_partial_word(word)
504 FROM unnest(%s) word""",
507 for partial, token in cur:
509 self._cache.partials[partial] = token
514 def _retrieve_full_tokens(self, name):
515 """ Get the full name token for the given name, if it exists.
516 The name is only retrived for the standard analyser.
518 norm_name = self._search_normalized(name)
520 # return cached if possible
521 if norm_name in self._cache.fulls:
522 return self._cache.fulls[norm_name]
524 with self.conn.cursor() as cur:
525 cur.execute("SELECT word_id FROM word WHERE word_token = %s and type = 'W'",
527 full = [row[0] for row in cur]
529 self._cache.fulls[norm_name] = full
534 def _compute_name_tokens(self, names):
535 """ Computes the full name and partial name tokens for the given
539 partial_tokens = set()
542 analyzer_id = name.get_attr('analyzer')
543 norm_name = self._normalized(name.name)
544 if analyzer_id is None:
547 token_id = f'{norm_name}@{analyzer_id}'
549 full, part = self._cache.names.get(token_id, (None, None))
551 variants = self.token_analysis.analysis[analyzer_id].get_variants_ascii(norm_name)
555 with self.conn.cursor() as cur:
556 cur.execute("SELECT (getorcreate_full_word(%s, %s)).*",
557 (token_id, variants))
558 full, part = cur.fetchone()
560 self._cache.names[token_id] = (full, part)
562 full_tokens.add(full)
563 partial_tokens.update(part)
565 return full_tokens, partial_tokens
568 def _add_postcode(self, postcode):
569 """ Make sure the normalized postcode is present in the word table.
571 if re.search(r'[:,;]', postcode) is None:
572 postcode = self.normalize_postcode(postcode)
574 if postcode not in self._cache.postcodes:
575 term = self._search_normalized(postcode)
579 with self.conn.cursor() as cur:
580 # no word_id needed for postcodes
581 cur.execute("""INSERT INTO word (word_token, type, word)
582 (SELECT %s, 'P', pc FROM (VALUES (%s)) as v(pc)
585 WHERE type = 'P' and word = pc))
586 """, (term, postcode))
587 self._cache.postcodes.add(postcode)
591 """ Collect token information to be sent back to the database.
593 def __init__(self, cache):
598 def _mk_array(tokens):
599 return '{%s}' % ','.join((str(s) for s in tokens))
602 def add_names(self, fulls, partials):
603 """ Adds token information for the normalised names.
605 self.data['names'] = self._mk_array(itertools.chain(fulls, partials))
608 def add_housenumbers(self, conn, hnrs):
609 """ Extract housenumber information from a list of normalised
612 self.data['hnr_tokens'] = self._mk_array(self._cache.get_hnr_tokens(conn, hnrs))
613 self.data['hnr'] = ';'.join(hnrs)
616 def add_street(self, tokens):
617 """ Add addr:street match terms.
619 self.data['street'] = self._mk_array(tokens)
622 def add_place(self, tokens):
623 """ Add addr:place search and match terms.
626 self.data['place'] = self._mk_array(tokens)
629 def add_address_terms(self, terms):
630 """ Add additional address terms.
632 tokens = {key: self._mk_array(partials)
633 for key, partials in terms if partials}
636 self.data['addr'] = tokens
640 """ Cache for token information to avoid repeated database queries.
642 This cache is not thread-safe and needs to be instantiated per
649 self.postcodes = set()
650 self.housenumbers = {}
653 def get_hnr_tokens(self, conn, terms):
654 """ Get token ids for a list of housenumbers, looking them up in the
655 database if necessary. `terms` is an iterable of normalized
662 token = self.housenumbers.get(term)
669 with conn.cursor() as cur:
670 cur.execute("SELECT nr, getorcreate_hnr_id(nr) FROM unnest(%s) as nr",
672 for term, tid in cur:
673 self.housenumbers[term] = tid