AI / MLJavaScriptLangChain.jsLLMNode.js

LangChain.jsを利用して、任意のGitHubリポジトリの情報を利用したRAGを作ってみた

仕事柄、そしてAWS Community Builderや個人開発者として、いろんなツールやOSSを触ってみようとする機会が日々出てきます。が、全てをしっかりと把握し切るには時間が足りず、タブを積み上げている状態が継続し […]

広告ここから
広告ここまで

仕事柄、そしてAWS Community Builderや個人開発者として、いろんなツールやOSSを触ってみようとする機会が日々出てきます。が、全てをしっかりと把握し切るには時間が足りず、タブを積み上げている状態が継続し続けることも少なくなりません。

この辺りがLangChainやLLMでなんとかならないかなと思い、試行錯誤してみた時の覚書です。

リポジトリのURLなどからEmbeddingを作るクラスを用意する

いろんなリポジトリを読ませてみたいなと思ったので、LoaderやSplitterなどを抽象化するクラスを用意しました。

import { GithubRepoLoader } from "langchain/document_loaders/web/github";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { FaissStore } from "langchain/vectorstores/faiss";
import { Embeddings } from "langchain/embeddings/base";
import { BaseChatModel } from "langchain/dist/chat_models/base";
import { StringOutputParser } from "langchain/schema/output_parser";
import { AIMessagePromptTemplate, ChatPromptTemplate, HumanMessagePromptTemplate } from "langchain/prompts";
import { RunnableConfig, RunnableSequence } from "langchain/schema/runnable";
import { formatDocumentsAsString } from "langchain/util/document";
import { Document } from "langchain/document";

let instance: GithubRetrievalQAChain | null = null;
export const createGithubRetrievalQAChain = (options: GithubRetrievalQAChainParams) => {
    //if (instance) return instance
    instance = new GithubRetrievalQAChain(options)
    return instance
}

type Language = "cpp" | "go" | "java" | "js" | "php" | "proto" | "python" | "rst" | "ruby" | "rust" | "scala" | "swift" | "markdown" | "latex" | "html" | "sol";
export type GithubRetrievalQAChainParams = {
    github: Array<{
        repoURL: string;
        branch: string;
        language: Language
    }>
    githubAccesToken: string;
    model: BaseChatModel;
    embedding: Embeddings;
    directoryPath: string;
}
export class GithubRetrievalQAChain {
    private readonly loaders: Array<{
        loader: GithubRepoLoader;
        splitter: RecursiveCharacterTextSplitter;
    }> = [];
    private readonly embedding: Embeddings;
    private readonly directoryPath: string;
    private readonly model: BaseChatModel;
    constructor(options: GithubRetrievalQAChainParams) {
        options.github.forEach(gh => {
            this.loaders.push({
                loader: new GithubRepoLoader(
                    gh.repoURL,
                    {
                        accessToken: options.githubAccesToken,
                        branch: gh.branch,
                        recursive: true,
                        unknown: "warn",
                        maxConcurrency: 2, // Defaults to 2
                    }
                ),
                splitter: RecursiveCharacterTextSplitter.fromLanguage(gh.language, {
                    chunkSize: 2000,
                    chunkOverlap: 200,
                })
            })
        })
        this.embedding = options.embedding;
        this.directoryPath = options.directoryPath;
        this.model = options.model;
    }

