101
93

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【図解解説/初心者OK】たった1時間でNext.jsで最新AIを使ったチャットアプリを作ろう【RAG/LangChain/OpenAI/TypeScript】

Last updated at Posted at 2024-12-22

next-rag.png

はじめに

みなさんこんにちは、Watanabe Jin(@Sicut_study)です。
最近世の中ではよりAIブームが加速しており、プログラミングもChatGPTが欠かせない存在となりました。

こちらはChatGPTで利用されるそれぞれのモデルがいつのデータを学習したのかを表しています。

MODEL Knowledge cutoff
gpt-4o Oct 2023
gpt-3.5-turbo Sep 2021

GPTは一番新しいもので2023年の10月までのデータしか学習されていません!

 
今季のアニメについて聞けないじゃん!!! (2024年12月時点)
 

モデルを追加で学習(ファインチューニング)することも可能ですが、
再学習には「時間」と「お金」がかかってしまうので今季のアニメについて聞きたいというモチベーションでは厳しいところがあります。

そこでは今回はRAGを使った最新情報を使えるチャットボット作成をしていきます

このチュートリアルをやることで最近話題になっているRAGを理解できるだけではなく、Next.jsを利用してあなたの好きなチャットボットを作ることができます。

Webサイトの隅に専門のチャットボットをおいたり、学習には使えない社内の機密情報を利用したチャットボットを作って会社のヒーローになることも余裕でしょう

動画教材もご用意してます

こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材ではわからない細かい箇所があれば動画も活用ください。

対象者

  • 最新のAI技術の仕組みを理解したい
  • Next.jsをやってみたい
  • Reactを始めてみたい
  • 手を動かして実践的に学びたい
  • アプリを作りながら仕組みを理解していきたい
  • JavaScriptがなんとなくわかる

RAGとはなにか?

まずこのハンズオンを始める前に「RAGとはなにか?」「どのような仕組みで最新情報をAIが回答できるのか」という2点について詳しく解説していきます。

しっかり理解することで実装パートでより理解しながら進めることが可能です。

2.png

まずはRAGについて解説します。
RAGとはAIモデル(ChatGPT)に外部データを渡してそこから答えを導いてもらうアプローチだと思ってくれれば大丈夫です。こうすることでAI自体に文章から答えを導き出せる能力があれば、最新情報を渡して答えを見つけてもらうことで新しい情報を答えさせることができます。
 

イメージしやすい例をあげると、「誰かに自分の代わりに質問の答えを調べてもらう」感じです。
 

3.png

 

① あなたが2023年の総理大臣を友達に質問します。
② 友達は海外の友達だったので、もちろん総理大臣など知りません。
③ そこでwikipediaを検索して総理大臣を特定しました
④ 「2023年は〇〇総理大臣だよ」

このような流れを行うのがRAGの仕組みになっています。

4.png

実際のシステムの仕組みはこのようになっています。

① 質問をアプリケーションに入力
② 質問をDBに渡して関連するページのデータを取得(ここでは総理大臣に関連するページ)
③ ChatGPTに関連するページのデータをいくつか渡してその中から総理大臣をみつけてもらう

あとでもっと詳しい設計レベルのお話をしますが、今はこの程度の理解でも問題ありません。
 
RAGにはいくつかのメリットがあるので紹介します。

 
5.png

 
先程も説明しましたが、再学習しなくても専門性の高い最新情報を答えてくれるのがメリットです。
またAIモデルも渡した文章から答えを見つけてくれる能力があればRAGを利用できるので軽量なモデルやコストの安いモデルなどでも利用可能です。

アプリの設計を考える

設計を考える上でいくつかのパートに分けるとわかりやすいので解説していきます。

1. 関連ページの取り込み

今回は2024年のアニメに関するページをいくつか取り込みます。
例えば、2024年アニメの全体に関する情報はこちらのページで取り込みます。

image.png
 

特定のアニメに特化したページはこちらで取得します。例えば「推しの子」です。

image.png

 
そして取得したデータはベクトル化という処理を行います。

6.png

 
① ページをクローリング(プログラムで実際に取りに行く)してデータ取得
② ページをいい感じのまとまりの文章ごとに分割する(たとえばパラグラフごとなど)
③ 分割した文章を-1〜1で表現する
④ データベースに文章を保存する

②③に関しては今回利用するAIモデルの中でのルールに沿っていい感じにやってくれるため気にする必要はありません。このルールはモデルごとに異なるため利用するモデルのルールでやる必要があります。
 

この作業は1度だけやれば十分です。もしデータを最新に更新したい場合は再度DBに更新をかけます。今回は簡単なスクリプトでページのクローリング→ベクトル化→DB保存までをやります。
 

2. 質問に類似した文を取得する

次に質問を同じくChatGPTのルールでベクトル化して、その文章に類似した文章をDBから取得する処理を行います。

 
7.png
 

① 質問をモデルのルールで-1〜1にベクトル化する
② ベクトルに対して質問(ベクトル)に近い文章をDBからいくつか取得する

ここで近い文章という言葉についてもっと詳しく解説します。

例えば、「猫」をベクトル化して以下のような表現が得られたとします。

猫 : [0, 1, 0, 0, 1, 0, 0]

そして以下の2つの単語もベクトル化をします。

犬 : [0, 1, 0, 0, 1, 0, 1]
トラック : [1, 1, 0, 0, 0, 1, 0]

ベクトルを比較すると、「猫と犬」は「猫とトラック」を比べたときより近いベクトルになっています(最後が0か1かの違いしかありません)

実際のベクトルはこんなに簡単ではないですが、ベクトルを比較すれば近い文章を取得することが可能です。今回利用するAstra DBは近いベクトルをクエリで簡単に取得することができます。

3. AIに答えを見つけてもらう

8.png

