近年、OpenAIのGPT-4やGoogleのGemini、MetaのLLaMAをはじめとする大規模言語モデル(Large Language Model:LLM)の能力が大幅に向上し、自然言語処理において優れた結果を収めています[1][2][3]。これらのLLMは、膨大な量のテキストデータで学習されており、さまざまな自然言語処理タスクにおいて、タスクに固有なデータを用いてモデルをファインチューニングすることなく、より正確で自然なテキスト生成や、複雑な質問への回答が可能となっています。

LLM-jp-eval[4]およびMT-bench-jp[5]を用いた日本語LLMの評価結果。Nejumi LLMリーダーボード Neoより取得。

大規模言語モデルは近年急速な進歩を遂げていますが、これらの進歩にもかかわらず、裏付けのない情報や矛盾した内容を生成する点においては依然として課題があります。たとえば、実世界の事実について尋ねたときに誤った回答を生成したり、与えられた文脈情報との不整合や生成内容の論理的な不整合などの問題が発生します。これらの問題は「ハルシネーション(Hallucination)」と呼ばれており、本番環境でLLMを提供する際に大きな課題となっています。

事実性と忠実性に関するハルシネーションの例。事実性に関するハルシネーションでは質問に対する回答中の事実が誤っている。それに対し、忠実性に関するハルシネーションではコンテキストとの一貫性がない回答をしている。画像は[6]より引用。

ハルシネーションを緩和するアプローチの1つに、外部データソースから情報を取得して、生成プロセスに統合する手法であるRetrieval Augmented Generation(RAG)があります[7]。具体的には、以下の図に示すように、クエリに対する応答を生成する前に、外部データソースを検索して関連情報(コンテキスト)を取得し、その情報をクエリとともにプロンプトに埋め込んで、生成時に活用します。こうすることで、ドメイン固有の情報や最新の情報を生成時に活用できるようになり、ハルシネーションを緩和できます。

基本的なRAGのアーキテクチャ

RAGを構築するのは簡単ですが、本番環境での使用に耐えうるようにするためには、性能を改善するための戦略を検討する必要があります。そこで本記事では、RAGの性能を向上させるためのいくつかの戦略について紹介します。性能改善については主に検索側と生成側から取り組む必要がありますが、本記事では検索側に焦点を当てて紹介します。以下の図は、RAGの性能改善時に使用したテクニックとその改善具合を表していますが、プロンプトエンジニアリングだけでなく、検索関連のテクニックを用いて性能を向上させていることがわかります[8]。

OpenAI DevDay 2023で紹介されたRAGの性能を向上させるのに使ったテクニックの事例。チャンキングやリランキング、クエリ拡張など検索系のテクニックを使っていることがわかる。「Tool use」では、クエリを分類して、場合によってはSQLデータベースにアクセスできるようにしたとのこと。

本記事の構成についてですが、まず検索を改善するための戦略について紹介します。これらの戦略の中には、筆者が日本語のデータセットを使って検証した結果を含めて紹介しているものもあります。検証時に用いたデータセットやプロンプトなどの詳細については付録に記載したため、詳細が知りたい場合はそちらを参照してください。各戦略を紹介したあと、本記事を締めくくります。

前処理

まずは前処理についてです。前処理は、検索システムの性能を向上させるために重要なステップです。前処理を行うことで、検索システムがより正確に検索できるようになります。逆に言えば、元のデータが検索に適していなければ、どんな検索手法を使ったところで関連する情報は検索できません(ゴミを入れればゴミが出てくる)。そのため、検索性能を改善したい場合、最初に確認しておいたほうがよいステップと言えるでしょう。

よくある入力文書としてはPDFがありますが、これに対する戦略は、なるべく代替となるデータソース(Word文書など)を探して、そちらを処理するほうが良いということです(代替となるものがないとか、あっても持ち主が渡してくれないとかあると思いますが)。PDFは基本的に人間が読むものなので、コンピューターでの取り扱いが難しい場面が存在します。もしPDFからテキストを抽出する場合、あらゆる場合において100%の正しさで抽出するのは厳しいことを認識しておく必要があります。

PDFからの抽出自体はライブラリやサービスが整備されているので、いくつか考慮する点について書いておきます。厄介な問題として、実際に表示されている以上のテキストが含まれていることがある点が挙げられます。たとえば、ページの外側に配置されている、非常に小さな文字、白背景に白い文字、文字間に含まれるスペースなどが該当します。これらのテキストは、人間が読むときには気にならないものの、コンピューターにとってはノイズとなることがあります。そのため、必要に応じて、除去しましょう。

見えないテキストの例。例はNestle 2010と2012の年次報告書より(それぞれ13ページ目と16ページ目)。左側の例では、画面外に見えないテキストが存在するため、テキストの抽出処理を行うと、表示されていないテキストが抽出される。右側の例では、白背景に白文字のテキストが存在している。

これもよくあると思いますが、テキストだと思ったら実際にはスキャンした埋め込み画像というパターンも扱う必要があるでしょう。このような場合は、テキストデータが存在しないため、OCRに頼ることになります。OCRのエラーは、検索システムの性能低下につながる可能性があるため、注意が必要です。たとえば、あるAIに関する文書をOCRで読み込んだあと、「AI」と検索してヒットしなかったのを不思議に思って原因を調査したところ、「AI(エーアイ)」ではなく「Al(エーエル)」と認識されていたなんてことがありました。このような認識誤りに対しては、後処理で対処することになります。

レイアウト解析による文書構造の検出もよく行われる処理です。この処理では、文書内のタイトルや表、図、パラグラフといった要素を検出します。この有用性については「メタデータによるフィルタリング」や「チャンキング戦略」で触れることにしますが、PDF文書の構造を解析するか否かでRAGの性能が変わるという報告[9]もあるので、性能を改善したい場合には検討することになるでしょう。

