LLamaIndexでクエリ書き換え(HyDE)
クエリと検索対象のドキュメントはそもそも類似度やボリューム等で異なるため、ベクトル検索において適切なコンテキストを拾えない場合がある。そこで、クエリのコンテキストとなるような「仮」のドキュメントをLLMで生成して、このドキュメントをベクトル検索することで、検索精度を高めるというのが、HyDE(Hypothetical Document Embeddings)。
以下でクエリパイプラインを使った際に少しHyDEを触っているが、もう少し直接的な感じで触ってみる。
Colaboratoryで。今回はトレーシングにArize Phoenixを使う。
事前準備
インストール。
!pip install llama-index llama-index-callbacks-arize-phoenix
OPENAI_API_KEYを設定
from google.colab import userdata
import os
os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')
トレーシングを有効化。表示されたURLにアクセスすればArize Phoenixの画面が表示される。
import phoenix as px
from llama_index.core import set_global_handler
px.launch_app()
set_global_handler("arize_phoenix")
🌍 To view the Phoenix app in your browser, visit https://XXXXXXXXXX-colab.googleusercontent.com/
データは以下を使用する。
!wget https://d.line-scdn.net/stf/linecorp/ja/csr/dataset_.zip
!unzip dataset_.zip
import pandas as pd
df = pd.read_excel("dataset_.xlsx")
df.drop(columns=["ID","カテゴリ2","出典","<参考>UMカテゴリタグ","<参考>UMサービスメニュー\n(標準的な行政サービス名称)"], inplace=True)
df.rename(columns={
'サンプルID': 'ID',
'サンプル 問い合わせ文': 'Question',
'サンプル 応答文': 'Answer',
'カテゴリ1': 'Category',
}, inplace=True)
df
あとでHyDEの参考とするために、文字数とかを見ておく。
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
question_length_mean = df['Question_length'].mean()
question_length_median = df['Question_length'].median()
axes[0].hist(df['Question_length'], bins=100, color='blue', alpha=0.7)
axes[0].set_title(f'Question Character Counts\nMean: {question_length_mean:.2f}, Median: {question_length_median:.2f}')
axes[0].set_xlabel('Number of Characters')
axes[0].set_ylabel('Frequency')
axes[0].axvline(question_length_mean, color='red', linestyle="solid", linewidth=1)
axes[0].axvline(question_length_median, color='orange', linestyle='solid', linewidth=1)
axes[0].grid(True)
answer_length_mean = df['Answer_length'].mean()
answer_length_median = df['Answer_length'].median()
axes[1].hist(df['Answer_length'], bins=100, color='green', alpha=0.7)
axes[1].set_title(f'Answer Character Counts\nMean: {answer_length_mean:.2f}, Median: {answer_length_median:.2f}')
axes[1].set_xlabel('Number of Characters')
axes[1].set_ylabel('Frequency')
axes[1].axvline(answer_length_mean, color='red', linestyle="solid", linewidth=1)
axes[1].axvline(answer_length_median, color='orange', linestyle='solid', linewidth=1)
axes[1].grid(True)
plt.tight_layout()
plt.show()
回答は大体100文字前後ぐらいと考えれば良さそう。質問が20文字前後と考えると、この時点でベクトル検索の類似度比較には差があることがわかる。
ではインデックスを作成。一般的なドキュメントからの読み込みではなく、Pandasのデータフレームから回答部分だけをベクトル化しようと思うので、直接ノードを作成している。
from llama_index.core.schema import TextNode, NodeRelationship, RelatedNodeInfo
nodes = []
for idx, row in df.iterrows():
id = row["ID"]
text = row["Answer"].replace("\n\n","\n") # あとでセパレータで"\n\n"を使用するので削除しておく
node = TextNode(text=text, id_=id)
nodes.append(node)
from llama_index.core import VectorStoreIndex
from llama_index.embeddings.openai import OpenAIEmbedding
from pprint import pprint
embed_model = OpenAIEmbedding(model="text-embedding-ada-002")
index = VectorStoreIndex(nodes, embed_model=embed_model, show_progress=True)
HyDEを実装する前に、動作確認も兼ねて、通常のQuery Engine、Retrieverを実装してみる。
通常のQuery Engine
from llama_index.core.prompts import PromptTemplate
from llama_index.llms.openai import OpenAI
# 各コンテキストを"----"で分割して埋め込む関数
def format_context_fn(**kwargs):
context_list = kwargs["context_str"].split("\n\n")
fmtted_context = "\n----\n".join(context_list)
return fmtted_context
# QAテンプレート
text_qa_prompt = """\
コンテキスト情報は以下です。
====
{context_str}
====
与えられたコンテキスト情報を元に、事前知識を使用することなく、質問に答えてください。
質問: {query_str}
回答: \
"""
text_qa_template = PromptTemplate(text_qa_prompt, function_mappings={"context_str": format_context_fn})
llm = OpenAI(model="gpt-3.5-turbo-0125", temperature=0.1)
query_engine = index.as_query_engine(llm=llm, similarity_top_k=5, response_mode="compact")
query_engine.update_prompts({"response_synthesizer:text_qa_template": text_qa_template})
response = query_engine.query("母子手帳を受け取りたいのですが、手続きを教えてください。")
print(response)
母子手帳を受け取るためには、まず妊娠届を提出する必要があります。妊娠届には、診断を受けた病院名・医師名を記入して提出してください。その後、自治体の指定された窓口や出張所などで母子手帳を受け取ることができます。母子手帳をなくした場合は、再交付を受けることも可能ですが、申請の際にはご本人確認できるものを持参する必要があります。出生前の母子手帳と出生後の母子手帳で再交付の受け取り場所が異なるので、注意が必要です。詳細な手続きや受け取り場所については、自治体のHP内関連ページをご確認ください。
トレーシングを見てみる。
元のQAデータで上記のクエリにマッチする本来の回答は以下。
まあ元の質問と回答が果たして正しいのか?という感もあるにはあるのだけどね。一応、母子手帳に関するコンテキストは拾ってきているわけだし。
通常のRetriever
LLMへのアクセスを行わずに単純にベクトル検索結果だけを取得する。
retriever = index.as_retriever(similarity_top_k=5)
contexts = retriever.retrieve("母子手帳を受け取りたいのですが、手続きを教えてください。")
fmtted_context = [f"ID: {c.id_}\nScore: {c.get_score()}\n\n{c.get_content()}" for c in contexts]
print("\n----\n".join(fmtted_context))
ID: 37
Score: 0.9046913162865187
母子手帳の申請には診断書はいりませんが、妊娠届に診断を受けた病院名・医師名を記入していただきます。
----
ID: 3
Score: 0.8988739974936976
母子手帳は、妊娠届の内容を確認させていただき、その場でお渡しします。
▼詳しくはこちら
(自治体HP内関連ページのURL)
----
ID: 108
Score: 0.8975728310165151
母子手帳をなくしたときは、再交付を受けてください。
お子さんが出生前の母子手帳については、(再交付を受けられる場所)で再交付を受けられます。
お子さんが出生後の母子手帳については、(再交付を受けられる場所)で受けられます。
申請の際はご本人確認できるものをお持ちください。
◆お問い合わせ
(自治体の担当課等の名称)
(電話番号)/(開庁時間)
----
ID: 36
Score: 0.8875449557084947
産前は母子手帳以外の手続きは特にありません。
産後に、出生の届出や出生通知書の提出、(自治体が行う出産助成等)の申請をお願いします。
----
ID: 2
Score: 0.8787112814856418
母子手帳は、○○市役所本庁舎△△階××課窓口、◎◎出張所、………(その他の受け取り場所を適宜記載)………で受け取れます。
▼詳しくはこちら
(自治体HP内関連ページのURL)
Query Engineでretrievalした内容と同じものが取得できているのがわかる。
HyDEを使ったクエリの書き換え
まずはQuery EngineやRetrieverを使わずに、シンプルにクエリの書き換えだけ。HyDEQueryTransformを使う。
from llama_index.core.indices.query.query_transform.base import HyDEQueryTransform
hyde = HyDEQueryTransform(include_original=False)
query_bundle = hyde("母子手帳を受け取りたいのですが、手続きを教えてください。")
query_bundle.embedding_strs[0]
母子手帳を受け取るためには、まず地元の保健センターや市区町村役場に行く必要があります。そこで、母子手帳の申請書を提出し、必要な書類や健康診断の結果などを提出することが求められます。申請書には、母親の基本情報や妊娠の経過、予定日などの情報が記入されます。また、母子手帳は妊娠22週目までに受け取ることが推奨されています。手続きが完了すると、母子手帳は無料で発行され、妊娠中から出産後までの健康管理や子育てのサポートに役立つ情報が記載されています。手続きの詳細や必要な書類は、各自治体のウェブサイトや窓口で確認することができます。
include_original=True
にすると元のクエリがembedding_strs[1]
に入ってきて、クエリ時にどうやら両方ともembeddingsが生成される様子。
プロンプトはこれ。
ということで日本語に置き換える。
from llama_index.core.indices.query.query_transform.base import HyDEQueryTransform
from llama_index.core.prompts.base import PromptTemplate
from llama_index.core.prompts.prompt_type import PromptType
from llama_index.llms.openai import OpenAI
hyde_prompt_template = """\
以下の質問に答える文章を書いてください。
重要な詳細はできるだけ多く含めるようにしてください。
文章は日本語で100字前後にしてください。
質問: {context_str}
文章: \
"""
hyde_prompt = PromptTemplate(hyde_prompt_template, prompt_type=PromptType.SUMMARY)
llm = OpenAI(model="gpt-3.5-turbo-0125", temperature=0.1)
hyde = HyDEQueryTransform(llm=llm, hyde_prompt=hyde_prompt, include_original=False)
query_bundle = hyde("母子手帳を受け取りたいのですが、手続きを教えてください。")
query_bundle.embedding_strs[0]
母子手帳を受け取るためには、まず医療機関で妊娠を確認し、妊婦健診を受ける必要があります。その後、市区町村役場や保健所で母子手帳の交付申請を行います。必要な書類は、健康保険証や妊婦健診の受診票などです。手続きが完了すると、母子手帳が交付されます。母子手帳は妊娠中から出産後1歳までの健康管理や予防接種の記録などが記載されています。
HyDEを使ったQuery Engine
HyDEを使ったQuery Engineは、TransformQueryEngineを使う。temperatureは最小限のランダム性がほしいというところで0.1にしている。
from llama_index.core.indices.query.query_transform.base import HyDEQueryTransform
from llama_index.core.query_engine import TransformQueryEngine
from llama_index.core.prompts.base import PromptTemplate
from llama_index.core.prompts.prompt_type import PromptType
from llama_index.llms.openai import OpenAI
hyde_prompt_template = """\
以下の質問に答える文章を書いてください。
重要な詳細はできるだけ多く含めるようにしてください。
文章は日本語で100字前後にしてください。
質問: {context_str}
文章: \
"""
hyde_prompt = PromptTemplate(hyde_prompt_template, prompt_type=PromptType.SUMMARY)
llm = OpenAI(model="gpt-3.5-turbo-0125", temperature=0.1)
hyde = HyDEQueryTransform(llm=llm, hyde_prompt=hyde_prompt, include_original=False)
hyde_query_engine = TransformQueryEngine(query_engine, hyde)
response = hyde_query_engine.query("母子手帳を受け取りたいのですが、手続きを教えてください。")
print(response)
妊娠したら妊娠届を○○課窓口(または支所・出張所窓口)に提出し、母子手帳を受け取ってください。妊娠届には妊娠週数、分娩予定日、性病に関する健康診断(血液検査)の有無、結核に関する健康診断(レントゲン検査)の有無及び診断を受けた医療機関の名前・所在地・診断者氏名を記入していただく必要がありますので、予め妊婦(委任者)ご本人にご確認の上お越しください。代理でも届出することができますが、委任状が必要になります。
トレースを見てみる。
まずHyDEで仮の回答が生成される。
これを使ってretrievalが行われる。以下だけ見るとクエリがそのまま渡されているように見えるけど、
embeddingsの処理で最初の仮の回答が使用されていることがわかる。
で最終回答が生成される。
まあ本来retrievalで引っ張りたいコンテキストは選択されていないのだけども、HyDEを使わなかった場合とは異なるコンテキストが選択されているのがわかる。
HyDEを使ったRetriever
HyDEを使ったRetrieverは、TransformRetrieverを使う。
from llama_index.core.indices.query.query_transform.base import HyDEQueryTransform
from llama_index.core.retrievers import TransformRetriever
from llama_index.core.prompts.base import PromptTemplate
from llama_index.core.prompts.prompt_type import PromptType
hyde_prompt_template = """\
以下の質問に答える文章を書いてください。
重要な詳細はできるだけ多く含めるようにしてください。
文章は日本語で100字前後にしてください。
質問: {context_str}
文章: \
"""
hyde_prompt = PromptTemplate(hyde_prompt_template, prompt_type=PromptType.SUMMARY)
llm = OpenAI(model="gpt-3.5-turbo-0125", temperature=0.1)
hyde = HyDEQueryTransform(llm=llm, hyde_prompt=hyde_prompt, include_original=False)
hyde_retriever = TransformRetriever(retriever, hyde)
contexts = hyde_retriever.retrieve("母子手帳を受け取りたいのですが、手続きを教えてください。")
fmtted_context = [f"ID: {c.id_}\nScore: {c.get_score()}\n\n{c.get_content()}" for c in contexts]
print("\n----\n".join(fmtted_context))
ID: 450
Score: 0.9318868272200984
妊娠したら妊娠届を○○課窓口(または支所・出張所窓口)に提出し、母子手帳を受け取ってください。
▼詳しくはこちら
(自治体HP内関連ページのURL)
----
ID: 37
Score: 0.9216687830401521
母子手帳の申請には診断書はいりませんが、妊娠届に診断を受けた病院名・医師名を記入していただきます。
----
ID: 149
Score: 0.9212460212176378
妊娠届(母子手帳交付申請含む)は代理でも届出することができます。ただし、妊婦ご本人と同一世帯の方以外が代理申請する場合は、委任状が必要になります。
また、妊娠届には妊娠週数、分娩予定日、性病に関する健康診断(血液検査)の有無、結核に関する健康診断(レントゲン検査)の有無及び診断を受けた医療機関の名前・所在地・診断者氏名を記入していただく必要がありますので、予め妊婦(委任者)ご本人にご確認の上お越しください。
▼詳しくはこちら
(自治体HP内関連ページのURL)
----
ID: 360
Score: 0.9202001406179467
妊娠した人は、(市役所○○課、保健所、保健相談所等の妊娠届を出せる場所を記載してください。)で妊娠届を出してください。(母子手帳や妊婦健診の受診票など、妊娠した人にお渡しするもの)をさしあげます。なるべく早めの届出をお願いします。
▼詳しくはこちら
(自治体HP内関連ページのURL)
----
ID: 3
Score: 0.917349522143543
母子手帳は、妊娠届の内容を確認させていただき、その場でお渡しします。
▼詳しくはこちら
(自治体HP内関連ページのURL)
ちなみにinclude_original=True
にするとどうなるか。トレースを見ると書き換え前のクエリもembeddingsが生成されているのがわかるけど、これをどう検索で使っているのかはちょっとわからなかった。
全部試していないので、今回の例だと効果があったとは言えない。ただ、文書から一定の長さを持ったチャンクで抽出するようなケースだと、クエリとドキュメントのボリュームには差異がある可能性が高いということから考えても、効果があるケースはあると思う。
ただ仮回答を生成する部分でLLMが必要になること、LLMがどういった文章を生成してくるか、というところで、どうしてもLLM次第な部分が出てくるので正直読みにくいかなという気はする。