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

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

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

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

本記事の構成についてですが、まず検索を改善するための戦略について紹介します。これらの戦略の中には、筆者が日本語のデータセットを使って検証した結果を含めて紹介しているものもあります。検証時に用いたデータセットやプロンプトなどの詳細については付録に記載したため、詳細が知りたい場合はそちらを参照してください。各戦略を紹介したあと、本記事を締めくくります。
前処理
まずは前処理についてです。前処理は、検索システムの性能を向上させるために重要なステップです。前処理を行うことで、検索システムがより正確に検索できるようになります。逆に言えば、元のデータが検索に適していなければ、どんな検索手法を使ったところで関連する情報は検索できません(ゴミを入れればゴミが出てくる)。そのため、検索性能を改善したい場合、最初に確認しておいたほうがよいステップと言えるでしょう。
よくある入力文書としてはPDFがありますが、これに対する戦略は、なるべく代替となるデータソース(Word文書など)を探して、そちらを処理するほうが良いということです(代替となるものがないとか、あっても持ち主が渡してくれないとかあると思いますが)。PDFは基本的に人間が読むものなので、コンピューターでの取り扱いが難しい場面が存在します。もしPDFからテキストを抽出する場合、あらゆる場合において100%の正しさで抽出するのは厳しいことを認識しておく必要があります。
PDFからの抽出自体はライブラリやサービスが整備されているので、いくつか考慮する点について書いておきます。厄介な問題として、実際に表示されている以上のテキストが含まれていることがある点が挙げられます。たとえば、ページの外側に配置されている、非常に小さな文字、白背景に白い文字、文字間に含まれるスペースなどが該当します。これらのテキストは、人間が読むときには気にならないものの、コンピューターにとってはノイズとなることがあります。そのため、必要に応じて、除去しましょう。

これもよくあると思いますが、テキストだと思ったら実際にはスキャンした埋め込み画像というパターンも扱う必要があるでしょう。このような場合は、テキストデータが存在しないため、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
などの関数を利用して、質問に回答するのに必要なコンテキストを抽出し、生成に利用しています。こうすることで、既存の手法よりも優れた性能を達成しています。

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

チャンキング戦略
RAGの文脈におけるチャンキングとは、長いテキストをより小さなセグメント(チャンク)に分割するプロセスのことを指します。RAGでは、基本的には元のテキストではなく、分割して得られたチャンクを検索対象とします。最近では、AnthropicのClaude 2のような長い入力を扱えるモデルもありますが、情報の位置によってはうまく利用できないことがあること[12]や、入力が小さいほど処理も速い、料金も安いといった点から、入力クエリに関連するチャンクだけを活用して生成することがよく行われています。
分割するだけだと書くと、簡単そうに聞こえるのですが、実際にはさまざまな方法が考え出されており、思っているよりも複雑なプロセスです。基本的には関連するテキストをひとまとまりにしておきたいのですが、何をどうまとめたいかはテキストの種類にも依存し、チャンクが小さすぎたり大きすぎたりしても、検索性能が低下したり、関連するテキストを生成器に与える機会を逃したりする可能性があります。どのような設定で分割するのがよいのかは状況によって異なるので、実験しながら良い方法を探っていくことになります。
チャンキングについてイメージしやすくするために、いくつかの方法について例を挙げて説明します。まず、もっとも単純な方法は、テキストの内容や構造に関係なく、テキストを指定された文字数やトークン数で分割する方法です。この方法はわかりやすいですが、テキストの内容や構造を考慮していないため、1つのチャンクの中に異なるトピックを持つパラグラフが含まれたり、文章の途中で切れることがあり、検索や生成の性能低下につながる可能性があります。以下に文字数で分割した例を示します。異なるチャンクを色分けして表示していますが、チャンクの境界が文章の途中にあったり、パラグラフにまたがっていることがわかります。

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

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

