【Nextjs】【TypeScript】OpenAI APIとLangChainを使ってPDFの内容を質問したい

2023/05/31に公開

まえがき

OpenAI APIとLangChainを使ってPDFの内容を質問する機能を構築します。
その中でOpenAI APIとLangChainの使い方でわかったことを記録します。

開発環境

プロジェクトを新規作成

まずは新規プロジェクトを作成します。

https://beta.nextjs.org/docs/installation#automatic-installation

上記のHPを参考に以下のコマンドを実行します。
ここではプロジェクト名をnext-langchainとします。

npx create-next-app@latest next-langchain

実行後、聞かれる質問はそのまま答えてください。
プロジェクトが作成されたら、ディレクトリに移動します。
移動したら一度立ち上げて見ます。

cd next-langchain
npm run dev

プロジェクトのコードをスッキリしておく

コードを見るとごちゃごちゃしているので、app/page.tsxをスッキリしておきます。

app/page.tsx
export default function Home() {
  return (
    <main>
      <h1 className="text-center text-3xl">langchain</h1>
    </main>
  );
}

またapp/globals.cssはTailwind CSS以外は削除します。

app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

ついでにapp/layout.tsxもスッキリしておきます。

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をインストールします。
以下のリンクに解説されています。

https://js.langchain.com/docs/getting-started/install#installation

npm install -S langchain

PDFを読み込む

ここからは以下のリンクのドキュメントを参考に進めます。

https://js.langchain.com/docs/modules/indexes/document_loaders/examples/file_loaders/pdf#usage-one-document-per-file

読み込むPDFのサンプルとしてpg.pdfを用意しました。

pdf-parseをインストール

PDFを読み込むためにpdf-parseをインストールします。

npm install pdf-parse

まずは読み込んでみる

まずはドキュメントを参考にapp/page.tsxを以下のコードに変更します。
return以降のJSXの部分はスッキリさせておきます。

app/page.tsx
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が要素として配列にまとめられています。
詳しい内容は以下のリンクのドキュメントに記載されています。

https://js.langchain.com/docs/modules/schema/document

オブジェクトからpageContentの内容を取り出す

オブジェクトからpageContentの内容を取り出すには、まずは配列の要素を取り出します。
ここではres_pdfの後に[0]をつけて最初のページの要素を取り出します。

app/page.tsx
- console.log(res_pdf);
+ console.log(res_pdf[0]);

これでオブジェクトのデータを取り出せます。
ちょっと長いですが、内容を見てみます。
確認すると余計な\nが大量にあります。
データと扱うためにスッキリさせたいので\nを削除します。
しかし、このままでは編集できないので、pageContentの内容を取り出します。

app/page.tsx
- console.log(res_pdf[0]);
+ console.log(res_pdf[0].pageContent);

次はメソッドreplaceを使って\nをスペース に置換します。
ついでに数字の桁区切り記号も削除します。

app/page.tsx
- console.log(res_pdf[0].pageContent);
+ console.log(res_pdf[0].pageContent.replace(/\n/g, " ").replace(/,/g, ""));

出力結果を見ると無事に\nが削除されました。

複数ページをまとめて読み込む

まずは以下のようにコードを変更します。

app/page.tsx
- 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に渡します。

app/page.tsx
- console.log(res_pdf[0].pageContent.replace(/\n/g, " ").replace(/,/g, ""));
+ const str = res_pdf[0].pageContent.replace(/\n/g, " ").replace(/,/g, "");

文字列を分割する

ここからは以下のリンクのドキュメントを参考に進めます。

https://js.langchain.com/docs/modules/indexes/text_splitters/examples/character

分割の設定をする

データを分割するサイズを設定するためにCharacterTextSplitterを使います。
以下のコードのようにインポートします。

app/page.tsx
import { CharacterTextSplitter } from "langchain/text_splitter";

次に新しいオブジェクトを作成し、変数splitterに渡します。
そのコンストラクタに3つの引数としてを渡します。

  • separator: テキストをチャンクに分割するために使用される文字。
  • chunkSize: 分割後の各チャンク(断片)の最大文字数です。
  • chunkOverlap: 各チャンクが前のチャンクと重複する文字数。
app/page.tsx
const splitter = new CharacterTextSplitter({
  separator: " ",
  chunkSize: 512,
  chunkOverlap: 24,
});

separatorをスペース chunkSize512chunkOverlap24で設定しました、
数値は以下の記事を参考にさせていだたきました。

https://qiita.com/windows222/items/232f05bafa95a9c8874e#コード

splitterオブジェクトが作成された後は、メソッドcreateDocumentsを呼び出してPDFから取り出した文字列を分割します。
分割した結果をオブジェクトにして変数outputに渡します。

app/page.tsx
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型の値のオブジェクトになります。
以下のリンクに詳しい解説が載っています。

https://typescriptbook.jp/reference/type-reuse/utility-types/record

次にDocument<Record<string, any>>Record<string, any>型のオブジェクトになります。
最後にDocument<Record<string, any>>[]Record<string, any>型のオブジェクトの配列になります。

配列内のオブジェクトを取り出す

今までは出力結果をコンソールで確認してきました。
ここからはブラウザー上で見られるようにJSX内を編集します。
mapメソッドを使って要素を取り出します。
以下の記事を参考にさせていだたきました。

https://www.javadrive.jp/javascript/array/index17.html#section1

以下のコードをJSX内に追加します。

app/page.tsx
  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

以下のリンクのドキュメントを参考に進めます。

https://js.langchain.com/docs/modules/models/llms/integrations#openai

まずは以下のコードのようにOpenAIをインポートします。

import { OpenAI } from "langchain/llms/openai";

次に新しいオブジェクトを作成し、変数modelに渡します。
そのコンストラクタに引数としてopenAIApiKey: process.env.OPENAI_API_KEYというオプションを指定します。
APIキーは他人に知られると勝手に使われたりする危険性があるので、ファイル.env.localOPENAI_API_KEYの値で記録してprocess.env.OPENAI_API_KEYで呼び出します。

const model = new OpenAI({
  openAIApiKey: process.env.OPENAI_API_KEY
});

クラスOpenAIcall()メソッドを使用して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

以下のリンクのドキュメントを参考に進めます。

https://js.langchain.com/docs/modules/models/embeddings/additional_functionality#adding-a-timeout

まずは以下のコードのように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を使用します。

以下のリンクのドキュメントを参考に進めます。

https://js.langchain.com/docs/modules/indexes/vector_stores/integrations/memory

まずは以下のコードのように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を使用します。
以下のリンクのドキュメントを参考に進めます。

https://js.langchain.com/docs/modules/chains/index_related_chains/document_qa

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);

これで基本形ができました。
ここまでのコードをまとめると以下のようになります。

app/page.tsx
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両方に対応しています。
みなさんの投資ライフに少しでも活用していただきれば幸いです。
以下のリンクからそれぞれのサイトに移動してダウンロードをお願いします。
https://jpstockminimemo.arafipro.com/
https://usstockminimemo.arafipro.com/

あとがき

youtubeチャンネル「typescriptでフルスタックエンジニアになる」を運営しています。

https://www.youtube.com/channel/UCqmIGhDsE5y-fmjtp8SnblQ/

Cloudflare WorkersにAPIを作成したり、Cloudflare Pagesにデプロイする前提でフォームの作り方や認証機能の使い方を動画にしています。
ぜひ、ご覧ください。

GitHubで編集を提案

Discussion