LlamaIndexでBM25Retrieverを試す
てBM25を使ったretrieverをLlamaIndexで試してみる。
パッケージインストール
!pip install -U llama-index llama-index-retrievers-bm25
BM25の場合はトークナイザーが重要になる。デフォルトはこんな感じ。
これを日本語で実行してみるとこうなる。
!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とかも考えたほうがいいと思うけど、とりあえず。
データを用意する。以下を使う。
!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: 夜間・休日窓口の場合、母子手帳の証明や届書の受理証明書などの発行、 子どもに関する手当・助成の受付は
ランキングとスコアが同じなので、想定通りに動いている様子。
RAG Fusionを行うFusionRetrieverのドキュメントで、ベクトルリトリーバとBM25リトリーバを両方使う場合だとこんな書き方になっている。
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標準のオンメモリベースのベクトルインデックスを使っているけど、外部のベクトルデータベースの場合でもできるんだろうか?
ちなLangChainの場合も似たような感じっぽい。
日本語はいろいろ大変だなぁ。。。
トークナイザーを自分で実装してBM25に渡す方法は、以下のコミットで使えなくなった。
https://github.com/run-llama/llama_index/commit/3f83323a3f006fd13847bb75013c454a820cd37cbm25sライブラリを使うのにあわせてtokenizerオプションが削除され、bm25sとpystemmerを渡すようになっているのだが、ともに日本語に対応していないと思う。。。
古いバージョン(llama-index-retrievers-bm25==0.1.5、LlamaIndex本体のバージョンタグはv0.10.52)を使うしかないのかな?ちょっとBM25s側で日本語を組み込めないかを確認してみる。
ということで、BM25Sを試してみた。
一応日本語もできなくはないんだけど、日本語のトークナイザーは自分で実装する必要がある。
で、これをLlamaIndexの最新のBM25Retrieverに組み込みたいのだけど、これははなかなか厳しそう。というのは以下。
これなぁ、、、BM25Sのtokenize
メソッドは日本語では使えないので、自分で実装しないといけないのに対して、BM25Retrieverが直接tokenize
を呼んでたら、何も出来なくない?BM25S側がインタフェースとしてトークナイザーを渡せるようにはなっていないんだし。
なんていうか今の組み合わせは絶望的なんだよなぁ・・・・ でもできなくはないんだけどインデックス作成プロセスがLlamaIndex外になる気がするんだよなぁ・・・・
やれる方法としてはBM25Retrieverのサブクラスを作るしか無いんじゃないかなぁ・・・もしくは一番上に書いた通り古いバージョンのBM25Retrieverのパッケージを使うかしかなさそう。
古いパッケージを使うなら以下
!pip install -U llama-index "llama-index-retrievers-bm25==0.1.5"