LoginSignup
0
0

【langchain】TFIDFRetrieverのマージ

Posted at

概要

異なるコーパスから作られた複数のTFIDFRetrieverを1つにする方法を検討しました。

背景

TFIDFRetrieverはlangchainでキーワード検索をするときの有用な選択肢の一つです。
開発途中で検索対象の文書を追加する場合など、TFIDFRetrieverの更新やマージをしたくなることがあります。

FAISSRetrieverなどDenseな埋め込みに基づくRetrieverの場合はdocstoreとembeddingをただ拡張すればよいのですが、TFIDFなどSparseなRetrieverの場合、単語のIDやIDF値がコーパスごとに決定されるため、単純にマージはできません。そこで方法を考えました。

(注:キーワード検索用のSparseなRetrieverとしては、BM25Retrieverのほうがよりメジャーなのですが、BM25Retrieverは内部で使用されているrank_bm25の扱いに習熟できていないため、今回はTFIDFRetrieverを対象とします。)

一番シンプルなのは、複数のデータセットをマージして、TFIDFRetrieverを作り直すことです。
例えばdataset1とdataset2でそれぞれ作ったTFIDFRetrieverをマージしたければ、dataset1+dataset2を作ってTFIDFRetriever.from_textsなどを再度作れば良いです。

ただこの方法は形態素解析やIDFの計算を全てやり直すことになり、datasetの量によっては時間がかかってしまいます。そこでこれを効率化する方法を考えます。

方針

ソースコードによればTFIDFRetrieverのマージすべき属性は以下です。

  • docs: List[Document]
  • tfidf_array: scipy.sparse._csr.csr_matrix (docsをvectorizerでtransformした行列)
  • vectorizer: TfidfVectorizer

それぞれ以下のようにマージできます。

  • docsのリストをextendするだけです。
  • vectorizer(TfidfVectorizer)のマージ方法は別の記事で検討しました。idfとデータセット長から各単語の出現回数を計算することでマージ後のidfを高速に計算できます。
  • tfidf_arrayはいくつかの方向性があります。形態素解析済みのデータセットがあれば、マージ後のvectorizerでtransformすることで作れます。形態素解析済みのデータセットがなければ、もとのtfidf_arrayをinverse_transformして得ることもできます。ただし単語の重複がのぞかれるため誤差があります。それが許容できない場合は、docsを形態素解析する必要があります。

実装

tfidf_arrayのマージはinverse_transformを使うパターンで実装してみます。

ライブラリをimportします。

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from langchain.retrievers import TFIDFRetriever
from typing import List, Dict, Any, Tuple, Optional
import MeCab

vectorizerをmergeする関数を作ります(参考)

def merge_tfidf_vectorizers(vectorizers, datasets, forced_tfidf_params = None)->TfidfVectorizer:
    """
    複数のTfidfVectorizerインスタンスをマージする関数

    :param vectorizers: TfidfVectorizerインスタンスのリスト
    :param datasets: 各TfidfVectorizerに対応するデータセットのリスト
    :param tfidf_params: TfidfVectorizerのパラメータ
    :return: 統合された新しいTfidfVectorizerインスタンス
    """
    # IDF値から文書頻度を逆算する関数
    def idf_to_doc_freq(idf, n_docs):
        df = (n_docs + 1) / np.exp(idf - 1) - 1
        return np.round(df).astype(int)

    
    # 語彙と文書頻度を統合する
    combined_vocabulary = set()
    combined_df = {}
    n_docs_combined = 0

    for vectorizer, dataset in zip(vectorizers, datasets):
        df = idf_to_doc_freq(vectorizer.idf_, len(dataset))
        vocab_df = dict(zip(vectorizer.get_feature_names_out(), df))

        combined_vocabulary.update(vectorizer.get_feature_names_out())
        for word, freq in vocab_df.items():
            combined_df[word] = combined_df.get(word, 0) + freq

        n_docs_combined += len(dataset)

    combined_vocabulary = sorted(combined_vocabulary)

    # 新しいIDF値を計算
    combined_idf = np.log((n_docs_combined + 1) / (np.array(list(combined_df.values())) + 1)) + 1

    # 0番目の要素のparamをコピー。vectorizers同士のパラメータは全て等しい前提
    tfidf_params = vectorizers[0].get_params()
    tfidf_params["vocabulary"] = combined_vocabulary # vocabularyだけ更新
    forced_tfidf_params = forced_tfidf_params or {}
    tfidf_params.update(forced_tfidf_params) # 強制的に上書きしたいパラメータがあれば上書き

    # 新しいTfidfVectorizerインスタンスを作成してIDF値を設定
    tfidf_combined = TfidfVectorizer(**tfidf_params)
    tfidf_combined.fit(["dummy"])  # ダミーデータで初期化
    tfidf_combined._tfidf.idf_ = combined_idf

    return tfidf_combined

TFIDFRetrieverをマージする関数を作ります。

