- BrainTools - https://www.braintools.ru -

Хотел перестать копировать из Wordstat. Получилась мультиагентная система с Ensemble Voting

Ни одного из этих слов в моих планах не было. Я просто задолбался вручную таскать ключи из Wordstat в Excel.

Версия 1: лишь бы не копировать руками

Знакомая ситуация: открываешь Wordstat, вводишь маску, ждёшь, копируешь, вставляешь в Excel. Следующая маска. И так по кругу. Каждый раз одно и то же.

Написал скрипт. Никакой архитектуры просто цикл, запросы к Bukvarix (у них есть бесплатный API), файл на выходе. Работало. На этом стоило остановиться.

Не остановился.

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

Версия 2: свежие данные и первые метрики

Добавил второй источник – XMLRiver. Платный пулинг прокси к Яндекс XML: те же данные что Wordstat, только через API, без капч, в реальном времени.

Важный момент: Bukvarix и XMLRiver не цепочка, а два независимых режима. Bukvarix быстро и бесплатно даёт широкий список с синонимами, но частотности протухшие. XMLRiver нужен, когда важна свежесть например перед запуском кампании.

В XMLRiver-режиме прикрутил три типа частотности:

Базовая:      ремонт квартир         → 45 661
Точная "!":   "ремонт квартир"       → 12 340  (фиксирует форму слова)
Уточнённая:   [!ремонт !квартир]     →  8 912  (фиксирует порядок и форму)

Параллельно на 10 потоков, retry-логика на случай когда XMLRiver присылает {"error": "Выполните перезапрос"} – значит их пул не справился с капчей Яндекса, надо повторить.

Заодно сразу добавил конкурентность ключа. Покупать данные Ahrefs ради десктопного инструмента дороговато, поэтому обошёлся эвристикой:

def calculate_difficulty(frequency: int, keyword: str) -> str:
    word_count = len(keyword.split())
    score = (word_count * 1000) / (frequency + 1)
    if score > 50:
        return "🟢 ЛЕГКО"
    if score > 10:
        return "🟡 СРЕДНЕ"
    return "🔴 СЛОЖНО"

Логика [1] простая: длинный хвост с низкой частотой – конкурентов мало, короткий высокочастотный – конкуренция высокая. Не Ahrefs, понятно, но как первичный фильтр при разборе тысяч ключей вполне рабочий.

На этом инструмент уже был полезным. Можно было остановиться.

Не остановился. Опять.

Версия 3: кластеризация и скрытая проблема пайплайна

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

Прикрутил SentenceTransformers с моделью paraphrase-multilingual-MiniLM-L12-v2 — 120 МБ, работает на CPU, русский понимает нормально.

И тут же словил ловушку чистой NLP-кластеризации: семантически похожие запросы не всегда конкурируют в выдаче. «Ремонт квартир Москва» и «Ремонт квартир Воронеж» смысл одинаковый, но это две разные страницы. NLP радостно их объединит, а потом удивляйся почему структура кривая.

Решение – SERP Veto. Перед кластеризацией собираем ТОП-10 Яндекса для каждого ключа и смотрим пересечение URL. Менее 2 общих URL – разные кластеры, даже если NLP говорит «одно и то же»:

overlap = len(urls_core.intersection(urls_cand))
if urls_core and urls_cand and overlap < 2:
    continue  # SERP Veto — разные кластеры

Три режима кластеризации:

  • NLP Only – чистая семантическая близость через эмбеддинги. Быстро, SERP-запросов не нужно.

  • SERP Only – только по пересечению URL в выдаче. Медленно, зато точно.

  • Hybrid – NLP формирует кандидатов, SERP Veto отсекает ложные объединения. Лучший баланс.

Гео-изоляция работает во всех режимах: если у одного ключа «Москва», а у другого «Воронеж» они не попадут в один кластер даже при полном совпадении NLP и SERP. Отдельная проверка до любого сравнения.

И вот тут вылезла проблема, которую я сразу не заметил.

Первая версия пайплайна работала в лоб: получил список → сразу за SERP → потом кластеризация. На 3000 ключей это 3000 SERP-запросов до того как убрали хоть один дубль.

А Bukvarix и XMLRiver щедро возвращают морфологические дубли:

ремонт квартир
ремонт квартиры
ремонт квартире
квартир ремонт

NLP-кластеризатор честно пытался разобраться, что это одно и то же. SERP тратил запросы на дубли. Медленно и бессмысленно.

