Привет, Хабр!
Меня зовут Артём, я фаундер Leadl.ai. Мы строим AI-агента для поиска b2b-клиентов, и одна из его ключевых задач это мониторинг чатов и различных источников.
Звучит просто, пока не сталкиваешься с масштабом.

У нас в пуле 20000+ чатов в 15 источниках. Суммарно около 1000 000 000 сообщений в сутки. Из них реально полезных (запросы на услуги, поиск подрядчиков, вакансии) от силы 3-5%. Остальное: флуд, криптоспам, «доброе утро», мемы и бесконечные стикеры.

Задача: вытащить эти 3-5% качественных сообщений. Первой мыслью было отдать всё на откуп большой LLM типа GPT-4o. Посчитали. Среднее сообщение 50 токенов. 100 000 сообщений 50 токенов/сообщение ($10 / 1M токенов) = $50 в день только на input. Добавьте сюда output и prompt — и счёт легко перевалит за $100-150/день или $3000-4500/месяц. Для стартапа это путь в никуда.

Нам нужен был pipeline, который бы отсеивал мусор на ранних этапах, чтобы до дорогого LLM-скоринга доходило не более 5-10% от всего потока. Вот как мы его построили, через какие грабли прошли и что из этого вышло.

Общая архитектура пайплайна

Идея простая: каскадная фильтрация. Каждый следующий этап сложнее и дороже предыдущего, но он работает с уже очищенными данными. Это как в компьютерных играх: сначала пачка слабых мобов, потом мини-босс, и только в конце — финальный рейд-босс (наша LLM).
Вот как выглядит наш пайплайн:
graph TD

A[Raw Message Stream<br>~100,000/day] --> B{Stage 1: Heuristic Filter};

B --> |~70% filtered| C{Stage 2: Lightweight ML Classifier};

C --> |~60% filtered| D{Stage 3: Semantic Search Check};

D --> |~50% filtered| E{Stage 4: LLM Scorer};

E --> |~20% filtered| F{Stage 5: Deduplication};

F --> G[Clean Leads<br>~3,000/day];

B --> H1[Trash Bin 1];

C --> H2[Trash Bin 2];

D --> H3[Trash Bin 3];

E --> H4[Trash Bin 4];

В итоге до LLM доходит всего около 5-8% от первоначального потока. Давайте разберём каждый этап.

Этап 1: Грубый эвристический фильтр

Задача: отсеять самый очевидный мусор с минимальными затратами CPU-времени.

Это первый и самый важный рубеж обороны. Он работает на простых правилах и регулярных выражениях. Дёшево, сердито, невероятно эффективно. Здесь мы отсеиваем около 70% всего потока.

Что уходит в утиль:

  1. Слишком короткие сообщения: всё, что короче условных 30 символов. Прощайте, «+», «ап», «актуально», «добрый день». Конечно, можно случайно отсеять «ищу js-dev», но на практике такие короткие запросы почти не встречаются.

  2. Сообщения от ботов: банальная проверка if message.sender.is_bot.

  3. Медиа и стикеры: if message.media or message.sticker.

  4. Стоп-слова: огромный список слов-триггеров, которые почти никогда не встречаются в целевых сообщениях. Это и «спасибо», «пожалуйста», «созвон», «митинг», и политические термины, и крипто-лексика («airdrop», «листинг», «NFT»).

  5. Спам-ссылки: регулярка на популярные спам-домены и Telegram-каналы.

Вот примитивный пример на Python, иллюстрирующий логику:
STOP_WORDS = {'спасибо', 'пожалуйста', 'добрый', 'утро', 'вечер', ...}

MIN_LENGTH = 30

def heuristic_filter(message_text: str) -> bool:

"""Returns True if the message is likely junk."""

if len(message_text) < MIN_LENGTH:

return True

Простая токенизация и проверка на стоп-слова

words = set(message_text.lower().split())

if words.intersection(STOP_WORDS):

return True

