Atlorium

Адрес есть, координат нет: как геокодировать накопленную базу и не поверить в красивый мусор

Геокодер почти всегда что-нибудь возвращает — и в этом главная ловушка. Разбираем, как отличить координату дома от центра улицы, во что обходится пакетный прогон и где считать результат непригодным.

12 минут чтения логистика · аналитик · разработчик · продакт-менеджер

В понедельник аналитик выгрузил из CRM 38 тысяч адресов доставки и прогнал их через геокодер. Ответ пришёл почти на всё — пустых оказалось меньше двух процентов. Карта покрылась точками, зоны доставки пересчитались, новый тариф на выезд за город ушёл в прайс к вечеру среды.

Через месяц логист заметил, что в одном подмосковном посёлке все двести с лишним адресов стоят в одной точке — на въезде. Геокодер не знал в этом посёлке ни одного дома и вернул то, что знал: центр населённого пункта. Формально он не соврал. В ответе у каждой такой точки был уровень точности, и он честно говорил «это центр посёлка, а не ваш дом». Просто разбор ответа этого поля не читал: код брал latitude и longitude и клал их в базу.

Так портятся геоданные чаще всего. Не оттого, что геокодер ошибся, а оттого, что он ответил на вопрос, которого ему не задавали, — и ответ приняли за другой.

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

Где именно течёт

Геокодер устроен так, что почти всегда возвращает хоть что-нибудь: город из запроса он узнаёт даже тогда, когда не узнал улицу. Это удобно на демонстрации и разрушительно на реальной выгрузке, где часть адресов заведомо неполна.

Двести адресов в одной точке

Что происходит
В посёлке нет ни одного дома с координатой. Геокодер отдаёт центр населённого пункта, координаты у всех записей совпадают до шестого знака.
Во что обходится
Зоны доставки посчитаны по неверным точкам, курьер получает маршрут к въезду, клиент — звонок «выйдите к шлагбауму». Ошибка вскрывается на последней миле, самой дорогой.
Чем закрывается
Уровень точности приходит в каждом варианте, а результаты грубее центра улицы наш сервис не отдаёт совсем: вместо красивой ложной точки будет 404, и запрос не спишется.

«13к2» стал домом 13

Что происходит
Корпус в реестре не заведён отдельной записью. Совпадение неполное, координата берётся от дома 13 — соседнего здания того же владельца.
Во что обходится
Промах на 80–150 метров в плотной застройке. Для карты это незаметно, для расчёта времени доставки и для привязки объекта к зданию — уже нет.
Чем закрывается
Такой ответ помечен уровнем range. Если приблизительная координата хуже её отсутствия, включается режим exactOnly: он оставляет только буквальные попадания, а остальное превращает в бесплатное «не найдено».

В геокодер уехала строка из формы целиком

Что происходит
«мск, тверская 7 стр 2, 3 подъезд, домофон 45, после 18» — вместе с подъездом, домофоном и пожеланием по времени.
Во что обходится
Часть строк не находится вовсе, часть находится не тем: лишние фрагменты сбивают разбор запроса. На десятках тысяч записей это заметная доля ручной работы.
Чем закрывается
Строку сначала разбирает стандартизация адреса, и в геокодер уходит адрес, а не заметка для курьера. Порядок операций разобран ниже.

Сколько стоит прогнать базу

Считать надо не запросы, а результаты. Правило тарификации здесь одно: списывается только найденный адрес, прошедший порог качества. Ответ «не найдено», ответ «найдено, но грубее центра улицы», некорректный ввод и недоступность сервиса не оплачиваются.

В пакетном режиме то же правило работает построчно. Пакет из тысячи адресов, из которых нашлось семьсот, стоит семьсот единиц, а не тысячу. Сколько именно списано, приходит прямо в ответе полем billedUnits — сверять с личным кабинетом не нужно.

Что считаем Пример Откуда взять свою цифру
N — записей в базе 38 000 Количество строк в выгрузке адресов
Запросов к API 38 N ÷ 1000: пакет принимает до 1000 строк за вызов
Оплаченных единиц N × доля найденных Поле found в ответе на первой же тысяче
Из них пригодных «до дома» доля exact и range Разложить свои результаты по precision
Ручная очередь ненайденные + street Всё, что не прошло ваш порог точности

Замерять долю найденных на всей базе не нужно и не стоит: прогоните первую тысячу и посмотрите на found и на распределение по precision. Выгрузка обычно однородна, и первая тысяча предсказывает остальные тридцать семь с точностью, которой достаточно для планирования.

