]> git.openstreetmap.org Git - nominatim.git/commitdiff
Merge pull request #3610 from lonvia/search-preprocessing
authorSarah Hoffmann <lonvia@denofr.de>
Mon, 16 Dec 2024 09:26:09 +0000 (10:26 +0100)
committerGitHub <noreply@github.com>
Mon, 16 Dec 2024 09:26:09 +0000 (10:26 +0100)
Add configurable query preprocessing

13 files changed:
docs/customize/Tokenizers.md
docs/develop/ICU-Tokenizer-Modules.md
docs/develop/Tokenizers.md
settings/icu_tokenizer.yaml
src/nominatim_api/connection.py
src/nominatim_api/core.py
src/nominatim_api/query_preprocessing/__init__.py [new file with mode: 0644]
src/nominatim_api/query_preprocessing/base.py [new file with mode: 0644]
src/nominatim_api/query_preprocessing/config.py [new file with mode: 0644]
src/nominatim_api/query_preprocessing/normalize.py [new file with mode: 0644]
src/nominatim_api/search/icu_tokenizer.py
src/nominatim_api/typing.py
test/python/api/query_processing/test_normalize.py [new file with mode: 0644]

index 30be170edea91babfa271d47364d123d84f9fdf0..3c29972d7eedbf93a43e6946985281a737749fd2 100644 (file)
@@ -4,12 +4,11 @@ The tokenizer module in Nominatim is responsible for analysing the names given
 to OSM objects and the terms of an incoming query in order to make sure, they
 can be matched appropriately.
 
-Nominatim offers different tokenizer modules, which behave differently and have
-different configuration options. This sections describes the tokenizers and how
-they can be configured.
+Nominatim currently offers only one tokenizer module, the ICU tokenizer. This section
+describes the tokenizer and how it can be configured.
 
 !!! important
-    The use of a tokenizer is tied to a database installation. You need to choose
+    The selection of tokenizer is tied to a database installation. You need to choose
     and configure the tokenizer before starting the initial import. Once the import
     is done, you cannot switch to another tokenizer anymore. Reconfiguring the
     chosen tokenizer is very limited as well. See the comments in each tokenizer
