Работая над проектом, связанным с обработкой геоданных, возникла необходимость в геокодере. Развернув довольно популярное решение на базе nominatim и попробовав геокодировать адреса разной степени испорченности, я пришел к выводу, что работает он так себе. Даже скармливая ему достаточно адекватные названия улиц, nominatim зачастую не мог их распознать.

Проблема, судя по всему, заключалась в том, что он буквально трактовал передаваемые ему адреса. Например, найти "Караванная улица, Санкт-Петербург" ему не составляло труда, а вот где находится "улица Караванная, Санкт-Петербург" он, почему-то, не знал.

Вторым минусом были совершенно непомерные аппетиты в области занимаего места. Развернутая база данных РФ занимала порядка 50GB, рзворачивалась целую ночь, а ведь ее еще обновлять надо. А если страну добавить? А фиг вам, встроенные в nominatim утилиты такого не позволяют. Приходилось с нуля разворачивать.

В общем, решил я разобраться с данными osm, и попробовать реализовать свое решение. Хотелось, чтобы оно занимало приемлемое количество места, легко разворачивалось и быстро работало.

Готовим данные

Буду использовать данные OpenStreetMap, которые находятся в открытом доступе и регулярно обновляются. Есть несколько источников, где можно скачать данные в формате osm, но я буду использовать http://download.geofabrik.de/. Я буду писать геокодер, ориентированный на русский формат адресов, а значит и данные буду брать только по России. В тестовых целях, если вы хотите повторить мой опыт, рекомендую выбирать не всю страну, а только ее часть. Я возьму данные своего родного сибирского края.

Скачиваем себе файл:

wget -o data.osm.pbf http://download.geofabrik.de/russia/siberian-fed-district-latest.osm.pbf

Формат данных osm.pbf

Что же это за формат - osm.pbf? Есть формат .osm - это просто xml с данными OpenStreetMap, а osm.pbf - это бинарная версия osm. Чтобы понять, что в нем находится, создадим postgresql базу данных и, используя утилиту ogr2ogr, заполним ее данными из osm.pbf файла.

Конвертируем данные

Необходимо установить пакет gdal-bin, который включает в себя утилиту ogr2ogr:

sudo apt-get install gdal-bin

Теперь создаем тестовую базу данных, в которую загоним данные data.osm.pbf:

createdb osm_data_test
echo 'CREATE EXTENSION postgis; CREATE EXTENSION hstore;' | psql -d osm_data_test

Запускаем конвертор (не забудьте указать свои настройки подключения):

ogr2ogr -f 'PostgreSQL' PG:"host=localhost user=username password=***** dbname=osm_data_test" data.osm.pbf

Разбиремся с данными

Как же организованы OpenStreetMap данные? Есть три основных типа объектов:

  • node (узлы или точки);
  • way (пути);
  • relation (отношения).

Из этих объектов строятся более сложные объекты. Давайте расмотрим на примере города Иркутска.

Вот так он выглядит: www.openstreetmap.org/relation/1430614.

Область, занимаемая городом, образует полигон:

Вот так он хранится в виде xml-ки (можно открыть тут www.openstreetmap.org/api/0.6/relation/1430614). Я немого упростил:

<?xml version="1.0" encoding="UTF-8"?>
<osm>
 <relation id="1430614">
  <member type="way" ref="100117231" role="outer"/>
  <member type="way" ref="389513985" role="outer"/>
  <member type="way" ref="100013563" role="outer"/>
  ...
  <tag k="addr:country" v="RU"/>
  <tag k="addr:region" v="Иркутская область"/>
  <tag k="name" v="Иркутск"/>
  <tag k="name:ru" v="Иркутск"/>
  <tag k="place" v="city"/>
  <tag k="type" v="multipolygon"/>
  <tag k="wikidata" v="Q6576"/>
  <tag k="wikipedia" v="ru:Иркутск"/>
 </relation>
</osm>

<relation id="1430614"> - говорит о том, что у нас составной объект c id=1430614.

