読者です 読者をやめる 読者になる 読者になる

FutureInsight.info

AI、ビッグデータ、ライフサイエンス、テクノロジービッグプレイヤーの動向、これからの働き方などの「未来」に注目して考察するブログです。

全文検索エンジンLuxとPythonの軽量Webアプリケーションフレームワークfapws3で構築する高速検索サービス

今、手元で検索サービスを作成するためにいろいろ実験をしているのですが、ある程度ノウハウが貯まったので公開しておこうかと思います。長いエントリーになりますので、検索サービスの構築に興味がある人だけ閲覧下さい。
一般的な検索エンジンは主に2つのパートに別れます。一つは、クローラとインデクサからなるデータを収集するバックエンド、もう一つは検索を行うフロントエンドです。クローラとインデックス部分に関してはまだ手元で試しているところなのです紹介できる状態にないのですが、Pythonを用いたフロントエンドに関しましては、だいたいやり方がわかってきたので、ここで公開しておこうと思います。個人レベルが作れそうな検索サービスの構築に興味がある方はよんでいただければ幸いです。

[追記]クローラ部分は時間がかかりそうなので、インデクサ部分を公開しました。

利用するソフトウェア

まず、利用するソフトウェアは主に以下の二つです。

Luxは未踏を通して知り合ったmogwaingさんが作成している全文検索エンジンです。目標としては、公開している全文検索エンジンの中では、最速を目指しているとのことです。このLuxを使ってみたくて検索サービスの作成をスタートしました。
fapws3の方は現在Python業界最速の軽量Webアプリケーションフレームワークです。libevを用いた非同期イベントドリブンベースで設計されており、非常に高速に動作します。昔はPythonの軽量Webアプリケーションフレームワークといえばweb.pyだったんですが、ぶっちゃけweb.pyってぜんぜん早くないんですよね。検索エンジンのような認証とかが特に必要がない部分では速度を重視したかったのでfapws3にしてみました。fapws3の性能データは以下をどうぞ。Python業界ではたぶん最速です。

まずはインストール

まずはLuxとfapws3をインストールしましょう。両方ともREADMEを見てインストールしていただければいいのですが、簡単に説明しておきますと、Luxはインストール時にlibevent、mecab、mecabの辞書(mecab-ipadicとか)、protobuf、luxioを要求します。また、fapws3はlibevを必要とします。またLuxはビルドにboostが必要でした。
上記の依存関係のあるツールは今後のことを考えyumよりも手元でビルドした方が良いとおもうのですが、boostだけは絶対にyum、apt、portなどのパッケージツールでインストールするようにしてください。これは次のステップでboost-pythonを必要とするためです。boostのインストールだけならば、boostは基本的にはヘッダだけで構成されるテンプレートライブラリなので、特に問題ないのですがboost-pythonはboostの中では珍しくビルドが必要なパッケージです。ただ、一度トライしたことのある人なら知っているかと思いますが、boostのビルドは結構面倒です。なので、boostだけはおとなしくパッケージツールから持ってくるようにしてください。

構成の説明

で、作成する検索サービスの簡単な構成を説明しますとWebサーバであるfapws3からLuxを叩くという感じになります。通常の運用では、fapws3を一般的なWebサーバからのリダイレクトかfastcgiで動かすことになるかと思うので、以下のような感じになります。ちょっと運用は煩雑になりますがリダイレクトの方がちょっと性能はいいらしいです。(fapws2ですが参考: FAPWS2でDjangoを動かす - スコトプリゴニエフスク通信)

  • Webサーバ(lighttpdとか) => リダイレクト、またはfastcgi => fapws3 <= (boost-pythonによるバインディング)=> Lux

LuxはC++で記述されたライブラリ、fapws3はPythonのWeb軽量フレームワークなので、バインディングが必要です。ここに関しては何を使ってもいいのですが、僕は慣れ親しんだboost-pythonを使います。PythonとCとのバインディングの方法はいろいろあるのですが、個人的にはpythonとC++系のライブラリをバインディングするときはboost-pythonが優れていると思っています。それはboost-pythonがC++のクラスをそのままPython側のインスタンスとして渡すことが2、3行で可能なためです。

PythonとLuxのバインディング