Порядок операций: разбор, реестр, координаты

Три сервиса решают три разные задачи, и путать их дорого. Стандартизация адреса работает со строкой: разбирает текст на уровни, выкидывает подъезды и комментарии, приводит запись к единому виду. ГАР/ФИАС работает с государственным реестром: подтверждает, что адрес существует, и выдаёт коды — почтовый индекс, ОКТМО, ОКАТО, КЛАДР. Геокодинг работает с картой: отвечает, где это находится.

Шаг Сервис Зачем нужен именно здесь
1 Стандартизация адреса Из строки формы делает адрес: уровни отдельно, мусор отдельно. Записи, из которых адрес выделить не удалось, дальше не идут — и не тратят платные запросы.
2 ГАР/ФИАС Нужен, если вам требуются коды и подтверждение существования адреса. Для чистой задачи «нанести на карту» шаг пропускается.
3 Прямой геокодинг Даёт координату и уровень её точности. Здесь же выбирается порог: что кладём в базу автоматически, а что уходит человеку.

Если адрес уже хранится у вас разложенным по полям, третий шаг делается не свободной строкой, а структурированным запросом: address, locality, region, postalCode. Разбор свободной строки — самый ненадёжный этап геокодирования, потому что геокодеру приходится угадывать, где город, а где улица. Готовые поля этот этап снимают. Особенно заметно на названиях, которые встречаются и как улица, и как населённый пункт.

Код: пакетный прогон и разбор ответа

Все примеры работают на публичном демо-ключе ak_sandbox_demo_mockdata_v1. Он отдаёт моки — согласованный между собой набор вокруг дома в Казани. Данные вымышленные, но форма ответа настоящая, и на ней можно написать разбор до регистрации. В пакетном демо каждая третья строка намеренно не находится: именно этот случай в реальной выгрузке обрабатывается хуже всего.

Пакетный прогон выгрузки

# Пакет: до 1000 строк за вызов, ответ строго в порядке запроса
curl -X POST \
     -H "Authorization: Bearer ak_sandbox_demo_mockdata_v1" \
     -H "Content-Type: application/json" \
     -d '{"queries":["Казань, улица Баумана, 13","Москва, Тверская 1","нераспознаваемая строка"],"sizePerQuery":1}' \
     "https://atlorium.com/api/geocodeforward/batch"

# Одиночный адрес строкой
curl -H "Authorization: Bearer ak_sandbox_demo_mockdata_v1" \
     "https://atlorium.com/api/geocodeforward?text=Казань,%20улица%20Баумана,%2013&size=5"
        

import requests

BASE = "https://atlorium.com"
HEADERS = {"Authorization": "Bearer ak_sandbox_demo_mockdata_v1"}

# Уровни точности упорядочены от лучшего к худшему.
PRECISION_RANK = {"exact": 0, "range": 1, "street": 2, "locality": 3, "area": 4, "none": 5}

# Свой порог. Для доставки до подъезда разумно "range", для аналитики по районам хватит "street".
THRESHOLD = "range"


def geocode_batch(queries: list[str]) -> dict:
    """Пакет до 1000 строк. Больше — отклоняется целиком с кодом 400."""
    resp = requests.post(
        f"{BASE}/api/geocodeforward/batch",
        json={"queries": queries, "sizePerQuery": 1},
        headers=HEADERS,
        timeout=60,
    )
    resp.raise_for_status()
    return resp.json()


def classify(item: dict) -> tuple[str, dict | None]:
    """Три исхода вместо двух: взяли, отправили человеку, не нашли."""
    if not item["matches"]:
        return "not_found", None

    best = item["matches"][0]
    if PRECISION_RANK[best["precision"]] <= PRECISION_RANK[THRESHOLD]:
        return "ok", best

    return "review", best


chunk = ["Казань, улица Баумана, 13", "Москва, Тверская 1", "нераспознаваемая строка"]
data = geocode_batch(chunk)

# billedUnits меньше requested ровно на число ненайденных строк.
print(data["requested"], data["found"], data["billedUnits"])

for item in data["items"]:
    state, match = classify(item)
    if state == "ok":
        point = match["point"]
        save(item["query"], point["latitude"], point["longitude"], match["placeId"])
    else:
        to_manual_queue(item["query"], state)
        

// Порог точности — часть бизнес-логики, а не деталь интеграции.
// Уровни упорядочены, поэтому проверка пишется одним сравнением.
public enum GeocodePrecision { Exact, Range, Street, Locality, Area, None }