近い文章をいくつかDBから取得したら、DBから取得したデータ(複数)と質問(ベクトル)をAIに渡して答えを文章から見つけます。

そうすることで最新情報を答えてくれるようなチャットボットができるのです。

設計を理解したところでさっそく1. 関連ページの取り込みのスクリプトを作成していきましょう!

関連ページの取り込みスクリプトを作成する

まず初めに2024年のアニメに関するWikipediaのページをスクレイピング→ベクトル化→DB保存をするためのスクリプトを作成していきます。

Node.jsが入っているかを確認します。入っていない場合は、以下のサイトからインストールしてください。Node.jsの入れ方は多くの記事で紹介されているので割愛します。

$ node -v
v22.4.0

1. TypeScriptの環境構築

それではプロジェクトを作成します。

$ mkdir rag-chatbot
$ mkdir rag-chatbot/scripts
$ touch rag-chatbot/scripts/main.ts
$ cd rag-chatbot/scripts

rag-chatbot/scriptsをVSCodeで開きます。

image.png

まずはTypeScriptを実行するための環境を構築します。

$ npm init
// すべてエンター
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.

See `npm help init` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
package name: (scripts) 
version: (1.0.0) 
description: 
entry point: (index.js) 
test command: 
git repository: 
keywords: 
author: 
license: (ISC) 
About to write to /home/jinwatanabe/workspace/qiit/rag-chatbot/scripts/package.json:

{
  "name": "scripts",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "description": ""
}


Is this OK? (yes) 

これでpackgae.jsonが作成されます。

package.json
{
  "name": "scripts",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "seed": "./node_modules/.bin/ts-node --project tsconfig.json ./main.ts" // 追加
  },
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "ts-node": "^10.9.2",
    "typescript": "^5.7.2"
  }
}

今回はDBにデータを投入するので、seedを追加しました。

image.png

npm run seedを実行すると./node_modules/.bin/ts-node --project tsconfig.json ./main.tsが裏では実行されます。(このようにすることで長いコマンドを打たなくてもよくなります)

するとmain.ts(TypeScript)をmain.js(JavaScript)に変換してくれます。
Node.jsではJavaScriptを実行できるので変換したものを実行しています。

ここではts-nodeがまだないので、インストールしましょう

$ npm install ts-node typescript

こうすることでnode_modulests-nodeがあることがわかります。このライブラリを使ってTypeScriptをJavaScriptに変換しています。

image.png

seedコマンドで--project tsconfig.jsonとしているのでtsconfig.jsonを用意します。

$ npx tsc --init

tsconfig.jsonが作成できたら、TypeScriptのファイルを実行してみましょう

main.ts
console.log("Hello World")
$ npm run seed

> [email protected] seed
> ./node_modules/.bin/ts-node --project tsconfig.json ./main.ts

Hello World

TypeScriptを実行できるようになりました。

2. Wikipediaページを集める

まずはWikipediaのページ情報を集めてくるコードを書いていきます。
スクレイピングにはLangChainとその裏で使用されているPuppeteerというライブラリが必要なので最初にインストールをします。

$ npm i langchain @langchain/community puppeteer 

Wikipediaのページ情報を集めてくるスクリプトはこちらになります。

main.ts
import {
  Browser,
  Page,
  PuppeteerWebBaseLoader,
} from "@langchain/community/document_loaders/web/puppeteer";
const animeData = [
  "https://ja.wikipedia.org/wiki/Category:2024%E5%B9%B4%E3%81%AE%E3%83%86%E3%83%AC%E3%83%93%E3%82%A2%E3%83%8B%E3%83%A1",
  // "https://ja.wikipedia.org/wiki/%E3%80%90%E6%8E%A8%E3%81%97%E3%81%AE%E5%AD%90%E3%80%91_(%E3%82%A2%E3%83%8B%E3%83%A1)",
];

const scrapePage = async () => {
  const pageData = [];
  for await (const url of animeData) {
    const loader = new PuppeteerWebBaseLoader(url, {
      launchOptions: {
        headless: true,
        args: ["--no-sandbox", "--disable-setuid-sandbox"],
      },
      gotoOptions: {
        waitUntil: "domcontentloaded",
      },
      evaluate: async (page: Page, browser: Browser) => {
        const result = await page.evaluate(() => document.body.innerHTML);
        await browser.close();
        return result;
      },
    });

    const data = await loader.scrape();
    pageData.push(data);
  }

  return pageData;
};

(async () => {
  const data = await scrapePage();
  console.log(data);
})();

まずは今回集めてくるアニメの情報をWikipediaからいくつかピックアップして用意します。
(実行に時間がかかるため最初以外はコメントにしますが、あとで戻します)

const animeData = [
  "https://ja.wikipedia.org/wiki/Category:2024%E5%B9%B4%E3%81%AE%E3%83%86%E3%83%AC%E3%83%93%E3%82%A2%E3%83%8B%E3%83%A1",
  // "https://ja.wikipedia.org/wiki/%E3%80%90%E6%8E%A8%E3%81%97%E3%81%AE%E5%AD%90%E3%80%91_(%E3%82%A2%E3%83%8B%E3%83%A1)"
];

スクレイピングに関しては、animeDataをforで回してlangchainに用意されている機能を利用して行います。

    const loader = new PuppeteerWebBaseLoader(url, {
      launchOptions: {
        headless: true,
        
      },
      gotoOptions: {
        waitUntil: "domcontentloaded",
      },
      evaluate: async (page: Page, browser: Browser) => {
        const result = await page.evaluate(() => document.body.innerHTML);
        await browser.close();
        return result;
      },
    });

設定は公式ドキュメント通りではありますが、evaluateに実際に取得したページに対して行うことを書いていきます。

      evaluate: async (page: Page, browser: Browser) => {
        const result = await page.evaluate(() => document.body.innerHTML);
        await browser.close();
        return result;
      },

