はじめに
こんにちは!any 株式会社のプロダクトチームに所属している @thesugar です。
この記事は any Product Team Advent Calendar2024 12 日目の記事になります。
今回は、LLM (Large Language Model) において注目が集まる RAG (Retrieval Augmented Generation) の性能改善に取り組んだ体験記をお届けします。
具体的な Tips というよりも、試行錯誤の記録とでも言うべきものになっておりますが、生成 AI や RAG 開発に取り組む方にとって少しでも参考になれば幸いです。
課題感
まず、自分が手元で検証する際、ベクトル検索のみを使用した RAG による文書検索機能を実装してみました。しかし、そうしたところ「固有名詞や専門用語 + 一般名詞」の質問文に対する回答精度が低いという課題感がありました。
- 質問文の例: 「商品番号 Hoge123 の在庫が存在する店舗は?」
- 誤った回答例: 「(別の商品の情報を引用して)店舗 foo です」「わかりません」
- 想定される原因
- ベクトル検索は意味的類似性に基づく検索を行うものであるため、それ自体として意味を持たない固有名詞 Hoge123 の情報をうまく扱えず、別の商品の在庫店舗に関するチャンク(ノイズチャンク)を高いスコアで Retrieve しているため、本来取得したいチャンク(正解チャンク)が LLM に渡らない
- ノイズチャンク例: 「商品番号 Fuga456 は店舗 foo で取り扱いがあり、…」「商品番号 Hoge123 は白いTシャツです。この商品は夏にかけて…」
- 正解チャンク例:「商品番号 Hoge123 は店舗 bar に在庫があります」
(※上記例はイメージです)
ハイブリッド検索の導入
ここでハイブリッド検索の活用を考えました。ハイブリッド検索とは、意味的な類似性をもとに検索を行うベクトル検索と、キーワードの一致をもとに検索を行う全文検索を組み合わせる検索手法です。
ベクトル検索およびハイブリッド検索の両方で検索結果としてチャンク群が取得されますが、RFF(ランク融合)などの手法によりスコアが再計算され、その再計算結果をもとに最終的なチャンク群を得ます。
従来ベクトル検索単体で RAG を構築していましたが、そこに全文検索を組み込むことで、固有名詞や専門用語を含む質問にも高い精度で応答できるようになることが期待できると考えました。
技術スタック / アーキテクチャ
従来(ベクトル検索のみ)
- 文書のベクトル化: 文書をチャンク分割(※)したうえで、OpenAI の Embedding API によりベクトル化。ベクトル化されたドキュメントは OpenSearch に格納する。
- 検索フロー: ユーザーからの質問文を Embedding API によりベクトル化→OpenSearch を使ってナレッジに対してベクトル検索→LLM に回答候補となるチャンクを渡す
(※)チャンク分割:文書を一定の長さ(文字数)ごとに分割して、小さな塊に分割すること。チャンク化やチャンキングとも。一定の長さごとに分割するほか、段落ごとに分割したり再帰的に分割を行ったりと手法はさまざま。
ハイブリッド検索導入後
- 全文検索: ユーザーからの質問文をキーワードとして OpenSearch を使って文書群に対して全文検索を実行。
- ランク融合: ベクトル検索と全文検索のスコアを再計算し、最適なチャンクを選定。
ハイブリッド検索精度向上のために試したこと
まずは、ここまで述べたとおりにハイブリッド検索を実装し、最終的なスコア順で並べたときの上位 10 件までのチャンクを回答候補とするよう実装しました。そうすることで一定の効果は見られたものの、一方で、やはりノイズチャンクをもとに回答してしまったり、最終的な 10 件のうちに正解チャンクを含められなかったりという事象は引き続き発生し、必ずしも大きく改善したとは言えないような体感でした。
そこで、以下のような試みを行いました。
パラメータ調整
RFF において、ベクトル検索と全文検索のスコアを融合するには以下の式で表される計算を行います。
S_{\text{hybrid}}(d) = \frac{1}{k + R_{\text{vector}}(d)} + \frac{1}{k + R_{\text{fulltext}}(d)}
ただし、
\displaylines{
S_{\text{hybrid}}(d): {\text{文書 d のハイブリッドスコア(融合スコア)。}}\\
R_{\text{vector}}(d): ベクトル検索における文書 d の順位。\\
R_{\text{fulltext}}(d): 全文検索における文書 d の順位。
}
RFF で用いるパラメータ k としてよく用いられる値は 60 ですが、その値を変更して結果を比較しました。数式を見ると k は右辺の両項の分母にありますから、k を大きくすると元のランクの影響が小さくなり(ランク上位の文書とランク下位の文書のスコアの差が小さくなる)、逆に k を小さくすると元のランクの影響が大きくなります。
しかし今回の場合、k の値を 0, 10, 60 と変更してみましたが目に見える改善は特段ありませんでした。
ベクトル検索と全文検索それぞれの結果に対する重みの設定
ベクトル検索・全文検索それぞれに重みを設定し、各検索手法の「優先度合い」を設定できるようにしました。
S_{\text{hybrid}}(d) = \frac{w_{\text{vector}}}{k + R_{\text{vector}}(d)} + \frac{w_{\text{fulltext}}}{k + R_{\text{fulltext}}(d)}
重み w_vector, w_fulltext を変更し、「ベクトル検索と全文検索が同程度に優先される」、「ベクトル検索より全文検索が優先される」、「全文検索がベクトル検索より優先される」などのパターンで検証しました。
ただし、これも大きな変化にはつながりませんでした。
最終的に LLM に渡すチャンクの件数の変更
従来は LLM に 10 件のチャンクを渡して検証していましたが、それを 5 件に減らしたり、15 件などに増やして検証しました。
チャンク数を増やすと、正解(となりうる)チャンクが複数ある質問に対しては有効に働いていそう(最終的に LLM に渡す件数まで絞る過程で、正解チャンクが切り捨てられる事象が起きにくくなる)でしたが、その一方で、ノイズチャンクが回答に混じりやすくもなりました。チャンク数を減らした場合も同様(うまくいったケースではノイズが混じりづらくなるものの、正解チャンクが切り捨てられるケースも増える)でした。
チャンク数増減のアプローチには一定の有効性はありそうですが、件数を変えるのに加えて何かしらの対策も別途必要そうです。
最終的に LLM に渡すチャンク件数という話とは少しずれますが、プロセス全体で見て扱うチャンク件数を増やしてみるという話であれば、最初にもっと大量にチャンクを取得してきて、その後より高精度なモデルにより再順位づけを行うリランキングという手法は有効かもしれません。
チャンク拡張
あるチャンクを取得する際に、その前後のチャンクも合わせて取得するアプローチを試しました。前後の文脈も拾ってくることになるので、前後にも正解(となりうる)チャンクが存在すればそれも回答に含められますし、逆にノイズチャンクは前後の文脈が補完されることでノイズだと判定されやすくなることが期待されます。
実際に、回答文の中に含まれる正しい情報の量が増えたケースもありましたが、一方でチャンク件数を増やしたときと同様に、ノイズを含んでしまうケースも増えてしまいました。
ソート
従来は、最終的に LLM にチャンク群を渡す際、最新の情報をより優先するように、元の文書の更新日時順にチャンクを並び替えたうえで渡していましたが、それをスコア順を保ったまま渡すようにして検証してみました。
しかし、これはあまり効果が見られませんでした。
ただし、並び順が変わるだけで回答が変わる現象は見られました。LLM に渡す 10 件のチャンクをランダムに並び替えると、最終的に引用されるチャンクや生成される回答文は並び順が変わるたびに変化しました。ただし、Lost in the Middle 論文に述べられているような「コンテキストの序盤もしくは終盤に正解チャンクがあればパフォーマンスが向上する」などの特定の傾向は確認できず(チャンク長やコンテキスト全体の長さが論文と比較して小規模であるためかと思いますが)、一旦は他のアプローチを模索することとなりました。
全文検索用にクエリ変換
最初にハイブリッド検索を試したときは、ユーザーからの質問文を LLM により一部変換(会話履歴を踏まえ、指示語の具体化や質問文の要約を実施)したものをベクトル検索においても全文検索においてもクエリとして用いていました。
たとえば、「その在庫がある店舗は?」というユーザーの質問は会話履歴を踏まえたうえで「商品番号 Hoge123 の在庫が存在する店舗は?」などと変換されます。その変換後の文章をベクトル検索・全文検索両方で用いていましたが、全文検索においては「商品番号 Hoge123 在庫 店舗」などシンプルなキーワードをクエリとするように変更しました。
単純な方法ではありますが、自分が試したかぎりではこれが一番有効に働きました。
You perform a full-text search in the document database to solve the user's question. Please convert the user's text into simple keywords for the search. Make sure not to add extra or redundant keywords.
chatHistories: (チャット履歴)
User's question: (ユーザーの質問文)
Keywords for a full-text search in Japanese :
検証中に直面した難しさ
精度の検証が難しい
AI が返す回答の精度を検証するにあたり、まずは、質問例と想定回答をいくつも用意し、AI により得られた回答と想定回答のコサイン類似度を取得し平均値をスコアとするなどして定量化することを考え、実際にそのような仕組みを構築しました。
しかしながら、具体的になんらかのサービスへの応用を考えた際、そのような定量的な検証で必ずしも十分なのかは慎重に吟味する必要があります。たとえば、質問に対して複数の関連文書(A, B, C)があるとして、アルゴリズム変更前は「A、B」を根拠に回答したが変更後は「B, C」を根拠に回答したという場合、(理想は A, B, C すべてを含むものだとしても)その変化を許容できるのか否かはケースバイケースであり、実際に質問と回答をチェックしないかぎり判断ができないというケースも多々あることでしょう。
対応策1: 人手による評価
結局、特に今回のようにアルゴリズムに変更が入る場合は、質問例と想定回答を用意するところまでは同じですが、従来のモデルと変更後のモデルで出力がどのように変化したのかを具体的に見比べて判断するようにしています。
開発時には LangSmith などのツールの活用が考えられますが、ひととおり検証が済んだらたとえばスプレッドシートにまとめるなどして、検証対象の文書群について詳しい人や想定ユーザーなどに実際に見てもらい、シート内に適宜コメントを付してもらうなどもよいかもしれません。以下画像はそのイメージです。
対応策2: ログ分析
また、もしかすると精度の検証という話とはずれるかもしれませんが、各処理ステップのログを詳細に記録し、どうしてそのような回答になったのかをトレースできるようにしています。そもそも所望のチャンクは取得されているのか、最終的に LLM に渡されるチャンク群の中には含まれているか、各チャンクに付されたスコアの数値はいくつで、どのような並び順で渡されたのかなどという情報がわかるようにしています。こうすることで、AI の回答が変わったときに、なぜ(あるいはどの段階で)その変化がもたらされたのかということがわかります。
上記画像は実際に出力したログのスクリーンショットです(チャンク ID は記事におけるわかりやすさのためにダミー値に変換)。このように、検証の際は実際に取得されたチャンクなどを一つ一つ確認することもしばしばです。
今後に向けて
今回は固有名詞や専門用語に強い RAG をつくることを目的としてハイブリッド検索を検証しましたが、上述のリランキングや他にもたとえば RAG Fusion などまだ試せていない手法もまだまだあるので検証を進めたいと思います。また、文書を取得してくる部分だけでなく、後段の推論・回答生成プロセスの部分におけるアプローチがないか等も引き続き調査・検討していきたいです。
まとめ
ここまでで書いたとおり、生成 AI のチャットボット開発は実装においても評価においても困難が多く、日々手探り感もあります。実際この記事も、冒頭でも述べたとおり有力な手法の紹介というよりは開発過程の四苦八苦を紹介するというテイストのものになりました。
しかしながら、本記事で紹介した課題や対応策が、RAG をこれから導入しようとする開発者の方々や、現在同じようになんらかの苦戦をされている方にとってほんの少しでも参考になれば幸いです。
any株式会社ではナレッジ経営クラウドQastのエンジニアを絶賛募集中です。
是非採用ページをご覧ください!
興味がある方は、こちらよりご応募お待ちしております。
エンジニア組織/文化について詳しく知りたい方はこちら。