    public async createIndex() {
        const documents: Array<Document> = []
        for await (const repositoryLoader of this.loaders) {
            const docs = await repositoryLoader.loader.loadAndSplit(repositoryLoader.splitter)
            docs.forEach(doc => {
                documents.push(doc)
            })
        }
        const vectorStore = await FaissStore.fromDocuments(documents, this.embedding);
        await vectorStore.save(this.directoryPath);
    }
    public async loadVectorStore() {
        const loadedVectorStore = await FaissStore.load(
          this.directoryPath,
          this.embedding
        );
        return loadedVectorStore
    }
    public async similaritySearch(query: string, k?: number | undefined) {
        const loadedVectorStore = await this.loadVectorStore();
        const result = await loadedVectorStore.similaritySearch(query, k);
        return result;
    }
    public async createQAChain() {
        const loadedVectorStore = await this.loadVectorStore();
        const retriever = loadedVectorStore.asRetriever();
        const model = this.model.pipe(new StringOutputParser())

        const questionGeneratorTemplate = ChatPromptTemplate.fromMessages([
            AIMessagePromptTemplate.fromTemplate(
              "Given the following conversation about a codebase and a follow up question, rephrase the follow up question to be a standalone question."
            ),
            AIMessagePromptTemplate.fromTemplate(`Follow Up Input: {question}
          Standalone question:`),
        ]);
        const combineDocumentsPrompt = ChatPromptTemplate.fromMessages([
            AIMessagePromptTemplate.fromTemplate(
              "Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer. And you need to answer in Japanese.\n\n{context}\n\n"
            ),
            HumanMessagePromptTemplate.fromTemplate("Question: {question}"),
        ]);
        const combineDocumentsChain = RunnableSequence.from([
            {
              question: (output: string) => output,
              context: async (output: string) => {
                const relevantDocs = await retriever.getRelevantDocuments(output);
                return formatDocumentsAsString(relevantDocs);
              },
            },
            combineDocumentsPrompt,
            model,
            new StringOutputParser(),
        ]);
          
        const conversationalQaChain = RunnableSequence.from([
            {
                question: (i: { question: string }) => i.question,
            },
            questionGeneratorTemplate,
            model,
            new StringOutputParser(),
            combineDocumentsChain,
        ]);
        return conversationalQaChain
    }
    public async invokeQAChain(question: string, options?: RunnableConfig) {
        const chain = await this.createQAChain()
        const result = await chain.invoke({question}, options)
        return result
    }
}

createIndexでEmbeddingの生成とインデックスへの保存を行い、invokeQAChainに質問を渡すような使い方をします。

質問とデータ投入のAPIを作る

ローカルで動かす前提でHonoを使ったAPIを作りました。先ほどのクライアントを使って、読み込みのAPIと質問のAPIの2つを作ります。


import { Hono } from "hono";
import { createGithubRetrievalQAChain } from "./helper";
import { env } from "../env";
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
import { ChatOpenAI } from "langchain/chat_models/openai";
import path from 'path';


export const useShoppingCartApp = new Hono()

const createQAChain = () => {
    const embedding = new OpenAIEmbeddings({
        openAIApiKey: env.openai.apiKey
    })
    const model = new ChatOpenAI({
        modelName: 'gpt-4',
        openAIApiKey: env.openai.apiKey
    })
    return createGithubRetrievalQAChain({
        github: [{
            repoURL: 'https://github.com/dayhaysoos/use-shopping-cart',
            branch: 'master',
            language: 'js',
        }],
        githubAccesToken: env.github.accessToken,
        embedding,
        model,
        directoryPath: path.join(__dirname, '../../vector-store/repos/use-shopping-cart')
    })
}

useShoppingCartApp.post('load', async c => {
    const chain = createQAChain()
    await chain.createIndex()
    return c.text('index created')
})

useShoppingCartApp.post('ask', async c => {
    const { question } = await c.req.json<{
        question: string
    }>()
    const chain = await createQAChain().createQAChain()
    const answerStream = await chain.stream({
        question
    })
    return c.streamText(async stream => {
        stream.writeln("[Start stream]")
        for await (const chunk of answerStream) {
            stream.write(chunk)
        }
        stream.writeln("\n[end stream]")
    })
})

API経由でリポジトリについて聞いてみる

インデックスの作成までできれば、質問をAPI経由で投げてみます。-sNをつけることで、レスポンスをStreamで受け取ります。

curl -XPOST http://localhost:3000/github/use-shopping-cart/ask -sN -H "content-type: application/json" -d '{"question": "このライブラリはNext.jsで使えますか?使い方を教えてください"}'

返答はこのようなものが出てきました。

[Start stream]
はい、このライブラリはNext.jsで使用可能です。主に以下のステップに従って設定することができます。

