Azure OpenAIでRAGを構築してチャットボットを作ってみた【part1】

RAGとは、Retrieval-Augmented Generationの略であり、LLMによるテキスト生成に外部情報の検索を組み合わせることで、回答精度を向上させる技術のことです。

chatGPTはユーザーから入力されたクエリを学習に利用してしまうため、機密文書や議事録をchatGPTに投げてしまうと、情報漏洩に繋がってしまいます。

しかし、RAGを利用することで、GPTは外部情報をGPT自体の学習に利用することなく検索できるようになるので、機密情報をGPTで扱いたい場合や、ドキュメントを踏まえた上でGPTに回答して欲しい場合などにとても便利です。

本記事では、Azure OpenAIを用いて社内規程について質問できるチャットボットをSlack上で使えるように実装した流れについて、説明していきたいと思います。

全体像

図でまとめると下記のようになります。

まず、ストレージアカウントにある社内文書を、AI Searchから参照してベクトル化します。

メインとなるのはApp Serviceで、こちらにBot Framework SDKを用いてNode.jsで書いたコードをデプロイします。

Bot ServiceはSlackと連携しておき、必要なモジュールをインポートして作成したコードによって、Bot Service、Azure OpenAI、AI Search、cosmosDBが呼び出され、プログラムが実行されます。

各サービスの役割

Bot Service

チャットボットをSlackに連携するために使用します。Slackに加えてTeamsやLINEなど、主要コミュニケーションツールとの連携が可能で、ソースコードを変更せずに利用できます。webチャットでのテスト機能もあります。

Azure OpenAI

GPTに質問するために使用します。GPTだけではなく、RAGを構築する際に必要なtext-embeddingもデプロイすることで使用可能です。

AI Search

Azure OpenAIに渡すベクトルデータを作成するために使用します。(ベクトル化)

cosmosDB

質問のクエリと回答のクエリを保存しておくために利用します。保存された履歴を確認することで、GPTが過去の会話を踏まえて回答することを可能にします。

コーディング

App Serviceへデプロイするコードについて解説していきます。

まず前提として、Bot Framework SDKを使用したアプリケーションコードの雛形を作成できるツールがあるので、そちらを利用します。

npm install -g npm
npm install -g yo

npm install -g generator-botbuilder

作業ディレクトリに移動し、

yo botbuilder

のコマンドを実行すると、アプリケーションコードの雛形が作成されます。 主に編集するのはbot.jsであり、今回はこちらを下記のように書き換えます。

// REQUIRE
const { ActivityHandler, MessageFactory } = require('botbuilder');
const { OpenAIClient, AzureKeyCredential } = require('@azure/openai');
const { CosmosClient } = require('@azure/cosmos');

// define const value
const endpoint = 'Azure OpenAIのエンドポイント';
const apiKey = 'Azure OpenAIのAPIキー';
const deploymentName = 'デプロイしたGPTの名前';

// for RAG
const azureSearchEndpoint = 'AI Searchのエンドポイント';
const azureSearchAdminKey = 'AI Searchのアドミンキー';
const azureSearchIndexName = 'AI Searchのインデックス名';

// for History
const cosmosEndpoint = 'cosmosDBのエンドポイント';
const cosmosKey = 'cosmosDBのキー';

