langChainを活用したローカルRAGの実装（Vue.js）

# 概要
vue.jsからOpenAIをちゃんと使うシリーズ。
チャットだけでは物足りない。。
かといってAzureCognitiveSerarchと連携するほどでもなぁ、というケース、あると思います。
langCain.jsではその辺もちゃんとサポートされているので、サクッと実装できます。

## 構成
- Node.js
- Vue.js (Vue3 + TypeScript + Composition API）
- Azure OpenAI or OpenAI互換のAPI
- "langchain": "^0.1.11"
- "@langchain/core": "^0.1.20"
- "@langchain/openai": "^0.0.13"
- "docxtemplater": "^3.46.0"
- "pizzip": "^3.1.6"

（2024/03時点）
docxtemplaterとpizzipはOffice系対応用なので、PDFだけでよければ不要ですね。

## 実装1.ファイル読み込み
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/570991/b31a3402-e477-513d-d32b-44da67de37ad.png)
まずは、ローカルからアップロードしたファイルを、langChainの内部クラスであるDocuments形式に変換します。

### 画面
``` html
<v-card>
      <v-card-title>ファイルを選択</v-card-title>
      <v-card-text>
        <v-file-input
          v-model="inputFile.selectedFiles"
          accept="application/pdf,text/*,.docx"
          show-size
          label="ファイルを選択"
        ></v-file-input>
      </v-card-text>
      <v-card-actions>
        <v-spacer></v-spacer>
        <v-btn
          @click="
            inputFile.selectedFiles = [];
          "
          >キャンセル</v-btn
        >
        <v-btn color="blue" @click="readFile">確定</v-btn>
      </v-card-actions>
    </v-card>
```
### コード
``` typescript
// 必要なクラスのインポート
import { Document } from "@langchain/core/documents";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { WebPDFLoader } from "langchain/document_loaders/web/pdf";
import { TokenTextSplitter } from "langchain/text_splitter";
// Office系
import PizZip from "pizzip";
import Docxtemplater from "docxtemplater";
// 画面の一時変数
const inputFile = reactive({
  selectedFiles: [],
  appendFiles: [],
  docs: [] as Document[],
  vectorStore: undefined as MemoryVectorStore | undefined,
});
// テキストを適当なサイズに切るやつ
const tokenTextSplitter = new TokenTextSplitter({
  encodingName: "gpt2",
  chunkSize: 1024,
  chunkOverlap: 128,
});
// onMountedでpdfjsのワーカーを作っておく
let pdfjs = undefined as any;
onMounted(async () => {
  pdfjs = await import("pdfjs-dist/legacy/build/pdf.min.mjs");
  const pdfjsWorker = await import(
    "pdfjs-dist/legacy/build/pdf.worker.min.mjs"
  );
  pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;
});
//#########################
// ドキュメント読み込み
//#########################

const readFile = async () => {
  if (inputFile.selectedFiles.length == 0) return;
  let input = inputFile.selectedFiles[0];
  // PDFファイル
  if (input.type == "application/pdf") {
    const loader = new WebPDFLoader(input, {
      pdfjs: () => pdfjs,
    });
    inputFile.docs = await loader.load();
    if (inputFile.docs.length > 0) {
      inputFile.appendFiles.push(input);
    } else {
      addLog("error", "読み込めないファイル形式です。");
    }
  }
  // txtファイル
  else if (input.type.startsWith("text/")) {
    const fileReader = new FileReader();
    fileReader.readAsText(input);
    await new Promise((resolve) => (fileReader.onload = () => resolve()));
    const text = fileReader.result as string;
    if (text.length > 0) {
      inputFile.docs = await tokenTextSplitter.createDocuments([text]);
      inputFile.appendFiles.push(input);
    } else {
      addLog("error", "読み込めないファイル形式です。");
    }
  }
  // Officeファイル
  else if (input.type.startsWith("application/vnd.openxmlformats")) {
    const fileReader = new FileReader();
    fileReader.readAsBinaryString(input);
    await new Promise((resolve) => (fileReader.onload = () => resolve()));
    const blob = fileReader.result;
    if (blob.length > 0) {
      const zip = new PizZip(blob);
      const doc = new Docxtemplater(zip, {
        paragraphLoop: true,
        linebreaks: true,
      });
      const text = doc.getFullText();
      if (text.length > 0) {
        inputFile.docs = await tokenTextSplitter.createDocuments([text]);
        inputFile.appendFiles.push(input);
      }
    }
    if (inputFile.docs.length == 0) {
      addLog("error", "読み込めないファイル形式です。");
    }
  } else {
    addLog("error", "読み込めないファイル形式です。");
  }

  inputFile.selectedFiles = [];
  inputFile.select = false;
};
```
PDFが一番簡単で
``` typescript
const loader = new WebPDFLoader(input, {
      pdfjs: () => pdfjs,
    });
inputFile.docs = await loader.load();
```
でおしまいです。
ポイントは`WebPDFLoader`を使う、`pdfjs`のインスタンスを予め用意しておく、くらいですね。
これでページ毎に分かれたDocumentが読み込まれます