def merge_tfidf_retrievers(retrievers: List[TFIDFRetriever]):
    """
    複数のTFIDFRetrieverインスタンスをマージする関数

    :param retrievers: TFIDFRetrieverインスタンスのリスト
    :return: 統合された新しいTFIDFRetrieverインスタンス
    """
    # vectorizerのマージ
    vectorizers = [retriever.vectorizer for retriever in retrievers]
    merged_vectorizer = merge_tfidf_vectorizers(vectorizers, [retriever.docs for retriever in retrievers])
    # tfidf_arrayのマージ
    merged_tokenized_docs = []
    for retriever in retrievers:
        tokenized_docs = retriever.vectorizer.inverse_transform(retriever.tfidf_array)
        merged_tokenized_docs.extend(tokenized_docs)
    params = merged_vectorizer.get_params()
    merged_vectorizer.set_params(analyzer=lambda x: x) # transformの時間節約のため一時的にanalyzerを無効化
    merged_tfidf_array = merged_vectorizer.transform(merged_tokenized_docs)
    merged_vectorizer.set_params(analyzer=params["analyzer"]) # analyzerを元に戻す
    # docsのマージ
    merged_docs = [doc for retriever in retrievers for doc in retriever.docs]
    # TFIDFRetrieverインスタンスの作成
    merged_retriever = TFIDFRetriever(docs=merged_docs, vectorizer=merged_vectorizer, tfidf_array=merged_tfidf_array
                                      , k=retrievers[0].k) # kはretrievers間で全て同じと仮定

    return merged_retriever

テストしてみます。


dataset1 = ["文書1からのテキスト", "文書2からのテキスト"]
dataset2 = ["文書3からのテキスト", "文書4からのテキスト"]

# 形態素解析器の初期化(MeCab)
mecab = MeCab.Tagger("-Owakati")

# 形態素解析を行う関数
def tokenize(text):
    return mecab.parse(text).split()

retriever1 = TFIDFRetriever.from_texts(dataset1, tfidf_params={"analyzer": tokenize})
retriever2 = TFIDFRetriever.from_texts(dataset2, tfidf_params={"analyzer": tokenize})

merged_retriever = merge_tfidf_retrievers([retriever1, retriever2])
print("idf")
print("retriever1")
print(retriever1.vectorizer.get_feature_names_out())
print(retriever1.vectorizer.idf_)
print("retriever2")
print(retriever2.vectorizer.get_feature_names_out())
print(retriever2.vectorizer.idf_)
print("merged_retriever")
print(merged_retriever.vectorizer.get_feature_names_out())
print(merged_retriever.vectorizer.idf_)
print("")
print("検索テスト")
print(retriever1.get_relevant_documents("文書1"))
print(retriever2.get_relevant_documents("文書3"))
print(merged_retriever.get_relevant_documents("文書1と文書3"))
idf
retriever1
['1' '2' 'から' 'の' 'テキスト' '文書']
[1.40546511 1.40546511 1.         1.         1.         1.        ]
retriever2
['3' '4' 'から' 'の' 'テキスト' '文書']
[1.40546511 1.40546511 1.         1.         1.         1.        ]
merged_retriever
['1' '2' '3' '4' 'から' 'の' 'テキスト' '文書']
[1.91629073 1.91629073 1.         1.         1.         1.
 1.91629073 1.91629073]

検索テスト
[Document(page_content='文書1からのテキスト'), Document(page_content='文書2からのテキスト')]
[Document(page_content='文書3からのテキスト'), Document(page_content='文書4からのテキスト')]
[Document(page_content='文書1からのテキスト'), Document(page_content='文書3からのテキスト'), Document(page_content='文書4からのテキスト'), Document(page_content='文書2からのテキスト')]

厳密には検証していませんが、なんとなくうまくいっている気がします。

おまけ

tfidf_arrayのマージの別バージョンの方法のコードです。
動くかどうかは試していません。

形態素解析をやり直す

時間がかかってもよければ、これを使うのもありです。

# docsからtfidf_arrayを再計算
def merge_tfidf_retrievers2(retrievers: List[TFIDFRetriever]):
    """
    複数のTFIDFRetrieverインスタンスをマージする関数

    :param retrievers: TFIDFRetrieverインスタンスのリスト
    :return: 統合された新しいTFIDFRetrieverインスタンス
    """
    # vectorizerのマージ
    vectorizers = [retriever.vectorizer for retriever in retrievers]
    merged_vectorizer = merge_tfidf_vectorizers(vectorizers, [retriever.docs for retriever in retrievers])

    # docsのマージ
    merged_docs = [doc for retriever in retrievers for doc in retriever.docs]
    # tfidf_arrayのマージ
    merged_tfidf_array = merged_vectorizer.transform([doc.page_content for doc in merged_docs])
    # インスタンスの初期化
    merged_retriever = TFIDFRetriever(docs=merged_docs, vectorizer=merged_vectorizer, tfidf_array=merged_tfidf_array
                                      , k=retrievers[0].k) # kはretrievers間で全て同じと仮定

    return merged_retriever

形態素解析もIDF計算もやり直す

厳密さは上記と変わらず、単に時間がよりかかるので、あまり使う意味はないです。ただ実装がシンプルなのでメンテナンスはしやすいです。

# docsからtfidf_arrayを再計算
def merge_tfidf_retrievers3(retrievers: List[TFIDFRetriever], tokenized_docs: List[List[str]]):
    """
    複数のTFIDFRetrieverインスタンスをマージする関数

    :param retrievers: TFIDFRetrieverインスタンスのリスト
    :return: 統合された新しいTFIDFRetrieverインスタンス
    """
    # vectorizerのマージ
    vectorizers = [retriever.vectorizer for retriever in retrievers]
    merged_vectorizer = merge_tfidf_vectorizers(vectorizers, [retriever.docs for retriever in retrievers])
    # docsのマージ
    merged_docs = [doc for retriever in retrievers for doc in retriever.docs]
    # tfidf_arrayの計算とインスタンスの初期化
    merged_retriever = TFIDFRetriever.from_documents(merged_docs, tfidf_params=retrievers[0].vectorizer.get_params()
                                                     , k = retrievers[0].k) # kはretrievers間で全て同じと仮定

    return merged_retriever
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0