Переделал порядок, каждый этап теперь уменьшает список перед следующей дорогой операцией:

1. Bukvarix ИЛИ XMLRiver → сырой список (~3000 ключей)
        ↓
2. Regex Shield — мгновенная чистка очевидного мусора
   (вакансии, авито, обрывки, аббревиатуры)
        ↓
3. Fuzzy Dedup — схлопываем морфологические дубли
   pymorphy2 лемматизирует, rapidfuzz считает token_sort_ratio
   порог 82%, группировка по первому слову для скорости
        ↓ (~1500-2000 уникальных)
4. SERP-сбор — 10 параллельных потоков, только уникальные
        ↓
5. NLP + SERP Veto кластеризация
        ↓
6. Intent, Difficulty, LSI

Почему порог 82? Подбирал руками: при 90 «ремонт квартиры» и «ремонт однушки» ложно схлопываются, при 75 «ремонт квартиры цена» и «ремонт квартиры стоимость» остаются как разные ключи. 82 на реальных данных убирает 30-40% списка до SERP.

Fuzzy Dedup сравнивает не все N² пар, а только внутри блоков с одинаковым первым словом после лемматизации секунды вместо минут на 3000 ключей.

К этому моменту инструмент умел собирать, частотить, дедуплицировать и группировать. На выходе красивая структурированная таблица. В которой по-прежнему сидело 15-30% мусора.

Версия 4: AI-фильтрация, или почему один промпт – это несерьёзно

Чистить 3000 ключей вручную часа три-четыре. Каждый раз. Минус-слова помогают, но не закрывают пограничные случаи: агрегаторы, запросы от людей ищущих работу, информационный интент, который маскируется под коммерческий.

Ну, думаю, тут-то LLM и поможет. Накидал промпт: «оставь коммерческие, удали мусор». Казалось, что хватит.

Нет. Не хватит.

DeepSeek не знает контекст. «Ремонт» – это квартир, телефонов или двигателей? «Бригада» – строительная или из сериала? Без контекста модель опирается на общие знания, и результат – лотерея.

PlannerAgent: сначала объясни, про что ниша

Добавил агента, который перед классификацией получает описание ниши и генерирует план: нишеспецифичные few-shot примеры, список ловушек, гео-фильтр:

Целевой клиент — частное лицо, нанимающее бригаду.
Он НЕ является: человеком ищущим работу, покупателем материалов.

SUITABLE:
- "ремонт квартиры под ключ москва" — целевой
- "бригада для ремонта однушки" — целевой

IRRELEVANT:
- "работа ремонт квартир" — ищет работу, не услугу
- "авито ремонт квартир" — агрегатор

Стало лучше. Но стабильности по-прежнему не хватало.

ID-нумерация: экономия токенов

Прежде чем решать проблему качества, разобрался со стоимостью. При наивном подходе промпт с 20 ключами – около 600 токенов. А модели не нужны сами строки в ответе, только решение по каждой. Решение: нумеруем ключи, просим вернуть только ID:

numbered_list = "n".join([f"{i}. {kw}" for i, kw in regular_keywords.items()])
# → "1. ремонт квартир под ключn2. авито ремонтn..."

# Модель возвращает:
# {"suitable": [1, 5, 8], "irrelevant": [2, 3], "minus": [4], "check": [{"id": 6, "reason": "..."}]}

for id_num in parsed.get("suitable", []):
    normalized[id_to_kw[id_num]] = "ПОДХОДЯЩИЕ"

Ответ сократился с ~400 до ~80 токенов на батч. На 3000 ключей – экономия 30-40% от общего расхода.

Замер нестабильности

Прогнал один и тот же датасет из 671 ключа три раза подряд. Вот что получилось:

🎯 Стабильные (все 3 прогона одинаково):  253 (37.7%)
⚠️ Нестабильные (2 из 3):                 166 (24.7%)
❓ Одноразовые (1 из 3):                   252 (37.6%)

38% стабильности. На трети датасета хуже монетки.

Причина: PlannerAgent каждый раз генерировал чуть разные few-shot примеры, которые тянули за собой разные решения на пограничных кейсах. Даже при temperature=0 DeepSeek не гарантирует идентичный вывод на длинных генерациях.

Ensemble Voting

Раз один прогон нестабилен прогоняем три раза и берём большинство голосов. Каждый батч из 20 ключей получает три независимых решения параллельно:

def single_vote(_):
    response = ai_client.call(system_prompt, user_message, temperature=0)
    return ai_client.parse_json(response)

