Сценарии использования и решения

Кластеризация отзывов и обращений по темам с помощью эмбеддингов: о чём на самом деле пишут клиенты

6 шагов 18 мин чтения 50 просмотров 0
Содержание

Заранее заданные категории находят только те проблемы, которых вы уже ждёте. Новая партия со слабым шарниром, курьерская служба, теряющая посылки в одном регионе, или запутанный шаг настройки проявляются как множество по-разному сформулированных жалоб, часто на нескольких языках. Эмбеддинги превращают каждый текст в набор чисел, отражающий его смысл, поэтому тексты об одном и том же оказываются рядом независимо от формулировки и языка. В этой статье отзывы, обращения или ответы на опросы группируются по темам с помощью мультиязычной модели эмбеддингов и кластеризации k-means, локальная языковая модель даёт название каждой теме, а скрипт составляет отчёт с размерами тем, средними оценками, примерами цитат и динамикой по месяцам.

Темы — это статистические группы, а не факты. Прежде чем принимать решения по теме, прочитайте примеры текстов; собирайте тексты клиентов только способами, которые разрешают платформы и допускают ваши обязательства по защите персональных данных.

Шаг 1: Установка инструментов

apt install -y python3-venv
python3 -m venv /opt/reviews                         # skip if you created it for the review analysis guide
/opt/reviews/bin/pip install ollama numpy scikit-learn
ollama pull bge-m3          # multilingual embedding model
ollama pull qwen2.5:7b      # names the topics

Установка Ollama описана в статье запуск локальной LLM с Ollama.

Шаг 2: Подготовка текстов

Нужен CSV-файл в кодировке UTF-8 со столбцом text. Необязательные столбцы используются, если они есть: rating добавляет среднюю оценку по каждой теме, а date (ГГГГ-ММ-ДД) — таблицу тем по месяцам. Файл отзывов из статьи анализ отзывов покупателей с помощью локальной LLM подходит без изменений. Нужно не менее 50 текстов; при нескольких сотнях и более темы получаются чётче.

Шаг 3: Скрипт выделения тем

cat > /opt/reviews/topics.py <<'EOF2'
import csv, json, os, sys
from collections import Counter
import numpy as np
import ollama
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score

EMBED = os.environ.get("EMBED_MODEL", "bge-m3")          # multilingual embedding model
MODEL = os.environ.get("MODEL", "qwen2.5:7b")
LANGUAGE = os.environ.get("REPORT_LANG", "English")
LABEL = {"type": "object", "properties": {"label": {"type": "string"}, "description": {"type": "string"}},
         "required": ["label", "description"]}

with open(sys.argv[1], encoding="utf-8-sig") as f:
    rows = [r for r in csv.DictReader(f) if r.get("text", "").strip()]
if len(rows) < 50:
    sys.exit("Need at least 50 texts to find meaningful topics.")
texts = [r["text"].strip()[:2000] for r in rows]

vectors = []
for i in range(0, len(texts), 64):                        # embed in batches of 64
    vectors += ollama.embed(model=EMBED, input=texts[i:i + 64]).embeddings
    print(f"embedded {min(i + 64, len(texts))}/{len(texts)}", flush=True)
X = np.array(vectors, dtype=np.float32)
X /= np.linalg.norm(X, axis=1, keepdims=True)

if os.environ.get("K"):
    k = int(os.environ["K"])
