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