ここではページに対してevaluateをすることで取得したページに対してJavaScriptの処理を実行することができます。中ではWebページの document.body.innerHTML(つまり、

タグの中身すべて)を取得しています。

実行するとページが取得できていることがわかります。

❯ npm run seed

> [email protected] seed
> ./node_modules/.bin/ts-node --project tsconfig.json ./main.ts

[
  '<a class="mw-jump-link" href="#bodyContent">コンテンツにスキップ</a>\n' +
    '<div class="vector-header-container">\n' +
    '\t<header class="vector-header mw-header">\n' +
    '\t\t<div class="vector-header-start">\n' +
    '\t\t\t<nav class="vector-main-menu-landmark" aria-label="サイト">\n' +

(省略)

次にDBにこれらの文章をベクトルにして保存してきます。
ここで利用するのが、Astra DBChatGPT APIです。

Astra DBはアカウント作成をしてから以下の手順でDB作成とキーの取得をします。

左メニューから「Database」を選択

image.png

「Create Database」をクリック

image.png

Database name : anime_db
Region : us-east2

に設定して「Create Database」をクリック
初期化(Your database is initializing)が始まるので初期化が終わるまで待ちます

image.png

「Generate Token」をクリック

image.png

クリップボードにコピーしておきます。

image.png

.envを作ってトークンを入れておきましょう

$ touch .env
.env
ASTRA_DB_NAMESPACE="default_keyspace"
ASTRA_DB_COLLECTION="anime"
ASTRA_DB_API_ENDPOINT="あなたのエンドポイント"
ASTRA_DB_APPLICATION_TOKEN="あなたのトークン"

他にもいくつかの環境変数を用意しました。
あなたのエンドポイントにエンドポイントも入力します

image.png
ASTRA_DB_NAMESPACEASTRA_DB_COLLECTIONはこのハンズオンではこの値で大丈夫です。

ChatGPT APIは以下の記事を参考にしてAPIキーを取得してください。

APIキーが取得できたら.envに追加します。

.env
ASTRA_DB_NAMESPACE="default_keyspace"
ASTRA_DB_COLLECTION="anime"
ASTRA_DB_API_ENDPOINT="あなたのエンドポイント"
ASTRA_DB_APPLICATION_TOKEN="あなたのトークン"
OPENAI_API_KEY="あなたのAPIキー"

忘れずに.gitignoreにも追加しておきましょう

$ touch .gitignore
.gitignore
.env
node_modules

ここまででセットアップはすべて完了ですので、実際にWikipediaのページをベクトル化してDBに保存してみましょう

3. 文章をベクトル化する

まずはOpenAIとAstraDBを簡単に利用できるライブラリをインストールします
環境変数も読み込みたいのでdotenvもいれました

$ npm i openai @datastax/astra-db-ts dotenv

コードを以下のように実装しました。

main.ts
import { PuppeteerWebBaseLoader } from "@langchain/community/document_loaders/web/puppeteer";
import { Page, Browser } from "puppeteer";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import OpenAI from "openai";
import dotenv from "dotenv"; // 追加

dotenv.config(); // 追加

const animeData = [
  "https://ja.wikipedia.org/wiki/Category:2024%E5%B9%B4%E3%81%AE%E3%83%86%E3%83%AC%E3%83%93%E3%82%A2%E3%83%8B%E3%83%A1",
  // "https://ja.wikipedia.org/wiki/%E3%80%90%E6%8E%A8%E3%81%97%E3%81%AE%E5%AD%90%E3%80%91_(%E3%82%A2%E3%83%8B%E3%83%A1)"
];

const scrapePage = async () => {
  const pageData = [];
  for await (const url of animeData) {
    const loader = new PuppeteerWebBaseLoader(url, {
      launchOptions: {
        headless: true,
      },
      gotoOptions: {
        waitUntil: "domcontentloaded",
      },
      evaluate: async (page: Page, browser: Browser) => {
        const result = await page.evaluate(() => document.body.innerHTML);
        await browser.close();
        return result;
      },
    });

    const data = await loader.scrape();
    pageData.push(data);
  }

  return pageData;
};

const {
  ASTRA_DB_NAMESPACE,
  ASTRA_DB_COLLECTION,
  ASTRA_DB_API_ENDPOINT,
  ASTRA_DB_APPLICATION_TOKEN,
  OPENAI_API_KEY,
} = process.env;

const openai = new OpenAI({ apiKey: OPENAI_API_KEY });

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 512,
  chunkOverlap: 100,
});

const convertVector = async (pageData: string[]) => {
const vectors = [] as number[][];
const chunks = [] as string[];
for (const page of pageData) {
  const pageChunks = await splitter.splitText(page);
  for await (const chunk of pageChunks) {
    const embedding = await openai.embeddings.create({
      model: "text-embedding-3-small",
      input: chunk,
      encoding_format: "float",
    });

    const vector = embedding.data[0].embedding;
    console.log(vector);
    vectors.push(vector);
    chunks.push(chunk);
  }
}
return { vectors, chunks };
};

const main = async () => {
  const pageData = await scrapePage();
  await convertVector(pageData);
};

main();

まずは環境変数を読み込むための設定と読み込みを行います。

import dotenv from "dotenv"; // 追加

dotenv.config(); // 追加

(省略)

const {
  ASTRA_DB_NAMESPACE,
  ASTRA_DB_COLLECTION,
  ASTRA_DB_API_ENDPOINT,
  ASTRA_DB_APPLICATION_TOKEN,
  OPENAI_API_KEY,
} = process.env;

次に文章をいい感じのまとまりに切るための準備をします。

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 512,
  chunkOverlap: 100,
});

chunkSizeは1つのまとまりの文字数を最大512文字
chunkOverwrapとは他のまとまりと重複を最大100文字許すという意味になります

