1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2023 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Implementation of query analysis for the ICU tokenizer.
10 from typing import Tuple, Dict, List, Optional, NamedTuple, Iterator, Any, cast
12 from collections import defaultdict
16 from icu import Transliterator
18 import sqlalchemy as sa
20 from nominatim.typing import SaRow
21 from nominatim.api.connection import SearchConnection
22 from nominatim.api.logging import log
23 from nominatim.api.search import query as qmod
24 from nominatim.api.search.query_analyzer_factory import AbstractQueryAnalyzer
28 'W': qmod.TokenType.WORD,
29 'w': qmod.TokenType.PARTIAL,
30 'H': qmod.TokenType.HOUSENUMBER,
31 'P': qmod.TokenType.POSTCODE,
32 'C': qmod.TokenType.COUNTRY
36 class QueryPart(NamedTuple):
37 """ Normalized and transliterated form of a single term in the query.
38 When the term came out of a split during the transliteration,
39 the normalized string is the full word before transliteration.
40 The word number keeps track of the word before transliteration
41 and can be used to identify partial transliterated terms.
48 QueryParts = List[QueryPart]
49 WordDict = Dict[str, List[qmod.TokenRange]]
51 def yield_words(terms: List[QueryPart], start: int) -> Iterator[Tuple[str, qmod.TokenRange]]:
52 """ Return all combinations of words in the terms list after the
56 for first in range(start, total):
57 word = terms[first].token
58 yield word, qmod.TokenRange(first, first + 1)
59 for last in range(first + 1, min(first + 20, total)):
60 word = ' '.join((word, terms[last].token))
61 yield word, qmod.TokenRange(first, last + 1)
64 @dataclasses.dataclass
65 class ICUToken(qmod.Token):
66 """ Specialised token for ICU tokenizer.
69 info: Optional[Dict[str, Any]]
71 def get_category(self) -> Tuple[str, str]:
73 return self.info.get('class', ''), self.info.get('type', '')
76 def rematch(self, norm: str) -> None:
77 """ Check how well the token matches the given normalized string
78 and add a penalty, if necessary.
80 if not self.lookup_word:
83 seq = difflib.SequenceMatcher(a=self.lookup_word, b=norm)
85 for tag, afrom, ato, bfrom, bto in seq.get_opcodes():
86 if tag in ('delete', 'insert') and (afrom == 0 or ato == len(self.lookup_word)):
88 elif tag == 'replace':
89 distance += max((ato-afrom), (bto-bfrom))
91 distance += abs((ato-afrom) - (bto-bfrom))
92 self.penalty += (distance/len(self.lookup_word))
96 def from_db_row(row: SaRow) -> 'ICUToken':
97 """ Create a ICUToken from the row of the word table.
99 count = 1 if row.info is None else row.info.get('count', 1)
104 elif row.type == 'W':
105 if len(row.word_token) == 1 and row.word_token == row.word:
106 penalty = 0.2 if row.word.isdigit() else 0.3
107 elif row.type == 'H':
108 penalty = sum(0.1 for c in row.word_token if c != ' ' and not c.isdigit())
109 if all(not c.isdigit() for c in row.word_token):
110 penalty += 0.2 * (len(row.word_token) - 1)
111 elif row.type == 'C':
112 if len(row.word_token) == 1:
116 lookup_word = row.word
118 lookup_word = row.info.get('lookup', row.word)
120 lookup_word = lookup_word.split('@', 1)[0]
122 lookup_word = row.word_token
124 return ICUToken(penalty=penalty, token=row.word_id, count=count,
125 lookup_word=lookup_word, is_indexed=True,
126 word_token=row.word_token, info=row.info)
130 class ICUQueryAnalyzer(AbstractQueryAnalyzer):
131 """ Converter for query strings into a tokenized query
132 using the tokens created by a ICU tokenizer.
135 def __init__(self, conn: SearchConnection) -> None:
139 async def setup(self) -> None:
140 """ Set up static data structures needed for the analysis.
142 async def _make_normalizer() -> Any:
143 rules = await self.conn.get_property('tokenizer_import_normalisation')
144 return Transliterator.createFromRules("normalization", rules)
146 self.normalizer = await self.conn.get_cached_value('ICUTOK', 'normalizer',
149 async def _make_transliterator() -> Any:
150 rules = await self.conn.get_property('tokenizer_import_transliteration')
151 return Transliterator.createFromRules("transliteration", rules)
153 self.transliterator = await self.conn.get_cached_value('ICUTOK', 'transliterator',
154 _make_transliterator)
156 if 'word' not in self.conn.t.meta.tables:
157 sa.Table('word', self.conn.t.meta,
158 sa.Column('word_id', sa.Integer),
159 sa.Column('word_token', sa.Text, nullable=False),
160 sa.Column('type', sa.Text, nullable=False),
161 sa.Column('word', sa.Text),
162 sa.Column('info', self.conn.t.types.Json))
165 async def analyze_query(self, phrases: List[qmod.Phrase]) -> qmod.QueryStruct:
166 """ Analyze the given list of phrases and return the
169 log().section('Analyze query (using ICU tokenizer)')
170 normalized = list(filter(lambda p: p.text,
171 (qmod.Phrase(p.ptype, self.normalize_text(p.text))
173 query = qmod.QueryStruct(normalized)
174 log().var_dump('Normalized query', query.source)
178 parts, words = self.split_query(query)
179 log().var_dump('Transliterated query', lambda: _dump_transliterated(query, parts))
181 for row in await self.lookup_in_db(list(words.keys())):
182 for trange in words[row.word_token]:
183 token = ICUToken.from_db_row(row)
185 if row.info['op'] in ('in', 'near'):
186 if trange.start == 0:
187 query.add_token(trange, qmod.TokenType.NEAR_ITEM, token)
189 query.add_token(trange, qmod.TokenType.QUALIFIER, token)
190 if trange.start == 0 or trange.end == query.num_token_slots():
192 token.penalty += 0.1 * (query.num_token_slots())
193 query.add_token(trange, qmod.TokenType.NEAR_ITEM, token)
195 query.add_token(trange, DB_TO_TOKEN_TYPE[row.type], token)
197 self.add_extra_tokens(query, parts)
198 self.rerank_tokens(query, parts)
200 log().table_dump('Word tokens', _dump_word_tokens(query))
205 def normalize_text(self, text: str) -> str:
206 """ Bring the given text into a normalized form. That is the
207 standardized form search will work with. All information removed
208 at this stage is inevitably lost.
210 norm = cast(str, self.normalizer.transliterate(text))
211 numspaces = norm.count(' ')
212 if numspaces > 4 and len(norm) <= (numspaces + 1) * 3:
218 def split_query(self, query: qmod.QueryStruct) -> Tuple[QueryParts, WordDict]:
219 """ Transliterate the phrases and split them into tokens.
221 Returns the list of transliterated tokens together with their
222 normalized form and a dictionary of words for lookup together
225 parts: QueryParts = []
227 words = defaultdict(list)
229 for phrase in query.source:
230 query.nodes[-1].ptype = phrase.ptype
231 for word in phrase.text.split(' '):
232 trans = self.transliterator.transliterate(word)
234 for term in trans.split(' '):
236 parts.append(QueryPart(term, word, wordnr))
237 query.add_node(qmod.BreakType.TOKEN, phrase.ptype)
238 query.nodes[-1].btype = qmod.BreakType.WORD
240 query.nodes[-1].btype = qmod.BreakType.PHRASE
242 for word, wrange in yield_words(parts, phrase_start):
243 words[word].append(wrange)
245 phrase_start = len(parts)
246 query.nodes[-1].btype = qmod.BreakType.END
251 async def lookup_in_db(self, words: List[str]) -> 'sa.Result[Any]':
252 """ Return the token information from the database for the
255 t = self.conn.t.meta.tables['word']
256 return await self.conn.execute(t.select().where(t.c.word_token.in_(words)))
259 def add_extra_tokens(self, query: qmod.QueryStruct, parts: QueryParts) -> None:
260 """ Add tokens to query that are not saved in the database.
262 for part, node, i in zip(parts, query.nodes, range(1000)):
263 if len(part.token) <= 4 and part[0].isdigit()\
264 and not node.has_tokens(i+1, qmod.TokenType.HOUSENUMBER):
265 query.add_token(qmod.TokenRange(i, i+1), qmod.TokenType.HOUSENUMBER,
266 ICUToken(0.5, 0, 1, part.token, True, part.token, None))
269 def rerank_tokens(self, query: qmod.QueryStruct, parts: QueryParts) -> None:
270 """ Add penalties to tokens that depend on presence of other token.
272 for i, node, tlist in query.iter_token_lists():
273 if tlist.ttype == qmod.TokenType.POSTCODE:
274 for repl in node.starting:
275 if repl.end == tlist.end and repl.ttype != qmod.TokenType.POSTCODE \
276 and (repl.ttype != qmod.TokenType.HOUSENUMBER
277 or len(tlist.tokens[0].lookup_word) > 4):
278 repl.add_penalty(0.39)
279 elif tlist.ttype == qmod.TokenType.HOUSENUMBER \
280 and len(tlist.tokens[0].lookup_word) <= 3:
281 if any(c.isdigit() for c in tlist.tokens[0].lookup_word):
282 for repl in node.starting:
283 if repl.end == tlist.end and repl.ttype != qmod.TokenType.HOUSENUMBER:
284 repl.add_penalty(0.5 - tlist.tokens[0].penalty)
285 elif tlist.ttype not in (qmod.TokenType.COUNTRY, qmod.TokenType.PARTIAL):
286 norm = parts[i].normalized
287 for j in range(i + 1, tlist.end):
288 if parts[j - 1].word_number != parts[j].word_number:
289 norm += ' ' + parts[j].normalized
290 for token in tlist.tokens:
291 cast(ICUToken, token).rematch(norm)
294 def _dump_transliterated(query: qmod.QueryStruct, parts: QueryParts) -> str:
295 out = query.nodes[0].btype.value
296 for node, part in zip(query.nodes[1:], parts):
297 out += part.token + node.btype.value
301 def _dump_word_tokens(query: qmod.QueryStruct) -> Iterator[List[Any]]:
302 yield ['type', 'token', 'word_token', 'lookup_word', 'penalty', 'count', 'info']
303 for node in query.nodes:
304 for tlist in node.starting:
305 for token in tlist.tokens:
306 t = cast(ICUToken, token)
307 yield [tlist.ttype.name, t.token, t.word_token or '',
308 t.lookup_word or '', t.penalty, t.count, t.info]
311 async def create_query_analyzer(conn: SearchConnection) -> AbstractQueryAnalyzer:
312 """ Create and set up a new query analyzer for a database based
313 on the ICU tokenizer.
315 out = ICUQueryAnalyzer(conn)