gensimを用いたBM25実装

# はじめに
　情報検索の手法として有名なTF-IDFを発展させた手法として知られているOkapi BM25(以降、BM25)
　定義式や式の解説などは調べればすぐ出てくるのですが、実装方法はあまり見かけなかったので、備忘録として記事にします。
　本記事は、gensimを用いたBM25の実装を目的とするものであり、数式の説明等は行いませんことはご了承ください。

# 概要
　BM25は、情報検索における順位付けの手法である。検索エンジンがクエリとの関連性に応じて、文書を順位付けするのに用いられるます。BM25の"BM"は、"Best Matching" の略です。
　ロンドン大学シティ校が1980年代から1990年代にかけて開発したオカピ情報検索システムに最初に実装されたため、 "Okapi BM25" と呼ばれるが、単に、この手法自体の名称であるBM25とも呼ばれています。

　BM25では、TF-IDFでネックになっていた「長い文書と短い文書が混在しているときに、短い文書の単語のTF値(1文書における単語の出現頻度)が高くなってしまう。」という問題を改善しました。

# 定義式
```math
Score\left( D,Q \right) = \sum_{k=1}^n IDF\left(q_{i} \right)・
\frac{f\left(q_{i}, D\right)・(k_{i}+1)}{f\left(q_{i}, D\right)+k_{1}・
(1-b+b・\frac{|D|}{avgdl})}
```

　ザックリ説明すると、単語の出現頻度・単語の希少性・文書の長さ考慮して、クエリに対する文書集合の類似度を評価します。そのため、単語の意味や文脈などは全く考慮した評価は出来ません。

# 開発環境
python 3.9.12
janome 0.4.2
gensim 3.8.3

　**gensimは、記事を書いたときの最新版(4.2.0)では、BM25はライブラリから除外しているので、3.8.3をインストールしてください。
　今回の記事は、ここが一番大事です。ここ以外は飾りであるといっても過言ではありません 笑**

# コード
　以下にgensimでのBM25の実装コードを記載します。
``` BM25.py
# -*- coding: utf-8 -*-

from gensim.summarization.bm25 import BM25
from janome.tokenizer import Tokenizer


class best_match:
    def __init__(self):
        self.t = Tokenizer()

    #前処理
    def pre_process(self, docs):
        self.docs = docs
        corpus = [self.wakachi(doc) for doc in self.docs]
        self.bm25_ = BM25(corpus)
    
    #クエリとの順位付け
    def ranking(self, query):
        wakachi_query = self.wakachi(query)
        self.scores = self.bm25_.get_scores(wakachi_query)

    #分かち書き
    def wakachi(self, doc):
        return list(self.t.tokenize(doc, wakati = True))

    #上位n件を抽出
    def select_docs(self, num):
        docs_dict = dict(zip(self.scores, self.docs))
        docs_dict = dict(sorted(docs_dict.items(), reverse = True))
        print("\n・検索結果")
        i = 0
        for key, value in docs_dict.items():
            print(round(key, 3), value)
            i += 1
            if i == num: break

if __name__ == "__main__":
    query = "インドカレー屋で提供されているラッシーは、とても美味しい。"
    docs = ["カレーよりもシチューが好きだ。",
            "ガンジス川を見るためにインドに来た。",
            "カレーが好きだ。中でも、インドカレーが一番好きだ。",
            "自宅で作ったラッシーも美味しい。",
            "欧風カレーとインドカレーは全くの別物だが、どちらも美味しい。",
            "インドカレーが好きだ。"]
    while True:
        try:
            num = int(input("検索数を自然数で入力してください:"))
            if num <= 0:
                print("0より大きな数字を入力してください。")
            elif num < len(docs):
                break
            else:
                print("文書数より多い数字が入力されています。")
        except Exception:
            print("数字以外のテキストが入力されています。")

    print("クエリ:", query)
    inst_BM = best_match()
    inst_BM.pre_process(docs)
    inst_BM.ranking(query)
    inst_BM.select_docs(num)
```

pre_process関数：コーパスの作成
ranking関数：クエリに対して、BM25にてコーパスの順位付け
selecl_docs関数：類似度の高い順に上位n件のスコアと文章を表示

# 実行結果
検索数を自然数で入力してください:3

クエリ:インドカレー屋で提供されているラッシーは、とても美味しい。

・検索結果
3.791 自宅で作ったラッシーも美味しい。
2.567 欧風カレーとインドカレーは全くの別物だが、どちらも美味しい。
1.197 カレーが好きだ。中でも、インドカレーが一番好きだ。

# 考察
　今回の文書集合では「ラッシー」「美味しい」の希少性が高く、「カレー」の出現頻度が高い文書が多いので、その辺りが評価の決め手になりました。
　インドカレーは、形態素解析を行うと「インド」「カレー」に分割されるため、インドカレーを1単語としたい場合は、janomeの形態素解析辞書とは別に自作の形態素解析辞書を追加する必要があります。
　また、文書を表す単語の中で不要な品詞(助詞、副詞など)の除外を行うことでノイズを除去することが可能です。
　そのあたりは、次の記事で書きたいと思います。

# 参考
[wikipedia](https://ja.wikipedia.org/wiki/Okapi_BM25 "Okapi BM25")
[ミエルカAI](https://mieruca-ai.com/ai/tf-idf_okapi-bm25/#mtoc_3 "【技術解説】単語の重要度を測る？TF-IDFとOkapi BM25の計算方法とは")