<tag k="addr:country" v="RU"/> - тэги, которые подробно описывают объект; именно они составляют всю "соль" объекта и, на основании тэгов, мы будем формировать нашу БД для геокодинга. Атрибут k задает ключ тэга, а v, соотвтественно, значение.

<member type="way" ref="100117231" role="outer"/> - это описание части составного объекта, внешняя граница типа путь с id=100117231 (можно посмотреть тут www.openstreetmap.org/way/100117231).

Хранится в xml следующим образом:

<?xml version="1.0" encoding="UTF-8"?>
<osm>
 <way id="100117231">
  <nd ref="3878810419"/>
  <nd ref="3380833435"/>
  <nd ref="1156332656"/>
 </way>
</osm>

<way id="100117231"> - объект типа путь с id=100117231.

<nd ref="3878810419"/> - объект типа узел (node) с id=3878810419, в xml хранится следующим образом:

<?xml version="1.0" encoding="UTF-8"?>
<osm>
 <node id="3878810419" lat="52.3103909" lon="104.4496520"/>
</osm>

То есть просто точка с двумя координатами. Доступно для изучения тут: www.openstreetmap.org/node/3878810419.

Давайте найдем город в нашей сформированной базе данных:

SELECT *
FROM multipolygons
WHERE osm_id='1430614'

Отбросил часть столбцов, которые используются для задания специфических характеристик объектов:

ogc_fid osm_id   name type place other_tags wkb_geometry
2013 1430614   Иркутск multipolygon city "addr:country"=>".. 0106000020E6...
  • ogc_fid - id нашей базы;
  • osm_id - id в базе OSM;
  • name - имя;
  • type - тип объекта (тут у всех multipolygon);
  • place - семантика полигона, то есть город, область, село, остров и т.д. (список возможных значений: SELECT DISTINCT place FROM multipolygons);
  • other_tags - те тэги, под которые не выделены отдельные столбцы (это особености поведения ogr2ogr: под часть тегов выделяютс столбцы, под часть нет).

Стоит отметить то, как мы можем получить к ним доступ. Для этого мы и подключили расширение hstore к нашей базе:

SELECT other_tags::hstore->'addr:region'
FROM multipolygons
WHERE osm_id='1430614'

Выдаст нам "Иркутская область".

Можно немного и поэкспериментировать теперь. Вот следующая выборка должна выдать нам все дома на улице Байкальской, города Иркутск:

SELECT other_tags::hstore->'addr:housenumber', *
FROM multipolygons
WHERE other_tags::hstore->'addr:street' ~ 'Байкальская'
  and other_tags::hstore->'addr:city' ~ 'Иркутск'

Но тут есть небольшие сложности, связанные с тем, что не для каждого дома указываются все теги. Обычно заполняютя только самые важные. То есть улица и номер дома там будет, а вот название города могут и не указать. Из-за этого использовать данные без предварительной обработки не получится. В принципе, эта проблема вполне решаема и мы справимся с ней, но чуть позже.

Формируем базу

Мы будем использовать imposm3 для конвертации данных в postgres базу. Эта утилита написаная на go - простая и удобная в испльзовании. Позволяет настроить мэппинг и фильтрацию данных. Благодаря этому, мы сэкономим время, затраченное на конвертацию, и место на жестком диске. Нас интересует определение географического местоположения по адресу: номер дома, улица, город. Следовательно, только эти данные мы и будем писать базу.

Устанавливаем imposm3

Для использования imposm3 необходмо иметь установленный golang версии не ниже 1.6 и настроенную переменную GOPATH.

Дальше все просто:

go get github.com/omniscale/imposm3
go install github.com/omniscale/imposm3/cmd/imposm3

Чтобы утилита была доступна в терминале, не забудьте добавить $GOPATH/bin в $PATH.

Заполняем БД

