]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/tokenizer/icu_tokenizer.py
Merge remote-tracking branch 'upstream/master'
[nominatim.git] / nominatim / tokenizer / icu_tokenizer.py
1 """
2 Tokenizer implementing normalisation as used before Nominatim 4 but using
3 libICU instead of the PostgreSQL module.
4 """
5 import itertools
6 import json
7 import logging
8 import re
9 from textwrap import dedent
10
11 from nominatim.db.connection import connect
12 from nominatim.db.utils import CopyBuffer
13 from nominatim.db.sql_preprocessor import SQLPreprocessor
14 from nominatim.indexer.place_info import PlaceInfo
15 from nominatim.tokenizer.icu_rule_loader import ICURuleLoader
16 from nominatim.tokenizer.base import AbstractAnalyzer, AbstractTokenizer
17
18 DBCFG_TERM_NORMALIZATION = "tokenizer_term_normalization"
19
20 LOG = logging.getLogger()
21
22 def create(dsn, data_dir):
23     """ Create a new instance of the tokenizer provided by this module.
24     """
25     return LegacyICUTokenizer(dsn, data_dir)
26
27
28 class LegacyICUTokenizer(AbstractTokenizer):
29     """ This tokenizer uses libICU to covert names and queries to ASCII.
30         Otherwise it uses the same algorithms and data structures as the
31         normalization routines in Nominatim 3.
32     """
33
34     def __init__(self, dsn, data_dir):
35         self.dsn = dsn
36         self.data_dir = data_dir
37         self.loader = None
38
39
40     def init_new_db(self, config, init_db=True):
41         """ Set up a new tokenizer for the database.
42
43             This copies all necessary data in the project directory to make
44             sure the tokenizer remains stable even over updates.
45         """
46         self.loader = ICURuleLoader(config)
47
48         self._install_php(config.lib_dir.php)
49         self._save_config()
50
51         if init_db:
52             self.update_sql_functions(config)
53             self._init_db_tables(config)
54
55
56     def init_from_project(self, config):
57         """ Initialise the tokenizer from the project directory.
58         """
59         self.loader = ICURuleLoader(config)
60
61         with connect(self.dsn) as conn:
62             self.loader.load_config_from_db(conn)
63
64
65     def finalize_import(self, config):
66         """ Do any required postprocessing to make the tokenizer data ready
67             for use.
68         """
69         with connect(self.dsn) as conn:
70             sqlp = SQLPreprocessor(conn, config)
71             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_indices.sql')
72
73
74     def update_sql_functions(self, config):
75         """ Reimport the SQL functions for this tokenizer.
76         """
77         with connect(self.dsn) as conn:
78             sqlp = SQLPreprocessor(conn, config)
79             sqlp.run_sql_file(conn, 'tokenizer/icu_tokenizer.sql')
80
81
82     def check_database(self, config):
83         """ Check that the tokenizer is set up correctly.
84         """
85         # Will throw an error if there is an issue.
86         self.init_from_project(config)
87
88
89     def update_statistics(self):
90         """ Recompute frequencies for all name words.
91         """
92         with connect(self.dsn) as conn:
93             if conn.table_exists('search_name'):
94                 with conn.cursor() as cur:
95                     cur.drop_table("word_frequencies")
96                     LOG.info("Computing word frequencies")
97                     cur.execute("""CREATE TEMP TABLE word_frequencies AS
98                                      SELECT unnest(name_vector) as id, count(*)
99                                      FROM search_name GROUP BY id""")
100                     cur.execute("CREATE INDEX ON word_frequencies(id)")
101                     LOG.info("Update word table with recomputed frequencies")
102                     cur.execute("""UPDATE word
103                                    SET info = info || jsonb_build_object('count', count)
104                                    FROM word_frequencies WHERE word_id = id""")
105                     cur.drop_table("word_frequencies")
106             conn.commit()
107
108
109     def name_analyzer(self):
110         """ Create a new analyzer for tokenizing names and queries
111             using this tokinzer. Analyzers are context managers and should
112             be used accordingly:
113
114             ```
115             with tokenizer.name_analyzer() as analyzer:
116                 analyser.tokenize()
117             ```
118
119             When used outside the with construct, the caller must ensure to
120             call the close() function before destructing the analyzer.
121
122             Analyzers are not thread-safe. You need to instantiate one per thread.
123         """
124         return LegacyICUNameAnalyzer(self.dsn, self.loader.make_sanitizer(),
125                                      self.loader.make_token_analysis())
126
127
128     def _install_php(self, phpdir):
129         """ Install the php script for the tokenizer.
130         """
131         php_file = self.data_dir / "tokenizer.php"
132         php_file.write_text(dedent(f"""\
133             <?php
134             @define('CONST_Max_Word_Frequency', 10000000);
135             @define('CONST_Term_Normalization_Rules', "{self.loader.normalization_rules}");
136             @define('CONST_Transliteration', "{self.loader.get_search_rules()}");
137             require_once('{phpdir}/tokenizer/icu_tokenizer.php');"""))
138
139
140     def _save_config(self):
141         """ Save the configuration that needs to remain stable for the given
142             database as database properties.
143         """
144         with connect(self.dsn) as conn:
145             self.loader.save_config_to_db(conn)
146
147
148     def _init_db_tables(self, config):
149         """ Set up the word table and fill it with pre-computed word
150             frequencies.
151         """
152         with connect(self.dsn) as conn:
153             sqlp = SQLPreprocessor(conn, config)
154             sqlp.run_sql_file(conn, 'tokenizer/icu_tokenizer_tables.sql')
155             conn.commit()
156
157
158 class LegacyICUNameAnalyzer(AbstractAnalyzer):
159     """ The legacy analyzer uses the ICU library for splitting names.
160
161         Each instance opens a connection to the database to request the
162         normalization.
163     """
164
165     def __init__(self, dsn, sanitizer, token_analysis):
166         self.conn = connect(dsn).connection
167         self.conn.autocommit = True
168         self.sanitizer = sanitizer
169         self.token_analysis = token_analysis
170
171         self._cache = _TokenCache()
172
173
174     def close(self):
175         """ Free all resources used by the analyzer.
176         """
177         if self.conn:
178             self.conn.close()
179             self.conn = None
180
181
182     def _search_normalized(self, name):
183         """ Return the search token transliteration of the given name.
184         """
185         return self.token_analysis.search.transliterate(name).strip()
186
187
188     def _normalized(self, name):
189         """ Return the normalized version of the given name with all
190             non-relevant information removed.
191         """
192         return self.token_analysis.normalizer.transliterate(name).strip()
193
194
195     def get_word_token_info(self, words):
196         """ Return token information for the given list of words.
197             If a word starts with # it is assumed to be a full name
198             otherwise is a partial name.
199
200             The function returns a list of tuples with
201             (original word, word token, word id).
202
203             The function is used for testing and debugging only
204             and not necessarily efficient.
205         """
206         full_tokens = {}
207         partial_tokens = {}
208         for word in words:
209             if word.startswith('#'):
210                 full_tokens[word] = self._search_normalized(word[1:])
211             else:
212                 partial_tokens[word] = self._search_normalized(word)
213
214         with self.conn.cursor() as cur:
215             cur.execute("""SELECT word_token, word_id
216                             FROM word WHERE word_token = ANY(%s) and type = 'W'
217                         """, (list(full_tokens.values()),))
218             full_ids = {r[0]: r[1] for r in cur}
219             cur.execute("""SELECT word_token, word_id
220                             FROM word WHERE word_token = ANY(%s) and type = 'w'""",
221                         (list(partial_tokens.values()),))
222             part_ids = {r[0]: r[1] for r in cur}
223
224         return [(k, v, full_ids.get(v, None)) for k, v in full_tokens.items()] \
225                + [(k, v, part_ids.get(v, None)) for k, v in partial_tokens.items()]
226
227
228     @staticmethod
229     def normalize_postcode(postcode):
230         """ Convert the postcode to a standardized form.
231
232             This function must yield exactly the same result as the SQL function
233             'token_normalized_postcode()'.
234         """
235         return postcode.strip().upper()
236
237
238     def _make_standard_hnr(self, hnr):
239         """ Create a normalised version of a housenumber.
240
241             This function takes minor shortcuts on transliteration.
242         """
243         return self._search_normalized(hnr)
244
245     def update_postcodes_from_db(self):
246         """ Update postcode tokens in the word table from the location_postcode
247             table.
248         """
249         to_delete = []
250         with self.conn.cursor() as cur:
251             # This finds us the rows in location_postcode and word that are
252             # missing in the other table.
253             cur.execute("""SELECT * FROM
254                             (SELECT pc, word FROM
255                               (SELECT distinct(postcode) as pc FROM location_postcode) p
256                               FULL JOIN
257                               (SELECT word FROM word WHERE type = 'P') w
258                               ON pc = word) x
259                            WHERE pc is null or word is null""")
260
261             with CopyBuffer() as copystr:
262                 for postcode, word in cur:
263                     if postcode is None:
264                         to_delete.append(word)
265                     else:
266                         copystr.add(self._search_normalized(postcode),
267                                     'P', postcode)
268
269                 if to_delete:
270                     cur.execute("""DELETE FROM WORD
271                                    WHERE type ='P' and word = any(%s)
272                                 """, (to_delete, ))
273
274                 copystr.copy_out(cur, 'word',
275                                  columns=['word_token', 'type', 'word'])
276
277
278     def update_special_phrases(self, phrases, should_replace):
279         """ Replace the search index for special phrases with the new phrases.
280             If `should_replace` is True, then the previous set of will be
281             completely replaced. Otherwise the phrases are added to the
282             already existing ones.
283         """
284         norm_phrases = set(((self._normalized(p[0]), p[1], p[2], p[3])
285                             for p in phrases))
286
287         with self.conn.cursor() as cur:
288             # Get the old phrases.
289             existing_phrases = set()
290             cur.execute("SELECT word, info FROM word WHERE type = 'S'")
291             for word, info in cur:
292                 existing_phrases.add((word, info['class'], info['type'],
293                                       info.get('op') or '-'))
294
295             added = self._add_special_phrases(cur, norm_phrases, existing_phrases)
296             if should_replace:
297                 deleted = self._remove_special_phrases(cur, norm_phrases,
298                                                        existing_phrases)
299             else:
300                 deleted = 0
301
302         LOG.info("Total phrases: %s. Added: %s. Deleted: %s",
303                  len(norm_phrases), added, deleted)
304
305
306     def _add_special_phrases(self, cursor, new_phrases, existing_phrases):
307         """ Add all phrases to the database that are not yet there.
308         """
309         to_add = new_phrases - existing_phrases
310
311         added = 0
312         with CopyBuffer() as copystr:
313             for word, cls, typ, oper in to_add:
314                 term = self._search_normalized(word)
315                 if term:
316                     copystr.add(term, 'S', word,
317                                 json.dumps({'class': cls, 'type': typ,
318                                             'op': oper if oper in ('in', 'near') else None}))
319                     added += 1
320
321             copystr.copy_out(cursor, 'word',
322                              columns=['word_token', 'type', 'word', 'info'])
323
324         return added
325
326
327     @staticmethod
328     def _remove_special_phrases(cursor, new_phrases, existing_phrases):
329         """ Remove all phrases from the databse that are no longer in the
330             new phrase list.
331         """
332         to_delete = existing_phrases - new_phrases
333
334         if to_delete:
335             cursor.execute_values(
336                 """ DELETE FROM word USING (VALUES %s) as v(name, in_class, in_type, op)
337                     WHERE type = 'S' and word = name
338                           and info->>'class' = in_class and info->>'type' = in_type
339                           and ((op = '-' and info->>'op' is null) or op = info->>'op')
340                 """, to_delete)
341
342         return len(to_delete)
343
344
345     def add_country_names(self, country_code, names):
346         """ Add names for the given country to the search index.
347         """
348         # Make sure any name preprocessing for country names applies.
349         info = PlaceInfo({'name': names, 'country_code': country_code,
350                           'rank_address': 4, 'class': 'boundary',
351                           'type': 'administrative'})
352         self._add_country_full_names(country_code,
353                                      self.sanitizer.process_names(info)[0])
354
355
356     def _add_country_full_names(self, country_code, names):
357         """ Add names for the given country from an already sanitized
358             name list.
359         """
360         word_tokens = set()
361         for name in names:
362             norm_name = self._search_normalized(name.name)
363             if norm_name:
364                 word_tokens.add(norm_name)
365
366         with self.conn.cursor() as cur:
367             # Get existing names
368             cur.execute("""SELECT word_token FROM word
369                             WHERE type = 'C' and word = %s""",
370                         (country_code, ))
371             word_tokens.difference_update((t[0] for t in cur))
372
373             # Only add those names that are not yet in the list.
374             if word_tokens:
375                 cur.execute("""INSERT INTO word (word_token, type, word)
376                                (SELECT token, 'C', %s
377                                 FROM unnest(%s) as token)
378                             """, (country_code, list(word_tokens)))
379
380             # No names are deleted at the moment.
381             # If deletion is made possible, then the static names from the
382             # initial 'country_name' table should be kept.
383
384
385     def process_place(self, place):
386         """ Determine tokenizer information about the given place.
387
388             Returns a JSON-serializable structure that will be handed into
389             the database via the token_info field.
390         """
391         token_info = _TokenInfo(self._cache)
392
393         names, address = self.sanitizer.process_names(place)
394
395         if names:
396             fulls, partials = self._compute_name_tokens(names)
397
398             token_info.add_names(fulls, partials)
399
400             if place.is_country():
401                 self._add_country_full_names(place.country_code, names)
402
403         if address:
404             self._process_place_address(token_info, address)
405
406         return token_info.data
407
408
409     def _process_place_address(self, token_info, address):
410         hnrs = []
411         addr_terms = []
412         for item in address:
413             if item.kind == 'postcode':
414                 self._add_postcode(item.name)
415             elif item.kind in ('housenumber', 'streetnumber', 'conscriptionnumber'):
416                 hnrs.append(item.name)
417             elif item.kind == 'street':
418                 token_info.add_street(self._compute_partial_tokens(item.name))
419             elif item.kind == 'place':
420                 token_info.add_place(self._compute_partial_tokens(item.name))
421             elif not item.kind.startswith('_') and \
422                  item.kind not in ('country', 'full'):
423                 addr_terms.append((item.kind, self._compute_partial_tokens(item.name)))
424
425         if hnrs:
426             hnrs = self._split_housenumbers(hnrs)
427             token_info.add_housenumbers(self.conn, [self._make_standard_hnr(n) for n in hnrs])
428
429         if addr_terms:
430             token_info.add_address_terms(addr_terms)
431
432
433     def _compute_partial_tokens(self, name):
434         """ Normalize the given term, split it into partial words and return
435             then token list for them.
436         """
437         norm_name = self._search_normalized(name)
438
439         tokens = []
440         need_lookup = []
441         for partial in norm_name.split():
442             token = self._cache.partials.get(partial)
443             if token:
444                 tokens.append(token)
445             else:
446                 need_lookup.append(partial)
447
448         if need_lookup:
449             with self.conn.cursor() as cur:
450                 cur.execute("""SELECT word, getorcreate_partial_word(word)
451                                FROM unnest(%s) word""",
452                             (need_lookup, ))
453
454                 for partial, token in cur:
455                     tokens.append(token)
456                     self._cache.partials[partial] = token
457
458         return tokens
459
460
461     def _compute_name_tokens(self, names):
462         """ Computes the full name and partial name tokens for the given
463             dictionary of names.
464         """
465         full_tokens = set()
466         partial_tokens = set()
467
468         for name in names:
469             analyzer_id = name.get_attr('analyzer')
470             norm_name = self._normalized(name.name)
471             if analyzer_id is None:
472                 token_id = norm_name
473             else:
474                 token_id = f'{norm_name}@{analyzer_id}'
475
476             full, part = self._cache.names.get(token_id, (None, None))
477             if full is None:
478                 variants = self.token_analysis.analysis[analyzer_id].get_variants_ascii(norm_name)
479                 if not variants:
480                     continue
481
482                 with self.conn.cursor() as cur:
483                     cur.execute("SELECT (getorcreate_full_word(%s, %s)).*",
484                                 (token_id, variants))
485                     full, part = cur.fetchone()
486
487                 self._cache.names[token_id] = (full, part)
488
489             full_tokens.add(full)
490             partial_tokens.update(part)
491
492         return full_tokens, partial_tokens
493
494
495     def _add_postcode(self, postcode):
496         """ Make sure the normalized postcode is present in the word table.
497         """
498         if re.search(r'[:,;]', postcode) is None:
499             postcode = self.normalize_postcode(postcode)
500
501             if postcode not in self._cache.postcodes:
502                 term = self._search_normalized(postcode)
503                 if not term:
504                     return
505
506                 with self.conn.cursor() as cur:
507                     # no word_id needed for postcodes
508                     cur.execute("""INSERT INTO word (word_token, type, word)
509                                    (SELECT %s, 'P', pc FROM (VALUES (%s)) as v(pc)
510                                     WHERE NOT EXISTS
511                                      (SELECT * FROM word
512                                       WHERE type = 'P' and word = pc))
513                                 """, (term, postcode))
514                 self._cache.postcodes.add(postcode)
515
516
517     @staticmethod
518     def _split_housenumbers(hnrs):
519         if len(hnrs) > 1 or ',' in hnrs[0] or ';' in hnrs[0]:
520             # split numbers if necessary
521             simple_list = []
522             for hnr in hnrs:
523                 simple_list.extend((x.strip() for x in re.split(r'[;,]', hnr)))
524
525             if len(simple_list) > 1:
526                 hnrs = list(set(simple_list))
527             else:
528                 hnrs = simple_list
529
530         return hnrs
531
532
533
534
535 class _TokenInfo:
536     """ Collect token information to be sent back to the database.
537     """
538     def __init__(self, cache):
539         self._cache = cache
540         self.data = {}
541
542     @staticmethod
543     def _mk_array(tokens):
544         return '{%s}' % ','.join((str(s) for s in tokens))
545
546
547     def add_names(self, fulls, partials):
548         """ Adds token information for the normalised names.
549         """
550         self.data['names'] = self._mk_array(itertools.chain(fulls, partials))
551
552
553     def add_housenumbers(self, conn, hnrs):
554         """ Extract housenumber information from a list of normalised
555             housenumbers.
556         """
557         self.data['hnr_tokens'] = self._mk_array(self._cache.get_hnr_tokens(conn, hnrs))
558         self.data['hnr'] = ';'.join(hnrs)
559
560
561     def add_street(self, tokens):
562         """ Add addr:street match terms.
563         """
564         if tokens:
565             self.data['street'] = self._mk_array(tokens)
566
567
568     def add_place(self, tokens):
569         """ Add addr:place search and match terms.
570         """
571         if tokens:
572             self.data['place'] = self._mk_array(tokens)
573
574
575     def add_address_terms(self, terms):
576         """ Add additional address terms.
577         """
578         tokens = {key: self._mk_array(partials)
579                   for key, partials in terms if partials}
580
581         if tokens:
582             self.data['addr'] = tokens
583
584
585 class _TokenCache:
586     """ Cache for token information to avoid repeated database queries.
587
588         This cache is not thread-safe and needs to be instantiated per
589         analyzer.
590     """
591     def __init__(self):
592         self.names = {}
593         self.partials = {}
594         self.postcodes = set()
595         self.housenumbers = {}
596
597
598     def get_hnr_tokens(self, conn, terms):
599         """ Get token ids for a list of housenumbers, looking them up in the
600             database if necessary. `terms` is an iterable of normalized
601             housenumbers.
602         """
603         tokens = []
604         askdb = []
605
606         for term in terms:
607             token = self.housenumbers.get(term)
608             if token is None:
609                 askdb.append(term)
610             else:
611                 tokens.append(token)
612
613         if askdb:
614             with conn.cursor() as cur:
615                 cur.execute("SELECT nr, getorcreate_hnr_id(nr) FROM unnest(%s) as nr",
616                             (askdb, ))
617                 for term, tid in cur:
618                     self.housenumbers[term] = tid
619                     tokens.append(tid)
620
621         return tokens