最近では文の意味の近さに基づいて分割する方法[16](以下の図を参照)も実装されていたり、紹介した以外にもさまざまな分割方法があっておもしろいのですが、そのすべてをここで紹介することはできないので、分割の話はここまでにしておきます。これまでにチャンキングの戦略について説明してきましたが、どのような戦略がよいのかは状況によって異なるので、実験しながら良い方法を探っていくことになります。
チャンクサイズの設定に関するいくつかの実験について紹介しておきましょう。Microsoftのブログでは、チャンクサイズとオーバーラップの設定を変えた場合のベクトル検索の性能についての実験結果が示されています[18]。以下の表を見ると、チャンクサイズが小さいほうが性能が高くなっていることがわかります。一方、[19]や[20]の実験では、チャンクサイズを大きくしたほうが性能が高くなっています。使用しているデータセットも評価方法も異なる実験ではありますが、扱うテキストや埋め込みモデルなどの状況によって、適切なチャンクサイズが異なるであろうことがうかがえます。
トークン数 | Recall@50 |
---|---|
512 | 42.4 |
1024 | 37.5 |
4096 | 36.4 |
8191 | 34.9 |
埋め込みモデルの選択
伝統的な情報検索では、クエリと文書の類似度を計算するために、転置インデックスを用いてクエリ中の単語を含む文書をマッチングし、文書中の単語の出現回数をもとにしたTF-IDFやBM25といったスコア付け手法が使われてきました。これらの手法は、単語の有無や出現回数をもとにしているため、同義語や異表記に対しては特別な処理をしなければならず、また、文書やクエリの意味を捉えるのに限界があります。たとえば、以下の2つの文は、意味や単語の順序を考慮せず、単語の頻度だけを用いて類似度を計算すると、非常に高い値となってしまいます。
- 犬が人を噛んだ
- 人が犬を噛んだ
近年では、単語や文書の意味を考慮した検索を実現するために、埋め込みを使った検索も使われるようになりました。埋め込みとは、単語や文書を表現する数値ベクトルのことを指します。埋め込みモデルを使うと、似た意味の単語やテキストを似たようなベクトルに変換してくれるため、単語や文書の意味を考慮した検索の実現を期待できます。検索時には、クエリの埋め込みを文書の埋め込みと同じベクトル空間に存在するように変換するため、あとはそのベクトル空間上でベクトル同士の類似度を計算すれば、クエリと文書の類似度がわかるというわけです。

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

埋め込みを使う場合は、適切な埋め込みモデルを選択することが重要になります。検索などの目的とするタスクにおける性能はもちろん重要ですが、対応言語や最大入力長、OSSモデルか否か、学習データのドメイン、いつの時点の学習データを使ったか、マルチモーダル対応か否かなど、さまざまな観点を考慮して選択する必要があるでしょう。OpenAIなどのサービスを使う場合と比べて、OSSのモデルを使う場合はインフラの面倒を見る必要がありますが、選択の幅が広い点やチューニングの余地がある点は利点と言えます。検索を対象にしていたわけではありませんが、以前にOpenAIのモデルと医療に特化したOSSのモデルを医療テキストの意味的な類似性を測るタスクで比較したときは、後者のほうが性能が高かったことがあります[21]。
現在、どのようなモデルの性能が高いのかを知りたい場合は、埋め込みモデルのベンチマークであるMTEB(Massive Text Embedding Benchmark)[22]のリーダーボードを確認してみると良いでしょう。このベンチマークでは、分類やクラスタリング、情報検索といったタスクのデータセットを用いて、各モデルの性能が競われています。基本的には英語のモデルを対象にしていますが、中には多言語対応のモデルがあるので、日本語を扱う場合には、それらのモデルが採用候補になるでしょう。

また、日本語の埋め込みモデルについて知りたい場合、名古屋大学による研究論文[23]とリポジトリが参考になります。この研究では、SimCSEと呼ばれる手法を使って学習した日本語の埋め込みモデルの性能を比較しています。以下の表に示すように、数十のモデルに対する性能が評価されているので、これをとっかかりにモデルを選択し、学習して評価してみると良いでしょう。願わくは、日本語版のMTEBが出てきますように(すでにあったら教えてください)。
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 |
ファインチューニング
- アダプターの学習
- 埋め込みモデルのファインチューニング
アダプターの学習
text-embedding-ada-002
のような任意のモデルに使える点、文書を再度埋め込む必要がない点が挙げられます。
アダプターを学習する手順は以下のようになっています。まずは学習用のデータセットが必要になるため、それを用意します。すでにあればそれを使えばいいですが、ない場合はLLMを使って、質問と回答のペアを生成することで用意できます。その後、生成されたペアを使って、アダプターをファインチューニングします。最後に、ファインチューニングされたアダプターを使って、任意のモデルから生成された埋め込みを変換します。
-
- 学習用データセットの生成(質問と回答からなる)
- モデルのファインチューニング
- モデルの評価
任意のモデルに対して使える点や学習が高速な点、アダプターのサイズが小さいのでデプロイが簡単な点などがメリットとして挙げられるのですが、その一方、性能向上という観点からは効果はそれほど大きくない印象です。実際、LlamaIndexのチュートリアル[25]で示されている例でも大幅に向上しているわけではありませんし、個人的に実験したときも、性能に大きな変化はありませんでした。とはいえ、試しやすい方法ではあるため、選択肢の1つとして検討するのはありかもしれません。
埋め込みモデルのファインチューニング
こちらの方式では、埋め込みモデルそのものをファインチューニングすることで、一般的なモデルを特定のデータやクエリに対する検索に最適化されたモデルに変換します。モデルそのものをチューニングする仕組み上、OpenAIなどのモデルに対しては適用できませんが、アダプターを使ったときと比べると検索性能をより改善することが期待できます。学習用のデータはLLMを使って質問と回答のペアを得ることで生成でき、そのペアを使ってモデルをファインチューニングします。こうしてファインチューニングしたモデルを使えば、検索に適した埋め込みを得られます。
以下に、多言語E5をファインチューニングして得られたモデルを検索に使うことで、検索性能がどのように変化するかを検証した結果を示します。多言語E5にはbase
とlarge
の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やクエリ書き換え専用のモデルを用いてクエリを書き換えることで性能が向上すると報告されています。

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

