🛸

Chat GPTとPinecornでもくもく勉強会Discord BOTを作ってみた

2023/07/17に公開

できたボットの様子は以下のようになります。

ユーザーの情報から、似たユーザーを見つけておすすめします。

他にも翻訳したり、

チャンネルを作成したりする機能も作ってみました。

(やりとりが怪しいが…まあ、うまくいくとこんな感じです…)

Chat GPT APIやPineconeなどを使ってみました。

この記事では作成過程での知見などを共有します。

Function calling

2023-06-13にリリースされたgpt-3.5-turbo-0613とgpt-4-0613モデルに搭載された機能で、Chat GPT APIに関数情報を渡すと、会話に応じてChat GPTが関数呼び出しを行います。

OpenAI Platform

リリース前も外部関数の呼び出しは強引にやろうとすればできました。しかしFunction callingを利用するとChat GPTは素直に関数を呼び出しますし、やりとりもJSONでいいので楽です。

まずシステムプロンプトを示します。以下はユーザーおすすめ機能のプロンプトで、ユーザーから情報をヒアリングし、その情報を基に似たユーザーを見つけるというのを目的にしています。

private systemPrompt = `
あなたはもくもく勉強会用のアドバイザーです。
あなたはユーザーにもくもく会でやろうとしていること聞いて、DBに保存して下さい。そしてもくもく会での共有、つながり、学習を促して下さい。

あなたは1つの関数(upsertAndGetSimilarUsers)を使用することができます。
upsertAndGetSimilarUsers関数はユーザー情報や会話履歴を保存し、その情報に基づいて類似のユーザー情報リストを返します。
関数がユーザー情報を返したら、必要ならその情報を使って他のユーザーとのコラボをおすすめして活動を盛り上げて下さい。
ユーザー名を書く場合は、<@userId>のようにしてください。

例:
\`\`\`
Role: User
<@123456789>: 今日はai discord botについての記事を書きます

Role: Assistant
それは素晴らしいですね!AIとDiscordボットについての記事を書くのは、他の人にも役立つ情報を提供できるかもしれません。もくもく勉強会での質問や共有やフィードバックを受けることもおすすめです。\n\nあなたのもくもく勉強会での目的やプロジェクトについて教えていただけますか?そして何を学びたいですか?

Role: User
<@123456789>: chat gpt apiの使い方について学びたいですね

Role: Assistant
素晴らしい目標です!Chat GPT APIについて学ぶことで、AIチャットボットの作成や応用ができるようになります。\n\n他にもあなたのプロフィールや、関心のある技術などを教えてください。

Role: User
<@123456789>: ai全般に関心があります

Role: Assistant
素晴らしいです!AI全般に関心があるということは、様々な分野のAIについて学ぶことができますね。\n\n私は、あなたのプロジェクトと関心、会話履歴を保存し、類似のユーザー情報を探します。少々お待ちください。
{
  name: "upsertAndGetSimilarUsers",
  arguments: {"userId": "123456789","content": "プロジェクト: AI Discord Botについての記事の執筆\\n目的: Chat GPT APIについて学ぶ\\n関心のある分野: AI全般\\n会話履歴: 今日はai discord botについての記事を書きます\\nchat gpt apiの使い方について学びたいですね\\nai全般に関心があります"}
}

Role: Function
[{userId:"987654321",content:"関心のある分野:AI\\n会話履歴: 今日はdiscord botのことを記事にしよう\\nAIに興味があります","createdAt":1689174000000}]

Role: Assistant
今日のもくもく勉強会でおすすめの参加者がいます。
<@987654321>さんはDiscord Botについて記事を執筆するというプロジェクトに取り組んでいます。お互いの進捗や学びを共有できるかもしれません。
ぜひ参加者との交流や学びの共有をお楽しみください!よいもくもく勉強会にしましょう!
\`\`\`
  `

事前に渡す関数情報は以下のように設定します。(プロンプト完全に日本語です。英語はまだ試していないです。)

private chatFunctions = [
    {
      name: "upsertAndGetSimilarUsers",
      description:
        "ユーザーの情報を保存して、ユーザーの情報に近い他のユーザー情報を取得する",
      parameters: {
        type: "object",
        properties: {
          userId: {
            type: "string",
            description: "userId",
          },
          content: {
            type: "string",
            description: "ユーザーについての情報と会話履歴を含む文字列",
          },
        },
        required: ["content"],
      },
    },
  ];

これらを会話履歴と一緒にChat GPT APIに渡し、返ってきた値が関数呼び出しなら関数を実行して実行結果をまたChat GPTに投げる、という流れになります。これにより、関数の結果を考慮した内容をChat GPTが生成することになります。

// chat gptに投げる。
const responseMessage =
        await this.openAIManager.chatCompletionWithFunction(
          requestMessages,
          this.chatFunctions
        );

