こんにちは、AIチームの二宮です。
この記事はAI Shift Advent Calendar 2023の20日目の記事です。
本記事では、RAG(Retrieval Augmented Generation)を強化する技術について調査した結果をご紹介いたします。RAGの概要や基本的な実装については、以前の記事で詳しく説明していますので、そちらをご覧ください。
はじめに
RAGは、大規模な文書から関連箇所を効率的に獲得し、それを基に回答を生成する手法です。特にカスタマーサポート分野では、自社情報に基づいて回答することが重要であるため、RAGの導入検討が積極的に行われております。最近では、RAGに関連する技術が多数提案されていますので、改めてどのような改善ができるのか調査してみました。
本記事では、詳細な実装までは触れず、関連するリンクを記載することに留めています。RAGの改善の方針を立てる際にご参考になれば幸いです。また、本記事で紹介する内容はRAGの強化に関する技術を網羅しているわけではないため、その点、ご了承いただけますと幸いです。
本記事では、Retrieverの検索対象のデータに関する用語として、以下を利用しています。
- Knowledge DB:検索対象となるテキストを含むデータ
- Chunk:Knowledge DBを検索できるように分割したテキストの単位
- Index:効率的に検索するため、Chunkとそのベクトルがペアとなっているデータストア
RAGを強化する技術の調査
以下が目次になります。
- Query Understanding Layerの追加
- Metadata Filteringの追加
- 適切なDocument Loaderの選定
- Chunk分割手法の再考
- 検索結果のリランキング
- 検索結果の圧縮
Query Understanding Layerの追加
名前の通り、ユーザーの質問(Query)を詳細に理解(Understanding)する仕組みになります。ここでは、LLMを用いて以下のような処理を行います。
Intent Detection | 質問の意図を理解して、後続処理を分岐させる |
Condensing | 質問と過去の対話履歴から、単体で意味をなす質問を生成する |
Query Decomposition | 質問を複数の質問に分解する |
HyDE | 質問から回答を擬似的に生成する |
Intent Detection
RAG以外の方法で応答する選択肢がある場合に有効です。例えば、事前に整理されたQAから選択して応答したり、特定の意図を検知してRAG以外の処理を実行したい場合が考えられます。特にカスタマーサポートでは、雑多に書かれたドキュメントよりも、整理されたQAから可能な限り回答したいという動機があるため、Intent Detectionによる適切なQAのマッチングが有効な場合があります。
Condensing
話し言葉のように指示語が多く含まれていたり、ターン数が多い対話では、過去の対話履歴を活用することが有効です。例えば、以下の会話を考えてみます。
User: AI Shiftについて教えて
Bot: チャットボットやボイスボットを開発している企業です
User: 2つ目の詳細について教えて(-> AI Shiftのボイスボットの詳細について教えて)
この場合、最後のユーザーの質問である「2つ目の詳細について教えて」だけでは意味が通らず、Retrieverで検索しても関連情報が得られる可能性は低いです。そこで、過去の対話履歴を用いることで「AI Shiftのボイスボットの詳細について教えて」という質問をLLMで生成でき、これで検索することで適切な検索結果が得られます。Condensingには同じ意味の表記が複数あり、NVIDIAのRAGのブログではStandalone Questionと記載されています。
Query Decomposition
質問が長く、複数の意図が含まれる傾向がある場合に有効です。複数の質問に分解して個別に検索することで、それぞれに関連する情報を獲得することができます。例えば、以下のような例が考えられます。
User: ログインができないので、もう解約したいです
- Question1: ログインができない
- Question2: 解約したい
このユーザーは、もしかしたらログイン方法がわかれば、解約しなくて済むかもしれません。そのため、どちらの情報も検索して回答に含めることが望ましいと考えられます。複数の質問から検索する場合は、LangChainのMultiQueryRetrieverや、LlamaIndexのMulti-Step Query Engineを参考にしてください。
HyDE
質問とKnowledge DBの形式が大きく乖離している場合にはHyDE(論文)が有効です。入力される質問をLLMに与え、擬似的に回答(Hallucinated Answer)を生成し、それを用いて検索することで検索精度が向上する可能性があります。以下の例を考えてみます。
User: AI Shiftについて教えて
-> LLMで擬似的に生成した回答: 対話システムやボットを開発しているIT企業である
-> 生成した回答で検索した文書: AI Shiftは、チャットボットやボイスボットを開発している企業である
Bot: チャットボットやボイスボットを開発している企業です
「AI Shiftについて教えて」という質問で検索するよりも、擬似的に生成した回答である「対話システムやボットを開発しているIT企業である」というテキストの方が、よりKnowledge DB中のテキストに近い表現であるため、ベクトル表現の類似度が高くなる可能性があります。実装は、LangChainのHyDE Retreiverや、LlamaIndexのHyDE Query Transformを参考にしてください。
ここまでQuery Understanding Layerでの4つの処理を紹介しました。注意点として、Query Understanding Layerでは基本的にLLMの推論が必要となるので、推論コストと推論時間が増加します。ご自身のRAGシステムに必要な処理を取捨選択することをおすすめします。
Metadata Filteringの追加
検索時にmetadataによるフィルタリングで検索範囲を制限することで、より高精度に検索できます。以下は、各ドキュメントに{"project": "project_a"}という辞書型のmetadataを割り当てた場合を想定したLlamaIndexの実装です。
from llama_index import VectorStoreIndex, Document
from llama_index.vector_stores import MetadataFilters, ExactMatchFilter
documents = [
Document(text="document", metadata={"project": "project_a"}),
...
]
metadata_filters = MetadataFilters(filters=[
ExactMatchFilter(key="project", value="project_a")
])
index = VectorStoreIndex.from_documents(documents)
engine = index.as_query_engine(filters=metadata_filters)
一般的に、RAGの検索対象を変える方法として、Indexを切り替えることが考えられます。しかし、Metadata Filteringを用いることで、まとめて1つのIndexとして作成し、metadataで検索範囲を変更することができます。上手く活用することで、余分なIndexの作成を防ぎ、コスト削減につながる可能性があります。
適切なDocument Loaderの選定
Document Loaderは、多様なファイルからテキストを読み込むモジュールです。Knowledge DBとして与えられるテキストデータは、PDFやExcelなど多様な形式で保存されていることが多いです。基本的にRAGは、テキスト同士の類似度に基づき検索するため、より整形された綺麗なテキストを読み込むことで、RAGの精度向上が期待できます。
本記事では、LangChainのDocument Loadersを取り上げます。例えば、以下のようなDocument Loaderがあります。
- TextLoader
- CSVLoader
- ArxivLoader
- UnstructuredPDFLoader
- UnstructuredURLLoader
- UnstructuredPowerPointLoader
- Docx2txtLoader (Word)
- DirectoryLoader
- GoogleDriveLoader
上記はごく一部に過ぎませんが、テキストファイルやCSVファイルだけでなく、PDF、URL、PowerPoint、Wordからも読み込めるのは非常に便利ですね。また、DirectoryLoaderのように、ディレクトリ内のファイルを全て読み込んでくれるものもあり、実験的に素早く動かしたい時に重宝します。
基本的な利用方法
LangChainのDocument Loaderは沢山ありますが、基本的な使い方はほとんど同じです。詳細は、LangChainの公式ドキュメントをご確認ください。以下は、TextLoaderを利用したPythonスクリプトになります。
from langchain.document_loaders import TextLoader
text_loader = TextLoader("YOUR_TEXT_PATH")
text_document = text_loader.load()
print(f"{text_document=}")
CSVLoader:CSVファイルのカラムを考慮して読み込む
以下のCSVファイルを作成し、./data/source.csv
に配置した場合を想定してみます。
,title,text
0,ログイン,"ホームページをご確認ください"
1,認証が通らない,"お電話でご連絡ください"
これに対して、TextLoaderとCSVLoaderを試してみます。
# text_document
[Document(page_content=',title,text\n0,ログイン,"ホームページをご確認ください"\n1,認証が通らない,"お電話でご連絡ください"', metadata={'source': './data/source.csv'})]
# csv_document
[Document(page_content=': 0\ntitle: ログイン\ntext: ホームページをご確認ください', metadata={'source': './data/source.csv', 'row': 0}), Document(page_content=': 1\ntitle: 認証が通らない\ntext: お電話でご連絡ください', metadata={'source': './data/source.csv', 'row': 1})]
これを見ると、CSVLoaderでは1行が1Documentとして扱われています。さらに、title
やtext
といったカラム名がテキストに付与され、metadataにrow
が追加されていることがわかります。
CSVLoaderに限らず、構造化されているデータは専用のDocument Loaderを利用した方が良いかもしれません。
ArxivLoader:論文を読み込む
試しに、ragasの論文を読み込んでみました。その結果が以下になります。
[Document(page_content='RAGAS: Automated Evaluation of Retrieval Augmented Generation\nShahul Es†, Jithin James†, Luis Espinosa-Anke∗♢, Steven Schockaert∗\n†Exploding Gradients\n∗CardiffNLP, Cardiff University, United Kingdom\n♢AMPLYFI, United Kingdom\nshahules786@gmail.com,jamesjithin97@gmail.com\n{espinosa-ankel,schockaerts1}@cardiff.ac.uk\nAbstract\nWe introduce RAGAS (Retrieval Augmented\nGeneration Assessment), a framework for\nreference-free evaluation of Retrieval Aug-\nmented Generation (RAG) pipelines.\n...LLMs.'})]
ArxivLoaderでは、正しく二段組の論文を順序通りに抽出することができました。ただし、二段組による文中の改行が頻繁に存在します。また、章や段落などの構造がなく全てプレーンテキストとなります。
DirectoryLoader:ディレクトリを一括で読み込む
Directory Loaderでは、指定されたディレクトリ内のファイルを全て読み込みます。ディレクトリ内で階層化されている場合も全て読み込みます。試しにPDF, CSV, PowerPoint, Excel, Wordを読み込んでみました。
# document[0] <- pdf
Document(page_content='3 2 0 2\n\np e S 6 2\n\n] L C . s c [\n\n1 v 7 1 2 5 1 . 9 0 3 2 : v i X r a\n\nRAGAS: Automated Evaluation ...relevance.', metadata={'source': 'data/source.pdf'})
# document[1] <- csv
Document(page_content='\n\n\n\ntitle\ntext\n\n\n0.0\nログイン\nホームページをご確認ください\n\n\n1.0\n認証が通らない\nお電話でご連絡ください\n\n\n', metadata={'source': 'data/source.csv'})
# document[2] <- pptx
Document(page_content='カスタマーサポートにおける\n\nRAGの改善\n\n株式会社AI Shift...', metadata={'source': 'data/tmp/source.pptx'})
# document[3] <- xlsx
Document(page_content='\n\n\n\ntitle\ntext\n\n\n0.0\nログイン\nホームページをご確認ください\n\n\n1.0\n認証が通らない\nお電話でご連絡ください\n\n\n', metadata={'source': 'data/tmp/source.xlsx'})
# document[4] <- docx
Document(page_content='カスタマーサポートにおけるRAGの改善...', metadata={'source': 'data/tmp/source.docx'})
結果、1つのファイルにつき、1つのDocumentのリストが返ってきました。そして全てのファイル形式において、概ね問題なく読み込むことができました。ファイルのPathがmetadataとして取得されるのも便利ですね。PDFはちゃんと二段組の場合でも読み込むこともできましたし、PowerPointは上から順にテキストを抽出することができました。
一方で、CSVやExcelではセルごとに改行区切りで連結していきます。上述のCSVLoaderのような処理はないので、用途に応じて専用のDocument Loaderを使った方が良いかもしれません。また、Excelの場合、シートタイトルは抽出されたテキストに含まれておらず、シートが複数ある場合は単純な連結になるので注意が必要です。
GoogleDriveLoader:Google Driveから一括で読み込む
今回は試していませんが、LangChainにはGoogleDriveLoaderが実装されています。実験的に試したい場合に、Google Driveからデータを読み込めるのは非常に便利ですね。以下がPythonスクリプトになります。ただし、これを動作させるためにはGoogleの認証周りの手続きが必要となります。
from langchain.document_loaders import GoogleDriveLoader
loader = GoogleDriveLoader(
folder_id="YOUR_FOLDER_ID",
token_path="YOUR_CREDENTIAL_JSON_PATH",
recursive=False,
)
docs = loader.load()
Chunk分割手法の再考
Document Loaderでテキストを読み込んだ後、Chunkというテキストのまとまりに分割します。RAGの精度向上には、Chunk分割手法を見直してみることが非常に有効です。Chunkごとに意味が完結されていると、Retrieverが正しく関連文書を得ることができます。この部分は、LangChainでDocument Transformersとして実装されており、こちらの記事で実装方法が紹介されています。
ただし、HTMLやMarkdownなどのようにテキストが構造化されている場合は、自身でChunkを作成することをおすすめします。例えば、本記事の構成を例に挙げて考えてみます。
# RAGを強化する
## はじめに
(本文0)
## RAGを強化する技術の調査
### Query Understanding Layerの追加
#### Intent Detection
(本文1)
#### Condensing
(本文2)
#### Query Decomposition
(本文3)
...
これに対して特別な処理なくChunk分割を行うと、上から順に分割していくこととなり、以下のようになることが考えられます。
# RAGを強化する
## はじめに
(本文0)
## RAGを強化する技術の調査
### Query Understanding Layerの追加
ーーーーーーーーーーー
#### Intent Detection
(本文1)
#### Condensing
(本文2)
ーーーーーーーーーーー
#### Query Decomposition
(本文3)
ーーーーーーーーーーー
...
これではChunkごとに複数の意味が含まれるため、正しく検索できる可能性が低下します。例えば、「Intent Detection」以下のテキストがそれまでのテキストと分割されてしまうため、それが「Query Understanding Layerの追加」の一部である、という情報が失われてしまいます。そこで、Chunkごとに意味を完結させるために、以下のように分割することで、検索精度が向上する可能性があります。
# RAGを強化する技術
## はじめに
(本文0)
ーーーーーーーーーーー
# RAGを強化する技術
## RAGを強化する技術の調査
### Query Understandingを追加する
#### Intent Detection
(本文1)
ーーーーーーーーーーー
# RAGを強化する技術
## RAGを強化する技術の調査
### Query Understandingを追加する
#### Condensation
(本文2)
ーーーーーーーーーーー
# RAGを強化する技術
## RAGを強化する技術の調査
### Query Understandingを追加する
#### Query Decomposition
(本文3)
ーーーーーーーーーーー
...
一度Chunk分割の実装の見直しをおすすめします。
検索結果のリランキング
RAGのRetrieverで得た文書に対して、再度ランク付けを行い、関連性が高い文書のみを利用することで、検索精度が向上する可能性があります。リランキングの必要性や実装は、こちらの記事が参考になります。
リランキングの方法として以下が考えられます。
- Cross-Encoderを用いる
- Cohere's rerank endpointを用いる
- LLMを用いる
Cross-Encoderを用いる
検索モデルには、Bi-EncoderとCross-Encoderが考えられます。RetrieverではBi-Encoderがよく利用され、質問と文書の類似度計算時に分けてベクトル変換を行っています。それに対して、Cross-Encoderでは、質問と文書を連結して類似度を計算しています。これらを比較すると、Cross-Encoderは、質問と文書を連結させることでそれらの単語間のAttentionを計算できるため、精度面で優れている場合が多いです。一方で、Bi-Encoderは、文書に対するベクトル変換を事前に行うことができ、推論時は質問に対する1回のベクトル変換だけが行われるため、推論時間が短いという利点があります。
したがって、Bi-Encoderを用いたRetrieverで大規模なKnowledge DBから関連性の高い小規模な文書集合を獲得し、それらに対してCross-Encoderを用いたRerankerでリランキングを行い、関連性が低い文書は除外するといった処理が効果的であると考えられます。Cross-Encoderを用いたリランキングについては、こちらの記事をご参考ください。
Cohere's rerank endpointを用いる
2つ目のCohere's rerank endpointは、API経由で手軽にリランキングすることができる機能になります。内部で利用されているEmbeddingモデルは$0.10 / 1M tokensと安価で、LangChainではCohere Rerankerとして実装されています。Multilingual対応のモデルだと日本語も利用することができます。
LLMを用いる
3つ目にLLMを用いたリランキングがあります。具体的には、文書ごとに質問との関連性を判定する方法や、質問と小規模な文書集合を一括で与えてリランキングする方法が考えられます。これは、段階的にLLMに問題を解かせるChainsと類似していると捉えることができます。ただし、LLMの推論コストと推論時間が増加するため、導入前の十分な動作検証をおすすめします。
検索結果の圧縮
LLMを用いて、検索結果から不要な箇所を削除する方法です。先程のリランキングでLLMを利用する方法と類似しておりますが、検索で得た文書自体の中身まで修正する点で異なります。より整形された文章を生成することができるため、より効果的であると考えられます。一方で、リランキングでLLMを利用する方法と比べると、こちらは選択タスクではなく生成タスクであるため、出力テキストが長くなり推論時間が増加する傾向にあります。LangChainではContextual Compressionとして実装されています。
ツールの選定
RAGを実装する上で、個人的にはLlamaIndexとLangChainが思い付きます。そこで、こちらの記事を参考に、それらの利用シーンについて考えてみました。
まず、LlamaIndexは検索とデータ取得に特化したツールであり、RAGアプリケーションに適しています。Indexの作成については非常に多くの選択肢があり、特定の情報へのアクセスや大規模なデータセットを扱う場合はLlamaIndexが効果的です。余談ですが、LlamaIndexの公式X(旧Twitter)では頻繁にRAGに関する手法やCookbookが紹介されており、非常に参考になります。
次に、LangChainはより汎用的なLLMアプリケーションの機能を提供するツールになります。連携しているサービスやツールも幅広いため、より多くの要望に適したアプリケーションが作成できます。注意点として、LangChainを本番環境で利用することには議論があります。例えば、こちらの記事では、LangChainが過度に抽象化された実装であったり、破壊的な変更があったりすることに言及されており、それ故のデバッグの難しさが問題に挙げられていました。
これらのことから、どちらか一方を選択するのではなく、それぞれの特徴を理解して、必要に応じて組み合わせて構築することをおすすめします。
RAGの強化:どの改善策から始めるべきか
上述のように、RAGを強化するための多数の技術が提案されています。しかし、個人的な経験では、データ設計の精緻化が最も効果があると感じています。具体的には、Knowledge DBの詳細な分析やChunk分割の見直し、プロンプトの動作検証に基づく修正が挙げられます。特に、適切に整形されたKnowledge DBは、Retrieverの検索精度の向上に寄与するだけでなく、メンテナンスも容易になると考えています。
当然ながら、十分な動作検証も重要です。弊社では、以前の記事で紹介したように、RAGを実験的に試せる環境を設けています。この環境を利用して、Retrieverの検索結果とGeneratorの応答を確認し、どの改善策を試すべきか探っています。この際、ドメイン固有のリソース(プロンプトやKnowledge DB)の修正と、汎用的に効果のある修正(Indexの作り方やRetrieverの検索方法)は分けて考えており、RAGの精度向上には両方の改善が効果的です。
さらに、最近Googleから新たなLLMであるGeminiがリリースされたように、どのLLMを利用するかという選択肢も増えています。新しくリリースされる技術の検証を素早く実施し、自らの環境とデータで確認することが重要であると感じます。
おわりに
ここまでお読みいただきありがとうございました。
AI Shiftではエンジニアの採用に力を入れています! 少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか? (オンライン・19時以降の面談も可能です!) 【面談フォームはこちら】
明日は、AIチームの戸田より、RAGのデータセットについてご紹介いたします。
どうぞよろしくお願いいたします。