活用シーンとソリューション

レビューをトピック分類:埋め込み(Embedding)で口コミや問い合わせの本当の話題を見つける

6 ステップ 14 分で読めます 53 回閲覧 0
目次

あらかじめ決めたカテゴリーでは、すでに想定している問題しか見つかりません。ヒンジの弱い新しい生産ロット、特定の地域で荷物を紛失する配送業者、わかりにくいセットアップ手順などは、さまざまな言い回しの不満として、しかも複数の言語で現れることがよくあります。埋め込み(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 でローカル LLM を動かす方法で解説しています。

ステップ2:テキストを用意

text 列を含む UTF-8 の CSV ファイルを使います。任意の列は、存在する場合に使用されます。rating があればトピックごとの平均評価が、date(YYYY-MM-DD)があれば月別のトピック表が追加されます。ローカル LLM でレビューを分析する方法のレビューファイルはそのまま使えます。テキストは最低 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 の範囲(ファイルが小さい場合はより少なく)で試して、グループを最もよく分けられる数(シルエットスコアで判定)を採用します。その後、各トピックの中心に最も近い 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:トピックを解釈する

  • 平均評価が低い大きなトピックは、最大の課題です。
  • 月ごとに増えているトピックは、新しいロット、仕入れ先、配送業者、商品説明文など、何かが変わったことを示しています。
  • 2 つの話題が混ざったトピックは、トピック数が少なすぎることを意味します。K を大きくして再実行してください。逆に、似たような小さなトピックが多数ある場合は K が大きすぎます。
  • 対応を決める前に、texts_with_topics.csv をトピックで絞り込み、そのトピックのテキストをすべて読んでください。

ステップ6:結果を活用する

よくある質問

処理にどのくらい時間がかかりますか?

埋め込みの作成はテキスト生成よりはるかに高速で、数千件の短いレビューであれば、マルチコア CPU で通常は数分です。トピックの命名には、トピック 1 つにつきモデルの回答 1 回分の時間がかかります。

毎回同じトピックになりますか?

データと設定が同じであれば、同じになります。クラスタリングは固定の乱数シードを使い、モデルは temperature 0 で実行されるためです。新しいデータを加えたり K を変えたりすると、トピックも変わります。

複数の言語が混在していても使えますか?

使えます。bge-m3 は言語が異なっても同じ意味の文章を近くに配置するため、同じ問題についての中国語、英語、タイ語の不満が 1 つのトピックにまとまることがあります。トピック名は REPORT_LANG で指定した言語で書かれます。

上記の手順で解決しない場合は、サポートチケットを送信して IMIDC の 24 時間 365 日対応テクニカルサポートへご連絡ください。サーバー IP、テキストの件数、使用しているモデル、エラー出力を添えていただくと、より迅速に調査できます。

この回答はお役に立ちましたか?

関連チュートリアル