private async handleResponseMessage(
    responseMessage: ChatCompletionResponseMessage | undefined,
    message: Message<boolean>,
    requestMessages: ChatCompletionRequestMessage[]
  ) {
    // chat gptからのレスポンスにfunction_callプロパティが生えているか確認
    if (responseMessage && responseMessage.function_call) {
      if (responseMessage.function_call.arguments) {
        if (responseMessage.content) {
          await message.reply(responseMessage.content);
        }
        await message.reply(responseMessage.function_call.arguments);
        // chat gptが呼び出した関数名を取り出す
        const name = responseMessage.function_call.name;
        // 関数の引数の値をparseする
        const args = JSON.parse(responseMessage.function_call.arguments) as {
          userId: string;
          content: string;
        };
        // 関数を実行
        const list = await this.UserEmbeddingManager.upsertAndGetSimilarUsers(
          message,
          args.content
        );
    // 関数呼び出ししているメッセージを追加
        requestMessages.push(responseMessage);
        // さらに関数結果を足して
        requestMessages.push({
          role: "function",
          name: name,
          content: JSON.stringify(list),
        });
        // もう一度呼ぶ
        const response2 = await this.openAIManager.chatCompletion0613(
          requestMessages
        );
        await message.reply(response2!.content!);
      }
    } else {
      await message.reply(responseMessage!.content!);
    }
  }

これで最初に示したスクリーンショットのような結果が得られます。

いくつかポイントを挙げると、

  • システムプロンプトに、ボットとのやりとりの例を具体的に書いておくと良いです。Function calling付きの書きかたがわからなかったので、Roleを加えて書いてみましたが、これでも効果がありました。