with ThreadPoolExecutor(max_workers=votes) as vote_pool:
    vote_results = list(vote_pool.map(single_vote, range(votes)))

counts = Counter(votes_for_keyword)
threshold = votes // 2 + 1  # для 3 голосов = 2

if winner_count >= threshold:
    result = winner[0]
else:
    result = "ПРОВЕРИТЬ"  # ничья → отдельный лист

Ничья (1-1-1) не уходит в мусор и не классифицируется автоматически. Она идёт в отдельный лист «Проверить». Позже к этому листу я приделал арбитражного агента: он получает аргументы всех трёх голосов и выносит финальный вердикт. Снимает примерно 90% ручной работы с этого листа.

Расход токенов, понятно, вырос в три раза. На DeepSeek это не страшно – 3000 ключей с votes=3 стоят примерно $0.3.

До:   37.7% стабильных классификаций
После: ~85% стабильных классификаций

Подвох, который я не ожидал

После всей этой работы – параллельность, ансамбль, арбитр я обнаружил забавное. «Ремонт квартир под ключ» с частотностью 45 661 стабильно улетал в ПРОВЕРИТЬ. Флагманский коммерческий запрос. Самый очевидный.

Разобрался: в промпте были правила «оставляй если содержит цену, стоимость, заказ, гео». А «под ключ» не содержит ничего из этого. Три агента честно не могли договориться потому что все трое работали по одному промпту с логической дырой.

Фикс три строки в промпте. Три строки. А я перед этим неделю строил архитектуру.

Урок: валидируй промпт на 50 крайних случаев прежде чем городить ансамбль поверх него.

Что ещё добавил по ходу

Paranoid Mode. Реальный кейс: у заказчика бренд с названием, похожим на обычное слово. AI выбрасывал все брендовые запросы как информационные. Решение – Whitelist: слова, которые AI не трогает вообще. Ключи с ними идут в ПОДХОДЯЩИЕ, минуя DeepSeek. Проверка по токенам, не по substring – иначе слово «ключ» в whitelist защитило бы «ключи от квартиры». Побочный эффект: брендовые ключи не тратят токены.

SERP модуль. Отдельная вкладка для быстрой проверки конкурентной среды. XMLRiver отдаёт Яндекс XML – парсим органику (кто в топе, доминируют ли агрегаторы), related queries (бесплатные LSI от Яндекса), рекламные блоки (есть реклама – есть деньги в нише), и нейроответы Яндекса (если Яндекс уже отвечает своим AI – органический трафик на запрос будет падать). Всё параллельно на 10 потоках.

AI-ассистент. Чат поверх загруженного датафрейма. Вместо того чтобы каждый раз открывать Excel – пишешь «покажи коммерческие с частотой выше 500», ассистент генерирует pandas.query() и предлагает применить кнопкой.

Итого

Начинал с «хочу не копировать из Wordstat». Получилось вот это:

Bukvarix ИЛИ XMLRiver
      ↓
Regex Shield + Fuzzy Dedup
      ↓
SERP-сбор (10 потоков)
      ↓
NLP + SERP Veto кластеризация
      ↓
PlannerAgent → ExecutorAgent (votes=3) → ArbiterAgent
      ↓
ValidatorAgent (адвокат дьявола)
      ↓
Чистое ядро: ~85% точности, ~10% ошибок ансамбля, ~5% на ручную проверку

Стек: Python 3.11, DeepSeek API, XMLRiver, SentenceTransformers, rapidfuzz, pymorphy2, pandas, customtkinter.

Стоимость прогона 3000 ключей с votes=3 – $0.3. Время 20-30 минут вместо 3-4 часов руками.

Что намеренно не стал делать: RAG на разовых нишах (каждый заказ новая ниша, накопленное не переиспользуется), Keys.so API (9300₽/мес не отбивается при текущих объёмах), FAISS на 100k ключей (нет такого кейса в реальности).

Если интересно

Инструмент пока для личного использования, публичного релиза нет.

Если хочешь обсудить архитектуру, поспорить про ensemble voting или рассказать как решал похожую задачу – вэлкам

Автор: JuxaDan

Источник [2]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/27773

URLs in this post:

[1] Логика: http://www.braintools.ru/article/7640

[2] Источник: https://habr.com/ru/articles/1015348/?utm_campaign=1015348&utm_source=habrahabr&utm_medium=rss

www.BrainTools.ru

Rambler's Top100