База данных osm_data_test была у нас тестовая, исключительно для целей изучения сырых данных, предоставляемых osm файлом. Теперь давайте сделаем базу данных под наши специфические нужды (хотя можно было бы использовать и osm_data_test):

createdb osm_data
echo 'CREATE EXTENSION postgis; CREATE EXTENSION hstore;' | psql -d osm_data

Я собираюсь организовать две таблицы:

  • одна будет содержать все возможные города/поселки/села с геометрией и прочей полезной инофрмацией;
  • вторая будет хранить здания.

Создадим файл mapping.yml и заполним его следующим образом:

tables:
  buildings:
    type: "polygon" # тип геометрии используемый для таблицы, по сути, говорим что будем брать данные из таблички multipolygons
    mapping: # фильтрация по наличия поля, и значению в этом поле
      building: [__any__] # любое не NULL значение, какие значения бывают https://wiki.openstreetmap.org/wiki/RU:Key:building
    columns: # перечисляем поля таблицы
      - {name: osm_id, type: id}                                    # идентификатор OpenStreetMap
      - {name: geometry, type: geometry}                            # геометрия
      - {key: name, name: name, type: string}                       # поле с именем объекта
      - {key: "addr:street", name: street, type: string}            # улица
      - {key: "addr:postcode", name: postcode, type: string}        # почтовый индекс
      - {key: "addr:city", name: city, type: string}                # город
      - {key: "addr:housenumber", name: housenumber, type: string}  # номер дома
      - {key: "addr:quarter", name: quarter, type: string}          # квартал
  cities:
    type: "polygon" # тип геометрии используемый для таблицы
    mapping:
      place: # кто есть кто можно посмотреть тут https://wiki.openstreetmap.org/wiki/RU:Key:place
        - city                 # крупные города
        - town                 # средний или малый город
        - village              # посёлок городского типа
        - hamlet               # Любой сельский населённый пункт размером от двух-трёх домашних хозяйств, не подходящий под критерии village
        - allotments           # всякие  СНТ, ДНТ и т.п.
        - isolated_dwelling    # хутор
        - neighbourhood        # микрорайоны, кварталы и т.п.
        - suburb               # пригороды
        - locality             # заброшенные деревни, урочище
    columns:
      - {name: osm_id, type: id}
      - {name: geometry, type: geometry}
      - {name: type, type: mapping_value}
      - {key: name, name: name, type: string}
      - {key: "addr:country", name: country, type: string}
      - {key: "addr:district", name: district, type: string}
      - {key: "addr:region", name: region, type: string}
      - {key: "addr:postcode", name: postcode, type: string}
      - {key: population, name: population, type: string}
      - {key: "population:date", name: population_date, type: string}
      - {key: official_status, name: official_status, type: string}

Подробнее о мэппинге можно прочитать здесь: imposm.org/docs/imposm3/latest/mapping.html.

Созадим конфигурационный imposm3_config.json файл, чтобы не передавать настройки подключения к БД каждый раз:

{
  "cachedir": "/tmp/imposm3_cache",
  "connection": "postgis://user:password@localhost/osm_data",
  "mapping": "mapping.yaml",
}

Запускаем конверсию и ждем:

imposm3 import -config imposm3_config.json -read data.osm.pbf -dbschema-import public -write -overwritecache

Корректируем базу

Теперь у нас есть база данных с двумя таблицами osm_buildings и osm_cities. Давайте подключимся к ней.

Возможно, вы заметили, что там есть еще таблица spatial_ref_sys. Она создается при подключении расширения postgis и содержит числовые ID и текстовые описания систем координат, используемых в пространственной базе данных.

Попробуем выбрать все здания Иркутска:

SELECT b.geometry, b.name, b.street, b.housenumber, b.city, c.name
FROM osm_cities c, osm_buildings b
WHERE c.name = 'Иркутска'
  and st_contains(c.geometry, b.geometry)

