昨年、LangChainを利用したRetrievalQA(検索ベースの質問応答システム)の試作を行いました。 その過程で、この技術の有効性を確かめるとともに、このような言語モデルの応用の可能性を実感しました。
大規模言語モデル(LLM)はその性質上、驚異的なテキスト生成能力を持ちながらも、「ハルシネーション」と呼ばれる、虚偽の情報を生み出すことがあります。 この点は、特に信頼性が求められる研究活動や学習活動においては大きな障害となり得ます。
またハルシネーションのような問題がなくとも、根拠となるソース情報を参照することは、あらゆる知的活動の基本と言えます。 情報を正確に取り扱う上でソースへのアクセスは必須の要素です。
このような背景から、RetrievalQAの手法はRAG(Retrieval-Augmented Generation)という形で引き続き注目を集めています。
しかし、実用的なRAGを構築しようとすると、特に情報源への参照において、難しいポイントがいくつかありました。
この記事では、それらの課題と回避のためにローカルで実施してみたことを書き留めておきます。
RAGの構築
ここで利用するRAGアプリケーションはLangChainをベースとし、内容は以下公式サイトにあるものをそのまま使っているとして進めます。
Text Embeddingのコスト
OpenAIのText Embedding APIのコストは決して高額ではないですが、量や規模が増えてくると気になります。 そもそもインプットできるトークンのQuota制限もありますし、ローカルでできると後々便利です。
また読書に応用する場合、書籍PDFなどを使ってRAGを構築するとなると、Embeddingが学習を共なわないとしても、 何らかの形で全文をpublicにしかねない行為は避けておく方が無難でしょう。
そこでHuggingFaceのモデルを利用して、ローカルでEmbeddingをします。
LangChainにはHuggingFaceのSentence Transformerを利用するためのラッパーが提供されています。
|
|
こちらを利用することで、HuggingFace上で利用可能なモデルを指定し、ローカルで利用することができます。
ここで利用している multilingual-e5-large
は日本語の性能が高いと評価されているモデルのひとつです。
言語の壁
ソースとして利用したいWebサイトやPDFが日本語とは限りません。
この場合、考えられる対応は2つあります。
- ベクターDBに入れる文章データ自体を翻訳する
- ベクターDBに問い合わせるクエリを翻訳する
GPT4などのように、多言語での運用に支障がないモデルをチャット用LLMとして利用するのであれば、2つ目の方法がよいと考えます。
DB自体を翻訳してしまうとソースとして参照している文面がどこにあったのか探しにくくなりますし、改変されているのでソースとしてそのまま引用もできません。 また、うまく検索できていないときに最悪英語でクエリをすればいいので、可用性が高いだろうと思っています。
ベクターDBへのクエリを翻訳する
ベクターDBへのクエリを英語にするには、Quary Transformationを応用します。
上記公式サイトの例では、これまでの質問の文脈を考慮してクエリを生成するために利用されています。 これを応用して英語でのクエリを生成させます。
|
|
このようにクエリの生成を「英語で」お願いします。
GraphQLに関するRAGを作成して、「GraphQLに引数を渡すには?」と質問をしてみます。
|
|
「How to pass arguments in GraphQL」というクエリが生成されていることがわかります。
今回は比較的直接的な翻訳のようになっていますが、生成を依頼するプロンプトを工夫することで柔軟にクエリを生成させる応用も考えられると思います。
ベクターDB内の情報の利用効率
Text Embeddingの入力トークンサイズには限度があり、OpenAIでは8191トークンです。
以下のようにRAGの例でよくあるパターンでは、このサイズに収まるようにする意味でも、適当なチャンクにテキストを分割してEmbeddingしています。。
|
|
しかしこの分け方は、必ずしも文章として意味のあるまとまりになるとは限らないため、検索結果がおかしなものになってしまうことがあります。
またWebから取得したドキュメントではHTMLタグなどが残っていたり、コンテンツとは関係のない情報が入っていたりするとノイズになってしまいます。
この対策のために、より意味のある文章のまとまりで分割することを検討します。 ここでは利用しやすいlWebから取得したHTMLドキュメントをスクレイピングして、ベクター化して違いを比較します。
ソースとして、 ayatakeshi
さんが公開されているelispの日本語版マニュアルのサイトを利用します。
GNU Emacs Lisp Reference Manual
このサイトは1ページに全ての情報がまとまっており、内容がチャプター、セクション、サブセクションに綺麗に構造化されています。
ベクターDBはFAISS GPU、モデルは intfloat/multilingual-e5-large
を利用しました。
TextSplitterを利用した場合
まず先の例にあるようにTextSplitterによるチャンクに「外部プロセスを利用するには?」というクエリを投げてみます。
retrieverの参照数は k=3
とします。
Emacs Lispプログラムは、それら自身のプロセス内で別のプログラムを呼び出すことができます。 これらは、親プロセスであるEmacsプロセスのサブプロセス、または子プロセスと呼ばれます。 非同期プロセスの作成については、セクション40.4で説明されています。 非同期プロセスは、作成後にEmacsと並列して実行され、Emacsはプロセスとコミュニケーションを取ることができます。
この回答のために参照したソースは以下の3つです。
オペレーティングシステムの用語ではプロセス(process)とはプログラムを実行できるスペースのことです。Emacsはプロセス内で実行されます。Emacs Lispプログラムは別のプログラムをそれら自身のプロセス内で呼び出すことができます。これらは親プロセス(parent process)であるEmacsプロセスのサブプロセス(subprocesses)、または子プロセス(child processes)と呼ばれます。
[ << ] [ < ] [ Up ] [ > ] [ >> ]
[Top] [Contents] [Index] [ ? ]
40.4 非同期プロセスの作成
このセクションでは非同期プロセス(asynchronous process)を作成する方法について説明します。非同期プロセスは作成後にEmacsと並列して実行され、Emacsは以降のセクション(プロセスへの入力の送信とプロセスからの出力の受信を参照)で説明する関数を使用してプロセスとコミュニケーションができます。プロセスコミュニケーションは部分的に非同期なだけであることに注意してください。Emacsはこれらの関数を呼び出したときだけプロセスとのデータを送受信できます。
[ << ] [ < ] [ Up ] [ > ] [ >> ]
[Top] [Contents] [Index] [ ? ]
40.9.4 プロセスの出力を受け取る
非同期サブプロセスからの出力は、通常はEmacsが時間の経過や端末入力のような、ある種の外部イベントを待機する間だけ到着します。特定のポイントで出力の到着を明示的に許可したり、あるいはプロセスからの出力が到着するまで待機することでさえ、Lispプログラムでは有用な場合が時折あります。
Function: accept-process-output &optional process seconds millisec just-this-one ¶ この関数はプロセスからの保留中の出力をEmacsが読み取ることを許す。この出力はプロセスのフィルター関数により与えられる。この関数はprocessが非nilならprocessから何らかの出力を受け取るかprocessが接続をcloseするまでリターンしない。
元のページにあるページ操作用のリンクテキストが残っているため、チャンク内の文字数をだいぶ食ってしまっているのがわかります。
また、検索結果としてはいいところを突いているのですが、肝心のプロセス作成の解説などが切れてしまっており、elispとしてどう操作するかまでの情報があまり含まれていないように見えます。
マニュアルでスクレイピングした場合
次にBeautifulSoupを使ってマニュアルでスクレイピングしたソースを使ってみます。
スクレイピングの方針は、チャプター、セクション、サブセクションそれぞれの直下に含まれるテキストコンテンツを単位として抽出することとします。 加えてページ操作のリンクなど不要なものは除いていきます。
参考までにスクレイピング用のコードは以下のような形で実施しました。
|
|
この内容をFAISSでインデックス化し、同じく「外部プロセスを利用するには?」というクエリを問い合わせてみます。
|
|
先程のTextSplitterの結果と比べると、大分詳細な内容が回答されていることがわかります。
参照しているソースの情報は以下のとおりです。
40.4 非同期プロセスの作成 このセクションでは非同期プロセス(asynchronous process)を作成する方法について説明します。非同期プロセスは作成後にEmacsと並列して実行され、Emacsは以降のセクション(プロセスへの入力の送信とプロセスからの出力の受信を参照)で説明する関数を使用してプロセスとコミュニケーションができます。 …
40 プロセス オペレーティングシステムの用語ではプロセス(process)とはプログラムを実行できるスペースのことです。Emacsはプロセス内で実行されます。Emacs Lispプログラムは別のプログラムをそれら自身のプロセス内で呼び出すことができます。これらは親プロセス(parent process)であるEmacsプロセスのサブプロセス(subprocesses)、または子プロセス(child processes)と呼ばれます。 …
2.5.8 プロセス型 プロセス(process)という単語は、通常は実行中のプログラムを意味します。Emacs自身はこの種のプロセス内で実行されます。しかしEmacs Lispでは、プロセスとはEmacsプロセスにより作成されたサブプロセスを表すLispオブジェクトです。 …
まず、インプットとしてセクションやチャプター、サブセクション単位の情報がまとまって渡っていることがわかります。
一方検索で引かれているチャプターやセクションは、同じようなところになっているようなことが伺えます。
しかし、Chat用のLLMに渡る連なる情報が欠けていたTextSplitterと比較して、引かれたチャンクに含まれる情報がしっかり入力されていることもあって、より精度の高い回答が生成されたようです。
まとめ
この記事では、RAGを実際に利用するにあたって困った点と、それを回避するために実施してみた方法をまとめました。
特にベクターDBへ入れておく情報を整理しておくことは、RAGとしてより有意義な回答を得るために非常に有効だと感じました。
ただ、それなりに手間はかかりますし、SPAのようなサイトだと難しい可能性もあります。 またPDFでは、構造化され、parseの選択肢も多いHTMLに比べるとハードルが高いという面もあります。 しかし、多くの場合一度インデックスを作成してしまえば頻繁に再構築する必要もないでしょうし、HTMLの構造も大きく変わらないと思うので、やる価値はあると思いました。
更に料金面でも、トークンのやりとりが増加するため、価格が膨らみやすいことにも注意が必要です。
上記の例では k=3
で3セクション相当のデータをクエリと共にOpenAI APIに送信していますが、6-7000トークンは送っていると思います。
場合によっては k=2
以下に設定し、トークンを押さえても回答精度が出せるケースもあると思うので、調整する必要があると思います。
しかし、RAG自体は自分のインプット効率を高めるのに非常に有用なツールだと思いますので、しっかりと調整することでコスト以上のリターンを得られるようにしていけるのではないかと思います。