TLDR: https://semagram.io/

Всё началось с того, что меня сократили на работе, и я несколько месяцев подряд не мог найти новую работу. Так получилось, что крупнейший работодатель региона Amadeus (хотя я работал даже не там) - решил заморозить найм и тоже сократить добрую часть консультантов именно в тот момент, когда я отрицательно трудоустроился. В итоге на рынке высвободилась большая масса айти-специалистов, которую не могли трудоустроить другие компании (а кто-то из них, возможно, и сам напрягся “а? Amadeus сокращает найм и внедряет ИИ? На всякий случай тоже заморозим найм”). Я оказался в общей массе.

Так что параллельно с прохождением немногочисленных собеседований я начал думать о том, какие бы проекты запилить. Во-первых, продолжить обновлять свой опыт в резюме, пусть и немного в другом разделе. Во-вторых, а вдруг, мало ли что может случиться.

Я брейнштормил идеи с ИИ, первые проекты были не особо примечательными - сначала это был GDPR-friendly счётчик посещений-картинка (а-ля Liveinternet, узнал, что сейчас есть движение за Интернет по старым технологиям на платформе Neocities). Затем я поймал себя на мысли, что очень завидую другу - автору Telegram бота @DickGrowerBot с MAU, превышающим 200 тысяч. В итоге я совместил первое со вторым и сделал генератор баджей-картинок с MAU, которые можно вставлять на GitHub, в блоги и т. д. - https://tgbotmau.quoi.dev/.

Тут пригодилось и парсить HTML (t.me), и MTProto (у ботов с маленьким MAU t.me не показывает MAU, а клиент Telegram показывает - хотелось бы показывать их MAU тоже), я познакомился с библиотеками lol_html и grammers (в последнюю даже успел отправить баг-репорт).

Друг встроил бадж в свою git репу, я отправил PR авторам ещё нескольких Telegram ботов с открытым исходным кодом, половина поблагодарила и смерджила, другая половина проигнорировала.

Это было небольшим успехом (относительно моих предыдущих проектов), но было понятно, что проект останется нишевым. Где-то в это время я наткнулся на статью про проект Tg Atlas, где авторы соскрапили 400 тысяч Telegram каналов с помощью 17 IP-адресов за 4 дня (сейчас в статье я вижу немного другие формулировки, возможно, статья обновилась, а возможно мне привиделось). Я раньше не думал о том, что порог входа столь низок.

Я начал с того, что пособирал юзернеймы ботов из разных каталогов и сделал свой каталог. Но не простой, а с семантическим поиском. Можно было написать “бот, чтобы увеличить член” и получить @DickGrowerBot на первом месте в выдаче, а рядом тонна его аналогов (он и сам является клоном другого популярного бота - @pipisabot, в котором, однако, наблюдается засилье рекламы). У меня появился эталон качества поиска по каталогу. При этом я обнаружил, что генерация эмбеддингов и их поиск в целом достаточно простая вещь, а через OpenAI API я сгенерировал 12 тысяч эмбеддингов всего за 0.03$.

Я продолжил размышлять и исследовать разные вопросы, пока не обнаружил, что все каталоги Telegram чатов и каналов, которые я нашёл (включая очень крупные), предоставляют лишь поиск по ключевым словам (и, конечно же, куче других фильтров). Они хороши для опытных аналитиков “мне нужны чаты в такой-то категории с таким-то охватом”, “мне нужно найти все упоминания бренда в каналах” и т. д. Но это не то, что нужно простому человеку. Простому человеку (в том числе мне), как известно, нужна кнопка “сделать зашибись”. Чтобы можно было написать “канал про жизнь в Сербии” или “бот для оформления esim” и получить результат, а не настраивать бесчисленные фильтры. В подтверждение моей позиции легко найти жалобы на плохой discovery каналов в Telegram. По сути, Telegram похож на веб 90х-00х - простенький поиск по ключевым словам, каталоги сайтов по категориям. Google только недавно появился.

При этом у Telegram миллиард пользователей, так что идея не нишевая.

В общем, можно попробовать. К тому же проект отдаёт серьёзностью (я напроектировал аж 14 микросервисов и оценку в 2 месяца фулл-тайм работы - инфраструктура скрапинга, поиска и т. д., а БД на миллион+ строк попахивает маленькой, но уже Big Data). Такое и в резюме не стыдно добавить и расписать. Если я доведу до ума такой проект, я уже буду не тем человеком, кем был пару месяцев назад.

