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