1. 開発サーバーを起動します。実行するには以下のコマンドを使用します。
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
```

2. ブラウザで[http://localhost:3000](http://localhost:3000)を開いて結果を確認します。

3. `app/page.js` ファイルを変更することでページの編集を開始します。ファイルを編集すると、ページが自動的に更新されます。

なお、このプロジェクトでは [`next/font`](https://nextjs.org/docs/basic-features/font-optimization)を使用して、カスタムGoogleフォントのInterを自動的に最適化し、ロードしています。

詳細については、Next.jsのドキュメンテーションやチュートリアルをご覧ください。

さらに、`use-shopping-cart`パッケージを使用する方法は以下の通りです。

1. デモを実行するには、以下のコマンドを実行します。
```bash
yarn
yarn workspace typescript-usage start
```

2. TypeScriptとReactを使用する例として、以下のようなコードがあります。
```tsx
import * as React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import { CartProvider } from 'use-shopping-cart'

ReactDOM.render(
  <React.StrictMode>
    <CartProvider
      cartMode="checkout-session"
      stripe=""
      currency="USD"
      loading={<p aria-live="polite">Loading redux-persist...</p>}
    >
      <App />
    </CartProvider>
  </React.StrictMode>,
  document.getElementById('root')
)
```
このコードは特にtypesを使用していませんが、type-safeです。

以上がNext.jsでこのライブラリを使用する基本的な方法です。

サンプルコード付きで説明が出ているのはよいなーと思いますが、よくよく見るとあまりNext.jsでの使い方ではない書き方をしているようにも見えます。

また、質問と指示の方法を変えると、あまり具体的な回答を得られないケースも出てきました。

$ curl -XPOST http://localhost:3000/github/use-shopping-cart/ask -sN -H "content-type: application/json" -d '{"question": "このライブラリを紹介する入門記事の下書きを作成してください。"}'
[Start stream]
はい、それは可能です。このライブラリの使用方法や特徴をまとめた入門記事を作成することは非常に効果的な方法で、ユーザーが理解を深めるのに役立つと考えます。
[end stream]

具体的な指示ではなく、意図や目的だけを伝えるような文章を渡すと、つれない返答になる確率が上がりました。

$ curl -XPOST http://localhost:3000/github/use-shopping-cart/ask -sN -H "content-type: application/json" -d '{"question": "このライブラリを紹介する開発者向けのサイトを作ろうと思います。どのようなコンテンツを用意すべきでしょうか?"}'
[Start stream]
私の知識では具体的な答えを提供できません。
[end stream]
st-hideokamoto2:hono-langchain hideokamoto$ curl -XPOST http://localhost:3000/github/use-shopping-cart/ask -sN -H "content-type: application/json" -d '{"question": "このライブラリを紹介する開発者向けの記事を作りたいです。入門記事の下書きを作成してください。"}'
[Start stream]
申し訳ありませんが、別の情報提供の方法を提案するための十分な情報が提供されていません。
[end stream]

この辺りはQAChainのサンプルコードにあったプロンプトから見直していくことで改善できるかもしれません。

Docsサイトも読み込ませてみた

もう一つの試みとして、そのライブラリのドキュメントサイトも読み込ませてみました。

import { Hono } from "hono";
import { createGithubRetrievalQAChain } from "./helper";
import { env } from "../env";
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
import { ChatOpenAI } from "langchain/chat_models/openai";
import path from 'path';


export const capacitorStripeApp = new Hono()

const createQAChain = () => {
    const embedding = new OpenAIEmbeddings({
        openAIApiKey: env.openai.apiKey
    })
    const model = new ChatOpenAI({
        modelName: 'gpt-4',
        openAIApiKey: env.openai.apiKey,
        temperature: 1
    })
    return createGithubRetrievalQAChain({
        github: [{
            repoURL: 'https://github.com/capacitor-community/stripe',
            branch: 'main',
            language: 'js',
        }, {
            repoURL: 'https://github.com/ionic-jp/capacitor-plugins-docs',
            branch: 'main',
            language: 'markdown',
        }],
        githubAccesToken: env.github.accessToken,
        embedding,
        model,
        directoryPath: path.join(__dirname, '../../vector-store/repos/capacitor-stripe')
    })
}

capacitorStripeApp.post('load', async c => {
    const chain = createQAChain()
    await chain.createIndex()
    return c.text('index created')
})

capacitorStripeApp.post('ask', async c => {
    const { question } = await c.req.json<{
        question: string
    }>()
    const chain = await createQAChain().createQAChain()
    const answerStream = await chain.stream({
        question
    })
    return c.streamText(async stream => {
        stream.writeln("[Start stream]")
        for await (const chunk of answerStream) {
            stream.write(chunk)
        }
        stream.writeln("\n[end stream]")
    })
})

こちらもインデックを生成後に質問を投げてみます。

$ curl -XPOST http://localhost:3000/github/capacitor/ask -sN -H "content-type: application/json" -d '{"question": "PaymentSheetの使い方を教えて"}'

このような回答が出てきました。ドキュメントのサンプルコードなどが情報として渡しているからか、より具体的なサンプルや手順が出てきました。

[Start stream]
PaymentSheetを使用するには、以下のステップを実施します。

### 1. createPaymentSheet
まず`createPaymentSheet`メソッドでPaymentSheetの設定を行います。バックエンドのエンドポイントに接続して各キー(`paymentIntent`、`ephemeralKey`、`customer`)を取得する必要があります。取得後、これらのキーを`createPaymentSheet`メソッドに設定します。

```ts
import { Stripe, PaymentSheetEventsEnum } from '@capacitor-community/stripe';