Скрапинг

Первым делом надо раздобыть побольше Telegram юзернеймов. Лучше всего валидных.

Взял индекс Common Crawl, прокачал 16 ТБ через свой домашний компьютер. Так как индекс просто не влез бы на мой 512 ГБ диск, использовал стриминг - извлекал t.me ссылки (а также все остальные вариации типа tg://resolve?domain=username) не сохраняя индекс. Скрипт работал неделю без остановок. Собрал более двух с половиной миллионов уникальных юзернеймов (не все из них оказались валидными, конечно же).

Начал скрапить t.me через несколько платных ротируемых прокси. Скрапим только валидные юзернеймы по регулярке. Если видим публичный канал, чат или бота - сохраняем в БД имя + описание + аватар (ссылки на CDN временные, аватары приходится кешировать на своей стороне). Если видим юзера или что-то непонятное - всё равно отмечаем юзернейм как обработанный, но ничего больше не сохраняем.

По итогу 500к каналов, 86 тысяч групп, 55 тысяч ботов. Обогатил данные различными публичными каталогами каналов => стало 800к каналов, 124к групп, 62к ботов. Извлёк юзернеймы и t.me ссылки из описаний каналов - нашлось больше 400к, из них 286к оказались новыми. Внезапно произошёл прирост ботов - 94к ботов итого.

Начал скрапить сообщения (последние 10-15, сколько показывает t.me без пагинации). Если встречается форвард из незнакомого канала, добавляю его в очередь скрапинга. Получил регулярный прирост каналов/чатов/ботов, перестал внимательно следить, кроме того что отметил, что примерно 10% всех упоминаемых новых юзернеймов в каналах - юзернеймы ботов.

От сообщений сохраняется тип + текст (если есть). Самый популярный тип сообщения - одиночная картинка с подписью, с небольшим отставанием идёт текст без медиа. Самый непопулярный тип сообщения - инвойс, на втором месте по антипопулярности - контакт.

Средняя длина текста в сообщении (если он вообще есть) - около 530 символов. Среднее количество сообщений, доступных без пагинации в превью канала - 13.

У ботов нет публичных сообщений, а описания часто неинформативны. Нужно второе описание (которое отображается в пустом диалоге) и список команд. Скрапим через MTProto. Используем юзербота для ResolveUsername + GetFullUser. Медленно из-за жёстких лимитов на ResolveUsername (обновлять потом можно быстро), потенциально нарушаем ToS Telegram (но, полагаю, я немного в серой зоне, так как не собираю данные юзеров, а лишь ботов), могут забанить, никого не агитирую.

Интересные аккаунты

Есть некоторое солидное количество групп и чатов, аватарка которых на t.me не грузится, так как CDN выдаёт 500 ошибку для ссылки (и он будет выдавать её и через день, и через неделю). Например: @magiskcnshare. У некоторых таких аккаунтов хотя бы сам клиент Telegram грузит аватарку, но есть и те, у которых даже в клиенте отображается лишь превью и бесконечная загрузка при попытке её увеличить. В общем, пришлось добавлять в скрапер логику "если аватарка не скачалась 3 раза подряд, то считаем, что у профиля её нет вместо возврата юзернейма в очередь скрапинга".

Есть каналы, в которых есть пустые сообщения. Буквально без тела.

Самый наглядный пример - @vlad_shoky_drum_school - здесь вообще единственное сообщение в канале и оно пустое. Сразу после div с заголовком сообщения идёт div с подвалом, без третьего div посередине с контентом. В клиенте Telegram вместо пустоты отображается 0.

Таких сообщений встретилось несколько десятков. Тоже пришлось добавлять исключение в скрапер.

Но приз на самую уникальность получает чат @vip_labels - в то время как подавляющее большинство аватарок Telegram весят 10-25 Кб, аватарка этого чата весит целых 3.5 Мб! Почему это важно? Потому что в Axum ограничение по умолчанию на тело HTTP запроса - 2 Мб. Соответственно, микросервис ответственный за загрузку аватарок в S3 хранилище отказывался обрабатывать такой запрос от скрапера. Фикс был тривиален - поднять лимит :-)

