[LangChain.jsでいろんなRAGを作る]外部APIを利用したRAGをLangChain.jsのLCELだけで作る
LangChain.jsを使用して、RAGやテキスト生成機能を実装するシリーズが続いています。今回は、RAGの検索部分をベクターストアを使用せずに実装する方法に挑戦しました。検索の仕組みについては柔軟であり、REST APIを使用して検索クエリを投げる方法でRAGを構築しました。検索APIを利用して回答文章を生成するために、3つのChainが必要です。今後は記事本文を渡す際に生じるエラーに対処し、関連性の高い文章をピックアップする方法に挑戦する予定です。
目次
LangChain.jsを使っていろんなRAGやテキスト生成機能の実装を試すシリーズを個人的に続けています。今回はRAGの検索部分について、あえてベクターストア・ベクトル検索を使わずに実装する方法に挑戦しました。
なぜベクターストアを使わないのか
RAGは「プロンプトに検索結果を追加情報として含めることで、LLMが生成する回答を意図した内容に近づける」手法です。プロンプトに渡せる情報の量には、LLMごとに設定されたトークン数上限という制限がありますので、できるだけ関連性の高いコンテンツだけを詰め込む必要があります。そのため、記事内の文章をある程度のセクションやChunkに分割し、関連性の高い部分だけを抽出する方法としてベクターストアを利用した検索が選ばれています。つまり「回答の精度を高めるためや、効率的な検索を実装するために、ベクトル検索が採用されやすい」が、「検索の仕組み自体はなにを使っても問題はない」と考えることができます。
ということで、今回はあえてREST APIに対して検索クエリを投げるやり方でRAGを作ってみましょう。
REST APIを利用した検索でのRAGを設計する
検索系のAPIを利用するには、「入力された自然文章から、検索クエリを抽出する」ステップが事前に必要です。生成された検索クエリを元に検索APIからデータを取得し、その結果を用いて回答文章を生成します。
つまりLangChainで実装するには、以下の3つのChainが必要です。
- 検索キーワードを生成するChain
- キーワードで検索APIリクエストを投げるChain
- 検索結果から回答文を生成するChain
検索APIを利用したRAGを実装するためのChainを作る
作るべきChainを整理したところで、早速実装してみましょう。
検索キーワードを生成するChain
まずは検索クエリを作成するChainを作ります。今回はChatモデルを利用する前提で、「システムプロンプトにタスクや前提情報を渡し、その後質問文を渡す」作り方にしました。検索クエリ以外の文章の生成を避けるため、いくつかの質問と回答の例を渡す「Few Shot Prompting」形式にしています。
const chatExtractSearchKeywordPrompt = ChatPromptTemplate.fromMessages([
['system', `Given a question, extract the essential keywords for a search query.
Ensure only keywords are produced, with no sentences or explanations.
- Input should be processed to identify the core subjects and actions.
- Output strictly as keywords, separated by spaces, capturing the essence of the query.
Example inputs and expected outputs:
- Input: "How to use Hono on AWS Lambda?" => Expected Output: "Hono AWS Lambda"
- Input: "How to use AWS?" => Expected Output: "AWS"
Reminder: Do not generate any additional text. Focus solely on extracting and outputting keywords.`],
['human', '{question}']
])
あとはこのプロンプトにLLMモデルやOutputParserを追加したChainを作ればOKです。
const extractSearchKeywordChain = RunnableSequence.from([
chatExtractSearchKeywordPrompt,
chatCloudflare,
new StringOutputParser()
])
キーワードで検索APIリクエストを投げるChain
続いて検索処理を実装するChainを作りましょう。LCELの引数(RunnableSequence.from
の配列)には、まず先ほど作成したChainの結果をkeyword
変数として受け取るオブジェクトを渡します。その次にRunnableLambda
を利用して動的な処理を実装しましょう。今回の例では、WordPressのREST API(WP API)に対して検索を行い、そのレスポンスに含まれる最初の記事の概要文(excerpt
)を返す実装にしています。
const searchPostChain = RunnableSequence.from([
{
keyword: extractSearchKeywordChain
},
new RunnableLambda({
func: async (input) => {
const result = await fetch(`https://example.com/wp-json/wp/v2/posts?search=${encodeURIComponent(input.keyword)}`)
const posts = await result.json()
const excerpt = (posts as any)[0].excerpt.rendered
return excerpt
}
}),
new StringOutputParser(),
])
もし複数の記事を渡したい場合は、検索結果の配列を.join(‘\n’)
などでテキストとして結合してあげましょう。ただしこれをすると、モデルや検索結果によっては、トークン数が溢れてしまう場合がありますのでご注意ください。
検索結果から回答文を生成するChain
最後に検索結果を元に回答を生成するChainを作ります。プロンプト自体はシンプルな指示内容にしました。
const chatGenerateAnswerPrompt = ChatPromptTemplate.fromMessages([
[
"system",
`Answer the question based on only the following context:
{context}`,
],
["human", "{question}"],
])
もし回答文が英語などになることがある場合、このように「日本語で生成して」と明示的に指示を出してもよいかもしれません。
const chatGenerateAnswerPrompt = ChatPromptTemplate.fromMessages([
[
"system",
`Answer the question based on only the following context:
{context}`,
],
[
"system",
`You should answer the question in Japanese.`,
],
["human", "{question}"],
])
あとはこちらもLCELでモデルやプロンプトなどを指定します。question
はユーザーが入力した値をそのまま使いたいので、RunnablePassthrough
で何もせずにプロンプトに渡すように指示しています。
const generateAnswerChain = RunnableSequence.from([
{
context: searchPostChain,
question: new RunnablePassthrough()
},
chatGenerateAnswerPrompt,
chatCloudflare,
new StringOutputParser()
])
もしかすると、プロンプトインジェクションなどへの対策は、このRunnablePassthrough
している部分に定義するとよいかもしれません。
Cloudflare Workers AI(llama-2)で試す
ここまで準備ができましたので、動かしてみましょう。今回はCloudflare Workers AIのllama-2モデルを使いました。
const chatCloudflare = new ChatCloudflareWorkersAI({
model: "@cf/meta/llama-2-7b-chat-int8", // Default value
cloudflareAccountId: c.env.CLOUDFLARE_ACCOUNT_ID,
cloudflareApiToken: c.env.CLOUDFLARE_API_TOKEN,
});
3つ目のChainをinvoke
またはstream
して回答を生成します。
await generateAnswerChain.invoke({
question: "How to use Hono on AWS Lambda?"
})
今回自分のブログ記事を利用して試したところ、このような回答が得られました。
"Ah, my apologies! Here's the answer in Japanese:\n「Sonik」はJavaScriptのメタフレームワークで、Yusuke-sanがメンテナンスしています。Next.js-likeのファイルベースのルーティングとHono-likeのREST APIを使用して、ウェブサイトやウェブアプリケーションを作成することができます。この文章では、AWS CDKプロジェクトを構成して、SonikアプリをAWSにデプロイする方法を説明しています。この中では、LambdaとS3リソースを定義するコードスニペットや、静的アセットデプロイの設定方法も示しています。また、AWS Lambdaにアダプトするために、新しいエントリーポ"
Next step: 記事本文を渡したり、複数記事を渡す方法の検討
記事の本文ではなく概要文を渡したり、検索結果の1件目のみを渡している理由は、LLMのトークン数が原因です。今回のコードで、記事本文または複数記事をLLMに渡そうとすると、次のようなエラーが発生しました。
✘ [ERROR] Error: Cloudflare LLM call failed with status code 500
ベクトル検索と異なり、記事本文そのまま、それもHTMLなどを含めた状態で、モデルに渡そうとしていることが原因だと考えられます。ですので次のステップとしては、余計な文字列の除去や、MemoryVectorStoreを利用して記事の中から関係性の高い文章をピックアップするなどに挑戦してみようと思います。