商品には Shopee、Lazada、Amazon、TikTok Shop、自社ストアなどで数百件ものレビューが集まり、複数の言語が混在することも少なくありません。星評価を見れば何かが問題になっていることはわかっても、何が問題なのかまではわかりません。この記事では、ローカルの言語モデルですべてのレビューを読み込み、全体的な感情、レビューが評価または批判している商品の観点(アスペクト)、言及されている具体的な問題を記録します。さらに商品ごとのレポートで、最も批判の多い観点、星評価と本文が一致しないレビュー、主な問題と改善案をまとめた AI の要約を確認できます。すべて自社サーバー上で動作するため、顧客の文章が第三者に送信されることはありません。
apt install -y python3-venv
python3 -m venv /opt/reviews
/opt/reviews/bin/pip install ollama numpy scikit-learn
ollama pull qwen2.5:7b
Ollama のセットアップは Ollama でローカル LLM を動かす方法で解説しています。numpy と scikit-learn はこの記事では使いませんが、関連記事のトピック分析で使用します。
レビューを UTF-8 の CSV ファイルにエクスポートします。id 列と text 列は必須です。sku、rating(1~5)、date は任意ですが、あるとレポートがより役立つものになります。
id,sku,rating,date,text
R1001,LS-01,2,2026-09-14,"The stand wobbles when I type, screws came loose after a week."
R1002,LS-01,5,2026-09-15,"Sturdy, looks great on my desk, easy to fold."
R1003,BT-07,1,2026-09-15,"左耳机三天就没声音了,客服也没回复。"
cat > /opt/reviews/reviews.py <<'EOF2'
import csv, json, os, sys
from collections import Counter, defaultdict
import ollama
MODEL = os.environ.get("MODEL", "qwen2.5:7b")
LANGUAGE = os.environ.get("REPORT_LANG", "English") # language of issues, summaries and the report
ASPECTS = ["quality", "size_fit", "function", "durability", "ease_of_use",
"delivery", "packaging", "price_value", "customer_service", "other"]
SENT = {"type": "string", "enum": ["positive", "neutral", "negative"]}
SCHEMA = {
"type": "object",
"properties": {
"sentiment": SENT,
"aspects": {"type": "array", "items": {"type": "object", "properties": {
"aspect": {"type": "string", "enum": ASPECTS}, "sentiment": SENT}, "required": ["aspect", "sentiment"]}},
"issues": {"type": "array", "items": {"type": "string"}},
"summary": {"type": "string"},
},
"required": ["sentiment", "aspects", "issues", "summary"],
}
SYSTEM = ("You analyse customer reviews for an online shop. The review can be in any language. "
"Give the overall sentiment, and the sentiment of each product aspect the review actually mentions. "
f"List concrete problems as short phrases in {LANGUAGE} (empty list if there are none) "
f"and write a one-sentence summary in {LANGUAGE}. Do not invent anything that is not in the review.")
def ask(system, text, schema=None):
r = ollama.chat(model=MODEL, format=schema, options={"temperature": 0}, messages=[
{"role": "system", "content": system}, {"role": "user", "content": text}])
return r.message.content
src, out = sys.argv[1], "reviews_tagged.csv"
done = {}
if os.path.exists(out):
with open(out, encoding="utf-8-sig") as f:
done = {r["id"]: r for r in csv.DictReader(f)}
with open(src, encoding="utf-8-sig") as f:
reviews = [r for r in csv.DictReader(f) if r.get("text", "").strip()]
fields = ["id", "sku", "rating", "sentiment", "aspects", "issues", "summary", "text"]
new_file = not os.path.exists(out)
with open(out, "a", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=fields)
if new_file:
w.writeheader()
for r in reviews:
if r["id"] in done:
continue
d = json.loads(ask(SYSTEM, r["text"][:4000], SCHEMA))
row = {"id": r["id"], "sku": r.get("sku", ""), "rating": r.get("rating", ""), "sentiment": d["sentiment"],
"aspects": "; ".join(f"{a['aspect']}:{a['sentiment']}" for a in d["aspects"]),
"issues": "; ".join(i.strip() for i in d["issues"] if i.strip()), "summary": d["summary"], "text": r["text"]}
w.writerow(row)
f.flush()
done[r["id"]] = row
print(r["id"], d["sentiment"], flush=True)
by_sku = defaultdict(list)
for row in done.values():
by_sku[row["sku"] or "-"].append(row)
with open("report.md", "w", encoding="utf-8") as f:
for sku, rows in sorted(by_sku.items(), key=lambda x: -len(x[1])):
sent = Counter(r["sentiment"] for r in rows)
neg = Counter(a.split(":")[0] for r in rows for a in r["aspects"].split("; ") if a.endswith(":negative"))
issues = [i for r in rows for i in r["issues"].split("; ") if i]
odd = [r["id"] for r in rows if r["rating"].isdigit() and ((int(r["rating"]) >= 4 and r["sentiment"] == "negative")
or (int(r["rating"]) <= 2 and r["sentiment"] == "positive"))]
f.write(f"## {sku}: {len(rows)} reviews\n\n")
f.write(f"- Positive {sent['positive']}, neutral {sent['neutral']}, negative {sent['negative']}\n")
f.write("- Most criticised aspects: " + (", ".join(f"{a} ({n})" for a, n in neg.most_common(5)) or "none") + "\n")
f.write("- Rating does not match the text: " + (", ".join(odd) or "none") + "\n\n")
if issues:
f.write(ask(f"You are a product manager. Group these customer complaints into the main problems, "
f"most frequent first, and suggest one concrete fix for each. Answer in {LANGUAGE}, briefly.",
"\n".join(issues[:300])) + "\n\n")
print("report:", sku, flush=True)
print("done: reviews_tagged.csv, report.md")
EOF2
JSON スキーマにより、モデルは決まった構造で回答します。3 種類の感情のいずれか、指定したリストに含まれる観点、そして問題点のリストです。処理済みのレビューはすぐに保存され、2 回目の実行ではスキップされるため、いつでも中断・再開できます。後から同じ CSV に新しいレビューを追加することも可能です。
cd /data/reviews
REPORT_LANG="Simplified Chinese" nohup /opt/reviews/bin/python /opt/reviews/reviews.py reviews.csv > reviews.log 2>&1 &
tail -f reviews.log
CPU サーバーの場合、7B モデルの処理時間はレビューの長さに応じて 1 件あたり数秒から数十秒です。大量のエクスポートは夜間に実行するか、MODEL=qwen2.5:3b を指定して qwen2.5:3b などの小さいモデルを使ってください。結果は reviews_tagged.csv(レビュー 1 件につき 1 行)と report.md(商品ごとに 1 セクション)に出力されます。
reviews_tagged.csv を商品とネガティブな感情で絞り込むと、各問題の元になったレビューの原文を読めます。ASPECTS のリストは、商品カテゴリーに合わせて変更してください。例えば衣料品なら fabric、size_fit、colour、電子機器なら battery、connectivity、sound などです。
毎月新しいレビューをエクスポートして同じファイルでスクリプトを実行し、レポートを時系列で比較します。想定していなかった問題を見つけるには、埋め込みで顧客の話題を把握する方法の手順でレビューをトピックに分類してください。レビューへの返信を効率化するには、AI でレビュー返信文を下書きする方法を参照してください。顧客が最も高く評価している点は、商品ページの説明文に使える良い材料にもなります。
内容が明確なレビューであれば、感情と観点の判定は信頼できます。皮肉、賛否が混在したレビュー、「ok」のような非常に短い文章は判定が難しくなります。数値を信頼する前に、ランダムに選んだ 20 件のレビューを結果と照合してください。また、回答を安定させるため temperature は 0 のままにしてください。
できます。各チケットや会話を text 列に入れ、観点を請求、配送、セットアップ、アカウントなどのサポートカテゴリーに置き換えてください。
多くの AI API は構造化出力に対応していますが、その場合レビューはサーバーの外に送信されます。事前に、プライバシーに関する義務、プロバイダーの利用規約、サービス提供地域を確認してください。
上記の手順で解決しない場合は、サポートチケットを送信して IMIDC の 24 時間 365 日対応テクニカルサポートへご連絡ください。サーバー IP、使用しているモデル、個人データを除いた CSV の先頭数行、エラー出力を添えていただくと、より迅速に調査できます。