Городские Telegram-чаты похожи на непрерывный поток логов без схемы. В одном сообщении человек ищет квартиру, в следующем агент публикует двадцатую копию объявления, затем идут курс валют, потерянный телефон, рекомендация врача и спор не по теме. Поиск по словам быстро упирается в синонимы, несколько языков, устаревающие сообщения и главное противоречие: «сниму квартиру» и «сдам квартиру» говорят об одном объекте, но требуют противоположных результатов.
Ниже — архитектурные решения, которые мы используем в Global Pulse, поиске по городским Telegram-сообществам. Первый город — Нячанг. Это не рассказ о гарантированных лидах: система работает только с доступными сообщениями и может пропускать полезные события или ошибаться в классификации.
Почему обычного полнотекстового поиска мало
Пользователь формулирует задачу нормальным языком: «нужен тихий байк без прав», «кто сегодня меняет доллары», «ищу мастера починить кондиционер». Автор сообщения может написать «50cc scooter», «обмен USD», «ремонт сплита». Буквального совпадения нет, хотя смысл тот же.
Обратная проблема возникает с точными сущностями. Для запроса «Honda Lead 2012» семантическая близость не должна вытеснять модель, год и название. Поэтому один универсальный поиск хуже маршрутизатора, который выбирает режим:
lexical — когда в запросе есть обязательные якоря: модель, адрес, число, имя;
vector — когда важен смысл и формулировки могут различаться.
Для векторного режима запрос превращается в embedding, а кандидаты ищутся по косинусному расстоянию в PostgreSQL с pgvector. Мы не используем один вечный порог расстояния. Распределение кандидатов меняется от запроса к запросу, поэтому граница выбирается по «локтю» последовательности расстояний, а при отсутствии выраженного разрыва действует ограниченный fallback.
Нормализация сообщения как отдельный этап
Индексировать сырой текст напрямую неудобно. Перед поиском сообщение превращается в событие со структурированными признаками: краткое резюме, категория, намерение, временные и предметные атрибуты, язык, источник и ссылка на оригинал. Для embeddings хранится отдельный статус: pending, ready или failed. Это позволяет переиндексировать сбои отдельно от основного ingest и не считать отсутствие вектора отсутствием самого сообщения.
Нормализация не должна «улучшать» факты. Если в исходнике не указана цена, нельзя достраивать её по типичному рынку. Если непонятно, квартира это или комната, классификатор должен сохранить неопределённость. Сырые сообщения остаются источником истины, а нормализованные поля — поисковым представлением, которое можно пересоздать.
Дубликаты — ещё одна ловушка. Репост одного объекта полезно схлопнуть, но две квартиры одного агента нельзя считать одним предложением. Поэтому дедупликация учитывает не только близость embeddings, но и предметные признаки, контакты и сигнатуру предложения. Очень высокая семантическая близость — сильный сигнал, но не единственное условие.
Спрос и предложение — это не один intent
Самая дорогая ошибка для уведомлений — перепутать стороны рынка. «Ищу фотографа» — запрос, «снимаю свадьбы» — предложение. Для обычного поиска пользователь иногда хочет видеть предложения, отвечающие его потребности. Для бизнес-уведомления, наоборот, нужно поймать именно новое изъявление потребности.
Мы используем явные классы request, offer, info, warning, recommendation и other. При маршрутизации важно учитывать форму пользовательского запроса. Если человек ищет услугу для себя, кандидаты-предложения допустимы. Если бизнес подписался на появление спроса, сообщения с саморекламой не должны заполнять канал уведомлений.
Одних слов «ищу» и «предлагаю» недостаточно. В аренде «сниму» и «сдаю» различаются одной буквой, а смешанные сообщения могут одновременно содержать запрос и предложение. Поэтому intent проверяется вместе с категорией и доказательными фразами; противоречащие комбинации отбрасываются или отправляются на повторную обработку.
Финальная фильтрация после retrieval
Retrieval отвечает на вопрос «что похоже», но не «что действительно полезно». После lexical или vector search кандидаты проходят финальную фильтрацию. Она проверяет соответствие намерению, ограничениям запроса и устраняет карточки, ведущие к одному и тому же контакту или реальному предложению.
Свежесть входит в ранжирование отдельно. Для городского поиска объявление недельной давности часто хуже сегодняшнего, даже если текст ближе. Однако нельзя просто сортировать по времени: свежий шум не должен вытеснить релевантное сообщение. Вес свежести комбинируется с lexical- или vector-оценкой в зависимости от выбранного режима.
Если локальный корпус не подходит, внешний поиск допустим только для публичной справочной информации. Для живых объявлений, аренды, покупки, работы и частных контактов такой fallback отключён: веб-страница не заменяет актуальное сообщение из нужного города.
Язык ответа не равен языку корпуса
В Нячанге одно обсуждение может содержать русский, английский и вьетнамский текст. Переводить весь корпус заранее дорого и рискованно: перевод способен исказить адрес, имя или коммерческую формулировку.
В запросе поэтому есть отдельный response_language. Поиск идёт по общему индексу, а итоговые карточки и объяснение формируются на языке интерфейса. Ссылка ведёт на оригинальное сообщение, чтобы пользователь мог проверить контекст. Язык ответа — часть API-контракта, а не предположение по алфавиту запроса.
Как из поиска получается уведомление
Подписка хранит смысл запроса и город, а не набор застывших ключевых слов. Новые нормализованные события можно сопоставлять с подписками тем же поисковым планом: intent, категория, режим retrieval и финальная проверка. Это особенно важно для бизнеса: «кто сдаёт байк» и «нужен скутер на месяц» должны находиться без ручного перечисления всех вариантов, но объявления конкурентов не должны выглядеть как клиентский спрос.
Уведомление содержит ссылку на исходное сообщение. Система не обещает продажу и не знает, актуальна ли потребность через час. Её задача скромнее: уменьшить задержку между появлением релевантного сообщения и реакцией пользователя.
Ограничения, которые нельзя спрятать за LLM
Качество ограничено составом подключённых сообществ, доступностью сообщений и модерацией самих чатов. Закрытые переписки не становятся доступными от того, что поверх поиска есть модель. Ошибки intent особенно вероятны в коротких сообщениях без контекста. Embeddings могут сближать тематически похожие, но операционно разные события. Финальный фильтр уменьшает шум, но способен удалить полезный редкий случай.
Поэтому в системе нужны наблюдаемость очередей, статусы индексации, повторная обработка failed-событий и набор ручных тест-кейсов для запросов на разных языках. Хорошая демонстрация — не доказательство качества; важнее регулярно проверять ошибки на реальных формулировках и не превращать найденные совпадения в выдуманную бизнес-метрику.
Мы вынесли этот подход в работающий интерфейс Global Pulse: поиск и уведомления, а Telegram-бот доступен здесь. Сейчас поддержан Нячанг; архитектура разделяет город, язык ответа и поисковый план, поэтому добавление новых городов не требует переписывать пользовательский контракт.

