『RAG データとの会話の仕方』

『RAG データとの会話の極意を学ぶ』

ChatGPTを使用して顧客フィードバックを分析する包括的なガイド

DALL-E 3による画像

以前の記事では、ChatGPTを使用してトピックモデリングの方法について説明しました。私たちのタスクは、さまざまなホテルチェーンの顧客コメントを分析し、各ホテルで言及される主なトピックを特定することでした。

このようなトピックモデリングの結果、各顧客レビューのトピックがわかり、それによって簡単にフィルタリングや詳細な分析ができるようになりました。しかし、実際の生活では、全ての使用ケースを網羅できるような耐用集合のトピックを持つことは不可能です。

例えば、先ほどの顧客フィードバックから特定されたトピックのリストが以下に示されています。

これらのトピックは、顧客フィードバックの概要を把握し、初期の事前フィルタリングに役立ちます。しかし、ジムや朝食のドリンクに関して顧客がどのように考えているのかを理解したい場合、実際には「ホテル施設」や「朝食」のトピックから多くの顧客フィードバックを自分自身で確認する必要があります。

幸いなことに、LLM(Language Models)はこの分析に役立ち、多くの時間を顧客のレビューを見ることに費やす手間を省くことができます(もちろん、顧客の声を直接聞くことも依然として役立つかもしれません)。この記事では、そのようなアプローチについて説明します。

この分析には、LangChain(LLMアプリケーションで最も人気のあるフレームワークの1つ)を引き続き使用します。LangChainの基本的な概要は、前の記事で見つけることができます。

単純なアプローチ

特定のトピックに関連するコメントを取得する最も簡単な方法は、テキスト内の特定の単語(例えば、「gym」や「drink」など)を探すことです。私はChatGPTが存在しなかった時に何度もこのアプローチを使用してきました。

このアプローチの問題点は明らかです:

  • 近くの体育館やホテルのレストランでのアルコール飲料に関する関連性のないコメントが多くなる可能性があります。このようなフィルタは充分に特定されていないため、文脈を考慮することができず、誤検知が多くなります。
  • 一方で、十分なカバレッジが得られない可能性もあります。同じことを指すためにわずかに異なる単語(たとえば、drinks、refreshments、beverages、juicesなど)を人々は使います。タイプミスもあるかもしれません。また、顧客が異なる言語を話す場合、このタスクはさらに複雑になる可能性があります。

つまり、このアプローチは精度と再現率の両方に問題があります。質問についてのおおまかな理解を得ることはできますが、その能力には限界があります。

もう1つの潜在的な解決策は、トピックモデリングと同じアプローチを使用することです。すべての顧客コメントをLLMに送信し、モデルに興味のあるトピックに関連するかどうかを定義させるのです。さらに、モデルに顧客フィードバックをまとめて提供するように依頼することもできます。

このアプローチはかなりうまく機能するはずです。ただし、制約もあります:特定のトピックに詳しく探求したいたびに、すべてのドキュメントをLLMに送信する必要があります。定義したトピックに基づく高レベルのフィルタリングがあるにしても、LLMに渡すデータがかなり多くなり、コストがかかる可能性があります。

幸いなことに、このタスクを解決する別の方法があります。それが「RAG(Retrieval-augmented generation)」と呼ばれるものです。

検索補完生成

私たちはドキュメント(顧客レビュー)のセットを持っており、これらのドキュメントの内容に関連する質問をしたいと考えています(たとえば、「朝食について顧客は何が好きですか?」)。先ほど説明したように、すべての顧客レビューをLLMに送信したくはないため、最も関連性の高いドキュメントのみを選定できる方法が必要です。それから、ユーザーの質問とこれらのドキュメントをLLMに文脈として渡すだけで済むはずです。

このようなアプローチは「検索補完生成」またはRAGと呼ばれています。

Scheme by author

