Хабр накопил за 20 лет уникальный корпус технических статей — но его интерфейс устроен как лента соцсети: побеждает свежее, а через две недели статья фактически исчезает. Автор igor_suhorukov решил это исправить: собрал 34 270 лучших публикаций, прогнал через LLM и сгруппировал по смыслу — без опоры на хабы и теги.
Почему теги и хабы не решают задачу
Хабр позволяет автору указать до пяти хабов и произвольные теги. Проблема в том, что эти метки субъективны: один автор пишет «машинное обучение», другой — «ML», третий вообще ставит хаб «Хабр». Поиск по ключевым словам возвращает шум.
Параллельно растёт объём контента с коммерческой мотивацией: продвижение HR-бренда, реклама облачных сервисов, нейросгенерированные тексты ради SEO. Они вытесняют из ленты качественные технические материалы, на которые автор тратит дни. Алгоритм ленты не различает глубину — он реагирует на частоту и вовлечённость.
Семантическая кластеризация решает обе проблемы сразу: статьи группируются по реальному смыслу, а не по тому, что автор написал в поле «теги».
Какие статьи попали в выборку
Из всего архива Хабра отобрано около 10% публикаций — те, что прошли хотя бы один из четырёх порогов качества:
| Критерий | Порог |
|---|---|
| Рейтинг | ≥ 100 |
| Комментарии | ≥ 100 |
| Закладки | ≥ 100 |
| Просмотры | ≥ 100 000 |
Итог — 34 270 статей за 2006–2026 годы. Каждая хранится как markdown-файл с метаданными: заголовок, дата, автор, хабы, теги, просмотры, рейтинг, закладки, комментарии. Приложение обходит папку и раскладывает поля в базу автоматически.
Извлечение признаков через LLM
Каждую статью читает модель google/gemma-4-31b-it через OpenAI-совместимый API (в данном случае — OpenRouter). На выходе — JSON примерно из 30 полей:
{
"title": "...",
"summary": "...",
"meaning": "...",
"tldr": "...",
"keywords": ["...", "..."],
"topics": ["...", "..."],
"key_findings": ["...", "..."],
"audience": "...",
"tone": "neutral",
"genre": "tutorial",
"document_type": "article",
"domain": "...",
"completeness": 0.85,
"contradictions": false,
"demagogy_techniques": [],
"advertising": false,
"personal_data": false,
"nsfw": false
}Запрос идёт в 30 параллельных потоков. Медианный вызов занимает 53 секунды, весь корпус обработан за 30 часов активной работы (двое суток с перерывами).
# Пример запуска через OpenRouter-совместимый эндпоинт
# Приложение поддерживает также локальные Ollama и llama.cpp
python process_articles.py \
--input ./articles/ \
--model google/gemma-4-31b-it \
--api-base https://openrouter.ai/api/v1 \
--workers 30 \
--output ./features.duckdbНа 34 270 статей ушло 266 млн входных и 52 млн выходных токенов — в среднем 7 800 входных и 1 500 выходных на статью. Итоговая стоимость: $75,30, или около $0,002 за статью.
Эмбеддинги и кластеризация
После извлечения признаков строятся векторные представления. Отдельный вектор создаётся для аннотации, смысла, аудитории, каждой темы, топика и вывода. Модель — Qwen3-VL-Embedding-2B, размерность вектора — 2 048.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("Qwen/Qwen3-VL-Embedding-2B")
texts = [article["summary"] + " " + article["meaning"] for article in articles]
embeddings = model.encode(texts, batch_size=32, show_progress_bar=True)Всего получается 384 756 векторов. Всё хранится в одной базе DuckDB размером около 13,6 ГБ.
Кластеризацию выполняет HDBSCAN с ускорением на GPU (CUDA или Vulkan). Минимальный размер кластера — 3 статьи. Названия групп генерирует отдельный вызов LLM по содержимому кластера.
import cuml # RAPIDS cuML для GPU-ускорения
from cuml.cluster import HDBSCAN
clusterer = HDBSCAN(min_cluster_size=3, metric="euclidean")
labels = clusterer.fit_predict(embeddings)Где ломается и что учесть
Стоимость растёт нелинейно. $75 за 34 тысячи статей — это при использовании облачного API с выгодным тарифом. Если обрабатывать весь Хабр (около 340 тысяч статей), счёт вырастет до ~$750. Локальные модели через Ollama или llama.cpp снимают эту проблему, но требуют GPU с достаточным VRAM для Gemma-4-31B.
Качество JSON не гарантировано. Модель иногда нарушает схему. Нужна валидация ответа перед записью в базу — иначе битые записи тихо ломают эмбеддинги.
DuckDB на 13,6 ГБ — не для слабых машин. Для локального запуска нужно минимум 16 ГБ RAM, комфортно — 32 ГБ.
Эмбеддинги устаревают. Если добавить новые статьи, пересчитывать придётся только их векторы, но кластеризацию — заново по всему корпусу.
Что попробовать дальше
Тот же pipeline применим к любому корпусу: внутренняя wiki, Confluence, личные заметки в Obsidian. Данные не уходят в облако, если использовать локальные модели. AI-агент получает структурированный индекс с точными «рецептами» решений вместо размытого полнотекстового поиска.
Следующий шаг — RAG-интерфейс поверх DuckDB: задаёшь вопрос, агент ищет ближайшие векторы, возвращает конкретные статьи с аннотациями.
FAQ
Сколько реально стоит обработать 34 000 статей через облачный API?
$75,30 через OpenRouter с моделью Gemma-4-31B — около $0,002 за статью. Локальный запуск через Ollama или llama.cpp обходится дешевле, но нужна видеокарта с достаточным VRAM.
Можно ли запустить это без GPU?
Извлечение признаков через API не требует GPU на вашей стороне. HDBSCAN-кластеризацию можно запустить на CPU через пакет hdbscan, но на 384 тысячах векторов это займёт значительно больше времени, чем GPU-версия через RAPIDS cuML.
Почему результаты анализа хранятся на английском?
Английский снижает расход токенов и улучшает качество работы компактных локальных LLM. Русскоязычные статьи анализируются корректно, но JSON с результатами возвращается на английском.
Подходит ли этот подход для личной базы знаний из заметок?
Да. Pipeline не привязан к Хабру: любой корпус markdown-файлов с метаданными обрабатывается по той же схеме. Локальные модели позволяют не отправлять данные в облако.
Что такое HDBSCAN и чем он лучше K-means для этой задачи?
HDBSCAN (Hierarchical Density-Based Spatial Clustering) не требует задавать количество кластеров заранее и умеет помечать выбросы как «шум». Для тематической кластеризации текстов это важно: количество реальных тем в корпусе неизвестно заранее, а часть статей не попадает ни в одну группу.