const getReply = async (req, userId) => {

    // Preparement of saving history
    const csClient = new CosmosClient({ endpoint: cosmosEndpoint, key: cosmosKey }); // アカウント全体のメタデータ、DB管理用オブジェクト
    const { database } = await csClient.databases.createIfNotExists({ id: 'お好きな名前' });
    const { container } = await database.containers.createIfNotExists({ id: 'お好きな名前' });

    const querySpec = {
        query: `SELECT * FROM c WHERE c.userID = @userID`,
        parameters: [
            {
                name: '@userID',
                value: `${userId}`
            }
        ]
    };

    var messages = [];

    var response = await container.items.query(querySpec).fetchAll();
    for (var item of response.resources) {
        // console.log(item);
        messages.push({ role: 'user', content: item.req });
        messages.push({ role: 'assistant', content: item.res });
    }

    messages.push({ role: 'user', content: req });


    var res = '';
    var citation = [];

    console.log(userId);

    try {
        const client = new OpenAIClient(endpoint, new AzureKeyCredential(apiKey));
        const events = await client.streamChatCompletions(deploymentName, messages, {
            maxTokens: 1000,
            azureExtensionOptions: {
                extensions: [
                    {
                        type: 'azure_search',
                        endpoint: azureSearchEndpoint,
                        indexName: azureSearchIndexName,
                        strictness: 3,
                        topNDocuments: 7,
                        roleInformation: `
                        (ここにプロンプトを記述する)
                        `,
                        authentication: {
                            type: 'api_key',
                            key: azureSearchAdminKey
                        }
                    }
                ]
            },
            temperature: 0.3,
            topP: 0.3
        });
        for await (const event of events) {
            for (const choice of event.choices) {
                const delta = choice.delta?.content;
                if (delta !== undefined) {
                    res += delta;
                }
                if (choice.delta?.context !== undefined) {
                    for (const citata of choice.delta.context.citations) {
                        console.log(citata);
                        // for MarkDown
                        // citation.push('[' + citata.filepath + ']' + '(' + citata.url + ')\n\n');
                        // for Slack
                        citation.push('<' + citata.url + '|' + citata.filepath + '>\n\n');
                    }
                }
            }
        }
        // 参考文書の追加
        var citationNosame = Array.from(new Set(citation));
        if (res.slice(-10) !== '少々お待ちください。') {
            if (citation.length !== 0) {
                res += '\n\n===========================\n\n※関連する社内規程を下記にまとめます\n\n';
                console.log('why?');
            }
            for (const cite of citationNosame) {
                res += cite;
            }
        }
    } catch (err) {
        console.log(err);
        throw err;
    }
    await container.items.upsert({ req: req, res: res, userID: userId, conversationId: userId });
    return res;
};

class EchoBot extends ActivityHandler {
    constructor() {
        super();
        // See https://aka.ms/about-bot-activity-message to learn more about the message and other activity types.
        this.onMessage(async (context, next) => {
            var tempMessage = await context.sendActivity('入力中...');
            const replyText = await getReply(context.activity.text, context.activity.from.id);
            await context.deleteActivity(tempMessage.id);
            await context.sendActivity(MessageFactory.text(replyText, replyText));
            // By calling next() you ensure that the next BotHandler is run.
            await next();
        });

        this.onMembersAdded(async (context, next) => {
            const membersAdded = context.activity.membersAdded;
            const welcomeText = '質問をどうぞ!';
            for (let cnt = 0; cnt < membersAdded.length; ++cnt) {
                if (membersAdded[cnt].id !== context.activity.recipient.id) {
                    await context.sendActivity(MessageFactory.text(welcomeText, welcomeText));
                }
            }
            // By calling next() you ensure that the next BotHandler is run.
            await next();
        });
    }
}

module.exports.EchoBot = EchoBot;

各種必要なキーなどは、ソースコードに直書きではなく、.envファイルなどを作成して、分離しておくことを推奨します。

デプロイ

Azure CLIを用いてコマンドからデプロイします。

brew install azure-cli

先ほどのbot.jsやindex.jsのあるディレクトリへ移動し、下記コマンドを実行し、zipファイルを作成します。

zip -r ../deploy.zip .

その後、

az login

でログインした後に、

az webapp deployment source config-zip --resource-group "<リソースグループ名>" --name "<App Service (Webアプリ) の名前>" --src ./deploy.zip

を実行すると、zipファイルがデプロイされます。

Slackとの連携と実行

事前にSlack API側でアプリを作成しておきます。

Bot Serviceの画面でSlackを選択し、各種必要な情報を入力し、Add to Slackボタンを押して認証を完了させます。

こちらが実行した時の様子です。

まとめ

今回はAzure OpenAIを用いて社内文書を検索して回答するチャットボットの解説をしました。

次回はSlackで回答させる際の書式設定や、ユーザーフレンドリーな表示の実装について解説したいと思います。