RAGのパイプラインは次のステージで構成されています:

  • データソースからのドキュメントの読み込み
  • ドキュメントをより使いやすいチャンクに分割します。
  • ストレージ:このユースケースでは、データを効果的に処理するためによく使用されるベクトルストアが使用されることがあります。
  • 問題に関連するドキュメントの取得
  • 生成は、質問と関連するドキュメントをLLMに渡し、最終的な回答を取得することです

OpenAIはこの週に Assistant API を開始したことをお聞きになるかもしれません。これらのステップをすべて代わりに行うことができます。ただし、私はそれがどのように動作し、どのような特異性を持つかを理解するために、全プロセスを通過する価値があると信じています。

では、すべてのステージをステップバイステップで見ていきましょう。

ドキュメントの読み込み

最初のステップは、ドキュメントを読み込むことです。 LangChainはさまざまなドキュメントタイプをサポートしています。たとえば、CSVまたはJSONです。

このような基本的なデータ型にLangChainを使用するメリットは何でしょうか。 CSVやJSONファイルは、標準のPythonライブラリを使用して解析できることは言うまでもありません。ただし、LangChainデータローダーAPIを使用し、コンテンツとメタデータを含むドキュメントオブジェクトが返されるため、後でLangChainドキュメントを使用するのが簡単です。

もう少し複雑なデータ型の例を見てみましょう。

ウェブページのコンテンツを分析するタスクをしばしば行うため、HTMLとの作業が必要になります。 BeautifulSoupライブラリをすでに習得しているかもしれませんが、BSHTMLLoaderが役立つ場合があります。

LLMアプリケーションと関連するHTMLに関して興味深いことは、おそらくそれを大幅に前処理する必要があるということです。ブラウザのインスペクタを使用してWebサイトを見ると、サイト上で表示されるテキストよりもはるかに多くのテキストが使用されていることに気付くでしょう。これは、レイアウト、フォーマット、スタイルなどを指定するために使用されます。

Image by author, LangChain documentation

ほとんどの実際の場合、私たちはLLMにすべてのこれらのデータを渡す必要はありません。サイトのための全HTMLは簡単に200Kトークンを超える可能性があります(そしてそれのみがユーザーとして表示されるテキストの10〜20%にすぎません)、したがって、それをコンテキストのサイズに収めることは難しいでしょう。さらに、この技術的な情報はモデルの仕事を少し難しくする可能性があります。

したがって、通常はHTMLからテキストのみを抽出し、後続の分析に使用します。これを行うには、以下のコマンドを使用できます。その結果、Webページからのテキストがpage_contentパラメータに含まれるDocumentオブジェクトを取得します。

 from langchain.document_loaders import BSHTMLLoaderloader = BSHTMLLoader("my_site.html")data = loader.load() 

他によく使用されるデータタイプはPDFです。 PyPDFライブラリを使用してPDFを解析できます。 DALL-E 3ペーパーからテキストを読み込みましょう。

 from langchain.document_loaders import PyPDFLoaderloader = PyPDFLoader( "https://cdn.openai.com/papers/DALL_E_3_System_Card.pdf")doc = loader.load() 

出力では、各ページに1つのドキュメントセットが表示されます。メタデータでは、sourcepageフィールドの両方が入力されます。

したがって、LangChainでは、さまざまなドキュメントタイプで作業することができます。

初期のタスクに戻りましょう。データセットには、各ホテルの顧客コメント用の別個の.txtファイルがあります。ディレクトリ内のすべてのファイルを解析し、まとめる必要があります。DirectoryLoaderを使用することができます。

from langchain.document_loaders import TextLoader、DirectoryLoader、text_loader_kwargs = {'autodetect_encoding': True}loader = DirectoryLoader('./hotels/london'、show_progress = True、 loader_cls=TextLoader、 loader_kwargs = text_loader_kwargs)docs = loader.load() print docs()82

私たちは、テキストが標準のUTF-8ではエンコードされていないため、'autodetect_encoding': Trueも使用しました。

結果として、ドキュメントのリストを取得しました-テキストファイルごとに1つのドキュメントです。各ドキュメントは個々の顧客レビューからなることを知っています。ホテルのすべての顧客コメントではなく、より効果的に作業するためには、より小さなチャンクで作業する方が良いです。次のステージに移り、ドキュメントの分割について詳しく説明しましょう。

