アペフチ

ドリルダウン(ファセット検索)の仕組み

Groongaで学ぶ全文検索 2015-12-18に行って来た。今日のお題はドリルダウン(ファセット検索)。

ドリルダウン

ある検索語で検索した時に、検索結果をさらに絞り込むと何件になるかなどの集計をする(発表した時に間違いを指摘してもらった。ドリルダウンは絞り込みではなく集計)ことを、予め(検索時に同時に)Groongaがやっておいてくれる、という機能だ。例えばるりまサーチで、「call」を検索した場合、callを含むページのほか、画面左にインスタンスメソッドが何件あるか、特異メソッドは何件あるか……といった結果も表示されている。これは、「callを含み、かつインスタンスメソッドのページ」は何件あるか、という数になっている。

このように、検索結果に対して、既に(与えられた)キーで集計した結果を返すのがドリルダウン。まず、既に分類が終わっているので、検索結果を絞り込む作業が簡単に(クリックするだけで)できる。また、0件の時はそのことが分かるので、不要な絞りこみ作業をユーザーがわざわざする必要がない。というように、ドリルダウンは全文検索を補助する機能だ。全文検索とは関係ない文脈では集計機能ということになる。

ドリルダウンを高速にするために、Groongaのデータ構造が活きている。RDBMSは行指向のテーブル型データベースだから、ストレージ上、一行のデータが複数カラム分、まとまった場所に置かれるようになっている。あるカラムに関する集計(インスタンスメソッドは何件で、特異メソッドは何件で……)をする場合には各行をループさせて、その中で注目しているカラムの場所まで移動して、内容に応じて集計結果を更新する必要がある。

Groongaは列指向データベースだから、ストレージ上、あるカラムのデータがまとまった場所に置かれている。だから集計する場合には一箇所からデータをまとめて取って来て数えたりしていけばよい。

集計に関してはこうなっていて、全文検索では更に「検索結果の中で」のそういった集計を行うことになるが、特別なことはない。一旦全文検索を行ってレコードを取得する。その中で更に集計する。ヒットしなかったレコード分はスキップして集計するのでややもったいないが、列指向でのやり方よりは効率がいい。

多段ドリルダウン

多段ドリルダウンはどうやっているのかも聞いた。本のデータベースがあった時に、雑誌 -> プログラミングというように下位分類を持つようなやつだ(こうじゃないタイプの「多段ドリルダウン」も考えられるそうだが思い出せないとのこと)。

Groongaにはキーを二個使ってドリルダウンができる機能があるそうだ。列指向データベースなので大分類カラム(「雑誌」)、小分類カラム(「プログラミング」)のデータはそれぞれの場所にまとまって置かれているが、ドリルダウンする時に、それぞれから同じレコードの物を取り出してペアにしてまとめることができるのだ。

例えば、何かで検索して結果セットが得られた後に、ドリルダウン結果を大分類と小分類のペアである[雑誌,プログラミング]、[ハードカバー,小説]……の集まりというデータとみなして作ることができる。つまり「大分類が雑誌で小分類がプログラミング」というレコード(本)が何件あるか、という結果を作ることができる。

この結果を使えば、大分類+小分類での絞り込みをサポートすることができる。が、これだけだと大分類のみでの絞り込み結果を出さない。その結果も勿論欲しいので、更に大分類カラムのみの集計もする……ということはしないで、Groongaは頑張って、あるカラムの一回のスキャンで色んな結果用の処理を行うようになっているらしい(ということは、インデックスを調べる前の計画を頑張っているということかな?-> 別に頑張ると言うほど大変なことではなかった)。

また、この話は(Groongaでは)二個に限らず、n個に一般化できるらしい。

ユーザー定義の集約関数

参加者から「平均、最大値……」といった予め用意された関数以外の、ユーザーが定義した関数は使えるのか、という質問がでた。結論は「できない」。

一つはいいインターフェイスがないから。もう一つは、そういう機能を入れると遅くなってしまうから。

ドリルダウン結果のデータ構造

ドリルダウンの結果はハッシュテーブルになっている。