else:                                                     # try 3..15 topics, keep the best-separated result
    scores = {}
    for n in range(3, max(4, min(15, len(texts) // 10)) + 1):
        scores[n] = silhouette_score(X, KMeans(n_clusters=n, n_init=10, random_state=0).fit_predict(X))
    k = max(scores, key=scores.get)
km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X)

topics = {}
for c in range(k):
    idx = np.where(km.labels_ == c)[0]
    nearest = idx[np.argsort(np.linalg.norm(X[idx] - km.cluster_centers_[c], axis=1))[:12]]
    r = ollama.chat(model=MODEL, format=LABEL, options={"temperature": 0}, messages=[
        {"role": "system", "content": f"These customer texts belong to one topic. Give the topic a short label "
         f"(max 6 words) and a one-sentence description, both in {LANGUAGE}."},
        {"role": "user", "content": "\n---\n".join(texts[j] for j in nearest)}])
    t = json.loads(r.message.content)
    ratings = [float(rows[j]["rating"]) for j in idx if rows[j].get("rating", "").replace(".", "", 1).isdigit()]
    topics[c] = {"label": f"T{c + 1} {t['label']}", "description": t["description"], "count": len(idx),
                 "rating": round(sum(ratings) / len(ratings), 2) if ratings else "",
                 "examples": [texts[j][:200] for j in nearest[:3]]}

with open("texts_with_topics.csv", "w", newline="", encoding="utf-8-sig") as f:
    w = csv.writer(f)
    w.writerow(list(rows[0].keys()) + ["topic"])
    for r, c in zip(rows, km.labels_):
        w.writerow(list(r.values()) + [topics[c]["label"]])

with open("topics.md", "w", encoding="utf-8") as f:
    for c, t in sorted(topics.items(), key=lambda x: -x[1]["count"]):
        f.write(f"## {t['label']}: {t['count']} texts ({t['count'] * 100 // len(rows)}%)"
                + (f", average rating {t['rating']}" if t["rating"] != "" else "") + "\n\n")
        f.write(t["description"] + "\n\n" + "".join(f"> {e}\n>\n" for e in t["examples"]) + "\n")
    if "date" in rows[0]:                                 # topic share per month, to spot rising problems
        months = sorted({r["date"][:7] for r in rows if r.get("date")})
        f.write("## Topics per month\n\n| Topic | " + " | ".join(months) + " |\n|---|" + "---|" * len(months) + "\n")
        for c, t in topics.items():
            counts = Counter(r["date"][:7] for r, l in zip(rows, km.labels_) if l == c and r.get("date"))
            f.write(f"| {t['label']} | " + " | ".join(str(counts.get(m, 0)) for m in months) + " |\n")
print(f"done: {k} topics -> topics.md, texts_with_topics.csv")
EOF2

Скрипт строит эмбеддинги текстов пакетами, перебирает от 3 до 15 тем (для небольших файлов меньше) и оставляет то количество, при котором группы разделяются лучше всего (по коэффициенту силуэта, silhouette score), а затем отправляет языковой модели по 12 текстов, ближайших к центру каждой темы, чтобы она дала теме название.

Шаг 4: Запуск

cd /data/reviews
REPORT_LANG="Simplified Chinese" /opt/reviews/bin/python /opt/reviews/topics.py reviews.csv

# Force a fixed number of topics, for example 8
K=8 REPORT_LANG="Simplified Chinese" /opt/reviews/bin/python /opt/reviews/topics.py reviews.csv

Результаты — topics.md, где темы перечислены от самой крупной к самой мелкой с долей, средней оценкой, описанием, примерами цитат и количеством по месяцам, и texts_with_topics.csv — ваш исходный файл с добавленным столбцом темы.

Шаг 5: Интерпретация тем

  • Крупные темы с низкой средней оценкой — ваши главные болевые точки.
  • Тема, которая растёт от месяца к месяцу, указывает на какое-то изменение: новую партию, поставщика, курьерскую службу или текст карточки товара.
  • Тема, в которой смешаны два разных вопроса, означает, что тем слишком мало; запустите скрипт снова с большим K. Много мелких похожих тем — признак того, что значение K слишком велико.
  • Отфильтруйте texts_with_topics.csv по теме, чтобы прочитать все её тексты, прежде чем решать, что делать.

Шаг 6: Применение результатов

Частые вопросы

Сколько времени это занимает?

Построение эмбеддингов идёт гораздо быстрее генерации текста: несколько тысяч коротких отзывов на многоядерном CPU обычно обрабатываются за считаные минуты. На название тем уходит по одному ответу модели на каждую тему.

Будут ли темы каждый раз одинаковыми?

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

Работает ли это с текстами на разных языках?

Да. bge-m3 располагает близкие по смыслу тексты рядом независимо от языка, поэтому жалобы на одну и ту же проблему на китайском, английском и тайском могут попасть в одну тему. Названия тем пишутся на языке, заданном в REPORT_LANG.

Если проблема не решена, создайте тикет — техническая поддержка IMIDC работает 24/7. Укажите IP сервера, количество текстов, используемые модели и текст ошибки, чтобы мы быстрее нашли причину.

Помог ли вам данный ответ?

Похожие руководства