2013年2月18日月曜日

WhooshとMongoDBを使った全文検索Webアプリの構築 - その1

ひょんなことから全文検索システムを作ることになり、いろいろ調べたことを備忘録的に記録しておく。
自分はいつもPython+Tornado+Nginx+MongoDBの構成でWebアプリを書いていて、この環境で手軽に全文検索ができたらいいなぁと思って試行錯誤した結果、本稿のようなことができることが分かったので公開しました。


【やりたいこと】
・全文検索(基本的にN-gram。できれば記事内の単語を元に関連記事の表示や単語の編集など)
・主に.txtのファイル内のテキスト本文を対象とする
・アカウントを発行して外部にも公開
(ここは本記事の趣旨から外れるので除外する。)

 【環境】
・さくらVPS 2Core 1GBメモリ
・CentOS 6.2
・Python 2.7.2
・Tornado 2.4.1(フレームワーク)
・MongoDB 2.2.3(データベース)
・Nginx 1.2.6(Webサーバ)
・Whoosh 2.4.1(全文検索エンジン)
・mecab-python(分かち書き処理を行うライブラリ)


【1/3 Whooshのインストール】

CentOS6.2環境にTornado、MongoDB、Nginxなどがインストールされている前提として話を進める。
まずはじめに全文検索エンジンであるWhooshをインストールする。

$ easy_install Whoosh

次にWhooshの概要と、簡単な使い方を記載しておく。
Whooshはpythonで実装された全文検索ライブラリとなっており、当然ながらPythonとの親和性が非常に高く、導入の敷居も低いので手軽に全文検索を始めることができる。

Whooshは、Apache Lucene(ルシーン)というJavaで記述された全文検索エンジンの仕組みを踏襲してPythonで構築されているようなので、Luceneを使ったことがある人にはするっと理解できると思う。

<<Whooshの考え方>>
・「Schema(スキーマ)」という「データを格納するための構造」を
 作成し、そのスキーマを元にインデックスの作成/検索を行う。