分類カラムの集計処理中、「雑誌」という物を見付けた場合、 「雑誌」が、ドリルダウン結果に既にあるかどうかを素早く知る必要があるからだ。なければ結果データに「雑誌」を加えて「1件」というデータにすることになるし、あれば既存の値をインクリメントする必要がある。

ハッシュのキーはカラムの値その物だとして、バリューの方は、Groongaでは「バリュー」という物になっているらしい。配列とかではない。どんなデータでもよい長さの決まったバイト列で、それを使用する機能の方で適宜解釈して使う、とのこと。

このバリューの方は、ここまでの話だと合計や最大値などになるので、単独の数値でよさそう。だがもっと別の物を入れてもよくて、実際、全文検索結果レコードの内容を入れると便利になる。ドリルダウン結果に加えて、より詳細な結果を同時に見せられるからだ。例えばGoogle検索でたまに、サイトの下位ページが出ることがあるが、そういった情報を出すために検索結果データの内容を使うことができる。

DroongaをインストールするItamaeレシピ

今日の日記はGroonga Advent Calendar 2015の五日目です。昨日はcosmo0920さんのGroonga族のHomebrewの変遷を振り返るでした。やっぱりコマンド一つで簡単にインストールできるのはよい。しかし、そのためには陰で誰かが苦労しているということも伺える記事だった。

今日は、Homebrewなどで一発インストールのできないGroonga族の一員、Droongaのインストールについて書く。

Groongaにはレプリケーション機能がない。DroongaはGroongaを複数のマシンにレプリケーションさせるプロダクトだ。公式サイトのほか、去年のGroonga Advent Calendarにも記事があって、とても面白く読んだ。Droongaが何かということはこれらを見てほしい。

公式サイトには勿論インストール手順も書かれているのだが、今日時点でこれはうまくいかない。そこで僕は、サーバーの構成管理ツールであるItamaeのレシピを作ってインストールしている。今日はそれを使ったインストール方法を書こうと思う。以下、Vagrantを使ってUbuntu 15.04の環境で実行している。マシンイメージはVagrant Cloudから公式のイメージを持って来た。

Droongaのインストール時にはメモリーが必要なので、Vagrantfileに設定を書いて2GiBくらい確保しておく。

Vagrant.configure(2) do |config|
  config.vm.define :ubuntu1504 do |node|
    node.vm.box = "ubuntu/vivid64"
    node.vm.provider "virtualbox" do |provider|
      provider.memory = 2048
    end
  end
  # :
  # :
end

バーチャルマシンを起動したら、まずログインして環境を更新する。

[host]$ vagrant up ubuntu1504
[host]$ vagrant ssh ubuntu1504
[vm]$ sudo apt-get update
[vm]$ sudo apt-get upgrade -y

