]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/tokenizer/icu_tokenizer.py
9c25b6d7940fc145a2565a326d239463e32227cc
[nominatim.git] / nominatim / tokenizer / icu_tokenizer.py
1 # SPDX-License-Identifier: GPL-2.0-only
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2022 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 but using
9 libICU instead of the PostgreSQL module.
10 """
11 import itertools
12 import json
13 import logging
14 import re
15 from textwrap import dedent
16
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
23
24 DBCFG_TERM_NORMALIZATION = "tokenizer_term_normalization"
25
26 LOG = logging.getLogger()
27
28 def create(dsn, data_dir):
29     """ Create a new instance of the tokenizer provided by this module.
30     """
31     return LegacyICUTokenizer(dsn, data_dir)
32
33
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.
38     """
39
40     def __init__(self, dsn, data_dir):
41         self.dsn = dsn
42         self.data_dir = data_dir
43         self.loader = None
44
45
46     def init_new_db(self, config, init_db=True):
47         """ Set up a new tokenizer for the database.
48
49             This copies all necessary data in the project directory to make
50             sure the tokenizer remains stable even over updates.
51         """
52         self.loader = ICURuleLoader(config)
53
54         self._install_php(config.lib_dir.php)
55         self._save_config()
56
57         if init_db:
58             self.update_sql_functions(config)
59             self._init_db_tables(config)
60
61
62     def init_from_project(self, config):
63         """ Initialise the tokenizer from the project directory.
64         """
65         self.loader = ICURuleLoader(config)
66
67         with connect(self.dsn) as conn:
68             self.loader.load_config_from_db(conn)
69
70
71     def finalize_import(self, config):
72         """ Do any required postprocessing to make the tokenizer data ready
73             for use.
74         """
75         with connect(self.dsn) as conn:
76             sqlp = SQLPreprocessor(conn, config)
77             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_indices.sql')
78
79
80     def update_sql_functions(self, config):
81         """ Reimport the SQL functions for this tokenizer.
82         """
83         with connect(self.dsn) as conn:
84             sqlp = SQLPreprocessor(conn, config)
85             sqlp.run_sql_file(conn, 'tokenizer/icu_tokenizer.sql')
86
87
88     def check_database(self, config):
89         """ Check that the tokenizer is set up correctly.
90         """
91         # Will throw an error if there is an issue.
92         self.init_from_project(config)
93
94
95     def update_statistics(self):
96         """ Recompute frequencies for all name words.
97         """
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")
112             conn.commit()
113
114
115     def _cleanup_housenumbers(self):
116         """ Remove unused house numbers.
117         """
118         with connect(self.dsn) as conn:
119             if not conn.table_exists('search_name'):
120                 return
121             with conn.cursor(name="hnr_counter") as cur:
122                 cur.execute("""SELECT word_id, word_token FROM word
123                                WHERE type = 'H'
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+')
128                             """)
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+')
135                             """)
136                 for row in cur:
137                     for hnr in row[0].split(';'):
138                         candidates.pop(hnr, None)
139             LOG.info("There are %s outdated housenumbers.", len(candidates))
140             if candidates:
141                 with conn.cursor() as cur:
142                     cur.execute("""DELETE FROM word WHERE word_id = any(%s)""",
143                                 (list(candidates.values()), ))
144                 conn.commit()
145
146
147
148     def update_word_tokens(self):
149         """ Remove unused tokens.
150         """
151         LOG.warning("Cleaning up housenumber tokens.")
152         self._cleanup_housenumbers()
153         LOG.warning("Tokenizer house-keeping done.")
154
155
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
159             be used accordingly:
160
161             ```
162             with tokenizer.name_analyzer() as analyzer:
163                 analyser.tokenize()
164             ```
165
166             When used outside the with construct, the caller must ensure to
167             call the close() function before destructing the analyzer.
168
169             Analyzers are not thread-safe. You need to instantiate one per thread.
170         """
171         return LegacyICUNameAnalyzer(self.dsn, self.loader.make_sanitizer(),
172                                      self.loader.make_token_analysis())
173
174
175     def _install_php(self, phpdir):
176         """ Install the php script for the tokenizer.
177         """
178         php_file = self.data_dir / "tokenizer.php"
179         php_file.write_text(dedent(f"""\
180             <?php
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');"""))
185
186
187     def _save_config(self):
188         """ Save the configuration that needs to remain stable for the given
189             database as database properties.
190         """
191         with connect(self.dsn) as conn:
192             self.loader.save_config_to_db(conn)
193
194
195     def _init_db_tables(self, config):
196         """ Set up the word table and fill it with pre-computed word
197             frequencies.
198         """
199         with connect(self.dsn) as conn:
200             sqlp = SQLPreprocessor(conn, config)
201             sqlp.run_sql_file(conn, 'tokenizer/icu_tokenizer_tables.sql')
202             conn.commit()
203
204
205 class LegacyICUNameAnalyzer(AbstractAnalyzer):
206     """ The legacy analyzer uses the ICU library for splitting names.
207
208         Each instance opens a connection to the database to request the
209         normalization.
210     """
211
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
217
218         self._cache = _TokenCache()
219
220
221     def close(self):
222         """ Free all resources used by the analyzer.
223         """
224         if self.conn:
225             self.conn.close()
226             self.conn = None
227
228
229     def _search_normalized(self, name):
230         """ Return the search token transliteration of the given name.
231         """
232         return self.token_analysis.search.transliterate(name).strip()
233
234
235     def _normalized(self, name):
236         """ Return the normalized version of the given name with all
237             non-relevant information removed.
238         """
239         return self.token_analysis.normalizer.transliterate(name).strip()
240
241
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.
246
247             The function returns a list of tuples with
248             (original word, word token, word id).
249
250             The function is used for testing and debugging only
251             and not necessarily efficient.
252         """
253         full_tokens = {}
254         partial_tokens = {}
255         for word in words:
256             if word.startswith('#'):
257                 full_tokens[word] = self._search_normalized(word[1:])
258             else:
259                 partial_tokens[word] = self._search_normalized(word)
260
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}
270
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()]
273
274
275     @staticmethod
276     def normalize_postcode(postcode):
277         """ Convert the postcode to a standardized form.
278
279             This function must yield exactly the same result as the SQL function
280             'token_normalized_postcode()'.
281         """
282         return postcode.strip().upper()
283
284
285     def _make_standard_hnr(self, hnr):
286         """ Create a normalised version of a housenumber.
287
288             This function takes minor shortcuts on transliteration.
289         """
290         return self._search_normalized(hnr)
291
292     def update_postcodes_from_db(self):
293         """ Update postcode tokens in the word table from the location_postcode
294             table.
295         """
296         to_delete = []
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
303                               FULL JOIN
304                               (SELECT word FROM word WHERE type = 'P') w
305                               ON pc = word) x
306                            WHERE pc is null or word is null""")
307
308             with CopyBuffer() as copystr:
309                 for postcode, word in cur:
310                     if postcode is None:
311                         to_delete.append(word)
312                     else:
313                         copystr.add(self._search_normalized(postcode),
314                                     'P', postcode)
315
316                 if to_delete:
317                     cur.execute("""DELETE FROM WORD
318                                    WHERE type ='P' and word = any(%s)
319                                 """, (to_delete, ))
320
321                 copystr.copy_out(cur, 'word',
322                                  columns=['word_token', 'type', 'word'])
323
324
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.
330         """
331         norm_phrases = set(((self._normalized(p[0]), p[1], p[2], p[3])
332                             for p in phrases))
333
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 '-'))
341
342             added = self._add_special_phrases(cur, norm_phrases, existing_phrases)
343             if should_replace:
344                 deleted = self._remove_special_phrases(cur, norm_phrases,
345                                                        existing_phrases)
346             else:
347                 deleted = 0
348
349         LOG.info("Total phrases: %s. Added: %s. Deleted: %s",
350                  len(norm_phrases), added, deleted)
351
352
353     def _add_special_phrases(self, cursor, new_phrases, existing_phrases):
354         """ Add all phrases to the database that are not yet there.
355         """
356         to_add = new_phrases - existing_phrases
357
358         added = 0
359         with CopyBuffer() as copystr:
360             for word, cls, typ, oper in to_add:
361                 term = self._search_normalized(word)
362                 if term:
363                     copystr.add(term, 'S', word,
364                                 json.dumps({'class': cls, 'type': typ,
365                                             'op': oper if oper in ('in', 'near') else None}))
366                     added += 1
367
368             copystr.copy_out(cursor, 'word',
369                              columns=['word_token', 'type', 'word', 'info'])
370
371         return added
372
373
374     @staticmethod
375     def _remove_special_phrases(cursor, new_phrases, existing_phrases):
376         """ Remove all phrases from the databse that are no longer in the
377             new phrase list.
378         """
379         to_delete = existing_phrases - new_phrases
380
381         if to_delete:
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')
387                 """, to_delete)
388
389         return len(to_delete)
390
391
392     def add_country_names(self, country_code, names):
393         """ Add default names for the given country to the search index.
394         """
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],
401                                      internal=True)
402
403
404     def _add_country_full_names(self, country_code, names, internal=False):
405         """ Add names for the given country from an already sanitized
406             name list.
407         """
408         word_tokens = set()
409         for name in names:
410             norm_name = self._search_normalized(name.name)
411             if norm_name:
412                 word_tokens.add(norm_name)
413
414         with self.conn.cursor() as cur:
415             # Get existing names
416             cur.execute("""SELECT word_token, coalesce(info ? 'internal', false) as is_internal
417                              FROM word
418                              WHERE type = 'C' and word = %s""",
419                         (country_code, ))
420             existing_tokens = {True: set(), False: set()} # internal/external names
421             for word in cur:
422                 existing_tokens[word[1]].add(word[0])
423
424             # Delete names that no longer exist.
425             gone_tokens = existing_tokens[internal] - word_tokens
426             if internal:
427                 gone_tokens.update(existing_tokens[False] & word_tokens)
428             if gone_tokens:
429                 cur.execute("""DELETE FROM word
430                                USING unnest(%s) as token
431                                WHERE type = 'C' and word = %s
432                                      and word_token = token""",
433                             (list(gone_tokens), country_code))
434
435             # Only add those names that are not yet in the list.
436             new_tokens = word_tokens - existing_tokens[True]
437             if not internal:
438                 new_tokens -= existing_tokens[False]
439             if new_tokens:
440                 if internal:
441                     sql = """INSERT INTO word (word_token, type, word, info)
442                                (SELECT token, 'C', %s, '{"internal": "yes"}'
443                                   FROM unnest(%s) as token)
444                            """
445                 else:
446                     sql = """INSERT INTO word (word_token, type, word)
447                                    (SELECT token, 'C', %s
448                                     FROM unnest(%s) as token)
449                           """
450                 cur.execute(sql, (country_code, list(new_tokens)))
451
452
453     def process_place(self, place):
454         """ Determine tokenizer information about the given place.
455
456             Returns a JSON-serializable structure that will be handed into
457             the database via the token_info field.
458         """
459         token_info = _TokenInfo(self._cache)
460
461         names, address = self.sanitizer.process_names(place)
462
463         if names:
464             fulls, partials = self._compute_name_tokens(names)
465
466             token_info.add_names(fulls, partials)
467
468             if place.is_country():
469                 self._add_country_full_names(place.country_code, names)
470
471         if address:
472             self._process_place_address(token_info, address)
473
474         return token_info.data
475
476
477     def _process_place_address(self, token_info, address):
478         hnrs = set()
479         addr_terms = []
480         streets = []
481         for item in address:
482             if item.kind == 'postcode':
483                 self._add_postcode(item.name)
484             elif item.kind == 'housenumber':
485                 norm_name = self._make_standard_hnr(item.name)
486                 if norm_name:
487                     hnrs.add(norm_name)
488             elif item.kind == 'street':
489                 streets.extend(self._retrieve_full_tokens(item.name))
490             elif item.kind == 'place':
491                 if not item.suffix:
492                     token_info.add_place(self._compute_partial_tokens(item.name))
493             elif not item.kind.startswith('_') and not item.suffix and \
494                  item.kind not in ('country', 'full'):
495                 addr_terms.append((item.kind, self._compute_partial_tokens(item.name)))
496
497         if hnrs:
498             token_info.add_housenumbers(self.conn, hnrs)
499
500         if addr_terms:
501             token_info.add_address_terms(addr_terms)
502
503         if streets:
504             token_info.add_street(streets)
505
506
507     def _compute_partial_tokens(self, name):
508         """ Normalize the given term, split it into partial words and return
509             then token list for them.
510         """
511         norm_name = self._search_normalized(name)
512
513         tokens = []
514         need_lookup = []
515         for partial in norm_name.split():
516             token = self._cache.partials.get(partial)
517             if token:
518                 tokens.append(token)
519             else:
520                 need_lookup.append(partial)
521
522         if need_lookup:
523             with self.conn.cursor() as cur:
524                 cur.execute("""SELECT word, getorcreate_partial_word(word)
525                                FROM unnest(%s) word""",
526                             (need_lookup, ))
527
528                 for partial, token in cur:
529                     tokens.append(token)
530                     self._cache.partials[partial] = token
531
532         return tokens
533
534
535     def _retrieve_full_tokens(self, name):
536         """ Get the full name token for the given name, if it exists.
537             The name is only retrived for the standard analyser.
538         """
539         norm_name = self._search_normalized(name)
540
541         # return cached if possible
542         if norm_name in self._cache.fulls:
543             return self._cache.fulls[norm_name]
544
545         with self.conn.cursor() as cur:
546             cur.execute("SELECT word_id FROM word WHERE word_token = %s and type = 'W'",
547                         (norm_name, ))
548             full = [row[0] for row in cur]
549
550         self._cache.fulls[norm_name] = full
551
552         return full
553
554
555     def _compute_name_tokens(self, names):
556         """ Computes the full name and partial name tokens for the given
557             dictionary of names.
558         """
559         full_tokens = set()
560         partial_tokens = set()
561
562         for name in names:
563             analyzer_id = name.get_attr('analyzer')
564             norm_name = self._normalized(name.name)
565             if analyzer_id is None:
566                 token_id = norm_name
567             else:
568                 token_id = f'{norm_name}@{analyzer_id}'
569
570             full, part = self._cache.names.get(token_id, (None, None))
571             if full is None:
572                 variants = self.token_analysis.analysis[analyzer_id].get_variants_ascii(norm_name)
573                 if not variants:
574                     continue
575
576                 with self.conn.cursor() as cur:
577                     cur.execute("SELECT (getorcreate_full_word(%s, %s)).*",
578                                 (token_id, variants))
579                     full, part = cur.fetchone()
580
581                 self._cache.names[token_id] = (full, part)
582
583             full_tokens.add(full)
584             partial_tokens.update(part)
585
586         return full_tokens, partial_tokens
587
588
589     def _add_postcode(self, postcode):
590         """ Make sure the normalized postcode is present in the word table.
591         """
592         if re.search(r'[:,;]', postcode) is None:
593             postcode = self.normalize_postcode(postcode)
594
595             if postcode not in self._cache.postcodes:
596                 term = self._search_normalized(postcode)
597                 if not term:
598                     return
599
600                 with self.conn.cursor() as cur:
601                     # no word_id needed for postcodes
602                     cur.execute("""INSERT INTO word (word_token, type, word)
603                                    (SELECT %s, 'P', pc FROM (VALUES (%s)) as v(pc)
604                                     WHERE NOT EXISTS
605                                      (SELECT * FROM word
606                                       WHERE type = 'P' and word = pc))
607                                 """, (term, postcode))
608                 self._cache.postcodes.add(postcode)
609
610
611 class _TokenInfo:
612     """ Collect token information to be sent back to the database.
613     """
614     def __init__(self, cache):
615         self._cache = cache
616         self.data = {}
617
618     @staticmethod
619     def _mk_array(tokens):
620         return '{%s}' % ','.join((str(s) for s in tokens))
621
622
623     def add_names(self, fulls, partials):
624         """ Adds token information for the normalised names.
625         """
626         self.data['names'] = self._mk_array(itertools.chain(fulls, partials))
627
628
629     def add_housenumbers(self, conn, hnrs):
630         """ Extract housenumber information from a list of normalised
631             housenumbers.
632         """
633         self.data['hnr_tokens'] = self._mk_array(self._cache.get_hnr_tokens(conn, hnrs))
634         self.data['hnr'] = ';'.join(hnrs)
635
636
637     def add_street(self, tokens):
638         """ Add addr:street match terms.
639         """
640         self.data['street'] = self._mk_array(tokens)
641
642
643     def add_place(self, tokens):
644         """ Add addr:place search and match terms.
645         """
646         if tokens:
647             self.data['place'] = self._mk_array(tokens)
648
649
650     def add_address_terms(self, terms):
651         """ Add additional address terms.
652         """
653         tokens = {key: self._mk_array(partials)
654                   for key, partials in terms if partials}
655
656         if tokens:
657             self.data['addr'] = tokens
658
659
660 class _TokenCache:
661     """ Cache for token information to avoid repeated database queries.
662
663         This cache is not thread-safe and needs to be instantiated per
664         analyzer.
665     """
666     def __init__(self):
667         self.names = {}
668         self.partials = {}
669         self.fulls = {}
670         self.postcodes = set()
671         self.housenumbers = {}
672
673
674     def get_hnr_tokens(self, conn, terms):
675         """ Get token ids for a list of housenumbers, looking them up in the
676             database if necessary. `terms` is an iterable of normalized
677             housenumbers.
678         """
679         tokens = []
680         askdb = []
681
682         for term in terms:
683             token = self.housenumbers.get(term)
684             if token is None:
685                 askdb.append(term)
686             else:
687                 tokens.append(token)
688
689         if askdb:
690             with conn.cursor() as cur:
691                 cur.execute("SELECT nr, getorcreate_hnr_id(nr) FROM unnest(%s) as nr",
692                             (askdb, ))
693                 for term, tid in cur:
694                     self.housenumbers[term] = tid
695                     tokens.append(tid)
696
697         return tokens