Хотел перестать копировать из Wordstat. Получилась мультиагентная система с Ensemble Voting. deepseek.. deepseek. llm.. deepseek. llm. nlp.. deepseek. llm. nlp. python.. deepseek. llm. nlp. python. SentenceTransformers.. deepseek. llm. nlp. python. SentenceTransformers. seo.. deepseek. llm. nlp. python. SentenceTransformers. seo. автоматизация.. deepseek. llm. nlp. python. SentenceTransformers. seo. автоматизация. кластеризация семантики.. deepseek. llm. nlp. python. SentenceTransformers. seo. автоматизация. кластеризация семантики. семантическое ядро.. deepseek. llm. nlp. python. SentenceTransformers. seo. автоматизация. кластеризация семантики. семантическое ядро. яндекс директ.

Ни одного из этих слов в моих планах не было. Я просто задолбался вручную таскать ключи из 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 "🔴 СЛОЖНО"

Логика простая: длинный хвост с низкой частотой – конкурентов мало, короткий высокочастотный – конкуренция высокая. Не 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

Источник

Rambler's Top100