まず検索するデータに関してはLuxに付属するexampleに入っているサンプルデータを利用します。exampleに付属するサンプルデータはtitle、created_at、urlの各要素を持っています。インデックス側の作業はexampleに付属するindexプログラムを使ってすでに終了しているとして、ここで実装が必要な部分は検索を行う部分です。実際に検索エンジンを作るときにはクローラとインデクサを作成する部分の方がずっと大変だとおもいますが、現時点でまだたいしたノウハウがないので今回はそこには踏み込みません。PythonとLuxの検索インターフェースのバインディングは以下のように記述しました。長いですが、そのまま貼り付けておきます。

#include <lux/search.h>
#include <iostream>
#include <string>
#include <time.h>
#include <sys/time.h>
#include <boost/python.hpp>

using namespace std;

class Searcher
{
public:

  Searcher()
  {
    engine = NULL;
  }

  virtual ~Searcher()
  {
    if(engine!=NULL) {
      delete engine;
    }
  }

  int set_service(string service)
  {
    Lux::Config::Document doc_config;
    if (!Lux::DocumentConfigParser::parse(service, doc_config)) {
      return 0;
    }
    engine = new Lux::Engine(doc_config);
    if (engine->open(service, Lux::DB_RDONLY)) {
      return 0;
    }
    return 1;
  }

  int set_keys(PyObject* py_obj)
  {
    if(PyList_Check(py_obj)) {
      PyListObject* py_keys = (PyListObject*)py_obj;
      if (py_keys) {
	int num_keys = PyList_GET_SIZE(py_keys);
	keys.reserve(num_keys);
	for (int i=0; i<num_keys; i++) {
	  PyObject* py_key = PyList_GET_ITEM(py_keys, i);
	  if (PyString_Check(py_key)) {
	    keys.push_back(PyString_AS_STRING(py_key));
	  } else {
	    return 0;
	  }
	}
      }
    } else {
      return 0;
    }
    return 1;
  }

  PyObject* get_rs(string query, int pages)
  {
    double t1, t2;
    t1 = gettimeofday_sec();
    
    // create search condition
    Lux::SortCondition scond(Lux::SORT_SCORE, Lux::DESC);
    Lux::Paging paging(pages);
    Lux::Condition cond(scond, paging);
    Lux::Searcher searcher(*engine);
    Lux::ResultSet rs = searcher.search(query, cond);

    t2 = gettimeofday_sec();
    
    PyObject* pt(PyDict_New());
    
    // create result py object
    PyDict_SetItemString(pt, "total_hits", PyInt_FromLong(rs.get_total_num()));
    PyDict_SetItemString(pt, "base", PyInt_FromLong(rs.get_base()));
    PyDict_SetItemString(pt, "num", PyInt_FromLong(rs.get_num()));
    PyDict_SetItemString(pt, "time", PyFloat_FromDouble(t2-t1));  
    PyObject* prs(PyTuple_New(rs.get_num()));
    rs.init_iter();
    uint32_t index = 0;

    while (rs.has_next()) {    
      Lux::Result r = rs.get_next();
      PyObject* pr(PyDict_New());
      PyDict_SetItemString(pr, "id", PyString_FromString(r.get_id().c_str()));
      PyDict_SetItemString(pr, "score", PyInt_FromLong(r.get_score()));
      
      for (vector<string>::iterator i=keys.begin();i!=keys.end();++i) {
	PyDict_SetItemString(pr, (*i).c_str(),
			     PyString_FromString(r.get(*i).c_str()));	
      }
      
      PyTuple_SetItem(prs, index, pr);
      index++;
    }
  
    PyDict_SetItemString(pt, "result_set", prs);
    return pt;
  }
  
protected:

  Lux::Engine* engine;
  vector<string> keys;

  double gettimeofday_sec()
  {
    struct timeval tv; 
    gettimeofday(&tv, NULL);
    return tv.tv_sec + (double)tv.tv_usec*1e-6;
  }
};

BOOST_PYTHON_MODULE(lux_python)
{
  using namespace boost::python;\
  class_<Searcher>("Searcher")
    .def("set_service", &Searcher::set_service)
    .def("set_keys", &Searcher::set_keys)
    .def("search", &Searcher::get_rs)
    ;
}

このソースコードを以下のようにコンパイルするとlux_python.soが作成されます。

g++ -I/usr/include/python2.5 -shared -o lux_python.so $^ -lboost_python -llux -fPIC

lux_python.soの使い方は以下の通りです。

