Não há dúvida de que o ChatGPT da OpenAI é excepcionalmente inteligente - passou no teste de advogado , possui conhecimento semelhante ao de um médico e alguns testes marcaram seu QI em 155 . No entanto, tende a fabricar informações em vez de conceder ignorância. Essa tendência, aliada ao fato de seu conhecimento cessar em 2021, impõe desafios na construção de produtos especializados usando a API GPT.
Como podemos superar esses obstáculos? Como podemos transmitir novos conhecimentos a um modelo como o GPT-3? Meu objetivo é abordar essas questões construindo um bot de resposta a perguntas empregando Python, a API OpenAI e incorporação de palavras.
Pretendo criar um bot que gere pipelines de integração contínua a partir de um prompt, que, como você deve saber, são formatados com YAML em Semaphore CI/CD.
Aqui está um exemplo do bot em ação:
Captura de tela do programa em execução. Na tela, o comando python query.py "Create a CI pipeline that builds and uploads a Docker image to Docker Hub"
é executado e o programa imprime o YAML correspondente a um CI pipeline que executa a ação solicitada.
No espírito de projetos como DocsGPT , My AskAI e Libraria , pretendo "ensinar" o modelo GPT-3 sobre Semaphore e como gerar arquivos de configuração de pipeline. Vou conseguir isso aproveitando a documentação existente .
Não assumirei conhecimento prévio de construção de bot e manterei o código limpo para que você possa adaptá-lo às suas necessidades.
Você não precisa de experiência em codificar um bot ou conhecimento de redes neurais para seguir este tutorial. No entanto, você precisará de:
O ChatGPT, ou mais precisamente, GPT-3 e GPT-4, os Large Language Models (LLMs) que os alimentam, foram treinados em um enorme conjunto de dados com uma data limite em setembro de 2021.
Em essência, o GPT-3 sabe muito pouco sobre eventos além dessa data. Podemos verificar isso com um prompt simples:
ChatGPT não sabe quem ganhou a Copa do Mundo em 2022.
Enquanto alguns modelos OpenAI podem sofrer ajustes finos , os modelos mais avançados, como os que nos interessam, não podem; não podemos aumentar seus dados de treinamento.
Como podemos obter respostas do GPT-3 além de seus dados de treinamento? Um método envolve explorar suas habilidades de compreensão de texto; aprimorando o prompt com contexto relevante, provavelmente podemos obter a resposta correta.
No exemplo abaixo, forneço contexto do site oficial da FIFA e a resposta difere significativamente:
Com o contexto fornecido, o ChatGPT pode responder com precisão.
Podemos deduzir que o modelo pode responder a qualquer solicitação se for dado um contexto relevante o suficiente. A questão permanece: como podemos saber o que é relevante dado um prompt arbitrário? Para resolver isso, precisamos explorar o que são incorporações de palavras .
No contexto dos modelos de linguagem, uma incorporação é uma forma de representar palavras, sentenças ou documentos inteiros como vetores ou listas de números.
Para calcular os embeddings, precisaremos de uma rede neural como word2vec ou text-embedding-ada-002 . Essas redes foram treinadas em grandes quantidades de texto e podem encontrar relações entre palavras analisando as frequências com as quais padrões específicos aparecem nos dados de treinamento.
Digamos que temos as seguintes palavras:
Imagine que usamos uma dessas redes de incorporação para calcular os vetores para cada palavra. Por exemplo:
Palavra | Vetor | Contexto |
---|---|---|
Gato | [0,1, 0,2, 0,3, 0,4, 0,5] | Animais, objetos, pequenas coisas |
Cachorro | [0,6, 0,7, 0,8, 0,9, 1,0] | Animais, objetos, coisas grandes |
Bola | [0,2, 0,4, 0,6, 0,8, 1,0] | Objetos, brinquedos, pequenas coisas |
Casa | [0,3, 0,6, 0,9, 1,2, 1,5] | Prédios, casas, coisas grandes |
Assim que tivermos os vetores para cada palavra, podemos usá-los para representar o significado do texto. Por exemplo, a frase “O gato perseguiu a bola” pode ser representada como o vetor [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 vetor representa uma frase sobre um animal perseguindo um objeto.
Embeddings de palavras podem ser visualizados como espaços multidimensionais onde palavras ou frases com significados semelhantes estão próximas. Podemos calcular a "distância" entre os vetores para encontrar significados semelhantes para qualquer texto de entrada.
Representação 3D de incorporações como espaços vetoriais. Na realidade, esses espaços podem ter centenas ou milhares de dimensões. Fonte: Conheça a multiferramenta da AI: Embeddings vetoriais
A matemática real por trás de tudo isso está além do escopo deste artigo. No entanto, a principal conclusão é que as operações vetoriais nos permitem manipular ou determinar o significado usando a matemática . Pegue o vetor que representa a palavra “rainha”, subtraia o vetor “mulher” dele e adicione o vetor “homem”. O resultado deve ser um vetor próximo a “rei”. Se adicionarmos “filho”, devemos chegar perto de “príncipe”.
Até agora, discutimos a incorporação de redes neurais tomando palavras como entradas e números como saídas. No entanto, muitas redes modernas passaram do processamento de palavras para o processamento de tokens.
Um token é a menor unidade de texto que pode ser processada pelo modelo. Os tokens podem ser palavras, caracteres, sinais de pontuação, símbolos ou partes de palavras.
Podemos ver como as palavras são convertidas em tokens experimentando o tokenizador on-line OpenAI , que usa codificação de pares de bytes (BPE) para converter texto em tokens e representar cada um com um número:
Muitas vezes, há uma relação de 1 para 1 entre tokens e palavras. A maioria dos tokens inclui a palavra e um espaço à esquerda. No entanto, existem casos especiais como "embedding", que consiste em dois tokens, "embed" e "ding" ou "capacities", que consistem em quatro tokens. Se você clicar em "IDs de token", poderá ver a representação numérica do modelo de cada token.
Agora que entendemos o que são os embeddings, a próxima pergunta é: como eles podem nos ajudar a criar um bot mais inteligente?
Primeiro, vamos considerar o que acontece quando usamos a API GPT-3 diretamente. O usuário emite um prompt e o modelo responde da melhor maneira possível.
No entanto, quando adicionamos contexto à equação, as coisas mudam. Por exemplo, quando perguntei ao ChatGPT sobre o vencedor da Copa do Mundo depois de contextualizar, isso fez toda a diferença.
Então, o plano para construir um bot mais inteligente é o seguinte:
Vamos começar como a maioria dos projetos, projetando o banco de dados.
Nosso banco de dados de contexto deve incluir a documentação original e seus respectivos vetores. Em princípio, podemos empregar qualquer tipo de banco de dados para esta tarefa, mas um banco de dados vetorial é a ferramenta ideal para o trabalho.
Bancos de dados vetoriais são bancos de dados especializados projetados para armazenar e recuperar dados vetoriais de alta dimensão. Em vez de empregar uma linguagem de consulta como SQL para pesquisa, fornecemos um vetor e solicitamos os N vizinhos mais próximos.
Para gerar os vetores, usaremos o text-embedding-ada-002 da OpenAI, pois é o modelo mais rápido e econômico que eles oferecem. O modelo converte o texto de entrada em tokens e usa um mecanismo de atenção conhecido como Transformer para aprender seus relacionamentos. A saída dessa rede neural são vetores que representam o significado do texto.
Para criar um banco de dados de contexto, irei:
Primeiro, devo inicializar um arquivo de ambiente com a chave da API OpenAI. Este arquivo nunca deve ser submetido ao controle de versão, pois a chave da API é privada e vinculada à sua conta.
export OPENAI_API_KEY=YOUR_API_KEY
Em seguida, criarei um virtualenv para meu aplicativo Python:
$ virtualenv venv $ source venv/bin/activate $ source .env
E instale o pacote OpenAI:
```bash $ pip install openai numpy
Vamos tentar calcular a incorporação da string "Docker Container". Você pode executar isso no Python REPL ou como um script 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 você pode ver, o modelo do OpenAI responde com uma lista embedding
contendo 1536 itens — o tamanho do vetor para text-embedding-ada-002.
Embora existam vários mecanismos de banco de dados vetoriais para escolher, como o Chroma , que é de código aberto, escolhi o Pinecone porque é um banco de dados gerenciado com um nível gratuito, o que torna as coisas mais simples. O plano inicial deles é mais do que capaz de lidar com todos os dados de que preciso.
Depois de criar minha conta Pinecone e recuperar minha chave de API e ambiente, adiciono os dois valores ao meu arquivo .env
.
Agora .env
deve conter meus segredos Pinecone e OpenAI.
export OPENAI_API_KEY=YOUR_API_KEY # Pinecone secrets export PINECONE_API_KEY=YOUR_API_KEY export PINECONE_ENVIRONMENT=YOUR_PINECONE_DATACENTER
Em seguida, instalo o cliente Pinecone para Python:
$ pip install pinecone-client
Preciso inicializar um banco de dados; estes são os conteúdos do 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']} )
O script pode levar alguns minutos para criar o banco de dados.
$ python db_create.py
Em seguida, instalarei o pacote tiktoken . Vou usá-lo para calcular quantos tokens os documentos de origem têm. Isso é importante porque o modelo de incorporação só pode lidar com até 8191 tokens.
$ pip install tiktoken
Ao instalar os pacotes, vamos também instalar tqdm
para produzir uma barra de progresso bonita.
$ pip install tqdm
Agora eu preciso fazer o upload dos documentos para o banco de dados. O script para isso será chamado index_docs.py
. Vamos começar importando os módulos necessários e definindo algumas 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
Em seguida, precisaremos de uma função para contar tokens. Há um exemplo de contador de token na página 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
Por fim, precisarei de algumas funções de filtragem para converter o documento original em exemplos utilizáveis. A maioria dos exemplos na documentação está entre cercas de código, então vou apenas extrair todo o código YAML de cada arquivo:
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
Acabei com as funções. Em seguida, isso carregará os arquivos na memória e extrairá os exemplos:
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}]" })
Neste ponto, todos os YAMLs devem ser armazenados na lista new_data
. A etapa final é carregar as incorporações no 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 referência, você pode encontrar o arquivo index_docs.py completo no repositório de demonstração
Vamos executar o script index para finalizar a configuração do banco de dados:
$ git clone https://github.com/semaphoreci/docs.git /tmp/docs $ source .env $ python index_docs.py /tmp/docs
O painel Pinecone deve mostrar vetores no banco de dados.
Podemos consultar o banco de dados com o seguinte código, que você pode executar como um script ou diretamente no 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 você pode ver, a primeira correspondência é o YAML para um pipeline do Semaphore que extrai uma imagem do Docker e a executa. É um bom começo, pois é relevante para nossa string de pesquisa "Docker Containers".
Temos os dados e sabemos como consultá-los. Vamos colocá-lo para trabalhar no bot.
As etapas para processar o prompt são:
Como de costume, começarei definindo algumas constantes em complete.py
, o script principal do 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)
Em seguida, adicionarei funções para contar tokens, conforme mostrado nos exemplos do OpenAI . A primeira função conta tokens em uma string, enquanto a segunda conta tokens em mensagens. Veremos as mensagens em detalhes daqui a pouco. Por enquanto, digamos apenas que é uma estrutura que mantém o estado da conversa na memória.
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
A função a seguir usa o prompt original e as strings de contexto para retornar um prompt 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:" )
A função get_message
formata o prompt em um formato compatível com a API:
def get_message(role: str, content: str) -> dict: """Generate a message for OpenAI API completion.""" return {"role": role, "content": content}
Existem três tipos de papéis que afetam como o modelo reage:
Agora, para a parte envolvente. A função get_context
pega o prompt, consulta o banco de dados e gera uma string de contexto até que uma destas condições seja atendida:
context_tokens_per_query
, o espaço que reservei para context.match_min_score
são ignoradas. 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
A próxima e última função, complete
, emite a solicitação da API para OpenAI e retorna a resposta do 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()
Isso é tudo; agora só tenho que lidar com os argumentos da linha de comando e chamar as funções na ordem correta:
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))
É hora de executar o script e ver como ele se sai:
$ python complete.py "Create a CI pipeline that builds and uploads a Docker image to Docker Hub"
O resultado é:
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 é o primeiro bom resultado. O modelo inferiu a sintaxe dos exemplos de contexto que fornecemos.
Lembre-se que comecei com um objetivo modesto: criar um assistente para escrever pipelines YAML. Com um conteúdo mais rico em meu banco de dados de vetores, posso generalizar o bot para responder a qualquer pergunta sobre o Semaphore (ou qualquer produto — lembre-se de clonar os documentos em /tmp
?).
A chave para obter boas respostas é - sem surpresa - contexto de qualidade. Simplesmente carregar todos os documentos no banco de dados de vetores provavelmente não produzirá bons resultados. O banco de dados de contexto deve ser curado, marcado com metadados descritivos e ser conciso. Caso contrário, corremos o risco de preencher a cota de token no prompt com contexto irrelevante.
Então, de certa forma, há uma arte – e muita tentativa e erro – envolvida em ajustar o bot para atender às nossas necessidades. Podemos experimentar o limite de contexto, remover conteúdo de baixa qualidade, resumir e filtrar o contexto irrelevante ajustando a pontuação de similaridade.
Você deve ter notado que meu bot não nos permite ter uma conversa real como o ChatGPT. Fazemos uma pergunta e obtemos uma resposta.
Converter o bot em um chatbot completo não é, em princípio, muito desafiador. Podemos manter a conversa reenviando respostas anteriores ao modelo com cada solicitação de API. As respostas GPT-3 anteriores são enviadas de volta sob a função de "assistente". Por exemplo:
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))
Infelizmente, esta implementação é bastante rudimentar. Ele não suportará conversas estendidas, pois a contagem de tokens aumenta a cada interação. Em breve, atingiremos o limite de 4096 tokens para GPT-3, impedindo mais diálogos.
Portanto, temos que encontrar uma maneira de manter a solicitação dentro dos limites do token. Seguem algumas estratégias:
Aprimorar as respostas do bot é possível com incorporação de palavras e um bom banco de dados de contexto. Para conseguir isso, precisamos de documentação de boa qualidade. Há uma quantidade substancial de tentativa e erro envolvida no desenvolvimento de um bot que aparentemente possui uma compreensão do assunto.
Espero que esta exploração aprofundada de incorporações de palavras e grandes modelos de linguagem ajude você a criar um bot mais potente, personalizado de acordo com suas necessidades.
Edifício feliz!
Publicado também aqui .