Обработка аватарок

Семантический поиск по аватаркам можно делать двумя способами:

  • Мультимодальный генератор эмбеддингов (например, SigLIP)

  • Преобразование аватарки в текст (любая мультимодальная LLM), а потом работаем как с остальным текстом

Хочу иметь возможность экспериментировать с разными алгоритмами эмбеддингов, поэтому хочу перегнать аватарки в текст, но не хочу за это слишком много платить.

Многие модели хотят 500$+ за 1 миллион картинок, не в бюджете.

GPT-4.1 mini - в режиме низкого разрешения картинка занимает всего 85 токенов (у других моделей и других режимов счёт идёт на 1000+ токенов). С учётом промта, входа, выхода, а также батчинга получается 138$ за 1 миллион картинок. Уже терпимо, но всё ещё дорого.

Gemini 2.5 Flash-Lite - 26$ за 1 миллион картинок. Приемлемо, начинаю реализовывать, но плююсь от Gemini Batch API - документация в разных местах противоречит сама себе, из-за этого мой микросервис отправил батч на обработку, прождал несколько часов результат, но не смог распарсить ответ. После фикса новый батч висел больше 8 часов (и без гарантий, что он снова распарсится!).

Надоело. Пошёл искать ещё варианты.

Нахожу бенчмарк - https://playground.roboflow.com/ranking/captioning Gemma 3 4B - open-source модель, которую можно запустить на RTX 4090 (в отличие от более жирных моделей). И при этом по задаче captioning она на 7-м месте, опережая оба мои варианта (и то, что её обгоняет, я в любом случае не смогу использовать, потому что там речь о 500 за 1М картинок). Арендую виртуалку на vast.ai, за 24 часа и 6 обрабатываю миллион аватарок.

А для точечной регенерации описания можно использовать эту же модель, но уже на OpenRouter по цене где-то 15$ за миллион аватарок.

База данных

Postgres + pg_vector? Удобный, привычный стек Postgres, и у меня уже есть немного опыта с pg_vector. Но в этот раз я хочу реализовать гибридный поиск, и у меня есть предчувствие, что делать всё руками будет сложно, особенно чтобы оно не тормозило.

ElasticSearch? Умеет всё, что нужно для гибридного поиска. Но у меня предубеждение к инфраструктурным компонентам на Java, и оно только подтверждается, когда прикидываю требования по ОЗУ.

И тут внезапно натыкаюсь на ParadeDB. Обещают Elastic-Quality Search, но на Postgres. Звучит отлично!

Я и так использую Postgres 18 в Docker для того, что уже реализовано, ChatGPT уверяет меня, что можно просто заменить образ (так как до CREATE EXTENSION pg_search это, по сути, обычный Postgres той же версии, а они совместимы, если не менять мажорную версию). Он был не прав. Через несколько часов после замены образа база умирает - начинают появляться дубли в таблицах с UNIQUE индексами, БД замечает это при попытке любых операций как-то затрагивающих такие строки и их невозможно изменить, сбойных строк становится всё больше. Невозможно даже сделать дамп базы данных. Да, можно было бы поискать решения (например, попытаться отключить проверку уникальности), но зачем мне БД в непонятно каком состоянии, у меня же есть ежедневный бекап через pg_dump. Так что просто грохаю Docker volume от Postgres и раскатываю резервную копию. Теряю несколько часов скрапинга (так как тот дамп, который я сделал прямо перед заменой образа, я успел удалить, ведь сначала ничего не сломалось). Проблема уходит.

Эмбеддинги

