Sarcástico chatbot GPT-3 que pode se lembrar de qualquer coisa construída em Node.js e OpenAI API
Objetivo: Chatbot que pode se lembrar de tudo e responder a perguntas.
Ferramentas: Node.js (NestJS), PostgreSQL (Prisma), OpenAI API & Pinecone (como banco de dados vetorial).
Conceitos: API, Embedding, AI, Prompt Design, SQL e Vector Database
Fonte:https://github.com/iceener/tuesday-chatbot-gpt-3
Usar a API GPT-3 é uma ferramenta muito interessante que podemos usar para construir algo em cima dela. O GPT-3 é, por padrão, bastante inteligente e pode responder a várias perguntas, mas obviamente não tem conhecimento sobre algo específico para nós. Portanto, criaremos um bot que responderá às perguntas usando nossas próprias informações.
Completações e prompts
A API OpenAI pode criar uma conclusão para o prompt e os parâmetros fornecidos. O prompt é limitado a 2.048–4.096 tokens (dependendo do modelo), o que significa que tanto o prompt quanto a conclusão retornada devem ser menores do que algo como 1.500–2.500 palavras. Tenha isso em mente.
A conversa básica é assim:
- (eu) Como você está?
- (gpt-3) Estou bem, obrigado. E você?
Mas posso modificar o prompt para especificar exatamente o que preciso, basta olhar:
- (eu) Como você está? Responda de forma sarcástica.
- (gpt-3) Oh, apenas pêssego.
Eu tenho um problema com isto. Todas essas solicitações são independentes, o que significa que não temos contexto aqui. Claro que podemos adicionar um.
Contexto: “Adam é um cara legal!”
Responda a uma pergunta abaixo usando o contexto.
Pergunta (eu): Quem é Adão?
Resposta (gpt-3): Adam é um cara legal.
Acho que você já sabe o que podemos fazer com isso: construir um chatbot que se lembre de tudo. Podemos apenas salvar qualquer entrada do usuário em nosso banco de dados e adicioná-la como um contexto para descobrir como mantê-la abaixo do limite de prompt.
Configurar um projeto
A primeira etapa será criar um novo projeto NestJS com o Nest CLI.
$ nest new tuesday && cd tuesday
$ nest g mo prisma
$ nest g mo messages
$ nest g s messages
$ nest g co messages
$ nest g s messages/openai
$ nest g s messages/pinecone
$ pnpm i prisma @prisma/client -D
$ snpx prisma init
DATABASE_URL="postgresql://overment:password@localhost:5432/tuesday?schema=public"
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => {
await app.close();
});
}
}
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
// (...)
import { PrismaService } from './prisma/prisma.service';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// ---- add this
const prismaService = app.get(PrismaService);
await prismaService.enableShutdownHooks(app);
// ----
await app.listen(3000);
}
bootstrap();
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Message {
id Int @default(autoincrement()) @id
message String
}
$ npx prisma migrate dev --name init
Nosso banco de dados SQL está pronto. Armazenaremos todas as mensagens que nosso bot receberá e as usaremos para criar o contexto de um prompt atual. Então, acesse o message.service.ts e atualize-o adicionando o método createMessage e o método checkIfMessageExists , para garantir que não armazenaremos mensagens duplicadas.
constructor(private readonly prisma: PrismaService) {}
async createMessage(message: string) {
const exists = await this.checkIfMessageExists(message);
if (exists) {
return {
...exists,
created: false,
};
}
const newMessage = await this.prisma.message.create({
data: {
message,
},
});
return {
...newMessage,
created: true,
};
}
private async checkIfMessageExists(message) {
return this.prisma.message.findFirst({
where: {
message,
},
});
}
constructor(
private readonly messagesService: MessagesService,
private readonly openaiService: OpenaiService,
private readonly pineconeService: PineconeService,
) {}
@Post()
async createMessage(@Body() data: { message: string }) {
const { id, created } = await this.messagesService.createMessage(
data.message,
);
return id;
}
Precisamos começar a conversar com a API OpenAI. Lembre-se de que esta é uma API paga; depois de criar uma conta, verifique se você definiu os limites de uso adequados. A boa notícia é que chamadas de API simples não devem custar muito. Depois de criar uma conta, basta pegar uma chave de API e voltar para mim.
Instale o openai e atualize o arquivo .env
$ pnpm i openai
// .env file
OPENAI_API_KEY=sk-...................
- O modelo 'text-davinci-003' é o modelo mais avançado e caro atualmente disponível. Se quiser ou precisar, pode experimentar outros modelos.
- Lembre-se de que o valor 'max_tokens' é o comprimento total de um prompt e conclusão, não uma contagem de caracteres; no entanto, por enquanto, 250 devem ser suficientes para um simples bate-papo.
- Uma temperatura indica o quão “criativa” uma resposta deve ser. Valores mais próximos de 0 indicam uma resposta mais rígida, enquanto valores mais próximos de 1 indicam uma resposta mais criativa. Mas lembre-se de que isso também é uma simplificação e não uma descrição exata desse valor, mas você pode pensar dessa maneira.
import { Injectable } from '@nestjs/common';
import { Configuration, OpenAIApi } from 'openai';
@Injectable()
export class OpenaiService {
constructor() {
const configuration = new Configuration({
apiKey: process.env.OPENAI_API_KEY,
});
this.openai = new OpenAIApi(configuration);
}
async createCompletion(prompt, context) {
const completion = await this.openai.createCompletion({
model: 'text-davinci-003',
prompt: prompt,
max_tokens: 250,
temperature: 0.2,
});
return completion?.data.choices?.[0]?.text;
}
}
@Post()
async createMessage(@Body() data: { message: string }) {
const { id, created } = await this.messagesService.createMessage(
data.message,
);
return this.openaiService.createCompletion(data.message, '');
}
Em primeiro lugar, nosso prompt agora se parecerá com isto:
Com base neste contexto: \n\n\n
[contexto gerado]
\n\n\n
Responda a pergunta abaixo com a maior sinceridade possível, se não souber a resposta, diga que não sabe de forma sarcástica caso contrário, apenas responda.
\n\n\n
[entrada/pergunta do usuário atual]
Como você pode ver, estamos especificando claramente um contexto e uma instrução para gerar uma resposta. Todas essas seções são separadas por \n\n\n e neste ponto nosso contexto está vazio, então o bot sempre responderá "não sei" de forma sarcástica.
@Injectable()
export class OpenaiService {
private CONTEXT_INSTRUCTION = 'Based on this context:';
private INSTRUCTION = `Answer the question below as truthfully as you can, if you don't know the answer, say you don't know in a sarcastic way otherwise, just answer.`;
private openai;
constructor() {
const configuration = new Configuration({
apiKey: process.env.OPENAI_API_KEY,
});
this.openai = new OpenAIApi(configuration);
}
async createCompletion(prompt, context) {
const completion = await this.openai.createCompletion({
model: 'text-davinci-003',
prompt: `${this.CONTEXT_INSTRUCTION}\n\n\nContext: "${context}" \n\n\n${this.INSTRUCTION} \n\n\n ${prompt}`,
max_tokens: 250,
temperature: 0.2,
});
return completion?.data.choices?.[0]?.text;
}
}
Estamos chegando mais perto. Nosso prompt precisa de um contexto adequado, mas não podemos simplesmente pegar tudo o que temos em nosso banco de dados PostgreSQL. Em vez disso, obteremos a entrada atual do usuário e buscaremos apenas informações significativas dela e a usaremos como um “contexto”. Podemos fazer isso criando embeddings com base nos dados que temos (um array de algo chamado “vetores”). Essas incorporações serão salvas no banco de dados da Pinecone . O Pinecone também é capaz de consultar essas incorporações e encontrar aquelas que estão próximas umas das outras. Em palavras simples, poderemos pesquisar tudo o que temos que esteja de alguma forma conectado com uma entrada atual do usuário. Graças a isso, nosso contexto não será muito longo e ao mesmo tempo será útil para criar uma boa conclusão.
Vamos para o pinecone.service.ts . Precisamos de três coisas:
- configuração PineconeClient (que precisa ser instalado com pnpm i pinecone-client `) Você deve adicionar API KEY, Base URL e Pinecone Namespace no arquivo .env. Você pode obtê-los no Console Pinecone . O novo índice deve ter um nome (no meu caso é `tuesday`), dimensão definida como 1536 e tipo de pod definido como P2. Lembre-se de que o URL base deve começar com `https://`, como: `https://tuesday-XXXXXXX.svc.us-west1-gcp.pinecone.io`
- um método para atualizar (criar ou atualizar) um vetor. Como você pode ver, isso é algo realmente simples.
- um método de consulta que pegará um determinado vetor e retornará os 10 principais resultados, que serão filtrados e mapeados para uma matriz de números (identificadores). Esses identificadores serão utilizados para consulta de informações de nosso banco de dados SQL.
import { Injectable } from '@nestjs/common';
import { PineconeClient } from 'pinecone-client';
@Injectable()
export class PineconeService {
private pinecone;
constructor() {
this.pinecone = new PineconeClient({
apiKey: process.env.PINECONE_API_KEY,
baseUrl: process.env.PINECONE_BASE_URL,
namespace: process.env.PINECONE_NAMESPACE,
});
}
upsert(vectors) {
return this.pinecone.upsert({
vectors: [vectors],
});
}
async query(vector) {
const { matches } = await this.pinecone.query({
vector,
topK: 10,
includeMetadata: true,
includeValues: false,
});
return matches
.filter((match) => match.score > 0.8)
.map((match) => parseInt(match.id));
}
}
async createEmbedding(prompt) {
const { data: embed } = await this.openai.createEmbedding({
input: prompt,
model: 'text-embedding-ada-002',
});
return embed;
}
Agora podemos juntar tudo isso, mas há mais uma coisa que precisamos e estou falando de dois métodos em messages.service.ts . O primeiro encontrará mensagens com base na matriz de identificadores. Um segundo criará um contexto juntando as mensagens .
async messages(ids: number[]) {
return this.prisma.message.findMany({
where: {
id: {
in: ids,
},
},
});
}
async getContext(ids: number[]) {
return (await this.messages(ids))
.filter(
(message, index, self) =>
index === self.findIndex((t) => t.message === message.message),
)
.reduce((acc, message) => {
return acc + message.message + '\n';
}, '');
}
A última etapa será atualizar `messages.controller.ts` e usar todos esses métodos.
- endpoint recebe uma mensagem de um usuário
- estamos armazenando esta mensagem em nosso banco de dados ou retornando uma existente
- a mensagem do usuário é convertida para a incorporação
- esta incorporação é usada para consultar o banco de dados Pinecone, que nos retornará uma matriz de `ids` que podemos usar em `messages.service.ts` para buscar um conteúdo real.
- com base nos `ids` retornados, estamos buscando mensagens e juntando-as, então temos um contexto
- se uma mensagem for nova, armazenamos sua incorporação em uma pinha
- estamos solicitando ao GPT-3 uma conclusão com base em um determinado prompt e contexto gerado
Um resultado se parece com isto:
- (eu): Quem é overment?
- (bot): não sei, quem é overment?
- (eu): overment é um cara esperto de Cracóvia.
- (bot): Não sei.
- (eu): Você sabe onde mora overment?
- (bot): Não sei, mas tenho certeza que é em algum lugar de Cracóvia.
- (eu): Quem é overment?
- (bot): overment é o apelido de Adam.
Uma última resposta mostra que o bot se lembrou de algo que eu disse a ele anteriormente sobre mim e foi capaz de responder corretamente à minha última pergunta. Isso significa que temos um bot capaz de lembrar tudo o que dizemos e usar essa informação em uma discussão posterior.
Qual é o próximo?
Neste ponto, nosso bot é muito simples. É fácil enganá-lo, e a qualidade de suas respostas depende principalmente do que dizemos a ele, mas tudo bem. Podemos pensar em preparar um conjunto de informações das quais ele se lembrará e depois usará para responder às nossas perguntas ou procurá-las.
Definitivamente, seria uma boa ideia aprender mais sobre design de prompt, embeddings e criação de conjuntos de dados, o que pode nos levar a fazer um bot que possa pesquisar informações, responder a perguntas com base nos dados que fornecemos ou até mesmo executar algumas ações com base em instruções dadas.
Se você tiver algum feedback ou ideias que gostaria de compartilhar comigo, deixe um comentário.





































![O que é uma lista vinculada, afinal? [Parte 1]](https://post.nghiatu.com/assets/images/m/max/724/1*Xokk6XOjWyIGCBujkJsCzQ.jpeg)