預先設定的分類只能找出你已經預料到的問題。比如新批次產品的鉸鏈不結實、某個地區的快遞頻繁丟件,或者某個安裝步驟讓人摸不著頭腦,這些問題往往表現為大量措辭各異的投訴,而且常常涉及多種語言。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、文本數量、所用的模型以及報錯資訊,方便工程師快速定位。