hh-radar
# hh-radar
[](https://github.com/Denmurzik/hh-radar/actions/workflows/ci.yml)
[](pyproject.toml)
[](LICENSE)
**Своя база вакансий hh.ru, которую LLM-агент опрашивает как набор инструментов.**
Полнотекстовый поиск на PostgreSQL, поиск по смыслу на pgvector, семь MCP-инструментов
и честный замер того, где поиск ошибается.
📊 **[Витрина](https://denmurzik.github.io/hh-radar/)** — открывается за десять секунд, все цифры
посчитаны программой из базы. Сейчас на ней демонстрационный корпус: вакансии hh нельзя ни
выложить в публичный репозиторий, ни скачать без токена приложения, поэтому страница
собрана из сгенерированных данных и говорит об этом первым же абзацем.
---
## Зачем это написано
Я искал работу через hh.ru и делал руками одно и то же: открыть выдачу, прочитать
тридцать описаний, выписать, какие навыки повторяются, прикинуть, куда вообще есть
смысл писать. Поиск на hh отвечает на «есть ли слово в тексте». На вопросы, которые
возникали у меня — «где чаще всего просят n8n и сколько за это платят», «какие
вакансии возьмут человека без коммерческого опыта», «где нужно чинить чужие сломанные
автоматизации» — он не отвечает вообще.
Поэтому вакансии складываются в свою базу, а поверх базы стоит MCP-сервер. Дальше
вопрос задаётся обычными словами прямо в Claude Desktop, а агент сам решает, чем его
обслужить: полнотекстовым поиском, агрегацией по навыкам, срезом зарплат или поиском
по смыслу.
```
Ты: какие навыки чаще всего требуют в вакансиях по автоматизации за последний месяц?
Claude вызывает skill_stats(query="автоматизация", published_within_days=30)
→ Python — 61% вакансий, медиана 140 000 ₽
n8n — 34%, медиана 150 000 ₽
Docker — 29%, ...
```
Цифры в примере — иллюстрация формата ответа. Настоящие лежат на витрине и меняются
с каждым сбором.
## Что внутри
| Слой | Что делает |
|---|---|
| `hh/` | Клиент API hh.ru: OAuth-токен приложения, ограничение частоты, ретраи, обход потолка выдачи |
| `ingest/` | Идемпотентная запись в базу: UPSERT, двухфазная загрузка карточек |
| `db/` | Четыре сущности плюс связь многие-ко-многим, полнотекстовый индекс, слой запросов |
| `rag/` | Чанкинг, эмбеддинги, поиск по смыслу на pgvector, гибридный поиск, **оценка качества** |
| `mcp_server/` | Семь инструментов для LLM-агента поверх всего перечисленного |
| `showcase/` | Генератор статической витрины для GitHub Pages |
### Инструменты MCP
| Инструмент | Отвечает на вопрос |
|---|---|
| `search_vacancies` | «Найди вакансии со словами X, удалённо, от 150 тысяч» |
| `semantic_search` | «Найди вакансии, где надо чинить чужие сломанные автоматизации» |
| `get_vacancy` | «Покажи эту вакансию целиком» |
| `skill_stats` | «Какие навыки требуют чаще всего и сколько за них платят» |
| `market_overview` | «Какая вилка на рынке, сколько удалёнки, кто больше всех нанимает» |
| `compare_to_profile` | «Мне сюда стоит писать?» — с честным ответом «нет, и вот почему» |
| `db_status` | «Что вообще есть в базе и за какой период» — чтобы агент не выдумывал |
## Быстрый старт
Нужны Docker и Python 3.12+.
```bash
git clone https://github.com/Denmurzik/hh-radar
cd hh-radar
uv sync --extra rag # или: pip install -e ".[rag]"
docker compose up -d db # PostgreSQL 17 + pgvector на порту 5433
uv run alembic upgrade head # схема
uv run hh-radar seed # 52 демонстрационные вакансии, без токена и без сети
uv run hh-radar status
```
Через минуту у вас работающая база и MCP-сервер. Дальше — либо подключить его к
Claude Desktop (см. ниже), либо собрать настоящие данные.
Вакансии, которые кладёт `seed`, **вымышлены**: сорок восемь из них собирает
[`scripts/make_demo_corpus.py`](scripts/make_demo_corpus.py) из двенадцати ролевых
архетипов с фиксированным зерном. Чужие объявления в публичном репозитории лежать не
должны, а без токена приложения их и не получить. Проходят они через тот же разбор,
тот же конвейер записи и тот же чанкинг, что и живые данные, — отдельной ветки «для
демо» в коде нет. Подробности в [`samples/README.md`](samples/README.md).
### Настоящие данные
`GET /vacancies` у hh **закрыт для анонимных запросов** — отвечает
`403 bad_authorization`. Нужен токен приложения:
1. Зайдите на [dev.hh.ru/admin](https://dev.hh.ru/admin) → «Создать приложение».
2. Скопируйте Client ID и Client Secret.
3. `cp .env.example .env` и впишите их в `HH_CLIENT_ID` / `HH_CLIENT_SECRET`.
Токен по `client_credentials` запрашивается автоматически и кэшируется на диск —
`/token` не дёргается на каждый запуск.
```bash
uv run hh-radar ingest --days 30 # собрать вакансии
uv run hh-radar index # посчитать эмбеддинги
uv run hh-radar showcase # пересобрать витрину
```
### Подключение к Claude Desktop
`%APPDATA%\Claude\claude_desktop_config.json` (Windows) или
`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
```json
{
"mcpServers": {
"hh-radar": {
"command": "C:\\путь\\к\\hh-radar\\.venv\\Scripts\\python.exe",
"args": ["-m", "hh_radar.mcp_server"],
"env": { "DATABASE_URL": "postgresql+psycopg://hh:hh@localhost:5433/hh_radar" }
}
}
}
```
Перезапустите Claude Desktop — в списке инструментов появится `hh-radar`.
## Схема базы
```mermaid
erDiagram
EMPLOYERS ||--o{ VACANCIES : "нанимает"
VACANCIES ||--o{ VACANCY_CHUNKS : "режется на"
VACANCIES }o--o{ SKILLS : "требует"
EMPLOYERS {
bigint id PK "идентификатор hh"
string name
bool trusted
}
VACANCIES {
bigint id PK "идентификатор hh"
string name
int salary_from_rub "приведено к рублям"
bool is_remote
text description
tsvector search_vector "генерируемый, GIN"
timestamptz published_at "индекс DESC"
}
SKILLS {
int id PK
string name UK "нормализованное"
string display_name "как написал работодатель"
}
VACANCY_CHUNKS {
bigint id PK
int chunk_index
text content
vector embedding "384, HNSW cosine"
}
```
## Инженерные решения, которые стоит объяснить
Здесь то, что не видно из списка технологий, но именно это отличает работающий
сборщик от примера из документации.
**Потолок выдачи в 2000 элементов.** hh не отдаёт больше двух тысяч вакансий на один
поисковый запрос, сколько страниц ни проси. Сборщик сначала спрашивает, сколько всего
найдено за интервал дат; если больше потолка — режет интервал пополам и повторяет для
каждой половины рекурсивно. Дробление останавливается на часовом окне: если и там
перебор, честнее записать предупреждение в лог, чем молча потерять данные.
**tsvector — генерируемый столбец, а не вычисление на лету.** Postgres поддерживает
его сам при каждой записи, GIN-индекс строится по готовому значению. Название вакансии
весит больше описания (`setweight` A против B) — совпадение в заголовке релевантнее
совпадения где-то в середине абзаца про ДМС.
Проверить план: `hh-radar explain "kubernetes airflow"`. Команда не просто печатает
`EXPLAIN ANALYZE`, а разбирает его, потому что «Seq Scan» сам по себе ещё ничего не
означает. На маленькой таблице последовательное чтение дешевле индекса, и планировщик
прав. На большой, но при запросе, под который подходит половина базы, — тоже прав.
Проблема только в третьем случае: запрос избирательный, таблица большая, а индекс не
взят — обычно потому, что после массовой заливки не собрана статистика. Поэтому
`hh-radar ingest` в конце сам выполняет `ANALYZE`: без этого первые же поиски по
свежей базе идут мимо индекса.
**Различение INSERT и UPDATE без лишнего SELECT.** У `INSERT ... ON CONFLICT DO UPDATE`
системный столбец `xmax` равен нулю у только что вставленной строки и содержит
идентификатор транзакции у обновлённой. `RETURNING (xmax = 0)` даёт точную статистику
прогона одним запросом.
**Двухфазная загрузка.** Поиск отдаёт сто вакансий за запрос, но без описания и
навыков; полная карточка стоит одного запроса на вакансию. Поэтому сначала пишется
всё, что дал поиск, и только потом дозагружаются карточки — по тем вакансиям, у
которых их ещё нет. Прерванный на середине прогон ничего не теряет, следующий
подхватывает с того же места.
**Название вакансии в каждом чанке.** Кусок текста «требуется опыт от года и знание
Docker» не находится по запросу «python-разработчик»: слова «python» в самом куске
нет. Поэтому каждый чанк начинается с названия вакансии. Мелочь, которая меняет
recall в разы.
**Схлопывание по вакансии в семантическом поиске.** Без этого одна многословная
вакансия занимает всю первую страницу выдачи своими пятью чанками. Берём с запасом,
оставляем лучший чанк каждой вакансии.
**RRF, а не взвешенная сумма.** Гибридный поиск объединяет полнотекстовую и
семантическую выдачу по Reciprocal Rank Fusion. Складывать `ts_rank` с косинусной
близостью нельзя: это числа из разных пространств, и коэффициент для их смешивания
подбирался бы под конкретный набор запросов. RRF работает с рангами, а ранги
сравнимы всегда.
**Заглушка вместо модели в CI.** `EMBEDDING_BACKEND=hash` — детерминированный
хеш-эмбеддер той же размерности. Он ничего не знает о смысле, но позволяет прогнать
всю проводку кода, не выкачивая 220 МБ ONNX на каждый пуш. Настоящая модель —
`paraphrase-multilingual-MiniLM-L12-v2` через fastembed, на CPU, без API-ключей:
проверяющий должен уметь запустить проект, не заводя аккаунтов.
## Где поиск ошибается
<!-- EVAL:BEGIN -->
**Чисел здесь нет, и это осознанно.** Опубликовать recall@10 по вымышленному корпусу,
который сам же и сгенерировал, — значит принять экзамен у самого себя: запросы и
данные писал один человек, и метрика померит их совпадение, а не качество поиска.
Настоящие числа появятся здесь после сбора с токеном приложения; до тех пор честнее
пустая таблица, чем красивая.
Что при этом сделано и работает:
```bash
uv run hh-radar evaluate # в терминал
uv run hh-radar evaluate -o eval/report.md # в файл
```
Команда гоняет [размеченный набор из 25 запросов](eval/queries.yaml) через три метода
поиска — полнотекстовый, семантический и гибридный — и считает recall@k, precision@k,
MRR и среднее время ответа для каждого. Эталон берётся из `relevant_vacancy_ids`, а
пока ручной разметки нет — из регулярных выражений по названию вакансии. Запросы, для
которых эталона не нашлось вообще, не идут в среднее и считаются отдельной колонкой:
записать их в провалы было бы враньём в свою пользу наоборот.
Интереснее таблицы — то, что команда печатает под ней: список запросов, на которых
метод не нашёл ни одной релевантной вакансии в топ-5, вместе с тем, что он вернул
вместо неё. По нему видно класс запросов, который система не тянет. Даже на
демонстрационном корпусе это уже видно: на перифразах вроде «нужен человек, который
свяжет CRM и мессенджер через no-code» полнотекстовый поиск не возвращает **ничего** —
`websearch_to_tsquery` требует все значимые слова разом, — а семантический возвращает
ровно те вакансии, где про no-code не сказано ни разу. Ради этой разницы pgvector в
проекте и стоит.
<!-- EVAL:END -->
Разметка лежит в [`eval/queries.yaml`](eval/queries.yaml) и составлена
с уклоном в то, что ключевыми словами не находится: перифразы, описания задачи вместо
названия технологии, запросы про условия и про уровень кандидата.
## Ограничения
Пишу прямо, потому что проверять всё равно будут:
- **Это личный проект**, а не продакшен под нагрузкой. Написан для собственного
поиска работы.
- **Нужен токен приложения hh.** Без него доступны только справочники; вакансии
не отдаются никому анонимно.
- **Курсы валют статичные.** Приведение зарплат к рублям — приближение для
сортировки, а не финансовый расчёт. Тянуть курсы ЦБ ради этого — усложнение
без пользы.
- **Выгрузок вакансий в репозитории нет.** База живёт локально. В `samples/`
лежит демонстрационный корпус на 52 вымышленные вакансии — его кладёт `seed`
и на нём собрана витрина; настоящих объявлений здесь нет ни одного.
- **Модель эмбеддингов выбрана по весу, а не по качеству.** Из многоязычных,
доступных в fastembed, `paraphrase-multilingual-MiniLM-L12-v2` — самая лёгкая
(220 МБ против 2.24 ГБ у `multilingual-e5-large`). Насколько это стоит recall,
видно в разделе выше.
- **`compare_to_profile` не проверяет требования к переезду и английскому**:
в данных hh нет полей, по которым это можно утверждать, а гадать по ключевым
словам в описании — врать с уверенным лицом.
## Разработка
```bash
# uv sync приводит окружение в точное соответствие набору extra,
# поэтому для разработки нужны оба: без rag не соберётся семантический поиск,
# без dev не будет pytest.
uv sync --extra dev --extra rag
uv run pytest -q # юнит-тесты, база не нужна
uv run ruff check src tests
uv run mypy
uv run alembic check # схема и модели не разошлись
```
Тесты, которым нужна живая база или настоящая модель, помечены маркерами:
```bash
uv run pytest -m integration # требует docker compose up -d db
uv run pytest -m embeddings # скачивает модель
```
## Лицензия
MIT — см. [LICENSE](LICENSE).
TDQS
Scored across 7 tools
Each tool targets a distinct function: lexical vs semantic search, individual vacancy detail, skill statistics, market aggregates, candidate-fit comparison, and database metadata. The overlapping search/stat tools explicitly cross-reference each other to prevent misselection.
Most names are lowercase snake_case and use clear noun/verb tokens, but the pattern is mixed: search_vacancies/get_vacancy/compare_to_profile are verb-first, while skill_stats/market_overview/db_status are noun-first. Still predictable and readable.
Seven tools cover the job-search niche without redundancy; each one serves a clear use case and none feel like filler.
The surface covers the full workflow: discover via search/semantic search, inspect via get_vacancy, analyze via skills/market tools, evaluate fit via compare_to_profile, and understand data boundaries via db_status. No critical missing operation for the stated purpose.