Whooshで検索機能の向上を図る

はじめに

前に類似本検索システムを作成したのですが、その中で数万ある本の候補の中から探したい本の検索する部分があります。 そのときは入力された単語に対し検索を全書籍に対して行う、という最も単純な手法を実装したのですが、 もう少しいいやり方がないかなーといくつか資料を読んで改善を実施したのでその過程を記述します。

参考資料

現状の問題点

  • 複数のワードを入力することができず、OR検索やNOT検索もできない
  • 登録されている書籍を全検索しているので、件数が増えた場合に検索時間が線形に増える。
  • 検索が一致した後のリストの返し方に何も優先順位をつけていない

解決方法

pythonで利用できる全文検索パッケージのwhooshを使います。商用ではサーバ機能を併せ持つElasticsearch等が使われることが多そうだったのですが、今回はheroku上に実装したいという意図もありこちらを使用しました。ちなみにドキュメントの類はElasticsearchの方が豊富そうでした。

検索を実施する最も簡単な方法は、検索したい文字列を検索対象すべてに対し順に検索をかけることですが、これだと検索対象の数だけ時間がかかってしまいます。それを防ぐために単語単位でインデックスを持たせるという手法があります。 例えば「犬も棒に当たる」という検索文字列に対し、「犬」、「棒」、「当たる」というそれぞれの単語に対して事前に対応する検索対象が何かを別々に記録しておくことで、走査が必要な数が全体での単語のユニーク数に収まるようになりますし、複数の単語の検索結果を比較することでAND検索やOR検索も行えるようになります。これを行うためには形態素解析を事前に行う必要があります。

実は今回実験の対象とするデータだと、検索先の文書件数が2万弱とデータ量が多くなく全検索でも速度はあまり気にならないので、Whooseを使うメリットはANDやORを使うことができるようになることと、ランキングを考慮できることになります。

実装メモ

検索部分の実装はこのような形になりました。 各機能の詳しい説明については公式を参照するのが確実です。

## indexの型の作成
schema = Schema(title=TEXT(stored=True), content=TEXT(stored=True, analyzer=StandardAnalyzer(stoplist=None)),count=NUMERIC(stored=True, sortable=True))
if not os.path.exists("heroku_index"):
    os.mkdir("heroku_index")
ix = create_in("heroku_index", schema)

# index作成
writer = ix.writer()
for num in range(len(bookdict_all_sort_upper10)):
    #表層形
    titlewords = set([m.surface() for m in tokenizer_obj.tokenize(
        bookdict_all_sort_upper10[num][0], mode)])
    #正規化
    titlewords = titlewords.union(set([m.normalized_form(
    ) for m in tokenizer_obj.tokenize(bookdict_all_sort_upper10[num][0], mode)]))
    #辞書表記
    titlewords = titlewords.union(set([m.dictionary_form(
    ) for m in tokenizer_obj.tokenize(bookdict_all_sort_upper10[num][0], mode)]))
    writer.add_document(title=bookdict_all_sort_upper10[num][0], content=" ".join(list(titlewords-remove_words)),
                        count=bookdict_all_sort_upper10[num][1])
writer.commit()

# 検索
outarr=[]
q = qp.parse(searchword_parsed)
with ix.searcher() as s:  # with内で処理する必要あり
    results = s.search(q, limit=999, sortedby="count",
                        reverse=True)  # 本の登場数でソート
    for i in results:
        outarr.append(i.values()[2]) #検索結果を格納

実装メモ

  • 形態素解析について

    • sudachiを使用
    • sudachiでは形態素解析の方法に3つの選択肢があるが、固有名詞を抽出できそうなSplitMode.Cを使用している。
    • 表記揺れや類似語に対応できることを期待して表層型と辞書型と正規化型の3つの形態素解析の結果を用いている。
  • 全文検索エンジンについて

    • whooshを使用
    • 後で格納した値を取り出したい場合はスキーマ作成時にstored_Trueとしておく必要がある。
    • 元が英語ベースでデフォルトだと形態素解析後に1文字のものはstopwordとして除外されてしまうため、1文字の検索を有効化したいときはスキーマ作成時にcontext=analyzer=StandardAnalyzer(stoplist=None)としておく必要がある。
    • 検索後に値を取り出す際はwith内で実施する必要がある。
    • whooshはデフォルトだとBM25というTFIDFに似たアルゴリズムでランキングを返しているが、今回は検索ワードの類似性よりも本の登場回数を用いたかったため、検索のsortedbyの部分を本の登場回数に変更している。

結果

OR検索の例 f:id:rmizutaa:20200803085943p:plain

NOT検索の例 f:id:rmizutaa:20200803090138p:plain

今回の更新によりスペース区切りの複数ワード入力や、OR検索やNOT検索ができるようになりました。 また、本の登録冊数でソートしているので、目的の本が上位に来やすくなったかと思います。

まとめ

whooshを使用してサイトの検索機能の向上を図りました。

いろいろなサイトの検索サジェストを見ていると、 入力された単語とは直接関係はないが検索したい対象に近い結果が出てくるような仕組みもあったので、 また時間があればそのあたりも調べてみたいです。

使用したコードはこちらになります。(コード部のみ)

github.com