見出し画像

LLM、RAG初心者がLlamaindexを使って製品ヘルプBotを作ってみた

こんにちは遠藤です。

今回はLlamaIndexというRAGの仕組みを構築する際に便利なフレームワークを利用して、製品に対する質問をしたら回答をしてくれるヘルプbotを作るチュートリアル記事となっています。
使用するデータソースは公開されているサイトを使いますので、こちらの記事を読んで実際に手元で試すことができます。
概要は飛ばしてチュートリアルに進みたい方は「製品ヘルプを取り込んでQA botを構築」のセクションから読んでください。

はじめに

最近LangChainの本を一冊読んでみて、SalesforceのEinstein AIってまんまLangChainなんだなという気がして、仕事も兼ねてちゃんと体験しておかなくてはと思ったことがありました。
そんなわけで一旦「IoTシステムの異常検知にAWS SageMakerを使って学習させたモデルを適用する」というテーマから離れて、RAGを使ったちゃんとしたシステムを実装してみることにしました。

具体的には、RAGを使って自社製品のヘルプBotを実装して評価していきます。実務に関わるのでどのくらい使えるのかの評価もしやすそうです。しかし、ちょっと試してみた限りでは、実用までにはなかなかハードルが高そうな印象です。

RAGの検索対象としてインデックスに取り込むデータは、公開されている製品ヘルプサイト、サポートシステムのやりとり、GitHubのコードとWikiあたりを考えています。これらをうまい具合に組み合わせてして、サポートの回答を提案するチャットボットを実装していきます。

RAG(Retrieval-Augmented Generation)とは

RAGについてはよく目についたり耳にしたりしていましたが、人に説明できるほど理解しているかというと「?」でした。

RAGはRetrieval-Augmented Generationの頭文字で、Googleの解説だと以下のように説明されています。

RAG(検索拡張生成)は、従来の情報検索システム(データベースなど)の強みと、生成大規模言語モデル(LLM)の機能を組み合わせた AI フレームワークです。この追加の知識と AI 独自の言語スキルを組み合わせることで、AI は、より正確で最新の、特定のニーズに関連するテキストを作成できます。

検索拡張生成(RAG)とは

ちょっと抽象的すぎてあんまりわからないですw

ユースケースで考えて見ると、たとえばプライベートな社内の情報など既成のモデルが学習していないデータをプロンプトのコンテキストに載っけてLLMがいい具合に回答を生成できるような仕組み・手法のことをRAGといいます。

Google の解説をもう少し読み進めてみると「RAG を使用する理由」には以下があります

  • 最新情報へのアクセス

  • 事実に基づく根拠付け

  • コンテキストの関連性

  • 事実に基づく一貫性

  • ベクトルデータベースの利用:

  • 回答精度の向上

  • RAGとchatbot

なるほどと思うものと、あまり腑に落ちないものとありますね。

なんか万能そうなRAG、そうであればすでに結構な仕事がLLMの恩恵によって楽になっていそうですが、実際はまだそうなっていません。ChatGPTなど既存のモデルを利用する範囲でできることを適用するのは結果もすぐでてわかりやすいですが、RAGはインデックスに取り込むコンテンツの前処理をちゃんとする面倒さがあり、コンテキストに注入するコンテンツをうまく抽出するためのインデックスを作成するところも難しいように見えます。ここらへんをちょっと調べるといきなりアカデミックな記事がヒットして面食らったりと、昔ならがらのデータサイエンスと機械学習の沼にハマっていきそう。。

これらRAG課題のそれぞれについて解説できるほどの知見は私にはないので、このシリーズでは実体験で解決できたことをいくつか共有できればと考えています。

LlamaIndexとは

前置きが長くなりましたが、ここからが本題です。

RAGを構築するためのオープンソースのフレームワークとしてはLangChainが有名ですが、LlamaIndexも次点くらいの位置に居るフレームワークです。

LlamaIndexは、LangChainより高レベルのインターフェースを提供するフレームワークな印象で、ただのQAボットを作るのであればLangChainより簡単に始められます。

