LangChain Hypothetical Document Embeddings (HyDE) 全面解説

NLP
LLMs
LangChain
Published

May 15, 2023

本文の概要

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の手順は以下のステップで行われます。

  1. QueryとDocument両方ともEmbedding(ベクトル)に変換する
  2. QueryとDocumentのコサイン類似度を計算する
  3. コサイン類似度が一番高いDocumentを返す
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]

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です。

Tip

llmを定義する時、一度生成するドキュメントの数を指定できます。例えばn4に指定すると、一度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()
tydi_df_sample.head(2)
query_id query positive_passages negative_passages
1041 1320 有価証券とはなんですか? 有価証券(ゆうかしょうけん)とは、伝統的には財産的価値のある私権を表章する証券で、その権利の... 有価証券届出書の提出日以降、当該有価証券届出書の効力が発生する以前において、有価証券届出書に...
670 862 浅草寺はいつ建設された 推古天皇36年(628年)、宮戸川(現・隅田川)で漁をしていた檜前浜成・竹成(ひのくまのはま... 1907年(明治40年)、昆虫学者名和靖は日露戦争の勝利記念に昆虫館を建設したいと考え、東京...

確認する内容としては以下の3つとします。

  1. MRR: Mean Reciprocal Rank、平均逆順位です。総合的にパフォーマンスを確認することができます。
  2. 正解数:上位1位は正解の数です。直感的にわかりやすいです。
  3. 検索にかかる時間: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,
)
rank_list = test(tydi_df_sample["query"].tolist(), vectorstore)
print(f"mrr: {get_mrr(rank_list):.3f}")
print(f"correct num: {get_correct_num(rank_list)}")
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
hyde_rank_list = test(tydi_df_sample["query"].tolist(), vectorstore)
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
hyde_with_title_rank_list = test(tydi_df_sample["query"].tolist(), vectorstore)
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秒