レイアウト解析と関連しますが、単語とパラグラフの検知や順番を決める処理も必要になることがあります。ライブラリにもよりますが、ものによっては2段組みを無視してテキストを読み込んでしまうことがあります。そのような場合、文章がめちゃくちゃになるので使い物になりません。英語の文章をOCRで読み込むと、接続を表すハイフンがそのまま残ってしまうこともあります。また、文章の順番を決める必要がある場合もあります。たとえば、以下の文章であれば、2段組みの左側まで読み終えたら、そのまま下のテキストを読み込むのではなく、2段組みの右側に移動して読み込んだほうがよいでしょう。

文章の順番を考慮して読み込みたい場合の例

テキストの抽出とも関係しますが、ときには文書のメタデータを抽出しておくと役に立つ場合があります。このようなメタデータの例としては、ページ番号や章や節のタイトル、文書の作成日、地理情報、文書のカテゴリなどが挙げられます。これらのメタデータは、検索結果を絞り込むためのフィルターとして使うことで検索性能を向上させられることがあります。たとえば、「昨日のメールを要約して」というクエリの場合、文字列としての「昨日」とメール本文をマッチさせるのではなく、メールの受信日にマッチした結果を返さなければ、望むような応答はできないでしょう。

場合によっては、文書を分類しておくことが有効な場合もあります。たとえば、日本語と英語の文書を扱う必要がある場合、言語ごとに分類してから、それぞれの前処理パイプラインに処理を任せる構成にすることも考えられます。それ以外にも、ファイルの中身の形式(請求書、契約書、レシートなど)による分類やファイル形式(PDF、Word、Excelなど)による分類なども考えられます。PDFのように、同じファイル形式でも、Wordから変換したものやPowerPointから変換したものが混ざっているような場合、変換元のファイル形式によって、処理を分けたくなる場合もあります。

その他の前処理としては、テキストのクリーニングや正規化があります。たとえば、テキストのクリーニングとしては、マークアップなどの不要な文字列の除去や、正規化、OCRのエラー修正などが挙げられます。このような処理は、検索エンジン側でしてくれるものもありますが、自分でしておく必要があることもあります。たとえば、最近はベクトル検索を使う方も多いと思いますが、以前にOSSの埋め込みモデルを使ったときには、その埋め込みモデルが入力をあらかじめ全角に変換していることを想定していたため、前処理で変換しておく必要がありました。このような前処理をしなければ、性能低下につながります。

メタデータによるフィルタリング

メタデータによるフィルタリングでは、文書の属性情報(メタデータ)に基づいて、文書を絞り込む処理をします。たとえば、文書のメタデータとしては、文書のタイトルや作成者、作成日、キーワード、カテゴリなどが挙げられます。これらの情報を利用することで、文書の内容を直接参照することなく、必要な文書だけを効率的に絞り込めます。前処理の時点で、文書からメタデータを抽出しておく必要がありますが、うまく使えれば検索性能を改善するのに役立ちます。

メタデータによるフィルタリングが役に立つ例として、レストラン検索について考えてみましょう。たとえば、あるユーザーがお昼時に東京で携帯電話から「とんこつラーメン」と検索したとします。その検索結果として、博多のラーメン屋が表示されたらユーザーにとって期待外れな結果である可能性が高いでしょう。この場合、ユーザーはおそらくランチでラーメン屋に行きたいだけなので、文字列の一致度だけでなく、メタデータに格納された店の位置情報を利用して、近くの店を表示したほうが役に立つはずです。

別の例としてPDFTriage[10]について説明します。RAGでは検索したコンテキストを生成時に利用しますが、多くの場合、WebページやPDF、プレゼンテーションなどの構造を持つ文書を平文として扱っています。このような方法だと、たとえば「5〜7ページを要約して」や「表3における最大の売上高の年は?」といった質問に回答するのが難しくなります。そこで、PDFTriageでは文書構造に関するメタデータを抽出したあと、fetch_pagesなどの関数を利用して、質問に回答するのに必要なコンテキストを抽出し、生成に利用しています。こうすることで、既存の手法よりも優れた性能を達成しています。

PDFTriageでは、文書から構造化されたメタデータ表現を生成してから問い合わせる。画像は[10]より引用。

最後に私の好きな記事[11]の事例を紹介して、本節を終えたいと思います。この事例では、RAGを使ってメールアプリのアシスタントを作る方法について解説しています。このようなアシスタントでは、ユーザーは「昨日〇〇さんは何の仕事をしていた?」のようなクエリを入力してくるわけですが、このような場合に日付をメタデータとして利用できると、検索性能を向上させることができます。記事ではそれだけでなく、キーワード抽出や固有表現認識、日付の範囲抽出などを組み合わせて検索性能を向上させていたり、結果をヒューリスティックや機械学習を使ってリランキングする方法についても紹介しています。ぜひ一読してみてください。

メタデータの抽出と検索への活用

チャンキング戦略

RAGの文脈におけるチャンキングとは、長いテキストをより小さなセグメント(チャンク)に分割するプロセスのことを指します。RAGでは、基本的には元のテキストではなく、分割して得られたチャンクを検索対象とします。最近では、AnthropicのClaude 2のような長い入力を扱えるモデルもありますが、情報の位置によってはうまく利用できないことがあること[12]や、入力が小さいほど処理も速い、料金も安いといった点から、入力クエリに関連するチャンクだけを活用して生成することがよく行われています。

分割するだけだと書くと、簡単そうに聞こえるのですが、実際にはさまざまな方法が考え出されており、思っているよりも複雑なプロセスです。基本的には関連するテキストをひとまとまりにしておきたいのですが、何をどうまとめたいかはテキストの種類にも依存し、チャンクが小さすぎたり大きすぎたりしても、検索性能が低下したり、関連するテキストを生成器に与える機会を逃したりする可能性があります。どのような設定で分割するのがよいのかは状況によって異なるので、実験しながら良い方法を探っていくことになります。

チャンキングについてイメージしやすくするために、いくつかの方法について例を挙げて説明します。まず、もっとも単純な方法は、テキストの内容や構造に関係なく、テキストを指定された文字数やトークン数で分割する方法です。この方法はわかりやすいですが、テキストの内容や構造を考慮していないため、1つのチャンクの中に異なるトピックを持つパラグラフが含まれたり、文章の途中で切れることがあり、検索や生成の性能低下につながる可能性があります。以下に文字数で分割した例を示します。異なるチャンクを色分けして表示していますが、チャンクの境界が文章の途中にあったり、パラグラフにまたがっていることがわかります。