また、LlamaIndexはHPを見るとどちらかというと法人向けな印象があります。とくにデータを統合する仕組みに力を入れていてLlamaHubにはReaderと呼ばれるデータを取り込むアダプターがたくさん提供されています。

今回の目的のように、Webサイト、Intercom、GitHubなど複数のデータソースを統合する場合に連携基盤を別途用意する必要がないためとても便利そうです。

LlamaIndexでRAGを実装する流れ

LlamaIndexはドキュメントがとても充実しています。Getting Started的なドキュメントの「Learn」ではRAGのパイプラインは以下の流れで実装していくと解説されています。

  1. Loading: データのロード、チャンクに分割するなど前処理を行いDocumentオブジェクトのリストを生成します

  2. Indexing: Documentと計算したEmbeddingをインデックスに追加します

  3. Storing: 作成したインデックスを永続化します

  4. Querying: 1.〜3.で作成したインデクスを利用してRAGを実行します

製品ヘルプを取り込んでQA botを構築

ここからがこの記事の本題です。

以下の弊社製品「MatchingMap」のヘルプサイトの記事をインデックスに追加してRAGを構築していきます。

今回は公開情報のみで実装しています。以下で解説するコードはgistにまとめてありますので、ぜひ実際に試してみてください。

https://gist.github.com/hrendoh/1feba36187358e5f13622b8ebfd95298

前処理

ヘルプはGitbookというサービスを利用して書いており、GitHubのリポジトリと連携しているのでgitで記事を取得することもできますが、記事の読者にも試していただけるように公開されている製品ヘルプサイトからBeautifulSoupを使ってスクレイプしています。

LlamaIndexにはWebサイト用のReader、BeautifulSoupWebReaderがあるのでページのリンクを取得した後にBeautifulSoupWebReaderでDocument[]を取得することもできますが、今回はチャンクに分ける前のデータを目で確認したかったので一旦テキストに落としています。

製品ヘルプサイトからテキストを抽出したコードは以下です。

import os
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse

# 元となるWebページのURL(目次ページ)
base_url = "https://help.co-meeting.co.jp/matchingmap"

# requestsを使ってWebページを取得
response = requests.get(base_url)
response.encoding = 'utf-8'

# BeautifulSoupを使ってHTMLを解析
soup = BeautifulSoup(response.text, 'html.parser')

# classが 'group/toclink' の a 要素を全て抽出
toc_links = soup.find_all('a', class_='group/toclink')

# リンク先のURLをリストアップ
urls = [urljoin(base_url, a['href']) for a in toc_links]

# リンク先のページの内容を取得し、テキストのみを抽出してファイルに保存
for link in urls:
    # Webページの取得
    page_response = requests.get(link)
    page_response.encoding = 'utf-8'
    
    # BeautifulSoupを使ってHTMLを解析
    page_soup = BeautifulSoup(page_response.text, 'html.parser')
    # asideとheaderタグを除去
    for tag in page_soup.find_all(['aside', 'header']):
        tag.decompose()
    
    # ページのテキストを抽出
    page_text = page_soup.get_text(separator="\n", strip=True)
    
    # ファイルパスを生成(URLのパスに基づく)
    parsed_url = urlparse(link)
    directory = os.path.dirname(parsed_url.path.lstrip('/'))
    filename = os.path.basename(parsed_url.path).replace('/', '_') + '.txt'
    
    # ディレクトリが存在しない場合は作成
    if directory and not os.path.exists(directory):
        os.makedirs(directory)
    
    # テキストをディレクトリ内のファイルに保存
    filepath = os.path.join(directory, filename)
    with open(filepath, "w", encoding="utf-8") as file:
        file.write(page_text)
    
    print(f"コンテンツが '{filepath}' に保存されました。")

実行すると以下のようにWebサイトのパスをディレクトリに展開して、記事が保存されます。

Loading & Transformation(ドキュメントの読み込みと変換)

