サーバーワークスの村上です。
NBAシーズン開幕まであと約1ヶ月、楽しみですね!
昨季優勝したナゲッツが呪術廻戦のエンディング風に試合スケジュールを公開していますので貼っておきますね。
Domain Expansion: Schedule Release 💥 pic.twitter.com/TmnZEFlwFo
— Denver Nuggets (@nuggets) 2023年8月17日
さきに本ブログのまとめです。
- 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が必要なページに対応するため |
以下のような流れで動作します。
- Lambda関数
web-crawler
を実行する- FAQページからテキスト情報を取得します。また、ページ内にあるリンクについてはリンク先のページ情報も取得します。
- 得られたテキストをS3
source-materials-bucket
に保存します。
- Lambda関数
read-source-and-build-index
が実行される- S3
source-materials-bucket
からテキスト情報を取得 - これを埋め込み(Embedding、後述)し、S3
created-index-bucket
に保存
- S3
- ユーザーがAmazon Lexを通じて質問する
- Amazon LexはLambda関数
lex-codehook
を呼び出す- S3
created-index-bucket
から関連する情報を取得し、質問とともに大規模言語モデル(LLM)にリクエスト - LLMから返ってきた応答をAmazon Lexに返す
- S3
- ユーザーは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を作成するという内容のブログです。
さて、将来的な展望ですが、先日「Knowledge base for Amazon Bedrock」というアップデートがありました。
これによるとユーザーが自前で実装していたS3などのナレッジベースについて、Amazon Bedrockと統合され、ユーザーの負荷が軽減するような内容と解釈できます。以下、一部抜粋したものを機械翻訳しました。
今日、顧客はRAGを実装するためにいくつかの未分化のステップを実行している。Amazon Bedrockのための知識ベースは、異なるシステムを統合する必要性を排除します。開発者は、Amazon S3バケットなどのドキュメントの場所を指定することができ、Bedrockは、取り込みワークフロー(ドキュメントのフェッチ、チャンキング、エンベッディングの作成、ベクターデータベースへの保存)と実行時のオーケストレーション(エンドユーザーのクエリに対するエンベッディングの作成、ベクターデータベースからの関連チャンクの検索、FMへの渡し)の両方を管理します。
これはかなり楽しみな内容ですので、GAしたら是非ブログにしたいなと考えています。
構築した内容
ここから構築手順になりますが、詳細なコードの内容はこちらのGitHubをご参照ください。
本ブログでは私が変更した内容のみ記載します。
FAQページの内容を取得する
さて、まずはFAQページの内容を取得するためのLambda関数「web-crawler」の構築です。
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
で使用するコードは以下です。
web-crawl-results.txt
というファイルがS3source-materials-bucket
に保存されると、このLambdaの処理が開始されます。
処理後は、S3created-index-bucket
にvector_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()
応答をLexへ返す
Lambda関数「lex-codehook」で使用するコードは以下です。ここでも使用するLLMをgpt-3.5-turbo-instruct
に変更していますが変更した内容は前項と同じですので割愛します。
まとめ
以上、RAGの実装例のご紹介でした。
いろいろと触ってみて感じたことですが、LLMが参照するナレッジベースの構築や検索の仕組みはできるだけAWSマネージドなものにした方が良いと感じました。
今回はウェブサイトのクロールや埋め込み、検索を実装しました。LlamaIndexを使っているとはいえ、以前試したAmazon Kendraを使う構成よりも大変でした。
Amazon Kendraを使えばデータソースの追加や検索を良しなにやってくれるので、そのありがたみを感じた次第です。