テキスト系は、langChainにクラスがないので、自分で読みます。
といっても
``` typescript
const fileReader = new FileReader();
fileReader.readAsText(input);
await new Promise((resolve) => (fileReader.onload = () => resolve()));
const text = fileReader.result as string;
inputFile.docs = await tokenTextSplitter.createDocuments([text]);
```

`fileReader` で読んで、`tokenTextSplitter.createDocuments([text])`に放り込むだけです。
`[text]`（配列）じゃないと駄目なのでそこだけ注意。
Office系はバイナリとして読んで、ライブラリでテキスト抜いて、`tokenTextSplitter.createDocuments([text])`に放り込めばOK。

## 実装2.MemoryVectorStoreの作成

次に、検索用のメモリDBを作ってやります。
タイミングとしてはどこでもよいのですが、それなりにAPIとかが呼ばれるので、質問の直前とかに入れるのがよいかと。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/570991/dbdfe85a-4709-60e7-6fa3-08252270c3cb.png)
### 画面側のコード
``` typescript 
import {
  getVectorStoreFromDocs,
  searchVectorStore,
} from "@/ragapi";
// ドキュメントからRAGを取得
const get_rag_from_docs = async (prompt: string) => {
  if (inputFile.vectorStore == undefined) {
    inputFile.vectorStore = await getVectorStoreFromDocs(
      app_store.authToken,
      inputFile.docs
    );
  }
};
```
### 裏側のコード

例によって、バックエンドの切り替えを楽にしたいので、モデルを作るところを別関数にしてます。
それ以外はlangChainのチュートリアルどおり。

``` typescript ragapi.ts
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Document } from "@langchain/core/documents";

function getModel(token: string | undefined): OpenAIEmbeddings {
  const openAIConfig = app_store.openAIConfig!;
  // Azure用
  if (appSetting.login_required) {
    return new OpenAIEmbeddings({
      azureOpenAIApiKey: openAIConfig.apim_subscription_key,
      azureOpenAIBasePath: openAIConfig.endpoint + "/deployments",
      //　モデルは決め打ち
      azureOpenAIApiDeploymentName: "text-embedding-ada-002",
      modelName: "text-embedding-ada-002",
      // 2024-02-15-preview とか
      azureOpenAIApiVersion: openAIConfig.api_version,
      // APIManagement関係の追加ヘッダ
      configuration: {
        defaultHeaders: {
          "Ocp-Apim-Subscription-Key": openAIConfig.apim_subscription_key,
          Authorization: "Bearer " + token,
        },
      },
    });
  } else {
    // ローカルorOpenAI用
    return new OpenAIEmbeddings({
      openAIApiKey: openAIConfig.apim_subscription_key,
      configuration: {
        baseURL: openAIConfig.endpoint,
      },
    });
  }
}

export async function getVectorStoreFromDocs(
  token: string | undefined,
  docs: Document[]
): Promise<MemoryVectorStore> {
  const embeddings = getModel(token);
  try {
    const vectorStore = await MemoryVectorStore.fromDocuments(docs, embeddings);
    return vectorStore;
  } catch (error) {
    console.error(error);
    throw error;
  }
}
```

