Заранее заданные категории находят только те проблемы, которых вы уже ждёте. Новая партия со слабым шарниром, курьерская служба, теряющая посылки в одном регионе, или запутанный шаг настройки проявляются как множество по-разному сформулированных жалоб, часто на нескольких языках. Эмбеддинги превращают каждый текст в набор чисел, отражающий его смысл, поэтому тексты об одном и том же оказываются рядом независимо от формулировки и языка. В этой статье отзывы, обращения или ответы на опросы группируются по темам с помощью мультиязычной модели эмбеддингов и кластеризации k-means, локальная языковая модель даёт название каждой теме, а скрипт составляет отчёт с размерами тем, средними оценками, примерами цитат и динамикой по месяцам.
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.
Нужен CSV-файл в кодировке UTF-8 со столбцом text. Необязательные столбцы используются, если они есть: rating добавляет среднюю оценку по каждой теме, а date (ГГГГ-ММ-ДД) — таблицу тем по месяцам. Файл отзывов из статьи анализ отзывов покупателей с помощью локальной LLM подходит без изменений. Нужно не менее 50 текстов; при нескольких сотнях и более темы получаются чётче.
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 текстов, ближайших к центру каждой темы, чтобы она дала теме название.
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 — ваш исходный файл с добавленным столбцом темы.
K. Много мелких похожих тем — признак того, что значение K слишком велико.texts_with_topics.csv по теме, чтобы прочитать все её тексты, прежде чем решать, что делать.Построение эмбеддингов идёт гораздо быстрее генерации текста: несколько тысяч коротких отзывов на многоядерном CPU обычно обрабатываются за считаные минуты. На название тем уходит по одному ответу модели на каждую тему.
При тех же данных и настройках — да: кластеризация использует фиксированное начальное значение генератора случайных чисел (seed), а модель работает с температурой 0. Новые данные или другое значение K меняют темы.
Да. bge-m3 располагает близкие по смыслу тексты рядом независимо от языка, поэтому жалобы на одну и ту же проблему на китайском, английском и тайском могут попасть в одну тему. Названия тем пишутся на языке, заданном в REPORT_LANG.
Если проблема не решена, создайте тикет — техническая поддержка IMIDC работает 24/7. Укажите IP сервера, количество текстов, используемые модели и текст ошибки, чтобы мы быстрее нашли причину.