Skip to content

ADR-016: Geo Suggest Engine

Дата: 2026-06-03 Статус: Proposed Контекст: DEV-380 (API/runtime integration), DEV-386 (GeoProvider), DEV-387 (/store/geo/suggest endpoint)

Связанные документы:


Контекст

/store/geo/suggest — публичный endpoint автодополнения адреса для формы продавца и каталога. Принимает короткий query (Невский 1, СПб, Мск), возвращает топ-10 кандидатов с address_id для последующего resolve.

Не путать с ADR-009 — там продуктовый поиск (товары/OEM/категории). Здесь — адресный suggest над 1M+ ГАР записей.

Не путать с ADR-015 — там batch lat/lon координаты (DIY OSM ETL + DaData fill-in). Здесь — runtime автодополнение по тексту.

Требования

  • Hit rate (MVP gate): ≥ 48/50 gold-set top-3 без fallback; 50/50 с DaData Suggestions free-tier fallback при MVP-нагрузке (1-5K req/day, вписывается в 10K/day free limit). При росте нагрузки выше free limit — fallback отключается, hit rate возвращается к 48/50 + 2 known limitations.
  • Latency: P95 ≤ 200ms @ 100 RPS (DEV-428 §7.4 SLO)
  • Typo tolerance: «Каазнь» → Казань, «Перетербург» → Санкт-Петербург (gold-set §2) — закрывается Meilisearch typoTolerance native + DaData Suggestions для heavy-typo edge cases
  • Сокращения: «СПб», «ул.», «пр-кт», «мкр» (gold-set §1, §3)
  • Бюджет: zero recurring spend в MVP (Meilisearch self-hosted + DaData free tier only)
  • PHP Slim 4 backend (/store/* уже на этом стеке)
  • DaData policy: разрешён ТОЛЬКО free tier (10K req/day Suggestions API). Платные тарифы — нет. Подход уточнён после премортема H-001: free OK, paid нет (см. премортем)

Характеристики нагрузки

ЭтапЗаписей в индексеЗапросов/день
MVP (7 городов)1M (ГАР active+actual)1-5K
Year 1 (89 регионов)~12M50-100K
Year 212M + дельты100-500K

Решение

Принято Meilisearch как primary suggest engine. Gap до 48/50 закрывается scope expansion в DEV-379 (city + district rows) + DEV-427 (metro_stations) + DEV-386 (mix-script normalization). DaData Suggestions free-tier (10K req/day) добавляется как fallback decorator на пустой результат — закрывает 2 heavy-typo queries до 50/50 при MVP-нагрузке.

Free-tier limit (10K req/day) рассчитан на MVP (1-5K req/day). При приближении к limit — fallback отключается feature flag, hit rate деградирует до 48/50, открывается follow-up на OSS spell-checker (Hunspell/SymSpell) или другие варианты.

pg_trgm — только если архитектор накладывает constraint «без новых runtime-сервисов» (см. §«Откатываемость» Plan C).

Обоснование

Спайк DEV-430 (2026-06-03) на 4.45M ГАР записей 7 регионов прогнал gold-set 50 запросов × {pg_trgm, Meilisearch} × {baseline, +synonyms}:

EngineModetop-3 hit rateP50 latencyP95 latency
pg_trgmbaseline33/50 (66%)477ms2961ms
pg_trgm+synonyms33/50 (66%)952ms10662ms
Meilisearchbaseline32/50 (64%)5ms16ms
Meilisearch+synonyms38/50 (76%)8ms47ms

Решающие факторы:

  1. Latency: Meilisearch ~100× быстрее pg_trgm (P50 5-8ms vs 477-952ms). На 4.45M записях pg_trgm GIN+<% всё равно делает bitmap heap recheck, который на популярных trigram превышает SLO 200ms (P95 = 2961ms у baseline). Меньшая база (89 регионов = ~12M) усугубит проблему ×3. Plan DEV-428 §7.4 (P95 ≤ 200ms @ 100 RPS) физически недостижим с pg_trgm на полном ГАР без дополнительного scope-cut.

  2. Synonym effectiveness: Meilisearch +6 от словаря (32→38/50). pg_trgm +0 (33→33/50) — словарные expansion-варианты в pg_trgm не разрешают типичные fail-cases (метро #41-43, district «р-н» #21, city abbreviation без synonyms #4-6).

  3. Typo tolerance: Meilisearch ловит «Каазнь» → Казань нативно. pg_trgm на пороге word_similarity ≥ 0.45 возвращает 0 hits; на пороге 0.28 возвращает 476K записей (slow + irrelevant rank).

  4. Index build time: Meilisearch индексировал 4.45M записей за 50 секунд. pg_trgm GIN build на той же базе ~5 минут. Для weekly ГАР deltas Meilisearch более operationally friendly.

  5. Operational cost: Meilisearch — отдельный сервис, отдельный backup/restore, отдельный мониторинг. ADR-009 уже принял Meilisearch для product search в Phase 2 — переиспользование инфраструктуры снижает marginal cost.

Закрытие gap (38/50 → 48/50)

Спайк Meilisearch+synonyms дал 38/50 (76%). Категоризация 12 fail и план закрытия каждого без external fallback:

КатегорияКол-воРешениеOwner
City queries («Москва», «СПб» как city entity, не house substring)4Добавить level='city' rows в addresses для топ-городов + city_id scoping в endpointDEV-379 (ETL) + DEV-387 (contract)
Mix-script edge cases («моCKва» Latin+Cyrillic)3confusables.normalize() или Unicode mapping в suggest_normalize chainDEV-386 (worker)
Metro queries2Индексировать metro_stations отдельным Meilisearch index либо в addresses с level='metro'DEV-427 + DEV-386
District queries1Добавить level='district' rows в ETLDEV-379
Heavy typos (≥2 symbol edit в 6-char слове)2DaData Suggestions free-tier fallback на пустой результат (см. §«Эскалация на DaData») — закрывает в MVP при usage < 10K req/dayDEV-386 (decorator)

Итого: 48/50 (96%) без external fallback, 50/50 (100%) с DaData free-tier fallback при MVP-нагрузке.

Принятые ограничения

  • Free-tier disable fallback — при usage > 10K req/day DaData fallback отключается feature flag. Hit rate деградирует до 48/50, 2 heavy-typo queries дают пустой результат. UX смягчается сообщением «попробуйте другое написание» + ручной ввод адреса.
  • Migration trigger — при стабильном approach to free limit (например > 7K req/day по перцентилю недели) открывается follow-up ADR: OSS spell-checker (Hunspell/SymSpell self-hosted), либо переутверждение paid DaData tier с продактом/финансами.

Стек

КомпонентРешениеЛицензияОбоснование
Suggest engineMeilisearch v1.10+MIT76% hit rate baseline → 96% после scope expansion (DEV-379+427+386), P95 47ms, 50s index build на 4.45M
Index dataaddresses table (ADR-014 schema) + metro_stations (DEV-427)Single source; обновляется ETL DEV-379
NormalizationSQL функция suggest_normalize(q text) + Python/PHP wrapper читают geo-synonyms.yaml + Unicode confusables mappingПереносимо между языками (premortem DEV-430 H-004); mix-script normalization добавляется в DEV-386
Словарь синонимовpartizap-docs/specs/geo-synonyms.yamlSingle source с провенансом (universal / gold-set / historical)
Rate limitingNginx limit_req zone=suggest (~30 req/min per IP)Защита от scraping/DoS — endpoint публичный (premortem H-004)
Fallback на пустой результатDaData Suggestions free tier (10K req/day)Commercial free tierЗакрывает 2 heavy-typo queries; усложение фичей-флагом отключается при approach к free limit
Empty result UX (после fallback)Сообщение «попробуйте другое написание» + ручной ввод адресаНа случай когда fallback отключён или тоже не нашёл

Эскалация на DaData (free tier only)

php
// в /store/geo/suggest
$results = $this->meilisearch->search($q, $cityId, $limit);
$this->logger->info('geo.suggest', [
    'query' => $q,
    'normalized' => $normalized,
    'expanded_terms' => $expandedTerms,
    'hit_count' => count($results),
    'synonyms_version' => $this->synonymsVersionHash,  // версия geo-synonyms.yaml на момент запроса (lru-cached)
]);

if (count($results) === 0) {
    $this->metrics->increment('geo.suggest.empty_count');
    if ($this->featureFlag->enabled('geo.dadata_fallback')) {
        $results = $this->dadata->suggest($q);  // free tier API
        $this->metrics->increment('geo.suggest.dadata_fallback_count');
    }
    // Если fallback отключён или вернул пусто — UI показывает «попробуйте другое написание»
}
return $results;

Free-tier governance:

  • DaData Suggestions API free tier: 10K req/day на API-ключ (актуальный лимит — проверить перед launch на dadata.ru/api/suggest/)
  • MVP usage prediction: 1-5K req/day, empty rate < 5% → DaData fallback calls ~ 50-250/day → 100× margin to free limit
  • Feature flag geo.dadata_fallback управляется ops без code-change
  • Alert правила:
    • geo.suggest.dadata_fallback_count > 7000/day (70% от free limit) → notify backend lead, open migration ADR
    • geo.suggest.dadata_fallback_count > 9500/day (95%) → автоматически отключить flag
  • Migration paths при approach to limit: OSS spell-checker self-hosted, переутверждение paid tier, или принятие 48/50 как final DoD

Не используется

  • pg_trgm only — latency P95 2961ms на 4.45M не вписывается в DEV-428 SLO. На полном ГАР (89 регионов, ~12M) ситуация хуже.
  • OpenSearch / ElasticSearch — оверхэд оператора JVM + heap tuning. Meilisearch достаточно для текущих требований.
  • DaData Suggestions paid tier (как primary или unlimited fallback) — нарушает zero-recurring-spend политику MVP. Free tier (10K req/day) допустим как ограниченный fallback (см. §«Эскалация на DaData»). Paid переутверждение — отдельный ADR с продактом/финансами.
  • DaData Geocode / Clean API — paid only, не используется. (Note: ADR-015 batch geocoding пересматривается отдельно — требование free/OSS политики распространяется и на batch.)
  • Nominatim self-hosted (OSS geocoder) — рассматривалось как замена fallback. Отложено: +1 runtime сервис, отдельный ETL pipeline, не закрывает heavy-typo case без дополнительного spell-checker. Возможно в отдельном ADR если empty rate > 15% в проде или при approach к DaData free limit.
  • Sphinx / Manticore — морально устаревает, отсутствие native typo tolerance.
  • Algolia / Typesense — over-budget для MVP + commercial vendor lock-in.

Архитектура (high-level)

                /store/geo/suggest?q=...&city_id=...


                  ┌─────────────────────┐
                  │ Nginx rate limit    │  30 req/min per IP
                  └──────────┬──────────┘

                  ┌─────────────────────┐
                  │ PHP Slim controller │
                  └──────────┬──────────┘
                             │ DEV-386 GeoProvider

            ┌──────────────────────────────────────┐
            │ 1. suggest_normalize(q)               │  SQL function
            │ 2. mix-script confusables normalize   │  Unicode mapping
            │ 3. expand via geo-synonyms.yaml       │  PHP read shared YAML (lru-cached version hash)
            │ 4. Meilisearch search()               │  v1.10 single-node MVP
            │ 5. structured log {q, expanded, hits} │  observability (H-003)
            └─────────────────┬────────────────────┘

                   hits >0 ───┴─── hits == 0
                     │                │
                     ▼                ▼
              return results    ┌────────────────────────┐
                                │ feature flag enabled?  │
                                └─────────┬──────────────┘

                                  yes ────┴──── no
                                   │            │
                                   ▼            ▼
                          ┌────────────┐   return [] + metric empty_count
                          │ DaData     │   (UI: «попробуйте другое написание»)
                          │ Suggestions│
                          │ (free tier)│
                          └─────┬──────┘

                        metric dadata_fallback_count
                        (auto-disable flag при > 9500/day)

Индекс Meilisearch

json
{
  "uid": "addresses",
  "primaryKey": "address_id",
  "searchableAttributes": ["value"],
  "filterableAttributes": ["region_id", "city_id", "district_id", "level"],
  "typoTolerance": {
    "enabled": true,
    "minWordSizeForTypos": {"oneTypo": 4, "twoTypos": 7}
  },
  "synonyms": {
    "спб": ["санкт-петербург", "питер"],
    "питер": ["санкт-петербург", "спб"],
    "мск": ["москва"],
    "ул": ["улица"],
    "пр-кт": ["проспект"]
  }
}

Synonyms map — генерируется из geo-synonyms.yaml (single source). Скрипт генерации — часть DEV-379 ETL pipeline.

Indexing pipeline

DEV-379 ГАР ETL → addresses table → Meilisearch sync hook


                  ┌──────────────────────────────────┐
                  │ Trigger on INSERT/UPDATE         │
                  │ → POST /indexes/addresses/docs   │
                  │ (batch 50K, async task)          │
                  └──────────────────────────────────┘

Delta sync cadence — daily (premortem H-002: на 2×/неделю stale до 3.5 суток vs Avito/Cian/Yandex с daily/realtime). Meilisearch indexing занимает 50 секунд на 4.45M, daily вписывается без проблем. On-demand триггер сохраняется для критичных update events.

Последствия

Положительные

  1. Latency в SLO — Meilisearch P95 47ms vs целевые 200ms (4× margin)
  2. Typo + synonyms из коробки — меньше custom-кода в PHP backend
  3. Operational alignment с ADR-009 — Meilisearch уже планируется для product search Phase 2
  4. Index build time 50s на 4.45M — daily resync без даунтайма
  5. Zero recurring external cost в MVP — Meilisearch self-hosted (MIT) + DaData free tier only. Нет per-request оплат до approach к free limit
  6. Observability на каждый suggest — структурный лог + synonyms version hash позволяет дебажить «почему по X вернулся Y» через 6-12 мес (premortem H-003)
  7. Hit rate 50/50 в MVP — DaData free-tier fallback закрывает 2 heavy-typo queries при usage < 10K req/day

Отрицательные

  1. Новый runtime сервис — Meilisearch cluster (single-node для MVP, можно 3-node реплика позже)
  2. Backup/restore strategy — Meilisearch dumps, отдельная процедура
  3. Index dual-write риск — если синхронизация addresses ↔ Meilisearch разъедется, suggest вернёт несуществующие address_id. Mitigation: nightly reconciliation job + sync tasks с retry
  4. Зависимость от DaData free-tier limit — при approach к 10K/day fallback отключается, hit rate деградирует до 48/50. Усложение мониторингом + feature flag + migration plan в follow-up ADR
  5. Scope expansion в blocking path для launch — DEV-379 (city+district rows) + DEV-427 (metro_stations) + DEV-386 (mix-script normalization) обязательны перед prod-лончем (без них гap не закрыт). Расширило критический путь на ~2-3 недели vs first plan
  6. Single-node SPOF — single-node Meilisearch — single point of failure. Митигация: k6 load test (100 RPS verify P95 ≤ 200ms) в DEV-428 + sizing CPU/RAM. Переход на 3-node replica при 50K req/day
  7. Vendor dependency (мягкая) — DaData как external API. Свободна по price в MVP, но ToS может измениться, rate limits могут ужесточиться. Migration plan обязателен

Откатываемость

Если Meilisearch operationally невозможен (security/compliance/team capacity):

  1. Plan B — Nominatim self-hosted (OSS geocoder) — +1 runtime сервис, отдельный OSM ETL pipeline. Открыть отдельный ADR. Не закрывает heavy-typo case без дополнительного spell-checker
  2. Plan C — pg_trgm с city_id pre-filter — латентность приемлема ТОЛЬКО при гарантированном city_id параметре от фронта; для search без city — пустой результат (UX «попробуйте другое написание»). Hit rate упадёт на city-less queries; вписывается в free/OSS политику без новых сервисов

Альтернативы (отклонены)

ПодходHit rate (spike)P50 latencyПричина отклонения
pg_trgm only33/50477msLatency не в SLO; ×3 хуже на 89 регионах
OpenSearchне бенчмаркенJVM + heap tuning overhead не оправдан для текущих требований
Algolia / TypesenseOver-budget MVP + commercial vendor lock-in
DaData Suggestions paid (как primary или unlimited fallback)external API SLAНарушает zero-recurring-spend политику MVP. Per-request cost не контролируется. Free tier (≤10K req/day) допустим — см. §«Эскалация»
Nominatim self-hosted (Plan B откатываемости)Отложено: +1 runtime сервис, не закрывает heavy-typo case без spell-checker. Возможно в отдельном ADR при empty rate > 15% или approach к DaData free limit

Открытые вопросы

  1. Single-node vs replicated Meilisearch — MVP single-node, переход на 3-node при достижении 50K req/day (требует mTLS + leader election). Premortem H-004 митигация: k6 100 RPS verify P95 ≤ 200ms на single-node до prod.
  2. addresses city entity rows — DEV-379 ETL должен включать city-level + district-level rows для топ-7 городов отдельно, чтобы query «Москва» матчилось на city entity, а не house с substring (см. DEV-430 spike fail breakdown).
  3. Mix-script normalization — query «моCKва» (Latin C+K в Cyrillic) не нормализуется. DEV-386 worker должен делать confusables.normalize() или Unicode-классы mapping.
  4. Metro stations в suggestmetro_stations — отдельная таблица (DEV-427), не в addresses. Решить в DEV-386: индексировать отдельным Meilisearch index metro или в addresses с level='metro'.
  5. Sync strategy addresses ↔ Meilisearch — synchronous (PHP вызов после INSERT) vs queue worker. Решение в DEV-386.
  6. Synonym dictionary growth — текущие 57 пар × экспансия на 89 регионов = ожидаемый ×3-×5 рост. Процесс расширения — weekly review топ-100 пустых запросов в prod (geo-suggest-gold-set.md §«Расширение»).
  7. MVP DoD: 48/50 или 50/50? — текущее ADR-решение: 50/50 при DaData free-tier fallback enabled и usage < 10K req/day; 48/50 при отключённом fallback (after-limit). Если продакт требует absolute 50/50 без зависимости от free tier → открыть отдельный ADR на OSS spell-checker (Hunspell / SymSpell self-hosted) или Plan B (Nominatim).
  8. Rate limiting placement — Nginx (DevOps управление) vs PHP middleware (backend контроль). Решит архитектурный review.
  9. DaData free-tier actual limit — проверить актуальное значение на dadata.ru/api/suggest/ перед launch (могло измениться с момента написания ADR; alert thresholds 7K/9.5K надо привязать к актуальному значению).

Action items

Выполнено

  • [x] Backend (DEV-430 spike): benchmark pg_trgm vs Meilisearch — выполнен 2026-06-03. Отчёт.
  • [x] Premortem ADR-016 — выполнен 2026-06-03. 4 дыры, 3 в топе. Премортем.

Blocking для prod-launch (закрывает gap 38→48/50)

  • [ ] Архитектор: review + утверждение ADR-016 → Accepted. Проверить DoD 48/50 vs 50/50 с продактом (открытый вопрос #7).
  • [ ] DevOps: Meilisearch v1.10 в prod docker-compose (single-node MVP).
  • [ ] DevOps: Nginx limit_req zone=suggest (~30 req/min per IP) на /store/geo/suggest (premortem H-004).
  • [ ] Backend (DEV-386): GeoProvider интерфейс + MeilisearchGeoProvider реализация + DaDataSuggestFallback decorator (free tier only, под feature flag geo.dadata_fallback).
  • [ ] Backend (DEV-386): DaData API client — register key, free tier only, idempotent retry, timeout 500ms (не блокировать запрос если DaData slow).
  • [ ] Backend (DEV-386): PHP loader geo-synonyms.yaml + version hash (lru-cached) + integration tests на эквивалентность с Python spike loader.
  • [ ] Backend (DEV-386): sync hook addresses → Meilisearch (initial bulk + delta on INSERT/UPDATE, daily cron).
  • [ ] Backend (DEV-386): mix-script normalization (Latin/Cyrillic confusables) в suggest_normalize chain — закрывает 3 mix-script fails.
  • [ ] Backend (DEV-386): структурный лог на каждый suggest — {query, normalized, expanded_terms[], hit_id, score, synonyms_version} (premortem H-003).
  • [ ] Backend (DEV-379): добавить city-level + district-level entities в addresses для топ-7 городов отдельно — закрывает 4 city + 1 district fails.
  • [ ] Backend (DEV-427+386): metro_stations индексация в Meilisearch (отдельный index или с level='metro') — закрывает 2 metro fails.
  • [ ] Backend (DEV-428): k6 нагрузочный тест 100 RPS concurrent на single-node Meilisearch — verify P95 ≤ 200ms (premortem H-004).
  • [ ] Backend (DEV-430 follow-up): повторить gold-set на полной конфигурации (Meilisearch + city/district/metro + mix-script + synonyms) — verify ≥ 48/50.

Ops/monitoring

  • [ ] Ops: мониторинг geo.suggest.empty_count + alert при empty rate > 10% (триггер на review топ-100 пустых запросов).
  • [ ] Ops: мониторинг geo.suggest.dadata_fallback_count + alert при > 7000/day (70% от free limit) → migration ADR.
  • [ ] Ops: auto-disable feature flag geo.dadata_fallback при dadata_fallback_count > 9500/day (95%).
  • [ ] Ops: мониторинг geo.suggest.rate_limited_count + alert при > 5% от total traffic (signal scraping attempt).
  • [ ] Ops: weekly review топ-100 пустых запросов prod → расширение geo-synonyms.yaml либо новых rows в addresses.
  • [ ] Ops: verify актуальный DaData Suggestions free limit на dadata.ru/api/suggest/ перед launch (open question #9).

Документация

  • [ ] Backend (DEV-430 follow-up): обновить geo-suggest-gold-set.md — пометить 2 heavy-typo queries как dadata-fallback-dependent (closes only when fallback enabled).
  • [ ] Frontend (DEV-402): UI hint «попробуйте другое написание» при пустом результате suggest (включая случай отключённого fallback) + ручной ввод адреса как UX fallback.

Принято

  • [ ] Backend lead: ___
  • [ ] Архитектор: ___
  • [ ] Дата принятия: ___