Casos de uso y soluciones

Agrupar reseñas y tickets por temas con embeddings: descubra de qué hablan realmente sus clientes

6 pasos 18 min de lectura 50 vistas 0
Contenido

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.

Los temas son agrupaciones estadísticas, no hechos. Lea los textos de ejemplo antes de actuar a partir de un tema, y recopile textos de clientes solo de las formas que permitan las plataformas y sus obligaciones de privacidad.

Paso 1: Instalar las herramientas

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.

Paso 2: Preparar los textos

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.

Paso 3: Crear el script de temas

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.

Paso 4: Ejecutar el script

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.

Paso 5: Interpretar los temas

  • Los temas grandes con una valoración media baja son sus mayores puntos débiles.
  • Un tema que crece mes a mes indica que algo ha cambiado: un nuevo lote, proveedor, empresa de mensajería o texto del anuncio.
  • Un tema que mezcla dos asuntos significa que hay demasiado pocos temas; vuelva a ejecutar el script con un K mayor. Muchos temas diminutos y parecidos indican que K es demasiado alto.
  • Filtre texts_with_topics.csv por un tema para leer todos sus textos antes de decidir qué hacer.

Paso 6: Aprovechar los resultados

Preguntas frecuentes

¿Cuánto tarda?

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.

¿Obtendré los mismos temas cada vez?

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.

¿Funciona con textos en varios idiomas?

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.

¿Fue útil la respuesta?

Tutoriales relacionados