Кастомный BERTopic + локальная LLM: кластеризация и интерпретация тем | AiManual
AiManual Logo Ai / Manual.
16 Май 2026 Гайд

Как построить кастомный пайплайн BERTopic с кластеризацией текстов и интерпретацией тем через локальную LLM

Пошаговый гайд по настройке пайплайна BERTopic с заменой c-TF-IDF на локальную LLM для осмысленных названий тем. Эмбеддинги, UMAP, HDBSCAN, промптинг.

Почему c-TF-IDF в BERTopic — это как читать мысли по губам

Стандартный BERTopic из коробки генерирует темы через class-based TF-IDF. Выглядит красиво, но на практике получаешь набор слов вроде «договор, оплата, счет, сумма» — и догадывайся, это про бухгалтерию или про мошенничество? Интерпретация тем превращается в угадайку. Особенно если тексты технические, короткие или с жаргоном.

Проблема в том, что c-TF-IDF не понимает контекста. Он просто находит характерные термы, но не складывает их в связную историю. А теперь представьте: мы скармливаем топ-10 документов каждой темы локальной LLM (например, Qwen2.5 7B через Ollama) и просим её сформулировать название и краткое описание. Результат — не «договор оплата счет», а «Обсуждение условий оплаты и штрафных санкций в договорах поставки». Чувствуете разницу?

В этом гайде я покажу, как построить кастомный пайплайн BERTopic, где этап интерпретации полностью заменён на промптинг локальной LLM. Никаких облачных API, всё работает на вашей машине или в корпоративном контуре.

Архитектура: от сырых текстов до человекочитаемых тем

Пайплайн состоит из четырёх этапов, которые мы разберём по косточкам:

  1. Предобработка и эмбеддинги — чистим текст, превращаем в векторы через SentenceTransformers.
  2. Снижение размерности и кластеризация — UMAP + HDBSCAN.
  3. Извлечение репрезентативных документов — берём топ-N документов из каждого кластера.
  4. Интерпретация через локальную LLM — отправляем документы в промпт, получаем название и описание темы.

На каждом этапе есть нюансы, без которых пайплайн развалится. Покажу на реальном примере — датасет отзывов на банковские услуги (анонимизированный).

💡 Весь код доступен на GitHub. Но не копируйте слепо — разберитесь, почему я ставлю флаг metric='cosine' в UMAP, а не 'euclidean'.

Этап 1. Предобработка и эмбеддинги: как не потерять смысл

Давайте сразу договоримся: никаких стоп-слов без разбора. В отзывах «не работает» — это ключевая фраза, а не мусор. Используйте spaCy или GLiNER 2 (о нём у меня есть отдельная статья) для удаления только PII-данных, если нужно. Лемматизация для BERTopic не обязательна, но иногда помогает — экспериментируйте.

Для эмбеддингов берите модель, которая понимает специфику вашего языка. Для русского — intfloat/multilingual-e5-large (актуальная версия на май 2026 — e5-mistral-7b-instruct, но она тяжелая). Можно использовать BAAI/bge-m3 — отлично работает с многоязычными текстами.

from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer('BAAI/bge-m3')
docs = [...]  # список предобработанных текстов
embeddings = model.encode(docs, show_progress_bar=True, normalize_embeddings=True)

Нормализация эмбеддингов (normalize_embeddings=True) даёт косинусную близость через скалярное произведение — для UMAP это критично.

Этап 2. UMAP + HDBSCAN: настройка, которая решает всё

Кластеризация — самое капризное место. Стандартные параметры BERTopic часто дают кучу мусорных кластеров. Я предпочитаю вынести UMAP и HDBSCAN наружу, чтобы иметь полный контроль.

UMAP

Ключевые параметры:

  • n_neighbors=15 — для больших датасетов (>10k) берите 25-30. Маленькое значение — слишком локальная структура.
  • min_dist=0.0 — почти всегда 0.0, чтобы кластеры не размазывались.
  • metric='cosine' — НЕ 'euclidean', иначе эмбеддинги сплющатся неправильно.
import umap

reducer = umap.UMAP(n_neighbors=15, min_dist=0.0, metric='cosine', random_state=42)
reduced_embeddings = reducer.fit_transform(embeddings)

HDBSCAN

Лучше min_cluster_size=10, min_samples=5. Если шума слишком много (>70%) — уменьшайте min_cluster_size или пробуйте другие метрики. HDBSCAN устойчив к выбросам, но выбрасывает всё в кластер -1.

import hdbscan

clusterer = hdbscan.HDBSCAN(min_cluster_size=10, min_samples=5, metric='euclidean')
clusters = clusterer.fit_predict(reduced_embeddings)

⚠️ ВАЖНО: UMAP и HDBSCAN работают на reduced_embeddings, а не на исходных эмбеддингах. Порядок сначала — потом кластеризация.

После кластеризации получаем метки для каждого документа. Нулевая тема (-1) — шум. Её можно либо игнорировать, либо перекластеризовать отдельно.

Этап 3. Извлечение репрезентативных документов для каждой темы