私は昨日の夜に友達とコンビニへ行った

という文章を区切ったときに重複することを許可した場合、例えば以下のようになります

まとまり1: 「私は昨日の夜に...」
まとまり2: 「昨日の夜に友達と...」

このように重複を許可することで情報の流れが途切れてしまうことを防いでいます。

プログラムを実行すると先程のWikipediaのスクレイピングが始まり集めてきたデータをベクトル化する関数に渡します。

const main = async () => {
  const pageData = await scrapePage();
  await convertVector(pageData);
};

main();

convertVectorでは受け取ったページ1つずつを先程のルールでいい感じのまとまりに分割をしています

  for (const page of pageData) {
    const chunks = await splitter.splitText(page);

そのあと、分割されたまとまりをそれぞれopenaiのルールで**-1〜1**にするベクトル化をしています。

      const embedding = await openai.embeddings.create({
        model: "text-embedding-3-small",
        input: chunk,
        encoding_format: "float",
      });

openaiを利用するための初期化も行っています。

const openai = new OpenAI({ apiKey: OPENAI_API_KEY });

実際に実行するとベクトルが表示されます。

$ npm run seed
❯ npm run seed

> [email protected] seed
> ./node_modules/.bin/ts-node --project tsconfig.json ./main.ts

(node:195614) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
[
    0.01957207,   0.017847328,  -0.021200398,  -0.025131952,   0.015565527,
  0.0014194304,  -0.062090706,    0.03248085,   0.018832896,  -0.028495735,
   0.037130155,   0.013090897,  -0.009491436,   0.019218551,   0.031645264,
   0.019475656,  -0.028045801,  -0.004285073,   0.020579062,   0.029245622,
  0.0003511751,    0.01569408,   0.017386682,   0.023653602,   0.021318238,
   0.027788697,  0.0018465986,   0.037730068,     0.0678327,  -0.010182404,
   0.010669831,  -0.034816217,   0.024532042,  -0.034044903,   0.011141189,
   -0.02731734,    0.04366489,   0.022625184,  -0.003283437,  -0.022475207,
    0.02141465, -0.0027411075,   0.055405993,   0.012544551,   0.023075117,
   0.052577842, -0.0139264865,   0.007456027,   0.033787798,   0.057762783,
    -0.0382657,  -0.033766374,   -0.02680313,  -0.007032876,   -0.03719443,
  0.0074828085,  -0.013497979, -0.0026969176,  -0.029824108, -0.0022027954,
   0.046835847,  -0.012244595,  -0.006925749,   -0.02188601, -0.0034628746,
   0.018779332,  -0.012565976,   0.005891975,   0.001617615,  -0.020814741,
   0.012308871,  -0.024253512,  -0.016208287,   0.010760889, -0.0020327314,
  0.0076595675,  -0.027381616,   -0.08368747, -0.0013464502,  -0.020782603,
   0.014172878,   0.051935084,   -0.05690577,  -0.028410032,   0.016518956,
  -0.048678428,  -0.008779043,   0.043407787,   0.002967413,  0.0005078481,
   -0.05219219,  0.0076756366,  -0.029845532,   -0.00728998,  -0.006550805,
   0.009277183,  -0.011141189,   0.012790943,  -0.019143563,    0.05420617,
  ... 1436 more items
]
[
  -0.027328715,   0.036319252,    -0.02926126,  -0.020543799,   0.019850604,
   0.036613334,  -0.028883154,    0.012298971,    0.05083435,  0.0022988364,
   0.030332562,   0.016710216,    0.054321334,  0.0070369863,   0.043902393,
   0.019745573,  -0.054321334,   0.0016896644,  -0.015491873, -0.0045267777,
    0.01721436,    0.03493286,    0.051338494,   0.006007696,   0.003245416,
   0.005435284,  -0.018212141,     0.02961836,    0.08099887,   0.004374485,
  -0.025753269,   -0.03871393, -0.00023483972,  -0.034764815,  -0.046087008,
  -0.054279324,    0.03678138,     0.02327457, -0.0076251524,  -0.022854451,
  -0.018369686,  0.0031325093,     0.09872787,    0.02879913,   0.025816288,
    0.06289175,   -0.04020535,    0.009447417,  0.0031272578,  -0.008517904,
   -0.04764145,  -0.017634477,   -0.014956222,  -0.003571008,   0.027286703,
   0.018096609,   -0.01002508,    0.011595273,  -0.019703561,   0.024072796,
   0.038587894,  -0.016815247,    0.015649417, -0.0067901667,  -0.007961247,
   0.031887002,   -0.01651066,    0.013758884,   0.014231517,   0.004899633,
   0.021972202,  0.0065485984,   -0.022875458,  0.0017894426,    0.06562252,
   0.008286839,  -0.042116888,    -0.06079116,  0.0047315857,    0.01746643,
  -0.004424374,    0.08587224,   -0.045540854,  -0.011353705,    0.01633211,
  -0.050918374,   -0.02802191,    0.018506223,   -0.03778967,  -0.007856218,
   -0.03623523, -0.0048549953,   -0.017046312,   0.014084476, -0.0001625498,
   0.023757707,   0.023085516,     -0.0209009,  -0.046675175,    0.01715134,
  ... 1436 more items

いい感じにベクトル化した文章を得ることができました

4. ベクトルをDBに保存する

最後に生成したベクトルをDBに保存しましょう

main.ts
import { PuppeteerWebBaseLoader } from "@langchain/community/document_loaders/web/puppeteer";
import { Page, Browser } from "puppeteer";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import OpenAI from "openai";
import dotenv from "dotenv";
import { Collection, DataAPIClient } from "@datastax/astra-db-ts";

dotenv.config();

const animeData = [
"https://ja.wikipedia.org/wiki/Category:2024%E5%B9%B4%E3%81%AE%E3%83%86%E3%83%AC%E3%83%93%E3%82%A2%E3%83%8B%E3%83%A1"
"https://ja.wikipedia.org/wiki/%E3%83%88%E3%83%AA%E3%83%AA%E3%82%AA%E3%83%B3%E3%82%B2%E3%83%BC%E3%83%A0",
];

const scrapePage = async () => {
const pageData = [];
for await (const url of animeData) {
  const loader = new PuppeteerWebBaseLoader(url, {
    launchOptions: {
      headless: true,
      args: ['--no-sandbox', '--disable-setuid-sandbox'],
    },
    gotoOptions: {
      waitUntil: "domcontentloaded",
    },
    evaluate: async (page: Page, browser: Browser) => {
      const result = await page.evaluate(() => document.body.innerHTML);
      await browser.close();
      return result;
    },
  });

  const data = await loader.scrape();
  pageData.push(data);
}

return pageData;
};

const {
ASTRA_DB_NAMESPACE,
ASTRA_DB_COLLECTION,
ASTRA_DB_API_ENDPOINT,
ASTRA_DB_APPLICATION_TOKEN,
OPENAI_API_KEY,
} = process.env;

const openai = new OpenAI({ apiKey: OPENAI_API_KEY });

const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 512,
chunkOverlap: 100,
});