Тут могут быть регулярки для спам-ссылок, и т.д....

return False

Пример

text1 = "добрый день, коллеги! как успехи?"

text2 = "Ищу подрядчика для разработки MVP на Python (Django/FastAPI). Бюджет 150к, сроки до конца месяца."

print(f'Text 1 is junk: {heuristic_filter(text1)}') # -> True

print(f'Text 2 is junk: {heuristic_filter(text2)}') # -> False

Стоимость: почти нулевая.
Результат: отсеиваем ~70% сообщений, которые гарантированно не являются лидами.

Этап 2: Лёгкий ML-классификатор

Задача: из оставшихся ~30 000 сообщений найти те, что похожи на лиды.

После эвристик у нас всё ещё много мусора: осмысленные, но нерелевантные обсуждения, вопросы не по теме и т.д. Здесь в игру вступает ML.

Сначала мы, как и многие, решили: «Возьмём rubert-tiny2 и дообучим на классификацию!». Собрали датасет на 10 000 сообщений (50/50 лиды/не лиды), дообучили, запустили. Получили recall 0.91, но precision 0.54. Это означало, что мы ловили почти все лиды, но в итоговой выборке было 46% мусора. Пользователи бы нас прокляли.

К тому же, даже rubert-tiny2 на CPU давал ощути��ую задержку. Нам нужно было что-то быстрее.

Вернулись к старому доброму TF-IDF + LogisticRegression из scikit-learn. Он обучается за секунды, инференс — миллисекунды. И что самое смешное, после настройки гиперпараметров и чистки словаря он дал precision: 0.82 и recall: 0.78 на нашей тестовой выборке. Да, мы теряем ~22% потенциальных лидов, но зато 82% того, что модель назвала лидом, — действительно лид. Это приемлемый компромисс для второго этапа.

Псевдокод пайплайна обучения

from sklearn.feature_extraction.text import TfidfVectorizer

from sklearn.linear_model import LogisticRegression

from sklearn.pipeline import Pipeline

X_train, y_train - размеченные данные

pipeline = Pipeline([

('tfidf', TfidfVectorizer(ngram_range=(1, 2), max_features=15000)),

('clf', LogisticRegression(C=5, solver='liblinear'))

])

pipeline.fit(X_train, y_train)

Теперь pipeline.predict(new_messages) работает очень быстро

и возвращает 0 (мусор) или 1 (похоже на лид)

Стоимость: очень низкая, инференс на CPU занимает единицы миллисекунд на сообщение.
Результат: отсеиваем ещё ~60% от оставшегося потока. После этого этапа у нас остаётся примерно 12 000 сообщений в день.

Этап 3: Семантическая проверка через Vector DB

Задача: проверить гипотезу классификатора. Насколько «лид» похож на уже известные нам хорошие лиды?

Этот этап — наша «вторая линия обороны разума». Классификатор может ошибаться. Например, сообщение «обсуждаем поиск подрядчиков» он может счесть за лид, хотя это мета-обсуждение.

Здесь мы используем семантический поиск. У нас есть база из нескольких тысяч эталонных, вручную проверенных лидов. Каждое сообщение из этой базы превращ��но в вектор (эмбеддинг) с помощью Sentence-BERT и хранится в вектороной СУБД (мы используем Qdrant).

Когда сообщение проходит ML-классификатор, мы не отправляем его сразу в LLM. Мы делаем следующее:

  1. Превращаем текст сообщения в вектор с помощью той же S-BERT модели.

  2. Ищем в Qdrant 3 самых близких по косинусному расстоянию вектора из нашей эталонной базы.

  3. Смотрим на расстояние до ближайшего соседа. Если оно слишком большое (например, distance > 0.4), то, скорее всего, наше сообщение-кандидат не очень-то и похоже на настоящие лиды, даже если классификатор так решил. Мы либо отбрасываем его, либо сильно понижаем его приоритет.

Это помогает отсеять семантические аномалии и делает систему более устойчивой.