ドキュメントの分割

次のステップは、ドキュメントの分割です。なぜこれを行う必要があるのか疑問に思うかもしれません。ドキュメントはしばしば長く、複数のトピックをカバーしています。たとえば、Confluenceページやドキュメントの場合です。このような長大なテキストをLLMに渡すと、LLMが関係のない情報に気を取られる問題や、テキストがコンテキストサイズに合わない問題が発生する可能性があります。

LLMと効果的に作業するためには、知識ベース(ドキュメントのセット)から最も関連性の高い情報を定義し、この情報のみをモデルに渡す価値があります。そのため、ドキュメントをより小さなチャンクに分割する必要があります。

一般的なテキストには、再帰的な文字による分割が最もよく使用されています。LangChainでは、これはRecursiveCharacterTextSplitterクラスで実装されています。

どのように機能するか理解しましょう。まず、分割機にとって優先順位の付いた文字リストを定義します(デフォルトでは["\n\n", "\n", " ", ""])。次に、分割機はこのリストを参照し、1つずつ文字でドキュメントを分割していきます。チャンクのサイズが十分に小さくなるまで続けます。つまり、このアプローチは、文脈サイズに合わせて(段落、文、単語など)意味的に関連性のある部分を一緒に保つようにします。

どのように機能するかを確認するために、Pythonの禅を使用してみましょう。このテキストには824文字、139単語、21段落が含まれています。

import thisを実行すると、Pythonの禅が表示されます。

zen = '''Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one -- and preferably only one -- obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!'''
print('文字数:%d' % len(zen))
print('単語数:%d' % len(zen.replace('\n', ' ').split(' ')))
print('段落数:%d' % len(zen.split('\n')))
# 文字数:825
# 単語数:140
# 段落数:21

RecursiveCharacterTextSplitterを使用して、比較的大きなチャンクサイズ300で開始しましょう。

from langchain.text_splitter import RecursiveCharacterTextSplittertext_splitter = RecursiveCharacterTextSplitter(    chunk_size = 300,    chunk_overlap  = 0,    length_function = len,    is_separator_regex = False,)text_splitter.split_text(zen)

私たちは3つのチャンクを取得します:264、293、263文字です。すべての文が一緒に保持されていることがわかります。

chunk_overlapパラメータを使用して重複を許可することができることに気づくかもしれません。これは重要です。質問と一緒にいくつかのチャンクをLLMに渡すため、各チャンクで提供された情報に基づいてのみ判断を下すためには、十分な文脈が必要です。

Scheme by author

chunk_overlapを追加してみましょう。

text_splitter = RecursiveCharacterTextSplitter(    chunk_size = 300,    chunk_overlap  = 100,    length_function = len,    is_separator_regex = False,)text_splitter.split_text(zen)

これで、264、232、297、263文字の4つの分割があり、チャンクが重なっていることがわかります。

チャンクサイズをもう少し小さくしてみましょう。

text_splitter = RecursiveCharacterTextSplitter(    chunk_size = 50,    chunk_overlap  = 10,    length_function = len,    is_separator_regex = False,)text_splitter.split_text(zen)

今度は、いくつかの長い文を分割する必要がありました。これが再帰的な分割方法の動作です:段落("\n")で分割した後でも、まだチャンクが十分に小さくないため、分割処理は" "に進みます。

さらに細かく分割することもできます。たとえば、length_function = lambda x: len(x.split("\n"))と指定することで、文字数の代わりに段落の数をチャンクの長さとして使用することができます。トークンの数に基づいてLLMのコンテキストサイズが制限されているため、一般的にはトークンごとに分割することもよくあります。

もう1つのカスタマイズオプションは、separatorsを使用して","ではなく" "で分割することです。いくつかの文で試してみましょう。