前処理で用意したテキストをSimpleDirectoryReaderで読み込みDocumentのリストを生成します。
次に、Documentのリストをチャンク化してインデックスを作成しいます。インデックスはVectorStoreIndexを使用しますが、引数transformationsにドキュメントをチャンク化してくれるSentenceSplitter を指定しています。
以下では、SentenceSplitterに1チャンクを512トークン、オーバーラップを10トークンで分割するように指定しています。これらのパラメータは適当なので要調整です。

import os
# OpenAIプラットフォームのAPIキーをセットする
os.environ['OPENAI_API_KEY'] = 'your api key'

# ディレクトリのテキストを読み込む
from llama_index.core import SimpleDirectoryReader
documents = SimpleDirectoryReader(input_dir="./matchingmap", recursive=True).load_data()

# チャンクを分けるための設定
from llama_index.core.node_parser import SentenceSplitter
text_splitter = SentenceSplitter(chunk_size=512, chunk_overlap=10)

# SentenceSplitterを指定してインデックスを生成
from llama_index.core import VectorStoreIndex
index = VectorStoreIndex.from_documents(
    documents, transformations=[text_splitter]
)

# インデックスをファイルとして永続化
index.storage_context.persist(persist_dir="./index")

このコードは、以下のページの「High-Level Transformation API」の解説を参照しました。

チャンクを作成する際に使用されるモデルは、

チャンクとトークンとは?
チャンクはインデックスを作成する単位と捉えてください。LLamaIndexにおいてチャンクはNodeのインスタンスとなります。またEmbddingもチャンクごとに生成されます。
トークンは、LLMが処理する最小単位で、短い英単語であれば1トークンというイメージです。日本語の場合はひらがなは1文字1トークン、漢字の場合は複数になります。日本語の文書は1チャンクが512トークンとすると300語程度含まれる計算です。

トークナイザー(Tokenizer)の指定
テキストをトークンに分割するのがトークナイザーですが、SentenceSplitterのデフォルトのトークナイザーはNLTKを使用しているようです。

https://github.com/run-llama/llama_index/blob/main/llama-index-core/llama_index/core/node_parser/text/sentence.py#L196

他のトークナイザーを使用したい場合は、パラメータtokenizerに指定することができます。

Indexing & Embedding(Embeddingの取得とインデックスに追加)

前述のコードでVectorStoreIndex.from_documentsを実行していますが、このなかでチャンクのEmbeddingの取得とインデックスへの追加が行われています。

チャンクのEmbeddingを取得するモデルは、デフォルトではOpenAIのEmddingモデル「text-embedding-ada-002」が使用されます。
他のOpenAIのEmbeddingモデルについては、「Embedding models」を確認してください。

VectorStoreIndexはチャンクとチャンクのEmbeddingを同時にインデックスに含めます(以下のページの「Vector Store Index」参照)。他に選択可能なインデックスについても以下に解説がありますので参照ください。

Storing(インデックスの永続化)

今回は取り込む記事もそれほど多くないので開発中は毎回インデックスを作ってもよい気もしますが、データ量が多ければインデックスを作成する時間とEmbeddingを生成するAPIの使用料がかかるので基本的にはインデックスを作成したらどこかに永続化しておく必要があります。

永続化方法も様々ありますが、データ量はそれほどなくまだ検証段階なのでローカルにファイルを保存します。

index.storage_context.persist(persist_dir="/index")

indexディレクトリに以下にインデックスが保存されているのが確認できます

Querying(質問の回答生成)

ファイル保存したインデックスを読み込みRAGを実行して質問に対する回答を生成します。

from llama_index.core import StorageContext, load_index_from_storage

# rebuild storage context
storage_context = StorageContext.from_defaults(persist_dir="./index")

# load index
index = load_index_from_storage(storage_context)

query_engine = index.as_query_engine()
response = query_engine.query(
    "レコードページからMatchingMapの地図を起動するボタンはどうやって設定しますか?"
)
print(response)

「レコードページからMatchingMapの地図を起動するボタンはどうやって設定しますか?」の質問に対する回答は以下になります。

地図を起動するボタンをレコードページに配置するには、以下の手順を実行します。

