概要
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.ファイル読み込み
まずは、ローカルからアップロードしたファイルを、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とかが呼ばれるので、質問の直前とかに入れるのがよいかと。
画面側のコード
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のチュートリアルどおり。
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から取り寄せます。
画面側のコード
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 });
};
export async function searchVectorStore(
vectorStore: MemoryVectorStore,
prompt: string
): Promise<Document[]> {
const res = await vectorStore.similaritySearch(prompt, 5);
return res;
}
あんまり長文を放り込むと、トークン長を浪費するので、上位5件だけ取ったうえで、更に文字数で適当にぶった切ります。
このへんはモデルの対応長とか、コストとかと相談で。
んで、SystemMessageというカタチで、チャットモデルへ引き渡します。
実装4.文脈質問
あとは普通にチャットすればOK。
// チャット送信
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
ちゃんと理解してますね!
同じく、最近発表されたLongRoPEについて
[2402.13753] LongRoPE: Extending LLM Context Window Beyond 2 Million Tokens
多分知らなそうな、シン・仮面ライダー(Wikipediaからテキストをダウンロードして食わせた)
まとめ
langChain.jsも機能が拡充してきて、だいぶ使えるようになったと感じます!
つぎはBingSearchでも組み込みたいところ。
ChatGPT使えば全部できるのですが、制限のかかった環境下(使えるクラウドサービスが制限されている)とかローカルのLLMを使いたいケースでも、ほぼそのまま使えるのが利点です。
よきlangChainライフを!