Las categorías predefinidas solo detectan los problemas que usted ya espera. Un nuevo lote de producción con una bisagra débil, una empresa de mensajería que pierde paquetes en una región o un paso de configuración confuso aparecen como muchas quejas redactadas de forma distinta, a menudo en varios idiomas. Los embeddings convierten cada texto en una lista de números que representa su significado, de modo que los textos sobre un mismo asunto quedan cerca unos de otros, sea cual sea su redacción o su idioma. Esta guía agrupa reseñas, tickets o respuestas de encuestas por temas con un modelo de embeddings multilingüe y clustering k-means, deja que un modelo de lenguaje local ponga nombre a cada tema y genera un informe con el tamaño de cada tema, la valoración media, citas de ejemplo y tendencias mensuales.
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
La configuración de Ollama se explica en ejecutar un LLM local con Ollama.
Use un archivo CSV en UTF-8 con una columna text. Las columnas opcionales se usan cuando existen: rating añade la valoración media por tema y date (AAAA-MM-DD) añade una tabla de temas por mes. El archivo de reseñas de analizar reseñas de clientes con un LLM local funciona sin cambios. Necesita al menos 50 textos; con varios cientos o más, los temas salen más claros.
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
El script genera los embeddings de los textos por lotes, prueba entre 3 y 15 temas (menos en archivos pequeños) y se queda con el número que mejor separa los grupos (el coeficiente de silueta); después envía al modelo de lenguaje los 12 textos más cercanos al centro de cada tema para que le ponga nombre.
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
Los resultados son topics.md, que enumera los temas de mayor a menor con su porcentaje, valoración media, descripción, citas de ejemplo y recuentos mensuales, y texts_with_topics.csv, su archivo original con una columna de tema añadida.
K mayor. Muchos temas diminutos y parecidos indican que K es demasiado alto.texts_with_topics.csv por un tema para leer todos sus textos antes de decidir qué hacer.Crear embeddings es mucho más rápido que generar texto: unos pocos miles de reseñas cortas suelen llevar minutos en una CPU multinúcleo. Poner nombre a los temas requiere una respuesta del modelo por tema.
Con los mismos datos y ajustes, sí: el clustering usa una semilla aleatoria fija y el modelo se ejecuta con temperatura 0. Los datos nuevos o un K distinto cambian los temas.
Sí. bge-m3 sitúa cerca los textos con el mismo significado aunque estén en idiomas distintos, de modo que las quejas en chino, inglés y tailandés sobre el mismo problema pueden acabar en un mismo tema. Los nombres de los temas se escriben en el idioma indicado en REPORT_LANG.
¿Sigue sin resolverlo después de seguir estos pasos? Abra un ticket de soporte y el equipo técnico de IMIDC, disponible 24/7, le ayudará. Incluya la IP del servidor, el número de textos, los modelos que usa y la salida del error para que podamos localizar el problema más rápido.