Closed5

LlamaIndexでBM25Retrieverを試す

kun432kun432

パッケージインストール

!pip install -U llama-index llama-index-retrievers-bm25

BM25の場合はトークナイザーが重要になる。デフォルトはこんな感じ。

https://github.com/run-llama/llama_index/blob/bb91f5219b0fba04e91544a0eb547e6d0cb4441d/llama-index-legacy/llama_index/legacy/retrievers/bm25_retriever.py#L17-L42

これを日本語で実行してみるとこうなる。

!pip install nltk
from nltk.stem import PorterStemmer
from llama_index.legacy.indices.keyword_table.utils import simple_extract_keywords


def tokenize_remove_stopwords(text: str) -> List[str]:
    # lowercase and stem words
    text = text.lower()
    stemmer = PorterStemmer()
    words = list(simple_extract_keywords(text))
    return [stemmer.stem(word) for word in words]


tokenize_remove_stopwords("母子手帳を受け取りたいのですが、手続きを教えてください。")

['母子手帳を受け取りたいのですが', '手続きを教えてください']

全然分かち書きされていない。ということで、日本語でやる場合はトークナイザーを用意してやる必要がある。今回はSudachiを使ってトークナイザーを定義した。

!pip install sudachipy sudachidict-core sudachidict-core sudachidict-small sudachidict-full
from typing import List
from sudachipy import Dictionary, SplitMode
import requests

# stopwordsの設定。SlothLibのものを使用して、いくつか追加した
url = "http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt"
stopwords = []
for line in requests.get(url).text.split("\r\n"):
    if len(line) > 1:
        stopwords.append(line)
stopwords.extend(
    [
        "ください",
        " ",
        " ",
    ]
)

# キーワードだけ抽出したいので雑に品詞でフィルタした
pos_list = ["名詞","動詞","形容詞"]

sudachi_tokenizer = Dictionary(dict="full").create()
mode = SplitMode.C

# トークナイザーの定義
def ja_tokenizer(text: str) -> List[str]:
    tokens = sudachi_tokenizer.tokenize(text, mode)
    token_list = []
    for token in tokens:
        if token.surface() in stopwords:
            continue
        if token.part_of_speech()[0] not in pos_list:
            continue
        token_list.append(token.surface())
    return token_list

試してみる。

ja_tokenizer("母子手帳を受け取りたいのですが、手続きを教えてください。")

['母子手帳', '受け取り', '手続き', '教え']

Normalized FormとかDictionary Formとかも考えたほうがいいと思うけど、とりあえず。

データを用意する。以下を使う。

https://linecorp.com/ja/csr/newslist/ja/2020/260

!wget https://d.line-scdn.net/stf/linecorp/ja/csr/dataset_.zip
!unzip dataset_.zip
import pandas as pd

df = pd.read_excel("dataset_.xlsx")
df.rename(columns={
    'サンプル 問い合わせ文': 'Question',
    'サンプル 応答文': 'Answer',
    'カテゴリ1': 'Category',
}, inplace=True)
df.drop(columns=["ID", "カテゴリ2","出典","<参考>UMカテゴリタグ","<参考>UMサービスメニュー\n(標準的な行政サービス名称)"], inplace=True)
df.rename(columns={"サンプルID":"ID"}, inplace=True)
df

上記よりLlamaIndexのテキストノードを作成。

from llama_index.core.schema import TextNode, NodeRelationship, RelatedNodeInfo

nodes = []
for idx, row in df.iterrows():
    id = row["ID"]
    text = row["Answer"].replace("\n\n","\n")
    node = TextNode(text=text, id_=id)
    nodes.append(node)

で、BM25Retriever。通常LlamaIndexでベクトルインデックス何かを作る場合だと、

  • インデックス作成
  • インデックスにas_retriever()でリトリーバーを生やす

という流れになるのだけど、BM25Retrieverは単純にテキストノードを読み込むだけみたい。

で、ここでtokenizerに先ほど作成したトークナイザーを指定する。

from llama_index.retrievers.bm25 import BM25Retriever

bm25_retriever = BM25Retriever(nodes=nodes, similarity_top_k=5, tokenizer=ja_tokenizer)

試してみる。

results = bm25_retriever.retrieve("母子手帳を受け取りたいのですが、手続きを教えてください。")

for r in results:
    print("Score:",r.get_score())
    print("ID:", r.id_)
    print("Contents:", r.get_content()[:50])
    print("******")

Score: 10.138956255349715
ID: 2
Contents: 母子手帳は、○○市役所本庁舎△△階××課窓口、◎◎出張所、………(その他の受け取り場所を適宜記載)…


Score: 8.174780299250818
ID: 231
Contents: 住民票の写しについては、(請求・受け取りができる場所)で請求・受け取りができます。
▼詳しくはこち