1. ページレイアウトに配置
  - 「設定」の「オブジェクトマネージャー」を開き、MatchingMapの地図を表示するオブジェクトを選択します。
  - 「ページレイアウト」を開き、使用しているページレイアウトを開きます。
  - 「モバイルおよび Lightning のアクション」を選択して、追加した「付近の求職者」または「付近の求人施設」ボタンを「Salesforce モバイルおよび Lightning Experience のアクション」の適当な場所にドラッグします。Classicの場合は、「ボタン」を選択して「付近の求職者」または「付近の求人施設」ボタンを「カスタムボタン」にドラッグします。最後に「保存」をクリックします。
2. Lightningアプリケーションビルダーで配置 (Lightningページに配置)
  - Lightningアプリケーションビルダーを開き、ボタンを配置するレコードページの右上の歯車のメニューから「編集ページ」を選択します。

まあ使えませんが、初回の結果としてはそこまで悪くはない回答を得られました。

続けて「MatchingMapの地図を起動する「付近の求人ボタン」はどうやって作成しますか?日本語で回答してください。」と聞いてみます。

「付近の求人ボタン」を作成するには、まず表示ラベルに「付近の求人」と入力し、名前に「NearByJob」などの適切な値を入力します。次に表示の種類で「詳細ページボタン」を選択し、動作は「新規ウィンドウに表示」のままにしておきます。最後にコンテンツソースは「URL」のままにして設定を保存します。

生成された回答

情報が足りないですが、誤ったことは回答していません。
もうちょいな気がします。過去の回答を踏まえてChatで質問を続けて行ければなんとか解決できるかもしれません。

Tracing and Debugging(デバッグ)

ところで、上記の回答はどのチャンクを使ってどのようなプロンプトを生成したのでしょうか?
それを確認するためにはクエリーを実行する前に以下のコードを追加しておきます。

import logging
import sys

logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout))

実行すると標準出力で使用したチャンクを確認することができます。

> [Node 27223764-c3b5-49c5-9dfb-39dae4085cda] [Similarity score:             0.889061] 作成したボタンをレコードページに配置する | MatchingMap ヘルプ  
「  
地図を起動するボタンを作成する  
」で作成したボタンをレコードページに配置するには以下の2つの方法があります。  
目...

> [Node 64e91568-9bc9-4958-b317-439271403048] [Similarity score:             0.888941] 地図を起動するボタンを作成する | MatchingMap ヘルプ  
取引先を例に、地図を起動する「付近の求職者」ボタンを追加する手順について解説します。  
❶「設定」の「オブジェクトマネージャー」...

> Top 2 nodes:

> [Node 27223764-c3b5-49c5-9dfb-39dae4085cda] [Similarity score:             0.889061] 作成したボタンをレコードページに配置する | MatchingMap ヘルプ  
「  
地図を起動するボタンを作成する  
」で作成したボタンをレコードページに配置するには以下の2つの方法があります。  
目...

デフォルトでは、類似度が最も近い2つのチャンクが使用されます。

ところで類似度の計算方法については、以下のページの「Querying」で解説されています。単純にEmbeddingのベクトル距離が近いものがトップ2に入るわけではなさそうで、ちょっと難しいです。

デバッグ出力から、プロンプトも含むcompletions APIへのリクエストも確認できます

