Pythonで自然言語処理〜WhooshとMecabを使って簡単全文検索〜

###はじめに
Pythonの[Whoosh](http://whoosh.readthedocs.io/en/latest/index.html)とMecabを使用して全文検索を行います。
全文検索とは、その名の通りある文章の中に指定の単語が含まれているかを調べる検索です。


##使用データ、環境など
今回使用するデータはwikipediaデータ(2.7GB)ですが好きなテキストデータで行えるので各人でデータを用意してください。

wikiのデータを使用したい場合は[こちら](https://dumps.wikimedia.org/jawiki/latest/)からjawiki-latest-pages-articles.xml.bz2をダウンロードしてWikipedia Extractorを使用することで記事ごとに`<doc> </doc>`で囲まれたテキストデータが得られるみたいです。(配布されたものを使用するので、自分ではこの方法を試していないです。)

Anaconda Python3.6使用 (Python2でも実行出来る可能性はあります。)
Windows, Mac, Linux(Ubuntu)のどの環境でも実行可能ですがWindowsはMecabの設定が手間でした。

100MB程度までのデータならノートパソコンでも太刀打ちできますが、2.7GBのデータを処理するのはまあまあ厳しいので、Google Compute Engineで Ubuntu 2cpu 13GBメモリ 65GBストレージのインスタンスを借りて実行しました。

最終的には、今回の方法で3.6GBのインデックスファイルが出来上がります。
インデックスファイルが処置途中で20GB以上になっていたことは確認したので、ストレージは多めに確保しておいた方がいいと思われます。

##Whoosh、Mecabの説明
__Whoosh__は全文検索用ライブラリで簡単に全文検索が出来ます。もともと英語を想定して作られているので、単語の間に空白が無い日本語で行う場合は、文章を単語分割してあげる必要があります。
一度インデックスファイルを作成した後でも、削除したり追加したりが可能です。

また、TF-IDF検索なんかにも対応してます。TF-IDF検索の記事も書くかもしれません。


__Mecab__は日本語を形態素解析して分かち書きしてくれるオープンソース形態素解析エンジンです。
例えば、「今日はとても暑いのでかき氷日和だ。」という日本語をMecabで形態素解析すると

```Mecab:Mecab
今日 キョウ 今日 名詞-副詞可能
は ハ は 助詞-係助詞
とても トテモ とても 副詞-助詞類接続
暑い アツイ 暑い 形容詞-自立 形容詞・アウオ段 基本形
ので ノデ ので 助詞-接続助詞
かき氷 カキゴオリ かき氷 名詞-一般
日和 ヒヨリ 日和 名詞-一般
だ ダ だ 助動詞 特殊・ダ 基本形
。 。 。 記号-句点
```

というように、文を品詞分解して結果を返してくれます。

Pythonには__Janome__という形態素解析ライブラリもありますが、実行速度がMecabに比べてとても遅いので今回はあまりオススメしません。(使用するテキストデータが小さいのであればそこまで大差ないです。)

##準備
####Mecabのインストール
Ubuntu

```
$ sudo apt-get install libmecab2 libmecab-dev mecab mecab-ipadic mecab-ipadic-utf8 mecab-utils
```

現時点ではlibmecab2ですが、その時の最新版にしてください。

Mac
簡単なので、Homebrewを使用します。入れていない方は、今後も便利なので入れておくのをオススメします。
Homebrewを入れたら、

```brew install mecab mecab-ipadic```

Windows
[ここから](http://taku910.github.io/mecab/)ダウンロードしてmecab-0.996.exeを実行してください。

追加の辞書をダウンロードしたい方はしてもいいかもしれません。
今回はデフォルトで行っていきます。

####WhooshとMecabをPythonで扱うためのmecab-python3のインストール
Whooshのインストールは
Anaconda環境
```conda install -c anaconda whoosh ```
Anacondaなし
```pip install Whoosh```

mecab-python3のインストールは
Mac, Linuxは
```pip install mecab-python3```
Windowsは
[こちらの記事](https://qiita.com/yukinoi/items/990b6933d9f21ba0fb43)を参考に行ってください。記事のコメントも参考に行えば上手くいくはずです。(Windowsの闇を垣間見ることがきっとできるでしょう。)

##コードの概略
wikiデータは、記事ごとに

```
<doc id="5" url="https://ja.wikipedia.org/wiki?curid=5" title="アンパサンド">
アンパサンド

アンパサンド (, &) とは「…と…」を意味する記号である。ラテン語の の合字で
・・・
</doc>
```

という構造になっています。
なので、まず記事ごとに正規表現を使用してタイトルとidを抜き出します。
そのあとに本文を一つの文字列として抜き出して、Mecabで分かち書きをして今回は名詞だけを取り出します。

分かち書きを終えたら、タイトル、idと共に記事の名詞集合をインデックスファイルに追加していきます。

これを全記事分ループして繰り返します。

インデックスファイルが完成したらそれを使用して検索する。

といった流れです。

##コードの説明
###インデックスファイル作成
WhooshとMecabの使い方をメインに説明していきます。
コードの完成品は最後に載せます。

まず初めに全文検索のためのSchemaとディレクトリを作ります。

```python:index_wiki.py
from whoosh.index import create_in
from whoosh.fields import *
import os

schema = Schema(title=TEXT(stored=True),path=ID(stored=True), content=KEYWORD)
if not os.path.exists("好きなディレクトリ名"):
    os.mkdir("好きなディレクトリ名")
ix = create_in("好きなディレクトリ名", schema)
writer = ix.writer()
```

Schemaの引数は、titleに記事名、pathに記事ID、contentに分かち書きした記事を入れます。
title,pathの```stored=True```は、titleとpathを保存しておくための設定です。
他の項目を保存したい場合は、

```
Schema(title=TEXT(stored=True),path=ID(stored=True), kari=TEXT(stored=True), content=KEYWORD)
```

のように適当な引数を増やせば対応可能です。

今回は文章(content)の情報保存にKEYWORDを設定していますがよく使われる3つを説明しておきます。
__TEXT__：単語の位置や前後関係を保持したままインデックスが作られるので、フレーズ検索を行うことが出来ます。例えば、「I love you」と検索したら、本文中に「I love you」 の部分があるかといった検索も可能です。

__KEYWORD__：TEXTとは異なり、位置を保存しません。単にその語が出てくるかどうかの検索になります。例えば、「I love you」と検索したら、本文中に 「I」「love」「you」が場所はバラバラでもいいので文章中に全て存在するかどうか検索します。

__NGRAM__：n-gramで保存します。例えばNGRAM(3,5)としておくと、「ありがとう」という語を、「ありがとう」「ありが」「ありがと」「りがと」「りがとう」「がとう」という形で保存します。一番再現率が高い方法ですが、計算時間と容量が必要です。

それから、インデックスファイルを作るためのディレクトリが無ければos.mkdirで作成して、create_inでディレクトリを指定し、書き込むためのwriter()を作ります。


次に、記事ごとに処理をします。

```python:index_wiki.py
files = open("全文検索したいファイル名", 'r', encoding='UTF-8')
string = files.readline()
kizi = text = title = ids = ''
i = 0
while string:
    if string.find('<doc') >= 0:
        ids, title = to_id_title(string)
    elif string.find('</doc>') >= 0:
        text = " ".join(to_wordlist(kizi)) #単語ごとに空白を入れて１つの文字列にする
        writer.add_document(title=title, path=ids, content=text)
        kizi = text = ''
        print('number:%d title:%s id:%s' %(i, title, ids)) #確認用python2ならかっこを外してください。
        i += 1
    else:
        kizi = kizi + string.strip()

    string = files.readline()
writer.commit() #必ず必要
```

ここは、ファイルを1行ずつ読み込んで、
`'<doc'`があるなら、`to_id_title`でtitleとidを抜き出し、
本文中は変数`kizi`に追加、
`'</doc>'`なら、`to_wordlist`で本文を分かち書きして名詞を抜き出して、変数textに単語ごとに空白を開けた文字列にして、
writer.add_document(title=title, path=ids, content=text)でインデックスファイルに追加といった処理です。

すべての記事が追加し終わったら、writer.commit()を忘れずに行いましょう。

to_id_title()は正規表現を使ってtitleとidを抜き出します。

```python:search_wiki.py
import re

reg = r'".+"' 
reg_id = r'id=".*?"' # .*? は0個以上の任意の文字の最小一致という意味
reg_title = r'title=".*?"'
pattern = re.compile(reg) #事前にコンパイルしておくと動作が早くなる
p1 = re.compile(reg_id)
p2 = re.compile(reg_title)

def to_id_title(words):
    ids = p1.search(words).group() #id=" "の部分を抜き出し
    title = p2.search(words).group() #title=" "の部分を抜き出し
    ids = pattern.search(ids).group()[1:-1] #id=" "から" "の中身を抜き出し
    title = pattern.search(title).group()[1:-1] #title=" "から" "の中身を抜き出し
    return (ids, title)
```

to_wordlist()は分かち書きして名詞だけを抜き出してリストに格納する関数です。
返り値はlistです。


```python:index_wiki.py
import MeCab

def to_wordlist(words):
    m = MeCab.Tagger ("-Ochasen")
    hukugou = "" #複合名詞
    word_set = set() #抜き出した名詞集合

    #不要語集合
    remove_words = {"(", ")", "（", "）", "[", "]",
                    "「", "」", "+", "-", "*", "$",
                    "'", '"', "、", ".", "”", "’",
                    ":", ";", "_", "/", "?", "!",
                    "。", ",", "=", "＝"}

    #分かち書きして分割された語を順にループ
    for chunk in m.parse(words.rstrip()).splitlines()[:-1]:
        word = chunk.rstrip().split('\t')[0] #分かち書きされた語
        tok = chunk.rstrip().split('\t')[3] #上の語の品詞

        #分かち書きした語の中に不要語集合内の語が出てきたら、flagに1以上の整数
        flag = len([r for r in word if r in remove_words])
        if flag > 0:
            #もし複合語があったら
            if hukugou != "" :
                word_set.add(hukugou) #集合なので同じものは複数回入ることがない
            hukugou = "" #リセット
            continue

        #もしtokが名詞ならhukugouに追加して、wordもword_setに追加
        if tok.startswith("名詞"):
            hukugou = hukugou + word
            word_set.add(word)
        else:
            #もし複合語があったら
            if hukugou != "" :
                word_set.add(hukugou)
            hukugou = "" #リセット
    return list(word_set)
```

ここでは複合名詞に対応するために少し複雑になっています。
複合名詞とは、「東京スカイツリー」のようなもので、Mecabのデフォルトの辞書では、「東京」「スカイ」「ツリー」と分割されてしまい「東京スカイツリー」では上手く検索ができなくなってしまいます。なので、変数hukugouを使って複合語を作り、リストの中に入れるようにします。

具体的には、1文が「東京」「スカイ」「ツリー」「は」・・・と分割されていたとすると、
1)「東京」は名詞だから「東京」はword_setにaddして hukugou =「東京」
2)「スカイ」は名詞だから「スカイ」はword_setにaddして hukugou =「東京スカイ」
3)「ツリー」は名詞だから「ツリー」はword_setにaddして hukugou =「東京スカイツリー」
4)「は」は名詞ではないから hukugou =「東京スカイツリー」をword_setにaddして hukugou = ""

