预先设定的分类只能找出你已经预料到的问题。比如新批次产品的铰链不结实、某个地区的快递频繁丢件,或者某个安装步骤让人摸不着头脑,这些问题往往表现为大量措辞各异的投诉,而且常常涉及多种语言。Embedding(文本向量)会把每段文本转换成一组代表其含义的数字,因此无论措辞或语言如何,讲同一件事的文本都会彼此靠近。本文用多语言 Embedding 模型和 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 的安装配置请参阅使用 Ollama 运行本地大模型。
准备一个包含 text 列的 UTF-8 编码 CSV 文件。以下可选列存在时会自动使用:rating 用于计算每个话题的平均评分,date(YYYY-MM-DD)用于生成按月统计的话题表。用本地大模型分析商品评价一文中的评价文件可以直接使用。至少需要 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 中按话题筛选,阅读该话题下的全部文本后再决定如何处理。生成 Embedding 比生成文本快得多:在多核 CPU 上,几千条简短评价通常几分钟就能处理完。为话题命名时,每个话题只需要模型回答一次。
数据和设置相同时,结果一样:聚类使用固定的随机种子,模型也以温度 0 运行。新增数据或改变 K 都会改变话题。
支持。bge-m3 会把不同语言中含义相同的文本放得很近,因此关于同一问题的中文、英文和泰文投诉可以归入同一个话题。话题名称使用 REPORT_LANG 指定的语言。
如果按以上步骤操作后问题仍未解决,请提交工单联系 IMIDC 7×24 技术支持,并附上服务器 IP、文本数量、所用的模型以及报错信息,方便工程师快速定位。