Redis
 Computer >> コンピューター >  >> プログラミング >> Redis

LangChain・Faiss・Next.jsでカスタムAIチャットボットを構築する方法 – 実践チュートリアル

本記事では、Upstash、Next.js、LangChain、Fly.ioを組み合わせて、オープンソースのカスタムコンテンツAIチャットボットを実際に構築した手順を詳しく解説します。Upstashを活用することで、モデル学習のスケジューリング、寛容なレートリミット設定、OpenAI APIレスポンスのキャッシングを実現できました。

LangChain・Faiss・Next.jsでカスタムAIチャットボットを構築する方法 – 実践チュートリアル

使用する技術スタック

  • Next.js: フロントエンドとバックエンドの両方を担当
  • LangChain: 言語モデルを活用したアプリケーション開発用フレームワーク
  • Upstash: QStashによるモデル学習のスケジューリング、レートリミット、OpenAIレスポンスのキャッシング
  • Tailwind CSS: スタイリング
  • Fly.io: デプロイ環境

必要な準備物

  • Node.js 18
  • Upstashアカウント
  • OpenAIアカウント(APIキー取得用)

Upstash Redisのセットアップ

Upstashアカウントを作成してログインしたら、「Redis」タブに移動し、データベースを作成します。

LangChain・Faiss・Next.jsでカスタムAIチャットボットを構築する方法 – 実践チュートリアル

LangChain・Faiss・Next.jsでカスタムAIチャットボットを構築する方法 – 実践チュートリアル

データベース作成後、「Details」タブを開き、「Connect your database」セクションまでスクロールします。表示された内容をコピーして、安全な場所に保存しておきましょう。

LangChain・Faiss・Next.jsでカスタムAIチャットボットを構築する方法 – 実践チュートリアル

さらに下へスクロールして「REST API」セクションを見つけ、「.env」ボタンを選択します。ここでも内容をコピーして保存してください。

LangChain・Faiss・Next.jsでカスタムAIチャットボットを構築する方法 – 実践チュートリアル

Upstash QStashのセットアップ

ログイン後、「QStash」タブに移動し、QSTASH_URLQSTASH_TOKENQSTASH_CURRENT_SIGNING_KEYQSTASH_NEXT_SIGNING_KEYの各値を取得します。こちらも同様にコピーして安全な場所に保管しておいてください。

LangChain・Faiss・Next.jsでカスタムAIチャットボットを構築する方法 – 実践チュートリアル

プロジェクトのセットアップ

セットアップは簡単です。アプリのリポジトリをクローンし、このチュートリアルに沿って中身を学んでいきましょう。プロジェクトをフォークするには、以下のコマンドを実行します。

git clone https://github.com/rishi-raj-jain/custom-content-ai-chatbot
cd custom-content-ai-chatbot
npm install

リポジトリをクローンしたら、.envファイルを作成し、前のセクションで保存した値を追加します。ファイルは以下のようになります。

# .env
 
# 上記の手順で取得した値

# Upstash Redis のシークレット
UPSTASH_REDIS_REST_URL="https://....upstash.io"
UPSTASH_REDIS_REST_TOKEN="..."
 
# Upstash QStash のシークレット
QSTASH_URL="https://qstash.upstash.io/v1/publish/"
QSTASH_TOKEN="..."
QSTASH_CURRENT_SIGNING_KEY="sig_..."
QSTASH_NEXT_SIGNING_KEY="sig_..."
 
# OpenAI キー
OPENAI_API_KEY="sk-..."
 
# 管理者アクセスキー
# 学習リクエストが管理者のみによって実行されることを検証するために使用
ADMIN_KEY="..."

これらの手順が完了すれば、次のコマンドでローカル環境を起動できます。

npm run dev

リポジトリの構成