import lux_python
lse = lux_python.Searcher()
lse.set_service("blogs")
lse.set_keys(["title", "created_at", "url"])
print lse.search("google", 5)

Lux側にsearch関数でgoogleという文字列を問い合わせ、検索結果をdict形式で取得しています。あらかじめset_keys関数で設定した項目のデータが取得されます。set_service関数はどのインデックスを読み込むかどうかです。ここではLuxのexampleに付属していたblogsを読み込んでいます。本当はエラーをおきたときはpython側の例外を投げてあげる必要があるのですが、ここではそこはズルして、関数が失敗したら0、成功したら1を返すようにしています。

これでPython側からLuxを叩く準備が整いました。

fapws3側のフロントエンド実装

上の準備が整えば、あとはfapws3側でユーザからの応答に対して検索結果を返すだけです。URLの"/search"のqパラメータに検索文字列が入ってきた場合、検索結果を返すようにしたいと思います。"http://○○○/search?q=[ここに検索文字列]"見たいなイメージです。テンプレートエンジンはオーソドックスにCheetahを使っています。今流行のテンプレートエンジンを使いたい場合はCheetahと同等の速度がでるMakoが良いのではないかと思います。今回はMakoほどの機能は必要なかったので枯れているCheetahを利用しました。ソースコードは以下の通りです。

# -*- coding: utf-8; -*-
#!/usr/bin/env python

import fapws._evwsgi as evwsgi
from fapws import base
import time
import sys
from fapws.contrib import views, zip, log
from Cheetah.Template import Template
import lux_python

def start():
    evwsgi.start("0.0.0.0", 8080)
    evwsgi.set_base_module(base)
    tmpl = Template(file="template.html")
    lux = lux_python.Searcher()
    lux.set_service("blogs")
    lux.set_keys(["title", "created_at", "url"])
    
    def search(environ, start_response):
        if environ["REQUEST_METHOD"] == "GET":
            start_response('200 OK', [('Content-Type','text/html')])
            if environ["fapws.params"].has_key("q"):
                search_result = lux.search(environ["fapws.params"]["q"][0], 5)
                tmpl.title = environ["fapws.params"]["q"][0] + " - Kensaku Beta"
                tmpl.query = environ["fapws.params"]["q"][0]
                rs = []
                for r in search_result["result_set"]:
                    r_dict = {}
                    r_dict["title"] = r["title"]
                    r_dict["url"] = r["url"]
                    r_dict["created_at"] = r["created_at"]
                    rs.append(r_dict)
                if len(rs) == 0:
                    rs = [{"title":"検索結果はありません", "url":"-", "created_at":"-"}]
                tmpl.rs = rs
            else:
                tmpl.title = "Kensaku Beta"
                tmpl.query = ""
                rs = [{"title":"検索結果はありません", "url":"-", "created_at":"-"}]
                tmpl.rs = rs               
 
            return [str(tmpl)]
        elif environ["REQUEST_METHOD"] == "POST":
            pass
    
    evwsgi.wsgi_cb(("/search", search))
    staticform=views.Staticfile("logo.png")

    evwsgi.set_debug(0)    
    evwsgi.run()

if __name__=="__main__":
    start()

このスクリプトを起動すると検索エンジンが起動します。やっていることは単純で、"/search"にアクセスがきた場合、search関数が呼ばれ、検索結果が出力されます。どのようなテンプレートを利用しても良いのですが、ここではTwitterのオフィシャル検索からデザインを拝借しました。上のスクリプトを動かすと以下のようになります。

もちろん日本語でも検索可能です。

もともとのデータが100件強ほどしかないので上の状態ではヒット数は多くありませんが、だいたいこの構成で1000万から2000万件くらいまでのデータはサーバ一台で処理できるっぽいです。この辺はLux様々です。

まとめ

Luxとfapws3を用いて高速な検索サービス構築する方法を紹介しました。ここまでのソースコードは以下のパッケージにまとめてあります。

依存関係のあるソフトウェアがインストールされた状態で以下のコマンドを実行すればlux_python.soが作成され、上記の検索サービスが8080ポートで起動するはずです。

make
python search_service.py

世の中に自分で検索サービスを作成したい人がどれくらいいるかわかりませんが、何かの参考になれば幸いです。次回(かなり後になると思いますが)はTwistedを用いたネットワークのレイテンシを狡猾に隠蔽する高機能なクローラを作成する方法を紹介しようと思います。