В моём каталоге ботов (https://tgbotmau.quoi.dev/catalog) хорошо себя зарекомендовал text-embedding-3-large с ценой 0.13$ за мегатокен. При миллионе документов и средней длине документа в 4000 символов это выходит более 130. Дорого!

Использую bge-m3. Обещает хороший семантический поиск на многоязычном датасете (а у меня как раз такой). На OpenRouter стоит 0.01 за мегатокен. Итого 10. При этом, как и в случае с генерацией текстовых описаний аватарок можно начальную генерацию выполнить на виртуалке от vast.ai и это обойдётся в 3-4.

Генерирую эмбеддинги, складываю в pg_vector. Семантический поиск начинает работать, но тупит 10-15 секунд. Оказывается, две вещи:

  • Поставщик bge-m3 на OpenRouter имеет несколько секунд latency. Неприятно, но жить можно

  • Я ошибся при создании индекса, и он не используется. Приходится создавать новый индекс на уже имеющихся данных, в CONCURRENT режиме создаётся за несколько часов.

И вот появляется первая версия моего поисковика:

Важный нюанс - индекс HNSW имеет параметр количества элементов, которые мы ищем. Задаётся через SET LOCAL hnsw.ef_search = N и суть в том, что поиск проделает работы не больше и не меньше, чем нужно для этого количества результатов. Мы, конечно, можем ограничить их через LIMIT, но если LIMIT меньше ef_search, СУБД всё равно проделает работу, необходимую для поиска ef_search элементов. А если LIMIT больше, чем ef_search, то вернётся меньше результатов. Так что надо ставить ef_search именно таким, какое количество результатов мы хотим получить.

Гибридный поиск

В целом поиск уже работает как магия - ты пишешь “канал с мемами” и ты реально получаешь канал с мемами. Ты пишешь “канал про человека с точки зрения биологии” и ты реально получаешь соответствующие научнопопулярные каналы.

Но есть запросы, в которых семантический поиск откровенно слаб. Например, ты вводишь конкретное название канала или хотя бы его часть (допустим, у канала длинное, но звучное имя, но ты запомнил середину). В этом случае с большой вероятностью можно получить даже не каналы близкой тематики, а вообще полную ерунду (потому что название многих каналов имеет очень условную связь с их содержанием и эмбеддинги начинают просто матчить по признаку “какой-то бессмысленный/абстрактный набор слов”, давая каналы с похожим неймингом, да и то лишь если взглянуть на это глазами ИИ).

С одной стороны, конечно, можно сказать “если знаешь имя канала, ты можешь воспользоваться встроенным поиском Telegram”, но с другой стороны, поисковик ощущается каким-то неполноценным, так как даёт осечку в наиболее тривиальных случаях.

Это можно исправить гибридным поиском - делается независимый поиск и по ключевым словам, и по векторам. А затем результаты объединяются. Если какой-то канал занял высокую позицию хотя бы в одном рейтинге - он должен иметь заметную позицию и в итоговом. Если какой-то канал занял высокую позицию сразу в двух рейтингах - это очень хороший матч и надо поднять его на первые места.

К счастью, с ParadeDB и входящим в него расширением pg_search, это не обязательно реализовывать вручную. Всё заработало почти с первой попытки.

Дальше имел место небольшой тюнинг весов. Ориентировался на то, что по запросу “Лентач” должен на первом месте быть одноимённый канал (победа keyword search), а по запросу “бот чтобы вырастить член” - DickGrowerBot и его конкуренты (победа semantic search). В ранжировании учитывается и информация из профиля канала, и семантическая близость запросу, и количество подписчиков.

По итогу получился удовлетворяющий меня результат.

В качестве бонуса сделал поиск по похожести на существующий чат/бот/канал, если передали известный @username (именно с собакой) и ничего больше.

При тестировании сервиса реальными пользователями оказалось, что не помешает так же пытаться соединить все слова вместе и искать такую подстроку среди юзернеймов. Таким образом @modularbot теперь можно найти по запросам и "modularbot", и "modular bot" (раньше второй запрос не находил релеватное).

API для агентов

В одном чате предложили добавить API для агентов с оплатой за запросы по протоколу x402. Добавил простой поисковый эндпойнт за 0.01 USDC и отправил сервис в несколько каталогов. Чем чёрт не шутит, вдруг какой-нибудь блуждающий по Интернету OpenClaw наткнётся на мой сервис и решит, что ему срочно нужно что-то найти в Telegram. Но за несколько дней ничего кроме моих же тестовых платежей на мой кошелёк не пришло :-)

Заключение

Вот так и родился сервис semagram.io, обеспечивающий семантический поиск по более чем миллиону Telegram каналов, чуть меньше чем паре сотен тысяч чатов и чуть меньше чем 150 тысяч ботов. Надеюсь, он будет вам полезен, а меня натолкнёт на мои следующие идеи.

БД на текущий момент весит уже 50 ГБ, а крутится всё это на моём домашнем сервере на MeLe Quieter 4C (справа).