LangChainのCharacterTextSplitterを用いて、文字数で分割した例。なお可視化にはChunkViz v0.1を用いた。

別の方法として、テキストを段階的に小さなチャンクに分割する方法があります。この方法では、いくつかの区切り文字を使って、テキストを階層的かつ反復的に小さなチャンクに分割します。たとえば、["\n\n", "\n", " ", ""]という区切り文字を使う場合、最初は\n\nで分割し、その結果が指定したサイズ以下のチャンクを生成しない場合は次の区切り文字である\nで分割し、以下これを繰り返すといった処理をします。この方法は、前の方法よりはテキストの構造を考慮しているため、検索や生成の性能が向上する可能性があります。以下にその例を示します。チャンクサイズは調整していますが、先ほどと比べるときれいに分割できています。

LangChainのRecursiveCharacterTextSplitterplitterを用いて、段階的に分割した例

文書の持つ構造に基づいてチャンクに分割する方法もあります。実際のところ、我々が業務で扱うような文書は、ほとんどの場合、ある程度の構造を持っています。たとえば、論文の場合は、タイトル、背景、関連研究などの構造を持っています。このような構造を利用して分割すると、意味的にきれいな分割をできるだけでなく、章や節のタイトルをフィルタリングや検索に活用して検索性能を向上させられる可能性があります。個人的には、Word文書をこの方法で分割していますが、MarkdownやHTMLであれば、ライブラリに分割用の機能が用意されていることもあります[13][14]。画像や表の扱いはまた別の難しさがありますが、表に関する戦略については[15]が参考になります。

文書の保つ構造に基づいてチャンクに分割する例。実際には、ここからさらに小さく分割したりする。

最近では文の意味の近さに基づいて分割する方法[16](以下の図を参照)も実装されていたり、紹介した以外にもさまざまな分割方法があっておもしろいのですが、そのすべてをここで紹介することはできないので、分割の話はここまでにしておきます。これまでにチャンキングの戦略について説明してきましたが、どのような戦略がよいのかは状況によって異なるので、実験しながら良い方法を探っていくことになります。

文の意味の近さに基づいて分割する方法。隣り合う文同士の類似度を比較し、決められたしきい値によってチャンクへ分割する。画像は[17]より引用。
チャンクへの分割方法も重要ですが、チャンクサイズの設定も検索性能に影響を与えます。チャンクサイズを小さくすると、関連する情報が複数のチャンクに分割されてしまう可能性がある一方、大きくすると、1つのチャンクに複数のトピックに関する情報が含まれてしまう可能性があります。また、埋め込みモデルを使ってチャンクをベクトルに変換する場合、そのモデルが扱える入力長の制限に合わせてチャンクサイズを設定する必要があります。どんな場合にでも最適な値というのは存在しないため、実験しながら値を決定する必要があります。

チャンクサイズの設定に関するいくつかの実験について紹介しておきましょう。Microsoftのブログでは、チャンクサイズとオーバーラップの設定を変えた場合のベクトル検索の性能についての実験結果が示されています[18]。以下の表を見ると、チャンクサイズが小さいほうが性能が高くなっていることがわかります。一方、[19]や[20]の実験では、チャンクサイズを大きくしたほうが性能が高くなっています。使用しているデータセットも評価方法も異なる実験ではありますが、扱うテキストや埋め込みモデルなどの状況によって、適切なチャンクサイズが異なるであろうことがうかがえます。

トークン数 Recall@50
512 42.4
1024 37.5
4096 36.4
8191 34.9

 

本節の最後に、検索対象のチャンクに関する話題を紹介しておきます。これまでは、テキストをチャンクに分割し、検索したチャンクを生成器に渡すことを想定して説明してきましたが、必ずしも検索対象のチャンクと生成器に渡すチャンクを一致させる必要はありません。個人的な経験になりますが、検索時はセクションタイトル、生成時にはセクション中のテキストを使うほうがよいことがありました。このように、検索対象のチャンクと検索結果として返すチャンクを切り分ける戦略が有効なことがあります。LangChainやLlamaIndexのような主要なライブラリでは、以下のような実装が利用可能です。

埋め込みモデルの選択

伝統的な情報検索では、クエリと文書の類似度を計算するために、転置インデックスを用いてクエリ中の単語を含む文書をマッチングし、文書中の単語の出現回数をもとにしたTF-IDFやBM25といったスコア付け手法が使われてきました。これらの手法は、単語の有無や出現回数をもとにしているため、同義語や異表記に対しては特別な処理をしなければならず、また、文書やクエリの意味を捉えるのに限界があります。たとえば、以下の2つの文は、意味や単語の順序を考慮せず、単語の頻度だけを用いて類似度を計算すると、非常に高い値となってしまいます。

  • 犬が人を噛んだ
  • 人が犬を噛んだ

近年では、単語や文書の意味を考慮した検索を実現するために、埋め込みを使った検索も使われるようになりました。埋め込みとは、単語や文書を表現する数値ベクトルのことを指します。埋め込みモデルを使うと、似た意味の単語やテキストを似たようなベクトルに変換してくれるため、単語や文書の意味を考慮した検索の実現を期待できます。検索時には、クエリの埋め込みを文書の埋め込みと同じベクトル空間に存在するように変換するため、あとはそのベクトル空間上でベクトル同士の類似度を計算すれば、クエリと文書の類似度がわかるというわけです。

埋め込みモデルを使ったテキストの変換

埋め込みを使った場合の情報検索のアーキテクチャについて以下の図に示します。埋め込みを使った情報検索では、まず、インデックス時には文書を埋め込みに変換して、埋め込みをインデックスに格納します。クエリ時には、クエリを埋め込みに変換して、インデックスに格納された埋め込みとの類似度を計算します。最後に、類似度が高い順に文書を並べ替えて、検索結果を返します。この図では、文書とクエリを同じモデル(エンコーダー)で変換していますが、別々のモデルを使って変換することもできます。