Теперь для каждого кластера (кроме -1) нужно выбрать несколько документов, которые лучше всего его представляют. Не берите случайные! Есть два подхода:

  • По расстоянию до центроида — найдите центроид кластера в эмбеддинговом пространстве и возьмите K ближайших документов. Минус: центроид может быть смещён выбросами.
  • По вероятности принадлежности — HDBSCAN даёт probabilities_ для каждого документа. Берите топ-5 с наибольшей вероятностью. Это надёжнее.
from collections import defaultdict
from scipy.spatial.distance import cdist

# probabilities — массив того же размера, что и clusters
cluster_docs = defaultdict(list)
for idx, cluster_id in enumerate(clusters):
    if cluster_id != -1:
        cluster_docs[cluster_id].append(idx)

representative_docs = {}
for cid, indices in cluster_docs.items():
    # берём 5 документов с наибольшей вероятностью принадлежности
    probs = [clusterer.probabilities_[i] for i in indices]
    top_indices = [indices[i] for i in np.argsort(probs)[-5:]]
    representative_docs[cid] = [docs[i] for i in top_indices]

Этап 4. Интерпретация через локальную LLM: промптинг — это искусство

Теперь самое интересное. Берём локальную LLM. В 2026 году золотой стандарт для такого — Qwen2.5 7B (инструкция) или Llama 3.2 8B. Запускаем через Ollama (версия 0.6+) или через vLLM, если нужен высокий throughput. Ollama проще для экспериментов.

Промпт должен быть жёстким по структуре. Мы не хотим, чтобы LLM фантазировала — только факты из документов.

import ollama

prompt_template = """Ты — аналитик, который помогает назвать тему на основе нескольких текстов.
Тексты:
{texts}

Придумай короткое название темы (до 5 слов) и описание (1 предложение).
Формат ответа:
Название: ...
Описание: ...
"""

def interpret_topic(docs, model='qwen2.5:7b'):
    texts = '\n---\n'.join(docs[:5])  # ограничиваем 5 документов
    response = ollama.chat(model=model, messages=[
        {'role': 'user', 'content': prompt_template.format(texts=texts)}
    ])
    return response['message']['content']

После парсинга ответа (регуляркой) получаем для каждого кластера название и описание. Для проверки качества можно прогнать через другую LLM — но это уже оверкилл.

💡
Совет: Если тема содержит смешанные концепции (например, «жалобы на скорость» и «жалобы на качество»), разбейте её на подтемы с помощью HDBSCAN с меньшим min_cluster_size или используйте иерархическую кластеризацию. BERTopic поддерживает эту логику, но мы её не трогали.

Ловушки, в которые я наступал (и вы наступите)

  1. Размер промпта — LLM имеет контекстное окно, но вброс 5 длинных документов может её запутать. Лучше брать короткие выдержки: суммаризируйте каждый документ одной фразой через LLM, а потом скормите эту фразу. Или используйте подход из статьи «Когда 128К токенов не хватает» — разбивайте документы на чанки.
  2. Повторяющиеся названия — две темы могут получить одинаковое имя. Решение: добавить в промпт предыдущие названия тем, чтобы LLM избегала повторов. Или постобработка через embedding similarity.
  3. Выбор количества тем — HDBSCAN сам определяет число кластеров. Если их больше 50, LLM будет интерпретировать их целую вечность. Используйте параметр min_cluster_size для грубой настройки.
  4. Скорость — на CPU Qwen2.5 7B выдаёт ~5 токенов/с. 100 тем = 500 токенов на ответ = ~100 секунд. Терпимо, но лучше GPU. Если нет GPU — уменьшайте число репрезентативных документов до 3 или используйте модель поменьше, например phi-3-mini.

Когда всё готово: визуализация и автоматизация

После интерпретации у вас есть структура: кластер_id -> (название, описание, список документов). Можно построить интерактивный дашборд на Bokeh или экспортировать в Excel для заказчика. Или, как я, прикрутить к пайплайну автоматическую отправку результатов в корпоративный чат (Slack, Telegram).

В моём проекте для Ростелекома мы использовали этот пайплайн для анализа обращений техподдержки. Система раз в сутки прогоняла новые тикеты через BERTopic, а LLM называла каждую тему — операторы видели не «договор оплата», а «Проблемы с закрытием договора после расторжения». Время на обработку инцидентов сократилось на 30%.

Кстати, о схожих подходах: если вы хотите сравнить BERTopic с другими методами topic modeling, взгляните на сравнение FASTopic, TopicGPT и LlooM — возможно, что-то подойдёт лучше. А если вам нужно просто классифицировать тексты без кластеризации, локальная LLM справится и в zero-shot режиме — я подробно описал это в соответствующем гайде.

Последний неочевидный совет

Не пытайтесь получить идеальные темы с первого запуска. Topic modeling — это итеративный процесс. Запустите пайплайн, посмотрите на названия, которые сгенерировала LLM, найдите кластеры, где названия дублируются или слишком общие, и измените параметры кластеризации для этих тем. Можно даже сделать второй проход UMAP+HDBSCAN только на «проблемных» документах. BERTopic это позволяет из коробки, но мы сделали всё вручную — так гибче.

И ещё: локальная LLM — это не чёрный ящик. Вы контролируете промпт, модель, количество документов. Настраивайте промпт до тех пор, пока названия не станут стабильными. А когда станут — поздравляю, вы построили кастомный пайплайн, который понимает ваш бизнес лучше любого API.

Подписаться на канал