@@ -43,10 +42,19 @@ On import the tokenizer processes names in the following three stages:
    See the [Token analysis](#token-analysis) section below for more
    information.
 
-During query time, only normalization and transliteration are relevant.
-An incoming query is first split into name chunks (this usually means splitting
-the string at the commas) and the each part is normalised and transliterated.
-The result is used to look up places in the search index.
+During query time, the tokeinzer is responsible for processing incoming
+queries. This happens in two stages:
+
+1. During **query preprocessing** the incoming text is split into name
+   chunks and normalised. This usually means applying the same normalisation
+   as during the import process but may involve other processing like,
+   for example, word break detection.
+2. The **token analysis** step breaks down the query parts into tokens,
+   looks them up in the database and assignes them possible functions and
+   probabilities.
+
+Query processing can be further customized while the rest of the analysis
+is hard-coded.
 
 ### Configuration
 
@@ -58,6 +66,8 @@ have no effect.
 Here is an example configuration file:
 
 ``` yaml
+query-preprocessing:
+    - normalize
 normalization:
     - ":: lower ()"
     - "ß > 'ss'" # German szet is unambiguously equal to double ss
@@ -81,6 +91,22 @@ token-analysis:
 The configuration file contains four sections:
 `normalization`, `transliteration`, `sanitizers` and `token-analysis`.
 
+#### Query preprocessing
+
+The section for `query-preprocessing` defines an ordered list of functions
+that are applied to the query before the token analysis.
+
+The following is a list of preprocessors that are shipped with Nominatim.
+
+##### normalize
+
+::: nominatim_api.query_preprocessing.normalize
+    options:
+        members: False
+        heading_level: 6
+        docstring_section_style: spacy
+
+
 #### Normalization and Transliteration
 
 The normalization and transliteration sections each define a set of
index d189422a9d8e20b56cd7c53a18f3efcc6a51571b..f19002c200bb9c6f683f5e3fd3406c478b097a1d 100644 (file)
@@ -14,10 +14,11 @@ of sanitizers and token analysis.
     implemented, it is not guaranteed to be stable at the moment.
 
 
-## Using non-standard sanitizers and token analyzers
+## Using non-standard modules
 
-Sanitizer names (in the `step` property) and token analysis names (in the
-`analyzer`) may refer to externally supplied modules. There are two ways
+Sanitizer names (in the `step` property), token analysis names (in the
+`analyzer`) and query preprocessor names (in the `step` property)
+may refer to externally supplied modules. There are two ways
 to include external modules: through a library or from the project directory.
 
 To include a module from a library, use the absolute import path as name and
@@ -27,6 +28,47 @@ To use a custom module without creating a library, you can put the module
 somewhere in your project directory and then use the relative path to the
 file. Include the whole name of the file including the `.py` ending.
 
+## Custom query preprocessors
+
+A query preprocessor must export a single factory function `create` with
+the following signature:
+
+``` python
+create(self, config: QueryConfig) -> Callable[[list[Phrase]], list[Phrase]]
+```
+
+The function receives the custom configuration for the preprocessor and
+returns a callable (function or class) with the actual preprocessing
+code. When a query comes in, then the callable gets a list of phrases
+and needs to return the transformed list of phrases. The list and phrases
+may be changed in place or a completely new list may be generated.
+
+The `QueryConfig` is a simple dictionary which contains all configuration
+options given in the yaml configuration of the ICU tokenizer. It is up to
+the function to interpret the values.
+
+A `nominatim_api.search.Phrase` describes a part of the query that contains one or more independent
+search terms. Breaking a query into phrases helps reducing the number of
+possible tokens Nominatim has to take into account. However a phrase break
+is definitive: a multi-term search word cannot go over a phrase break.
+A Phrase object has two fields:
+
+ * `ptype` further refines the type of phrase (see list below)
+ * `text` contains the query text for the phrase
+
+The order of phrases matters to Nominatim when doing further processing.
+Thus, while you may split or join phrases, you should not reorder them
+unless you really know what you are doing.
+
+Phrase types (`nominatim_api.search.PhraseType`) can further help narrowing
+down how the tokens in the phrase are interpreted. The following phrase types
+are known:
+
+::: nominatim_api.search.PhraseType
+    options:
+        heading_level: 6
+
+
 ## Custom sanitizer modules
 
 A sanitizer module must export a single factory function `create` with the
@@ -90,21 +132,22 @@ adding extra attributes) or completely replace the list with a different one.
 The following sanitizer removes the directional prefixes from street names
 in the US:
 
-``` python
-import re
-
-def _filter_function(obj):
-    if obj.place.country_code == 'us' \
-       and obj.place.rank_address >= 26 and obj.place.rank_address <= 27:
-        for name in obj.names:
-            name.name = re.sub(r'^(north|south|west|east) ',
-                               '',
-                               name.name,
-                               flags=re.IGNORECASE)
-
-def create(config):
-    return _filter_function
-```
+!!! example
+    ``` python
+    import re
+
+    def _filter_function(obj):
+        if obj.place.country_code == 'us' \
+           and obj.place.rank_address >= 26 and obj.place.rank_address <= 27:
+            for name in obj.names:
+                name.name = re.sub(r'^(north|south|west|east) ',
+                                   '',
+                                   name.name,
+                                   flags=re.IGNORECASE)
+
+    def create(config):
+        return _filter_function
+    ```
 
 This is the most simple form of a sanitizer module. If defines a single
 filter function and implements the required `create()` function by returning
@@ -128,13 +171,13 @@ sanitizers:
 
 !!! warning
     This example is just a simplified show case on how to create a sanitizer.
-    It is not really read for real-world use: while the sanitizer would
+    It is not really meant for real-world use: while the sanitizer would
     correctly transform `West 5th Street` into `5th Street`. it would also
     shorten a simple `North Street` to `Street`.
 
 For more sanitizer examples, have a look at the sanitizers provided by Nominatim.
 They can be found in the directory
-[`nominatim/tokenizer/sanitizers`](https://github.com/osm-search/Nominatim/tree/master/nominatim/tokenizer/sanitizers).
+[`src/nominatim_db/tokenizer/sanitizers`](https://github.com/osm-search/Nominatim/tree/master/src/nominatim_db/tokenizer/sanitizers).
 
 
 ## Custom token analysis module
index f4a55adcae1252cd72d0be3509583690b9ad446d..8ce2134373d105eea59f8a675026156c42f4415d 100644 (file)
@@ -91,14 +91,19 @@ for a custom tokenizer implementation.
 
 ### Directory Structure
 
-Nominatim expects a single file `src/nominatim_db/tokenizer/<NAME>_tokenizer.py`
-containing the Python part of the implementation.
+Nominatim expects two files containing the Python part of the implementation:
+
+ * `src/nominatim_db/tokenizer/<NAME>_tokenizer.py` contains the tokenizer
+   code used during import and
+ * `src/nominatim_api/search/NAME>_tokenizer.py` has the code used during
+   query time.
+
 `<NAME>` is a unique name for the tokenizer consisting of only lower-case
 letters, digits and underscore. A tokenizer also needs to install some SQL
 functions. By convention, these should be placed in `lib-sql/tokenizer`.
 
 If the tokenizer has a default configuration file, this should be saved in
-the `settings/<NAME>_tokenizer.<SUFFIX>`.
+`settings/<NAME>_tokenizer.<SUFFIX>`.
 
 ### Configuration and Persistence
 
@@ -110,9 +115,11 @@ are tied to a database installation and must only be read during installation
 time. If they are needed for the runtime then they must be saved into the
 `nominatim_properties` table and later loaded from there.
 
-### The Python module
+### The Python modules
 
-The Python module is expect to export a single factory function:
+#### `src/nominatim_db/tokenizer/`
+
+The import Python module is expected to export a single factory function:
 
 ```python
 def create(dsn: str, data_dir: Path) -> AbstractTokenizer
@@ -123,6 +130,20 @@ is a directory in the project directory that the tokenizer may use to save
 database-specific data. The function must return the instance of the tokenizer
 class as defined below.
 
+#### `src/nominatim_api/search/`
+
+The query-time Python module must also export a factory function:
+
+``` python
+def create_query_analyzer(conn: SearchConnection) -> AbstractQueryAnalyzer
+```
+
+The `conn` parameter contains the current search connection. See the
+[library documentation](../library/Low-Level-DB-Access.md#searchconnection-class)
+for details on the class. The function must return the instance of the tokenizer
+class as defined below.
+
+
 ### Python Tokenizer Class
 
 All tokenizers must inherit from `nominatim_db.tokenizer.base.AbstractTokenizer`
@@ -138,6 +159,13 @@ and implement the abstract functions defined there.
     options:
         heading_level: 6
 
+
+### Python Query Analyzer Class
+
+::: nominatim_api.search.query_analyzer_factory.AbstractQueryAnalyzer
+    options:
+        heading_level: 6
+
 ### PL/pgSQL Functions
 
 The tokenizer must provide access functions for the `token_info` column
index c5a809c68319f3095f2d9b4bf06c6456ff4b2b05..530df1a63397e423355ec7a75dc36178e2c2418e 100644 (file)
@@ -1,3 +1,5 @@
+query-preprocessing:
+    - step: normalize
 normalization:
     - ":: lower ()"
     - ":: Hans-Hant"
index e104745ebe784de098b97bb6a9d70f9a5a7a0d52..04268dc31f48df04b86080343d85712d97f94e87 100644 (file)
@@ -18,6 +18,7 @@ from .typing import SaFromClause
 from .sql.sqlalchemy_schema import SearchTables
 from .sql.sqlalchemy_types import Geometry
 from .logging import log
+from .config import Configuration
 
 T = TypeVar('T')
 
@@ -31,9 +32,11 @@ class SearchConnection:
 
     def __init__(self, conn: AsyncConnection,
                  tables: SearchTables,
-                 properties: Dict[str, Any]) -> None:
+                 properties: Dict[str, Any],
+                 config: Configuration) -> None:
         self.connection = conn
         self.t = tables
+        self.config = config
         self._property_cache = properties
         self._classtables: Optional[Set[str]] = None
         self.query_timeout: Optional[int] = None
index 3cf9e989141b5f118fe8d287805ad754124d42df..b98c0ba7638adcc6125e82eed5a3e0671de405ed 100644 (file)
@@ -184,7 +184,7 @@ class NominatimAPIAsync:
         assert self._tables is not None
 
         async with self._engine.begin() as conn:
-            yield SearchConnection(conn, self._tables, self._property_cache)
+            yield SearchConnection(conn, self._tables, self._property_cache, self.config)
 
     async def status(self) -> StatusResult:
         """ Return the status of the database.
diff --git a/src/nominatim_api/query_preprocessing/__init__.py b/src/nominatim_api/query_preprocessing/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/nominatim_api/query_preprocessing/base.py b/src/nominatim_api/query_preprocessing/base.py
new file mode 100644 (file)
index 0000000..1e0afd0
--- /dev/null
@@ -0,0 +1,32 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# This file is part of Nominatim. (https://nominatim.org)
+#
+# Copyright (C) 2024 by the Nominatim developer community.
+# For a full list of authors see the git log.
+"""
+Common data types and protocols for preprocessing.
+"""
+from typing import List, Callable
+
+from ..typing import Protocol
+from ..search import query as qmod
+from .config import QueryConfig
+
+QueryProcessingFunc = Callable[[List[qmod.Phrase]], List[qmod.Phrase]]
+
+
+class QueryHandler(Protocol):
+    """ Protocol for query modules.
+    """
+    def create(self, config: QueryConfig) -> QueryProcessingFunc:
+        """
+        Create a function for sanitizing a place.
+        Arguments:
+            config: A dictionary with the additional configuration options
+                    specified in the tokenizer configuration
+            normalizer: A instance to transliterate text
+        Return:
+            The result is a list modified by the preprocessor.
+        """
+        pass
diff --git a/src/nominatim_api/query_preprocessing/config.py b/src/nominatim_api/query_preprocessing/config.py
new file mode 100644 (file)
index 0000000..1948945
--- /dev/null
@@ -0,0 +1,34 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# This file is part of Nominatim. (https://nominatim.org)
+#
+# Copyright (C) 2024 by the Nominatim developer community.
+# For a full list of authors see the git log.
+"""
+Configuration for Sanitizers.
+"""
+from typing import Any, TYPE_CHECKING
+from collections import UserDict
+
+# working around missing generics in Python < 3.8
+# See https://github.com/python/typing/issues/60#issuecomment-869757075
+if TYPE_CHECKING:
+    _BaseUserDict = UserDict[str, Any]
+else:
+    _BaseUserDict = UserDict
+
+
+class QueryConfig(_BaseUserDict):
+    """ The `QueryConfig` class is a read-only dictionary
+        with configuration options for the preprocessor.
+        In addition to the usual dictionary functions, the class provides
+        accessors to standard preprocessor options that are used by many of the
+        preprocessors.
+    """
+
+    def set_normalizer(self, normalizer: Any) -> 'QueryConfig':
+        """ Set the normalizer function to be used.
+        """
+        self['_normalizer'] = normalizer
+
+        return self
diff --git a/src/nominatim_api/query_preprocessing/normalize.py b/src/nominatim_api/query_preprocessing/normalize.py
new file mode 100644 (file)
index 0000000..afb93de
--- /dev/null
@@ -0,0 +1,31 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# This file is part of Nominatim. (https://nominatim.org)
+#
+# Copyright (C) 2024 by the Nominatim developer community.
+# For a full list of authors see the git log.
+"""
+Normalize query text using the same ICU normalization rules that are
+applied during import. If a phrase becomes empty because the normalization
+removes all terms, then the phrase is deleted.
+
+This preprocessor does not come with any extra information. Instead it will
+use the configuration from the `normalization` section.
+"""
+from typing import cast
+
+from .config import QueryConfig
+from .base import QueryProcessingFunc
+from ..search.query import Phrase
+
+
+def create(config: QueryConfig) -> QueryProcessingFunc:
+    normalizer = config.get('_normalizer')
+
+    if not normalizer:
+        return lambda p: p
+
+    return lambda phrases: list(
+        filter(lambda p: p.text,
+               (Phrase(p.ptype, cast(str, normalizer.transliterate(p.text)))
+                for p in phrases)))
index fa14531aed0d6c07cf79c277255324495b1b063d..5976fbec05d8c515dfff092606942b62e602aaac 100644 (file)
@@ -16,12 +16,14 @@ from icu import Transliterator
 
 import sqlalchemy as sa
 
+from ..errors import UsageError
 from ..typing import SaRow
 from ..sql.sqlalchemy_types import Json
 from ..connection import SearchConnection
 from ..logging import log
-from ..search import query as qmod
-from ..search.query_analyzer_factory import AbstractQueryAnalyzer
+from . import query as qmod
+from ..query_preprocessing.config import QueryConfig
+from .query_analyzer_factory import AbstractQueryAnalyzer
 
 
 DB_TO_TOKEN_TYPE = {
@@ -151,6 +153,8 @@ class ICUQueryAnalyzer(AbstractQueryAnalyzer):
         self.transliterator = await self.conn.get_cached_value('ICUTOK', 'transliterator',
                                                                _make_transliterator)
 
+        await self._setup_preprocessing()
+
         if 'word' not in self.conn.t.meta.tables:
             sa.Table('word', self.conn.t.meta,
                      sa.Column('word_id', sa.Integer),
@@ -159,15 +163,36 @@ class ICUQueryAnalyzer(AbstractQueryAnalyzer):
                      sa.Column('word', sa.Text),
                      sa.Column('info', Json))
 
+    async def _setup_preprocessing(self) -> None:
+        """ Load the rules for preprocessing and set up the handlers.
+        """
+
+        rules = self.conn.config.load_sub_configuration('icu_tokenizer.yaml',
+                                                        config='TOKENIZER_CONFIG')
+        preprocessing_rules = rules.get('query-preprocessing', [])
+
+        self.preprocessors = []
+
+        for func in preprocessing_rules:
+            if 'step' not in func:
+                raise UsageError("Preprocessing rule is missing the 'step' attribute.")
+            if not isinstance(func['step'], str):
+                raise UsageError("'step' attribute must be a simple string.")
+
+            module = self.conn.config.load_plugin_module(
+                        func['step'], 'nominatim_api.query_preprocessing')
+            self.preprocessors.append(
+                module.create(QueryConfig(func).set_normalizer(self.normalizer)))
+
     async def analyze_query(self, phrases: List[qmod.Phrase]) -> qmod.QueryStruct:
         """ Analyze the given list of phrases and return the
             tokenized query.
         """
         log().section('Analyze query (using ICU tokenizer)')
-        normalized = list(filter(lambda p: p.text,
-                                 (qmod.Phrase(p.ptype, self.normalize_text(p.text))
-                                  for p in phrases)))
-        query = qmod.QueryStruct(normalized)
+        for func in self.preprocessors:
+            phrases = func(phrases)
+        query = qmod.QueryStruct(phrases)
+
         log().var_dump('Normalized query', query.source)
         if not query.source:
             return query
index 89aa44285f622e00020a5debb58b439be95f70c8..be9e9b58804ecc51cc97a467b3e5d8903267baeb 100644 (file)
@@ -21,9 +21,11 @@ if TYPE_CHECKING:
     from typing import Any
     import sqlalchemy as sa
     import os
-    from typing_extensions import (TypeAlias as TypeAlias)
+    from typing_extensions import (TypeAlias as TypeAlias,
+                                   Protocol as Protocol)
 else:
     TypeAlias = str
+    Protocol = object
 
 StrPath = Union[str, 'os.PathLike[str]']
 
diff --git a/test/python/api/query_processing/test_normalize.py b/test/python/api/query_processing/test_normalize.py
new file mode 100644 (file)
index 0000000..db8bbe0
--- /dev/null
@@ -0,0 +1,34 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# This file is part of Nominatim. (https://nominatim.org)
+#
+# Copyright (C) 2024 by the Nominatim developer community.
+# For a full list of authors see the git log.
+"""
+Tests for normalizing search queries.
+"""
+from pathlib import Path
+
+import pytest
+
+from icu import Transliterator
+
+import nominatim_api.search.query as qmod
+from nominatim_api.query_preprocessing.config import QueryConfig
+from nominatim_api.query_preprocessing import normalize
+
+def run_preprocessor_on(query, norm):
+    normalizer = Transliterator.createFromRules("normalization", norm)
+    proc = normalize.create(QueryConfig().set_normalizer(normalizer))
+
+    return proc(query)
+
+
+def test_normalize_simple():
+    norm = ':: lower();'
+    query = [qmod.Phrase(qmod.PhraseType.NONE, 'Hallo')]
+
+    out = run_preprocessor_on(query, norm)
+
+    assert len(out) == 1
+    assert out == [qmod.Phrase(qmod.PhraseType.NONE, 'hallo')]