Score: 7.8101200469768415
ID: 36
Contents: 産前は母子手帳以外の手続きは特にありません。
産後に、出生の届出や出生通知書の提出、(自治体が行う出


Score: 6.734619010857896
ID: 528
Contents: ごみの分別について、よく質問される品目の一覧はこちらをご覧ください。
(自治体HP内関連ページのUR


Score: 6.63101908955234
ID: 252
Contents: 夜間・休日窓口の場合、母子手帳の証明や届書の受理証明書などの発行、 子どもに関する手当・助成の受付は


入力クエリに対してもトークナイザーちゃんと動いているのかわからないので確認。

results = bm25_retriever.retrieve("母子手帳 受け取り 手続き 教えて")

for r in results:
    print("Score:",r.get_score())
    print("ID:", r.id_)
    print("Contents:", r.get_content()[:50])
    print("******")

Score: 10.138956255349715
ID: 2
Contents: 母子手帳は、○○市役所本庁舎△△階××課窓口、◎◎出張所、………(その他の受け取り場所を適宜記載)…


Score: 8.174780299250818
ID: 231
Contents: 住民票の写しについては、(請求・受け取りができる場所)で請求・受け取りができます。
▼詳しくはこち


Score: 7.8101200469768415
ID: 36
Contents: 産前は母子手帳以外の手続きは特にありません。
産後に、出生の届出や出生通知書の提出、(自治体が行う出


Score: 6.734619010857896
ID: 528
Contents: ごみの分別について、よく質問される品目の一覧はこちらをご覧ください。
(自治体HP内関連ページのUR


Score: 6.63101908955234
ID: 252
Contents: 夜間・休日窓口の場合、母子手帳の証明や届書の受理証明書などの発行、 子どもに関する手当・助成の受付は


ランキングとスコアが同じなので、想定通りに動いている様子。

kun432kun432

RAG Fusionを行うFusionRetrieverのドキュメントで、ベクトルリトリーバとBM25リトリーバを両方使う場合だとこんな書き方になっている。

https://docs.llamaindex.ai/en/stable/examples/low_level/fusion_retriever/

from llama_index.core import VectorStoreIndex
from llama_index.core.node_parser import SentenceSplitter
from llama_index.retrievers.bm25 import BM25Retriever

splitter = SentenceSplitter(chunk_size=1024)
index = VectorStoreIndex.from_documents(
    documents, transformations=[splitter], embed_model=embed_model
)

vector_retriever = index.as_retriever(similarity_top_k=2)

bm25_retriever = BM25Retriever.from_defaults(
    docstore=index.docstore, similarity_top_k=2
)

なるほど、docstoreを参照すればベクトルインデックスのテキストノードにアクセスできるっぽい。

ここまでのコードはLlamaIndex標準のオンメモリベースのベクトルインデックスを使っているけど、外部のベクトルデータベースの場合でもできるんだろうか?

kun432kun432

トークナイザーを自分で実装してBM25に渡す方法は、以下のコミットで使えなくなった。
https://github.com/run-llama/llama_index/commit/3f83323a3f006fd13847bb75013c454a820cd37c

bm25sライブラリを使うのにあわせてtokenizerオプションが削除され、bm25sとpystemmerを渡すようになっているのだが、ともに日本語に対応していないと思う。。。

古いバージョン(llama-index-retrievers-bm25==0.1.5、LlamaIndex本体のバージョンタグはv0.10.52)を使うしかないのかな?ちょっとBM25s側で日本語を組み込めないかを確認してみる。

ということで、BM25Sを試してみた。

https://zenn.dev/kun432/scraps/5ae46c49a92bcb

一応日本語もできなくはないんだけど、日本語のトークナイザーは自分で実装する必要がある。

で、これをLlamaIndexの最新のBM25Retrieverに組み込みたいのだけど、これははなかなか厳しそう。というのは以下。

https://github.com/run-llama/llama_index/blob/v0.10.58/llama-index-integrations/retrievers/llama-index-retrievers-bm25/llama_index/retrievers/bm25/base.py#L29-L51

https://github.com/run-llama/llama_index/blob/v0.10.58/llama-index-integrations/retrievers/llama-index-retrievers-bm25/llama_index/retrievers/bm25/base.py#L68-L84

これなぁ、、、BM25Sのtokenizeメソッドは日本語では使えないので、自分で実装しないといけないのに対して、BM25Retrieverが直接tokenizeを呼んでたら、何も出来なくない?BM25S側がインタフェースとしてトークナイザーを渡せるようにはなっていないんだし。

なんていうか今の組み合わせは絶望的なんだよなぁ・・・・ でもできなくはないんだけどインデックス作成プロセスがLlamaIndex外になる気がするんだよなぁ・・・・

やれる方法としてはBM25Retrieverのサブクラスを作るしか無いんじゃないかなぁ・・・もしくは一番上に書いた通り古いバージョンのBM25Retrieverのパッケージを使うかしかなさそう。

古いパッケージを使うなら以下

!pip install -U llama-index "llama-index-retrievers-bm25==0.1.5"
このスクラップは6ヶ月前にクローズされました