【Nextjs】【TypeScript】OpenAI APIとLangChainを使ってPDFの内容を質問したい
まえがき
OpenAI APIとLangChainを使ってPDFの内容を質問する機能を構築します。
その中でOpenAI APIとLangChainの使い方でわかったことを記録します。
開発環境
プロジェクトを新規作成
まずは新規プロジェクトを作成します。
上記のHPを参考に以下のコマンドを実行します。
ここではプロジェクト名をnext-langchain
とします。
npx create-next-app@latest next-langchain
実行後、聞かれる質問はそのまま答えてください。
プロジェクトが作成されたら、ディレクトリに移動します。
移動したら一度立ち上げて見ます。
cd next-langchain
npm run dev
プロジェクトのコードをスッキリしておく
コードを見るとごちゃごちゃしているので、app/page.tsx
をスッキリしておきます。
export default function Home() {
return (
<main>
<h1 className="text-center text-3xl">langchain</h1>
</main>
);
}
またapp/globals.css
はTailwind CSS以外は削除します。
@tailwind base;
@tailwind components;
@tailwind utilities;
ついでにapp/layout.tsx
もスッキリしておきます。
import "./globals.css";
export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>{children}</body>
</html>
);
}
LangChainをインストール
LangChainをインストールします。
以下のリンクに解説されています。
npm install -S langchain
PDFを読み込む
ここからは以下のリンクのドキュメントを参考に進めます。
読み込むPDFのサンプルとしてpg.pdf
を用意しました。
pdf-parseをインストール
PDFを読み込むためにpdf-parseをインストールします。
npm install pdf-parse
まずは読み込んでみる
まずはドキュメントを参考にapp/page.tsx
を以下のコードに変更します。
return
以降のJSXの部分はスッキリさせておきます。
import { PDFLoader } from "langchain/document_loaders/fs/pdf";
export default async function Home() {
const loader = new PDFLoader("data/pg.pdf");
const res_pdf = await loader.load();
console.log(res_pdf);
return (
<main>
<h1 className="text-center text-3xl">langchain</h1>
</main>
);
}
PDFLoader
という新しいオブジェクトを作成し、変数loader
に渡します。
そのコンストラクタに引数としてdata/pg.pdf
というPDFファイルのパスを渡します。
次にloader.load()
メソッドを呼び出し、出力結果を変数にres_pdf
に格納します。
最後にconsole.log(res_pdf)
を使ってPDFの読み込み結果をコンソールに出力します。
確認すると以下のように各ページごとにオブジェクトが出力されます。
[
Document {
pageContent: "(本文)"
metadata: { source: "data/pg.pdf", pdf: [Object], loc: [Object] }
},
<!-- 中略 -->
Document {
pageContent: "(本文)"
metadata: { source: "data/pg.pdf", pdf: [Object], loc: [Object] }
}
]
出力結果を見ると、Document
に各ページの要素がオブジェクトでまとめられています。
Document
の中のpageContent
に内容が格納されます。
そして、複数のDocument
が要素として配列にまとめられています。
詳しい内容は以下のリンクのドキュメントに記載されています。
pageContent
の内容を取り出す
オブジェクトからオブジェクトからpageContent
の内容を取り出すには、まずは配列の要素を取り出します。
ここではres_pdf
の後に[0]
をつけて最初のページの要素を取り出します。
- console.log(res_pdf);
+ console.log(res_pdf[0]);
これでオブジェクトのデータを取り出せます。
ちょっと長いですが、内容を見てみます。
確認すると余計な\n
が大量にあります。
データと扱うためにスッキリさせたいので\n
を削除します。
しかし、このままでは編集できないので、pageContent
の内容を取り出します。
- console.log(res_pdf[0]);
+ console.log(res_pdf[0].pageContent);
次はメソッドreplace
を使って\n
をスペース
に置換します。
ついでに数字の桁区切り記号も削除します。
- console.log(res_pdf[0].pageContent);
+ console.log(res_pdf[0].pageContent.replace(/\n/g, " ").replace(/,/g, ""));
出力結果を見ると無事に\n
が削除されました。
複数ページをまとめて読み込む
まずは以下のようにコードを変更します。
- const loader = new PDFLoader("data/pg.pdf");
+ const loader = new PDFLoader("data/pg.pdf", {
+ splitPages: false,
+ });
PDFLoader
のコンストラクタに2番目の引数としてsplitPages: false
というオプションを指定します。
このオプションはPDFをページごとに分割するかどうかを制御します。
false
が指定されているため、PDFは分割されずに1つのオブジェクトとして読み込まれます。
出力結果を見ると、Document
にすべてのページの要素がオブジェクトでまとめられています。
そして、Document
が要素として配列にまとめられています。
[
Document {
pageContent: "(本文)"
metadata: { source: 'data/pg.pdf', pdf: [Object] }
}
]
ここまでconsole.log()
を使って出力データを確認しましたが、いったん変数str
に渡します。
- console.log(res_pdf[0].pageContent.replace(/\n/g, " ").replace(/,/g, ""));
+ const str = res_pdf[0].pageContent.replace(/\n/g, " ").replace(/,/g, "");
文字列を分割する
ここからは以下のリンクのドキュメントを参考に進めます。
分割の設定をする
データを分割するサイズを設定するためにCharacterTextSplitter
を使います。
以下のコードのようにインポートします。
import { CharacterTextSplitter } from "langchain/text_splitter";
次に新しいオブジェクトを作成し、変数splitter
に渡します。
そのコンストラクタに3つの引数としてを渡します。
-
separator
: テキストをチャンクに分割するために使用される文字。 -
chunkSize
: 分割後の各チャンク(断片)の最大文字数です。 -
chunkOverlap
: 各チャンクが前のチャンクと重複する文字数。
const splitter = new CharacterTextSplitter({
separator: " ",
chunkSize: 512,
chunkOverlap: 24,
});
separator
をスペース
、chunkSize
を512
、chunkOverlap
を24
で設定しました、
数値は以下の記事を参考にさせていだたきました。
splitter
オブジェクトが作成された後は、メソッドcreateDocuments
を呼び出してPDFから取り出した文字列を分割します。
分割した結果をオブジェクトにして変数output
に渡します。
const output = await splitter.createDocuments([str]);
console.log(output);
console.log(output.length);
コンソールで確認するとオブジェクトは配列に格納されます。
また配列の要素数を確認すると49個ありました。
Document<Record<string, any>>[]
型
output
の型を確認するとDocument<Record<string, any>>[]
となっています。
私の理解度が何となくわかる程度なので詳しく見て行きます。
まずはRecord<string, any>
の部分を見ると、string
型のキーとany
型の値のオブジェクトになります。
以下のリンクに詳しい解説が載っています。
次にDocument<Record<string, any>>
はRecord<string, any>
型のオブジェクトになります。
最後にDocument<Record<string, any>>[]
はRecord<string, any>
型のオブジェクトの配列になります。
配列内のオブジェクトを取り出す
今までは出力結果をコンソールで確認してきました。
ここからはブラウザー上で見られるようにJSX内を編集します。
map
メソッドを使って要素を取り出します。
以下の記事を参考にさせていだたきました。
以下のコードをJSX内に追加します。
return (
<main>
<h1 className="text-center text-3xl">langchain</h1>
+ {output.map((obj, index) => (
+ <div key={index} className="p-2">
+ <p className="text-center text-2xl pb-2">Chank {index}</p>
+ <p>{obj.pageContent}</p>
+ </div>
))}
</main>
);
map
メソッドを使うときにはkey
属性に一意な値を指定する必要があります。
map
メソッドのコールバック関数に要素の値と要素のインデックスを呼び出します。
そしてkey
属性の値に要素のインデックスを指定します。
LangChainの各機能
LangChain LLMs
以下のリンクのドキュメントを参考に進めます。
まずは以下のコードのようにOpenAI
をインポートします。
import { OpenAI } from "langchain/llms/openai";
次に新しいオブジェクトを作成し、変数model
に渡します。
そのコンストラクタに引数としてopenAIApiKey: process.env.OPENAI_API_KEY
というオプションを指定します。
APIキーは他人に知られると勝手に使われたりする危険性があるので、ファイル.env.local
にOPENAI_API_KEY
の値で記録してprocess.env.OPENAI_API_KEY
で呼び出します。
const model = new OpenAI({
openAIApiKey: process.env.OPENAI_API_KEY
});
クラスOpenAI
のcall()
メソッドを使用してChatGPTに文章を渡しています。
call()
メソッドは与えられた文章に対してChatGPTが生成した応答をJSON形式で返します。
console.log()
でコンソールに出力されます。
const res = await model.call(
"What would be a good company name a company that makes colorful socks?"
);
console.log({ res });
LangChain Embeddings
以下のリンクのドキュメントを参考に進めます。
まずは以下のコードのようにOpenAIEmbeddings
をインポートします。
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
次に新しいオブジェクトを作成し、変数embeddings
に渡します。
そのコンストラクタに引数としてopenAIApiKey: process.env.OPENAI_API_KEY
というオプションを指定します。
ここまではLLMsの時と一緒です。
const embeddings = new OpenAIEmbeddings({
openAIApiKey: process.env.OPENAI_API_KEY
});
LangChain Vector Stores
Vector Storesは、Embeddingsしたベクトルデータベースを保存し、特定のクエリに対してもっとも関連性の高い文書、すなわちクエリの埋め込みともっとも類似した埋め込みを持つ文書を取り出すために最適化されたデータベースの一種です。
ここではメモリ内の保存するMemoryVectorStore
を使用します。
以下のリンクのドキュメントを参考に進めます。
まずは以下のコードのようにMemoryVectorStore
をインポートします。
import { MemoryVectorStore } from "langchain/vectorstores/memory";
次に新しいオブジェクトを作成し、変数store
に渡します。
そのコンストラクタに2つの引数を渡します。
最初の引数には分割したデータを格納した変数output
を、次の引数には変数embeddings
を渡します。
const store = await MemoryVectorStore.fromDocuments(output, embeddings);
変数question
に質問を代入しておきます。
const question = "What is the name of your company?"; //会社名は何ですか?
最後にstore
のメソッドsimilaritySearch
を呼び出して、変数question
を引数として渡します。
const relevantDocs = await store.similaritySearch(question);
LangChain Chains
Chains
とは言語モデルを他の情報源やサードパーティーのAPI、あるいは他の言語モデルと組み合わせる概念(だそう)です。
今回はDocument
に対して質問に答えてもらいたいので、DocumentsChain
を使用します。
以下のリンクのドキュメントを参考に進めます。
DocumentsChain
には3種類あります。
-
StuffDocumentsChain
:このチェーンは、3つの中でもっとも単純なものです。入力されたすべての文書をコンテキストとしてプロンプトに注入し、質問に対する答えを返すだけです。少数の文書に対するQAタスクに適しています。 -
MapReduceDocumentsChain
: このチェーンには前処理ステップが組み込まれており、トークンの総数がモデルで許容される最大トークン数未満になるまで、各文書から関連するセクションを選択します。そして、変換された文書をコンテキストとして使用し、質問に答えます。より大きな文書を対象としたQAタスクに適しており、前処理ステップを並行して実行できるため、実行時間が短縮されます。 -
RefineDocumentsChain
: このチェーンは、入力文書を1つずつ反復処理し、反復処理ごとに中間的な答えを更新します。このチェーンでは、前のバージョンの答えと次の文書をコンテキストとして使用します。大量の文書を対象としたQAタスクに適しています。
説明を読んで、今回はloadQAMapReduceChain
がよさそうなので使用します。
まずは以下のコードのようにloadQAMapReduceChain
をインポートします。
import { loadQAMapReduceChain } from "langchain/chains";
次に新しいオブジェクトを作成し、変数chain
に渡します。
そのコンストラクタに引数としてmodel
を指定します。
const chain = loadQAMapReduceChain(model);
最後にchain
のメソッドcall()
を呼び出して、引数としてプロパティinput_documents: relevantDocs
と変数question
を渡します。
結果は変数res
に渡します。
const res = await chain.call({
input_documents: relevantDocs,
question,
});
res
をコンソールで確認します。
res
はオブジェクトで要素output_text
の値に結果が格納されているので、以下のコードのようにします。
console.log(res.output_text);
これで基本形ができました。
ここまでのコードをまとめると以下のようになります。
import { PDFLoader } from "langchain/document_loaders/fs/pdf";
import { CharacterTextSplitter } from "langchain/text_splitter";
import { OpenAI } from "langchain/llms/openai";
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { loadQAMapReduceChain } from "langchain/chains";
export default async function Home() {
const loader = new PDFLoader("data/pg.pdf", {
splitPages: false,
});
const res_pdf = await loader.load();
const str = res_pdf[0].pageContent.replace(/\n/g, " ").replace(/,/g, "");
const splitter = new CharacterTextSplitter({
separator: " ",
chunkSize: 512,
chunkOverlap: 24,
});
const output = await splitter.createDocuments([str]);
const model = new OpenAI({
openAIApiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY,
});
const embeddings = new OpenAIEmbeddings({
openAIApiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY,
});
const store = await MemoryVectorStore.fromDocuments(output, embeddings);
const question = "What is the name of your company?"; //会社名は何ですか?
const relevantDocs = await store.similaritySearch(question);
const chain = loadQAMapReduceChain(model);
const res = await chain.call({
input_documents: relevantDocs,
question,
});
console.log(res.output_text);
return (
<main>
<h1 className="text-center text-3xl">langchain</h1>
{output.map((obj, index) => (
<div key={index} className="p-2">
<p className="text-center text-2xl pb-2">Chank {index}</p>
<p>{obj.pageContent}</p>
</div>
))}
</main>
);
}
せっかくなので結果をブラウザー上で見られるようにします。
JSX内を以下のように変更します。
<main>
<h1 className="text-center text-3xl">langchain</h1>
<p className="text-center pt-2">{res.text}</p>
</main>
見事、表示されました。
以上でとりあえず完成です。
甘い考えかも知れませんが、ロジックさえできればどうにかなると思っています。
これで機能のサンプルコレクションがまた1つできました。
歯車の再発明?
同様のサービスで「ChatPDF」が公開されています。
機能は私が作りたかったものもほぼ同じものです。
(私のものはクオリティが低いです)
歯車の再発明となってしまいますが、仕組みを理解するためによかったです。
スマホアプリ「ひとこと投資メモ」シリーズをリリース
記事とは関係ないことですが、最後にお知らせです。
Flutter学習のアウトプットの一環として「日本株ひとこと投資メモ」「米国株ひとこと投資メモ」を公開しています。
簡単に使えるライトな投資メモアプリです。
iPhone、Android両方に対応しています。
みなさんの投資ライフに少しでも活用していただきれば幸いです。
以下のリンクからそれぞれのサイトに移動してダウンロードをお願いします。
あとがき
youtubeチャンネル「typescriptでフルスタックエンジニアになる」を運営しています。
Cloudflare WorkersにAPIを作成したり、Cloudflare Pagesにデプロイする前提でフォームの作り方や認証機能の使い方を動画にしています。
ぜひ、ご覧ください。
Discussion