LoginSignup
1
0

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

Posted at

概要

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
まずは、ローカルからアップロードしたファイルを、langChainの内部クラスであるDocuments形式に変換します。

画面

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

コード

// 必要なクラスのインポート
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が一番簡単で

const loader = new WebPDFLoader(input, {
      pdfjs: () => pdfjs,
    });
inputFile.docs = await loader.load();

でおしまいです。
ポイントはWebPDFLoaderを使う、pdfjsのインスタンスを予め用意しておく、くらいですね。
これでページ毎に分かれたDocumentが読み込まれます

テキスト系は、langChainにクラスがないので、自分で読みます。
といっても

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

画面側のコード

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

画面側のコード

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

// チャット送信
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
image.png
image.png
ちゃんと理解してますね!

同じく、最近発表されたLongRoPEについて
[2402.13753] LongRoPE: Extending LLM Context Window Beyond 2 Million Tokens
image.png

多分知らなそうな、シン・仮面ライダー(Wikipediaからテキストをダウンロードして食わせた)
image.png

まとめ

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

よきlangChainライフを!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0