HyDE(Hypothetical Document Embeddings:仮の文書の埋め込み)は、入力されたクエリに対して仮の文書を生成し、その文書を埋め込み、検索に使用する手法です[28]。典型的な文書検索では、ユーザーが入力したクエリと文書の類似度を計算することが多いですが、クエリと文書が必ずしも類似しているとは限りません。そのため、生成モデルを使って回答のような文書(Hypothetical Document)を生成し、その文書と検索エンジンに格納された文書の類似度を計算してしまおうという考え方になります。
国民年金の免除申請をする際には、いくつかの持ち物が必要です。まず、申請書が必要ですので、市役所や年金事務所で入手するか、インターネットでダウンロードして印刷してください。また、申請者本人の本人確認書類も必要です。これには、運転免許証やパスポート、健康保険証などがあります。さらに、収入証明書も必要ですので、給与明細や年金受給証明書などを用意してください。また、免除申請の理由を証明するために、医療証明書や障害者手帳などの医療関係の書類も必要です。これらの持ち物を整理して、申請時に提出することで、スムーズに免除申請をすることができます。申請に必要な持ち物は、市役所や年金事務所のウェブサイトなどで詳細を確認してください。
まとめると、HyDEでは以下の手順で検索をすることになります。
- 検索クエリを与えて、GPTに仮の文書を生成させる
- 生成した文書を埋め込みモデルを用いてエンコード
- エンコードした情報から実際の文書を検索
ここで紹介した以外にも、フォローアップの質問を繰り返す方法[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 + Original
とFusion
の性能がほぼ同じなので、今回は複数のクエリを生成する効果が薄かったことがわかります。ベクトル検索を使うと、同じような意味のクエリは同じようなベクトルに変換されるので、より多様な複数のクエリを生成するようにすれば、また違う結果になる可能性があります。
モデル | 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.lucene
とja.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]では、高速なヒューリスティックを用いてリランキングしたあと、低速なクロスエンコーダーを用いて再度リランキングしています。
構成図のうち、最初の段階である検索には以下の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経由で利用可能です。
- mmarco-mMiniLMv2-L12-H384-v1
- bge-reranker-large
- Cohereの
rerank-multilingual-v2.0
評価結果は以下のとおりです。全文検索の場合は、リランカーを適用することで性能が向上する結果となりました。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,
)
参考資料
- Measuring Massive Multitask Language Understanding
- Training Verifiers to Solve Math Word Problems
- Evaluating Large Language Models Trained on Code
- llm-jp-eval | GitHub
- MT-bench-jp | GitHub
- A Survey on Hallucination in Large Language Models: Principles, Taxonomy, Challenges, and Open Questions
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks
- A Survey of Techniques for Maximizing LLM Performance
- Revolutionizing Retrieval-Augmented Generation with Enhanced PDF Structure Recognition
- PDFTriage: Question Answering over Long, Structured Documents
- A deep dive into the world’s smartest email AI
- Lost in the Middle: How Language Models Use Long Contexts
- MarkdownHeaderTextSplitter | LangChain
- HTMLHeaderTextSplitter | LangChain
- Benchmarking RAG on tables
- SemanticChunker | LangChain
- RetrievalTutorials | GitHub
- Azure AI Search: Outperforming vector search with hybrid retrieval and ranking capabilities
- Building RAG-based LLM Applications for Production
- Evaluating the Ideal Chunk Size for a RAG System using LlamaIndex
- 医療テキストを対象としたOpenAI埋め込みのチューニングとその効果
- MTEB: Massive Text Embedding Benchmark
- Japanese SimCSE Technical Report
- Text Embeddings by Weakly-Supervised Contrastive Pre-training
- Finetuning an Adapter on Top of any Black-Box Embedding Model
- Query Rewriting for Retrieval-Augmented Large Language Models
- Forget RAG, the Future is RAG-Fusion
- Precise Zero-Shot Dense Retrieval without Relevance Labels
- Measuring and Narrowing the Compositionality Gap in Language Models
- Take a Step Back: Evoking Reasoning via Abstraction in Large Language Models
- Reciprocal rank fusion outperforms condorcet and individual rank learning methods
- Relevance scoring in hybrid search using Reciprocal Rank Fusion (RRF)
- Reciprocal rank fusion | Elasticsearch
- Which BM25 Do You Mean? A Large-Scale Reproducibility Study of Scoring Variants
- Sudachi: a Japanese Tokenizer for Business
- C-Pack: Packaged Resources To Advance General Chinese Embedding
- 多段階リランキングモデルによるテキスト検索
- mMARCO: A Multilingual Version of the MS MARCO Passage Ranking Dataset
- Boosting RAG: Picking the Best Embedding & Reranker models
- FAQ Retrieval using Query-Question Similarity and BERT-Based Query-Answer Relevance