・スキーマにはFieldが定められており、Fieldの型やインデックス特性を
 考慮してテキストの保存をどのように行うかを考える必要がある
 Fieldは以下のようなものとなる。(引用 Whoosh v2.4.1 documentation

フィールド名 内容 ユニークにできるか否か インデックス有無 パラメータ
ID タイトル、ファイルのパス、URLなどに使用する stored
unique
IDLIST スペースや(または)句読点で区切られた複数の文字列をIDとして保存するフィールド 可能 stored
unique
expression
STORED インデックスしたくないものを保存するフィールド × × stored
KEYWORD スペースまたはカンマ区切りの文字列を保存し、タグのような使い方をする。区切られたそれぞれの文字(例えばabc,def,efg)はそれぞれの単語がインデックスされ、検索対象となる。ただしフレーズ検索(続いた文字列軍での検索)には対応していない。 × analyzer
phrase
chars
vactor
stored
spelling
NUMERIC 数値フィールド。Int / long / floatの値に対応。 可能 type
stored
unique
decimal_places
shift_steps
signed
DATETIME 日時(datetime型)を保存するフィールド。 可能 unique
stored
BOOLEAN ブーリアンを保存するフィールド。 × stored
NGRAM N-gramで文字列を保持するフィールド。minsizeで指定した数値(デフォルトは2)によって、最小となる文字数が決まる。 × minsize
maxsize
stored
queryor
phrase
NGRAMWORDS 連続する文字列(キーワード)をn単語単位で分割してインデックス化するフィールド。(例:we are theの場合、[we are][are the]の2つのデータとしてインデックス化する。 × minsize
maxsize
stored
tokenizer
at
queryor


今回登録したかったテキスト情報は
・タイトル(string)
・登録日時(datetime)
・テキスト本文(string)
・その他メタ情報が5種類ほど
といったものがあり、これらすべてをWhoosh側に登録するのはFieldの種類とカラム数が足らないので、

MongoDB側では
・タイトル
・その他メタ情報5種類ほど
といった補完情報を保存し、

そのレコードIDをWhoosh側で
・MongoDBのレコードID(_id)を「ID」にセット
・テキスト本文を「NGRAM」にセット
・登録日時を「DATETIME」にセット
して保存し、実際の検索はWhooshに行わせるようにした。


【2/3 Whooshの使い方 - テキストの登録】

<<テキストデータの登録>>
今回は.txt形式のテキストファイルがあり、このファイルを特定のディレクトリにアップロードしておいて
「テキストを抽出->MongoDBへ登録->Whooshへ登録」という処理を行った。
テキストファイルはあらかじめ指定のディレクトリにアップロードしておき、そのディレクトリ内にあるテキストを一括で登録・インデックス化するという流れになる。

from whoosh.fields import Schema, ID, STORED, NGRAM, DATETIME, TEXT
import whoosh.index as index
from whoosh.qparser import QueryParser

today = datetime.datetime.now()
indexdir = "/var/www/html/indexdir"
add_flg = 0
add_key = self.get_argument('add','')
if add_key=='1':
    #スキーマを定義する
    schema = Schema(
        mongo_rec_id=ID(stored=True, unique=True),
        regist_date=DATETIME(stored=True),
        body=TEXT(stored=True),
        n_body=NGRAM(stored=True)
    )
    database = con.UserData
    d_col = database.DocumentMasterData #MongoDBのコレクションを準備
    if not os.path.exists(indexdir):
        os.mkdir(indexdir)
        index.create_in(indexdir, schema)
    #/var/www/html/static/target_txt内のテキストファイル数を取得する
    target_dir = '/var/www/html/target_txt'
    check_dir = os.path.exists(target_dir)
    filecount = 0
    if check_dir:
        files = os.listdir(target_dir)
        filecount = len(files)
        for i in range(0,filecount):
            _filename = files[i];
            filename = '/var/www/html/target_txt/'+_filename
            dataarray = open(filename,"rb").read().split('\n') #テキストファイルを開く
            num = 0
            date_1 = ''
            something = ''
            flg = ''
            title = ''
            textvalue = ''
            today = datetime.datetime.now()
            line_f = '\n'
            for n in dataarray:
                if num==0:  date_1 = n.decode('cp932') #0. 日付を取得する
                if num==1:  something = n.decode('cp932')   #1. 日付(?)を取得する
                if num==2:  flg = n.decode('cp932')        #2. フラグを取得する
                if num==3:  title = n.decode('cp932')      #3. タイトルを取得する
                #4. 5行目以上のテキスト本文を最終行まですべて取得する
                if num>=4:
                    if textvalue!="":
                        if n.decode('cp932')=='':
                            textvalue = textvalue+line_f
                        else:
                            textvalue = textvalue+line_f+n.decode('cp932')
                else:
                        if n.decode('cp932')=='':
                            textvalue = line_f
                        else:
                            textvalue = n.decode('cp932')
                num+=1
    
                if title!="" and textvalue!="":
                #【STEP1/2】はじめにMongoDB側に補完情報を保存しておく
                _mongo_rec_id = d_col.insert({
                    "title":title,
                    "regist_date":today,
                    "update_date":"",
                    "page_num":0,
                    "category":""
                })
                _mongo_rec_id = str(_mongo_rec_id)
                if not isinstance(_mongo_rec_id,unicode):
                    _mongo_rec_id = unicode(_mongo_rec_id,'utf-8')
                
                #【STEP2/2】Whooshへドキュメントデータを保存する
                if _mongo_rec_id!="":
                    ix = index.open_dir(indexdir)
                    writer=ix.writer()
                    writer.add_document(
                        mongo_rec_id =_mongo_rec_id,
                        body              =textvalue,
                        n_body           =textvalue,
                        regist_date     =today
                    )
                    #optimize=Trueにすると登録処理に時間がかかる!
                    #try:    writer.commit(optimize=True) 
                    try:    writer.commit(optimize=False)
                    except: writer.cancel()
                    ix.close()


【3/3 Whooshの使い方 - 検索】

Whooshに登録したテキストデータを検索する時にも、上で説明した「Schema(スキーマ)」を使用する。

keyword = self.get_argument('keyword','') #検索したい文字
indexdir = "/var/www/html/indexdir"
if keyword!="":
    #検索する
    ix = None
    try:
        ix = index.open_dir(indexdir)
    #indexdirディレクトリにインデックスファイルがない場合に発生
    except index.EmptyIndexError:
        index.create_in(indexdir, schema)
        ix = index.open_dir(indexdir)
    searcher = ix.searcher()
    parser = QueryParser("n_body", schema = ix.schema)
    q = parser.parse(keyword)
    #検索取得件数を取得して検索実行(未指定の場合10件)
    results = searcher.search(q,limit=200)
    
    for r in results:
        self.write(r["n_body"].replace('\n','<br />'))
        self.write('<br />')
        self.write(str(r['mongo_rec_id']))
        self.write('<br />')
    ix.close()


現在、総登録件数における検索速度がどれくらいになるのか動作確認を行なっているところだが、約8万件登録した状態で上記の検索を実行した際の結果取得は平均して200msくらいで完了できる。


この後、辞書(MeCab)と絡めて検索精度を上げるための研究をしていこうと思う。

0 件のコメント:

コメントを投稿