外部APIを利用したRAGをLangChain.jsのLCELだけで作る2 – 部分的なベクトル検索を採用する
LangChain.jsを使用して、RAGやテキスト生成機能を実装するシリーズが続いています。前回はWordPressのREST APIを使用して、RAGの検索部分に挑戦しました。今回はエラーが発生した記事本文や複数記事をLLMに渡す試みについて説明されています。MemoryVectorStoreを利用することで、記事の関連性の高い文章を取得し、記事の検索結果をさらに深掘りする方法も紹介されています。WordPressから取得した記事情報の検索結果をMemoryVectorStoreに保存する方法や、RAGのインデックスと検索処理を実装する手順も示されています。Cloudflare Workers AIを使用して生成された日本語の回答に関する内容や、AWS Lambdaのアプリケーションをアップデートする手順についても触れられています。LLMのトークン数上限に対処する方法や、回答を生成する際に必要な文章の抽出方法についても言及されています。ベクターインデックスを利用した検索についての知見が共有されています。
目次
LangChain.jsを使っていろんなRAGやテキスト生成機能の実装を試すシリーズを個人的に続けています。前回はRAGの検索部分について、WordPressのREST APIで検索を実装する方法に挑戦しました。今回はその続きです。
前回の課題: 記事本文を渡したり、複数記事を渡す方法の検討
記事の本文ではなく概要文を渡したり、検索結果の1件目のみを渡している理由は、LLMのトークン数が原因です。今回のコードで、記事本文または複数記事をLLMに渡そうとすると、次のようなエラーが発生しました。
✘ [ERROR] Error: Cloudflare LLM call failed with status code 500
ベクトル検索と異なり、記事本文そのまま、それもHTMLなどを含めた状態で、モデルに渡そうとしていることが原因だと考えられます。ですので次のステップとしては、余計な文字列の除去や、MemoryVectorStoreを利用して記事の中から関係性の高い文章をピックアップするなどに挑戦してみようと思います。
MemoryVectorStoreを使って、検索結果からさらに深掘りする
「ベクトル検索を一時的に利用したいけども、PineconeやElasticsearch / OpensearchなどのDBを用意するのは避けたい」ケースでは、MemoryVectorStore
を利用することができます。このストアはデータの永続化機能を持たない代わりに、先に紹介したようなDBやベクターインデックスを別途用意する必要なく、ベクトル検索を処理の中で利用できるようになります。
基本的な使い方は次のようになります。
const documents = [
new Document({
pageContent: ”検索対象のコンテンツを文字列で”,
metadata: {
key: “value”
}
})
]
const vectorStore = await MemoryVectorStore.fromDocuments(
documents,
embeddings
)
記事検索のChainと連携させる
今回はWordPressから取得した記事情報の検索結果をMemoryVectorStore
に保存します。そのため、前回の記事で紹介した「WordPressの記事検索を行うChain」の中に処理を追加しましょう。
const searchPostChain = RunnableSequence.from([
{
keyword: extractSearchKeywordChain,
question: input => input.question
},
new RunnableLambda({
func: async (input) => {
// 記事を取得する
const result = await fetch(`https://example.com/wp-json/wp/v2/posts?filter[lang]=en&search=${encodeURIComponent(input.keyword)}`)
const posts = await result.json()
// 記事ごとに一旦LangChainのDocument化する
const documents = posts.map((post: any) => {
return new Document({
pageContent: post.content.rendered,
metadata: {
id: post.ID
}
})
})
// LLMのトークン上限を越さないように、Documentを分割する
const splitter = RecursiveCharacterTextSplitter.fromLanguage("html");
const transformer = new HtmlToTextTransformer();
const sequence = splitter.pipe(transformer);
const newDocuments = await sequence.invoke(documents);
// MemoryVectorStoreに保存する
const vectorStore = await MemoryVectorStore.fromDocuments(
newDocuments,
embeddings
)
// 元の質問文を利用して、ベクトル検索を実施
const vectorResult = await vectorStore.similaritySearch(input.question)
// 検索結果を文字列にするための結合処理
return vectorResult.map(result => result.pageContent.replace(/\/n\/n/, '\n')).join('\n')
}
}),
new StringOutputParser(),
])
ステップとしては、「インデックス作成」と「検索処理」を一度にやっているようなイメージだと考えてください。そのため、この処理を分割し、MemoryVectorStore
以外のベクターインデックスを利用するストアに差し替えることで、RAGのインデックスと検索処理を実装できるようになります。
Cloudflare Workers AIで試した結果
このサイトの記事を利用して試してみたところ、このような回答が生成されました。サイトの情報を利用した生成をしているようにも見えますが、 日本語があまり得意じゃないモデルを利用したためか、内容はもうすこし改善の余地がありそうです。
"「AWS Lambdaにアプリケーションをアップデートするには、以下の変更を行う必要があります。」\n「1. AWS Lambdaに新しいエントリーポイントファイルを作成する必要があります。_worker.tsファイルとは異なる新しいファイルを作成して、必要なモDULESをイMPORTすることができます。」\n「2. AWS Lambdaのリクエストとレスポンスをマップするためのハンドラー関数を設定する必要があります。lambda.tsファイルからハンドラー関数をエクスポートし、aws-lambdaモDULEのハンドラープロパティーに設定することができます。」"
とはいえ前回の記事ではLLMのトークン数上限に引っかかってしまった「複数記事の本文を利用して回答を生成する」実装が実現できました。
まとめ
今回の例のように、文字数が多いブログ記事などを複数件利用して回答を生成するRAGを作りたい場合、「回答生成に必要な文章だけを抽出する処理」が必要になります。そのため、「必要なデータのみ取得できるAPI」がある場合を除いては、ベクターインデックスを利用した検索を組みこむのがよさそうです。
今回のサンプルではベクターストアを用意しなくて済む分のコストは削減が期待できます。ただし一時的な利用とはいえ、LLMのEmbedding APIを利用することには変わりありません。そのため、永続化できるベクターインデックスを用意するコストより、Embedding API呼び出し料金が高くなったり、Embedding処理文の遅延が気になるまでの利用に止める方が良さそうです。