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 existing_tokens = {t[0] for t in cur}
420 # Only add those names that are not yet in the list.
421 new_tokens = word_tokens - existing_tokens
423 cur.execute("""INSERT INTO word (word_token, type, word)
424 (SELECT token, 'C', %s
425 FROM unnest(%s) as token)
426 """, (country_code, list(new_tokens)))
428 # Delete names that no longer exist.
429 gone_tokens = existing_tokens - word_tokens
431 cur.execute("""DELETE FROM word
432 USING unnest(%s) as token
433 WHERE type = 'C' and word = %s
434 and word_token = token""",
435 (list(gone_tokens), country_code))
438 def process_place(self, place):
439 """ Determine tokenizer information about the given place.
441 Returns a JSON-serializable structure that will be handed into
442 the database via the token_info field.
444 token_info = _TokenInfo(self._cache)
446 names, address = self.sanitizer.process_names(place)
449 fulls, partials = self._compute_name_tokens(names)
451 token_info.add_names(fulls, partials)
453 if place.is_country():
454 self._add_country_full_names(place.country_code, names)
457 self._process_place_address(token_info, address)
459 return token_info.data
462 def _process_place_address(self, token_info, address):
467 if item.kind == 'postcode':
468 self._add_postcode(item.name)
469 elif item.kind == 'housenumber':
470 norm_name = self._make_standard_hnr(item.name)
473 elif item.kind == 'street':
474 streets.extend(self._retrieve_full_tokens(item.name))
475 elif item.kind == 'place':
477 token_info.add_place(self._compute_partial_tokens(item.name))
478 elif not item.kind.startswith('_') and not item.suffix and \
479 item.kind not in ('country', 'full'):
480 addr_terms.append((item.kind, self._compute_partial_tokens(item.name)))
483 token_info.add_housenumbers(self.conn, hnrs)
486 token_info.add_address_terms(addr_terms)
489 token_info.add_street(streets)
492 def _compute_partial_tokens(self, name):
493 """ Normalize the given term, split it into partial words and return
494 then token list for them.
496 norm_name = self._search_normalized(name)
500 for partial in norm_name.split():
501 token = self._cache.partials.get(partial)
505 need_lookup.append(partial)
508 with self.conn.cursor() as cur:
509 cur.execute("""SELECT word, getorcreate_partial_word(word)
510 FROM unnest(%s) word""",
513 for partial, token in cur:
515 self._cache.partials[partial] = token
520 def _retrieve_full_tokens(self, name):
521 """ Get the full name token for the given name, if it exists.
522 The name is only retrived for the standard analyser.
524 norm_name = self._search_normalized(name)
526 # return cached if possible
527 if norm_name in self._cache.fulls:
528 return self._cache.fulls[norm_name]
530 with self.conn.cursor() as cur:
531 cur.execute("SELECT word_id FROM word WHERE word_token = %s and type = 'W'",
533 full = [row[0] for row in cur]
535 self._cache.fulls[norm_name] = full
540 def _compute_name_tokens(self, names):
541 """ Computes the full name and partial name tokens for the given
545 partial_tokens = set()
548 analyzer_id = name.get_attr('analyzer')
549 norm_name = self._normalized(name.name)
550 if analyzer_id is None:
553 token_id = f'{norm_name}@{analyzer_id}'
555 full, part = self._cache.names.get(token_id, (None, None))
557 variants = self.token_analysis.analysis[analyzer_id].get_variants_ascii(norm_name)
561 with self.conn.cursor() as cur:
562 cur.execute("SELECT (getorcreate_full_word(%s, %s)).*",
563 (token_id, variants))
564 full, part = cur.fetchone()
566 self._cache.names[token_id] = (full, part)
568 full_tokens.add(full)
569 partial_tokens.update(part)
571 return full_tokens, partial_tokens
574 def _add_postcode(self, postcode):
575 """ Make sure the normalized postcode is present in the word table.
577 if re.search(r'[:,;]', postcode) is None:
578 postcode = self.normalize_postcode(postcode)
580 if postcode not in self._cache.postcodes:
581 term = self._search_normalized(postcode)
585 with self.conn.cursor() as cur:
586 # no word_id needed for postcodes
587 cur.execute("""INSERT INTO word (word_token, type, word)
588 (SELECT %s, 'P', pc FROM (VALUES (%s)) as v(pc)
591 WHERE type = 'P' and word = pc))
592 """, (term, postcode))
593 self._cache.postcodes.add(postcode)
597 """ Collect token information to be sent back to the database.
599 def __init__(self, cache):
604 def _mk_array(tokens):
605 return '{%s}' % ','.join((str(s) for s in tokens))
608 def add_names(self, fulls, partials):
609 """ Adds token information for the normalised names.
611 self.data['names'] = self._mk_array(itertools.chain(fulls, partials))
614 def add_housenumbers(self, conn, hnrs):
615 """ Extract housenumber information from a list of normalised
618 self.data['hnr_tokens'] = self._mk_array(self._cache.get_hnr_tokens(conn, hnrs))
619 self.data['hnr'] = ';'.join(hnrs)
622 def add_street(self, tokens):
623 """ Add addr:street match terms.
625 self.data['street'] = self._mk_array(tokens)
628 def add_place(self, tokens):
629 """ Add addr:place search and match terms.
632 self.data['place'] = self._mk_array(tokens)
635 def add_address_terms(self, terms):
636 """ Add additional address terms.
638 tokens = {key: self._mk_array(partials)
639 for key, partials in terms if partials}
642 self.data['addr'] = tokens
646 """ Cache for token information to avoid repeated database queries.
648 This cache is not thread-safe and needs to be instantiated per
655 self.postcodes = set()
656 self.housenumbers = {}
659 def get_hnr_tokens(self, conn, terms):
660 """ Get token ids for a list of housenumbers, looking them up in the
661 database if necessary. `terms` is an iterable of normalized
668 token = self.housenumbers.get(term)
675 with conn.cursor() as cur:
676 cur.execute("SELECT nr, getorcreate_hnr_id(nr) FROM unnest(%s) as nr",
678 for term, tid in cur:
679 self.housenumbers[term] = tid