text_splitter = RecursiveCharacterTextSplitter(    chunk_size = 50,    chunk_overlap  = 0,    length_function = len,    is_separator_regex = False,    separators=["\n\n", "\n", ", ", " ", ""])text_splitter.split_text('''実装が説明しづらい場合、それは悪いアイデアです。実装が説明しやすい場合、それは良いアイデアです。''')

うまく動作しますが、カンマの位置が正しくありません。

この問題を修正するために、先読みを使った正規表現をセパレータとして使用できます。

text_splitter = RecursiveCharacterTextSplitter(    chunk_size = 50,    chunk_overlap  = 0,    length_function = len,    is_separator_regex = True,    separators=["\n\n", "\n", "(?<=\, )", " ", ""])text_splitter.split_text('''実装が説明しづらい場合、それは悪いアイデアです。実装が説明しやすい場合、それは良いアイデアです。''')

これで修正されました。

さらに、LangChainはコードを扱うためのツールを提供しており、それによりテキストはプログラミング言語固有の区切り文字に基づいて分割されます。

しかし、私たちの場合はもっと簡単です。各ファイル内の"\n"で区切られた個別の独立したコメントがあることを知っていますので、それで分割するだけです。残念ながら、LangChainはこのような基本的なユースケースをサポートしていないため、望むように動作させるために少しハッキングする必要があります。

