はじめに
こんにちは。 RevCommでCorporate EngineeringチームおよびFull Stackチームで活動している川添です。
社内の情報管理、うまくできているでしょうか?ルールやナレッジを共有しあっているけれども、過去に話した内容を何度も確認しあっている、過去の情報をうまく検索できない、などの問題が起きてないでしょうか?
どの会社でもこのような問題は起きているかと思いますが、RevCommでもやはり起きています。
今回は、そのような問題に対する一つのソリューションとして、 RAG (Retrieval-Augmented Generation) を用いたナレッジチャットボットを作ってみましたので、紹介させていただきます!
RAG (Retrieval-Augmented Generation) とは?
ざっくり言うと、文書のデータセットに対してキーワードや文章をもとにベクトル検索を行い、抽出された文書をもとに生成AIで回答を行うものです。
回答の元となるデータを生成AIに与えることで、間違った情報や関係ない情報の出力を減らすことができます。
システムの構成
今回は、Google CloudのVertex AI Search and Conversationを用いました。
ポイントを解説していきます。
BigQuery
各データソースの情報を集約する場所です。Google Vertex AI Search and Conversationに連携するためにBigQueryである必要はありましたが、BigQuery内でのデータの保存方法は、一般的なテーブルの形式であればどのような形でも大丈夫そうでした。
今回は、MiiTelのサポート記事であるZendeskと、社内のナレッジベースであるNotionの一部データを一つのデータセット・テーブルに保管して接続することにしました。
Google Vertex AI Search and Conversation
今回のRAGを実装するに当たって、情報検索を司る部分です。このサービスは、Google BigQueryなどの情報を読み込ませて、キーワードによる検索などを実現することが出来ます。つまり、自社の情報に対してググることができるというわけです。
実はSearch and Conversationという名前の通り、文章で検索を行って文章で回答を生成してくれる機能もあります。ただ、この機能はBigQueryの正規化されたデータには現時点(2023年12月14日時点)では提供されておりません。
PDFなどや画像などの非正規化データをGCSに保管して、その情報を接続した上であれば、文章検索・回答が実現できます。
こちらはサポートにも確認しましたが、残念ながらまだとのことでした。 😭
ただ、ロードマップにはあるらしいので、今後に期待ですね!
PDFで情報を保存して上記の機能を使うこともできましたが、正規化した形で情報を持っておきたかったのもあり、今回はBigQueryに情報を保存して、文章による検索・文章による要約生成・回答を別の形で実現することにしました。
詳細は、後述していきます。
Slack Bolt による Bot 作成
他の記事でも多く解説されているので詳細は省きますが、今回はSlack Botでメンションを受けると、それに対して反応するような形で作りました。
その中で、Google Vertex AI Search and Conversationを使ってRAGを実現するための工夫をいくつか行っています。
それらを、Bot内での処理にそって説明していきます。
- 問い合わせの文章から、検索に用いる検索キーワードを抽出する
問い合わせの文章は一般的に、「MiiTelとSalesforceの連携をする方法を教えてください。」のようになりますが、このままではBigQueryに保管されているデータを抽出することができません。文章での検索は、うまく検索できる場合もあった一方で、「教えてください」などのワードが入った途端に検索が出てこないケースがほとんどでした。
ですので、ChatGPTを使って、文章から検索に用いるワードを抽出するようにしています。
「MiiTelとSalesforce の連携をする方法を教えてください。」の場合、「MiiTel Salesforce 連携 方法」のように、スペースか何かで区切ってあり、語尾が省かれているような形が理想のようでした。
そのようなフレーズを抽出するために、色々試してみた結果、「次の文から検索に用いるための主要なキーワードやフレーズのみをスペース区切りで抜き出してください。」というプロンプトが一番効果的に感じたので、こちらを用いることにしました。
具体的には、以下のような実装です。
from openai import OpenAI openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) # 質問から主要キーワードのみを抽出 message = ( f"""次の文から検索に用いるための主要なキーワードやフレーズのみをスペース区切りで抜き出してください。: {question_user}""" ) messages = [ { "role": "user", "content": message, } ] response = openai_client.chat.completions.create( model="gpt-4", messages=messages ) message_response = response.choices[0].message.content print(message_response)
これで、検索のためのワードの抽出ができました。
- ナレッジ検索をする
検索のためのワードの抽出ができたので、それらを用いてGoogle Vertex AI Search and Conversationで検索を行います。
基本的には素直に検索を行えばいいのですが、キーワードの数が多い場合にはうまく抽出できないことがありました。検索結果が0件になってしまうような状態です。
素直に検索をやり直してもらう方法などもあるとは思いますが、今回は、検索結果が0件の場合はワードを減らしつつ検索をする方法と採用し、より広範囲で検索できるようにしてみました。あくまで社内の便利ツールなので、何かしら拾えるほうを優先しました。
実装は下記のような形です。
実装例が公式ドキュメント以外にあまり見つけられませんでしたので、もしかしたらもっとよい実装方法があるかもしれません。
credentials = service_account.Credentials.from_service_account_info( google_credential ) discoveryengine_client = discoveryengine.SearchServiceClient( credentials=credentials ) total_size = 0 while total_size == 0: # Discovery Engineで検索-------------------------------------------------- # Initialize request argument(s) request = discoveryengine.SearchRequest( serving_config="projects/xxxxxxxx/locations/global/collections/default_collection/dataStores/revcomm-knowledge-base_xxxxxxx/servingConfigs/default_search", query=message_response, ) # Make the request page_result = discoveryengine_client.search(request=request) print(page_result) total_size = page_result.total_size # 空白区切りで抽出されたキーワードの後ろを削除 message_response = re.split(r"\s+", message_response) message_response = message_response[:-1] # 空白区切りに戻す message_response = " ".join(message_response) if message_response == "": response = slack_utils.post_message( "質問から適切な文書を検索できませんでした。他の質問を入力してください。", thread_ts=thread_ts, ) return None
- 文章の中身の抽出
次に、各文章のタイトルや内容、URLなどを抽出して後で使えるようにしていきます。
上記の検索で取得したデータは独自のデータ構造で保管されているので、それを適宜辞書形式などに変換しながら抽出したりしています。
また、サービスのFAQページなどの情報はHTMLのまま保管してあるので、 HTMLのタグは除外するようにもしています。
# 文書のフォーマット count = 0 ## Handle the response support_content_list = [] for response in page_result: title = response.document.struct_data.__dict__["_pb"][ "title" ].string_value content = response.document.struct_data.__dict__["_pb"][ "body" ].string_value # contentから全てのhtmlタグを削除 content = re.sub(r"<[^>]*?>", "", content) # contentから全ての改行を削除 content = re.sub(r"\n", "", content) url = response.document.struct_data.__dict__["_pb"]["url"].string_value support_content_list.append([title, content, url]) count += 1 if count > 5: break print(support_content_list)
- ChatGPTに読み込ませるための準備
抽出したデータから、ChatGPTに読み込ませるためのデータを生成します。
また、参考にした情報のデータソースにアクセスできるようにURLのリストも作成しておきます。
# 中身の抽出 read_responses = [] support_content_urls_message = "" for support_content in support_content_list: message += f"""--------- ## タイトル {support_content[0]} ## 内容 {support_content[1]} ## URL {support_content[2]} """ support_content_urls_message += ( f"・[{support_content[0]}]({support_content[2]})\n" ) print(read_responses)
- 回答の生成
最後に、これらの情報を読み込ませつつ、元の質問に対する回答を生成します。
特段の工夫は行っていませんが、仮に読み込ませる文章が多くなった場合はトークン数が制限を超えてしまう可能性がありますので、工夫の余地があるポイントかもしれません。
もしやるとすれば、4.の段階で各文章の要約を作ってしまうのも一つの手だと思います。
# Generate Summary Response message = f"""次の文章を元に、「{question_user}」 という質問に対する回答を作成してください。 """ for read_response in read_responses: message += f"""--------- {read_response} """ messages = [ { "role": "user", "content": message, } ] response = openai_client.chat.completions.create( model="gpt-4", messages=messages ) message_response = response.choices[0].message.content print(message_response) output_message_for_slack = ( message_response + "\n\n参考:\n" + support_content_urls_message )
詳細の実装は省いている部分もありますが、以上が今回実装した内容となります!
作ってみたシステムへの評価
ある程度適切に情報抽出・回答ができるようになりましたが、実際はまだまだ改善余地がありそうです。
大きな問題の一つとして、サービスのFAQページの数に対して社内情報のNotionのデータ数が相対的に少なく、サービスのFAQページの内容がメインで検索されてしまう傾向などが出てきました。
原因として検索語句や情報の質の問題が考えられますが、例えばサービスのFAQページのデータとNotionのデータを別々にBigQueryに保存し、別々に検索するようにするなどの工夫をするる余地はあるかなと思っています。
おわりに
いかがでしたか?
今回紹介したもの以外にもさまざまなAIサービスが出ているので、すでに他のサービスを使って似たようなことを実現しているケースも多くあるかと思います。しかし、自分で実装してみることは面白いですし、自力で改善もできるので、試してみる価値はあると思います。
何かの参考にしてもらえたら幸いです!
最後までお読みいただき、ありがとうございました。