Blog | Productos y tecnología | | 9 minutos de lectura

Crear una herramienta de búsqueda de tickets de asistencia con Python

Crear una herramienta de búsqueda de tickets de asistencia con Python

Principales conclusiones

  • La búsqueda semántica ayuda a los equipos de asistencia a encontrar tickets ya resueltos, incluso cuando las nuevas solicitudes utilizan una redacción completamente diferente.
  • El tutorial utiliza Python, SQL y Actian Zen V17 AI para incorporar el texto de los tickets, almacenar vectores y recuperar incidencias similares anteriores.
  • La búsqueda por similitud vectorial compara los tickets en función de su significado, en lugar de basarse en palabras clave o valores de campo exactos.
  • Las uniones SQL y los filtros de estado garantizan que los agentes solo muestren los tickets relevantes que ya cuenten con soluciones verificadas.
  • Este mismo modelo puede servir para la búsqueda en bases de conocimiento, la detección de errores duplicados y la búsqueda de respuestas en las preguntas frecuentes para la incorporación de nuevos usuarios, sin necesidad de una base de datos vectorial independiente.

Llega una nueva incidencia: «La aplicación no me deja iniciar sesión tras la última actualización». En algún lugar de tu historial hay una incidencia casi idéntica, ya resuelta, con la solución anotada. La búsqueda por palabras clave no la encontrará si la redacción no coincide. La búsqueda semántica sí lo hará, y puedes implementarla en Python puro más SQL, sin necesidad de una base de datos vectorial independiente.

El problema

Los equipos de asistencia técnica se ven obligados a resolver una y otra vez el mismo puñado de problemas porque las soluciones anteriores quedan ocultas en un sistema de tickets que solo permite búsquedas por palabras clave o por campos exactos. «No puedo iniciar sesión tras la actualización» y «La aplicación no me deja iniciar sesión tras la última actualización» son el mismo problema a ojos de un agente de asistencia, pero una consulta del tipo LIKE ‘%login%’ no las relaciona entre sí.

En este tutorial se crea una pequeña herramienta que soluciona ese problema: codifica el texto de cada ticket como un vector, lo almacena en Actian Zen V17 AI y, cuando llega un nuevo ticket, busca las coincidencias más cercanas desde el punto de vista semántico y, a continuación, filtra esas coincidencias para quedarse solo con aquellas que se resolvieron realmente.

LO QUE NECESITARÁS: Python 3.8 o superior, los paquetes pyodbc y sentence-transformers, y una base de datos de IA Actian Zen V17 accesible a través de ODBC. No se necesitan claves API; las representaciones se ejecutan localmente utilizando estos paquetes.

Descripción general de la arquitectura

Antes de escribir ningún código, aquí tienes una visión general de lo que vas a conectar: tu proceso de Python, el motor SQL de Zen V17 y la capa VDE (Vector Data Engine) que hay debajo. VDE se ejecuta como un proceso complementario incluido en Zen (localhost:6573 REST / 6574 gRPC, panel de control en el 6575).

Aplicación en Python

sentence_transformers + pyodbc

→ Motor SQL Zen V17

SRDE · dbo.vector_*

→ Motor VDE

VectorAI DB · localhost:6573

→ Tienda Vector

Colecciones del índice HNSW en disco

Python se conecta a Zen V17 AI a través de pyodbc: el motor SQL sirve de puente entre las representaciones de aprendizaje automático y la búsqueda vectorial VDE.

Cada solicitud recorre el mismo trayecto de ida y vuelta de seis saltos, tanto si estás indexando un ticket como si estás buscando uno:

1

Introducción de texto

Cadena de texto del asunto del ticket

→ 2

model.encode()

sentence_transformers → matriz de tipo float de 384 dimensiones[]

→ 3

pyodbc Ejecutar

cursor.execute(sql) a través de ODBC

→ 4

Motor Zen SQL

dbo.vector_point_upsert / search()

→ 5

Búsqueda en VDE ANN

Similitud coseno, top-K

→ 6

Resultados de Python

u64_id, puntuación, carga útil

Flujo completo de datos desde el texto sin procesar, pasando por el modelo de aprendizaje automático, la conexión ODBC, el motor Zen SQL y el motor de búsqueda VDE, hasta volver a Python.

Si lo ordenamos por responsabilidad, el mismo sistema queda así:

Capa de aplicación

Python 3.8+ · sentence_transformers · pyodbc

Capa de datos

pyodbc.connect(DSN) · cursor.execute(sql)

Capa del motor SQL

Actian Zen V17 · SRDE · Funciones dbo.vector_*

Capa del motor vectorial

VectorAI DB (VDE) · REST: 6573 · gRPC: 6574

Capa de almacenamiento

Índice HNSW · Binario Float32 · Cargas útiles JSON

Estructura de cinco capas: cada capa se encarga de una responsabilidad. Solo escribirás código para las dos primeras.

PASO 1: Instalación, conexión y autenticación

pip install pyodbc sentence-transformers

A continuación, conéctate. Algunas instancias también requieren unas credenciales de sesión VDE antes de cualquier llamada a vector_*; si es tu caso, configúralas una vez por conexión:

import pyodbc, json

from sentence_transformers import SentenceTransformer

 

# all-MiniLM-L6-v2: 384-dim, ~90MB, runs locally, ~50ms per ticket

model = SentenceTransformer('all-MiniLM-L6-v2')

 

conn = pyodbc.connect(

    'Driver={Pervasive ODBC Interface};serverName=localhost;DBQ=Demodata;'

)

cursor = conn.cursor()

 

# Only needed if your instance requires it — if this raises

# "Invalid SET statement", your instance authenticates via the

# ODBC connection itself and you can skip this line.

# cursor.execute("SET VDE_CREDENTIAL = 'your_vde_token'")

PASO 2: Crear una colección para las representaciones de las entradas

Una colección, con dimensiones adaptadas al modelo de incrustación, distancia coseno para el texto:

cursor.execute("""

    dbo.vector_collection_create (

        'ticket_embeddings',

        '{"dimension": 384, "distance_metric": "cosine"}'

    )

""")

conn.commit()

print('Collection ready')

NOTA: La dimensión se fija en el momento de la creación. Si más adelante cambias de modelo de incrustación, elimina la colección y vuelve a crearla; no es posible cambiar el tamaño in situ.

PASO 3: Indexar tus entradas actuales

Este es el historial que estamos indexando: cinco incidencias resueltas, cada una con un asunto que se incluye:

ID Asunto Estado
101 No puedo iniciar sesión tras la actualización Resuelto
102 El correo electrónico para restablecer la contraseña nunca llega Resuelto
103 No se puede descargar el PDF de la factura Resuelto
104 El pago ha sido rechazado al finalizar la compra Resuelto
105 No se ha recibido el código de autenticación de dos factores Abrir

Una misma función se encarga tanto de la incrustación como de la escritura; el proceso es el mismo, tanto si es tu primer ticket como si es el centésimo:

def index_ticket(ticket_id, subject):

    # Step A: text -> 384-dim vector, entirely local

    embedding = model.encode(subject).tolist()

 

    # Step B: upsert — inserts if new, updates if ticket_id exists.

    # Inline the JSON directly in the SQL text — dbo.vector_*

    # calls don't reliably support ODBC "?" parameter binding.

    # Escape single quotes first: real text has apostrophes

    # ("won't", "can't") that would otherwise break out of the

    # SQL string literal and raise a syntax error.

    payload = json.dumps({'subject': subject}).replace("'", "''")

    cursor.execute(f"""

        dbo.vector_point_upsert (

            'ticket_embeddings',

            '{{"u64_id": {ticket_id}, "dim": 384}}',

            '{json.dumps(embedding)}',

            '{payload}'

        )

    """)

    conn.commit()

 

index_ticket(101, "Cannot login after update")

index_ticket(102, "Password reset email never arrives")

index_ticket(103, "Invoice PDF won't download")

index_ticket(104, "Payment declined at checkout")

index_ticket(105, "Two-factor code not received")

CONSEJO DE RENDIMIENTO: Para un backlog real, codifica todas las materias en un solo modelo por lotes con model.encode().

PASO 4 – Búsqueda: Buscar entradas anteriores similares

Llega una nueva solicitud, cuya redacción no se parece en nada a la que ya figura en vuestro sistema:

def find_similar(new_ticket_text, top_k=3):

    query_embedding = model.encode(new_ticket_text).tolist()

 

    # Inline the vector directly — dbo.vector_* calls don't

    # reliably support ODBC "?" parameter binding.

    cursor.execute(f"""

        SELECT TOP ({top_k})

            a.u64_id, a."SIMILARITY_SCORE", a.payload

        FROM dbo.vector_collection_search (

            'ticket_embeddings', 'vector',

            '{json.dumps(query_embedding)}', {top_k}, 384

        ) a

        WHERE a."SIMILARITY_SCORE" > 0.5

        ORDER BY a."SIMILARITY_SCORE" DESC

    """)

    for u64_id, score, payload in cursor.fetchall():

        subj = json.loads(payload)["subject"]

        print(f"  #{u64_id}  {score:.0%}  {subj}")

 

find_similar("App won't let me sign in after the last update")

Resultado (comparado con una ejecución real de MiniLM-L6-v2):

Entrada Similitud Asunto
#101 74% No puedo iniciar sesión tras la actualización

Ninguna palabra del nuevo ticket coincide exactamente con «login», «cannot» o «update»: el modelo ha realizado la coincidencia en función del significado, no del vocabulario. Esa es precisamente la ventaja que ofrece la búsqueda semántica frente a la búsqueda por palabras clave para esta tarea.

NOTA: Merece la pena fijarse en la diferencia real de puntuación: el ticket n.º 101 obtiene una puntuación de 0,74, pero el siguiente más cercano (el n.º 102, «El correo electrónico para restablecer la contraseña nunca llega») solo obtiene una puntuación de 0,32, muy por debajo del umbral de 0,5, por lo que, como es lógico, no aparece. Una diferencia tan clara como esa entre la coincidencia real y el resto es lo que hace que un umbral fijo funcione bien; en el catálogo de productos del tutorial de SQL complementario, las puntuaciones estaban mucho más agrupadas y se necesitaba un umbral más bajo. Comprueba siempre la diferencia en tus propios datos antes de elegir un número.