ここでようやくレシピの登場。GitHubに上げている(https://github.com/KitaitiMakoto/itamae-plugin-recipe-droonga)。gemまたはItamaeプラグインの形をしているがrubygems.orgには上げていないので、git cloneで持って来る必要がある。もっと一般的にしてからリリースしたいなと思って、そのまま時間が過ぎてしまっているのだ……。

[host]$ git clone https://github.com/KitaitiMakoto/itamae-plugin-recipe-droonga.git

リポジトリーをクローンしたら、Itamaeのレシピファイル(ここではrecipe.rb)を用意して、以下のように一行書く。

include_recipe "./itamae-plugin-recipe-droonga
/lib/itamae/plugin/recipe/droonga/default.rb"

そうしたらItamaeを実行すればよい。簡単だ。但し時間は掛かる。

[host]$ itamae ssh --vagrant --host ubuntu1504 recipe.rb

(上のイメージだとこれでいいが、DigitalOceanだと依存パッケージが足りなくてうまくいかなかったかも知れない。エラーメッセージを見ながら必要な物をインストールしてほしい。)

Droongaは二つのコンポーネントからなっている。Groongaデータベースを操作したり、他ノードと連携してレプリケーションを実現するDroonga Engineと、そこへのHTTPインターフェイスを提供するDroonga HTTP Serverだ。それぞれそれ用のプロセスを起動する必要がある。

公式サイトの記事ではserviceコマンドを使ってこれをコントロールすることになっているが、Ubuntuでは15.04からUpstartに代わってsystemdが導入されたので、レシピではsystemctlコマンドを使うようにしている。

[vm]$ sudo systemctl status droonga-engine
● droonga-engine.service - Droonga Engine
Loaded: loaded (/lib/systemd/system/droonga-engine.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2015-12-04 20:09:56 UTC; 37s ago
Main PID: 30190 (droonga-engine)
CGroup: /system.slice/droonga-engine.service
(snip)
[vm]$ sudo systemctl status droonga-http-server
● droonga-http-server.service - Droonga HTTP Server
Loaded: loaded (/lib/systemd/system/droonga-http-server.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2015-12-04 20:09:57 UTC; 2min 11s ago
Main PID: 30228 (node)
CGroup: /system.slice/droonga-http-server.service
(snip)

これでDroongaが動くはずだし、実際EPUB Searcherデモサイトではこの方法でインストールして、現在でも動作している。

尚、Droongaを動かすには、内部・外部から、hostnameで返って来るホスト名で名前解決できる必要がある。hostnameと違うホスト名を使いたい場合は、レシピのインストールの箇所(Droonga Engine該当箇所Droonga HTTP Server該当箇所)の最後のbash実行時にHOST環境を設定し、また/etc/hostsでも設定する必要があるので書き換えること。

# :
# :
execute "curl -sL https://deb.nodesource.com/setup_0.12 | bash HOST=..." do
  not_if "test -e /etc/apt/sources.list.d/nodesource.list"
end
# :
# :
execute "curl https://raw.githubusercontent.com/droonga/droonga-engine/master/install.sh | bash HOST=..." do
  not_if "type droonga-engine"
end
# :
# :

Firefox for AndroidでもPolymerが動作するようにする

この日記はPolymer 1.2.1で作っているのだが、この前まで僕のメインブラウザーであるFirefox for Androidでは読めなかった。今でもPolymer Element Catalogのサイトを見るとそれが体験できる。Firefox for PCでは問題ない。Firefox for iOSは知らない。

webcomponentsjsやPolymerにconsole.log()を仕込みながらプリントデバッグを頑張って原因を突き止めたところ、webcomponentsjsでのHTMLインポートの検出に問題があることが分かった。現時点でのwebcomponentsjsでは、ブラウザーにHTMLインポートの機能があるかどうかを、link要素の(JavaScriptの)オブジェクトにimportプロパティ(あれば関数)が存在するかどうか、in演算子で確認してチェックして判断している(該当箇所)。HTMLインポートをサポートしていないブラウザー(Firefox for PCなど)ではimportプロパティが存在せず、その場合はshimを使う。ところがFirefox for Androidでは、「link要素にimportプロパティが存在する」「しかしHTMLインポート機能はサポートしていない」ということになっている。link.importが、nullになっているのだ。たとえnullであっても、値が存在すればin演算子はtrueを返す。従ってFirefox for AndroidにはHTMLインポート機能が存在する、とwebcomponentsjsは判断しているわけだ。

一応、バグレポートはした。プルリクエストはリクエストしなかった。コントリビューションページによると、コントリビュートするにはライセンスに同意する必要がある。それは構わなかったのだが、同意手続きの過程で住所を入力欄が現れた。それも必須項目として。漠然と不安を覚えてプルリクエストは躊躇ってしまった。

webcomponentsjsでこの問題が対応されるかは分からない。だから今この日記ではこんなワークアラウンドを入れている。

(function() {
  var ua = navigator.userAgent;
  if (ua.indexOf("Android") !== -1 &&
    ua.indexOf("Firefox") !== -1 &&
    document.createElement("link").import === null) {
    delete HTMLLinkElement.prototype.import;
  }
})();

これを、webcomponentsjsをロードするscriptタグのに置いている。if節の条件はnullのチェックだけでよさそうだが、そうするとなぜかChromiumやChromeでページが読めなくなってしまったので、プラットフォームも判断している。なぜ読めなくなったかは調べていない。