## 実装3.質問に関連する文章の取得
実際に質問が来たら、まず関連しそうな文章をMemoryVectorStoreから取り寄せます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/570991/f8842075-f935-d02d-a6fa-98e94ee8947c.png)
### 画面側のコード
``` typescript 
import {
  getVectorStoreFromDocs,
  searchVectorStore,
} from "@/ragapi";
// ドキュメントからRAGを取得
const get_rag_from_docs = async (prompt: string) => {
  if (inputFile.vectorStore == undefined) {
    inputFile.vectorStore = await getVectorStoreFromDocs(
      app_store.authToken,
      inputFile.docs
    );
  }
  // 検索
  const docs_from_prompt = await searchVectorStore(
    inputFile.vectorStore,
    prompt
  );
  if (docs_from_prompt.length == 0) return undefined;
  let rag_meessage =
    "If necessary for your answer, use following documents as InputFile in your answer.\n";
  // 上位から4000文字まで利用する
  docs_from_prompt.forEach((doc) => {
    if (rag_meessage.length < 4000) rag_meessage += doc.pageContent + "\n";
  });
  if (rag_meessage.length > 4000) {
    rag_meessage = rag_meessage.slice(0, 4000);
  }
  return new SystemMessage({ content: rag_meessage });
};
```

``` typescript ragapi.ts
export async function searchVectorStore(
  vectorStore: MemoryVectorStore,
  prompt: string
): Promise<Document[]> {
  const res = await vectorStore.similaritySearch(prompt, 5);
  return res;
}
```
あんまり長文を放り込むと、トークン長を浪費するので、上位5件だけ取ったうえで、更に文字数で適当にぶった切ります。
このへんはモデルの対応長とか、コストとかと相談で。
んで、SystemMessageというカタチで、チャットモデルへ引き渡します。


## 実装4.文脈質問

あとは普通にチャットすればOK。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/570991/2a677974-09f7-bc4e-b372-ec6c5d6d5ac3.png)

``` typescript 
// チャット送信
const sendMessage = async () => {
    const query = []
    // ドキュメントからRAGを取得
    if (inputFile.appendFiles.length > 0) {
      const rag_messege = await get_rag_from_docs(input.content);
      if (rag_messege != null) {
        query.push(rag_messege);
      }
    }
    query.push(new HumanMessage({
        content: "Hello OpenAI!",
      }););
    //API呼び出し
    const temp = [0.9, 0.5, 0.1][input.temperature]; //・創造的に・バランス・厳格に
    const generator = streamChatCompletion_langChain(
      query as ChatMessage[],
      temp,
      appSetting.login_required ? app_store.authToken : undefined
    );
    // 順次生成の回答を表示
    loading_message.loading = true;
    for await (let chunk of generator) {
      loading_message.content = loading_message.content + chunk;
    }
    loading_message.loading = false;
}
```

## 動作
確実にChatGPTが知らないであろうことを聞きたいので、つい先日発表されたStarCoder2のペーパーを食わせてみました。
[\[2402\.19173\] StarCoder 2 and The Stack v2: The Next Generation](https://arxiv.org/abs/2402.19173)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/570991/c09daadf-37fa-ce4e-4be0-d7e0cbfdbb76.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/570991/784872c0-b684-4cfc-2904-e34bfd870ce9.png)
ちゃんと理解してますね！

同じく、最近発表されたLongRoPEについて
[\[2402\.13753\] LongRoPE: Extending LLM Context Window Beyond 2 Million Tokens](https://arxiv.org/abs/2402.13753)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/570991/80b4cefe-8e12-4005-7930-0aaa45a9ec84.png)

多分知らなそうな、シン・仮面ライダー（Wikipediaからテキストをダウンロードして食わせた）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/570991/b99f6826-177c-c250-39eb-c528ca05f0fa.png)

# まとめ
langChain.jsも機能が拡充してきて、だいぶ使えるようになったと感じます！
つぎはBingSearchでも組み込みたいところ。
ChatGPT使えば全部できるのですが、制限のかかった環境下（使えるクラウドサービスが制限されている）とかローカルのLLMを使いたいケースでも、ほぼそのまま使えるのが利点です。

よきlangChainライフを！