PASO 5 – Mostrar únicamente los tickets que se hayan resuelto realmente

Un ticket con un contenido similar que aún esté abierto no sirve de ayuda a un agente. Realiza una unión (JOIN) de los resultados del vector con tu tabla de tickets y filtra por estado, igual que en cualquier otra consulta SQL:

def find_resolved_matches(new_ticket_text, top_k=3):

    query_embedding = model.encode(new_ticket_text).tolist()

 

    cursor.execute(f"""

        SELECT TOP ({top_k})

            t.ticket_id, t.subject, t.resolution_notes,

            sr."SIMILARITY_SCORE"

        FROM dbo.vector_collection_search (

            'ticket_embeddings', 'vector', '{json.dumps(query_embedding)}',

            {top_k * 2}, 384    -- over-fetch for filter headroom

        ) sr

        INNER JOIN tickets t

            ON CAST(sr.u64_id AS INTEGER) = t.ticket_id

        WHERE sr."SIMILARITY_SCORE" > 0.5

          AND t.status = 'Resolved'

        ORDER BY sr."SIMILARITY_SCORE" DESC

    """)

    return cursor.fetchall()

En la consulta de inicio de sesión anterior, solo el ticket n.º 101 supera el umbral de similitud, por lo que esta búsqueda en concreto no activa el filtro de estado. Pero no hace falta recurrir a un ejemplo hipotético para comprender por qué es importante. Prueba con otro ticket recibido:

find_resolved_matches("verification code never received")

El ticket n.º 105 («No se ha recibido el código de doble factorización», aún abierto) es, con diferencia, el que más se ajusta semánticamente, con una similitud de 0,52, muy por encima del umbral de 0,5 y muy por delante de todos los demás tickets. Si se realiza una búsqueda sencilla, sin filtros, aparece en el primer puesto:

Entrada Similitud Estado
#105 52% Abrir
#102 40% Resuelto
#104 32% Resuelto

Sin embargo, si ejecutas `find_resolved_matches()` sobre el mismo texto, el n.º 105 desaparece por completo, Y `t.status = ‘Resolved’` lo elimina aunque sea la mejor coincidencia por similitud de todo el backlog. No se trata de una hipótesis: es lo que hace realmente la operación `JOIN` en el momento en que un ticket abierto real obtiene una buena puntuación. El filtro relacional hace exactamente lo que siempre hace una cláusula WHERE; simplemente filtra la salida de la IA en lugar de una columna normal.

En qué otros casos se aplica este patrón

Si cambias el asunto del ticket por otro campo de texto breve, las mismas cinco funciones se mantienen sin cambios:

  • Búsqueda en la base de conocimientos interna, inclusión de títulos y resúmenes de artículos, búsqueda a partir de la pregunta de un empleado.
  • Detectar incidencias duplicadas, integrar nuevos informes de incidencias y señalar cualquier caso con una similitud superior a 0,90 con una incidencia abierta existente antes de que se registre por segunda vez.
  • Búsqueda de preguntas frecuentes en la incorporación: integra la pregunta de un nuevo empleado en Slack y muestra la respuesta existente más relevante, en lugar de recurrir primero a una persona.

Cómo elegir tu modelo de incrustación

«all-MiniLM-L6-v2» es la configuración predeterminada ideal para textos breves, como los asuntos de los tickets: es lo suficientemente rápido y ligero como para ejecutarse en un portátil. Cámbiala a medida que cambien tus necesidades de volumen o precisión:

Modelo Dimensiones Velocidad Ideal para
all-MiniLM-L6-v2 384 ~50 ms por mensaje Uso general — este tutorial
parafraseo-MiniLM-L3-v2 384 ~20 ms/mensaje Gran volumen de tickets, menor precisión
all-mpnet-base-v2 768 ~120 ms/texto Triaje de mayor precisión
text-embedding-ada-002 1536 ~200 ms por llamada La mayor precisión (se necesita una clave API)

NOTA: Sea cual sea el modelo que elijas, crea la colección Zen con las dimensiones exactas de ese modelo. Las dimensiones son inalterables una vez creadas: mezclar modelos entre la inserción y la búsqueda genera silenciosamente puntuaciones sin sentido.

Lo que has construido

En cinco pasos, has indexado una lista de tickets pendientes como vectores, has realizado búsquedas en ella utilizando texto en inglés coloquial en lugar de palabras clave y has filtrado los resultados para mostrar únicamente las soluciones que un agente puede realmente asignar, todo ello con pyodbc y SQL estándar, sin necesidad de una base de datos de vectores ni de un cliente REST independientes.

Desde aquí, integra la función `find_resolved_matches()` en tu webhook de creación de tickets para que las soluciones sugeridas aparezcan en el momento en que se cree un nuevo ticket, y cambia las llamadas individuales a `index_ticket()` por una operación de «upsert» por lotes una vez que estés completando un historial real en lugar de cinco filas de muestra.