flowchart LR Input[query]-->Embedding[query embedding] Embedding-->DocumentEmbedding1[document embedding 1] Embedding-->DocumentEmbedding2[document embedding 2] Embedding-->DocumentEmbedding3[document embedding 3] subgraph CosineSimilarity[cosine similarity] DocumentEmbedding1-->CosineSimilarity1[0.1] DocumentEmbedding2-->CosineSimilarity2[0.8] DocumentEmbedding3-->CosineSimilarity3[0.3] end CosineSimilarity2-->FinalResult1[final result]
LangChain Hypothetical Document Embeddings (HyDE) 全面解説
本文の概要
Hypothetical Document Embeddings (HyDE)は去年提出した情報検索の精度を向上させるための手法です。
本文はHyDEの概念とLangchainでのその使い方を紹介しました。また、普通のEmbedding手法、HyDE、そして本文で提案したHyDE改善案、この三者の性能を比較しました。以下はテスト結果です。
手法 | 正解数(50件の中) | MRR | スピード |
---|---|---|---|
普通のEmbedding | 37 | 0.855 | 17秒 |
HyDE | 37 | 0.813 | 4分15秒 |
HyDE with title | 40 | 0.897 | 5分2秒 |
結論としてはHyDEはそれほど有効ではないことです。少し改善すれば性能は良くなりますが、検索スピードは非常に遅いですし、コストも大幅増加するので、ほぼ実用ではない手法といえます。
Hypothetical Document Embeddings (HyDE)の詳細
一般的なDense Information Retrievalの手順は以下のステップで行われます。
- QueryとDocument両方ともEmbedding(ベクトル)に変換する
- QueryとDocumentのコサイン類似度を計算する
- コサイン類似度が一番高いDocumentを返す
HyDEだと、query embedding
のところに工夫しました。直接QueryをEmbeddingに変換するのではなく、まずQueryに答えるドキュメントをLLMに生成させて、生成した仮想な答案をEmbeddingに変換します。
flowchart LR Input[query]-->LLM subgraph HyDE LLM-->FakeAnser[fake answer] end FakeAnser-->QueryEmbedding[query embedding] QueryEmbedding-->DocumentEmbedding1[document embedding 1] QueryEmbedding-->DocumentEmbedding2[document embedding 2] QueryEmbedding-->DocumentEmbedding3[document embedding 3] subgraph CosineSimilarity[cosine similarity] DocumentEmbedding1-->CosineSimilarity1[0.1] DocumentEmbedding2-->CosineSimilarity2[0.8] DocumentEmbedding3-->CosineSimilarity3[0.3] end CosineSimilarity2-->FinalResult1[final result] style HyDE stroke:#333,stroke-width:4px
実際にLangChainで使いましょう。
from langchain.embeddings import OpenAIEmbeddings
from langchain.chains import LLMChain, HypotheticalDocumentEmbedder
from langchain.prompts import PromptTemplate
from langchain.chat_models import ChatOpenAI
from dotenv import load_dotenv
# set the environment variables
load_dotenv()
# prepare the prompt template for document generation
prompt_template = """質問を回答しなさい。
質問:{question}
回答:"""
llm = ChatOpenAI()
# multi_llm = ChatOpenAI(n=4)
prompt = PromptTemplate(input_variables=["question"], template=prompt_template)
llm_chain = LLMChain(llm=llm, prompt=prompt, verbose=True)
# initialize the hypothetical document embedder
base_embeddings = OpenAIEmbeddings()
embeddings = HypotheticalDocumentEmbedder(llm_chain=llm_chain, base_embeddings=base_embeddings)
result = embeddings.embed_query("ゼルダの伝説の主人公は誰ですか?")
len(result)
1536
LangchainでHyDEを使うには、まずはHypotheticalDocumentEmbedder
を初期化する必要があります。初期化する際に必要なのは、仮想な答案を生成するllm_chain
と生成したテキストをEmbeddingに変換するbase_embeddings
です。
llm
を定義する時、一度生成するドキュメントの数を指定できます。例えばn
を4
に指定すると、一度4つのドキュメントを生成します。
使用する際には、embedding.embed_query
を使ってQueryをEmbeddingに変換します。これで最終的に1536次元のベクトルが得られます。
HypotheticalDocumentEmbedder
の内部処理
次にHypotheticalDocumentEmbedder
の内部は同様な処理になっているかを見ましょう。コアの関数は以下の2つです。
def combine_embeddings(self, embeddings: List[List[float]]) -> List[float]:
"""Combine embeddings into final embeddings."""
return list(np.array(embeddings).mean(axis=0))
def embed_query(self, text: str) -> List[float]:
"""Generate a hypothetical document and embedded it."""
# generate n hypothetical documents
var_name = self.llm_chain.input_keys[0]
result = self.llm_chain.generate([{var_name: text}])
# get all the hypothetical documents from result
documents = [generation.text for generation in result.generations[0]]
# embed the hypothetical documents
embeddings = self.embed_documents(documents)
# combine the embeddings by averaging
return self.combine_embeddings(embeddings)
毎回2つ仮想な答案を生成する場合のフロー図にすると以下のようになります。
flowchart LR Input([query])-->llm_chain subgraph HypotheticalDocumentEmbedder llm_chain-->ga1([generated answer 1]) llm_chain-->ga2([generated answer 2]) ga1-->OpenAIEmbeddings ga2-->OpenAIEmbeddings OpenAIEmbeddings -->embed1([embedding 1]) OpenAIEmbeddings -->embed2([embedding 2]) end embed1-->combine([averaged embedding]) embed2-->combine style llm_chain stroke:#333,stroke-width:4px style OpenAIEmbeddings stroke:#333,stroke-width:4px
HypotheticalDocumentEmbedderの処理の流れ
実際のパフォーマンステスト
HyDEは普通のEmbedding手法と比べてどのぐらい優れているかを実際に確認しましょう。
使うデータは多言語質問応答データセットである「Mr.TyDi」にある日本語データです。各Queryに対して、Positive DocumentとNegative Documentが与えられています。また、Queryの内容は基本的にWikiで検索できる一般的な知識です。なので、今回のHyDEには非常に適していると思います。
データはHuggingFaceのdatasetsからダウンロードします。データセットは7千件ありますが、コストを考慮して今回は100件のデータのみを使用します。また、テストする使うQueryの数は50件のみにします。つまり、50件のQueryに対して、合計200件(Pos + Neg)のドキュメントのランキングを行います。
from datasets import load_dataset
import pandas as pd
# to load all train, dev and test sets
dataset = load_dataset('castorini/mr-tydi', "japanese", split="train")
tydi_df = pd.DataFrame(dataset).sample(100, random_state=42)
for col in ["positive_passages", "negative_passages"]:
tydi_df[col] = tydi_df[col].apply(lambda x: x[0]["text"])
tydi_df_sample = tydi_df.iloc[:50,:].copy()
query_id | query | positive_passages | negative_passages | |
---|---|---|---|---|
1041 | 1320 | 有価証券とはなんですか? | 有価証券(ゆうかしょうけん)とは、伝統的には財産的価値のある私権を表章する証券で、その権利の... | 有価証券届出書の提出日以降、当該有価証券届出書の効力が発生する以前において、有価証券届出書に... |
670 | 862 | 浅草寺はいつ建設された | 推古天皇36年(628年)、宮戸川(現・隅田川)で漁をしていた檜前浜成・竹成(ひのくまのはま... | 1907年(明治40年)、昆虫学者名和靖は日露戦争の勝利記念に昆虫館を建設したいと考え、東京... |
確認する内容としては以下の3つとします。
- MRR: Mean Reciprocal Rank、平均逆順位です。総合的にパフォーマンスを確認することができます。
- 正解数:上位1位は正解の数です。直感的にわかりやすいです。
- 検索にかかる時間:HyDEはLLMでテキスト生成を行なうため、検索時間が大幅に増える予想です。
まずは、普通のEmbedding手法を使って場合のパフォーマンスをテストしてみましょう。
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.vectorstores import FAISS
from tqdm.auto import tqdm
def get_rank(query, docs):
for i, doc in enumerate(docs, start=1):
if query == doc.metadata["query"]:
return i
def test(test_query_list, vectorstore):
# fetch the documents
rank_list = []
for title in tqdm(test_query_list):
docs = vectorstore.similarity_search(title, k=200)
rank_list.append(get_rank(title, docs))
# summarize the results
return rank_list
def get_mrr(rank_list):
return sum([1/rank for rank in rank_list])/len(rank_list)
def get_correct_num(rank_list):
return len([rank for rank in rank_list if rank == 1])
# prepare the vectorstore
docs = tydi_df["positive_passages"].tolist() + tydi_df["negative_passages"].tolist()
meta_datas = [{"query": q} for q in tydi_df["query"].tolist()] + [{"query": ""} for q in tydi_df["query"].tolist()]
base_embeddings = OpenAIEmbeddings()
vectorstore = FAISS.from_texts(
texts=docs,
embedding=base_embeddings,
metadatas=meta_datas,
)
mrr: 0.855
correct num: 37
普通のEmbedding手法だと、50件のQueryの中、37件のドキュメントを正しく返せました。MRRは0.855、また、処理時間は17秒でした。
次にHyDEを使ってテストします。
from langchain.chat_models import ChatOpenAI
prompt_template = """質問に答えてください。
質問:{question}
答案:"""
llm = ChatOpenAI(verbose=True)
prompt = PromptTemplate(input_variables=["question"], template=prompt_template)
llm_chain = LLMChain(llm=llm, prompt=prompt, verbose=True)
embeddings = HypotheticalDocumentEmbedder(llm_chain=llm_chain, base_embeddings=base_embeddings)
vectorstore.embedding_function = embeddings.embed_query
print(f"mrr: {get_mrr(hyde_rank_list):.3f}")
print(f"correct num: {get_correct_num(hyde_rank_list)}")
mrr: 0.813
correct num: 35
意外ですが、HyDEを使うと逆に正解数が減りました。正解数は35件、MRRは0.813、処理時間は4分15秒でした。
HyDEの改善
HyDEは生成した仮想な答案をEmbeddingにしていますが、逆に重要なQueryの情報を捨てています。なので、仮想な答案をEmbeddingする前にQueryの情報を仮想な答案に加えることができれば、もっとパフォーマンスを改善できると考えられます。
その改善をしてみましょう。そのためには、まずHypotheticalDocumentEmbedder
を継承したクラスを作り、embed_query
を再定義する必要があります。
class HyDEWithTitle(HypotheticalDocumentEmbedder):
def embed_query(self, text: str):
"""Generate a hypothetical document and embedded it."""
var_name = self.llm_chain.input_keys[0]
result = self.llm_chain.generate([{var_name: text}])
documents = [generation.text for generation in result.generations[0]]
# add query to the beginning of the document
documents = [f"{text}\n{document}" for document in documents]
embeddings = self.embed_documents(documents)
return self.combine_embeddings(embeddings)
embeddings = HyDEWithTitle(llm_chain=llm_chain, base_embeddings=base_embeddings)
vectorstore.embedding_function = embeddings.embed_query
Retrying langchain.chat_models.openai.ChatOpenAI.completion_with_retry.<locals>._completion_with_retry in 1.0 seconds as it raised RateLimitError: That model is currently overloaded with other requests. You can retry your request, or contact us through our help center at help.openai.com if the error persists. (Please include the request ID 0d85be599b6abc67a7a59f467a5101cd in your message.).
print(f"mrr: {get_mrr(hyde_with_title_rank_list):.3f}")
print(f"correct num: {get_correct_num(hyde_with_title_rank_list)}")
mrr: 0.897
correct num: 40
検索するQueryを仮想な答案に追加することにより、正解数が多くなりましたし、全体のランクも上がりました。
まとめ
HyDEは予想より精度の改善が得られなかったです。Queryを仮想な答案に追加することにより、精度は普通のEmbedding手法より上がりました。しかし、処理時間が大幅に増えてしまいました。また、今回は測っていないですが、1件あたりのコストも何倍になると思うので、実際に使う場合は、精度と処理時間、コストを総合的に考えて使う必要があります。
手法 | 正解数(50件の中) | MRR | スピード |
---|---|---|---|
普通のEmbedding | 37 | 0.855 | 17秒 |
HyDE | 37 | 0.813 | 4分15秒 |
HyDE with title | 40 | 0.897 | 5分2秒 |