{'method': 'post', 'url': '/chat/completions', 'files': None, 'json_data': {'messages': [{'role': 'system', 'content': "You are an expert Q&A system that is trusted around the world.\nAlways answer the query using the provided context information, and not prior knowledge.\nSome rules to follow:\n1. Never directly reference the given context in your answer.\n2. Avoid statements like 'Based on the context, ...' or 'The context information ...' or anything along those lines."}, {'role': 'user', 'content': 'Context information is below.\n---------------------\nfile_path: /Users/hrendoh/workspace/sandbox/hello-llamaindex/matchingmap/admins/place-launch-map/create-button-to-launch-the-map.txt\n\nこのURLの末尾に各種パラメーターを記載することで、地図の縮尺や検索フィルタの初期値の設定など、地図起動時の挙動をカスタマイズすることが可能です。パラメーターについては「\n地図の起動パラメーターについて\n」をご参考ください。\n7)  「構文を確認」ボタンをクリックして入力したURLに誤りが無いことを確認します。\n8) 「保存」ボタンをクリックします。\n❹ 「OK」ボタンをクリックします。\n❺ 以上で完了です。作成したボタンをレコードページに配置するには、「\n作成したボタンをレコードページに配置する\n」をご参考ください。\n前へ\n地図の配置/起動\n次へ\n作成したボタンをレコードページに配置する\n最終更新\n2 か月前\n1) 表示ラベル:「付近の求職者」「付近の求人施設」など適当なボタン名を入力します。\n2) 名前:「NearByJobSeeker」、「NearByAccount」など適当な値を入力します。\n3) 表示の種類 : 「詳細ページボタン」を選択します。\n4) 動作:「新規ウィンドウに表示」のままにします。\n5) コンテンツソース:「URL」のままにします。\n\nfile_path: /Users/hrendoh/workspace/sandbox/hello-llamaindex/matchingmap/admins/install/setup.txt\n\n地図表示\nサンプルオブジェクトでは既に「付近の求人」「付近の求職者」ボタンが配置されています。その他のオブジェクトにボタンを配置する方法については「\n地図の配置/起動\n」をご覧ください。\n❶ 求職者レコードを表示し、「付近の求人」ボタンをクリックします。\n❷ 選択した求職者を中心に付近の求人施設をプロットする地図を表示できます。\n❸ 求人施設レコードを表示し、「付近の求職者」ボタンをクリックします。\n❹ 選択した求人施設を中心に付近の求職者をプロットする地図を表示できます。\n12.\n---------------------\nGiven the context information and not prior knowledge, answer the query.\nQuery: MatchingMapの地図を起動する「付近の求人ボタン」はどうやって作成しますか?日本語で回答してください。\nAnswer: '}], 'model': 'gpt-3.5-turbo', 'stream': False, 'temperature': 0.1}}

messagesにrole「system」と「user」のプロンプトが出力されていますが、これらのデフォルトテンプレートはLlamaIndexのリポジトリrun-llama / llama_indexこちらで確認できます。

RAGパイプラインのカスタマイズ

ここまで、ほぼLlamaIndexのデフォルト設定を利用してRAGを実装してきましたが、いくつかチューニングポイントについて補足していきます。

コンテキストに含めるチャンク数の調整

前述のとおりクエリーのコンテキストに含めるチャンク数をのデフォルトは2ですが、低レベルAPIを使用してチャンク数を調整することができます。
以下のコードはチャンク数は10個に変更しています。ただし、類似度が0.7以下は含めないようにカットオフも指定しています。

from llama_index.core import StorageContext, load_index_from_storage

# rebuild storage context
storage_context = StorageContext.from_defaults(persist_dir="./index")

# load index
index = load_index_from_storage(storage_context)

from llama_index.core import VectorStoreIndex, get_response_synthesizer
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.postprocessor import SimilarityPostprocessor
# configure retriever
retriever = VectorIndexRetriever(
    index=index,
    similarity_top_k=10,
)

# configure response synthesizer
response_synthesizer = get_response_synthesizer()

# assemble query engine
query_engine = RetrieverQueryEngine(
    retriever=retriever,
    response_synthesizer=response_synthesizer,
    node_postprocessors=[SimilarityPostprocessor(similarity_cutoff=0.7)],
)

response = query_engine.query(
    "MatchingMapの地図を起動する「付近の求人ボタン」はどうやって作成しますか?日本語で回答してください。"
)
print(response)

プロンプトのカスタマイズ

デフォルトのプロンプトテンプレートは、英語なので回答が英語で出力されてしまうことがあります。
そこで、以下のようにプロンプトテンプレートも日本語にしておくことができます。

from llama_index.core import StorageContext, load_index_from_storage

# rebuild storage context
storage_context = StorageContext.from_defaults(persist_dir="./index")

# load index
index = load_index_from_storage(storage_context)

from llama_index.core import VectorStoreIndex, get_response_synthesizer
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.postprocessor import SimilarityPostprocessor
# configure retriever
retriever = VectorIndexRetriever(
    index=index,
    similarity_top_k=10,
)