以下はプロジェクトの主要なフォルダ構成です。赤くマークされたファイルについては、この記事の中で詳しく説明していきます。これらはベクトルストアの管理、カスタムコンテンツで学習済みのAIと対話するためのAPIルートの作成(レスポンスのキャッシング含む)、モデル学習プロセスのスケジューリングに関わる部分です。

LangChain・Faiss・Next.jsでカスタムAIチャットボットを構築する方法 – 実践チュートリアル

データフローと処理の全体像

まず、データフローと実行される処理の全体像を図で確認しましょう。

LangChain・Faiss・Next.jsでカスタムAIチャットボットを構築する方法 – 実践チュートリアル

  • ユーザーがチャットボット経由で質問すると、まずIPアドレスに対してレートリミットのチェックが行われ、Upstash Redisにキャッシュされていない場合はOpenAI APIから回答を取得(その後キャッシュ)して、ユーザーへストリーミング配信されます。
  • 管理者が既存モデルに対して特定のURL群での再学習をリクエストすると、UpstashのQStashを介して、指定した遅延時間後にサーバーレス関数へPOSTリクエストが送信され、バックグラウンドでURLのコンテンツを取得してモデルが更新されます。

Next.jsでのChat・Train APIルートの実装

このセクションでは、クロスオリジンリクエストの許可、Chat API呼び出しへのレートリミット適用、レスポンスのキャッシュとユーザーへのストリーミング、特定URLに対するコンテンツ学習のスケジューリングを行う pages/api/chat.js と、与えられたURLに対して純粋にバックグラウンドで学習を実行する pages/api/train.js の実装方法を解説します。

1. CORSの有効化

corsパッケージを使ってCORSを有効にすることで、Webサイト上のボットなど、複数の場所でチャットボットを利用できるようになります。APIルートが初期化されると同時に、以下のようにCORSのセットアップを実行します。

// File: pages/api/chat.js
 
// cors関数への参照
import { runMiddleware } from '@/lib/cors'
 
