No hay duda de que ChatGPT de OpenAI es excepcionalmente inteligente: ha superado la prueba de la barra de abogados , posee conocimientos similares a los de un médico y algunas pruebas han registrado su coeficiente intelectual en 155 . Sin embargo, tiende a fabricar información en lugar de conceder ignorancia. Esta tendencia, junto con el hecho de que su conocimiento cesa en 2021, plantea desafíos en la creación de productos especializados utilizando la API de GPT.
¿Cómo podemos superar estos obstáculos? ¿Cómo podemos impartir nuevos conocimientos a un modelo como GPT-3? Mi objetivo es abordar estas preguntas mediante la construcción de un bot de respuesta a preguntas que emplee Python, la API de OpenAI e incrustaciones de palabras.
Tengo la intención de crear un bot que genere canalizaciones de integración continua a partir de un indicador que, como sabrá, están formateadas con YAML en Semaphore CI/CD.
Aquí hay un ejemplo del bot en acción:
Captura de pantalla del programa en ejecución. En la pantalla, se ejecuta el comando python query.py "Create a CI pipeline that builds and uploads a Docker image to Docker Hub"
y el programa imprime YAML correspondiente a una canalización de CI que realiza la acción solicitada.
En el espíritu de proyectos como DocsGPT , My AskAI y Libraria , planeo "enseñar" al modelo GPT-3 sobre Semaphore y cómo generar archivos de configuración de canalización. Lo lograré aprovechando la documentación existente .
No asumiré un conocimiento previo de la creación de bots y mantendré un código limpio para que pueda adaptarlo a sus requisitos.
No necesita experiencia en la codificación de un bot o conocimiento de redes neuronales para seguir este tutorial. Sin embargo, necesitará:
ChatGPT, o más exactamente, GPT-3 y GPT-4, los modelos de lenguaje grande (LLM) que los impulsan, se entrenaron en un conjunto de datos masivo con una fecha límite alrededor de septiembre de 2021.
En esencia, GPT-3 sabe muy poco sobre eventos posteriores a esa fecha. Podemos verificar esto con un simple aviso:
ChatGPT no sabe quién ganó la Copa del Mundo en 2022.
Si bien algunos modelos de OpenAI pueden someterse a ajustes finos , los modelos más avanzados, como los que estaban interesados, no pueden; no podemos aumentar sus datos de entrenamiento.
¿Cómo podemos obtener respuestas de GPT-3 más allá de sus datos de entrenamiento? Un método consiste en explotar sus habilidades de comprensión de texto; al mejorar el mensaje con el contexto relevante, es probable que podamos obtener la respuesta correcta.
En el siguiente ejemplo, proporciono el contexto del sitio oficial de la FIFA y la respuesta difiere significativamente:
Con el contexto proporcionado, ChatGPT puede responder con precisión.
Podemos deducir que el modelo puede responder a cualquier mensaje si se le da suficiente contexto relevante. La pregunta sigue siendo: ¿cómo podemos saber qué es relevante dada una indicación arbitraria? Para abordar esto, necesitamos explorar qué son las incrustaciones de palabras .
En el contexto de los modelos de lenguaje, una incrustación es una forma de representar palabras, oraciones o documentos completos como vectores o listas de números.
Para calcular las incrustaciones, necesitaremos una red neuronal como word2vec o text-embedding-ada-002 . Estas redes han sido entrenadas con cantidades masivas de texto y pueden encontrar relaciones entre palabras analizando las frecuencias con las que aparecen patrones específicos en los datos de entrenamiento.
Digamos que tenemos las siguientes palabras:
Imagina que usamos una de estas redes de incrustación para calcular los vectores de cada palabra. Por ejemplo:
Palabra | Vector | Contexto |
---|---|---|
Gato | [0,1, 0,2, 0,3, 0,4, 0,5] | Animales, objetos, cosas pequeñas. |
Perro | [0,6, 0,7, 0,8, 0,9, 1,0] | Animales, objetos, cosas grandes. |
Pelota | [0,2, 0,4, 0,6, 0,8, 1,0] | Objetos, juguetes, cosas pequeñas. |
Casa | [0,3, 0,6, 0,9, 1,2, 1,5] | Edificios, casas, cosas grandes. |
Una vez que tenemos los vectores para cada palabra, podemos usarlos para representar el significado del texto. Por ejemplo, la oración "El gato persiguió la pelota" se puede representar como el vector [0.1, 0.2, 0.3, 0.4, 0.5] + [0.2, 0.4, 0.6, 0.8, 1.0] = [0.3, 0.6, 0.9, 1.2, 1.5]. Este vector representa una oración que trata sobre un animal que persigue un objeto.
Las incrustaciones de palabras se pueden visualizar como espacios multidimensionales donde las palabras u oraciones con significados similares están juntas. Podemos calcular la "distancia" entre vectores para encontrar significados similares para cualquier texto de entrada.
Representación 3D de incrustaciones como espacios vectoriales. En realidad, estos espacios pueden tener cientos o miles de dimensiones. Fuente: Conozca la multiherramienta de AI: incrustaciones de vectores
Las matemáticas reales detrás de todo esto están más allá del alcance de este artículo. Sin embargo, la conclusión clave es que las operaciones vectoriales nos permiten manipular o determinar el significado usando las matemáticas . Tome el vector que representa la palabra "reina", réstele el vector "mujer" y agregue el vector "hombre". El resultado debería ser un vector en la vecindad de "rey". Si agregamos "hijo", deberíamos acercarnos a "príncipe".
Hasta ahora, hemos discutido la incorporación de redes neuronales que toman palabras como entradas y números como salidas. Sin embargo, muchas redes modernas han pasado de procesar palabras a procesar tokens.
Un token es la unidad de texto más pequeña que puede procesar el modelo. Los tokens pueden ser palabras, caracteres, signos de puntuación, símbolos o partes de palabras.
Podemos ver cómo las palabras se convierten en tokens experimentando con el tokenizador en línea de OpenAI , que utiliza la codificación de pares de bytes (BPE) para convertir texto en tokens y representar cada uno con un número:
A menudo hay una relación de 1 a 1 entre tokens y palabras. La mayoría de las fichas incluyen la palabra y un espacio inicial. Sin embargo, hay casos especiales como "incrustación", que consta de dos tokens, "incrustación" y "ding", o "capacidades", que consta de cuatro tokens. Si hace clic en "ID de token", puede ver la representación numérica del modelo de cada token.
Ahora que entendemos qué son las incrustaciones, la siguiente pregunta es: ¿cómo pueden ayudarnos a construir un bot más inteligente?
Primero, consideremos qué sucede cuando usamos la API GPT-3 directamente. El usuario emite un aviso y el modelo responde lo mejor que puede.
Sin embargo, cuando agregamos contexto a la ecuación, las cosas cambian. Por ejemplo, cuando le pregunté a ChatGPT sobre el ganador de la Copa del Mundo después de proporcionar contexto, marcó la diferencia.
Entonces, el plan para construir un bot más inteligente es el siguiente:
Comencemos como la mayoría de los proyectos, diseñando la base de datos.
Nuestra base de datos de contexto debe incluir la documentación original y sus respectivos vectores. En principio, podemos emplear cualquier tipo de base de datos para esta tarea, pero una base de datos vectorial es la herramienta óptima para el trabajo.
Las bases de datos vectoriales son bases de datos especializadas diseñadas para almacenar y recuperar datos vectoriales de alta dimensión. En lugar de emplear un lenguaje de consulta como SQL para buscar, proporcionamos un vector y solicitamos los N vecinos más cercanos.
Para generar los vectores, utilizaremos text-embedding-ada-002 de OpenAI, ya que es el modelo más rápido y rentable que ofrecen. El modelo convierte el texto de entrada en fichas y utiliza un mecanismo de atención conocido como Transformador para aprender sus relaciones. La salida de esta red neuronal son vectores que representan el significado del texto.
Para crear una base de datos de contexto, haré lo siguiente:
Primero, debo inicializar un archivo de entorno con la clave API de OpenAI. Este archivo nunca debe comprometerse con el control de versiones, ya que la clave API es privada y está vinculada a su cuenta.
export OPENAI_API_KEY=YOUR_API_KEY
A continuación, crearé un virtualenv para mi aplicación de Python:
$ virtualenv venv $ source venv/bin/activate $ source .env
E instale el paquete OpenAI:
```bash $ pip install openai numpy
Intentemos calcular la incrustación de la cadena "Docker Container". Puede ejecutar esto en Python REPL o como un script de Python:
$ python >>> import openai >>> embeddings = openai.Embedding.create(input="Docker Containers", engine="text-embedding-ada-002") >>> embeddings JSON: { "data": [ { "embedding": [ -0.00530336843803525, 0.0013223182177171111, ... 1533 more items ..., -0.015645816922187805 ], "index": 0, "object": "embedding" } ], "model": "text-embedding-ada-002-v2", "object": "list", "usage": { "prompt_tokens": 2, "total_tokens": 2 } }
Como puede ver, el modelo de OpenAI responde con una lista embedding
que contiene 1536 elementos, el tamaño del vector para text-incrustar-ada-002.
Si bien hay varios motores de bases de datos vectoriales para elegir, como Chroma , que es de código abierto, elegí Pinecone porque es una base de datos administrada con un nivel gratuito, lo que simplifica las cosas. Su plan de inicio es más que capaz de manejar todos los datos que necesitaré.
Después de crear mi cuenta de Pinecone y recuperar mi clave de API y mi entorno, agrego ambos valores a mi archivo .env
.
Ahora .env
debería contener mis secretos de Pinecone y OpenAI.
export OPENAI_API_KEY=YOUR_API_KEY # Pinecone secrets export PINECONE_API_KEY=YOUR_API_KEY export PINECONE_ENVIRONMENT=YOUR_PINECONE_DATACENTER
Luego, instalo el cliente Pinecone para Python:
$ pip install pinecone-client
Necesito inicializar una base de datos; estos son los contenidos del script db_create.py
:
# db_create.py import pinecone import openai import os index_name = "semaphore" embed_model = "text-embedding-ada-002" api_key = os.getenv("PINECONE_API_KEY") env = os.getenv("PINECONE_ENVIRONMENT") pinecone.init(api_key=api_key, environment=env) embedding = openai.Embedding.create( input=[ "Sample document text goes here", "there will be several phrases in each batch" ], engine=embed_model ) if index_name not in pinecone.list_indexes(): print("Creating pinecone index: " + index_name) pinecone.create_index( index_name, dimension=len(embedding['data'][0]['embedding']), metric='cosine', metadata_config={'indexed': ['source', 'id']} )
El script puede tardar unos minutos en crear la base de datos.
$ python db_create.py
A continuación, instalaré el paquete tiktoken . Lo usaré para calcular cuántos tokens tienen los documentos de origen. Esto es importante porque el modelo de incrustación solo puede manejar hasta 8191 tokens.
$ pip install tiktoken
Mientras instalamos paquetes, instalemos también tqdm
para producir una barra de progreso atractiva.
$ pip install tqdm
Ahora necesito subir los documentos a la base de datos. El script para esto se llamará index_docs.py
. Comencemos importando los módulos requeridos y definiendo algunas constantes:
# index_docs.py # Pinecone db name and upload batch size index_name = 'semaphore' upsert_batch_size = 20 # OpenAI embedding and tokenizer models embed_model = "text-embedding-ada-002" encoding_model = "cl100k_base" max_tokens_model = 8191
A continuación, necesitaremos una función para contar tokens. Hay un ejemplo de contador de fichas en la página de OpenAI:
import tiktoken def num_tokens_from_string(string: str) -> int: """Returns the number of tokens in a text string.""" encoding = tiktoken.get_encoding(encoding_model) num_tokens = len(encoding.encode(string)) return num_tokens
Finalmente, necesitaré algunas funciones de filtrado para convertir el documento original en ejemplos utilizables. La mayoría de los ejemplos en la documentación están entre cercas de código, así que solo extraeré todo el código YAML de cada archivo:
import re def extract_yaml(text: str) -> str: """Returns list with all the YAML code blocks found in text.""" matches = [m.group(1) for m in re.finditer("```yaml([\w\W]*?)```", text)] return matches
He terminado con las funciones. A continuación, esto cargará los archivos en la memoria y extraerá los ejemplos:
from tqdm import tqdm import sys import os import pathlib repo_path = sys.argv[1] repo_path = os.path.abspath(repo_path) repo = pathlib.Path(repo_path) markdown_files = list(repo.glob("**/*.md")) + list( repo.glob("**/*.mdx") ) print(f"Extracting YAML from Markdown files in {repo_path}") new_data = [] for i in tqdm(range(0, len(markdown_files))): markdown_file = markdown_files[i] with open(markdown_file, "r") as f: relative_path = markdown_file.relative_to(repo_path) text = str(f.read()) if text == '': continue yamls = extract_yaml(text) j = 0 for y in yamls: j = j+1 new_data.append({ "source": str(relative_path), "text": y, "id": f"github.com/semaphore/docs/{relative_path}[{j}]" })
En este punto, todos los YAML deben almacenarse en la lista new_data
. El paso final es cargar las incrustaciones en Pinecone.
import pinecone import openai api_key = os.getenv("PINECONE_API_KEY") env = os.getenv("PINECONE_ENVIRONMENT") pinecone.init(api_key=api_key, enviroment=env) index = pinecone.Index(index_name) print(f"Creating embeddings and uploading vectors to database") for i in tqdm(range(0, len(new_data), upsert_batch_size)): i_end = min(len(new_data), i+upsert_batch_size) meta_batch = new_data[i:i_end] ids_batch = [x['id'] for x in meta_batch] texts = [x['text'] for x in meta_batch] embedding = openai.Embedding.create(input=texts, engine=embed_model) embeds = [record['embedding'] for record in embedding['data']] # clean metadata before upserting meta_batch = [{ 'id': x['id'], 'text': x['text'], 'source': x['source'] } for x in meta_batch] to_upsert = list(zip(ids_batch, embeds, meta_batch)) index.upsert(vectors=to_upsert)
Como referencia, puede encontrar el archivo index_docs.py completo en el repositorio de demostración
Ejecutemos el script de índice para terminar con la configuración de la base de datos:
$ git clone https://github.com/semaphoreci/docs.git /tmp/docs $ source .env $ python index_docs.py /tmp/docs
El tablero de Pinecone debe mostrar vectores en la base de datos.
Podemos consultar la base de datos con el siguiente código, que puede ejecutar como un script o directamente en Python REPL:
$ python >>> import os >>> import pinecone >>> import openai # Compute embeddings for string "Docker Container" >>> embeddings = openai.Embedding.create(input="Docker Containers", engine="text-embedding-ada-002") # Connect to database >>> index_name = "semaphore" >>> api_key = os.getenv("PINECONE_API_KEY") >>> env = os.getenv("PINECONE_ENVIRONMENT") >>> pinecone.init(api_key=api_key, environment=env) >>> index = pinecone.Index(index_name) # Query database >>> matches = index.query(embeddings['data'][0]['embedding'], top_k=1, include_metadata=True) >>> matches['matches'][0] {'id': 'github.com/semaphore/docs/docs/ci-cd-environment/docker-authentication.md[3]', 'metadata': {'id': 'github.com/semaphore/docs/docs/ci-cd-environment/docker-authentication.md[3]', 'source': 'docs/ci-cd-environment/docker-authentication.md', 'text': '\n' '# .semaphore/semaphore.yml\n' 'version: v1.0\n' 'name: Using a Docker image\n' 'agent:\n' ' machine:\n' ' type: e1-standard-2\n' ' os_image: ubuntu1804\n' '\n' 'blocks:\n' ' - name: Run container from Docker Hub\n' ' task:\n' ' jobs:\n' ' - name: Authenticate docker pull\n' ' commands:\n' ' - checkout\n' ' - echo $DOCKERHUB_PASSWORD | docker login ' '--username "$DOCKERHUB_USERNAME" --password-stdin\n' ' - docker pull /\n' ' - docker images\n' ' - docker run /\n' ' secrets:\n' ' - name: docker-hub\n'}, 'score': 0.796259582, 'values': []}
Como puede ver, la primera coincidencia es el YAML para una canalización de Semaphore que extrae una imagen de Docker y la ejecuta. Es un buen comienzo ya que es relevante para nuestra cadena de búsqueda "Contenedores Docker".
Tenemos los datos y sabemos cómo consultarlos. Pongámoslo a trabajar en el bot.
Los pasos para procesar el aviso son:
Como de costumbre, comenzaré definiendo algunas constantes en complete.py
, el script principal del bot:
# complete.py # Pinecone database name, number of matched to retrieve # cutoff similarity score, and how much tokens as context index_name = 'semaphore' context_cap_per_query = 30 match_min_score = 0.75 context_tokens_per_query = 3000 # OpenAI LLM model parameters chat_engine_model = "gpt-3.5-turbo" max_tokens_model = 4096 temperature = 0.2 embed_model = "text-embedding-ada-002" encoding_model_messages = "gpt-3.5-turbo-0301" encoding_model_strings = "cl100k_base" import pinecone import os # Connect with Pinecone db and index api_key = os.getenv("PINECONE_API_KEY") env = os.getenv("PINECONE_ENVIRONMENT") pinecone.init(api_key=api_key, environment=env) index = pinecone.Index(index_name)
A continuación, agregaré funciones para contar tokens como se muestra en los ejemplos de OpenAI . La primera función cuenta tokens en una cadena, mientras que la segunda cuenta tokens en mensajes. Veremos los mensajes en detalle en un momento. Por ahora, digamos que es una estructura que mantiene el estado de la conversación en la memoria.
import tiktoken def num_tokens_from_string(string: str) -> int: """Returns the number of tokens in a text string.""" encoding = tiktoken.get_encoding(encoding_model_strings) num_tokens = len(encoding.encode(string)) return num_tokens def num_tokens_from_messages(messages): """Returns the number of tokens used by a list of messages. Compatible with model """ try: encoding = tiktoken.encoding_for_model(encoding_model_messages) except KeyError: encoding = tiktoken.get_encoding(encoding_model_strings) num_tokens = 0 for message in messages: num_tokens += 4 # every message follows {role/name}\n{content}\n for key, value in message.items(): num_tokens += len(encoding.encode(value)) if key == "name": # if there's a name, the role is omitted num_tokens += -1 # role is always required and always 1 token num_tokens += 2 # every reply is primed with assistant return num_tokens
La siguiente función toma el mensaje original y las cadenas de contexto para devolver un mensaje enriquecido para GPT-3:
def get_prompt(query: str, context: str) -> str: """Return the prompt with query and context.""" return ( f"Create the continuous integration pipeline YAML code to fullfil the requested task.\n" + f"Below you will find some context that may help. Ignore it if it seems irrelevant.\n\n" + f"Context:\n{context}" + f"\n\nTask: {query}\n\nYAML Code:" )
La función get_message
formatea el indicador en un formato compatible con la API:
def get_message(role: str, content: str) -> dict: """Generate a message for OpenAI API completion.""" return {"role": role, "content": content}
Hay tres tipos de roles que afectan la forma en que reacciona el modelo:
Ahora para la parte atractiva. La función get_context
toma el indicador, consulta la base de datos y genera una cadena de contexto hasta que se cumple una de estas condiciones:
context_tokens_per_query
, el espacio que reservé para context.match_min_score
se ignoran. import openai def get_context(query: str, max_tokens: int) -> list: """Generate message for OpenAI model. Add context until hitting `context_token_limit` limit. Returns prompt string.""" embeddings = openai.Embedding.create( input=[query], engine=embed_model ) # search the database vectors = embeddings['data'][0]['embedding'] embeddings = index.query(vectors, top_k=context_cap_per_query, include_metadata=True) matches = embeddings['matches'] # filter and aggregate context usable_context = "" context_count = 0 for i in range(0, len(matches)): source = matches[i]['metadata']['source'] if matches[i]['score'] < match_min_score: # skip context with low similarity score continue context = matches[i]['metadata']['text'] token_count = num_tokens_from_string(usable_context + '\n---\n' + context) if token_count < context_tokens_per_query: usable_context = usable_context + '\n---\n' + context context_count = context_count + 1 print(f"Found {context_count} contexts for your query") return usable_context
La función siguiente y final, complete
, emite la solicitud de API a OpenAI y devuelve la respuesta del modelo.
def complete(messages): """Query the OpenAI model. Returns the first answer. """ res = openai.ChatCompletion.create( model=chat_engine_model, messages=messages, temperature=temperature ) return res.choices[0].message.content.strip()
Eso es todo; ahora solo tengo que lidiar con los argumentos de la línea de comando y llamar a las funciones en el orden correcto:
import sys query = sys.argv[1] context = get_context(query, context_tokens_per_query) prompt = get_prompt(query, context) # initialize messages list to send to OpenAI API messages = [] messages.append(get_message('user', prompt)) messages.append(get_message('system', 'You are a helpful assistant that writes YAML code for Semaphore continuous integration pipelines and explains them. Return YAML code inside code fences.')) if num_tokens_from_messages(messages) >= max_tokens_model: raise Exception('Model token size limit reached') print("Working on your query... ") answer = complete(messages) print("Answer:\n") print(answer) messages.append(get_message('assistant', answer))
Es hora de ejecutar el script y ver cómo le va:
$ python complete.py "Create a CI pipeline that builds and uploads a Docker image to Docker Hub"
El resultado es:
version: v1.0 name: Docker Build and Push agent: machine: type: e1-standard-2 os_image: ubuntu1804 blocks: - name: "Build and Push Docker Image" task: jobs: - name: "Docker Build and Push" commands: - checkout - docker build -t /: . - echo "$DOCKERHUB_PASSWORD" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin - docker push /: promotions: - name: Deploy to production pipeline_file: deploy-production.yml auto_promote: when: "result = 'passed' and branch = 'master'"
Este es el primer buen resultado. El modelo ha inferido la sintaxis de los ejemplos de contexto que proporcionamos.
Recuerde que comencé con un objetivo modesto: crear un asistente para escribir canalizaciones YAML. Con un contenido más rico en mi base de datos de vectores, puedo generalizar el bot para responder a cualquier pregunta sobre Semaphore (o cualquier producto; ¿recuerda haber clonado los documentos en /tmp
?).
La clave para obtener buenas respuestas es, como era de esperar, el contexto de calidad. Es poco probable que simplemente cargar cada documento en la base de datos de vectores produzca buenos resultados. La base de datos de contexto debe ser curada, etiquetada con metadatos descriptivos y ser concisa. De lo contrario, corremos el riesgo de llenar la cuota de tokens en el aviso con un contexto irrelevante.
Entonces, en cierto sentido, hay un arte, y una gran cantidad de prueba y error, involucrados en ajustar el bot para satisfacer nuestras necesidades. Podemos experimentar con el límite de contexto, eliminar contenido de baja calidad, resumir y filtrar el contexto irrelevante ajustando el puntaje de similitud.
Es posible que haya notado que mi bot no nos permite tener una conversación real como ChatGPT. Hacemos una pregunta y obtenemos una respuesta.
Convertir el bot en un chatbot completo no es, en principio, demasiado desafiante. Podemos mantener la conversación reenviando respuestas anteriores al modelo con cada solicitud de API. Las respuestas anteriores de GPT-3 se devuelven bajo el rol de "asistente". Por ejemplo:
messages = [] while True: query = input('Type your prompt:\n') context = get_context(query, context_tokens_per_query) prompt = get_prompt(query, context) messages.append(get_message('user', prompt)) messages.append(get_message('system', 'You are a helpful assistant that writes YAML code for Semaphore continuous integration pipelines and explains them. Return YAML code inside code fences.')) if num_tokens_from_messages(messages) >= max_tokens_model: raise Exception('Model token size limit reached') print("Working on your query... ") answer = complete(messages) print("Answer:\n") print(answer) # remove system message and append model's answer messages.pop() messages.append(get_message('assistant', answer))
Desafortunadamente, esta implementación es bastante rudimentaria. No admitirá conversaciones extendidas ya que el recuento de tokens aumenta con cada interacción. Muy pronto, alcanzaremos el límite de 4096 tokens para GPT-3, lo que evitará más diálogo.
Entonces, tenemos que encontrar alguna forma de mantener la solicitud dentro de los límites del token. Siguen algunas estrategias:
Es posible mejorar las respuestas del bot con incrustaciones de palabras y una buena base de datos de contexto. Para lograr esto, necesitamos documentación de buena calidad. Hay una cantidad sustancial de prueba y error involucrada en el desarrollo de un bot que aparentemente posee una comprensión del tema.
Espero que esta exploración en profundidad de las incrustaciones de palabras y los grandes modelos de lenguaje lo ayude a crear un bot más potente, personalizado según sus requisitos.
¡Feliz edificio!
También publicado aquí .