埋め込みを使った情報検索のアーキテクチャ

埋め込みを使う場合は、適切な埋め込みモデルを選択することが重要になります。検索などの目的とするタスクにおける性能はもちろん重要ですが、対応言語や最大入力長、OSSモデルか否か、学習データのドメイン、いつの時点の学習データを使ったか、マルチモーダル対応か否かなど、さまざまな観点を考慮して選択する必要があるでしょう。OpenAIなどのサービスを使う場合と比べて、OSSのモデルを使う場合はインフラの面倒を見る必要がありますが、選択の幅が広い点やチューニングの余地がある点は利点と言えます。検索を対象にしていたわけではありませんが、以前にOpenAIのモデルと医療に特化したOSSのモデルを医療テキストの意味的な類似性を測るタスクで比較したときは、後者のほうが性能が高かったことがあります[21]。

現在、どのようなモデルの性能が高いのかを知りたい場合は、埋め込みモデルのベンチマークであるMTEB(Massive Text Embedding Benchmark)[22]のリーダーボードを確認してみると良いでしょう。このベンチマークでは、分類やクラスタリング、情報検索といったタスクのデータセットを用いて、各モデルの性能が競われています。基本的には英語のモデルを対象にしていますが、中には多言語対応のモデルがあるので、日本語を扱う場合には、それらのモデルが採用候補になるでしょう。

MTEBの結果。平均的な性能でOpenAIのtext-embedding-ada-002を上回るモデルはいくつもあることがわかる。

また、日本語の埋め込みモデルについて知りたい場合、名古屋大学による研究論文[23]とリポジトリが参考になります。この研究では、SimCSEと呼ばれる手法を使って学習した日本語の埋め込みモデルの性能を比較しています。以下の表に示すように、数十のモデルに対する性能が評価されているので、これをとっかかりにモデルを選択し、学習して評価してみると良いでしょう。願わくは、日本語版のMTEBが出てきますように(すでにあったら教えてください)。

比較結果の表。表は[23]より引用。
以下に、いくつかの埋め込みモデルを日本語の検索タスクで評価した結果を示します。ここで使ったモデルは、多言語E5(multilingual-e5-*)[24]の2つのモデル、OpenAIのtext-embedding-ada-002、Cohereが提供している多言語用のモデル(embed-multilingual-v3.0)です。E5はOSSとして公開されており、あとはSaaSとして提供されているモデルです。結果を見ると、multilingual-e5-largeはOpenAIのモデルに匹敵する性能であり、Cohereのモデルはそれらを上回っていることがわかります。すべての場合において当てはまるわけではありませんが、埋め込みモデルの選択が検索性能に影響を与えることを実感していただければと思います。

モデル Hit Rate@10 MRR@10
multilingual-e5-base 0.7679 0.5352
multilingual-e5-large 0.8457 0.6364
text-embedding-ada-002 0.8355 0.6436
embed-multilingual-v3.0 0.8584 0.6485

 

ファインチューニング

提供されている埋め込み用のサービスやOSSをそのまま使うこともできますが、学習用のデータセットを用意することで、モデルをチューニングし、性能向上をさせられる余地があります。そのための方法として、ここでは以下の2つを紹介します。
  • アダプターの学習
  • 埋め込みモデルのファインチューニング

アダプターの学習

アダプターを学習する方式では、任意のモデルから生成された埋め込みに対して適用できるアダプターをファインチューニングすることで、検索性能の向上を目指します。埋め込みモデルから得られた埋め込みを直接使う代わりに、アダプターで変換した結果を使うことで、特定のデータやクエリに対する検索に適した表現に変換されていることが期待できます。メリットとして、OpenAIのtext-embedding-ada-002のような任意のモデルに使える点、文書を再度埋め込む必要がない点が挙げられます。
アダプターのファインチューニング

アダプターを学習する手順は以下のようになっています。まずは学習用のデータセットが必要になるため、それを用意します。すでにあればそれを使えばいいですが、ない場合はLLMを使って、質問と回答のペアを生成することで用意できます。その後、生成されたペアを使って、アダプターをファインチューニングします。最後に、ファインチューニングされたアダプターを使って、任意のモデルから生成された埋め込みを変換します。

    1. 学習用データセットの生成(質問と回答からなる)
    2. モデルのファインチューニング
    3. モデルの評価

任意のモデルに対して使える点や学習が高速な点、アダプターのサイズが小さいのでデプロイが簡単な点などがメリットとして挙げられるのですが、その一方、性能向上という観点からは効果はそれほど大きくない印象です。実際、LlamaIndexのチュートリアル[25]で示されている例でも大幅に向上しているわけではありませんし、個人的に実験したときも、性能に大きな変化はありませんでした。とはいえ、試しやすい方法ではあるため、選択肢の1つとして検討するのはありかもしれません。

埋め込みモデルのファインチューニング

こちらの方式では、埋め込みモデルそのものをファインチューニングすることで、一般的なモデルを特定のデータやクエリに対する検索に最適化されたモデルに変換します。モデルそのものをチューニングする仕組み上、OpenAIなどのモデルに対しては適用できませんが、アダプターを使ったときと比べると検索性能をより改善することが期待できます。学習用のデータはLLMを使って質問と回答のペアを得ることで生成でき、そのペアを使ってモデルをファインチューニングします。こうしてファインチューニングしたモデルを使えば、検索に適した埋め込みを得られます。

以下に、多言語E5をファインチューニングして得られたモデルを検索に使うことで、検索性能がどのように変化するかを検証した結果を示します。多言語E5にはbaselargeの2つのモデルがあるので、これらをファインチューニングする前後の検索性能を測定することにします。また、比較用のモデルとしてOpenAIのtext-embedding-ada-002での性能も測定します。また、学習用のデータセットとして、人間が作成した質問と回答のペアとLLMが作成したペアのそれぞれを使った場合の性能を検証しています。

