- BrainTools - https://www.braintools.ru -
В первой статье [1] я разбирал RAG‑модуль этого проекта и главный вывод — что RAG оказался про замеры, а не про код. Здесь — про то, как устроена вся система вокруг него: классификатор, который решает, кому вообще нужна помощь, определитель типа проблемы и архитектура, которая всё это связывает.
Эта статья скорее про ML‑инженерию на реальных, грязных данных, чем про RAG. Про то, как собрать рабочий классификатор, когда у тебя нет размеченного датасета, метрики врут, а данные — это поток из телеграм‑чатов с опечатками, сленгом и тремя языками вперемешку. Цифры все реальные, из рабочих логов.
Есть публичные чаты криптобирж. Туда приходят люди, у которых что‑то случилось: заморозили средства, завис вывод, потеряли доступ. Их находят скамеры — пишут в личку, представляясь поддержкой. Модерация против этого бессильна: скамер не в чате, он читает открытую историю снаружи и пишет в ЛС. Подробнее эта логика [2] разобрана в исследовании [3].
Единственный момент, когда будущая жертва наблюдаема, — когда она публично пишет «помогите, не могу вывести». Идея бота: поймать этот момент и в ответ дать человеку предупреждение и, если есть, ссылку на реальный ответ поддержки. Чтобы это сделать, нужно решить две задачи классификации: это вообще просьба о помощи? и какого она типа?
Самая честная проблема реального ML — датасета нет. Нельзя скачать «размеченные просьбы о помощи из крипточатов». Его надо собрать самому, и то, как ты его собираешь, определяет потолок качества сильнее, чем выбор модели.
Первый датасет я собрал регулярками с якорями: искал по архиву сообщения со словами вроде «не могу вывести», «заблокир», «hacked». Быстро, но с встроенным потолком: такой датасет учит модель только тем формулировкам, которые ты сам придумал. Модель отлично ловит «не могу вывести» и слепа к «денюжку не отдают», потому что этого якоря у тебя в голове не было.
Модель на таком датасете хрупкая: спотыкается на опечатках («немогу»), на сленге, на перефразировках. Это не лечится добавлением ещё регулярок — ты просто добавишь ещё несколько формулировок к конечному списку и не покроешь бесконечное разнообразие живого текста.
Дальше я добирал данные не руками и не регулярками, а через active learning. Схема простая: обучаешь модель, прогоняешь по архиву и берёшь на разметку не случайные сообщения, а те, где модель сомневается (скор около границы) или где версии модели расходятся (одна говорит «да», другая «нет»).
Логика: модель сама показывает, где у неё слепые пятна. Там, где она уверена, размечать нечего — она и так права. Ценное — в зоне неуверенности, потому что именно там живут формулировки, которых не было в обучении [4].
Три итерации добора:
зона неуверенности модели (скор около границы),
разногласия между версиями (crossed up / crossed down),
целевые кластеры пропусков.
Датасет вырос с 830 до 1176 позитивов (всего 2900+ строк). Синтетики — меньше 2%: почти всё из живого архива. И вот тут контринтуитивная вещь про дедупликацию.
Когда чистишь датасет, рука тянется убрать похожие строки. Точные дубли — да, убрать: «это мошенники» три раза даёт нулевую новую информацию, просто утраивает вес одной точки.
Но вариации одной фразы удалять нельзя. «Не могу вывести деньги» / «немогу вывести((» / «не могу вывести средства с биржи» — каждая учит модель чуть другому: опечаткам, обрезанной пунктуации, синонимам. Ради этой робастности и добирались кривые короткие примеры. Уберёшь «похожие» автоматом по косинусу — вернёшь модель к хрупкости «делат vs делать».
Более того, самое ценное место датасета — контрастные пары у границы классов: «how to withdraw?» (не беда, вопрос новичка) против «can’t withdraw» (беда). Они почти‑дубли по тексту, но именно на их контрасте модель учится границе. Автоматический дедуп по близости снёс бы одну из пары и стёр бы самое информативное. Поэтому: точные дубли — скриптом, почти‑дубли — не трогать, лимит на повторы — дисциплиной разметки.
Самый долгий спор в проекте был не про модель, а про определение класса. Что считать позитивом?
Наивный ответ — «просьба о помощи». Но он ломается: «how to use futures?» — это тоже просьба о помощи, формально. Если помечать такое позитивом, бот будет триггериться на весь поток вопросов новичков, и предупреждение обесценится баннерной слепотой — люди перестанут его замечать.
Правильный критерий оказался другим: «уместна ли здесь прививка». Человек ждёт помощи от человека — беда названа, есть зов, ищет поддержку → да. Вопрос про механику продукта → нет. Это тонкая граница, и она определила разметку сильнее, чем любой гиперпараметр. Классический урок: в реальном ML определение метки — часть инженерии, а не данность.
Модель — мультиязычный эмбеддер плюс логистическая регрессия сверху. Приятный побочный эффект: обучена на ru/en, а работает и на языках, которых в датасете не было. Проверка на примерах, которых модель не видела:
[es] 0.95 ПРОСЬБА | no puedo retirar mis fondos de la cuenta
[de] 0.95 ПРОСЬБА | ich kann meine USDT nicht abheben, bitte hilfe
[fr] 0.90 ПРОСЬБА | mon depot n'est pas arrive sur la plateforme
[pt] 0.26 нет | bom dia pessoal, quando abre o mercado
Испанский, немецкий, французский ловятся без единого примера в обучении — эмбеддер переносит семантику «у меня беда» между языками. Это не магия, это свойство мультиязычного пространства, но на практике сэкономило разметку четырёх языков.
Вот эпизод, который стоил мне больше всего и который я считаю главным уроком проекта.
Я мерил качество кросс‑валидацией по обучающему датасету — стандартный подход. Цифры росли, я радовался. Проблема: датасет менялся каждую итерацию (я же добирал в него данные), поэтому CV‑метрики между версиями были несравнимы. Хуже — автоподбор порога по CV давал красивые 0.75 и прятал реальный прогресс модели.
Решение — то, что надо было сделать с самого начала: замороженный golden set. Взял ~245 случайных стратифицированных сообщений из архива, разметил вручную, и — ключевое — встроил в merge‑скрипты защиту, чтобы эти сообщения никогда не попадали в обучение. Все сравнения версий — только по нему.
И тут картина прояснилась. Рост полноты по итерациям, честный, по эталону:
|
версия |
полнота |
точность |
что добавлено |
|---|---|---|---|
|
v3 |
0.50 |
0.75 |
базовая линия |
|
v4 |
0.56 |
0.71 |
+215 примеров из зоны разногласий |
|
v5 |
0.78 |
0.78 |
рабочий порог выбран по эталону, а не по CV |
Обрати внимание [5] на скачок v4→v5. Данные почти не добавились — изменился способ выбора порога. CV показывала 0.75 и прятала то, что модель на самом деле сильно лучше. Как только порог стали выбирать по замороженному эталону, реальные 0.78 стали видны.
Мораль: кросс‑валидация по меняющемуся датасету — это не замер, это самообман. Нужен эталон, который заморожен и защищён от утечки в обучение. Иначе ты оптимизируешь метрику, которая живёт своей жизнью.
Ещё одна тонкость: golden set стратифицирован, а реальный поток — нет. Поэтому точность на потоке я мерил отдельно — разметкой 200 случайных срабатываний полного прогона по архиву:
|
порог |
точность |
доля потока |
|---|---|---|
|
0.60 |
0.74 |
100% |
|
0.70 |
0.82 |
~70% |
|
0.80 |
0.87 |
~40% |
Это дало ручку для продакшна: хочешь ловить всё — порог 0.60 (точность 0.74); хочешь чистоты — 0.80 (точность 0.87, но ловишь 40% потока). Цена ошибки [6] асимметрична: ложная прививка безвредна (человек лишний раз увидел предупреждение), а пропуск — это потенциальная жертва. Поэтому порог держим низким.
Определив, что это просьба о помощи, надо понять её тип: вывод, депозит, KYC, взлом. Тут была развилка, и она поучительна тем, что я не пошёл по пути усложнения.
Соблазн был построить каскад: логрег‑детектор, а сверху kNN‑вето, которое досматривает пограничные случаи. Звучит умно. Но прежде чем строить, я задал вопрос: а за счёт чего этот каскад обыграет тупую альтернативу «просто сдвинуть порог логрега»?
Ответа не нашлось. Разбор показал: в пограничной зоне golden set (скоры 0.45–0.60) — единицы примеров, 5–15 строк из 242. Любой вывод «kNN помог/не помог» на такой выборке — статистический шум. Я рисковал принять архитектурное решение по трём угаданным примерам.
Решение: не строить каскад, пока он не доказал превосходство на данных. Каскад — это ещё один порог, ещё одна зона калибровки, ещё один компонент, который может сломаться. Дополнительная сложность обязана себя оправдывать замером, а не идеей «так умнее». Проверить на имеющемся golden было нельзя (мало данных в нужной зоне) — значит, вопрос отложен до эталона побольше, а не решён на вере.
Это, пожалуй, недооценённый навык: знать, когда НЕ добавлять компонент. Половина «архитектуры» реального проекта — это отклонённые усложнения, каждое с причиной.
Система работает как отдельный HTTP‑сервис (FastAPI + SQLite) — детектор просьб о помощи и FAQ‑ретрив, подключённый к стороннему production‑боту Modera fire‑and‑forget вызовом, без доступа к его исходному коду. Своей очереди отправки и троттлинга под лимиты Telegram у сервиса нет — это зона ответственности бота Modera, который реально пишет в чат. У сервиса — журнал всех вердиктов в собственной SQLite‑базе и shadow‑режим для безопасной обкатки перед включением логики на реальных чатах. Trust score отсеивает своих: у кого есть история в чате — не проверяются, жертвы скама — это новички.
Одно решение стоит отдельного разбора, потому что оно и сэкономило, и аукнулось: единый эмбеддер на детектор и на RAG. Одна и та же мультиязычная модель считает эмбеддинги и для классификатора, и для векторного поиска. Экономия очевидна: одна модель в памяти [7], один пайплайн.
Но у этого есть цена, которая всплыла позже. Эмбеддер выбирался под детектор — под задачу «похоже ли это на просьбу о помощи». А переиспользовался для RAG — под задачу «похож ли вопрос на вопрос‑образец». Это разные задачи, и модель, оптимальная для первой, не обязана быть оптимальной для второй. Когда в RAG‑части уперся в потолок recall (об этом первая статья), одним из подозреваемых был именно этот компромисс: эмбеддер, выбранный не под поиск.
Это типичный инженерный размен: единый пайплайн экономит на старте и превращается в ограничение потом. Не ошибка — осознанный компромисс, но о его цене честно стоит знать заранее.
Сбор датасета — это инженерия, а не подготовка к инженерии. Наивный сбор регулярками ставит потолок качества раньше, чем ты выберешь модель. Active learning (добор из зон неуверенности) пробивает этот потолок, потому что модель сама показывает слепые пятна.
Определение метки — часть работы. «Уместна ли прививка» вместо «просьба о помощи» изменило проект сильнее любого гиперпараметра.
Замораживай эталон и защищай от утечки. Кросс‑валидация по меняющемуся датасету врёт. Golden set показал реальные 0.78 там, где CV прятала прогресс за 0.75.
Умей не усложнять. Каскад LogReg+kNN звучал умно, но не смог доказать превосходство над сдвигом порога на имеющихся данных. Отклонённое усложнение — тоже архитектурное решение.
У любого «переиспользования» есть цена. Единый эмбеддер сэкономил на старте и стал подозреваемым в потолке recall потом. Компромиссы честнее называть компромиссами.
Это вторая статья из цикла статей о проекте. Первая — про то, почему RAG‑часть оказалась про замеры, а не про код: ссылка [1].
Автор: noroots
Источник [8]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/33623
URLs in this post:
[1] первой статье: https://habr.com/ru/articles/1063026/
[2] логика: http://www.braintools.ru/article/7640
[3] исследовании: https://modera.me/research
[4] обучении: http://www.braintools.ru/article/5125
[5] внимание: http://www.braintools.ru/article/7595
[6] ошибки: http://www.braintools.ru/article/4192
[7] памяти: http://www.braintools.ru/article/4140
[8] Источник: https://habr.com/ru/articles/1063028/?utm_campaign=1063028&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.