Привет, Хабр!
Меня зовут Артём, я фаундер 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% всего потока.
Что уходит в утиль:
Слишком короткие сообщения: всё, что короче условных 30 символов. Прощайте, «+», «ап», «актуально», «добрый день». Конечно, можно случайно отсеять «ищу js-dev», но на практике такие короткие запросы почти не встречаются.
Сообщения от ботов: банальная проверка
ifmessage.sender.is_bot.Медиа и стикеры:
ifmessage.mediaor message.sticker.Стоп-слова: огромный список слов-триггеров, которые почти никогда не встречаются в целевых сообщениях. Это и «спасибо», «пожалуйста», «созвон», «митинг», и политические термины, и крипто-лексика («airdrop», «листинг», «NFT»).
Спам-ссылки: регулярка на популярные спам-домены и 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. Мы делаем следующее:
Превращаем текст сообщения в вектор с помощью той же
S-BERTмодели.Ищем в Qdrant 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 (стоимость ~ 52**.
Разница — более чем в 10 раз. Это позволяет нам держать цену на продукт адекватной.
Что бы мы сделали иначе?
Не начинать с BERT. Мы потратили пару недель на эксперименты со сложными моделями там, где прекрасно справилась логистическая регрессия. Мораль: всегда начинай с самого простого бейзлайна.
Больше внимания разметке. Качество данных для классификатора важнее архитектуры модели. Наш первый провал с
precisionбыл именно из-за криво размеченного датасета.
3. Семантический поиск — не панацея. Мы думали, что он сможет заменить классификатор, но нет. Он отлично работает как дополнительный слой проверки, но в качестве основного фильтра он медленнее и дороже простого ML.Надеюсь, наш опыт был полезен. Если вы решаете похожие задачи фильтрации UGC-контента — расскажите в комментариях, как устроен ваш пайплайн. Особенно интересно, кто и как борется с неявными запросами и какие подходы к дедупликации использует.
