【langchain】BM25Retriever/TFIDFRetrieverを日本語に対応させる

# 概要
langchainのBM25Retrieverで日本語文書を扱う方法のメモです。TFIDFRetrieverもほぼ同じやり方のため、末尾でコードだけ記します。

langchainのBM25Retrieverはデフォルトではスペースで分かち書きをするため、日本語文書の検索に適していません。

BM25Retriverを初期化する際にpreprocess_funcに日本語の分かち書きが可能な関数を指定すれば解決します。

ngramで分かち書きをする例を以下に示します。

```Python
from langchain.retrievers import BM25Retriever

def generate_character_ngrams(text, i, j, binary=False):
    """
    文字列から指定した文字数のn-gramを生成する関数。

    :param text: 文字列データ
    :param i: n-gramの最小文字数
    :param j: n-gramの最大文字数
    :param binary: Trueの場合、重複を削除
    :return: n-gramのリスト
    """
    ngrams = []
    
    for n in range(i, j + 1):
        for k in range(len(text) - n + 1):
            ngram = text[k:k + n]
            ngrams.append(ngram)
    
    if binary:
        ngrams = list(set(ngrams))  # 重複を削除
    
    return ngrams

def preprocess_func(text):
    i, j = 1, 3
    if len(text) < i:
        return [text]
    return generate_character_ngrams(text, i, j, True)
texts = [
    "こんにちは、今日はいい天気ですね"
    , "私たちは卒業します"
    , "また会う日まで"
]

retriever = BM25Retriever.from_texts(texts, preprocess_func=preprocess_func)
retriever.get_relevant_documents("こんにちは")
```

```
[Document(page_content='こんにちは、今日はいい天気ですね'),
 Document(page_content='私たちは卒業します'),
 Document(page_content='また会う日まで')]
```
# 環境
langchainとrank-bm25をpipします。

```sh
pip install langchain
pip install rank-bm25
```