こうすることで、「東京」「スカイ」「ツリー」「東京スカイツリー」を名詞集合の中に入れることが出来ます。
ただし、複合名詞に対応している辞書もあるので、それを入れるのもありです。

今回は名詞だけですが、動詞、形容詞等を使いたいのであればこの辺をいじってください。

これで、インデックスファイルを作ることが出来ます。
今回のであれば確か半日もかからず完成していたと思いますがこの辺は記憶が曖昧です。

以下がindex_wiki.py全て

```python:index_wiki.py
from whoosh.index import create_in
from whoosh.fields import *
import os
import MeCab
import re

reg = r'".+"'
reg_id = r'id=".*?"' # .*? は0個以上の任意の文字の最小一致という意味
reg_title = r'title=".*?"'
pattern = re.compile(reg) #事前にコンパイルしておくと動作が早くなる
p1 = re.compile(reg_id)
p2 = re.compile(reg_title)

def to_id_title(words):
    ids = p1.search(words).group() #id=" "の部分を抜き出し
    title = p2.search(words).group() #title=" "の部分を抜き出し
    ids = pattern.search(ids).group()[1:-1] #id=" "から" "の中身を抜き出し
    title = pattern.search(title).group()[1:-1] #title=" "から" "の中身を抜き出し
    return (ids, title)

def to_wordlist(words):
    m = MeCab.Tagger ("-Ochasen")
    hukugou = "" #複合名詞
    word_set = set() #抜き出した名詞集合

    #不要語集合
    remove_words = {"(", ")", "（", "）", "[", "]",
                    "「", "」", "+", "-", "*", "$",
                    "'", '"', "、", ".", "”", "’",
                    ":", ";", "_", "/", "?", "!",
                    "。", ",", "=", "＝"}

    #分かち書きして分割された語を順にループ
    for chunk in m.parse(words.rstrip()).splitlines()[:-1]:
        word = chunk.rstrip().split('\t')[0] #分かち書きされた語
        tok = chunk.rstrip().split('\t')[3] #上の語の品詞

        #分かち書きした語の中に不要語集合内の語が出てきたら、flagに1以上の整数
        flag = len([r for r in word if r in remove_words])
        if flag > 0:
            #もし複合語があったら
            if hukugou != "" :
                word_set.add(hukugou) #集合なので同じものは複数回入ることがない
            hukugou = "" #リセット
            continue

        #もしtokが名詞ならhukugouに追加して、wordもword_setに追加
        if tok.startswith("名詞"):
            hukugou = hukugou + word
            word_set.add(word)
        else:
            #もし複合語があったら
            if hukugou != "" :
                word_set.add(hukugou)
            hukugou = "" #リセット
    return list(word_set)

def main():
    try:
        #Schemaの定義
        schema = Schema(title=TEXT(stored=True),path=ID(stored=True), content=KEYWORD)
        if not os.path.exists("好きなディレクトリ名"):
            os.mkdir("好きなディレクトリ名")
        ix = create_in("好きなディレクトリ名", schema)
        writer = ix.writer()
        #全文検索をしたいファイルを開く
        files = open("全文検索したいファイル名", 'r', encoding='UTF-8')
        string = files.readline()
        kizi = text = title = ids = ''
        i = 0
        while string:
            if string.find('<doc') >= 0:
                ids, title = to_id_title(string)
            elif string.find('</doc>') >= 0:
                text = " ".join(to_wordlist(kizi)) #単語ごとに空白を入れて１つの文字列にする
                #インデックスファイルに追加
                writer.add_document(title=title, path=ids, content=text)
                kizi = text = '' #リセット
                print('number:%d title:%s id:%s' %(i, title, ids))#確認用python2ならかっこを外してください。
                i += 1
            else:
                kizi = kizi + string.strip()

            string = files.readline()
        writer.commit() #必ず必要
    except:
        import traceback
        traceback.print_exc()
    finally:
        files.close()

if __name__ == '__main__':
    main()
```