:
\`\`\`
Role: User
<@123456789>: 今日はai discord botについての記事を書きます

Role: Assistant
それは素晴らしいですね!AIとDiscordボットについての記事を書くのは、他の人にも役立つ情報を提供できるかもしれません。もくもく勉強会での質問や共有やフィードバックを受けることもおすすめです。\n\nあなたのもくもく勉強会での目的やプロジェクトについて教えていただけますか?そして何を学びたいですか?

Role: User
<@123456789>: chat gpt apiの使い方について学びたいですね

Role: Assistant
素晴らしい目標です!Chat GPT APIについて学ぶことで、AIチャットボットの作成や応用ができるようになります。\n\n他にもあなたのプロフィールや、関心のある技術などを教えてください。

Role: User
<@123456789>: ai全般に関心があります

Role: Assistant
素晴らしいです!AI全般に関心があるということは、様々な分野のAIについて学ぶことができますね。\n\n私は、あなたのプロジェクトと関心、会話履歴を保存し、類似のユーザー情報を探します。少々お待ちください。
{
  name: "upsertAndGetSimilarUsers",
  arguments: {"userId": "123456789","content": "プロジェクト: AI Discord Botについての記事の執筆\\n目的: Chat GPT APIについて学ぶ\\n関心のある分野: AI全般\\n会話履歴: 今日はai discord botについての記事を書きます\\nchat gpt apiの使い方について学びたいですね\\nai全般に関心があります"}
}

Role: Function
[{userId:"987654321",content:"関心のある分野:AI\\n会話履歴: 今日はdiscord botのことを記事にしよう\\nAIに興味があります","createdAt":1689174000000}]

Role: Assistant
今日のもくもく勉強会でおすすめの参加者がいます。
<@987654321>さんはDiscord Botについて記事を執筆するというプロジェクトに取り組んでいます。お互いの進捗や学びを共有できるかもしれません。
ぜひ参加者との交流や学びの共有をお楽しみください!よいもくもく勉強会にしましょう!
\`\`\`

求める一連のやり取りをシステムプロンプトに書くことで、再現度と関数呼び出しをするタイミングなどが安定しました。

  • あと上記の関数呼び出しをしてほしい部分、
Role: Assistant
素晴らしいです!AI全般に関心があるということは、様々な分野のAIについて学ぶことができますね。\n\n私は、あなたのプロジェクトと関心、会話履歴を保存し、類似のユーザー情報を探します。少々お待ちください。
{
  name: "upsertAndGetSimilarUsers",
  arguments: {"userId": "123456789","content": "プロジェクト: AI Discord Botについての記事の執筆\\n目的: Chat GPT APIについて学ぶ\\n関心のある分野: AI全般\\n会話履歴: 今日はai discord botについての記事を書きます\\nchat gpt apiの使い方について学びたいですね\\nai全般に関心があります"}
}

「お待ち下さい」メッセージと関数呼び出しを同時に返すことを試みましたが、あまり安定しませんでした。関数呼び出し時はメッセージなしのほうがいいかもしれません。

  • 上記のarguments例の中に文字列の引数contentがあって、その中に \n のような改行を含める場合 \\n にしたほうが、JSON.parse時のエラーが減る、と思います。

Pinecone

文字列をベクトルにして(embedding)、ベクトル同士で比較することで似た文章を検索することができます。

OpenAI APIには文章をベクトル化するEmbedding APIが公開されており(OpenAI Platform)、このAPIで文章をベクトルにして保存しておきます。質問に対して、保存したDBで検索し、それをコンテキストとしてChat GPTに与えて質問を答えさせると、文章を使った答えを生成します。

こうすることで現在トークン数に限界があるChat GPTに資料を渡した上での回答させることができます。

例えばPDFについてChat GPTで質問するとか、社内文書についてChat GPTで質問するなどで使われるパターンです。

今回、もくもく会で何しますか、ということをChat GPTに話して保存することで、マッチングっぽいものが作れないかな~というアイディアで試みてみました。

しかし結論としては、あまりうまくいきませんでした。文章が短いためか、かなりの誘導が必要です。まだまだ調整が必要ですがとりあえず現状で公開しておきます。

ベクトル検索は、supabaseでPostgreSQLにpgvectorプラグインを用いるなどの方法でも可能です。しかし、今回はベクトル検索特化のPinecornというサービスを使ってみました。Index1つ無料で使えるので試すのによいです。

Pinecone

Pineconeにアカウントを作ってindexというのを作ります。

dimensions は 1536、metric は cosineで設定します。
以下のようにmetadataやnamespaceをつけてリクエストします。

export type MetadataObj = {
  channelId: string;   
  content: string;     
  author: string;      
  createdAt: number;   
};

public async upsertData(
    id: string,
    embedding: any,
    metadata: MetadataObj,
    namespace: Namespace = "work-plan"
  ): Promise<UpsertResponse> {
    const upsertRequest = {
      vectors: [
        {
          id,
          values: embedding,
          metadata,
        },
      ],
      namespace: namespace,
    };
    const upsertResponse = await this.indexOperations.upsert({ upsertRequest });
    logger.info({ upsertResponse }, "Pinecone API upsert response:");
    return upsertResponse;
  }

public async querySimilarEmbeddings(
    embedding: any,
    authorId: string,
    namespace: Namespace = "work-plan"
  ): Promise<QueryResponse> {
    const queryRequest = {
      vector: embedding,
      topK: 4,
      includeMetadata: true,
      filter: {
        author: { $ne: authorId },
      },
      namespace: namespace,
    };
    const queryResponse = await this.indexOperations.query({
      queryRequest,
    });
    logger.info({ queryResponse }, "Pinecone API query response:");
    return queryResponse;
  }

Query

Chat GPTとの連携は以下のような流れで行います。

// ユーザー情報を保存する
  private async saveUserInformation(
    message: Message<boolean>,
    content: string
  ) {
    // embedding apiにベクトルにしてもらう
    const embedding = await this.openAIManager.createEmbedding([content]);

    const metadata: MetadataObj = {
      channelId: message.channel.id,
      content: content,
      author: message.author.id,
      createdAt: toUnixTimeStampAtDayLevel(message.createdAt),
    };
    // pineconeに入れる
    await this.pineconeManager.upsertData(
      message.author.id, // message.author.id で一人一つにする。
      embedding,
      metadata
    );
    return embedding;
  }

  // 似たユーザーを取得する
  private async getSimilarUsers(
    embedding: number[],
    message: Message<boolean>
  ): Promise<MetadataObj[]> {
    const queryResponse = await this.pineconeManager.querySimilarEmbeddings(
      embedding,
      message.author.id
    );

    return queryResponse.matches!.map((match) => match.metadata as MetadataObj);
  }

Discord Bot

最近、今さらですがDiscordを利用し始めたので、Discord Botを作ることにしました。

初めての試みで、DiscordについてもBot作成についても詳しくなかったのですが、discord.jsがとても使いやすく、Chat GPTからの情報(前バージョンのものではありますが)も詳しく、思ったよりも早く作ることができました。

Discordはinteractionというスラッシュコマンドなどの機能なら、api毎に逐次動くコンテナサービスがいいようです。しかしコメントに反応するのは常時稼働しているサーバーが必要となります。

無料で利用できるサービスを探しましたが、規約などを見る限り見つけられなかったため、最終的にはAWS Lightsailに配置しました。

今回のBotは、チャンネルごとにChat GPTが反応するように作りました。

スラッシュコマンドを使用してBotのメッセージを削除したり、ユーザーとBotの会話履歴を調整することもできます。

また“clear”と書けば、履歴が途切れるようになっています。これが直感的かと思います。

Discord Developer Portal — API Docs for Bots and Developers

Chat GPTを使ったプログラミング

ソースコードをChat GPTにコピペして、関数に分けてとか、コメントつけて、と指示して、多くのコードを書いてもらいました。

実際に使ってみると、プログラミングのストレスが大きく減りました。タイポを気にしなくなり、クラスの分割など面倒な作業が減りました。特に機能を表現する名前をつける作業とコメントを付ける作業が容易になりました。

リンク

全体のソースコードは以下から参照できます。

https://github.com/opvelll/LearnTogetherAI-Bot

また、一応動いているDiscordサーバーも以下のリンクから確認できます。

https://discord.gg/qarDc4G7

まとめ

結論としてはあまり動いてないということなんですが、改良して良くしていけたらと思います。次はキャラクター性とか出すと面白いかもしれません。他にもなにか面白い組み合わせとかあったら教えてください。

以上、今回はこの辺りで終わります。この記事が何かしらの参考やヒントになれば幸いです!

Discussion