const convertVector = async (pageData: string[]) => {
const vectors = [] as number[][];
const chunks = [] as string[];
for (const page of pageData) {
  const pageChunks = await splitter.splitText(page);
  for await (const chunk of pageChunks) {
    const embedding = await openai.embeddings.create({
      model: "text-embedding-3-small",
      input: chunk,
      encoding_format: "float",
    });

    const vector = embedding.data[0].embedding;
    console.log(vector);
    vectors.push(vector);
    chunks.push(chunk);
  }
}
return { vectors, chunks };
};


const client = new DataAPIClient(ASTRA_DB_APPLICATION_TOKEN);
const db = client.db(ASTRA_DB_API_ENDPOINT!, { namespace: ASTRA_DB_NAMESPACE });
const createCollection = async () => {
const res = await db.createCollection(ASTRA_DB_COLLECTION!, {
  vector: {
    dimension: 1536,
    metric: "cosine",
  },
});

console.log(res);
};


const saveVector = async (collection: Collection, chunks: string[], vector: number[][]) => {
for (let i = 0; i < chunks.length; i++) {
  await collection.insertOne({
    $vector: vector[i],
    text: chunks[i],
  });
}
}

const main = async () => {
const pageData = await scrapePage();
const { vectors, chunks } = await convertVector(pageData);

const collection = db.collection(ASTRA_DB_COLLECTION!);

if (vectors && chunks) {
  await createCollection();
  await saveVector(collection, chunks, vectors);
}
};

main();

まずAstraDBのクライアントとDBのコネクションを作成する関数を作ります

const client = new DataAPIClient(ASTRA_DB_APPLICATION_TOKEN);
const db = client.db(ASTRA_DB_API_ENDPOINT!, { namespace: ASTRA_DB_NAMESPACE });

この設定をすることでDBに対して操作を行えるようになります。
次にコレクションを作成します。

const createCollection = async () => {
  const res = await db.createCollection(ASTRA_DB_COLLECTION!, {
    vector: {
      dimension: 1536,
      metric: "cosine",
    },
  });

  console.log(res);
};

コレクションとはテーブルのようなもので、新しいものを作成しています。
コレクションにはベクトルの設定を入れる必要があるので、次元 1536、メトリクス コサイン類似度を設定しました。

ベクトルの形とどのように同じようなベクトル化を判断する方法をしていしたと思ってください。

const main = async () => {
  const pageData = await scrapePage();
  const { vectors, chunks } = await convertVector(pageData);

  const collection = db.collection(ASTRA_DB_COLLECTION!);

  if (vectors && chunks) {
    await createCollection();
    await saveVector(collection, chunks, vectors);
  }
};

もし先程作成したベクトル、その元となった文章、Astraクライアントを保存関数saveVecotrに渡します。

const saveVector = async (collection: Collection, chunks: string[], vector: number[][]) => {
  for (let i = 0; i < chunks.length; i++) {
    await collection.insertOne({
      $vector: vector[i],
      text: chunks[i],
    });
  }
}

この関数ではAstraクライアントを利用してDBに保存をしています。
データの回数だけforを回して保存してきます。ベクトルと文章のペアを保存するのが肝になってきます。このあと質問をベクトル化してDBに投げたときに類似するベクトルと対応する文章を返してもらうためです。

※まだ実行はしないでください

今回はわかりやすさを重視してそれぞれの工程を分けましたが本来はメモリを考えるとベクトル化してすぐにデータベースに保存するようにしたほうがよいです。
リファクタリングをしたコードが以下になります。

main.ts
import { PuppeteerWebBaseLoader } from "@langchain/community/document_loaders/web/puppeteer";
import { Page, Browser } from "puppeteer";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import OpenAI from "openai";
import dotenv from "dotenv";
import { Collection, DataAPIClient } from "@datastax/astra-db-ts";

dotenv.config();

const animeData = [
  "https://ja.wikipedia.org/wiki/Category:2024%E5%B9%B4%E3%81%AE%E3%83%86%E3%83%AC%E3%83%93%E3%82%A2%E3%83%8B%E3%83%A1",
  "https://ja.wikipedia.org/wiki/%E3%80%90%E6%8E%A8%E3%81%97%E3%81%AE%E5%AD%90%E3%80%91_(%E3%82%A2%E3%83%8B%E3%83%A1)"
];