###検索方法
作成したインデックスファイルを使って検索を行うには、searcherやQueryParserを使用して以下のように行います。

```python:search_wiki.py
from whoosh.index import open_dir
from whoosh import qparser
import time

def main():
    ix = open_dir("indexdir001") #作成したインデックスファイルのディレクトリを指定
    with ix.searcher() as searcher:
        #QueryParserに"content"内を検索することを指定
        parser = qparser.QueryParser("content", ix.schema)

        #OperatorsPluginはAnd,orなどに好きな記号を割り当てることが出来る
        op = qparser.OperatorsPlugin(And="&", Or="\\|", Not="~")
        parser.replace_plugin(op) #opをセット
        words = input("検索 (and:& or:| not ~)>>")
        start = time.time()
        words = words.split() #この部分をwords = words.split(" ")から訂正しました
        words = "".join(words) #空白の処理
        query = parser.parse(words) #parserに検索語を入れる
        results = searcher.search(query, limit=None) #検索語で全文検索

        #返ってきた結果を扱いやすいように加工
        #values()で、storedしてあった内容が返ってくる
        kizi  = [result.values() for result in results]
        for r in kizi:
            print('title:%s id:%s' %(r[0], r[1]))
        print("計%d記事" %len(kizi))
        print(str((time.time() - start)*10000//10)+"ms") #時間計測用

if __name__ == '__main__':
    main()
```
検索では、AND, OR, ANDNOT, ANDMAYBE, NOT が使用できて、 OperatorsPluginで好きな記号を指定できます。

##実行結果

```
検索 (and:& or:| not ~)>>自然言語処理&プログラミング言語
id:68 title:自然言語
id:69 title:プログラミング言語
id:70 title:人工知能
id:1154 title:Mind (プログラミング言語)
id:1475 title:Prolog
id:1909 title:連結リスト
id:2117 title:形態素解析
id:2244 title:構文解析
id:11394 title:人工無脳
id:205917 title:形式意味論
id:290077 title:専門用語
id:446321 title:Lex
id:1060176 title:COBOL
id:1337471 title:ウォルター・サヴィッチ
id:1933546 title:人工言語
id:2003468 title:Constraint Handling Rules
id:2656789 title:日本語プログラミング言語
計17記事
102.0ms
```

といった結果が返ってきます。

##まとめ
Whooshは色々な使い方ができると思うので、[公式ページ](http://whoosh.readthedocs.io/en/latest/index.html)を読んでみるのもオススメします。