public sealed record GeocodeDecision(
    string Query,
    double? Latitude,
    double? Longitude,
    string? PlaceId,
    GeocodePrecision Precision,
    bool NeedsHuman);

private static GeocodeDecision Decide(BatchItem item, GeocodePrecision threshold)
{
    var best = item.Matches.FirstOrDefault();

    if (best is null)
        return new GeocodeDecision(item.Query, null, null, null, GeocodePrecision.None, true);

    return new GeocodeDecision(
        item.Query,
        best.Point.Latitude,
        best.Point.Longitude,
        best.PlaceId,
        best.Precision,
        NeedsHuman: best.Precision > threshold);
}
        

// POST /api/geocodeforward/batch
{
  "items": [
    {
      "query": "Казань, улица Баумана, 13",
      "matches": [
        {
          "placeId": "11111111-2222-3333-4444-555555555555",
          "label": "Респ Татарстан, г Казань, ул Баумана, 13",
          "name": "13",
          "houseNumber": "13",
          "street": "ул Баумана",
          "postalCode": "420111",
          "point": { "latitude": 55.788226, "longitude": 49.121984 },
          "layer": "address",
          "precision": "exact",
          "admin": {
            "region": "Респ Татарстан",
            "county": "г Казань",
            "locality": "г Казань",
            "street": "ул Баумана"
          },
          "parentPlaceId": "66666666-7777-8888-9999-000000000000"
        }
      ]
    },
    { "query": "Москва, Тверская 1", "matches": [ /* ... */ ] },
    { "query": "нераспознаваемая строка", "matches": [] }
  ],
  "requested": 3,
  "found": 2,
  "billedUnits": 2,
  "attribution": "Адресные данные: ГАР/ФИАС (ФНС России), открытые данные. Координаты: © участники OpenStreetMap (ODbL)."
}
        

