Что такое «Мигалка» и какую задачу она решает
«Мигалка» - телеграм-бот, который следит примерно за тысячей каналов и присылает подписчику события, попавшие в его территорию: пожары, перекрытия, аварии на коммуникациях, эвакуации, розыск, любые происшествия с привязкой к месту. Подписчиков около 15 тысяч, и каждый выбрал свою зону: район, город или целый регион.
Инженерная задача формулируется одной фразой: превратить поток неструктурированного текста в структурированные события и доставить каждое из них только тем, кого оно касается. Разрыв между входом и выходом огромен. В канал приходит «на Ленинградке опять всё встало, объезжайте через Мневники», а на выходе нужен объект с типом события, координатами, узлом дерева территорий, кратким описанием и признаком того, что это не повтор вчерашней новости из соседнего канала.
Каждый шаг цепочки закрывает отдельный компонент: LLM-конвейер достаёт тип, локацию и описание, Google Maps и OpenStreetMap дают координаты и административные границы, PostGIS с ltree сопоставляет территории события и подписки, Qdrant ищет семантические дубли, Redis и пул ботов распределяют отправку. Ниже - разбор того, как это работает, с логикой запросов, схемами и типичными сбоями.
Общая архитектура: от сообщения в канале до оповещения пользователя
Поток данных выглядит так:
- Сборщики читают новые посты примерно из тысячи каналов и кладут их в очередь Redis вместе с идентификатором канала и временем публикации.
- Воркеры забирают сообщения из очереди и прогоняют через LLM-конвейер: получают тип события, сырую строку локации и краткое описание.
- Геокодер превращает строку локации в координаты, а административные границы из OpenStreetMap - в цепочку узлов дерева территорий.
- Блок дедупликации проверяет семантических соседей в Qdrant и пересечение геометрий в PostGIS.
- Матчер находит пользователей, чьи подписки покрывают территорию события.
- Задачи на отправку уходят в общую очередь, откуда их разбирают воркеры нескольких ботов.
Компоненты общаются асинхронно: сбор, извлечение, геокодирование и доставка живут в разных процессах и падают независимо друг от друга. Очередь в Redis здесь не украшение, а буфер, который гасит всплески. Массовое ЧП в крупном городе порождает десятки постов за минуту, и без буфера воркеры LLM и пул ботов захлебнулись бы одновременно.
Почему не обойтись одним LLM-вызовом
Соблазн понятен: дать модели пост и попросить «скажи, кому это отправить». На практике одного вызова мало по четырём причинам.
- Модель не гарантирует формат. Сегодня она вернёт чистый JSON, завтра добавит пояснение перед ним, послезавтра переименует поле. Для кода, который читает ответ, это лотерея.
- Модель не знает актуальных границ. Новый микрорайон или переименованная улица отсутствуют в её весах, а ошибка в геопривязке означает оповещение не тех людей.
- Модель не умеет дедуплицировать в масштабе. Два канала пишут об одном пожаре разными словами, и без сравнения с уже известными событиями пользователь получит два уведомления.
- Модель не отвечает за доставку. Лимиты Telegram, привязка подписчика к конкретному боту, повторные отправки - это инфраструктура, а не инференс.
Отсюда принцип: LLM извлекает смысл, а формат, географию, дубли и доставку контролирует детерминированный код. Если модель начинает путать инструкции на длинных промптах с несколькими примерами, качество извлечения падает, и об этом стоит помнить при выборе модели: разбор причин такого поведения разобран в материале о том, почему большие LLM хуже понимают инструкции.
LLM-конвейер: как текст превращается в структурированное событие
На входе промпт с жёсткой инструкцией вернуть JSON по схеме. Никакой свободной формулировки: только поля, которые дальше читает код.
Извлеки из сообщения одно событие о происшествии.
Верни JSON строго по схеме:
{"is_event": true, "type": "fire", "location_raw": "склад на ул. Ленина, 5", "summary": "Горит склад, площадь уточняется"}
Допустимые значения type: fire, accident, road_closure, utility_failure, emergency, crime, weather, other.
Если сообщение не описывает происшествие, верни {"is_event": false}.
Не добавляй пояснений, не меняй имена полей.
Модель вызывается в режиме structured output или JSON mode, чтобы ответ приходил без текстовой обёртки. Дальше начинается работа кода: схема, значения, обязательные поля.
Ретраи, тайм-ауты и резервная модель
Сбой модели - нормальный режим работы, а не авария. Схема обработки строится на трёх правилах.
Первое: у каждого запроса есть тайм-аут. Воркер, зависший на одном сообщении, блокирует очередь, а оповещение о пожаре теряет смысл через десять минут.
Второе: повторные попытки идут с экспоненциальной задержкой. Первая попытка, пауза, вторая с большим интервалом, третья с ещё большим. Так система переживает кратковременные ошибки API и не добивает провайдера шквалом одинаковых запросов.
Третье: после исчерпания попыток или превышения тайм-аута включается резервная модель. В типичной схеме основная модель точнее и дороже, резервная быстрее и дешевле, вплоть до локальной Llama на собственном железе. Качество извлечения у резерва ниже, зато конвейер не останавливается: событие обрабатывается с пометкой о том, что его разбирала вторая модель. Подбор локальной модели под такую роль упирается в VRAM и стоимость инференса, практический опыт сообщества собран в обзоре реальных кейсов локальных LLM, а сравнение открытых моделей по качеству следования инструкциям - в разборе открытых LLM 2026 года.
Валидация и постобработка ответа LLM
Ответ модели проверяется по JSON-схеме: обязательные поля на месте, тип события входит в допустимый список, строка локации не пустая, описание укладывается в лимит длины. Ответ вида "location": "везде" или "type": "катастрофа" не проходит валидацию и уходит на повторную попытку с уточнённым промптом.
Если после всех попыток локация не распознана, событие не рассылается. Это сознательный выбор в пользу точности: пользователь, получивший ложное оповещение о перекрытии в другом городе, теряет доверие к боту быстрее, чем получает его. В отдельных случаях событие сохраняется со статусом «требует уточнения» и попадает в очередь на ручную или общественную проверку.
Геокодирование и сопоставление территорий: Google Maps, OpenStreetMap, PostGIS и ltree
Строка локации из LLM проходит два независимых шага. Сначала Google Maps Geocoding API возвращает координаты: точку, а иногда и готовый адрес с компонентами. Затем координата сопоставляется с административными границами из OpenStreetMap, и событие получает узел дерева территорий: страна, регион, район, микрорайон.
Границы OSM здесь основа, а Google Maps поставщик координат. Причина простая: административная иерархия из OSM открыта, обновляется сообществом и её можно загрузить в собственную базу, тогда как геокодер отвечает только на вопрос «где это». Для новых ЖК и свежих развязок карты отстают на месяцы, поэтому спорные случаи попадают в отдельный лог и уточняются вручную.
Дерево территорий хранится в PostGIS, а путь по нему - в колонке типа ltree. Подписка пользователя и событие ссылаются на узлы этого дерева, и матчинг сводится к проверке вложенности:
SELECT s.user_id
FROM subscriptions s
JOIN events e ON e.id = $1
WHERE s.territory_path @> e.territory_path;
Оператор @> читается как «содержит»: путь подписки «Россия.Москва» содержит путь события «Россия.Москва.ЦАО.Тверской», значит подписчик получает уведомление. Обратный оператор <@ отвечает на вопрос «содержится в», и на нём строятся проверки вида «попадает ли эта улица в мой район».
Почему ltree, а не обычные foreign keys
Классическая модель с полем parent_id требует рекурсивного запроса на каждый матчинг: подняться от события вверх по дереву и сверить каждый уровень с подписками. Для дерева глубиной три-пять уровней это работает, но превращается в рекурсивный CTE на каждом событии и в отдельную головную боль при выборке «все подписчики ветки».
ltree хранит путь одной строкой с метками через точку, индексируется GiST и поддерживает операторы вложенности напрямую. Проверка «событие внутри территории подписки» становится одним условием в WHERE, а не обходом дерева. Дополнительный плюс: путь читаем в psql, и отладка матчинга не требует отдельного скрипта визуализации.
Обработка неточных и неоднозначных локаций
Живой текст редко содержит аккуратный адрес. «На Ленинградке» может означать шоссе, район или конкретный участок. «В центре» без контекста не значит ничего. Геокодер в таких случаях возвращает несколько кандидатов, а система выбирает наиболее вероятного по дополнительным сигналам: региону канала, координатам соседних сообщений из того же источника, упоминаниям других топонимов в тексте.
Если однозначно выбрать не удалось, событие либо не рассылается, либо уходит по самому широкому корректному узлу. Второй вариант спорный: оповещение «где-то в области» раздражает, зато не скрывает реальную опасность. Решение зависит от типа события, и для пожаров с эвакуацией порог терпимости к неточности выше, чем для перекрытия одной полосы.
Двухуровневая дедупликация: семантические эмбеддинги в Qdrant и сравнение географии
Один и тот же пожар попадает в пять каналов в течение получаса, и каждый пост сформулирован по-своему. Отсеивать такие повторы сравнением строк бессмысленно, поэтому дедупликация идёт в два уровня.
Первый уровень семантический. Текст события превращается в эмбеддинг и ищется в Qdrant среди ближайших соседей. Если косинусная близость с существующим событием выше порога, кандидат считается потенциальным дублем, но окончательный вердикт пока не выносится.
Второй уровень географический. PostGIS проверяет, совпадают или пересекаются геометрии событий, либо совпадает узел дерева территорий. Только при положительном результате обоих уровней новое событие присоединяется к существующему и не порождает отдельное оповещение.
Пример из практики: «Пожар на складе на Ленина 5» и «Возгорание на складе по улице Ленина» семантически близки, география совпадает, значит это дубль. Дальше система наращивает описание существующего события: добавляет источник, обновляет площадь, фиксирует время последнего подтверждения.
Почему одного семантического сравнения недостаточно
Улица Ленина есть почти в каждом городе, и тексты «прорыв трубы на улице Ленина» из двух регионов дадут почти одинаковые эмбеддинги. Без геопроверки такие события склеятся в одно, и подписчики одного города получат оповещение о чужой аварии, а второго не получат вовсе. География работает как жёсткий фильтр: даже при близости 0.95 события в разных узлах дерева остаются разными.
Выбор порога и работа с ложными срабатываниями
Порог косинусной близости (типичный ориентир 0.85) задаёт компромисс между пропущенными дублями и склеенными разными событиями. Ошибка в одну сторону даёт лишние уведомления, ошибка в другую - потерянные оповещения, и вторая опаснее.
Поэтому порог на семантическом уровне занижают, а решение принимают по географии: пусть кандидатов на объединение будет больше, чем реальных дублей, зато важное событие не потеряется. Дополнительно порог можно делать адаптивным по типу: для погодных предупреждений и массовых отключений он ниже, для точечных происшествий выше.
Обход лимитов Telegram: несколько ботов и общая очередь в Redis
Telegram Bot API ограничивает скорость отправки, причём лимиты считаются на конкретного бота. Один бот физически не разошлёт оповещение 15 тысячам подписчиков за минуту, если событие касается крупного города.
Решение - пул ботов с отдельными токенами и одна общая очередь в Redis. Воркеры забирают задачи на отправку и распределяют их по ботам, каждый из которых работает в своём темпе и не мешает остальным. Подписчик при этом привязан к конкретному боту: связь устанавливается через deep link при первом запуске, и дальнейшие сообщения может доставить только этот бот. Значит, распределение нагрузки идёт не по пользователям напрямую, а по тому, какие боты обслуживают какие группы подписок.
Очередь даёт ещё две возможности. Первая: приоритеты. Оповещение о пожаре обгоняет новость о плановом отключении воды. Вторая: повторная отправка. Если бот вернул ошибку доставки, задача возвращается в очередь с задержкой, а не теряется.
Распределённая верификация пользовательских репортов и ошибок
LLM ошибается, геокодер ошибается, источники публикуют слухи. Ловить всё это силами модерации невозможно, поэтому проверка распределена между пользователями. Под каждым оповещением есть быстрые действия: подтвердить событие, отметить его как ложное, указать неверную локацию, добавить свой репорт с места.
Голоса агрегируются, источники получают рейтинг, и событие автоматически деактивируется, когда несколько независимых пользователей отметили его как ложное. Такой же механизм работает для ошибок геопривязки: если событие уехало в чужой район, жалобы быстро это показывают, а исправленный узел дерева уходит в разбор.
У схемы есть неприятная грань: вес системных источников и пользовательских репортов нужно балансировать. Перекос в сторону автоматики делает сообщество бесполезным, перекос в сторону голосов открывает дорогу для накруток и локальных конфликтов. Как ошибка в этом балансе стоила проекту аудитории, показывает разбор кейса бота, потерявшего 2000 пользователей из-за алгоритма.
Ограничения и подводные камни архитектуры
- Галлюцинации LLM. Модель может выдать событие из поста, который ничего не сообщает, или подставить правдоподобную улицу вместо отсутствующей. Валидация снижает риск, но не убирает его полностью.
- Стоимость. Геокодирование каждого события и вызовы LLM с ретраями складываются в постоянный расход, который растёт вместе с числом каналов и всплесками новостей.
- Задержки. Экспоненциальные задержки и переход на резервную модель добавляют секунды, а для экстренных оповещений каждая минута на счету.
- Качество резерва. Резервная модель разбирает события хуже основной, и часть сообщений в такие периоды уходит с ошибками в типе или локации.
- Сложность отладки. Распределённая система из очередей, воркеров, геокодера, векторной базы и пула ботов требует трассировки по идентификатору события, иначе поиск причины «почему не пришло» превращается в археологию.
- Ложные объединения. Слишком агрессивная дедупликация склеивает разные события, и пользователь видит одно оповещение вместо двух.
- Зависимость от внешних API. Недоступность геокодера или основного провайдера LLM напрямую влияет на скорость обработки, а очереди при этом растут.
Мониторинг здесь не дополнительная функция, а часть архитектуры: длина очереди, доля событий без локации, число переходов на резервную модель и количество дедуплицированных событий дают раннее предупреждение о деградации раньше, чем её заметят пользователи.
Что можно переиспользовать в своих проектах
Из этой архитектуры переносимы конкретные паттерны, и они не привязаны к экстренным оповещениям.
- Очередь в Redis между сбором данных и обработкой. Гасит всплески и позволяет перезапускать воркеры без потери сообщений.
- Двухуровневая дедупликация: семантика по эмбеддингам плюс жёсткая проверка по доменному признаку. Для новостей это география, для заявок в поддержку - идентификатор клиента и канал обращения, для товаров - артикул.
- ltree для иерархий. Категории, оргструктуры, тарифные зоны, каталоги: всё, что имеет глубину три-пять уровней и проверяется запросом на вложенность.
- Ретраи с тайм-аутом и резервной моделью. Схема применима к любому вызову внешнего API, а не только к LLM.
- Пул ботов или аккаунтов с общей очередью. Так обходят лимиты любой платформы, где квота считается на отправителя.
- Распределённая верификация с агрегацией голосов. Дешёвый способ фильтровать ошибки, если продуманы веса источников.
Тот же каркас работает за пределами телеграма: локальные модели тоже умеют следить за событиями и рассылать уведомления, как в случае open-source агента Observer с локальными LLM. Если браться за похожую систему, начинайте с двух вещей: схемы события и способа его геопривязки. LLM здесь самая заменяемая часть, а основная работа уходит на инфраструктуру, дедупликацию и идемпотентность.