Все работает очень быстро, так как imposm3 позаботился о создании индексов, но если посмотреть, то огромное количество записей имеет незаполненое поле city. Помните, я писал, что не у всех объектов может быть заполнено поле city, тем не менее, используя postgis функцию st_contains, вычисляющую принадлежность одной геометрии другой (в нашем случае принадлежность полигона здания полигону образующему границы города), мы можем сопоставить город зданию, игнорируя значение osm_buildings.city.

Давайте заполним незаполненые значения поля city в таблице osm_buildings:

UPDATE osm_buildings b
SET city = c.name
FROM osm_cities c
WHERE st_contains(c.geometry, b.geometry) AND coalesce(b.city, '') = '';

Настраиваем поиск

Для поиска я буду использовать возможности postgreqsl для полнотестового поиска.

Рассмотрим процесс преобразования в вектор для полнотекстового поиска:

SELECT to_tsvector('Иркутск улица Лермонтова 100')
>>> '100':4 'иркутск':1 'лермонтов':3 'улица':2

Если вывод у вас выглядит как-то по-другому, скорее всего, у вас используется не русский язык по умолчанию. В этом случае укажите его явно:

SET DEFAULT_TEXT_SEARCH_CONFIG=russian

Либо, в качестве альтернативы, вы можете указывать язык явно при построении вектора:

SELECT to_tsvector('russian', 'Иркутск улица Лермонтова 100')

Пойдем дальше. Добавим строку запроса, по которой строится сопоставление:

SELECT to_tsvector('Иркутск улица Лермонтова 100'),
       plainto_tsquery('улица Лермонтова Иркутск')

Получим такой ответ:

to_tsvector plainto_tsquery
'100':4 'иркутск':1 'лермонтов':3 'улиц':2 'улиц' & 'лермонтов' & 'иркутск'

Теперь можем выполнить проверку на сопоставление, используя оператор @@:

SELECT to_tsvector('Иркутск улица Лермонтова 100')
       @@ plainto_tsquery('улица Лермонтова Иркутск')
>>> TRUE

По сути, идет проверка вложености лексем правого выражения в вектор лексем левого выражения, поменяем текст для вектора со строкой запроса, и такой запрос выдаст нам ложь:

SELECT to_tsvector('Иркутск улица Лермонтова')
       @@ plainto_tsquery('улица Лермонтова Иркутск 100')
>>> FALSE

На самом деле postgresql позволяет стоить более сложные запросы, чем те, в которых идет провека на полную вложеность, подробнее об этом можно почитать тут.

Добавляем файл с сокращениями

В адресах часто встречаются различные сокращения для слов: улица как ул., бульвар как бул. и т.д.

Например, очень хотелось бы, чтобы такой запрос выдавал все-таки TRUE:

SELECT to_tsvector('Иркутск улица Лермонтова 100')
       @@ plainto_tsquery('ул Лермонтова Иркутск')
>>> FALSE

Для того, чтобы поиск работал лучше, нам потребуется сделать файл с сокращениями. В папке /usr/share/postgresql/9.6/tsearch_data создадим файл thesaurus_russian_postgis.ths и добавим в него следующее содержимое:

площадь : *пл
площ : *пл
аллея : *ал
алл : *ал
тупик : *туп
улица : *ул
шоссе : *ш
шос : *ш
шосс : *ш
бульвар : *б
бул : *б
переулок : *пер
переул : *пер
набережная : *наб
просп : *пр
проспект : *пр

Теперь надо его активировать для БД. Для этого выполним следующие команды:

CREATE TEXT SEARCH DICTIONARY thesaurus_russian_postgis (
  TEMPLATE = thesaurus,
  DictFile = thesaurus_russian_postgis,
  Dictionary = pg_catalog.russian_stem
);
ALTER TEXT SEARCH CONFIGURATION russian
ALTER MAPPING FOR hword, hword_part, word
WITH thesaurus_russian_postgis, pg_catalog.russian_stem;

Если захочется, словарь можно будет DROP-нуть, но прежде его надо будет деактивировать:

