Appearance
ADR-015: Batch Geocoding Architecture
Дата: 2026-06-01 (sanity-check + OSM-match benchmark 2026-06-02; policy update 2026-06-03) Статус: Proposed — выбран Plan A+D hybrid (OSM auto-resolve + manual select fallback), ждёт утверждения архитектораКонтекст: DEV-402 (Геолокация и адресный флоу) — premortem H-003, DEV-426 §7.2 Связанные документы:
- ADR-014 Address Identifier
- ADR-016 Geo Suggest Engine — runtime suggest (отдельный вопрос; free-tier DaData policy установлена там)
- DEV-402 фронт-спека
- ГАР sanity-check 2026-06-02 (DEV-433)
- OSM-match benchmark 2026-06-02
Policy update (2026-06-03): При работе над ADR-016 уточнена free/OSS политика — paid DaData запрещён в проекте (любой режим: runtime и batch). Free-tier DaData Suggestions API разрешён, но Geocoding API не имеет free tier → DaData batch fill-in (исходный Plan C+B) исключён. Текущая версия ADR — переработка под политику.
Контекст
ADR-014 зафиксировал internal address_id (UUID) как ключ адреса и таблицу address_coordinates. Для гибрида авто-метро в форме продавца (premortem H-003) /store/geo/resolve обязан вернуть coords: {lat, lon} и metro_station_id (если ближайшая станция < 1500m). ГАР XML-дамп ФИАС координат уровня здания не содержит — нужен отдельный batch-источник координат.
Скоуп MVP: 7 городов РФ с метро — Москва, Санкт-Петербург, Нижний Новгород, Казань, Екатеринбург, Новосибирск, Самара.
Бюджет: ≤ 50K ₽ разово.
Технологии: PHP Slim 4 + PostgreSQL (PostGIS возможен).
Реальный масштаб
OSM Overpass (sanity-check 2026-06-01): count(building+addr:housenumber) per город:
| Город | OSM building+addr |
|---|---|
| Москва | 149,066 |
| Санкт-Петербург | 85,842 |
| Казань | 68,844 |
| Новосибирск | 58,621 |
| Самара | 37,354 |
| Нижний Новгород | 35,607 |
| Екатеринбург | 29,721 |
| Итого OSM | ~465K |
ГАР XML COUNT (DEV-433, sanity-check 2026-06-02): AS_HOUSES где ISACTIVE=1 AND ISACTUAL=1, привязанные к 7 целевым городам:
| Город | GAR active+actual | OSM | Δ % |
|---|---|---|---|
| Москва | 289,680 | 149,066 | +94% |
| Санкт-Петербург | 135,525 | 85,842 | +58% |
| Казань | 160,891 | 68,844 | +134% |
| Новосибирск | 126,381 | 58,621 | +116% |
| Самара | 78,788 | 37,354 | +111% |
| Нижний Новгород | 138,191 | 35,607 | +288% ⚠️ |
| Екатеринбург | 70,743 | 29,721 | +138% |
| Итого ГАР | 1,000,199 | 465,055 | +115% |
Изначальная оценка 6-8M завышена в 6-8 раз. Фактический addressable set по ГАР ≈ 1.0M записей. Расхождение GAR vs OSM объясняется multiplicity записей (корпуса/строения/литеры в ГАР = 1 building в OSM, типично 2-3×). Аномалия по НН требует доп. проверки (LEVEL=2 после реформы марта 2026 может захватывать прилегающие посёлки).
Полная методология и расчёт бюджета — gar-count-2026-06-02.md.
Решение
Принять гибридный подход A+D — DIY OSM ETL pipeline даёт auto-resolve на ~12% адресов (OSM coverage), для остальных 88% продавец выбирает станцию метро вручную в UI (manual select fallback, premortem H-003). Без paid DaData fill-in (нарушает free/OSS политику).
Архитектура
┌─────────────────────┐
│ Geofabrik Russia │
│ OSM PBF (~3.8GB) │ ←──── ODbL 1.0, daily snapshot
└──────────┬──────────┘
│ osmium / pyosmium
▼
┌──────────────────────────────────┐
│ Extract nodes/ways с тегами: │
│ building=* │
│ addr:city, addr:street, │
│ addr:housenumber │
│ → centroid lat/lon │
└──────────┬───────────────────────┘
│ libpostal нормализация
│ streetmangler словарь
│ (ул./улица, пр-кт/проспект, корпуса)
▼
┌──────────────────────────────────┐
│ Fuzzy-match с ГАР записями │
│ (addresses.source='gar') │
│ Match score ≥ threshold │
└──────────┬───────────────────────┘
│ match (~12%) → INSERT address_coordinates(source='osm')
│
│ no-match (~88%) → ничего; address_coordinates без записи
▼
┌──────────────────────────────────────────────────────────┐
│ Runtime /store/geo/resolve(address_id): │
│ если address_coordinates row есть → coords + metro auto │
│ иначе → coords=null, UI показывает manual metro select │
└──────────────────────────────────────────────────────────┘Цикл регулярно перезапускается (ETL daily/weekly) — рост OSM coverage снижает долю manual-select адресов со временем. Pre-warm OSM contribution через mapping party (открытый вопрос #8) — long-term путь к ≥50% OSM coverage.
Стек
| Компонент | Технология | Лицензия | Обоснование |
|---|---|---|---|
| Источник OSM | Geofabrik Russia PBF (3.8GB) | ODbL 1.0 | Бесплатно, daily snapshot, ODbL разрешает commercial use с attribution |
| Парсинг PBF | osmium-tool / pyosmium / php-osmium | BSL | Industry-standard, эффективен на больших файлах |
| Нормализация адресов | libpostal | MIT | 99.45% global parse accuracy (NB: RU отдельно не бенчмаркен — см. §Caveats) |
| Сокращения улиц | streetmangler словарь | MIT | Покрывает «ул./улица», «пр-кт/проспект», корпуса |
| Fallback для unmatched (~88%) | Manual metro select в UI (DEV-402 фронт) | — | Продавец выбирает станцию из dropdown; нет paid external API |
| Хранение координат | address_coordinates (см. ADR-014) — отсутствие row = нет auto-resolve | — | UUID PK на addresses.address_id; на pages без row UI fallback |
| Runtime запрос «метро в 1500m» | PostGIS ST_DWithin + GIST + KNN <-> | — | Микросекунды на запись (только для адресов с coords) |
Не используется
- OSM Nominatim self-host — over-engineering для одноразового batch; создан под runtime queries, требует 64GB+ RAM для full planet (или 16-32GB для RU). Прямой парсинг osmium эффективнее.
- DaData Geocode Batch API (любой режим — primary или fill-in) — нарушает free/OSS политику проекта (2026-06-03). Geocoding API не имеет free tier, 0.20 ₽/адрес × ~35-50K residual = 7-10K ₽ оплаты. Исключено единогласно с ADR-016 §«Не используется».
- DaData как primary — 465K × 0.20 = 93K ₽; нелегитимно по free/OSS политике (см. выше).
- Yandex Maps API Geocoder — $10K/год минимум (~900K ₽, 18× over budget) + историческое ToS-ограничение на хранение координат.
- 2GIS Geocoder API — ToS §4.2 (law.2gis.ru/api-rules) прямо запрещает долговременное хранение результатов геокодинга.
- OpenAddresses.io РФ — статический архив 2021, 15.4% покрытие, 7 целевых городов не enumerated.
- Росреестр — нет documented batch coordinate export API.
- Реформа ЖКХ (NextGIS dump) — только МКД, неполное покрытие ИЖС, формат не подходит под прямую интеграцию с ГАР.
Бюджет
Фактический OSM bottleneck: OSM-match benchmark (2026-06-02) показал — OSM coverage building+addr в России = ~12% от ГАР (120K из 1M записей). Никакое улучшение алгоритма (libpostal, deeper fuzzy) не превысит этого потолка на текущем состоянии OSM.
Результаты benchmark (sample 10,499 ГАР-адресов):
| Город | OSM building+addr | GAR | Теор. max | Sample match (с false-pos) |
|---|---|---|---|---|
| Москва | 31,778 | 289,680 | 11.0% | 29.9% |
| Казань | 34,512 | 160,891 | 21.5% | 35.2% |
| Самара | 1,576 | 78,788 | 2.0% | 4.9% |
| Итого | 120,632 | 1,000,199 | 12.1% | 23.3% |
Текущий 23.3% содержит false-positives (overly-permissive housenum нормализация). Реальный bound ≈ 12% OSM coverage.
Полный расчёт: osm-match-2026-06-02.md.
Варианты для решения (исторические)
| План | Бюджет | Coverage | Status |
|---|---|---|---|
| A — pure OSM 100% (исходный) | ~10K ₽ | ❌ невозможен (OSM 12% bound) | отброшен |
| B — DaData fill-in полное покрытие | 150-200K ₽ | 100% | отброшен по free/OSS политике |
| C — scope-cut «1500m буфер метро» c DaData | 50-100K ₽ | ≈30-50% (в зоне метро) | отброшен (содержит DaData) |
| D — manual metro select только | ~0 ₽ | 0% auto, 100% manual | rejected: UX downgrade для всех |
| E — OSM-rich cities first + DaData fill-in | ~50K ₽ | 4 города full + 3 manual | отброшен (содержит DaData) |
| A+D hybrid (принято) | ~10K ₽ | 12% auto + 88% manual select | выбрано 2026-06-03 |
Принято: A+D hybrid. OSM ETL даёт 12% адресов с auto-resolve. Для 88% продавец выбирает станцию метро вручную в UI (DEV-402 frontend). Без paid external API. Бюджет ~10K ₽ (только OSM ETL engineering).
Engineering effort (оценка)
4-8 человеко-дней backend + 2-3 дня frontend (manual select UI):
- osmium PBF extract + centroid calculation (1 день)
- libpostal нормализация ГАР streets (1-2 дня)
- Fuzzy-match streetmangler + Python (2-3 дня)
- QA + reconciliation reports (1-2 дня)
- Frontend manual metro select component (DEV-402) — dropdown с поиском, fallback UI когда
address_coordinatesrow отсутствует (2-3 дня)
Последовательность работ
- ✅ Backend sanity-check ГАР COUNT (DEV-433): выполнен 2026-06-02 — 1.0M записей. Отчёт.
- ✅ OSM-match benchmark: выполнен 2026-06-02 — реальный bottleneck = OSM coverage ≈ 12%. Отчёт.
- ✅ Policy clarification (2026-06-03): free/OSS only, paid DaData запрещён → выбран Plan A+D hybrid.
- Schema: таблица
address_coordinates(DDL в ADR-014) - ETL Phase 1 — OSM extract: Geofabrik Russia PBF → osmium → building+addr nodes/ways → centroid
(lat, lon)+ raw address fields - ETL Phase 2 — Match: libpostal нормализация → streetmangler словарь → fuzzy match с
addressesпо(region_id, city_id, street_normalized, house_number)→ INSERTaddress_coordinates(source='osm') - Validation: 10 случайных матчей МСК + 10 СПб проверить руками по карте → > 95% правильных
- Frontend manual metro select (DEV-402): dropdown компонент с поиском по
metro_stations, появляется когда/store/geo/resolveвернулcoords: null(адрес не в OSM coverage). Сохраняет выбор в форме продавца. - Recurring ETL: weekly osm-pbf snapshot → re-run pipeline → новые OSM coverage апдейты постепенно снижают долю manual-select адресов.
Acceptance
GET /store/geo/resolve?address_id=<любой дом МСК или СПб> возвращает:
coords: {lat, lon} | null— null когда адрес не в OSM coverage (frontend показывает manual metro select)metro_station_id: <id> | null— auto-resolved только если coords ≠ null И ближайшая станция ≤ 1500mmetro_distance_m: <int> | null— расстояние в метрах для auto-resolved случая
Покрытие: ≥ 12% адресов в addresses для 7 городов имеют запись в address_coordinates (auto-resolve). Остальные 88% — manual select в UI (premortem H-003 fallback теперь — основной путь для большинства адресов).
Последствия
Положительные
- Стоимость ~10K ₽ (только OSM ETL engineering effort, без paid external API)
- Open data — нет vendor lock-in; смена источника на любой другой провайдер не блокируется (ADR-014
addresses.source IN ('gar', 'kladr', 'dadata', 'osm', 'manual')) - Контроль качества — на каждом этапе можно вставить custom rules (например, отбросить матчи с low score < 0.7)
- Расширяемость — добавить новый город = обновить геофильтр в ГАР, перезапустить pipeline
- Стабильность — runtime не зависит от внешнего API (только internal
address_coordinateslookup) - Соответствие free/OSS политике — нет paid DaData, согласовано с ADR-016
- Coverage growth со временем — recurring ETL подхватывает рост OSM contribution; manual-select fraction снижается естественно
Отрицательные
- 88% адресов требуют manual metro select — UX downgrade для большинства продавцов в форме. Premortem H-003 fallback теперь — основной путь, не редкий fallback
- Engineering effort 4-8 backend + 2-3 frontend дней против 1-2 дней при чистом DaData
- Качество libpostal RU не отдельно бенчмаркен — risk false negatives на сложных адресах
- OSM PBF обновляется ежедневно, ГАР — 2×/нед — нужна стратегия sync между источниками (см. §Открытые вопросы)
- ODbL share-alike — если публиковать derived
address_coordinatesexternal — под ODbL; для внутреннего use Partizap — без ограничений - Зависимость от продавцов в выборе метро — корректность 88%
metro_station_idзависит от disciplined input в форме (валидация дропдауном против списка станций митигирует)
Откатываемость
Если 12% OSM coverage оказался недостаточным для UX (например жалобы на manual select dominance):
- Pre-warm OSM contribution — mapping party с импортом ГАР building data в OSM (long-term, 1-2 года к ≥50% coverage). Открытый вопрос #8.
- Plan B revisited — переутверждение paid DaData fill-in бюджета с продактом/финансами (требует override free/OSS политики).
- OSM Nominatim self-host для адресов вне Geofabrik PBF — расширение алгоритма за счёт runtime queries (over-engineering для batch, но возможно если нужно).
- Alternative free data sources — re-evaluate OpenStreetMap planet (вместо Geofabrik RU), реформа ЖКХ NextGIS dump для МКД (частичное покрытие новостроек).
Multi-phase rollback возможен; не блокирует фронт-эпик DEV-432.
Альтернативы (отклонены)
| Подход | Стоимость | Причина отклонения |
|---|---|---|
| OSM Nominatim self-host | ~100K setup + 10K/мес | Over-engineering под one-shot batch; Nominatim runtime-oriented |
| DaData Geocode (paid, любой режим) | 7-200K ₽ | Нарушает free/OSS политику (2026-06-03); Geocoding API не имеет free tier |
| Yandex Maps API | ~900K ₽/год | 18× over budget + ToS restrictions |
| 2GIS API | — | ToS §4.2 запрещает storage |
| OpenAddresses.io РФ | 0 ₽ | Архив 2021, 15.4% coverage, 7 целевых городов не enumerated |
| Росреестр PKK | 0 ₽ | Нет batch coordinate export API |
| Plan B (full DaData fill-in) | 80-150K ₽ | Нелегитимно по политике; ранее рекомендован, после policy update отброшен |
| Plan E (OSM-rich cities + DaData fill-in) | ~50K ₽ | Содержит paid DaData → отброшен |
Открытые вопросы
- ✅ ГАР sanity-check (DEV-433, 2026-06-02): 1.0M записей.
- ✅ OSM-match benchmark (2026-06-02): реальный bottleneck = OSM coverage ≈ 12%, не алгоритм.
- ✅ Решение по плану (2026-06-03): A+D hybrid — Plan B/C/E содержали paid DaData → отброшены по free/OSS политике.
- Аномалия НН (+288% vs OSM): валидация descendants OBJECTID=889336 — действительно ли LEVEL=2 муниципальный округ захватывает прилегающие посёлки. Спец-фильтр может потребоваться.
- libpostal RU accuracy: не критично (bottleneck в OSM coverage, не алгоритме), но даст +2-3% точности для production ETL.
- OSM ↔ ГАР sync стратегия: OSM daily snapshot vs ГАР 2×/нед дельты — как часто перезапускать ETL? Sequence: ГАР delta → OSM PBF snapshot → diff → INSERT/UPDATE только новых матчей
- ODbL share-alike на derived database — если
address_coordinatesэкспортируется в любую внешнюю систему (BI/analytics/affiliate-программу) — нужна legal review - Pre-warm OSM contribution (long-term): mapping party + ГАР import → coverage до 50-80% за 1-2 года, уменьшит долю manual-select адресов
- Manual select UX (DEV-402): правильно ли продавцы выбирают станцию? Метрика accuracy через выборочный sample + cross-check с почтовым индексом или другой proxy. Если accuracy < 80% — пересмотреть UI или вернуться к плану B (paid DaData с переутверждением)
- Manual select discoverability: в каких случаях показывать поле метро вообще (только когда
coordsесть и в зоне 1500m от метро) vs всегда показывать как опциональное
Action items
Выполнено
- [x] Backend (DEV-433): sanity-check ГАР COUNT — выполнен 2026-06-02. Отчёт.
- [x] Backend (новый спайк): OSM-match benchmark — выполнен 2026-06-02. Отчёт.
- [x] Policy clarification (2026-06-03): free/OSS only, paid DaData запрещён → выбран Plan A+D hybrid.
Blocking для prod-launch
- [ ] Архитектор: review ADR-015 → Accepted (бюджет ~10K ₽ — engineering effort, не требует финансовых согласований).
- [ ] Backend: валидация descendants для НН (OBJECTID=889336) — действительно ли соответствует именно городу.
- [ ] Backend (опц.): libpostal benchmark на 1K → подтвердить +2-3% точности.
- [ ] Backend (DEV-426 §7.2): ETL pipeline согласно §Последовательность работ — Phases 4-7 (OSM extract + match + validation).
- [ ] Backend (DEV-426 §7.4): sample-валидация 30 случайных матчей по карте → ≥ 95% правильных (false-positive bound).
- [ ] Frontend (DEV-402): manual metro select component — dropdown с поиском по
metro_stations, появляется когда/store/geo/resolveвернулcoords: null. Валидация выбора против списка станций. - [ ] Backend (DEV-426): endpoint
/store/geo/resolveповедениеcoords: nullдля адресов безaddress_coordinatesrow + четкий контракт frontend manual select.
Recurring
- [ ] Backend (DEV-426): weekly cron — OSM PBF snapshot → re-run ETL → расширение
address_coordinatesза счёт новых OSM contributions.
Долгосрочное
- [ ] Community (long-term): mapping party + ГАР import в OSM для поднятия coverage > 50% за 1-2 года (открытый вопрос #8).
- [ ] Legal: review ODbL share-alike implications если данные экспортируются external.
- [ ] Backend: metric
geo.resolve.manual_select_fraction— доля адресов без auto coords. При росте к 100% / устойчиво > 90% → пересмотр стратегии (Pre-warm OSM / переутверждение Plan B).
Принято
- [ ] Backend lead: ___
- [ ] Архитектор: ___
- [x] План: A+D hybrid (выбран 2026-06-03 после free/OSS policy clarification)
- [ ] Дата принятия: ___