人間が作成した質問と回答のペアを使って学習した場合の結果は以下のとおりです。学習前のモデルはlargeモデルがtext-embedding-ada-002と同程度かそれより低い性能ですが、学習をすることで性能が大きく改善することを確認できました。

モデル Hit Rate@10 MRR@10
text-embedding-ada-002 0.8355 0.6436
multilingual-e5-base 0.7679 0.5352
multilingual-e5-large 0.8457 0.6364
multilingual-e5-base-finetuned 0.8648 0.6456
multilingual-e5-large-finetuned 0.8980 0.6660

次に、GPT-3.5 Turboを用いて質問文から回答を生成し、それらのペアを用いて学習した結果(multilingual-e5-base-finetuned-hyde)を以下に示します。結果を見ると、チューニングしない場合よりは性能が向上していますが、人間が作成したデータを用いた場合と比べると低い性能になっています。回答の生成には、後ほど説明するHyDEのプロンプトを使用したので、回答が事実に基づいていない点や、回答の文字数の分布が生成したものとそうでないもので大きく異なっていることが原因として考えられます。

モデル Hit Rate@10 MRR@10
multilingual-e5-base 0.7679 0.5352
multilingual-e5-base-finetuned-hyde 0.7883 0.5825

クエリ変換

RAGで検索するときに、クエリを書き換えることで性能が向上することが報告されています。その背景として、ユーザーのクエリが検索に適したものになっているとは限らない点があります。この問題を解決するために、書き換え用のモデルを使ってクエリを書き換える手法が提案されています。たとえば、Rewrite-Retrieve-Read[26]と呼ばれる手法では、検索する前に、LLMやクエリ書き換え専用のモデルを用いてクエリを書き換えることで性能が向上すると報告されています。

Rewrite-Retrieve-Readの構成。真ん中はLLMを使って書き換える場合で、右側が専用モデルを使って書き換える場合。画像は[26]より引用。

場合によっては、複数のクエリを生成し、それらに対して検索を実行して、結果をマージすることで性能が向上することもあります。たとえば、RAG-Fusion[27]と呼ばれる手法では、ユーザーのクエリに対して、LLMを使って複数のクエリを生成し、それらに対して検索を実行して、結果をマージして返します。これにより、いくつかの観点から検索を実行できます。また、「TISとインテックはどちらが先に創業されましたか?」のようなクエリを「TISの創業年は?」と「インテックの創業年は?」のようなサブクエリに分割して検索するようなこともできます。

RAG-Fusionのアーキテクチャ

HyDE(Hypothetical Document Embeddings:仮の文書の埋め込み)は、入力されたクエリに対して仮の文書を生成し、その文書を埋め込み、検索に使用する手法です[28]。典型的な文書検索では、ユーザーが入力したクエリと文書の類似度を計算することが多いですが、クエリと文書が必ずしも類似しているとは限りません。そのため、生成モデルを使って回答のような文書(Hypothetical Document)を生成し、その文書と検索エンジンに格納された文書の類似度を計算してしまおうという考え方になります。

HyDEの図。画像は[28]より引用
HyDEによるクエリ変換の例として、「国民年金の免除申請をしたいのですが、申請に必要な持ち物は何を持っていけば良いですか?」というクエリを変換した例を以下に示します。内容が正しいとは限りませんが、回答のような文書が生成されていることがわかります。元のクエリの代わりに、この文書をクエリとして使うことになります。

国民年金の免除申請をする際には、いくつかの持ち物が必要です。まず、申請書が必要ですので、市役所や年金事務所で入手するか、インターネットでダウンロードして印刷してください。また、申請者本人の本人確認書類も必要です。これには、運転免許証やパスポート、健康保険証などがあります。さらに、収入証明書も必要ですので、給与明細や年金受給証明書などを用意してください。また、免除申請の理由を証明するために、医療証明書や障害者手帳などの医療関係の書類も必要です。これらの持ち物を整理して、申請時に提出することで、スムーズに免除申請をすることができます。申請に必要な持ち物は、市役所や年金事務所のウェブサイトなどで詳細を確認してください。

まとめると、HyDEでは以下の手順で検索をすることになります。

  1. 検索クエリを与えて、GPTに仮の文書を生成させる
  2. 生成した文書を埋め込みモデルを用いてエンコード
  3. エンコードした情報から実際の文書を検索

ここで紹介した以外にも、フォローアップの質問を繰り返す方法[29]やユーザーのクエリを抽象化する方法[30]など、クエリ変換の方法はさまざまなものがあります。ただし、質問によって適したクエリ変換の戦略は異なると考えられるので、クエリに応じて切り替える仕組みがあることが望ましいと考えられます。このような仕組みはクエリを分類することで実現できますが、その場合はクエリの分類性能が重要になります。自分で機械学習モデルを用意する場合は学習・検証・評価用データセットを用意し、LLMを使う場合はプロンプトのチューニング用と評価用のデータセットを用意して、性能を検証しながら進めるとよいでしょう。

クエリの分類例。この図では、分類先は適当に入れているだけなので、扱うクエリの種類に合わせて設定する。

以降では、日本語の質問応答データセットを利用して、クエリ変換をしたときの検索性能の検証結果を紹介します。クエリ変換の手法としては、Rewrite-Retrieve-Read(Rewrite)、マルチクエリ生成(Fusion)、HyDEの3つです。なお、マルチクエリ生成では3つのクエリを生成し、元のクエリと合わせて検索を実行したあと、結果をマージしています。また、すべての場合において、クエリ変換にはGPT-3.5 Turboを使用し、temperatureには0を設定しています。プロンプト等の詳細については付録を参照してください。