const scrapePage = async () => {
  const pageData = [];
  for await (const url of animeData) {
    const loader = new PuppeteerWebBaseLoader(url, {
      launchOptions: {
        headless: true,
        args: ['--no-sandbox', '--disable-setuid-sandbox'],
      },
      gotoOptions: {
        waitUntil: "domcontentloaded",
      },
      evaluate: async (page: Page, browser: Browser) => {
        const result = await page.evaluate(() => document.body.innerHTML);
        await browser.close();
        return result;
      },
    });

    const data = await loader.scrape();
    pageData.push(data);
  }

  return pageData;
};

const {
  ASTRA_DB_NAMESPACE,
  ASTRA_DB_COLLECTION,
  ASTRA_DB_API_ENDPOINT,
  ASTRA_DB_APPLICATION_TOKEN,
  OPENAI_API_KEY,
} = process.env;

const openai = new OpenAI({ apiKey: OPENAI_API_KEY });

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 512,
  chunkOverlap: 100,
});

const convertVectorAndSave = async (pageData: string[]) => {
  for (const page of pageData) {
    const pageChunks = await splitter.splitText(page);
    const collection = await db.collection(ASTRA_DB_COLLECTION!);

    for await (const chunk of pageChunks) {
      const embedding = await openai.embeddings.create({
        model: "text-embedding-3-small",
        input: chunk,
        encoding_format: "float",
      });

      const vector = embedding.data[0].embedding;
      await collection.insertOne({
        $vector: vector,
        text: chunk,
      });
    }
  }
};


const client = new DataAPIClient(ASTRA_DB_APPLICATION_TOKEN);
const db = client.db(ASTRA_DB_API_ENDPOINT!, { namespace: ASTRA_DB_NAMESPACE });
const createCollection = async () => {
  const res = await db.createCollection(ASTRA_DB_COLLECTION!, {
    vector: {
      dimension: 1536,
      metric: "cosine",
    },
  });

  console.log(res);
};

const main = async () => {
  const pageData = await scrapePage();
  await createCollection();
  await convertVectorAndSave(pageData);
};

main();

ベクトル化と保存を同時に行うようにしてメモリに残らないようにしました。
それでは実際に実行します。もしanimeDataがコメントになっている場合は戻しましょう

$ npm run seed

時間が少しかかります。AstraDBをみると徐々にベクトルが保存されていることがわかります。(animeをクリック)

image.png

終わったらデータベースの準備は終了です。Reactでチャットボットを作成しましょう。

チャットボットを開発する

1. Next.jsの環境構築

基本的にはドキュメントどおりに進めていきます。

またディレクトリは先程作成したscriptsの上に作ります

$ cd ..
$ npx create-next-app@latest
❯ npx create-next-app@latest
Need to install the following packages:
[email protected]
Ok to proceed? (y) y

