大規模言語モデル(LLM)を使ってFAQページに沿って答えてくれるチャットボットをつくろう

記事タイトルとURLをコピーする

サーバーワークスの村上です。

NBAシーズン開幕まであと約1ヶ月、楽しみですね!

昨季優勝したナゲッツが呪術廻戦のエンディング風に試合スケジュールを公開していますので貼っておきますね。

さきに本ブログのまとめです。

  • LLMの幻覚(Hallucination)軽減策としてRAGが挙げられる。
  • LLMが参照するナレッジベースはできるだけAWSマネージドに構築できるものが良い
  • Amazon BedrockのナレッジベースがRAGの実装を楽にしてくれそうなので期待大

以下目次です。

このブログでつくるもの

完成イメージ

サーバーワークスの「Cloud Automator よくあるご質問」に書いてあることを質問すると答えてくれるように作成しました。

検索しづらいことってたまにあると思いますが、そんなときに役立つかもしれません。

構成

アーキテクチャを記載します。

AWSの公式ブログを参考にしています。ただし、以下の点を変更しています。

変更点 備考
LLMをSageMaker JumpStartから選択可能なモデルではなくOpenAIのgpt-3.5-turbo-instructに変更 今後はAmazon Bedrockを使うためAPI呼び出しする形式にしました
FAQページから情報を取得する方法をrequestsからSeleniumに変更 JavaScriptが必要なページに対応するため

aws.amazon.com

以下のような流れで動作します。

  • Lambda関数 web-crawlerを実行する
    • FAQページからテキスト情報を取得します。また、ページ内にあるリンクについてはリンク先のページ情報も取得します。
    • 得られたテキストをS3 source-materials-bucketに保存します。
  • Lambda関数 read-source-and-build-indexが実行される
    • S3source-materials-bucketからテキスト情報を取得
    • これを埋め込み(Embedding、後述)し、S3created-index-bucketに保存
  • ユーザーがAmazon Lexを通じて質問する
  • Amazon LexはLambda関数lex-codehookを呼び出す
    • S3created-index-bucketから関連する情報を取得し、質問とともに大規模言語モデル(LLM)にリクエスト
    • LLMから返ってきた応答をAmazon Lexに返す
  • ユーザーはAmazon Lexから応答を受け取る

前提

ここまでお読みいただいて、

「LambdaとかS3とか使わず、LLMだけでいいのでは?」 「いちいち外から情報を与えるのではなく、LLMをファインチューニングすれば?」 「埋め込みってなに?」

など疑問に思うかもしれません。私は思ったことがあります。

ここでは前提となる概念を簡単に記載します。

まず、LLM単体での使用やLLMのファインチューニングについてですが、これには幻覚(Hallucination)や破壊的忘却(catastrophic forgetting)のリスクがあると言われています。以下、概要を記載します。

幻覚(Hallucination)

これは有名な話ですが、LLMは間違った内容を返すこともあります。

LLMは豊富な知識に基づき答えてくれると思いがちですが、次にくる確率が高いワードを出力しているだけのため、こういった現象が起きます。

破壊的忘却(catastrophic forgetting)

ファインチューニングが一概にダメというわけではありませんが、一般的に「破壊的忘却(catastrophic forgetting)」のリスクがあると言われています。

破壊的忘却(catastrophic forgetting)は、ファインチューニングによってLLMのパラメータ(重み)が変更されることにより、LLMが事前に学習した情報を忘れてしまう現象のことです。

私のイメージ

反対にプロンプトエンジニアリング(in-context learning)は、LLMのパラメータを更新しない手法です。

※実際の応答は上記と異なります

検索拡張生成 (RAG)

幻覚(Hallucination)や破壊的忘却(catastrophic forgetting)を軽減する対応策として、LLM外にあるナレッジベースの情報を基にLLMに回答を作成させる手法がとられています。

これを検索拡張生成 (RAG, Retrieval Augmented Generation) といいます。

外部から検索してきた情報を基に回答するよう指示し、もしも情報が検索できない(またはユーザーの質問と類似度が低い)場合は「分かりません」などと回答するよう指示することで、ハルシネーションを軽減しています。

埋め込み(Embedding)

埋め込み(Embedding)はLLMに情報を渡す際に行われる処理です。

例えばユーザーが「日本の首都はどこですか」とLLMに質問したとすると、このテキストを任意の長さに区切り、それぞれを数値化します(区切られた単位をトークンと言います)。

数値化したものをベクトル表現に変換するのですが、これをEmbeddingといいます。

さきほど類似度という言葉を使いましたが、ナレッジベースの情報の中からユーザーの質問と関連性が高い情報を検索する際に、このEmbeddingが使われています。関連性の高い単語は近接するようなイメージです。

将来的な展望

本ブログではナレッジベースをS3で実装しています。他にもAmazon Kendraなど様々なサービスが利用可能です。

以下のブログはAmazon KendraでRAGを作成するという内容のブログです。

blog.serverworks.co.jp

さて、将来的な展望ですが、先日「Knowledge base for Amazon Bedrock」というアップデートがありました。

aws.amazon.com

これによるとユーザーが自前で実装していたS3などのナレッジベースについて、Amazon Bedrockと統合され、ユーザーの負荷が軽減するような内容と解釈できます。以下、一部抜粋したものを機械翻訳しました。

今日、顧客はRAGを実装するためにいくつかの未分化のステップを実行している。Amazon Bedrockのための知識ベースは、異なるシステムを統合する必要性を排除します。開発者は、Amazon S3バケットなどのドキュメントの場所を指定することができ、Bedrockは、取り込みワークフロー(ドキュメントのフェッチ、チャンキング、エンベッディングの作成、ベクターデータベースへの保存)と実行時のオーケストレーション(エンドユーザーのクエリに対するエンベッディングの作成、ベクターデータベースからの関連チャンクの検索、FMへの渡し)の両方を管理します。