評価結果は以下のとおりです。BM25を使った全文検索の場合はHyDE以外は性能が向上するという結果になりました。HyDEの場合は、変換したクエリと不正解文書間の単語の重なりが多くなったために性能が低下した可能性があります。一方、OpenAIのtext-embedding-ada-002を使ったベクトル検索に対して適用した場合、書き換えたクエリだけを与えた場合は性能が低下しましたが、それ以外の場合は性能が向上するという結果になりました。書き換えたクエリだけで検索すると、元の意図を失う可能性があるので、元のクエリも与えたほうがよさそうだということ、Rewrite + OriginalFusionの性能がほぼ同じなので、今回は複数のクエリを生成する効果が薄かったことがわかります。ベクトル検索を使うと、同じような意味のクエリは同じようなベクトルに変換されるので、より多様な複数のクエリを生成するようにすれば、また違う結果になる可能性があります。

モデル Hit Rate@10 MRR@10
BM25 0.5918 0.3955
BM25 + Rewrite 0.6147 0.4202
BM25 + Fusion 0.6441 0.4398
BM25 + HyDE 0.5332 0.3451
text-embedding-ada-002 0.8355 0.6436
text-embedding-ada-002 + Rewrite 0.8329 0.6211
text-embedding-ada-002 + Rewrite + Original 0.8520 0.6374
text-embedding-ada-002 + Fusion 0.8520 0.6381
text-embedding-ada-002 + HyDE 0.8712 0.6546

ハイブリッド検索

検索性能を改善するための手法の1つとしてハイブリッド検索が知られています。ハイブリッド検索は、2つ以上の異なる検索技術を組み合わせた検索方法です。最近は、全文検索とベクトル検索の組み合わせを見ることが多く、その良いとこ取りをすることで検索性能を改善します。

典型的なハイブリッド検索の構成

ハイブリッド検索の有効性はいたるところで報告されていますが、「適当に全文検索とベクトル検索を組み合わせるだけで性能が上がるのか?」というとそれには疑問があります。ハイブリッド検索では、全文検索とベクトル検索の結果をランク融合アルゴリズムを使って統合することが行われます。そのような仕組み上、どちらか片方の検索性能が低い場合、全体としての性能が下がることが考えられるはずです。

そこで、日本語の質問応答データセットを利用して、ハイブリッド検索をしたときの検索性能の検証結果を紹介します。最初に、何も工夫せずに全文検索とベクトル検索を組み合わせた結果が性能を改善しないことがあることを示し、その次に全文検索を改善することでハイブリッド検索の性能が向上することを示します。

今回の実験では、全文検索器とベクトル検索器をRRF[31]を使って組み合わせることでハイブリッド検索器を構築し、日本語の質問応答データセットを利用して、検索性能を評価します。RRFは複数の検索結果を統合するために使われるランク融合アルゴリズムの一種で、Azure AI SearchやElasticsearchでも使える[32][33]人気のあるアルゴリズムです。

全文検索には、LangChainのBM25RetrieverおよびAzure AI Searchを使用します。BM25RetrieverのトークナイザーにはMeCabを採用し、その他はデフォルト設定とします。Azure AI Searchにはアナライザーとしてja.luceneja.microsoftを用意しました。一方、ベクトル検索にはOpenAIのtext-embedding-ada-002を使用し、ベクターストアとしてはFAISSのデフォルト設定を採用しました。

実験結果を以下に示します。全文検索ではスコアの計算にBM25を使っていますが、性能には違いがあることがわかります。BM25にはいくつかのバリエーションがあるので、その違いによる可能性もありますが、BM25のバリエーションによる性能差は小さいという研究結果[34]があることから考えると、トークン化やフィルタリングなどの前処理の違いによる性能差と考えられます。

ハイブリッド検索の性能を見ると、ベクトル検索だけの場合と比べて性能が低下するという結果になりました。結果を見ると、性能はja.lucene > ja.microsoft > LangChainの順なので、全文検索の性能に影響を受けていることがわかります。もちろん、すべての場合について当てはまる結果ではありませんが、ベクトル検索と全文検索を組み合わせればどんな場合でも性能が上がるという単純な話ではないことがわかりました。

モデル Hit Rate@10 MRR@10
BM25(LangChain) 0.5918 0.3955
BM25(ja.microsoft) 0.5995 0.4055
BM25(ja.lucene) 0.6862 0.4649
text-embedding-ada-002 0.8355 0.6440
text-embedding-ada-002 + BM25(LangChain) 0.7819 0.5855
text-embedding-ada-002 + BM25(ja.microsoft) 0.7997 0.5859
text-embedding-ada-002 + BM25(ja.lucene) 0.8252 0.6185

※実際のところ、検索システムを構築している人たちはチューニングしてから使うので、より良い結果が得られるはずです。ただ、ここではチューニングしない場合を対象にしています。実際、チューニングせずにハイブリッドにしているコードを見たことがあり、改善していなさそうでした。

というわけで、再考が求められます。上記の結果より、単純なデフォルト設定の全文検索を使うのには問題がありそうなので、チューニングをし、性能を改善してからハイブリッド検索器を構築してみましょう。ここでは、上記の実験結果でもっとも性能が悪かったLangChainの検索器を対象に性能改善を試みることにします。

性能改善にはいくつかの観点が考えられますが、ここでは以下の観点から取り組みます。

  • スコア計算
  • 前処理
  • 後処理
  • ランク融合アルゴリズム

まずスコア計算についてですが、先ほどの実験ではBM25Retrieverを使いましたが、今回の実験ではTFIDFRetrieverに変更します。こちらは、TFIDFの値をもとにスコア計算をする検索器であり、内部的にはscikit-learnの実装が使われています。scikit-learnの実装を使うと、Ngramなどの設定が楽なので変更しています。

次に前処理ですが、先ほどの実験で全文検索の中ではもっとも性能が高かったja.luceneのアナライザーを参考に、以下の処理をします。トークン化にはSudachi[35]を使用しており、分割モードにはもっとも短い単位であるAを使用しました。また、Ngramの範囲には(1, 2)を設定しています。

  • 基本形への変換
  • 品詞による単語の除去
  • 全角と半角の変換
  • ストップワードの除去
  • 小文字化
  • その他正規化
  • Ngram

次の後処理では、リランカーを用いて、検索結果のリランキングをしています。リランカーについてもさまざまな選択肢が考えられますが、今回はbge-reranker-largeを採用しました[36]。このモデルは、XLM-RoBERTaをベースに主に中国語と英語のデータセットを使って作成されています。