from langchain.text_splitter import CharacterTextSplittertext_splitter = CharacterTextSplitter(    separator = "\n",    chunk_size = 1,    chunk_overlap  = 0,    length_function = lambda x: 1, # hack - usually len is used     is_separator_regex = False)split_docs = text_splitter.split_documents(docs)len(split_docs) 12890

なぜハッキングが必要なのかの詳細は、私の前のLangChainに関する記事で確認できます。

ドキュメントの重要な部分はメタデータです。それはこのチャンクがどこから来たのかについてのより多くの文脈を提供できます。私たちの場合、LangChainはメタデータのsourceパラメーターを自動的に埋めたので、各コメントがどのホテルに関連しているのかを知ることができます。

HTMLMarkdownなどの他のアプローチもありますが、これらの方法はドキュメントを分割する際にメタデータにタイトルを追加します。このようなデータ型で作業している場合、これらの方法は非常に役立つことがあります。

ベクトルストア

今、コメントのテキストがあるので、次のステップはそれらを効果的に保存する方法を学ぶことです。そうすれば、私たちの質問に関連するドキュメントを取得できます。

コメントを文字列として保存することもできますが、このタスクを解決するのに役立ちません。- フィルターできない顧客のレビューは、質問に関連します。はるかに機能的な解決策は、ドキュメントの埋め込みを保存することです。

埋め込みは高次元ベクトルです。埋め込みは、単語やフレーズ間の意味的な意味や関係を捉えるため、意味的に似ているテキスト間にはより小さい距離があります。

私たちはOpenAI Embeddingsを使用します。なぜなら、それらは非常に人気があるからです。OpenAIは、text-embedding-ada-002モデルを使用することを推奨しています。なぜなら、それがより良いパフォーマンス、より拡張されたコンテキスト、そしてより低い価格を持っているからです。通常であれば、それにはいくつかのリスクと制限もあります:潜在的な社会的なバイアスと最近の出来事に関する限定的な知識。

おもちゃの例を使ってEmbeddingsを使用してみましょう。

from langchain.embeddings.openai import OpenAIEmbeddingsembedding = OpenAIEmbeddings()text1 = '私たちの部屋(スタンダード)はとてもきれいで広かったです。'text2 = 'ロンドンの天気は素晴らしかったです。'text3 = '実際に泊まった部屋は、他のホテルの部屋よりも広く、とてもよく整備されていました。'emb1 = embedding.embed_query(text1)emb2 = embedding.embed_query(text2)emb3 = embedding.embed_query(text3)print('''距離 1 -> 2: %.2f距離 1 -> 3: %.2f距離 2 -> 3: %.2f''' % (np.dot(emb1, emb2), np.dot(emb1, emb3), np.dot(emb2, emb3)))

OpenAIの埋め込みは既に正規化されているため、np.dotをコサイン類似度として使用することができます。

第1文と第3文はお互いに近く、第2文は異なります。第1文と第3文は似たような意味を持っています(両方とも部屋のサイズについてです)、一方、第2文は天気について話していて近くありません。したがって、埋め込み間の距離は実際にはテキスト間の意味的な類似性を反映しています。

今では、コメントを数値ベクトルに変換する方法がわかりました。次の問題は、このデータを簡単にアクセスできるようにどのように保存するかです。

私たちのユースケースについて考えてみましょう。私たちのフローは次のようになります:

  • 質問を受け取る
  • 埋め込みを計算する
  • この質問に関連する最も関連性のある文書チャンクを見つける(この埋め込みへの最小距離を持つもの)
  • 最後に、見つかったチャンクを初期質問とともにLLMにコンテキストとして渡す。

データストレージの通常のタスクは、K個の最も近いベクトル(K個の最も関連性の高いドキュメント)を見つけることです。したがって、質問の埋め込みと私たちが持っているすべてのベクトルとの距離(私たちの場合はCosine Similarity)を計算する必要があります。

SnowflakeやPostgresなどの一般的なデータベースは、このようなタスクにはパフォーマンスが低下します。しかし、このようなユースケースに最適化されたデータベースもあります。ベクトルデータベースと呼ばれるものです。

私たちはオープンソースの埋め込みデータベースであるChromaを使用します。Chromaは軽量なインメモリDBなので、プロトタイプに最適です。ベクトルストアのより多くのオプションはこちらで見つけることができます。

まず、pipを使用してChromaをインストールする必要があります。

pip install chromadb

データをローカルに保存し、ディスクから再読み込みするためにpersist_directoryを使用します。

from langchain.vectorstores import Chromapersist_directory = 'vector_store'vectordb = Chroma.from_documents(    documents=split_docs,    embedding=embedding,    persist_directory=persist_directory)

次回必要なときにディスクからデータを読み込むために、次のコマンドを実行できるようにする必要があります。

embedding = OpenAIEmbeddings()vectordb = Chroma(    persist_directory=persist_directory,    embedding_function=embedding)

データベースの初期化は、Chromaがすべてのドキュメントをロードし、それらの埋め込みをOpenAI APIを使用して取得する必要があるため、数分かかる場合があります。

すべてのドキュメントがロードされていることが分かります。

print(vectordb._collection.count())12890

これで、顧客のコメントの中からスタッフの礼儀に関するトップコメントを見つけるために類似性検索を使用することができます。

query_docs = vectordb.similarity_search('スタッフの礼儀', k=3)

ドキュメントは質問に非常に関連しています。

顧客コメントを使いやすい形で保存しましたので、これからリトリーバルについて詳しく話しましょう。

リトリーバル

既にvectordb.similarity_searchを使用して質問に関連するチャンクを取得しています。ほとんどの場合、このアプローチは問題なく動作しますが、いくつかのニュアンスがあるかもしれません:

  • 多様性の欠如 — モデルは非常に近いテキスト(重複を含む)を返す可能性がありますが、それらはLLMにあまり新しい情報を提供しません。
  • メタデータを考慮しないsimilarity_searchは、持っているメタデータ情報を考慮しません。たとえば、質問「Travelodge Farringdonの朝食」に関するトップ5のコメントをクエリする場合、結果のコメントのうち3つはuk_england_london_travelodge_london_farringdonというソースが等しいものであるはずです。
  • コンテキストサイズの制限 — 通常、LLMには制限されたコンテキストサイズがあり、ドキュメントをそれに合わせる必要があります。

これらの問題を解決するのに役立つテクニックについて説明しましょう。

多様性の解決策 — MMR(Maximum Marginal Relevance)

類似性検索は質問に最も近い回答を返します。しかし、モデルに完全な情報を提供するためには、最も類似したテキストにフォーカスしない方が良い場合があります。例えば、「Travelodge Farringdonの朝食」という質問に対して、トップ5の顧客レビューがコーヒーについてであるかもしれません。それらだけを見ていると、卵やスタッフの振る舞いに言及する他のコメントを見逃し、顧客のフィードバックの見方が制限されます。

顧客のコメントの多様性を高めるために、MMR(Maximum Marginal Relevance)アプローチを使用することができます。以下はそのやり方です:

  • まず、similarity_searchを使用して、質問に最も類似したドキュメントをfetch_k個取得します。
  • 次に、その中から最も多様なk個を選びます。
Scheme by author

MMRを使用する場合は、similarity_searchの代わりにmax_marginal_relevance_searchを使用し、fetch_kの数を指定する必要があります。出力に無関係な回答が含まれないように、fetch_kを比較的小さく保つことも重要です。以上です。

query_docs = vectordb.max_marginal_relevance_search('スタッフの礼儀', k = 3, fetch_k = 30)

同じ質問に対する例を見てみましょう。今回はより多様なフィードバックが得られました。ネガティブな意見も含まれています。

特定の性質に対処する – LLMを用いた検索

もう一つの問題は、ドキュメントを検索する際にメタデータを考慮に入れていないことです。これを解決するために、LLMに初期の質問を2つのパートに分けるように依頼します:

  • 文書のテキストに基づく意味フィルター
  • 持っているメタデータに基づくフィルター

このアプローチは「セルフクエリング」と呼ばれています。

まず、Travelodge Farringdonホテルに関連するファイル名を指定するsourceパラメーターを持つマニュアルフィルターを追加しましょう。

query_docs = vectordb.similarity_search('Travelodge Farrigdonの朝食', k=5,  filter = {'source': 'hotels/london/uk_england_london_travelodge_london_farringdon'})

次に、LLMを使用して自動的にそのようなフィルターを作成してみましょう。詳細にメタデータパラメーターを説明し、SelfQueryRetrieverを使用します。

from langchain.llms import OpenAIfrom langchain.retrievers.self_query.base import SelfQueryRetrieverfrom langchain.chains.query_constructor.base import AttributeInfometadata_field_info = [    AttributeInfo(        name="source",        description="全てのソースは 'hotels/london/uk_england_london_' で始まり、「hotel chain」、定数 'london_' 、場所が続きます。",        type="string",    )]document_content_description = "ホテルの顧客レビュー"llm = OpenAI(temperature=0.1) # モデルをより事実にするために低温設定# デフォルトでは 'text-davinci-003' が使用されますretriever = SelfQueryRetriever.from_llm(    llm,    vectordb,    document_content_description,    metadata_field_info,    verbose=True)question = "Travelodge Farringdonの朝食"docs = retriever.get_relevant_documents(question, k = 5)

私たちのケースは少し複雑です。メタデータのsourceパラメーターは複数のフィールドで構成されています:国、都市、ホテルチェーン、場所です。このような複雑なパラメーターは、モデルがメタデータフィルターをどのように使用するかを容易に理解できるよう、より細かく分割することが望ましいです。

ただし、詳細なプロンプトを使用すると、Travelodge Farringdonに関連するドキュメントのみが返されます。ただし、正直に言わせていただきますと、この結果を得るまでに数回の試行が必要でした。

デバッグをオンにして、どのように動作するかを確認しましょう。デバッグモードに入るには、以下のコードを実行するだけです。

import langchain langchain.debug = True

完全なプロンプトはかなり長いので、主な部分を見てみましょう。以下はプロンプトの開始部分で、モデルに私たちの期待と結果の主な基準を説明しています。

そして、few-shot prompting技術が使用され、モデルには2つの入力例と期待される出力が提供されます。以下はその一例です。

ChatGPTのようなチャットモデルではなく、指示に調整されていない一般的なLLMを使用しています。このLLMは、テキストの次のトークンを予測するために訓練されています。そのため、私たちは質問と文字列Structured output:でプロンプトを終了し、モデルに回答を提供することを期待しています。

結果として、モデルからは、最初の質問が意味的な部分(breakfast)とメタデータフィルター(source = hotels/london/uk_england_london_travelodge_london_farringdon)に分割されました。

それから、このロジックを使用してベクターストアからドキュメントを取得し、必要なドキュメントのみを取得しました。

サイズ制限への対処 — 圧縮

ユーザーが便利な検索技術の1つは、圧縮です。GPT 4 Turboは128Kトークンの文脈サイズを持っていますが、それでも制限があります。そのため、ドキュメントを前処理し、関連する部分のみを抽出することが望ましい場合があります。

主な利点は次のとおりです:

  • 圧縮により、最終的なプロンプトにより多くのドキュメントと情報が収まるようになります。
  • 非関連のコンテキストは前処理中にクリーニングされるため、より効果的な結果が得られます。

ただし、これらの利点にはコストがかかります — 圧縮のためにLLMへの複数の呼び出しが増え、スピードが低下し、価格が上昇します。

この技術についての詳細情報はこちらのドキュメントで確認できます。

Scheme by author

実際には、この技術を組み合わせてMMRを使用することもできます。私たちはContextualCompressionRetrieverを使用して結果を取得しました。また、返される結果として3つのドキュメントだけを指定しました。

from langchain.retrievers import ContextualCompressionRetrieverfrom langchain.retrievers.document_compressors import LLMChainExtractorllm = OpenAI(temperature=0)compressor = LLMChainExtractor.from_llm(llm)compression_retriever = ContextualCompressionRetriever(    base_compressor=compressor,    base_retriever=vectordb.as_retriever(search_type = "mmr",        search_kwargs={"k": 3}))question = "breakfast in Travelodge Farringdon"compressed_docs = compression_retriever.get_relevant_documents(question)

通常のように、内部での動作原理を理解するのが最も興味深い部分です。実際の呼び出しを見ると、テキストから関連する情報のみを抽出するために、LLMへの3つの呼び出しが行われています。以下に例を示します。

出力では、朝食に関連する文の一部のみが表示されているため、圧縮が役立ちます。

さらに、検索には他にも多くの有益なアプローチがあります。例えば、古典的なNLPの技術であるSVMTF-IDFなどです。さまざまなレトリーバーは、異なる状況で役立つ場合がありますので、タスクに最も適したものを選択するために、異なるバージョンを比較することをおすすめします。

世代

ついに、最後の段階にたどり着きました:すべてを組み合わせて最終的な答えを生成します。

すべてがどのように動作するかのスキームは以下の通りです:

  • ユーザーから質問を受け取ります。
  • 埋め込みを使用してベクトルストアからこの質問に関連するドキュメントを取得します。
  • 初期の質問と取得したドキュメントをLLMに渡し、最終的な答えを取得します。
著者によるスキーム

LangChainでは、このフローを素早く実装するためにRetrievalQAチェーンを使用できます。

from langchain.chains import RetrievalQAfrom langchain.chat_models import ChatOpenAIllm = ChatOpenAI(model_name='gpt-4', temperature=0.1)qa_chain = RetrievalQA.from_chain_type(    llm,    retriever=vectordb.as_retriever(search_kwargs={"k": 3}))result = qa_chain({"query": "ホテルのスタッフにお客様が好む点は何ですか?"})

ChatGPTへの呼び出しを見てみましょう。取得したドキュメントをユーザーのクエリと共に渡していることがわかります。

モデルからの出力は以下の通りです。

プロンプトをカスタマイズすることで、モデルの動作を調整することができます。たとえば、モデルにより簡潔な回答を求めることができます。

from langchain.prompts import PromptTemplatetemplate = """以下の文脈の要素を使用して最後の質問に答えます。 答えがわからない場合は、答えをでっち上げずに分からないと言ってください。回答はなるべく簡潔にまとめるようにしてください。すべてのポイントを1文でまとめます。______________{context}質問:{question}役立つ回答:"""QA_CHAIN_PROMPT = PromptTemplate.from_template(template)qa_chain = RetrievalQA.from_chain_type(    llm,    retriever=vectordb.as_retriever(),    return_source_documents=True,    chain_type_kwargs={"prompt": QA_CHAIN_PROMPT})result = qa_chain({"query": "ホテルのスタッフにお客様が好む点は何ですか?"})

今回は非常に短い回答が得られました。また、return_source_documents=Trueを指定したため、ドキュメントのセットも返されました。デバッグに役立つことがあります。

見てきたように、デフォルトではすべての取得したドキュメントは1つのプロンプトに組み合わされます。このアプローチは優れていてシンプルですが、コンテキストのサイズに収まる必要があります。収まらない場合は、より複雑なテクニックを適用する必要があります。

さまざまなチェーンタイプを見てみましょう。これにより、任意の数のドキュメントで作業できます。最初のものはMapReduceです。

このアプローチは古典的なMapReduceに似ています:各取得したドキュメントに基づいて回答を生成し(マップステージ)、これらの回答を最終的な回答に組み合わせます(リダクションステージ)。

著者によるスキーム

このようなアプローチの制限はコストと速度です。LLMへの呼び出しは1回ではなく、取得した各ドキュメントごとに行う必要があります。

コードに関しては、動作を変更するためにchain_type="map_reduce"を指定するだけです。

qa_chain_mr = RetrievalQA.from_chain_type(    llm,    retriever=vectordb.as_retriever(),    chain_type="map_reduce")result = qa_chain_mr({"query": "ホテルのスタッフにお客様が好む点は何ですか?"})

結果では、次の出力が得られました。

デバッグモードを使用して、それがどのように機能するかを見てみましょう。MapReduceですので、まずは各ドキュメントをLLMに送り、このチャンクに基づいて回答を得ました。以下は、1つのチャンクに対するプロンプトの例です。

その後、すべての結果を組み合わせ、LLMに最終的な回答を出させます。

以上です。

MapReduceアプローチに固有のもう一つの欠点があります。モデルは各ドキュメントを個別に見ており、それらをすべて同じ文脈で扱っていません。これにより、結果が悪化する可能性があります。

この欠点をRefine chainタイプで克服することができます。その場合、ドキュメントを順次見て、モデルに各反復で回答を改善させることができます。

Scheme by author

再び、chain_typeを別のアプローチでテストするために変更するだけです。

qa_chain_refine = RetrievalQA.from_chain_type(    llm,    retriever=vectordb.as_retriever(),    chain_type="refine")result = qa_chain_refine({"query": "ホテルのスタッフにお客様が好きなことは何ですか?"})

Refine chainを使用すると、少し冗長で完全な回答が得られます。

デバッグを使用してどのように機能するかを見てみましょう。最初のチャンクでは、最初から始めます。

次に、現在の回答と新しいチャンクを渡し、モデルに回答を改善する機会を与えます。

その後、残りの取得ドキュメントごとに回答を改善するプロンプトを繰り返し、最終結果を得ます。

それが本日お伝えしたいことです。さっと振り返りましょう。

要約

この記事では、Retrieval-augmented generationの全プロセスを通過しました。

  • 異なるデータローダーを見ました。
  • データの分割方法とその潜在的なニュアンスについて議論しました。
  • 埋め込みの概要を学び、データに効果的にアクセスするためにベクトルストアを設定しました。
  • リトリーバルの問題に対するさまざまな解決策を見つけ、多様性を増やし、コンテキストサイズの制限を克服し、メタデータを使用する方法を学びました。
  • 最後に、データに基づいて回答を生成するためにRetrievalQAチェーンを使用し、異なるチェーンタイプを比較しました。

この知識は、データを使用して同様のものを構築するのに十分なものです。

この記事を読んでいただき、ありがとうございます。お役に立ったことを願っています。ご質問やコメントがありましたら、コメント欄にお書きください。

データセット

Ganesan, Kavita and Zhai, ChengXiang. (2011). OpinRank Review Dataset. UCI Machine Learning Repository (CC BY 4.0). https://doi.org/10.24432/C5QW4W

参考文献

この記事は以下のコースに基づいています:

We will continue to update VoAGI; if you have any questions or suggestions, please contact us!

Share:

Was this article helpful?

93 out of 132 found this helpful

Discover more