(async () => {
  // Connect to your backend endpoint, and get every key.
  const { paymentIntent, ephemeralKey, customer } = await this.http.post<{
    paymentIntent: string;
    ephemeralKey: string;
    customer: string;
  }>(environment.api + 'payment-sheet', {}).pipe(first()).toPromise(Promise);

  // prepare PaymentSheet with CreatePaymentSheetOption.
  await Stripe.createPaymentSheet({
    paymentIntentClientSecret: paymentIntent,
    customerId: customer,
    customerEphemeralKeySecret: ephemeralKey,
  });
})();
```
ここで、`CreatePaymentSheetOption`のオプションを使用して`createPaymentSheet`を設定することが可能です。

### 2. presentPaymentSheet
`createPaymentSheet`メソッドの後に`presentPaymentSheet`メソッドを実行すると、プラグインはPaymentSheetを表示して結果を取得します。

```ts
(async () => {
  // present PaymentSheet and get result.
  const result = await Stripe.presentPaymentSheet();
  if (result.paymentResult === PaymentSheetEventsEnum.Completed) {
    // Happy path
  }
})();
```
`presentPaymentSheet`から`PaymentSheetResultInterface`を取得することができます。この`PaymentSheetResultInterface`は`PaymentSheetEventsEnum`のEnumから作成されます。

### 3. addListener
PaymentSheetのメソッドはリスナーに通知します。支払いプロセスの'Completed'イベントを取得したい場合は、`PaymentSheetEventsEnum.Completed`リスナーを`Stripe`オブジェクトに追加します。

```ts
// be able to get event of PaymentSheet
Stripe.addListener(PaymentSheetEventsEnum.Completed, () => {
  console.log('PaymentSheetEventsEnum.Completed');
});
```
使用できるイベント名は`PaymentSheetEventsEnum`です。

ただしこちらも、ドキュメントやサンプルコードにない情報については回答しない様子です。

 curl -XPOST http://localhost:3000/github/capacitor/ask -sN -H "content-type: application/json" -d '{"question": "PaymentSheetをVue.jsで使う方法を教えて"}'
[Start stream]
すみません、この文書にはPaymentSheetのVue.jsでの使用方法についての情報は含まれていません。
[end stream]

試してみた感想

プロンプトにコツが要りそうですが、ライブラリのざっくりした理解を得るためや、ちょっとしたデモコードを書きたい場合には使えることもありそうかなと思います。ただし読み込ませる情報量に左右される部分もありますので、サンプルコードやドキュメントサイトなどもembeddingしておくような設計にしておくのがよいようにも見えます。

ブックマークや限定記事(予定)など

WP Kyotoサポーター募集中

WordPressやフロントエンドアプリのホスティング、Algolia・AWSなどのサービス利用料を支援する「WP Kyotoサポーター」を募集しています。
月額または年額の有料プランを契約すると、ブックマーク機能などのサポーター限定機能がご利用いただけます。

14日間のトライアルも用意しておりますので、「このサイトよく見るな」という方はぜひご検討ください。

広告ここから
広告ここまで

Related Category posts