export default async function (req, res) {
 try {
 // ミドルウェアを実行
 await runMiddleware(req, res)
 // ...
 catch (e) {
 console.log(e.message || e.toString())
 }
 return res.end()
}
 
// CORS 関数
// File: lib/cors.js
import Cors from 'cors'
 
// corsミドルウェアを初期化
// 利用可能なオプションの詳細はこちら: https://github.com/expressjs/cors#configuration-options
const cors = Cors({
 methods: ['POST', 'OPTIONS', 'HEAD'],
})
 
// ミドルウェアの実行完了を待機し、
// エラー発生時には例外をスローするためのヘルパー関数
export function runMiddleware(req, res, fn = cors) {
 return new Promise((resolve, reject) => {
 fn(req, res, (result) => {
 if (result instanceof Error) return reject(result)
 return resolve(result)
 })
 })
}

2. 指定URLに対するコンテンツ学習リクエストのスケジューリング

Upstash QStashを使うと、「fire-and-forget(発射しっぱなし)」型のAPIを作成できます。メイン処理の完了を待ってレスポンスを受け取る必要はなく、バックグラウンドで処理を実行できます(任意の遅延時間を指定することも可能)。定期的に実行されるcronジョブとは異なり、リクエストごとに1回だけ実行される点が特徴です。

同じchat APIルート内で、admin-keyヘッダーを含むリクエストを受け付けます。その値がサーバー側のシークレット(ADMIN_KEY)と一致すれば、リクエストボディで渡されたURL群に対するコンテンツ学習を、一定の遅延時間(ここでは10秒)の後にスケジュールします。遅延後、学習リクエストは指定のエンドポイント(ここではhttps://custom-content-ai-chatbot.fly.dev/api/train)へ送信されます。

// File: pages/api/chat.js
 
// ヘッダーに `admin-key` が含まれている場合
if (req.headers['admin-key'] === process.env.ADMIN_KEY) {
 // ボディに `urls` がない場合は Bad Request を返す
 if (!req.body.urls) return res.status(400).send('No urls to train on.')
 // 今から10秒後にこのURL群で学習するようQStash APIを叩く
 await qstashClient.publishJSON({
 delay: 10,
 body: { urls: req.body.urls },
 url: 'https://custom-content-ai-chatbot.fly.dev/api/train'
 })
 return res.status(200).end()
}

それでは、train APIルート(pages/api/train.js)の中身を見ていきましょう。

// File: pages/api/train.js
 
import train from '@/lib/train'
import * as dotenv from 'dotenv'
import { redis } from '@/lib/redis'
import { runMiddleware } from '@/lib/cors'
import { verifySignature } from '@upstash/qstash/nextjs'
 
dotenv.config()
 
// リクエストボディのJSONへの自動変換を無効化
// 詳細: https://nextjs.org/docs/pages/building-your-application/routing/api-routes#custom-config
export const config = {
 api: {
 bodyParser: false,
 },
}
 
async function handler(req, res) {
 try {
 // ミドルウェアを実行
 await runMiddleware(req, res)
 // メソッドがPOST以外なら Forbidden Access を返す
 if (req.method !== 'POST') return res.status(403).send('No other methods allowed.')
 // ボディに `urls` がない場合は Bad Request を返す
 if (!req.body.urls) return res.status(400).send('No urls to train on.')
 // 指定されたURLで学習を実行
 await train(req.body.urls)
 // 保存完了後、Upstash内の全レスポンスキャッシュをクリア
 let allKeys = await redis.keys('*')
 if (allKeys) {
 // レートリミッター関連のキーを除外
 allKeys = allKeys.filter((i) => !i.includes('@upstash/ratelimit:'))
 const p = redis.pipeline()
 // 全キーの削除用パイプラインを作成
 allKeys.forEach((i) => p.del(i))
 // パイプラインのコマンドをトランザクションとして実行
 await p.exec()
 console.log('Cleaned cached responses in Upstash.')
 }
 return res.status(200).end()
 } catch (e) {
 console.log(e.message || e.toString())
 }
 return res.end()
}
 
// 受信リクエストが Upstash-Signature 付きの
// 有効なQStashスケジュールPOSTリクエストであることを検証
export default verifySignature(handler)

上記のコードでは、3つの重要な処理を行っています。

  • QStashのverifySignatureメソッドを使った受信リクエストの検証。内部的にはUpstash-Signatureヘッダーを確認し、受信した生のボディと照合します。
  • train関数の呼び出し。この関数がURLコンテンツの取得と、既存ベクトルストアへの追加(および保存)を担当します。
  • Redisトランザクションを使い、レートリミット実装に関するキーを除外した上で、Upstash Redis内のキャッシュ済みレスポンスをクリアします。

3. レートリミットの実装

レートリミットの実装には、Upstash Redisのデータベースクライアントと、@upstash/ratelimitというレートリミッターライブラリを使用します。

// File: lib/redis.js
// レートリミット関数への参照
 
import * as dotenv from 'dotenv'
import { Redis } from '@upstash/redis'
import { Ratelimit } from '@upstash/ratelimit'
 
// 環境変数を読み込み
dotenv.config()
 
// Upstash Redis を初期化
export const redis = new Redis({
 url: process.env.UPSTASH_REDIS_REST_URL,
 token: process.env.UPSTASH_REDIS_REST_TOKEN,
})
 
// Upstash レートリミッターを初期化
export const ratelimit = {
 chat: new Ratelimit({
 redis,
 // IPアドレスごとに1日30問までに制限
 limiter: Ratelimit.slidingWindow(30, '86400s'),
 }),
}

レートリミットのおかげで、このサービスを完全無料かつ公開状態で運用できています!これにより、システムの魅力であるチャット回答を誰にでも体験してもらえます。文字通り誰でも、Webサイト経由で1日に30件まで質問できます。IPアドレスをキーとして、1日30問というレートリミットを強制しています。

// File: pages/api/chat.js
 
import requestIp from 'request-ip'
import { ratelimit } from '@/lib/redis'
 
// ...
 
// クライアントのIPを取得
const detectedIp = requestIp.getClientIp(req)
 
// IPが検出できない場合は Bad Request を返す
if (!detectedIp) return res.status(400).send('Bad request.')
 
// レートリミットをチェック
const result = await ratelimit.chat.limit(detectedIp)
 
// 制限にかかった場合はその旨を返す
if (!result.success) return res.status(400).send('Rate limit exceeded.')
 
// チャット回答の提供へ続行

4. 保存済みインデックス付きベクトルストアの読み込みとOpenAIへの応答要求

すべてのチェックを通過したら、いよいよメインの処理です。カスタムコンテンツを使ってOpenAI APIを呼び出し、レスポンスをユーザーへ送信します。理解しやすいよう、さらに細かいパートに分けて説明します。

  • 4.1: 保存済みベクトルストアの取得
// File: pages/api/chat.js
 
// loadVectorStore関数への参照
import { loadVectorStore } from '@/lib/vectorStore'
 
// 学習済みモデルを読み込み
const vectorStore = await loadVectorStore()
 
// ...
 
// ベクトルストア関数
// File: lib/vectorStore.js
 
import { join } from 'path'
import { existsSync } from 'fs'
import { Document } from 'langchain/document'
import { FaissStore } from 'langchain/vectorstores/faiss'
import { OpenAIEmbeddings } from 'langchain/embeddings/openai'
 
export async function loadVectorStore() {
 const directory = join(process.cwd(), 'loadedVectorStore')
 const docStoreJSON = join(process.cwd(), 'loadedVectorStore', 'docstore.json')
 if (existsSync(docStoreJSON)) {
 // ディレクトリが見つかった場合は、Faiss連携で保存されたベクトルストアを読み込む
 return await FaissStore.load(directory, new OpenAIEmbeddings())
 } else {
 // コンテンツがない場合は、初期値として「Hey」だけのベクトルストアを作成
 return await FaissStore.fromDocuments([new Document({ pageContent: 'Hey' })], new OpenAIEmbeddings())
 }
}
  • 4.2: ユーザークエリへのプロンプトガイドラインの追加

LangChainのPromptTemplateを使用し、ユーザークエリと一緒に、AIがどのような形式・スタイルで回答すべきかの指示を渡します。

// File: pages/api/chat.js
 
import { z } from 'zod'
import { PromptTemplate } from 'langchain/prompts'
import { RetrievalQAChain } from 'langchain/chains'
import { OutputFixingParser, StructuredOutputParser } from 'langchain/output_parsers'
 
// 学習済みモデルを読み込み
// ...
 
// OpenAIに何を書かせるかを指定するプロンプトを作成
const outputParser = StructuredOutputParser.fromZodSchema(
 z.object({
 answer: z.string().describe('answer to question in HTML friendly format, use all of the tags wherever possible and including reference links'),
 }),
)
 
// ...
 
// OpenAIのレスポンスを精錬するための出力パーサークラスのインスタンスを作成
const outputFixingParser = OutputFixingParser.fromLLM(model, outputParser)
 
// 入力の処理方法をOpenAIに指示するプロンプトを作成
const prompt = new PromptTemplate({
 template: `Answer the user's question as best and be as detailed as possible:\n{format_instructions}\n{query}`,
 inputVariables: ['query'],
 partialVariables: {
 format_instructions: outputFixingParser.getFormatInstructions(),
 },
})
 
// プロンプトとクエリ、モデルをOpenAI APIに渡す
const chain = RetrievalQAChain.fromLLM(model, vectorStore.asRetriever(), prompt)
  • 4.3: レスポンスのストリーミングとキャッシング

Upstash Redisでレスポンスをキャッシュするには、LangChainが提供するUpstashRedisCacheキャッシュライブラリを使用します。既存のRedisインスタンスをクライアントとして渡し、そのキャッシュハンドラーをChatOpenAIラッパーに渡すことで、レスポンス生成後に自動的にキャッシュされます。

// File: pages/api/chat.js
 
import { redis } from '@/lib/redis'
import { ChatOpenAI } from 'langchain/chat_models/openai'
import { UpstashRedisCache } from 'langchain/cache/upstash_redis'
 
// 学習済みモデルを読み込み
// ...
 
// Upstashキャッシングを作成
const upstashRedisCache = new UpstashRedisCache({ client: redis })
 
// レスポンスが未キャッシュかどうかを判定するフラグ
let doesToken = false
 
const model = new ChatOpenAI({
 // ストリーミングを有効化し、できるだけ早くユーザーへ回答を返す
 streaming: true,
 // Upstash Redisキャッシュクライアントでレスポンスをキャッシュ
 cache: upstashRedisCache,
 callbacks: [
 {
 handleLLMNewToken(token) {
 // OpenAIからストリームを受信したらフラグをtrueに
 doesToken = true
 // トークンをユーザーへストリーミング
 res.write(token)
 },
 },
 ],
})
 
// LLM QAチェーンを作成
// ...
 
// キャッシュ参照用に出力を保持
const chainOutput = await chain.call({ query: req.body.input })
 
// トークンを1つも受信していなければコンテンツはキャッシュ済み
// キャッシュ済みレスポンスをそのまま返す
if (!doesToken) return res.status(200).send(chainOutput.text)

盛りだくさんの内容でしたが、これで実装はすべて完了です!

Fly.ioへのデプロイ

リポジトリにはFly.io向けの設定が最初から組み込まれており、具体的には以下のファイルが含まれています。

  • Dockerfile
  • fly.toml
  • .dockerignore

デプロイにはFly.ioのアカウントが必要です。アカウントを作成したら、プロジェクトのルートフォルダで以下のコマンドを実行してアプリを作成します。

# 組み込みの設定に基づいてアカウント内にアプリを作成
# 既存のfly.tomlで変更されるのはアプリ名のみ
fly launch

そして、以下のコマンドでデプロイします。

# 上記で作成した設定に基づいてアプリをデプロイ
fly deploy

以上でデプロイ完了です!本当にこれだけです。

まとめ

このプロジェクトを通じて、OpenAIレスポンスのキャッシング、レートリミット、モデル学習のためのスケジュール済みAPIリクエストの実装について、貴重な経験を得ることができました。しかも、ニーズに合わせてスケールするUpstashのようなサービスを活用することで実現できています。

Next.js, Redis, TailwindCSS, LangChain, Serverless Scheduling

  1. LangChainとUpstash Redisでチャット履歴を永続化する方法

    LangChainは、人間とAIの間の対話を実現するためのシンプルなインターフェースを提供しています。BufferMemoryを使うよう簡単に設定でき、会話履歴をメモリ上に保存できます。一部のユースケースではそれで十分ですが、アプリによってはチャット履歴を長期的に永続化したいケースもあるでしょう。幸い、これをUpstash Redisインスタンスに差し替えるのも同じくらい簡単です。 LangChainには、ioredis、node-redis、Upstash Redisなど、Redis向けの複数の統合機能が用意されています。Upstash RedisクライアントはREST経由で動作するため、V

  2. QStashでRedisの状態をデータベースへシームレスに同期する方法

    前回の記事では、フルスタックなNext.jsアプリケーションにおいて、ユーザーのクォータ(利用制限)の状態をRedisに保存する方法について解説しました。一般的にRedisはデータベースよりもはるかに高速なため、この手法を採用することでアプリケーションのパフォーマンスを大幅に向上させることができます。 特にUpstash Redisは、このユースケースに最適な選択肢です。完全マネージド型のRedisデータベースであり、HTTP経由でアクセスできるため、Vercelのようなエッジランタイム上でも、ユーザーに近い場所で低レイテンシのコード実行が可能になります。 QStashとは何か? QStash