最後のランク融合アルゴリズムでは、今回採用したランク融合アルゴリズムであるRRFのハイパーパラメーター(k)をチューニングしています。この値はデフォルトでは60ですが、今回は10としました。以下の図にRRFのハイパーパラメーターと順位を変化させたときのスコアの変化を示していますが、小さなkを設定すると、検索結果上位の文書をより重視して検索結果をマージすることに繋がります。

ハイパーパラメーターと順位を変えたときのスコアの変化

ここまでの処理を実装して評価した結果を以下に示します。改善版の全文検索は元のBM25と比べて性能が大きく改善していることがわかります。また、この全文検索をベクトル検索と組み合わせた結果を見ると、こちらも改善していることを確認できました。狙いどおり、全文検索の性能を改善することで、ハイブリッド検索の性能改善につながっています。

モデル Hit Rate@10 MRR@10
BM25(LangChain) 0.5918 0.3955
改善版の全文検索 0.8189 0.5615
text-embedding-ada-002 0.8355 0.6440
text-embedding-ada-002 + BM25(LangChain) 0.7819 0.5855
text-embedding-ada-002 + 改善版 0.8954 0.6616

リランキング

検索結果のリランキングは、リランキング用のモデル(Reranker)を用いて、検索結果を表示する前に、その順序を並び替える処理のことを指します。ご承知の通り、検索エンジンにもランク付けの機能はありますが、リランキング用のモデルを使うことで、最終的な順位を大幅に改善できる可能性があります。典型的な構成は以下に示すように、検索結果に対してリランキング用のモデルを適用して、最終的な順位を決定します。

リランキングを用いた典型的な検索アーキテクチャ。

リランキングには、さまざまな入力や手法を使えますが、最近では言語モデルを用いた手法が使われる場面を見かけるようになりました。このようなモデルでは、検索結果の上位の文書に対して、言語モデルを用いてクエリと文書の類似度を計算し、その類似度を用いてリランキングを行います。たとえば、最終的に上位10件の文書を表示したい場合、結果上位100件の文書に対して、クエリと文書を以下のようなモデル(クロスエンコーダー)に与えて類似度を計算し、計算した結果を使って並び替えた文書の上位10件を表示します。

典型的なクロスエンコーダーによるリランキング

言語モデルを用いて類似度計算できるなら、最初からそれでランク付けすればいいのでは?と思うかもしれませんが、このようなモデルは一般的に計算コストが高いため、膨大な量の文書に対するランク付けに使うのは実用的ではありません。そのため、検索器を使って、上位100件程度の文書を選択し、それらに対して言語モデルを用いたリランキングを行うのが一般的です。場合によっては、以下の図に示すように、精度と速度の異なる複数のモデルを段階的に組み合わせて使うことも考えられます。たとえば、[11]では、高速なヒューリスティックを用いてリランキングしたあと、低速なクロスエンコーダーを用いて再度リランキングしています。

多段階リランキングモデルのアーキテクチャ。画像は[37]より引用。
実際に、日本語での全文検索とベクトル検索の結果に対して、リランキング用のモデルを適用し、その結果を評価した結果を示します。構成としては、上記の図(リランキングを用いた典型的な検索アーキテクチャ)と同じものになります。以下では、検索器とリランカーについて説明します。

構成図のうち、最初の段階である検索には以下の2つを用意しました。それぞれ全文検索とベクトル検索に対応しています。どちらも上位100件の文書を検索しており、その結果をリランカーに渡しています。

    • BM25
    • OpenAIのtext-embedding-ada-002

リランカーとしては以下の3つを用意しました。mmarco-mMiniLMv2-L12-H384-v1(以下、mMiniLM)は、MS MARCOパッセージランキングデータセットを機械翻訳して作成した多言語版データセットであるmMARCOを用いて学習されています[38]。bge-reranker-large(以下、bge)は、XLM-RoBERTaをベースに主に中国語と英語のデータセットを使って作成されたモデルです。Cohereのリランカーは、本記事の執筆時点では日本語を含む14言語に対応したモデルであり、API経由で利用可能です。

評価結果は以下のとおりです。全文検索の場合は、リランカーを適用することで性能が向上する結果となりました。bgeは英語と中国語以外での性能が劣る可能性がありましたが、学習データに多少は日本語データが含まれていることや、英語と中国語での学習が転移していることが効いたと考えられます。一方、ベクトル検索の場合はリランカーを入れることで性能が低下しました。別のデータセットでは性能が向上するという報告[39]もあるので、ベクトル検索と併用することで必ずしも性能が低下するわけではありませんが、いかなる場合でもリランカーを入れれば性能が向上するという単純な話ではなさそうです。

モデル Hit Rate@10 MRR@10
BM25 0.5804 0.3936
BM25 + mMiniLM 0.6671 0.4589
BM25 + bge 0.7730 0.5546
BM25 + Cohere 0.7194 0.5089
text-embedding-ada-002 0.8355 0.6436
text-embedding-ada-002 + mMiniLM 0.7564 0.5119
text-embedding-ada-002 + bge 0.8201 0.5213
text-embedding-ada-002 + Cohere 0.7602 0.5265

クエリのルーティング

ここまでは1つのデータストアに対してクエリを実行する場合について説明してきましたが、実際には複数のデータストアにまたがってクエリを実行したいことがあります。たとえば、業務に関するクエリであれば、社内情報を格納したデータストアに問い合わせたいですが、医療系のサーベイをしたいのであればPubMed、LLMのサーベイであればarXivといったように、クエリに応じてデータストアを使い分けたいということが考えられます。場合によってはクエリをSQLに変換してデータベースにルーティングしたいこともあるでしょう。このような場合には、クエリのルーティングを行うことで、クエリに応じて適切なデータストアに問い合わせることができます。

おわりに