記事執筆時点でのライブラリバージョンは以下のとおりです。
```sh
% pip list | grep langchain      
langchain                 0.0.314
% pip list | grep rank-bm25 
rank-bm25                 0.2.2
```
# 背景
[公式のチュートリアル](https://python.langchain.com/docs/integrations/retrievers/bm25)に沿って、BM25Retriverでデフォルト設定のまま日本語文書の検索をしようとすると上手くいきません。

```Python
from langchain.retrievers import BM25Retriever
texts = [
    "こんにちは、今日はいい天気ですね"
    , "私たちは卒業します"
    , "また会う日まで"
]

retriever = BM25Retriever.from_texts(texts)
retriever.get_relevant_documents("こんにちは")
```

```
[Document(page_content='また会う日まで'),
 Document(page_content='私たちは卒業します'),
 Document(page_content='こんにちは、今日はいい天気ですね')]
```

上記では、「こんにちは」というクエリに対して、共通の部分文字列がある「こんにちは、今日はいい天気ですね」が1位になって欲しいのですが、3位になっており、失敗していることがわかります。

類似度スコアを確認してみます。

```Python
from langchain.retrievers.bm25 import default_preprocessing_func
scores = retriever.vectorizer.get_scores(default_preprocessing_func("こんにちは"))
for text, score in zip(texts, scores):
    print(text, score)
```

```
こんにちは、今日はいい天気ですね 0.0
私たちは卒業します 0.0
また会う日まで 0.0
```

全部0になっていました。

# 原因
[BM25Retrieverのソースコード](https://github.com/langchain-ai/langchain/blob/a50630277295c3884be8e839b04718d0e99b4ea4/libs/langchain/langchain/retrievers/bm25.py#L31)を読むと、初期化時にpreprocess_funcという引数を与えられることがわかります。

preprocess_funcにはCallable[[str], List[str]]という型、つまり、文字列を受け取って文字列のリストを返す関数を与えられます。preprocess_funcのデフォルト値は以下の、空白でsplitする処理であるため、空白を分かち書きの前提としない日本語などでは上手く機能しません。

```Python
def default_preprocessing_func(text: str) -> List[str]:
    return text.split()
```

# 解決方法
preprocess_funcに日本語の分かち書きに適した関数を与えれば解決します。

## ngram
例えば、文字列のngramを生成する関数を定義して与えてみます。

```Python
from langchain.retrievers import BM25Retriever
from typing import List

def generate_character_ngrams(text, i, j, binary=False):
    """
    文字列から指定した文字数のn-gramを生成する関数。

    :param text: 文字列データ
    :param i: n-gramの最小文字数
    :param j: n-gramの最大文字数
    :param binary: Trueの場合、重複を削除
    :return: n-gramのリスト
    """
    ngrams = []
    
    for n in range(i, j + 1):
        for k in range(len(text) - n + 1):
            ngram = text[k:k + n]
            ngrams.append(ngram)
    
    if binary:
        ngrams = list(set(ngrams))  # 重複を削除
    
    return ngrams

def preprocess_func(text: str) -> List[str]:
    i, j = 1, 3
    if len(text) < i:
        return [text]
    return generate_character_ngrams(text, i, j, True)
texts = [
    "こんにちは、今日はいい天気ですね"
    , "私たちは卒業します"
    , "また会う日まで"
]

retriever = BM25Retriever.from_texts(texts, preprocess_func=preprocess_func)
retriever.get_relevant_documents("こんにちは")
```

```
[Document(page_content='こんにちは、今日はいい天気ですね'),
 Document(page_content='私たちは卒業します'),
 Document(page_content='また会う日まで')]
```

ngramの文字数を指定できる汎用的な関数(generate_character_ngrams)を作り、preprocess_funcに要求される型に合うようにラップした関数を作り、初期化時に与えています。
結果は、妥当に見えます。

スコアを確認してみます。
 
```Python
scores = retriever.vectorizer.get_scores(preprocess_func("こんにちは"))
for text, score in zip(texts, scores):
    print(text, score)
```

```
こんにちは、今日はいい天気ですね 3.948115347334875
私たちは卒業します 0.3232424095747033
また会う日まで 0.0
```

よさそうです。

## 単語分かち書き
単語で分かち書きする処理もやってみます。
sudachiを使います。

```
pip install sudachipy
pip install sudachidict_full
```

```Python
from langchain.retrievers import BM25Retriever
from typing import List
from sudachipy import tokenizer
from sudachipy import dictionary


def generate_word_ngrams(text, i, j, binary=False):
    """
    文字列を単語に分割し、指定した文字数のn-gramを生成する関数。

    :param text: 文字列データ
    :param i: n-gramの最小文字数
    :param j: n-gramの最大文字数
    :param binary: Trueの場合、重複を削除
    :return: n-gramのリスト
    """

    tokenizer_obj = dictionary.Dictionary(dict="full").create()
    mode = tokenizer.Tokenizer.SplitMode.A
    tokens = tokenizer_obj.tokenize(text ,mode)
    words = [token.surface() for token in tokens]

    ngrams = []
    
    for n in range(i, j + 1):
        for k in range(len(words) - n + 1):
            ngram = tuple(words[k:k + n])
            ngrams.append(ngram)
    
    if binary:
        ngrams = list(set(ngrams))  # 重複を削除
    
    return ngrams

def preprocess_func(text: str) -> List[str]:
    return generate_word_ngrams(text,1, 1, True)
texts = [
    "こんにちは、今日はいい天気ですね"
    , "私たちは卒業します"
    , "また会う日まで"
]

retriever = BM25Retriever.from_texts(texts, preprocess_func=preprocess_func)
retriever.get_relevant_documents("こんにちは")
```

```
[Document(page_content='こんにちは、今日はいい天気ですね'),
 Document(page_content='また会う日まで'),
 Document(page_content='私たちは卒業します')]
```

「こんにちは、今日はいい天気ですね」が1位なのでよさそうです。
スコアを確認します。

```
scores = retriever.vectorizer.get_scores(preprocess_func("こんにちは"))
for text, score in zip(texts, scores):
    print(text, score)
```

```
こんにちは、今日はいい天気ですね 0.44419619457912235
私たちは卒業します 0.0
また会う日まで 0.0
```

スコアもよさそうです。

# TFIDFRetriever

TFIDFRetrieverの場合は、tfidf_params={"analyzer": preprocess_func}という指定によって、日本語の検索ができます。コードの一例だけ以下に示します。

```sh
pip install scikit-learn
```


```Python
from langchain.retrievers import TFIDFRetriever

def generate_character_ngrams(text, i, j, binary=False):
    """
    文字列から指定した文字数のn-gramを生成する関数。

    :param text: 文字列データ
    :param i: n-gramの最小文字数
    :param j: n-gramの最大文字数
    :param binary: Trueの場合、重複を削除
    :return: n-gramのリスト
    """
    ngrams = []
    
    for n in range(i, j + 1):
        for k in range(len(text) - n + 1):
            ngram = text[k:k + n]
            ngrams.append(ngram)
    
    if binary:
        ngrams = list(set(ngrams))  # 重複を削除
    
    return ngrams

def preprocess_func(text):
    i, j = 1, 3
    if len(text) < i:
        return [text]
    return generate_character_ngrams(text, i, j, True)
texts = [
    "こんにちは、今日はいい天気ですね"
    , "私たちは卒業します"
    , "また会う日まで"
]

retriever = TFIDFRetriever.from_texts(texts, tfidf_params={"analyzer":preprocess_func})
retriever.get_relevant_documents("こんにちは")

```

```
[Document(page_content='こんにちは、今日はいい天気ですね'),
 Document(page_content='また会う日まで'),
 Document(page_content='私たちは卒業します')]
```

```Python
from sklearn.metrics.pairwise import cosine_similarity

query_vec = retriever.vectorizer.transform(
        ["こんにちは"]
        )  # Ip -- (n_docs,x), Op -- (n_docs,n_Feats)
scores = cosine_similarity(retriever.tfidf_array, query_vec).reshape(
            (-1,)
        )  # Op -- (n_docs,1) -- Cosine Sim with each doc
for text, score in zip(texts, scores):
    print(text, score)
```

スコアを確認します。

```
こんにちは、今日はいい天気ですね 0.5150344814991464
私たちは卒業します 0.11429416477524268
また会う日まで 0.0
```

# おわりに
BM25RetrieverやTFIDFRetrieverなどキーワードベースのretrieverがデフォルトで日本語に適していない課題に対し、解決方法をまとめました。
公式が想定するやり方に沿っただけですが、チュートリアルには書いていなかったので、当初、少し困ってしまいました。どなたかの参考になれば幸いです。

# 更新履歴
- 2023/10/27: TfidfRetrieverのpreprocess_funcの指定方法が間違っていたためしゅうせいしまし