]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/tokenizer/legacy_icu_tokenizer.py
reorganize keyword creation for legacy tokenizer
[nominatim.git] / nominatim / tokenizer / legacy_icu_tokenizer.py
1 """
2 Tokenizer implementing normalisation as used before Nominatim 4 but using
3 libICU instead of the PostgreSQL module.
4 """
5 from collections import Counter
6 import functools
7 import io
8 import itertools
9 import json
10 import logging
11 import re
12 from textwrap import dedent
13 from pathlib import Path
14
15 from icu import Transliterator
16 import psycopg2.extras
17
18 from nominatim.db.connection import connect
19 from nominatim.db.properties import set_property, get_property
20 from nominatim.db.sql_preprocessor import SQLPreprocessor
21
22 DBCFG_NORMALIZATION = "tokenizer_normalization"
23 DBCFG_MAXWORDFREQ = "tokenizer_maxwordfreq"
24 DBCFG_TRANSLITERATION = "tokenizer_transliteration"
25 DBCFG_ABBREVIATIONS = "tokenizer_abbreviations"
26
27 LOG = logging.getLogger()
28
29 def create(dsn, data_dir):
30     """ Create a new instance of the tokenizer provided by this module.
31     """
32     return LegacyICUTokenizer(dsn, data_dir)
33
34
35 class LegacyICUTokenizer:
36     """ This tokenizer uses libICU to covert names and queries to ASCII.
37         Otherwise it uses the same algorithms and data structures as the
38         normalization routines in Nominatim 3.
39     """
40
41     def __init__(self, dsn, data_dir):
42         self.dsn = dsn
43         self.data_dir = data_dir
44         self.normalization = None
45         self.transliteration = None
46         self.abbreviations = None
47
48
49     def init_new_db(self, config, init_db=True):
50         """ Set up a new tokenizer for the database.
51
52             This copies all necessary data in the project directory to make
53             sure the tokenizer remains stable even over updates.
54         """
55         if config.TOKENIZER_CONFIG:
56             cfgfile = Path(config.TOKENIZER_CONFIG)
57         else:
58             cfgfile = config.config_dir / 'legacy_icu_tokenizer.json'
59
60         rules = json.loads(cfgfile.read_text())
61         self.transliteration = ';'.join(rules['normalization']) + ';'
62         self.abbreviations = rules["abbreviations"]
63         self.normalization = config.TERM_NORMALIZATION
64
65         self._install_php(config)
66         self._save_config(config)
67
68         if init_db:
69             self.update_sql_functions(config)
70             self._init_db_tables(config)
71
72
73     def init_from_project(self):
74         """ Initialise the tokenizer from the project directory.
75         """
76         with connect(self.dsn) as conn:
77             self.normalization = get_property(conn, DBCFG_NORMALIZATION)
78             self.transliteration = get_property(conn, DBCFG_TRANSLITERATION)
79             self.abbreviations = json.loads(get_property(conn, DBCFG_ABBREVIATIONS))
80
81
82     def finalize_import(self, config):
83         """ Do any required postprocessing to make the tokenizer data ready
84             for use.
85         """
86         with connect(self.dsn) as conn:
87             sqlp = SQLPreprocessor(conn, config)
88             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_indices.sql')
89
90
91     def update_sql_functions(self, config):
92         """ Reimport the SQL functions for this tokenizer.
93         """
94         with connect(self.dsn) as conn:
95             max_word_freq = get_property(conn, DBCFG_MAXWORDFREQ)
96             sqlp = SQLPreprocessor(conn, config)
97             sqlp.run_sql_file(conn, 'tokenizer/legacy_icu_tokenizer.sql',
98                               max_word_freq=max_word_freq)
99
100
101     def check_database(self):
102         """ Check that the tokenizer is set up correctly.
103         """
104         self.init_from_project()
105
106         if self.normalization is None\
107            or self.transliteration is None\
108            or self.abbreviations is None:
109             return "Configuration for tokenizer 'legacy_icu' are missing."
110
111         return None
112
113
114     def name_analyzer(self):
115         """ Create a new analyzer for tokenizing names and queries
116             using this tokinzer. Analyzers are context managers and should
117             be used accordingly:
118
119             ```
120             with tokenizer.name_analyzer() as analyzer:
121                 analyser.tokenize()
122             ```
123
124             When used outside the with construct, the caller must ensure to
125             call the close() function before destructing the analyzer.
126
127             Analyzers are not thread-safe. You need to instantiate one per thread.
128         """
129         norm = Transliterator.createFromRules("normalizer", self.normalization)
130         trans = Transliterator.createFromRules("trans", self.transliteration)
131         return LegacyICUNameAnalyzer(self.dsn, norm, trans, self.abbreviations)
132
133
134     def _install_php(self, config):
135         """ Install the php script for the tokenizer.
136         """
137         abbr_inverse = list(zip(*self.abbreviations))
138         php_file = self.data_dir / "tokenizer.php"
139         php_file.write_text(dedent("""\
140             <?php
141             @define('CONST_Max_Word_Frequency', {1.MAX_WORD_FREQUENCY});
142             @define('CONST_Term_Normalization_Rules', "{0.normalization}");
143             @define('CONST_Transliteration', "{0.transliteration}");
144             @define('CONST_Abbreviations', array(array('{2}'), array('{3}')));
145             require_once('{1.lib_dir.php}/tokenizer/legacy_icu_tokenizer.php');
146             """.format(self, config,
147                        "','".join(abbr_inverse[0]),
148                        "','".join(abbr_inverse[1]))))
149
150
151     def _save_config(self, config):
152         """ Save the configuration that needs to remain stable for the given
153             database as database properties.
154         """
155         with connect(self.dsn) as conn:
156             set_property(conn, DBCFG_NORMALIZATION, self.normalization)
157             set_property(conn, DBCFG_MAXWORDFREQ, config.MAX_WORD_FREQUENCY)
158             set_property(conn, DBCFG_TRANSLITERATION, self.transliteration)
159             set_property(conn, DBCFG_ABBREVIATIONS, json.dumps(self.abbreviations))
160
161
162     def _init_db_tables(self, config):
163         """ Set up the word table and fill it with pre-computed word
164             frequencies.
165         """
166         with connect(self.dsn) as conn:
167             sqlp = SQLPreprocessor(conn, config)
168             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_tables.sql')
169             conn.commit()
170
171             LOG.warning("Precomputing word tokens")
172
173             # get partial words and their frequencies
174             words = Counter()
175             with self.name_analyzer() as analyzer:
176                 with conn.cursor(name="words") as cur:
177                     cur.execute("SELECT svals(name) as v, count(*) FROM place GROUP BY v")
178
179                     for name, cnt in cur:
180                         term = analyzer.make_standard_word(name)
181                         if term:
182                             for word in term.split():
183                                 words[word] += cnt
184
185             # copy them back into the word table
186             copystr = io.StringIO(''.join(('{}\t{}\n'.format(*args) for args in words.items())))
187
188
189             with conn.cursor() as cur:
190                 copystr.seek(0)
191                 cur.copy_from(copystr, 'word', columns=['word_token', 'search_name_count'])
192                 cur.execute("""UPDATE word SET word_id = nextval('seq_word')
193                                WHERE word_id is null""")
194
195             conn.commit()
196
197
198 class LegacyICUNameAnalyzer:
199     """ The legacy analyzer uses the ICU library for splitting names.
200
201         Each instance opens a connection to the database to request the
202         normalization.
203     """
204
205     def __init__(self, dsn, normalizer, transliterator, abbreviations):
206         self.conn = connect(dsn).connection
207         self.conn.autocommit = True
208         self.normalizer = normalizer
209         self.transliterator = transliterator
210         self.abbreviations = abbreviations
211
212         self._cache = _TokenCache()
213
214
215     def __enter__(self):
216         return self
217
218
219     def __exit__(self, exc_type, exc_value, traceback):
220         self.close()
221
222
223     def close(self):
224         """ Free all resources used by the analyzer.
225         """
226         if self.conn:
227             self.conn.close()
228             self.conn = None
229
230
231     def get_word_token_info(self, conn, words):
232         """ Return token information for the given list of words.
233             If a word starts with # it is assumed to be a full name
234             otherwise is a partial name.
235
236             The function returns a list of tuples with
237             (original word, word token, word id).
238
239             The function is used for testing and debugging only
240             and not necessarily efficient.
241         """
242         tokens = {}
243         for word in words:
244             if word.startswith('#'):
245                 tokens[word] = ' ' + self.make_standard_word(word[1:])
246             else:
247                 tokens[word] = self.make_standard_word(word)
248
249         with conn.cursor() as cur:
250             cur.execute("""SELECT word_token, word_id
251                            FROM word, (SELECT unnest(%s::TEXT[]) as term) t
252                            WHERE word_token = t.term
253                                  and class is null and country_code is null""",
254                         (list(tokens.values()), ))
255             ids = {r[0]: r[1] for r in cur}
256
257         return [(k, v, ids[v]) for k, v in tokens.items()]
258
259
260     def normalize(self, phrase):
261         """ Normalize the given phrase, i.e. remove all properties that
262             are irrelevant for search.
263         """
264         return self.normalizer.transliterate(phrase)
265
266     @staticmethod
267     def normalize_postcode(postcode):
268         """ Convert the postcode to a standardized form.
269
270             This function must yield exactly the same result as the SQL function
271             'token_normalized_postcode()'.
272         """
273         return postcode.strip().upper()
274
275
276     @functools.lru_cache(maxsize=1024)
277     def make_standard_word(self, name):
278         """ Create the normalised version of the input.
279         """
280         norm = ' ' + self.transliterator.transliterate(name) + ' '
281         for full, abbr in self.abbreviations:
282             if full in norm:
283                 norm = norm.replace(full, abbr)
284
285         return norm.strip()
286
287
288     def _make_standard_hnr(self, hnr):
289         """ Create a normalised version of a housenumber.
290
291             This function takes minor shortcuts on transliteration.
292         """
293         if hnr.isdigit():
294             return hnr
295
296         return self.transliterator.transliterate(hnr)
297
298     def update_postcodes_from_db(self):
299         """ Update postcode tokens in the word table from the location_postcode
300             table.
301         """
302         to_delete = []
303         copystr = io.StringIO()
304         with self.conn.cursor() as cur:
305             # This finds us the rows in location_postcode and word that are
306             # missing in the other table.
307             cur.execute("""SELECT * FROM
308                             (SELECT pc, word FROM
309                               (SELECT distinct(postcode) as pc FROM location_postcode) p
310                               FULL JOIN
311                               (SELECT word FROM word
312                                 WHERE class ='place' and type = 'postcode') w
313                               ON pc = word) x
314                            WHERE pc is null or word is null""")
315
316             for postcode, word in cur:
317                 if postcode is None:
318                     to_delete.append(word)
319                 else:
320                     copystr.write(postcode)
321                     copystr.write('\t ')
322                     copystr.write(self.transliterator.transliterate(postcode))
323                     copystr.write('\tplace\tpostcode\t0\n')
324
325             if to_delete:
326                 cur.execute("""DELETE FROM WORD
327                                WHERE class ='place' and type = 'postcode'
328                                      and word = any(%s)
329                             """, (to_delete, ))
330
331             if copystr.getvalue():
332                 copystr.seek(0)
333                 cur.copy_from(copystr, 'word',
334                               columns=['word', 'word_token', 'class', 'type',
335                                        'search_name_count'])
336
337
338     def update_special_phrases(self, phrases, should_replace):
339         """ Replace the search index for special phrases with the new phrases.
340         """
341         norm_phrases = set(((self.normalize(p[0]), p[1], p[2], p[3])
342                             for p in phrases))
343
344         with self.conn.cursor() as cur:
345             # Get the old phrases.
346             existing_phrases = set()
347             cur.execute("""SELECT word, class, type, operator FROM word
348                            WHERE class != 'place'
349                                  OR (type != 'house' AND type != 'postcode')""")
350             for label, cls, typ, oper in cur:
351                 existing_phrases.add((label, cls, typ, oper or '-'))
352
353             to_add = norm_phrases - existing_phrases
354             to_delete = existing_phrases - norm_phrases
355
356             if to_add:
357                 copystr = io.StringIO()
358                 for word, cls, typ, oper in to_add:
359                     term = self.make_standard_word(word)
360                     if term:
361                         copystr.write(word)
362                         copystr.write('\t ')
363                         copystr.write(term)
364                         copystr.write('\t')
365                         copystr.write(cls)
366                         copystr.write('\t')
367                         copystr.write(typ)
368                         copystr.write('\t')
369                         copystr.write(oper if oper in ('in', 'near')  else '\\N')
370                         copystr.write('\t0\n')
371
372                 copystr.seek(0)
373                 cur.copy_from(copystr, 'word',
374                               columns=['word', 'word_token', 'class', 'type',
375                                        'operator', 'search_name_count'])
376
377             if to_delete and should_replace:
378                 psycopg2.extras.execute_values(
379                     cur,
380                     """ DELETE FROM word USING (VALUES %s) as v(name, in_class, in_type, op)
381                         WHERE word = name and class = in_class and type = in_type
382                               and ((op = '-' and operator is null) or op = operator)""",
383                     to_delete)
384
385         LOG.info("Total phrases: %s. Added: %s. Deleted: %s",
386                  len(norm_phrases), len(to_add), len(to_delete))
387
388
389     def add_country_names(self, country_code, names):
390         """ Add names for the given country to the search index.
391         """
392         full_names = set((self.make_standard_word(n) for n in names))
393         full_names.discard('')
394         self._add_normalized_country_names(country_code, full_names)
395
396
397     def _add_normalized_country_names(self, country_code, names):
398         """ Add names for the given country to the search index.
399         """
400         word_tokens = set((' ' + name for name in names))
401         with self.conn.cursor() as cur:
402             # Get existing names
403             cur.execute("SELECT word_token FROM word WHERE country_code = %s",
404                         (country_code, ))
405             word_tokens.difference_update((t[0] for t in cur))
406
407             if word_tokens:
408                 cur.execute("""INSERT INTO word (word_id, word_token, country_code,
409                                                  search_name_count)
410                                (SELECT nextval('seq_word'), token, '{}', 0
411                                 FROM unnest(%s) as token)
412                             """.format(country_code), (list(word_tokens),))
413
414
415     def process_place(self, place):
416         """ Determine tokenizer information about the given place.
417
418             Returns a JSON-serialisable structure that will be handed into
419             the database via the token_info field.
420         """
421         token_info = _TokenInfo(self._cache)
422
423         names = place.get('name')
424
425         if names:
426             full_names = self._compute_full_names(names)
427
428             token_info.add_names(self.conn, full_names)
429
430             country_feature = place.get('country_feature')
431             if country_feature and re.fullmatch(r'[A-Za-z][A-Za-z]', country_feature):
432                 self._add_normalized_country_names(country_feature.lower(),
433                                                    full_names)
434
435         address = place.get('address')
436
437         if address:
438             hnrs = []
439             addr_terms = []
440             for key, value in address.items():
441                 if key == 'postcode':
442                     self._add_postcode(value)
443                 elif key in ('housenumber', 'streetnumber', 'conscriptionnumber'):
444                     hnrs.append(value)
445                 elif key == 'street':
446                     token_info.add_street(self.conn, self.make_standard_word(value))
447                 elif key == 'place':
448                     token_info.add_place(self.conn, self.make_standard_word(value))
449                 elif not key.startswith('_') and \
450                      key not in ('country', 'full'):
451                     addr_terms.append((key, self.make_standard_word(value)))
452
453             if hnrs:
454                 hnrs = self._split_housenumbers(hnrs)
455                 token_info.add_housenumbers(self.conn, [self._make_standard_hnr(n) for n in hnrs])
456
457             if addr_terms:
458                 token_info.add_address_terms(self.conn, addr_terms)
459
460         return token_info.data
461
462
463     def _compute_full_names(self, names):
464         """ Return the set of all full name word ids to be used with the
465             given dictionary of names.
466         """
467         full_names = set()
468         for name in (n for ns in names.values() for n in re.split('[;,]', ns)):
469             word = self.make_standard_word(name)
470             if word:
471                 full_names.add(word)
472
473                 brace_split = name.split('(', 2)
474                 if len(brace_split) > 1:
475                     word = self.make_standard_word(brace_split[0])
476                     if word:
477                         full_names.add(word)
478
479         return full_names
480
481
482     def _add_postcode(self, postcode):
483         """ Make sure the normalized postcode is present in the word table.
484         """
485         if re.search(r'[:,;]', postcode) is None:
486             postcode = self.normalize_postcode(postcode)
487
488             if postcode not in self._cache.postcodes:
489                 term = self.make_standard_word(postcode)
490                 if not term:
491                     return
492
493                 with self.conn.cursor() as cur:
494                     # no word_id needed for postcodes
495                     cur.execute("""INSERT INTO word (word, word_token, class, type,
496                                                      search_name_count)
497                                    (SELECT pc, %s, 'place', 'postcode', 0
498                                     FROM (VALUES (%s)) as v(pc)
499                                     WHERE NOT EXISTS
500                                      (SELECT * FROM word
501                                       WHERE word = pc and class='place' and type='postcode'))
502                                 """, (' ' + term, postcode))
503                 self._cache.postcodes.add(postcode)
504
505     @staticmethod
506     def _split_housenumbers(hnrs):
507         if len(hnrs) > 1 or ',' in hnrs[0] or ';' in hnrs[0]:
508             # split numbers if necessary
509             simple_list = []
510             for hnr in hnrs:
511                 simple_list.extend((x.strip() for x in re.split(r'[;,]', hnr)))
512
513             if len(simple_list) > 1:
514                 hnrs = list(set(simple_list))
515             else:
516                 hnrs = simple_list
517
518         return hnrs
519
520
521
522
523 class _TokenInfo:
524     """ Collect token information to be sent back to the database.
525     """
526     def __init__(self, cache):
527         self.cache = cache
528         self.data = {}
529
530     @staticmethod
531     def _mk_array(tokens):
532         return '{%s}' % ','.join((str(s) for s in tokens))
533
534
535     def add_names(self, conn, names):
536         """ Adds token information for the normalised names.
537         """
538         # Start with all partial names
539         terms = set((part for ns in names for part in ns.split()))
540         # Add partials for the full terms (TO BE REMOVED)
541         terms.update((n for n in names))
542         # Add the full names
543         terms.update((' ' + n for n in names))
544
545         self.data['names'] = self._mk_array(self.cache.get_term_tokens(conn, terms))
546
547
548     def add_housenumbers(self, conn, hnrs):
549         """ Extract housenumber information from a list of normalised
550             housenumbers.
551         """
552         self.data['hnr_tokens'] = self._mk_array(self.cache.get_hnr_tokens(conn, hnrs))
553         self.data['hnr'] = ';'.join(hnrs)
554
555
556     def add_street(self, conn, street):
557         """ Add addr:street match terms.
558         """
559         if not street:
560             return
561
562         term = ' ' + street
563
564         tid = self.cache.names.get(term)
565
566         if tid is None:
567             with conn.cursor() as cur:
568                 cur.execute("""SELECT word_id FROM word
569                                 WHERE word_token = %s
570                                       and class is null and type is null""",
571                             (term, ))
572                 if cur.rowcount > 0:
573                     tid = cur.fetchone()[0]
574                     self.cache.names[term] = tid
575
576         if tid is not None:
577             self.data['street'] = '{%d}' % tid
578
579
580     def add_place(self, conn, place):
581         """ Add addr:place search and match terms.
582         """
583         if not place:
584             return
585
586         partial_ids = self.cache.get_term_tokens(conn, place.split())
587         tid = self.cache.get_term_tokens(conn, [' ' + place])
588
589         self.data['place_search'] = self._mk_array(itertools.chain(partial_ids, tid))
590         self.data['place_match'] = '{%s}' % tid[0]
591
592
593     def add_address_terms(self, conn, terms):
594         """ Add additional address terms.
595         """
596         tokens = {}
597
598         for key, value in terms:
599             if not value:
600                 continue
601             partial_ids = self.cache.get_term_tokens(conn, value.split())
602             term = ' ' + value
603             tid = self.cache.names.get(term)
604
605             if tid is None:
606                 with conn.cursor() as cur:
607                     cur.execute("""SELECT word_id FROM word
608                                     WHERE word_token = %s
609                                           and class is null and type is null""",
610                                 (term, ))
611                     if cur.rowcount > 0:
612                         tid = cur.fetchone()[0]
613                         self.cache.names[term] = tid
614
615             tokens[key] = [self._mk_array(partial_ids),
616                            '{%s}' % ('' if tid is None else str(tid))]
617
618         if tokens:
619             self.data['addr'] = tokens
620
621
622 class _TokenCache:
623     """ Cache for token information to avoid repeated database queries.
624
625         This cache is not thread-safe and needs to be instantiated per
626         analyzer.
627     """
628     def __init__(self):
629         self.names = {}
630         self.postcodes = set()
631         self.housenumbers = {}
632
633
634     def get_term_tokens(self, conn, terms):
635         """ Get token ids for a list of terms, looking them up in the database
636             if necessary.
637         """
638         tokens = []
639         askdb = []
640
641         for term in terms:
642             token = self.names.get(term)
643             if token is None:
644                 askdb.append(term)
645             elif token != 0:
646                 tokens.append(token)
647
648         if askdb:
649             with conn.cursor() as cur:
650                 cur.execute("SELECT term, getorcreate_term_id(term) FROM unnest(%s) as term",
651                             (askdb, ))
652                 for term, tid in cur:
653                     self.names[term] = tid
654                     if tid != 0:
655                         tokens.append(tid)
656
657         return tokens
658
659
660     def get_hnr_tokens(self, conn, terms):
661         """ Get token ids for a list of housenumbers, looking them up in the
662             database if necessary.
663         """
664         tokens = []
665         askdb = []
666
667         for term in terms:
668             token = self.housenumbers.get(term)
669             if token is None:
670                 askdb.append(term)
671             else:
672                 tokens.append(token)
673
674         if askdb:
675             with conn.cursor() as cur:
676                 cur.execute("SELECT nr, getorcreate_hnr_id(nr) FROM unnest(%s) as nr",
677                             (askdb, ))
678                 for term, tid in cur:
679                     self.housenumbers[term] = tid
680                     tokens.append(tid)
681
682         return tokens