# configure response synthesizer
response_synthesizer = get_response_synthesizer()

# Query prompt template
from llama_index.core import PromptTemplate
template = (
    "コンテキスト情報は以下です。\n"
    "---------------------\n"
    "{context_str}\n"
    "---------------------\n"
    "事前知識ではなく、与えられたコンテキスト情報から質問に答えてください。\n"
    "質問: {query_str}\n"
    "回答: "
)
qa_template = PromptTemplate(template)
# assemble query engine
query_engine = RetrieverQueryEngine(
    retriever=retriever,
    response_synthesizer=response_synthesizer,
    node_postprocessors=[SimilarityPostprocessor(similarity_cutoff=0.7)],
)
query_engine.update_prompts(
    {"response_synthesizer:text_qa_template": qa_template}
)
response = query_engine.query(
    "MatchingMapの地図を起動する「付近の求人ボタン」はどうやって作成しますか?"
)
print(response)

カスタマイズの評価

上記の2点の調整を行い、再び同じ質問「MatchingMapの地図を起動する「付近の求人ボタン」はどうやって作成しますか?」を投げてみて得られる回答は以下になりました。

以下の手順に従って、「付近の求人ボタン」を作成します。

1. 「設定」の「オブジェクトマネージャー」を開き、MatchingMapの地図を表示するオブジェクトを選択します。
2. 「ボタン、リンク、およびアクション」を開き、「新規ボタンまたはリンク」をクリックし「新規ボタンまたは新規リンク」の編集ページを開きます。
3. URLに以下の値を入力します。
/apex/matchingmap__index?id={!<オブジェクトのAPI参照名>.Id}&mapid=<使用する地図設定の地図名>
例えば、地図名が「near_by_job_seekers」の地図を起動するボタンを「取引先(Account)」オブジェクトのレコードページに配置する場合は、地図名を指定します。
4. 保存ボタンをクリックします。

以上の手順により、「付近の求人ボタン」を作成することができます。

生成された回答

より具体的な回答が得られました。固有名詞に関する内容以外は、ほぼパーフェクトなのではないでしょうか。予想していたよりも制度の高い回答が得ることができました。

続けて、「作成したボタンをレコードページに配置する方法を教えて下さい」と質問できるようにできれば、実際にやりたいことが解決しそうです。

まとめと今後の課題

ここまで、LlamaIndexの高レベルAPIを使用したRAGパイプラインを試した後に、一部低レベルAPIを使用したカスタマイズまで紹介してきました。

最後に得られた回答は、当初予想していたよりも精度が高いもので、ちょっと驚いています。

とはいえ実用できるほどではないので引き続き、以下の施策を試していく予定です。

インデックスするデータを増やす

以下の上方を追加して、製品ヘルプのみでは回答できないような質問にも対応できるようにしたいと考えています

  • ヘルプ中の画像

  • サポートシステム(Intercomのやりとり)

  • 製品のコード(Githubリポジトリ)

また、データソースを増やす場合に、インデックスをどう組み合わせるのかなども課題が出てきそうです。

プロンプトエンジニアリングによる回答精度向上

プロンプトテンプレートのカスタマイズのみでも回答精度はある程度改善できそうです。

  • Salesforceの内容についてはSalesforceの情報をできるだけ参照するようにしたい

  • 固有名詞をコンテキストに入れる

プロンプトエンジニアリングについては、OpenAIの解説「Prompt engineering - OpenAI API」をちゃんと読んでおこうと思います。

チャット形式で質問を連続で投げられるようにする

前回の質問・回答を踏まえて、次の質問に対する回答を生成できるようにできると、セルフサービスで問題を解決できる割合が増えそうです。

これを実現するにはLangChainを使ったほうが良いのかなと思っていましたが、LlamaIndexもChatEngineを提供しています。こちらのドキュメントに目を通してLangChainが必要なのかLlamaIndexだけで実現できるのかを確認しておきたいところです。

以上です。最後まで目を通していただきありがとうございました!


この記事が気に入ったらサポートをしてみませんか?