【langchain】TFIDFRetrieverのマージ

# 概要
異なるコーパスから作られた複数の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の量によっては時間がかかってしまいます。そこでこれを効率化する方法を考えます。

# 方針
[ソースコード](https://github.com/langchain-ai/langchain/blob/bed06a4f4ab802bedb3533021da920c05a736810/libs/langchain/langchain/retrievers/tfidf.py#L11)によればTFIDFRetrieverのマージすべき属性は以下です。

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

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

# 実装

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

ライブラリをimportします。

```Python
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する関数を作ります（[参考](https://qiita.com/shimajiroxyz/items/93df0a7673d44c7b8102))

```Python
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をマージする関数を作ります。

```Python
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
```

テストしてみます。

```Python

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])
```

```Python
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のマージの別バージョンの方法のコードです。
動くかどうかは試していません。

## 形態素解析をやり直す
時間がかかってもよければ、これを使うのもありです。

```Python
# 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計算もやり直す
厳密さは上記と変わらず、単に時間がよりかかるので、あまり使う意味はないです。ただ実装がシンプルなのでメンテナンスはしやすいです。

```Python
# 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
```