はじめに
みなさんこんにちは、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点について詳しく解説していきます。
しっかり理解することで実装パートでより理解しながら進めることが可能です。
まずはRAGについて解説します。
RAGとはAIモデル(ChatGPT)に外部データを渡してそこから答えを導いてもらうアプローチだと思ってくれれば大丈夫です。こうすることでAI自体に文章から答えを導き出せる能力があれば、最新情報を渡して答えを見つけてもらうことで新しい情報を答えさせることができます。
イメージしやすい例をあげると、「誰かに自分の代わりに質問の答えを調べてもらう」感じです。
① あなたが2023年の総理大臣を友達に質問します。
② 友達は海外の友達だったので、もちろん総理大臣など知りません。
③ そこでwikipediaを検索して総理大臣を特定しました
④ 「2023年は〇〇総理大臣だよ」
このような流れを行うのがRAGの仕組みになっています。
実際のシステムの仕組みはこのようになっています。
① 質問をアプリケーションに入力
② 質問をDBに渡して関連するページのデータを取得(ここでは総理大臣に関連するページ)
③ ChatGPTに関連するページのデータをいくつか渡してその中から総理大臣をみつけてもらう
あとでもっと詳しい設計レベルのお話をしますが、今はこの程度の理解でも問題ありません。
RAGにはいくつかのメリットがあるので紹介します。
先程も説明しましたが、再学習しなくても専門性の高い最新情報を答えてくれるのがメリットです。
またAIモデルも渡した文章から答えを見つけてくれる能力があればRAGを利用できるので軽量なモデルやコストの安いモデルなどでも利用可能です。
アプリの設計を考える
設計を考える上でいくつかのパートに分けるとわかりやすいので解説していきます。
1. 関連ページの取り込み
今回は2024年のアニメに関するページをいくつか取り込みます。
例えば、2024年アニメの全体に関する情報はこちらのページで取り込みます。
特定のアニメに特化したページはこちらで取得します。例えば「推しの子」です。
そして取得したデータはベクトル化という処理を行います。
① ページをクローリング(プログラムで実際に取りに行く)してデータ取得
② ページをいい感じのまとまりの文章ごとに分割する(たとえばパラグラフごとなど)
③ 分割した文章を-1〜1で表現する
④ データベースに文章を保存する
②③に関しては今回利用するAIモデルの中でのルールに沿っていい感じにやってくれるため気にする必要はありません。このルールはモデルごとに異なるため利用するモデルのルールでやる必要があります。
この作業は1度だけやれば十分です。もしデータを最新に更新したい場合は再度DBに更新をかけます。今回は簡単なスクリプトでページのクローリング→ベクトル化→DB保存までをやります。
2. 質問に類似した文を取得する
次に質問を同じくChatGPTのルールでベクトル化して、その文章に類似した文章をDBから取得する処理を行います。
① 質問をモデルのルールで-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に答えを見つけてもらう
近い文章をいくつか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で開きます。
まずは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が作成されます。
{
"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
を追加しました。
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_modules
にts-node
があることがわかります。このライブラリを使ってTypeScriptをJavaScriptに変換しています。
seedコマンドで--project tsconfig.json
としているのでtsconfig.json
を用意します。
$ npx tsc --init
tsconfig.jsonが作成できたら、TypeScriptのファイルを実行してみましょう
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のページ情報を集めてくるスクリプトはこちらになります。
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 DBとChatGPT APIです。
Astra DBはアカウント作成をしてから以下の手順でDB作成とキーの取得をします。
左メニューから「Database」を選択
「Create Database」をクリック
Database name : anime_db
Region : us-east2
に設定して「Create Database」をクリック
初期化(Your database is initializing)が始まるので初期化が終わるまで待ちます
「Generate Token」をクリック
クリップボードにコピーしておきます。
.env
を作ってトークンを入れておきましょう
$ touch .env
ASTRA_DB_NAMESPACE="default_keyspace"
ASTRA_DB_COLLECTION="anime"
ASTRA_DB_API_ENDPOINT="あなたのエンドポイント"
ASTRA_DB_APPLICATION_TOKEN="あなたのトークン"
他にもいくつかの環境変数を用意しました。
あなたのエンドポイント
にエンドポイントも入力します
ASTRA_DB_NAMESPACE
とASTRA_DB_COLLECTION
はこのハンズオンではこの値で大丈夫です。
ChatGPT APIは以下の記事を参考にしてAPIキーを取得してください。
APIキーが取得できたら.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
.env
node_modules
ここまででセットアップはすべて完了ですので、実際にWikipediaのページをベクトル化してDBに保存してみましょう
3. 文章をベクトル化する
まずはOpenAIとAstraDBを簡単に利用できるライブラリをインストールします
環境変数も読み込みたいのでdotenv
もいれました
$ npm i openai @datastax/astra-db-ts dotenv
コードを以下のように実装しました。
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に保存しましょう
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に投げたときに類似するベクトルと対応する文章を返してもらうためです。
※まだ実行はしないでください
今回はわかりやすさを重視してそれぞれの工程を分けましたが本来はメモリを考えるとベクトル化してすぐにデータベースに保存するようにしたほうがよいです。
リファクタリングをしたコードが以下になります。
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をクリック)
終わったらデータベースの準備は終了です。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
というプロジェクトを作り、TypeScript
とTailwindCSS
を入れるようにしています。
インストールが終わったらサーバーを起動します。
$ cd client
$ npm run dev
http://localhost:3000/を開いて以下の画面がでれば環境構築は終了です。
2. チャットボットの画面を作る
今回はこのようなチャットボットを目指して開発を進めていきます!
今回もopenai
とastradb`は利用するのでインストールしておきましょう
$ npm i @ai-sdk/openai @datastax/astra-db-ts ai openai
それぞれについては詳しくあとで説明します。
まずは最低限のUIを作ってチャットボットの動きを確かめられるようにしましょう
"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を使うことで簡単にすることができました。
画面はこのように最低限必要なものだけを表示しています。
質問をいれて試しに「Send」をクリックしてみましょう
すると右側のネットワークタブでhttp//localhost:3000/api/chat
が404
エラーになったことがわかります。つまりuseChatは質問文を受け取ってhttp//localhost:3000/api/chatに投げていることがわかります。
次はhttp//localhost:3000/api/chatでAIに対して問い合わせができるAPIをNext.jsで実装します。
3. Next.jsでAPIを作る
このハンズオンをやっている人の中にはNext.jsを初めて触るという人も多いはずです。
今回ReactではなくNext.jsを使っている理由で特に知ってほしいことを紹介します。
この中で特に今回重要となるのが①と③です。
Reactは基本クライアントサイドで実装することになります。
例えば以下のサイトはReactで開発したサイトです。ネットワークで取得したJavaScriptをみるとシークレットキーが見れることがわかります。
私のハンズオンでは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を作ることが可能です。
それでは実際にコードを実装します。
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
を叩けるようになります。
次に今回使うOpenAI
とAstrDB
のクライアントを初期化しておきます。
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のベクトル化をする」箇所の実装をします。
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つの文章にまとめる処理をしています。
最後に文章の中から答えを見つけてもらう処理を行います。
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画面からも実行してみましょう!
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
を選択しています。
それではスタイルを当てていきましょう。
"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>
);
}
課題にチャレンジ
ここまでの内容をより理解するために課題を用意しましたのでぜひチャレンジしてみてください
- AIの回答にAnswerという文字があるので表示しないようにする
- いま上映されている映画のチャットボットを作成する
おわりに
いかがでしたでしょうか?
RAGを通してどのようにAIが動いているのかを理解できたはずです。これらがわかるとGPTSがどのように動いているのかもわかりますね。
ぜひ学んだことを生かしてアプリを開発してみてください!
テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください。
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください!
▼▼▼
本記事のレビュアーの皆様
- tokec様
- 吉田侑平様
- Coby様
- 上嶋晃太様
次回のハンズオンのレビュアーはXにて募集します。
ハンズオンたくさん投稿しています!