ALTER TEXT SEARCH CONFIGURATION russian
ALTER MAPPING FOR hword, hword_part, word
WITH pg_catalog.russian_stem;

Теперь можно проверить, как работает пробразование:

SELECT to_tsvector('Иркутск улица Лермонтова 100')
>>> '100':4 'иркутск':1 'лермонтов':3 'ул':2

Далее, при преобразовании текста лексемы заменяеются на свои сокращенные копии. И, таким образом, наш запрос выдаст уже вполне адекватный ответ:

SELECT to_tsvector('Иркутск улица Лермонтова 100')
       @@ plainto_tsquery('ул Лермонтова Иркутск')
>>> TRUE

Создаем индекс для полнотекстового поиска

Теперь, когда мы более менее ориентируемся в посторении полнотекстовых запросов, можно перейти к нашей базе. У нас информация по зданиями хранится в таблице osm_buildings. Мы соберем общий текстовый вектор из трех полей city, street, housenumber. Выглядеть это будет примерно так:

SELECT to_tsvector(city) || to_tsvector(street) || to_tsvector(housenumber)
FROM osm_buildings
LIMIT 10 -- чтоб долго не ждать

Если запустить следующий запрос, который должен выдать нам все дома на улице Лермонтова, можно подвиснуть на длительное время:

SELECT *
FROM osm_buildings
WHERE to_tsvector(city) || to_tsvector(street) || to_tsvector(housenumber)
      @@ plainto_tsquery('ул Лермонтова Иркутск')

Для ускорения запроса мы создадим IMMUTABLE функцию и, используя ее, построим индекс.

Добавим функцию:

CREATE OR REPLACE FUNCTION make_tsvector(city text, street text, housenumber text)
  RETURNS tsvector
IMMUTABLE
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN (to_tsvector(city) || to_tsvector(street) || to_tsvector(housenumber));
END
$$;

Добавим индекс:

CREATE INDEX IF NOT EXISTS osm_buildings_address_gin_index ON osm_buildings
USING gin(make_tsvector(city, street, housenumber));

Подождем пару минут, пока он сформируется...

Геокодируем

Попробуем что-нибудь найти:

SELECT osm_id, city, housenumber, street, geometry
FROM osm_buildings
WHERE make_tsvector(city, street, housenumber)
      @@ plainto_tsquery('ул Лермонтова Иркутск')

Все работает мнгновенно, запрос выдает порядка 200 записей. Осталось расколдовать координаты объектов, для этого пригодится то самое поле geometry.

Мы возьмем центр геометрии здания и, перегнав в WGS84, получим список точек на карте:

SELECT osm_id, city, housenumber, street,
  st_y(st_transform(st_centroid(geometry), 4326)) as lat,
  st_x(st_transform(st_centroid(geometry), 4326)) as lon
FROM osm_buildings
WHERE make_tsvector(city, street, housenumber)
      @@ plainto_tsquery('ул Лермонтова Иркутск')

Если вывести их на карте, получим такую картину (красные точки - это найденные здания):

Это, конечно, еще не полноценный геокодер, но отличная основа. По сути, мы используем голый postgres. Для реализации более сложных решений геокодинга никто не мешает подключить тот же elasticsearch.

Хочу отметить размеры получаемой БД: в такой реализации для данных по всей России размер базы вместе со всеми индексами составил всего порядка 6GB (в противовес 50GB у номинатима).

Если вам не хватает каких-то данных, вы всегда можете отредактировать mapping.yaml и добавить дополнительные столбцы/таблицы. А утилита imposm3 - отличная и быстрая альтернатива утилитам osmosis или osm2pgsql.

На этом пожалуй все, благодарю за внимание.

PS Если вам, вдруг, понравилась эта статья, предлагаю r ознакомлению статью Административная карта на Vue+Flask, где я показываю как можно выводить геометрические данные на интерактивной карте на базе leaflet.js. Стэк все тот же postgresqlimposm3, правда там еще фронт простенький  на Vue пишем.