Appearance
ADR-016: Geo Suggest Engine
Дата: 2026-06-03 Статус: Proposed Контекст: DEV-380 (API/runtime integration), DEV-386 (GeoProvider), DEV-387 (/store/geo/suggest endpoint)
Связанные документы:
- ADR-009 — Движок полнотекстового поиска (продуктовый) — выбор движка для product search
- ADR-015 — Batch Geocoding Architecture — batch lat/lon (отдельный вопрос)
- DEV-430 spike report — benchmark pg_trgm vs Meilisearch на 4.45M ГАР
- geo-suggest-gold-set — 50 эталонных запросов
- geo-synonyms.yaml — словарь синонимов с провенансом
- ADR-016 премортем — 4 дыры, топ-3 решения, bias-check, reverse-премортем (2026-06-03)
Контекст
/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 регионов) | ~12M | 50-100K |
| Year 2 | 12M + дельты | 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}:
| Engine | Mode | top-3 hit rate | P50 latency | P95 latency |
|---|---|---|---|---|
| pg_trgm | baseline | 33/50 (66%) | 477ms | 2961ms |
| pg_trgm | +synonyms | 33/50 (66%) | 952ms | 10662ms |
| Meilisearch | baseline | 32/50 (64%) | 5ms | 16ms |
| Meilisearch | +synonyms | 38/50 (76%) | 8ms | 47ms |
Решающие факторы:
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.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).
Typo tolerance: Meilisearch ловит «Каазнь» → Казань нативно. pg_trgm на пороге
word_similarity ≥ 0.45возвращает 0 hits; на пороге 0.28 возвращает 476K записей (slow + irrelevant rank).Index build time: Meilisearch индексировал 4.45M записей за 50 секунд. pg_trgm GIN build на той же базе ~5 минут. Для weekly ГАР deltas Meilisearch более operationally friendly.
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 в endpoint | DEV-379 (ETL) + DEV-387 (contract) |
| Mix-script edge cases («моCKва» Latin+Cyrillic) | 3 | confusables.normalize() или Unicode mapping в suggest_normalize chain | DEV-386 (worker) |
| Metro queries | 2 | Индексировать metro_stations отдельным Meilisearch index либо в addresses с level='metro' | DEV-427 + DEV-386 |
| District queries | 1 | Добавить level='district' rows в ETL | DEV-379 |
| Heavy typos (≥2 symbol edit в 6-char слове) | 2 | DaData Suggestions free-tier fallback на пустой результат (см. §«Эскалация на DaData») — закрывает в MVP при usage < 10K req/day | DEV-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 engine | Meilisearch v1.10+ | MIT | 76% hit rate baseline → 96% после scope expansion (DEV-379+427+386), P95 47ms, 50s index build на 4.45M |
| Index data | addresses table (ADR-014 schema) + metro_stations (DEV-427) | — | Single source; обновляется ETL DEV-379 |
| Normalization | SQL функция 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.yaml | — | Single source с провенансом (universal / gold-set / historical) |
| Rate limiting | Nginx 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 ADRgeo.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.
Последствия
Положительные
- Latency в SLO — Meilisearch P95 47ms vs целевые 200ms (4× margin)
- Typo + synonyms из коробки — меньше custom-кода в PHP backend
- Operational alignment с ADR-009 — Meilisearch уже планируется для product search Phase 2
- Index build time 50s на 4.45M — daily resync без даунтайма
- Zero recurring external cost в MVP — Meilisearch self-hosted (MIT) + DaData free tier only. Нет per-request оплат до approach к free limit
- Observability на каждый suggest — структурный лог + synonyms version hash позволяет дебажить «почему по X вернулся Y» через 6-12 мес (premortem H-003)
- Hit rate 50/50 в MVP — DaData free-tier fallback закрывает 2 heavy-typo queries при usage < 10K req/day
Отрицательные
- Новый runtime сервис — Meilisearch cluster (single-node для MVP, можно 3-node реплика позже)
- Backup/restore strategy — Meilisearch dumps, отдельная процедура
- Index dual-write риск — если синхронизация
addresses↔ Meilisearch разъедется, suggest вернёт несуществующиеaddress_id. Mitigation: nightly reconciliation job + sync tasks с retry - Зависимость от DaData free-tier limit — при approach к 10K/day fallback отключается, hit rate деградирует до 48/50. Усложение мониторингом + feature flag + migration plan в follow-up ADR
- 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
- 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
- Vendor dependency (мягкая) — DaData как external API. Свободна по price в MVP, но ToS может измениться, rate limits могут ужесточиться. Migration plan обязателен
Откатываемость
Если Meilisearch operationally невозможен (security/compliance/team capacity):
- Plan B — Nominatim self-hosted (OSS geocoder) — +1 runtime сервис, отдельный OSM ETL pipeline. Открыть отдельный ADR. Не закрывает heavy-typo case без дополнительного spell-checker
- 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 only | 33/50 | 477ms | Latency не в SLO; ×3 хуже на 89 регионах |
| OpenSearch | не бенчмаркен | — | JVM + heap tuning overhead не оправдан для текущих требований |
| Algolia / Typesense | — | — | Over-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 |
Открытые вопросы
- 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.
addressescity entity rows — DEV-379 ETL должен включать city-level + district-level rows для топ-7 городов отдельно, чтобы query «Москва» матчилось на city entity, а не house с substring (см. DEV-430 spike fail breakdown).- Mix-script normalization — query «моCKва» (Latin C+K в Cyrillic) не нормализуется. DEV-386 worker должен делать
confusables.normalize()или Unicode-классы mapping. - Metro stations в suggest —
metro_stations— отдельная таблица (DEV-427), не вaddresses. Решить в DEV-386: индексировать отдельным Meilisearch indexmetroили вaddressesсlevel='metro'. - Sync strategy
addresses↔ Meilisearch — synchronous (PHP вызов после INSERT) vs queue worker. Решение в DEV-386. - Synonym dictionary growth — текущие 57 пар × экспансия на 89 регионов = ожидаемый ×3-×5 рост. Процесс расширения — weekly review топ-100 пустых запросов в prod (
geo-suggest-gold-set.md§«Расширение»). - 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).
- Rate limiting placement — Nginx (DevOps управление) vs PHP middleware (backend контроль). Решит архитектурный review.
- 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реализация +DaDataSuggestFallbackdecorator (free tier only, под feature flaggeo.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_normalizechain — закрывает 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: ___
- [ ] Архитектор: ___
- [ ] Дата принятия: ___