应用场景与解决方案

评价聚类发现用户痛点:用 Embedding 向量把评价和工单自动归类成话题

6 个步骤 12 分钟阅读 55 次阅读 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、文本数量、所用的模型以及报错信息,方便工程师快速定位。

这篇文章有帮助吗?

相关教程