本記事では、RAGの性能向上に効く戦略を紹介しました。この分野は発展が速いので、紹介できていないテクニックも多数ありますが、参考になれば幸いです。人気度などを用いたブースティングやフィールドごとの重み付け、Learning to Rank、パーソナライゼーションなどのテクニックはここでは紹介できませんでしたが、それらを含めた検索性能向上のためのテクニックについて、個人的には以下に挙げた書籍が参考になりました。さらに詳しく知りたい方は、こちらの書籍を参考にしてみてください。

この記事が参考になったら左上の「いいね」ボタンを押して頂けると励みになります。

付録

実験に用いたデータセット

本記事内で紹介した実験の評価用データセットとしては、尼崎市のQAデータ[40]を使用しました。このデータセットには、784の質問に対して対応する回答がAからCの3つのカテゴリでラベル付けされています。Aの場合は正しい情報を含み、Bであれば関連する情報を含み、Cであればトピックが同じであることを意味します。今回はこれらのカテゴリを関連文書として扱っています。

プロンプト

Read-Rewrite-Retrievalのプロンプトは、LangChain Hub上のプロンプトをベースにしている。そのままでは英語のクエリを返してきたり、複数のクエリを返してくることがあったため、日本語用に変更している。

Provide a better search query in Japanese for web search engine to answer the given question. Question {x} Answer:

Rag-Fusionのプロンプトでは、元のクエリをベースに、日本語のクエリを3つ生成するように指示している。元々はLangChain Templatesのresearch-assistantで使われていたプロンプトだが、日本語用に変更して利用している。

Write 3 google search queries in Japanese to search online that form an objective opinion from the following: {question}
You must provide these alternative queries separated by newlines without a prefix number: query 1\nquery 2\nquery 3

HyDEのプロンプトでは、論文で使っていたプロンプトと同様のプロンプトを使用している。

Please write a passage in Japanese to answer the question in detail.
Question: {question}
Passage:

埋め込みモデルのファインチューニング

学習用のデータセットとしては、同じく尼崎市のQAデータに含まれるQAペアを使用した。このQAペアは、尼崎市のWebサイトから収集されており、1786件からなる。今回は、この内の80%を学習用、20%を検証用として利用。

E5の学習には、SentenceTransformersのMultipleNegativesRankingLossを利用した。この損失関数は、バッチ内の正しいQAペアを正例、それ以外の組み合わせを負例として使うことで学習を進める。損失関数の詳細については論文実装を参照。以下は、学習に用いたコードの抜粋。

from sentence_transformers import InputExample
from sentence_transformers import SentenceTransformer
from sentence_transformers import losses
from sentence_transformers.evaluation import InformationRetrievalEvaluator
from sklearn.model_selection import train_test_split
from torch.utils.data import DataLoader

examples = [
    InputExample(texts=[f"query: {q}", f"passage: {a}"])
    for q, a in zip(questions, answers)
]
train_examples, test_examples = train_test_split(
    examples,
    test_size=0.2,
    random_state=42
)
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=8)
test_dataloader = DataLoader(test_examples, batch_size=8)

model = SentenceTransformer("intfloat/multilingual-e5-base")
loss = losses.MultipleNegativesRankingLoss(model)
EPOCHS = 2
warmup_steps = int(len(train_dataloader) * EPOCHS * 0.1)
model.fit(
    train_objectives=[(train_dataloader, loss)],
    epochs=EPOCHS,
    warmup_steps=warmup_steps,
    output_path="results",
    show_progress_bar=True,
    evaluator=evaluator, 
    evaluation_steps=50,
)
 

参考資料

  1. Measuring Massive Multitask Language Understanding
  2. Training Verifiers to Solve Math Word Problems
  3. Evaluating Large Language Models Trained on Code
  4. llm-jp-eval | GitHub
  5. MT-bench-jp | GitHub
  6. A Survey on Hallucination in Large Language Models: Principles, Taxonomy, Challenges, and Open Questions
  7. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks
  8. A Survey of Techniques for Maximizing LLM Performance
  9. Revolutionizing Retrieval-Augmented Generation with Enhanced PDF Structure Recognition
  10. PDFTriage: Question Answering over Long, Structured Documents
  11. A deep dive into the world’s smartest email AI
  12. Lost in the Middle: How Language Models Use Long Contexts
  13. MarkdownHeaderTextSplitter | LangChain
  14. HTMLHeaderTextSplitter | LangChain
  15. Benchmarking RAG on tables
  16. SemanticChunker | LangChain
  17. RetrievalTutorials | GitHub
  18. Azure AI Search: Outperforming vector search with hybrid retrieval and ranking capabilities
  19. Building RAG-based LLM Applications for Production
  20. Evaluating the Ideal Chunk Size for a RAG System using LlamaIndex
  21. 医療テキストを対象としたOpenAI埋め込みのチューニングとその効果
  22. MTEB: Massive Text Embedding Benchmark
  23. Japanese SimCSE Technical Report
  24. Text Embeddings by Weakly-Supervised Contrastive Pre-training
  25. Finetuning an Adapter on Top of any Black-Box Embedding Model
  26. Query Rewriting for Retrieval-Augmented Large Language Models
  27. Forget RAG, the Future is RAG-Fusion
  28. Precise Zero-Shot Dense Retrieval without Relevance Labels
  29. Measuring and Narrowing the Compositionality Gap in Language Models
  30. Take a Step Back: Evoking Reasoning via Abstraction in Large Language Models
  31. Reciprocal rank fusion outperforms condorcet and individual rank learning methods
  32. Relevance scoring in hybrid search using Reciprocal Rank Fusion (RRF)
  33. Reciprocal rank fusion | Elasticsearch
  34. Which BM25 Do You Mean? A Large-Scale Reproducibility Study of Scoring Variants
  35. Sudachi: a Japanese Tokenizer for Business
  36. C-Pack: Packaged Resources To Advance General Chinese Embedding
  37. 多段階リランキングモデルによるテキスト検索
  38. mMARCO: A Multilingual Version of the MS MARCO Passage Ranking Dataset
  39. Boosting RAG: Picking the Best Embedding & Reranker models
  40. FAQ Retrieval using Query-Question Similarity and BERT-Based Query-Answer Relevance