これはかなり楽しみな内容ですので、GAしたら是非ブログにしたいなと考えています。

構築した内容

ここから構築手順になりますが、詳細なコードの内容はこちらのGitHubをご参照ください。

github.com

本ブログでは私が変更した内容のみ記載します。

FAQページの内容を取得する

さて、まずはFAQページの内容を取得するためのLambda関数「web-crawler」の構築です。

github.com

AWSのブログでは、requestsを使ってウェブサイトの情報を取得していますが、このまま使うとJavaScriptを使用しているページの情報は取得できませんでした。

そこで代わりにSeleniumを使用しています。

あらかじめこちらからstable-headless-chromiumをダウンロードし、Lambdaレイヤーを作成しておきます。

今回はコンテナイメージからLambdaを作成するため、コンソールからのLambdaレイヤー作成ではなく、Dockerfileでコンテナの/optにファイルを配置します。

以下がDockerfileです(headless.zipがダウンロードしたもの)。

FROM public.ecr.aws/lambda/python:3.7 AS builder
COPY headless.zip headless.zip
RUN yum install -y unzip \
  && unzip headless.zip -d /opt \
  && rm -f headless.zip

FROM public.ecr.aws/lambda/python:3.7
COPY --from=builder /opt /opt
COPY web_crawler_requirements.txt  .
RUN  pip3 install -r web_crawler_requirements.txt --target "${LAMBDA_TASK_ROOT}"
COPY *.py ${LAMBDA_TASK_ROOT}
CMD [ "web_crawler_app.handler" ]

以下がSeleniumに関する部分です。その他の箇所はGitHubの内容から変更していません。

from selenium import webdriver

options = webdriver.ChromeOptions()
options.binary_location = "/opt/headless/headless-chromium"
options.add_argument("--headless")
options.add_argument('--single-process')
options.add_argument('--disable-dev-shm-usage')
options.add_argument("--no-sandbox")
options.add_argument("user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.80 Safari/537.36")

browser = webdriver.Chrome(
    # chromedriverのパスを指定
    executable_path="/opt/headless/chromedriver",
    options=options
)

browser.get(page)

# ページのテキストを取得
response = browser.find_element_by_tag_name("body").text
# 全てのaタグ(リンク)要素を取得
links = browser.find_elements_by_tag_name("a")
# 各リンクからテキストとhref属性を取得
href_values = []
for link in links:
    link_href = link.get_attribute("href")
    href_values.append(link_href)
                    
#html2textすることでHTMLタグが取り除かれ、テキストだけが残る
response = self._html_to_text_parser.html2text(response)
# linkの情報もresponseに追加
response = response + str(href_values)

ウェブサイトの情報取得が完了するとweb-crawl-results.txtというファイルがS3source-materials-bucketに保存されます。

埋め込みを作成する

Lambda関数read-source-and-build-indexで使用するコードは以下です。

github.com

web-crawl-results.txtというファイルがS3source-materials-bucketに保存されると、このLambdaの処理が開始されます。

処理後は、S3created-index-bucketvector_store.jsonなどのファイルが保存されます。

これが埋め込み(Embedding)された後の情報で、以下のようにベクトルに変換された情報が入っています。

{"embedding_dict": {"fdc8b5e3-6d2b-47f3-a884-b4ee7157e3f6": [-0.023947378620505333, -0.021971654146909714, 0.012470935471355915, -0.005665292032063007, -0.005479653365910053, 0.008811203762888908, -0.023682180792093277, -0.024676673114299774, -0.00512163620442152, -0.03914322331547737, 0.0056288274936378, 0.009898515418171883, 0.0123317064717412, -0.017940644174814224,...

冒頭に記載のとおり、LLMをgpt-3.5-turbo-instructに変更していますので、埋め込みに使用するモデルも変更する必要があります。

from llama_index.llms import OpenAI 
from llama_index import OpenAIEmbedding
llm = OpenAI(model="gpt-3.5-turbo-instruct", temperature=0, max_tokens=256)
embed_model = OpenAIEmbedding()

また、OpenAIのAPIキーをSSMパラメータストアに保存し、Lambda Extensionsで取得するように変更しました。

def get_param():
    aws_session_token = os.environ.get('AWS_SESSION_TOKEN')
    req = urllib.request.Request('http://127.0.0.1:2773/systemsmanager/parameters/get/?name=<SSMパラメータストアの名前>&version=1&withDecryption=true')
    req.add_header('X-Aws-Parameters-Secrets-Token', aws_session_token)
    config = urllib.request.urlopen(req).read()
    key = json.loads(config)['Parameter']['Value']
    return key
openai.api_key = get_param()

docs.aws.amazon.com

応答をLexへ返す

Lambda関数「lex-codehook」で使用するコードは以下です。ここでも使用するLLMをgpt-3.5-turbo-instructに変更していますが変更した内容は前項と同じですので割愛します。

github.com

まとめ

以上、RAGの実装例のご紹介でした。

いろいろと触ってみて感じたことですが、LLMが参照するナレッジベースの構築や検索の仕組みはできるだけAWSマネージドなものにした方が良いと感じました。

今回はウェブサイトのクロールや埋め込み、検索を実装しました。LlamaIndexを使っているとはいえ、以前試したAmazon Kendraを使う構成よりも大変でした。

Amazon Kendraを使えばデータソースの追加や検索を良しなにやってくれるので、そのありがたみを感じた次第です。

村上博哉 (執筆記事の一覧)

2020年4月入社。機械学習が好きです。記事へのご意見など:hiroya.murakami@serverworks.co.jp