-- Псевдо-запрос к Vector DB

SELECT

lead_text,

cosine_similarity(embedding, <vector_of_new_message>)

FROM

golden_leads_collection

ORDER BY

cosine_similarity DESC

LIMIT 3;


Ты — AI-асессор. Твоя задача — проанализировать сообщение из Telegram-чата и извлечь информацию о запросе на услуги. Ответь в формате JSON.

Сообщение:

"""

{message_text}

"""

Проанализируй сообщение и верни JSON со следующими полями:

  • is_lead (boolean): true, если это явный поиск исполнителя/подрядчика/сотрудника.

  • lead_type (string): одно из ["project", "vacancy", "task", "other"].

  • score (integer, 0-100): насколько это качественный и понятный лид. 100 — есть бюджет, сроки, чёткое ТЗ. 0 — не лид.

  • summary (string): краткое описание запроса на 1-2 предложения.

  • stack (array of strings): список технологий и инструментов.

  • budget (string): бюджет, если указан.

Если это не лид (is_lead: false), все остальные поля, кроме score=0, должны быть null.

Такой промпт с JSON mode работает отлично. Мы получаем структурированные данные, которые дальше легко обрабатывать и отправлять пользователям.

Стоимость: высокая (относительно предыдущих шагов).
Результат: отсеиваются последние нерелевантные сообщения, а все целевые — обогащаются данными. На выходе остаётся ~3000-4000 чистых, структурированных лидов в сутки.

Этап 5: Дедупликация

Последний штрих. Часто бывает, что один и тот же запрос публикуют в нескольких чатах, иногда с небольшими изменениями. Чтобы не спамить пользователей одинаковыми лидами, мы используем простой механизм дедупликации:

  • Берём summary, сгенерированный LLM.

  • Считаем хэш от этого summary.

  • Храним хэши за последние N часов (например, 24).

  • Если хэш нового лида уже есть в базе — пропускаем его.

Для обработки небольших изменений в тексте («ищу дизайнера» vs «ищем дизайнера в команду») можно использовать нечёткое сравнение строк (например, расстояние Левенштейна), но на практике простого хэширования summary от LLM хватает в 90% случаев.

Что в итоге?

Этап

Вход, сообщ./день

Выход, сообщ./день

% отсева

Примерная стоимость/1000 сообщ.

1. Эвристики

100,000

30,000

70%

~$0.001

2. ML-классификатор

30,000

12,000

60%

~$0.05

3. Семантический поиск

12,000

6,000

50%

~$0.20

4. LLM-скоринг

6,000

~3,500

~40%

~$8.00

Итоговая экономия: вместо того чтобы обрабатывать 100 000 сообщений с помощью LLM (стоимость ~ 800), мы обрабатываем только 6 000. Общая стоимость процессинга 100k сообщений получается в районе $0.001*100 + $0.05*30 + $0.20*12 + $8*6 = $0.1 + $1.5 + $2.4 + $48 ≈ **52**.

Разница — более чем в 10 раз. Это позволяет нам держать цену на продукт адекватной.

Что бы мы сделали иначе?

  1. Не начинать с BERT. Мы потратили пару недель на эксперименты со сложными моделями там, где прекрасно справилась логистическая регрессия. Мораль: всегда начинай с самого простого бейзлайна.

  2. Больше внимания разметке. Качество данных для классификатора важнее архитектуры модели. Наш первый провал с precision был именно из-за криво размеченного датасета.
    3. Семантический поиск — не панацея. Мы думали, что он сможет заменить классификатор, но нет. Он отлично работает как дополнительный слой проверки, но в качестве основного фильтра он медленнее и дороже простого ML.

    Надеюсь, наш опыт был полезен. Если вы решаете похожие задачи фильтрации UGC-контента — расскажите в комментариях, как устроен ваш пайплайн. Особенно интересно, кто и как борется с неявными запросами и какие подходы к дедупликации использует.