✔ What is your project named? … client
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for next dev? … No / Yes
✔ Would you like to customize the import alias (@/* by default)? … No / Yes
Creating a new Next.js app in /home/jinwatanabe/workspace/qiit/rag-chatbot/client.

clientというプロジェクトを作り、TypeScriptTailwindCSSを入れるようにしています。
インストールが終わったらサーバーを起動します。

$ cd client
$ npm run dev

http://localhost:3000/を開いて以下の画面がでれば環境構築は終了です。

image.png

2. チャットボットの画面を作る

今回はこのようなチャットボットを目指して開発を進めていきます!

image.png

今回もopenaiとastradb`は利用するのでインストールしておきましょう

$ npm i @ai-sdk/openai @datastax/astra-db-ts ai openai

それぞれについては詳しくあとで説明します。

まずは最低限のUIを作ってチャットボットの動きを確かめられるようにしましょう

app/page.tsx
"use client";
import { useChat } from "ai/react";

export default function Home() {
  const {
    input,
    messages,
    handleInputChange,
    handleSubmit,
  } = useChat();

  return (
    <div>
      {messages.map((message, index) => (
        <div key={index}>
          <p>{message.content}</p>
        </div>
      ))}
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="Search"
          value={input}
          onChange={handleInputChange}
        />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

まずはHTML部分の解説をしていきます。
ここではAIと私達がやり取りしてきたメッセージの履歴messagesを1つ1つ表示しています。

      {messages.map((message, index) => (
        <div key={index}>
          <p>{message.content}</p>
        </div>
      ))}

messagesはuseChatというライブラリから提供されているhook関数を利用して返却されたmessagesを利用しています。useChatを使うことでチャットボットの実装をより簡単にできます。

  const {
    input,
    messages,
    handleInputChange,
    handleSubmit,
  } = useChat();

次にフォームの実装を見てきます。

      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="Search"
          value={input}
          onChange={handleInputChange}
        />
        <button type="submit">Send</button>
      </form>

<form>タグでインプットフォームとボタンを囲んでいます。

インプットフォームで入力されたときにuseChatで提供されているhandleInputChangeを呼ぶようにしています。これはuseChatで入力されている質問を認識するためです。

        <input
          type="text"
          placeholder="Search"
          value={input}
          onChange={handleInputChange}
        />
  const {
    input,
    messages,
    handleInputChange,
    handleSubmit,
  } = useChat();

ボタンはクリックするとsubmitイベントが発火するようになっています。

<button type="submit">Send</button>

submitイベントが発火するとformタグのhandleSubmitが実行されます。これもuseChatで提供されているものです。useChatではこの関数が実行されたら現在入力されている質問を使ってAIに問い合わせる処理を勝手にやってくれます。

本来なら入力を受け取ってChatGPTに投げる実装を1からしないといけないのですが、useChatを使うことで簡単にすることができました。

画面はこのように最低限必要なものだけを表示しています。

image.png

質問をいれて試しに「Send」をクリックしてみましょう

image.png

すると右側のネットワークタブでhttp//localhost:3000/api/chat404エラーになったことがわかります。つまりuseChatは質問文を受け取ってhttp//localhost:3000/api/chatに投げていることがわかります。

次はhttp//localhost:3000/api/chatでAIに対して問い合わせができるAPIをNext.jsで実装します。

3. Next.jsでAPIを作る

このハンズオンをやっている人の中にはNext.jsを初めて触るという人も多いはずです。
今回ReactではなくNext.jsを使っている理由で特に知ってほしいことを紹介します。

image.png

この中で特に今回重要となるのが①と③です。

Reactは基本クライアントサイドで実装することになります。
例えば以下のサイトはReactで開発したサイトです。ネットワークで取得したJavaScriptをみるとシークレットキーが見れることがわかります。

image.png

私のハンズオンではReactに集中してほしいのと一般公開をする前提ではないので、このようにしていることも多いです。しかし本来はNext.jsやバックエンドを用意してシークレットキーは隠さないといけません。

APIを開発する

Next.jsでAPIを開発するのは簡単です。Next.jsではディレクトリ構成がそのままルーティングになっているので、src/apiにディレクトリを作るとその構造がそのままREST APIのパスに対応します。

$ mkdir app/api
$ mkdir app/api/chat
$ touch app/api/chat/route.ts

このようにすると/api/chatのAPIを作ることが可能です。
それでは実際にコードを実装します。

app/api/chat/route.ts
import { DataAPIClient } from "@datastax/astra-db-ts";
import OpenAI from "openai";
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";

const {
  ASTRA_DB_NAMESPACE,
  ASTRA_DB_COLLECTION,
  ASTRA_DB_API_ENDPOINT,
  ASTRA_DB_APPLICATION_TOKEN,
  OPENAI_API_KEY,
} = process.env;

const openAIClient = new OpenAI({ apiKey: OPENAI_API_KEY });

const client = new DataAPIClient(ASTRA_DB_APPLICATION_TOKEN);
const db = client.db(ASTRA_DB_API_ENDPOINT!, { namespace: ASTRA_DB_NAMESPACE });

export async function POST(req: Request) {
  const { messages } = await req.json();
  const latestMessage = messages[messages.length - 1]?.content;

  let docContext = "";

  const embeddings = await openAIClient.embeddings.create({
    model: "text-embedding-3-small",
    input: latestMessage,
    encoding_format: "float",
  });

  const collection = await db.collection(ASTRA_DB_COLLECTION!);
  const cursor = collection.find(
    {},
    {
      sort: {
        $vector: embeddings.data[0].embedding,
      },
      limit: 10,
    }
  );

  const documents = await cursor.toArray();

  for await (const document of documents) {
    docContext += document.text + " ";
  }

  const template = {
    role: "system",
    content: `
      あなたはアニメについて詳しいです。
      コンテキストで受け取った情報を元に、アニメについての質問に答えることができます。
      これらのコンテキストは最近のWikiページから抽出されました。
      もしない情報がある場合はあなたの情報を使わないでください。
      レスポンスに画像は含めないでください。
      ----------------
      ${docContext}
      ----------------
      Questions: ${latestMessage}
      ----------------

    `,
  };

  const result = await streamText({
    model: openai("gpt-3.5-turbo"),
    prompt: template.content,
  });

  return result.toDataStreamResponse();
}

詳しく解説していきます。

まず初めにAPIの雛形はこのようになっています。

export async function POST(req: Request) {
}

これでPOSTリクエストで/api/chatを叩けるようになります。

次に今回使うOpenAIAstrDBのクライアントを初期化しておきます。

const {
  ASTRA_DB_NAMESPACE,
  ASTRA_DB_COLLECTION,
  ASTRA_DB_API_ENDPOINT,
  ASTRA_DB_APPLICATION_TOKEN,
  OPENAI_API_KEY,
} = process.env;

const openAIClient = new OpenAI({ apiKey: OPENAI_API_KEY });

const client = new DataAPIClient(ASTRA_DB_APPLICATION_TOKEN);
const db = client.db(ASTRA_DB_API_ENDPOINT!, { namespace: ASTRA_DB_NAMESPACE });

ここでnext.js側にも.envが必要になるのでコピーしておきます。

$ cp ../scripts/.env ./.env

次に図解でいう「①質問を-1〜1のベクトル化をする」箇所の実装をします。

7.png

  const { messages } = await req.json();
  const latestMessage = messages[messages.length - 1]?.content;

  let docContext = "";

  const embeddings = await openAIClient.embeddings.create({
    model: "text-embedding-3-small",
    input: latestMessage,
    encoding_format: "float",
  });

POSTで送られてきたメッセージから最新のメッセージ(つまり質問)を取得します。
そのあとにOpenAPIのルールでベクトル化をします。

次に「②類似しているベクトルを取得する」処理を行います。

  const collection = await db.collection(ASTRA_DB_COLLECTION!);
  const cursor = collection.find(
    {},
    {
      sort: {
        $vector: embeddings.data[0].embedding,
      },
      limit: 10,
    }
  );

  const documents = await cursor.toArray();

  for await (const document of documents) {
    docContext += document.text + " ";
  }

これはAstra DBでクエリ感覚で取得することができるので10件取得しました。
取得したデータにはベクトルと対応するテキストが保存されているので、テキスト部分を1つの文章にまとめる処理をしています。

8.png

最後に文章の中から答えを見つけてもらう処理を行います。

const template = {
    role: "system",
    content: `
      あなたはアニメについて詳しいです。
      コンテキストで受け取った情報を元に、アニメについての質問に答えることができます。
      これらのコンテキストは最近のWikiページから抽出されました。
      もしない情報がある場合はあなたの情報を使わないでください。
      レスポンスに画像は含めないでください。
      ----------------
      ${docContext}
      ----------------
      Questions: ${latestMessage}
      ----------------

    `,
  };

  const result = await streamText({
    model: openai("gpt-3.5-turbo"),
    prompt: template.content,
  });

  return result.toDataStreamResponse();

streamTextを使ってChatGPTに問い合わせることで、生成されている回答を1文字ずつリアルタイムに取得することができます。こうすることで全部の文章が生成される前に徐々にAPIレスポンスとして返却することができます。

それでは実際に叩いてみましょう

$ npm run dev
$ curl localhost:3000/api/chat -H "Content-Type: application/json" -d '{ "messages": [ { "content": "推しの子ってどんなアニメ?" } ] }'

0:"推"
0:"し"
0:"の"
0:"子"
0:"は"
0:"、"
0:"赤"
0:"坂"
0:"ア"
0:"カ"
0:"原"
0:"作"
0:"、"
0:"横"
0:"槍"
0:"メ"
0:"ン"
0:"ゴ"
0:"作"
0:"画"
0:"に"
0:"よ"
0:"る"
0:"同"
0:"名"
0:"の"
0:"漫"
0:"画"
0:"を"
(以下続く)

1文字1文字が正しく返るようになりました。では実際にWeb画面からも実行してみましょう!

image.png

ChatGPTに質問したときのように1文字ずつ表示がされました。
また回答もしっかりしているので問題なさそうです。

デザインを整えよう

最後にTailwindCSSを使っていい感じのデザインに変えていきます。
今回一部画像を利用しているので以下の画像をpublicにいれるようにしてください。

またshadcn/uiも使っているためインストールをしていきます。

❯ npx shadcn@latest init -d
✔ Preflight checks.
✔ Verifying framework. Found Next.js.
✔ Validating Tailwind CSS.
✔ Validating import alias.
✔ Writing components.json.
✔ Checking registry.
✔ Updating tailwind.config.ts
✔ Updating app/globals.css
  Installing dependencies.

It looks like you are using React 19. 
Some packages may fail to install due to peer dependency issues in npm (see https://ui.shadcn.com/react-19).

✔ How would you like to proceed? › Use --legacy-peer-deps
✔ Installing dependencies.
ℹ Updated 1 file:
  - lib/utils.ts

Success! Project initialization completed.
You may now add components.


ここでlegacy-peer-depsを選択してください。
これはReact19に対する対応です

必要なコンポーネントをインストールします。

$ npx shadcn add button input scroll-area avatar
✔ Checking registry.
  Installing dependencies.

It looks like you are using React 19. 
Some packages may fail to install due to peer dependency issues in npm (see https://ui.shadcn.com/react-19).

✔ How would you like to proceed? › Use --legacy-peer-deps
✔ Installing dependencies.
✔ Created 4 files:
  - components/ui/button.tsx
  - components/ui/input.tsx
  - components/ui/scroll-area.tsx
  - components/ui/avatar.tsx


$ npm i zod //依存ライブラリ

ここもlegacy-peer-depsを選択しています。

それではスタイルを当てていきましょう。

app/page.tsx
"use client";

import { useChat } from "ai/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Send } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";

export default function Home() {
  const {
    input,
    messages,
    handleInputChange,
    handleSubmit,
  } = useChat({
    api: '/api/chat',
    initialMessages: [
      {
        id: 'welcome',
        role: 'assistant',
        content: 'こんにちは!アニメについて何でも聞いてください!',
      },
    ],
  });

  return (
    <div className="relative w-full h-screen overflow-hidden">
      <div 
        className="absolute inset-0 z-0"
        style={{
          backgroundImage: 'url("/girl-background.jpg")',
          backgroundSize: 'cover',
          backgroundPosition: 'center'
        }}
      />
      
      <div className="absolute bottom-[70px] left-0 right-0 z-10 h-[calc(50%-70px)] overflow-y-auto p-4">
        <ScrollArea className="h-full">
          {messages.map((message) => (
            <div
              key={message.id}
              className={`flex items-end mb-4 ${
                message.role === 'assistant' ? 'justify-start' : 'justify-end'
              }`}
            >
              {message.role === 'assistant' && (
                <Avatar className="w-8 h-8 mr-2">
                  <AvatarImage src="/placeholder.svg" alt="AI" />
                  <AvatarFallback>AI</AvatarFallback>
                </Avatar>
              )}
              <div
                className={`max-w-[70%] p-3 rounded-2xl ${
                  message.role === 'assistant'
                    ? 'bg-white text-gray-800 rounded-bl-none'
                    : 'bg-blue-500 text-white rounded-br-none'
                }`}
              >
                {message.content}
              </div>
            </div>
          ))}
        </ScrollArea>
      </div>

      <div className="absolute bottom-0 left-0 right-0 z-20 p-4 bg-white bg-opacity-80">
        <form onSubmit={handleSubmit} className="flex items-center gap-2">
          <Input
            value={input}
            onChange={handleInputChange}
            placeholder="メッセージを入力..."
            className="flex-1 rounded-full border-gray-300 focus:border-blue-400 focus:ring-blue-400"
          />
          <Button 
            type="submit"
            size="icon"
            className="rounded-full bg-blue-500 hover:bg-blue-600"
          >
            <Send className="w-4 h-4" />
          </Button>
        </form>
      </div>
    </div>
  );
}

image.png

課題にチャレンジ

ここまでの内容をより理解するために課題を用意しましたのでぜひチャレンジしてみてください

  1. AIの回答にAnswerという文字があるので表示しないようにする
  2. いま上映されている映画のチャットボットを作成する

おわりに

いかがでしたでしょうか?
RAGを通してどのようにAIが動いているのかを理解できたはずです。これらがわかるとGPTSがどのように動いているのかもわかりますね。

ぜひ学んだことを生かしてアプリを開発してみてください!

テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください。

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください!
▼▼▼

本記事のレビュアーの皆様

  • tokec様
  • 吉田侑平様
  • Coby様
  • 上嶋晃太様

次回のハンズオンのレビュアーはXにて募集します。

ハンズオンたくさん投稿しています!

101
93
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
101
93

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?