自分はいつも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くらいで完了できる。
現在、総登録件数における検索速度がどれくらいになるのか動作確認を行なっているところだが、約8万件登録した状態で上記の検索を実行した際の結果取得は平均して200msくらいで完了できる。
この後、辞書(MeCab)と絡めて検索精度を上げるための研究をしていこうと思う。