應用場景與解決方案

評價聚類發現使用者痛點:用 Embedding 向量把評價和工單自動歸類成話題

6 個步驟 12 分鐘閱讀 51 次閱讀 0
本文目錄

預先設定的分類只能找出你已經預料到的問題。比如新批次產品的鉸鏈不結實、某個地區的快遞頻繁丟件,或者某個安裝步驟讓人摸不著頭腦,這些問題往往表現為大量措辭各異的投訴,而且常常涉及多種語言。Embedding(文本向量)會把每段文本轉換成一組代表其含義的數字,因此無論措辭或語言如何,講同一件事的文本都會彼此靠近。本文用多語言 Embedding 模型和 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 的安裝配置請參閱使用 Ollama 執行本地大模型。

步驟 2:準備文本

準備一個包含 text 列的 UTF-8 編碼 CSV 檔案。以下可選列存在時會自動使用:rating 用於計算每個話題的平均評分,date(YYYY-MM-DD)用於生成按月統計的話題表。用本地大模型分析商品評價一文中的評價檔案可以直接使用。至少需要 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:把結果用起來

常見問題

需要多長時間?

生成 Embedding 比生成文本快得多:在多核 CPU 上,幾千條簡短評價通常幾分鐘就能處理完。為話題命名時,每個話題只需要模型回答一次。

每次得到的話題都一樣嗎?

資料和設定相同時,結果一樣:聚類使用固定的隨機種子,模型也以溫度 0 執行。新增資料或改變 K 都會改變話題。

支援多種語言混合嗎?

支援。bge-m3 會把不同語言中含義相同的文本放得很近,因此關於同一問題的中文、英文和泰文投訴可以歸入同一個話題。話題名稱使用 REPORT_LANG 指定的語言。

如果按以上步驟操作後問題仍未解決,請提交工單聯絡 IMIDC 7×24 技術支援,並附上伺服器 IP、文本數量、所用的模型以及報錯資訊,方便工程師快速定位。

這篇文章有幫助嗎?

相關教程