Тот же вызов на шести языках (Python, TypeScript, Go, Java, C#, PHP) — в репозитории geocoding-api-client. Примеры запускаются сразу: ключ в них уже стоит демонстрационный.

Обратите внимание на две вещи в ответе. Первая: у каждой присланной строки есть свой элемент, даже у ненайденной, и рядом с результатом лежит исходная строка — склейка со своей таблицей по позиции остаётся возможной, но перестаёт быть единственным способом. Вторая: billedUnits меньше requested ровно на число ненайденных.

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

Обратная задача: точка есть, адреса нет

Трек курьера, отметка на карте, координаты из фотографии, геопозиция из мобильного приложения — здесь известна точка, а нужен адрес. Это отдельный сервис с отдельным тарифом, и устроен он иначе: базовый ответ стоит одну единицу работы, а каждое обогащение добавляет ещё одну.

Параметр Что добавляет Когда включать
базовый запрос address — ближайший адрес с расстоянием и уровнем точности Всегда: это и есть ответ на вопрос «какой здесь адрес»
includeNeighbors neighbors — следующие по близости дома Точка стоит между двумя зданиями или у торца длинного дома
includeAdmin adminAreas — образования, в границы которых попадает точка Нужен район и регион самой точки, а не ближайшего дома
includeNearby nearby — ориентиры вокруг Нужно описать место человеку: остановка, магазин, заправка рядом

Списывается при этом факт, а не план. Резервируется худший случай — «все включённые обогащения сработали», — а платите вы за то, что действительно вернулось. Попросили адрес и ориентиры вокруг, ориентиров в радиусе не нашлось: спишется одна единица, вторая вернётся на баланс. Число приходит в ответе полем billedUnits.

Адрес по координатам с обогащениями

# Базовый ответ — одна единица работы
curl -H "Authorization: Bearer ak_sandbox_demo_mockdata_v1" \
     "https://atlorium.com/api/geocodereverse?lat=55.788226&lon=49.121984"

# С обогащениями: соседние дома и принадлежность точки по границам
curl -H "Authorization: Bearer ak_sandbox_demo_mockdata_v1" \
     "https://atlorium.com/api/geocodereverse?lat=55.788226&lon=49.121984&includeNeighbors=true&includeAdmin=true"

# Только ориентиры вокруг точки, радиус в километрах
curl -H "Authorization: Bearer ak_sandbox_demo_mockdata_v1" \
     "https://atlorium.com/api/geocodereverse/nearby?lat=55.788226&lon=49.121984&radiusKm=1&size=10"
        

def reverse(lat: float, lon: float, *, neighbors=False, admin=False, nearby=False) -> dict | None:
    resp = requests.get(
        f"{BASE}/api/geocodereverse",
        params={
            "lat": lat,
            "lon": lon,
            "includeNeighbors": str(neighbors).lower(),
            "includeAdmin": str(admin).lower(),
            "includeNearby": str(nearby).lower(),
        },
        headers=HEADERS,
        timeout=15,
    )

    # 404 — адреса у точки нет либо ближайший объект дальше пяти километров.
    # Это нормальный исход посреди поля, и он не тарифицируется.
    if resp.status_code == 404:
        return None

    resp.raise_for_status()
    data = resp.json()

    # Две административные иерархии отвечают на РАЗНЫЕ вопросы:
    #   data["address"]["admin"] — где ЧИСЛИТСЯ найденный дом (выписка из реестра);
    #   data["adminAreas"]       — внутри каких границ ЛЕЖИТ сама точка (расчёт по полигонам).
    # Для дома в городе они совпадают, для точки в поле — расходятся. Склеивать нельзя.
    return data
        

// GET /api/geocodereverse?lat=55.788226&lon=49.121984&includeAdmin=true
{
  "query": { "latitude": 55.788226, "longitude": 49.121984 },
  "address": {
    "placeId": "11111111-2222-3333-4444-555555555555",
    "label": "Респ Татарстан, г Казань, ул Баумана, 13",
    "houseNumber": "13",
    "street": "ул Баумана",
    "postalCode": "420111",
    "point": { "latitude": 55.788226, "longitude": 49.121984 },
    "layer": "address",
    "precision": "exact",
    "distanceKm": 0.0,
    "admin": { "region": "Респ Татарстан", "county": "г Казань", "locality": "г Казань", "street": "ул Баумана" }
  },
  "adminAreas": [
    { "name": "Татарстан", "kind": "респ", "level": 1, "certainty": "boundary" },
    { "name": "Казань", "kind": "г", "level": 5, "certainty": "boundary" }
  ],
  "billedUnits": 2,
  "attribution": "Адресные данные: ГАР/ФИАС (ФНС России), открытые данные. Координаты: © участники OpenStreetMap (ODbL)."
}
        

Тот же вызов на шести языках (Python, TypeScript, Go, Java, C#, PHP) — в репозитории reverse-geocoding-api-client. Примеры запускаются сразу: ключ в них уже стоит демонстрационный.

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

Чего это не умеет

Раздел, ради которого статью стоит дочитать.

Координата есть почти у всего, но нужного качества — не у всего. Цифры мы измерили на полной заливке реестра, а не оценили. У 16 % объектов координата своя, взята с карты; ещё у 17,5 % она вычислена между соседними домами. Это и есть попадание в здание — треть реестра. Ещё у 21 % известна улица, и отдаётся её центр. У остальных точнее населённого пункта ничего нет — такой ответ сервис не отдаёт вовсе, он превращается в бесплатное «адрес не найден».

Итог для планирования: из реестра целиком поиск вернёт результат примерно по половине объектов, и у двух третей найденного координата будет уровня здания. Причина не в недоработке: в государственном реестре около 60 миллионов записей, в открытой карте — около 11 миллионов адресных точек, и четверть реестровых объектов — это земельные участки, которым в карте соответствия нет в принципе. Ваша выборка почти наверняка окажется лучше этих цифр: жилые дома в городах размечены на карте плотнее, чем участки в полях, а геокодируют обычно адреса клиентов, а не реестр целиком. Прогоните свою тысячу строк на демо-ключе и считайте по ней.

Только Россия. Данные загружены по территории Российской Федерации. Зарубежный адрес вернёт «не найдено» — бесплатно, но и бесполезно. Международную логистику на этом сервисе планировать нельзя.

Это не адресный справочник. Сервис отвечает, где объект находится. Официальное написание адреса, коды и подтверждение существования записи — работа ГАР/ФИАС и стандартизации адреса. Мы не пытаемся их заменить, и связка из двух вызовов остаётся нормальным способом решить задачу целиком.

Точки предпочтения нет. Сказать «ищи рядом вот с этой координатой» нельзя: запрос принимает адрес и количество вариантов, а не центр поиска. Если у вас есть подсказка о регионе — передавайте её полем region структурированного запроса, это работает лучше и предсказуемее.

Границы есть не у всего и упрощены. Геометрию в GeoJSON имеют административные образования — регион, район, город. У дома и улицы её нет: запрос не станет ошибкой, поле останется пустым, и дополнительная единица работы не спишется. Сама геометрия упрощена с допуском около ста метров, иначе ответ по субъекту весил бы десятки мегабайт. Точки у самой границы могут попасть не на ту сторону — для точного ответа о принадлежности берите обратный геокодинг, он считает по неупрощённой границе.

Ни квартир, ни подъездов. Самый подробный уровень — здание с номером дома. Ни помещений, ни жильцов в данных нет и быть не может: сервис работает с картой и реестром, а не с персональными данными.

Демо-ключ отдаёт один и тот же набор. В геокодинге это сделано намеренно: случайные координаты клиент ставит на карту и следующий час ищет несуществующую ошибку в своём коде. На моках удобно проверить форму ответа и разбор полей, а вот ветки «не найдено» и «низкая точность» на реальных данных придётся проверять своим ключом — кроме пакетного режима, где каждая третья строка не находится специально.

Частые вопросы

Частые вопросы

Что такое геокодирование адреса?

Это превращение адреса в координаты: на входе текст вроде «Казань, улица Баумана, 13», на выходе широта и долгота в системе WGS 84 — той же, что у GPS и у карт. Обратная задача, из координат в адрес, называется обратным геокодированием и решается отдельным сервисом.

Чем precision exact отличается от range?

Exact означает, что дом есть в данных и координата принадлежит именно ему. Range — одно из двух: либо положение дома вычислено между координатами соседей на улице пропорционально номеру, либо совпадение оказалось неполным, например спрашивали «13к2», а в реестре дом заведён одной записью «13». Попасть в нужный квартал такой результат позволяет, указать конкретное здание — нет.

Как геокодировать сразу всю базу адресов?

Пакетным режимом: POST на /api/geocodeforward/batch принимает до 1000 строк за вызов, выгрузка большего размера разбивается на части. Ответ приходит строго в порядке запроса, по одному элементу на каждую присланную строку, и каждый элемент несёт исходную строку рядом с результатом. Ненайденная строка пакет не обрывает и не оплачивается.

Сколько списывается, если адрес не найден?

Ничего. Тарификация идёт по результату: ответы «не найдено» и «найдено грубее центра улицы», некорректный ввод и недоступность сервиса не оплачиваются. В пакете счёт ведётся построчно, и фактическое число списанных единиц приходит в ответе полем billedUnits.

Можно ли получить координаты по почтовому индексу?

Да, но ответом будут не дома, а адресообразующие объекты: населённый пункт и улицы, которым принадлежит индекс. Домов на один индекс десятки тысяч, и такой список не отвечает на заданный вопрос. Режим удобен для автозаполнения формы и для проверки, что индекс в присланных данных не противоречит городу.

Чем геокодинг отличается от поиска в ГАР/ФИАС?

Геокодинг отвечает на вопрос «где это находится» и даёт координату с уровнем её точности. Поиск в государственном адресном реестре отвечает на вопрос «существует ли такой адрес» и выдаёт идентификаторы и коды — индекс, ОКТМО, ОКАТО, КЛАДР. Для миграции базы обычно нужны оба, и звать их стоит в таком порядке: сначала разбор строки, потом реестр, потом координаты.

Сервисы из этой статьи

Прямой геокодинг

Координаты по адресной строке: широта, долгота, разобранная структура и уровень точности. Оплата только за найденное

Обратный геокодинг

Адрес по координатам: ближайший дом и расстояние до него. По желанию — соседние дома, административная принадлежность точки и ориентиры вокруг, каждое дополнение отдельной единицей

Стандартизация адреса

Разбор адресной строки в структуру: уровни (регион/город/улица/дом/квартира), нормализация, коэффициент качества

Адреса ГАР/ФИАС

Поиск и нормализация адресов по справочнику ГАР/ФИАС

Попробовать прямо сейчас — без регистрации

Демо-ключ ak_sandbox_demo_mockdata_v1 — публичный и общий для всех. С ним API отвечает моками: данные правдоподобные, но сгенерированные, и они не меняются от запроса к запросу — на них удобно писать тесты. Настоящие данные приходят с личным ключом.

curl -H "Authorization: Bearer ak_sandbox_demo_mockdata_v1" \
     https://atlorium.com/openapi/geo_ru.json
Полезно? Перешлите коллеге: Telegram VK

Восстанавливаем соединение…

Похоже, связь с сервером ненадолго прервалась. Переподключаемся автоматически — пожалуйста, подождите несколько секунд.

Не удалось переподключиться

Проверьте интернет-соединение. Можно повторить попытку или обновить страницу.

Сессия устарела

Соединение восстановлено, но сессию нужно перезагрузить. Обновите страницу, чтобы продолжить.