Tutorial (Japanese BM25 and Document Search)
Implement BM25 for Japanese text with nagisa
This tutorial provides an example of implementing BM25 for Japanese text
by using nagisa for tokenization and nagisa.stopwords for removing stopwords.
You extract keywords from news articles with BM25,
and build a simple Japanese document search engine.
Finally, you use nagisa with BM25S (BM25-Sparse),
a fast BM25 library.
Install python libraries
Before we get started, please run the following commands to install the libraries used in this tutorial.
pip install nagisa
pip install tqdm
pip install bm25s
Note that bm25s requires Python 3.8 or later.
How document search works
A document search engine returns the documents relevant to a query in order of relevance. It splits documents and queries into words (terms), builds an inverted index that maps each term to the documents containing it, computes a score for each document that contains the query terms, and returns the top-k documents with the highest scores. Japanese text has no spaces between words, so the first step needs a tokenizer such as nagisa. BM25 is a standard function for computing this score.
BM25
BM25 gives a high score to a document that contains the query terms many times, especially the terms that are rare in the corpus. The score of a document \(d\) for a query \(q\) is
where \(\mathrm{tf}(t, d)\) is the frequency of the term \(t\) in the document \(d\), \(\mathrm{df}(t)\) is the number of documents containing \(t\), \(N\) is the number of documents, \(|d|\) is the length (the number of terms) of \(d\) and \(\mathrm{avgdl}\) is the average document length.
\(k_1\) (usually 1.2-2.0) controls how quickly the score saturates as the term frequency increases.
\(b\) (usually 0.75) controls the normalization by the document length.
This is the variant used by Lucene and BM25S. The original Okapi BM25 also multiplies each summand by \((k_1 + 1)\), which does not change the ranking, and its IDF can become negative.
Download the dataset
This tutorial uses the Livedoor News Corpus,
which contains 7,367 Japanese news articles in 9 categories.
The corpus (~9 MB) is downloaded automatically into the ldcc/ directory on the first run.
Each document is the title and the body of an article.
Tokenize Japanese text with nagisa
split_words() keeps the content words (nouns, verbs, adjectives, etc.) using nagisa.extract().
nagisa works on a sentence, so the text is tokenized line by line.
The words are lowercased so that a query “iphone” matches “iPhone”.
tokenize() then removes the stopwords in nagisa.stopwords.
def split_words(text):
# nagisa works on a sentence, so tokenize the text line by line.
words = []
for line in text.splitlines():
tokens = nagisa.extract(line, extract_postags=TARGET_POSTAGS)
words += [word.lower() for word in tokens.words]
return words
def tokenize(text):
return [word for word in split_words(text) if word not in nagisa.stopwords]
Particles, auxiliary verbs and symbols are removed based on their POS-tags, and “し” is removed as a stopword.
print(nagisa.tagging("iPhoneで使える便利なアプリを紹介します。"))
#=> iPhone/名詞 で/助詞 使える/動詞 便利/名詞 な/助動詞 アプリ/名詞 を/助詞 紹介/名詞 し/動詞 ます/助動詞 。/補助記号
print(tokenize("iPhoneで使える便利なアプリを紹介します。"))
#=> ['iphone', '使える', '便利', 'アプリ', '紹介']
Implement BM25
The BM25 class builds an inverted index (term -> {doc_id: term frequency})
and the IDF of each term in fit().
term_weight() computes the BM25 weight of a term in a document, which is the summand of the formula above.
class BM25:
def __init__(self, k1=1.5, b=0.75):
self.k1 = k1
self.b = b
def fit(self, corpus_tokens):
self.num_docs = len(corpus_tokens)
self.doc_lens = [len(tokens) for tokens in corpus_tokens]
self.avgdl = sum(self.doc_lens) / self.num_docs
# Inverted index: term -> {doc_id: term frequency}
self.index = {}
for doc_id, tokens in enumerate(corpus_tokens):
for term, tf in Counter(tokens).items():
self.index.setdefault(term, {})[doc_id] = tf
# Inverse document frequency of each term
self.idf = {}
for term, postings in self.index.items():
df = len(postings)
self.idf[term] = math.log(1 + (self.num_docs - df + 0.5) / (df + 0.5))
return self
def term_weight(self, term, doc_id):
tf = self.index.get(term, {}).get(doc_id, 0)
if tf == 0:
return 0.0
norm = self.k1 * (1 - self.b + self.b * self.doc_lens[doc_id] / self.avgdl)
return self.idf[term] * tf / (tf + norm)
def search(self, query_tokens, k=5):
# Only documents that contain a query term get a score.
scores = Counter()
for term in query_tokens:
for doc_id in self.index.get(term, {}):
scores[doc_id] += self.term_weight(term, doc_id)
return scores.most_common(k)
def keywords(self, doc_tokens, doc_id, k=10):
weights = {term: self.term_weight(term, doc_id) for term in dict.fromkeys(doc_tokens)}
# Sort by weight, and by term for ties to make the order reproducible.
return sorted(weights.items(), key=lambda x: (-x[1], x[0]))[:k]
keywords() ranks the terms of a document by their BM25 weights.
A term gets a high weight when it appears frequently in the document but rarely in the other documents,
so the top terms characterize the document.
search() adds up the weights of the query terms for each document.
Only the documents that contain a query term are scored,
so a query without any known term returns an empty list.
Run the script
Run the following command to tokenize the corpus, extract keywords and search documents. Tokenizing all articles takes several minutes.
python tutorial_bm25.py
1import io
2import math
3import os
4import tarfile
5import urllib.request
6from collections import Counter
7
8import nagisa
9from tqdm import tqdm
10
11CORPUS_URL = "https://www.rondhuit.com/download/ldcc-20140209.tar.gz"
12CORPUS_DIR = "ldcc"
13
14# Content words used as index terms
15TARGET_POSTAGS = ["名詞", "動詞", "形容詞", "形状詞", "英単語"]
16
17
18def download_corpus():
19 if os.path.isdir(CORPUS_DIR):
20 return
21
22 with urllib.request.urlopen(CORPUS_URL, timeout=120) as resp:
23 data = resp.read()
24
25 with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tar:
26 # The filter argument is not available in older Python versions.
27 if hasattr(tarfile, "data_filter"):
28 tar.extractall(path=".", filter="data")
29 else:
30 tar.extractall(path=".")
31 os.rename("text", CORPUS_DIR)
32
33
34def load_corpus():
35 titles = []
36 texts = []
37 for category in sorted(os.listdir(CORPUS_DIR)):
38 cat_path = os.path.join(CORPUS_DIR, category)
39 if not os.path.isdir(cat_path):
40 continue
41 for fname in sorted(os.listdir(cat_path)):
42 if not fname.endswith(".txt") or fname.startswith("LICENSE"):
43 continue
44 with open(os.path.join(cat_path, fname), encoding="utf-8") as f:
45 lines = f.read().splitlines()
46
47 # line 1: URL, line 2: date, line 3: title, line 4-: body
48 title = lines[2]
49 body = "\n".join(lines[3:])
50 titles.append(title)
51 texts.append(title+"\n"+body)
52 return titles, texts
53
54
55def split_words(text):
56 # nagisa works on a sentence, so tokenize the text line by line.
57 words = []
58 for line in text.splitlines():
59 tokens = nagisa.extract(line, extract_postags=TARGET_POSTAGS)
60 words += [word.lower() for word in tokens.words]
61 return words
62
63
64def tokenize(text):
65 return [word for word in split_words(text) if word not in nagisa.stopwords]
66
67
68class BM25:
69
70 def __init__(self, k1=1.5, b=0.75):
71 self.k1 = k1
72 self.b = b
73
74 def fit(self, corpus_tokens):
75 self.num_docs = len(corpus_tokens)
76 self.doc_lens = [len(tokens) for tokens in corpus_tokens]
77 self.avgdl = sum(self.doc_lens) / self.num_docs
78
79 # Inverted index: term -> {doc_id: term frequency}
80 self.index = {}
81 for doc_id, tokens in enumerate(corpus_tokens):
82 for term, tf in Counter(tokens).items():
83 self.index.setdefault(term, {})[doc_id] = tf
84
85 # Inverse document frequency of each term
86 self.idf = {}
87 for term, postings in self.index.items():
88 df = len(postings)
89 self.idf[term] = math.log(1 + (self.num_docs - df + 0.5) / (df + 0.5))
90 return self
91
92 def term_weight(self, term, doc_id):
93 tf = self.index.get(term, {}).get(doc_id, 0)
94 if tf == 0:
95 return 0.0
96 norm = self.k1 * (1 - self.b + self.b * self.doc_lens[doc_id] / self.avgdl)
97 return self.idf[term] * tf / (tf + norm)
98
99 def search(self, query_tokens, k=5):
100 # Only documents that contain a query term get a score.
101 scores = Counter()
102 for term in query_tokens:
103 for doc_id in self.index.get(term, {}):
104 scores[doc_id] += self.term_weight(term, doc_id)
105 return scores.most_common(k)
106
107 def keywords(self, doc_tokens, doc_id, k=10):
108 weights = {term: self.term_weight(term, doc_id) for term in dict.fromkeys(doc_tokens)}
109 # Sort by weight, and by term for ties to make the order reproducible.
110 return sorted(weights.items(), key=lambda x: (-x[1], x[0]))[:k]
111
112
113def main():
114 download_corpus()
115 titles, texts = load_corpus()
116 corpus_tokens = [tokenize(text) for text in tqdm(texts)]
117
118 bm25 = BM25().fit(corpus_tokens)
119 print("documents: {}, vocabulary: {}".format(bm25.num_docs, len(bm25.index)))
120
121 # Keyword extraction
122 for doc_id in [1100, 4058, 6233]:
123 print("\n[{}] {}".format(doc_id, titles[doc_id]))
124 keywords = bm25.keywords(corpus_tokens[doc_id], doc_id, k=10)
125 print(" "+", ".join(term for term, weight in keywords))
126
127 # Document search
128 for query in ["iPhoneの便利なアプリ", "サッカー日本代表の試合"]:
129 print("\nquery: {}".format(query))
130 for doc_id, score in bm25.search(tokenize(query), k=5):
131 print(" {:.2f} {}".format(score, titles[doc_id]))
132
133
134if __name__ == "__main__":
135 main()
This is the output of tutorial_bm25.py.
documents: 7367, vocabulary: 68012
[1100] スマホでカーナビを実現!純正ドリンクホルダーがスマホホルダーに早変わり【イケショップのレア物】
ホルダー, holder, ボールジョイント, ドリンク, car, かぶせる, mount, 両側, ミリ, 固定
[4058] 大人にふさわしいビターな味わい ショコラケーキで聖夜を飾る
引き渡し, ビスキュイ, ショコラ, ビター, ヘネシー, ケーキ, ガナッシュ, チョコレート, tel, 濃厚
[6233] 香川真司、メッシを語る「分かっていても止められない」
メッシ, シャビ, リオネル, バルセロナ, イニエスタ, マドリード, 切り返し, トリオ, ミラン, レアル
query: iPhoneの便利なアプリ
5.55 ようやくiPhoneでもChromeが使えるように!【iPhoneでチャンスを掴め】
5.31 もっさりiPhoneとはさよなら!ワンタップでサクサクにできる必須アプリ「もっさり解消 for iPhone」【iPhoneアプリ】
5.09 面倒な画面の明るさ設定はiPhoneを傾けるだけで好みの明るさに!「Tilt de Brightness(明るさ調整)」【iPhoneアプリ】
5.02 二画面の便利さを450円で!「iDisplay」でiPadをパソコンのセカンドディスプレイ化【iPhoneアプリ】【iPadアプリ】
5.01 女子だらけの公開イベントに潜入!iPhone女子部がKDDIデザイニングスタジオで「公開リアル部室」を開催【レポート】
query: サッカー日本代表の試合
7.56 代表戦皮肉るセルジオ越後氏、「これこそザ・フレンドリーマッチ」
7.41 U-23日本代表、まさかのドロー。セルジオ氏は「まるで高校サッカーの決勝」
7.35 日本代表戦を速報する中西哲生さん、新幹線から粘りのツイート
7.17 日本代表新アウエーユニに「こっちをホームにしろよ」の声
7.17 【五輪サッカー】 韓国に負けたイギリス代表に英ネットユーザーから批判相次ぐ
Use BM25S (BM25-Sparse) with nagisa
BM25S is a fast BM25 library that computes the scores of all documents in advance and stores them in a sparse matrix. Its default tokenizer splits text at non-word characters such as spaces and punctuation.
import bm25s
print(bm25s.tokenize(["日本語の文書を検索します。"], return_ids=False))
#=> [['日本語の文書を検索します']]
You can use nagisa by passing split_words() to the splitter argument of bm25s.tokenization.Tokenizer,
and nagisa.stopwords to the stopwords argument.
The following script builds the index with the same tokens as tokenize() above,
saves the index and the vocabulary of the tokenizer, loads them again and searches documents.
It imports functions from tutorial_bm25.py, so put both scripts in the same directory and run it there.
It also tokenizes the whole corpus, which takes several minutes.
python tutorial_bm25s.py
1import bm25s
2import nagisa
3
4from tutorial_bm25 import download_corpus, load_corpus, split_words
5
6INDEX_DIR = "ldcc_bm25s_index"
7
8
9def build_tokenizer():
10 # split_words() lowercases the words after tokenization, so set lower=False.
11 return bm25s.tokenization.Tokenizer(lower=False, splitter=split_words,
12 stopwords=nagisa.stopwords)
13
14
15def main():
16 download_corpus()
17 titles, texts = load_corpus()
18
19 # The default tokenizer of bm25s cannot split Japanese text into words.
20 print(bm25s.tokenize(["日本語の文書を検索します。"], return_ids=False, show_progress=False))
21
22 # Create the index with the nagisa tokenizer
23 tokenizer = build_tokenizer()
24 corpus_tokens = tokenizer.tokenize(texts, return_as="tuple")
25 retriever = bm25s.BM25(k1=1.5, b=0.75)
26 retriever.index(corpus_tokens)
27
28 # Save the index with the titles, and the vocabulary of the tokenizer
29 retriever.save(INDEX_DIR, corpus=titles)
30 tokenizer.save_vocab(INDEX_DIR)
31
32 # Load them again and search documents
33 retriever = bm25s.BM25.load(INDEX_DIR, load_corpus=True)
34 tokenizer = build_tokenizer()
35 tokenizer.load_vocab(INDEX_DIR)
36
37 queries = ["iPhoneの便利なアプリ", "サッカー日本代表の試合"]
38 query_tokens = tokenizer.tokenize(queries, update_vocab=False)
39 results, scores = retriever.retrieve(query_tokens, k=5)
40
41 for query, docs, doc_scores in zip(queries, results, scores):
42 print("\nquery: {}".format(query))
43 for doc, score in zip(docs, doc_scores):
44 print(" {:.2f} {}".format(score, doc["text"]))
45
46
47if __name__ == "__main__":
48 main()
This is the output of tutorial_bm25s.py.
[['日本語の文書を検索します']]
query: iPhoneの便利なアプリ
5.55 ようやくiPhoneでもChromeが使えるように!【iPhoneでチャンスを掴め】
5.31 もっさりiPhoneとはさよなら!ワンタップでサクサクにできる必須アプリ「もっさり解消 for iPhone」【iPhoneアプリ】
5.09 面倒な画面の明るさ設定はiPhoneを傾けるだけで好みの明るさに!「Tilt de Brightness(明るさ調整)」【iPhoneアプリ】
5.02 二画面の便利さを450円で!「iDisplay」でiPadをパソコンのセカンドディスプレイ化【iPhoneアプリ】【iPadアプリ】
5.01 女子だらけの公開イベントに潜入!iPhone女子部がKDDIデザイニングスタジオで「公開リアル部室」を開催【レポート】
query: サッカー日本代表の試合
7.56 代表戦皮肉るセルジオ越後氏、「これこそザ・フレンドリーマッチ」
7.41 U-23日本代表、まさかのドロー。セルジオ氏は「まるで高校サッカーの決勝」
7.35 日本代表戦を速報する中西哲生さん、新幹線から粘りのツイート
7.17 日本代表新アウエーユニに「こっちをホームにしろよ」の声
7.17 【五輪サッカー】 韓国に負けたイギリス代表に英ネットユーザーから批判相次ぐ
The results are the same as those of tutorial_bm25.py. The scores can differ slightly in the last digits because BM25S uses float32.
Notes on using BM25S with nagisa:
Set
lower=False. By default, BM25S lowercases the text before splitting it, but nagisa may split and tag lowercased text differently from the original text.split_words()lowercases the words after tokenization instead.Pass
return_as="tuple"when tokenizing the corpus so that the index stores the vocabulary. Otherwise, queries given as lists of strings get a score of 0 for all documents.Save the vocabulary of the tokenizer with
tokenizer.save_vocab()and load it withtokenizer.load_vocab(). A new tokenizer has an empty vocabulary, sotokenize(queries, update_vocab=False)returns no tokens without it.Unlike
search()above, BM25S returnskdocuments even if a query has no known terms. In that case, all the scores are 0, so ignore the results with a score of 0.