フィールド f1 が abc で始まるものを検索するのに q=f1:abc* としたり、abc がフィールド f1 内に現れるものを検索するのに q=f1:*abc* とするのは誤りである。一般的に前者は前方一致検索、後者は中間一致検索などと呼ばれるが、転置インデックス方式の検索エンジンでは検索対象の最小単位が単語となり、単語の完全一致が基本となる。したがって、「前方一致検索」「中間一致検索」というような概念がない。検索対象フィールドが単語の場合は完全一致検索となるし、2単語以上のフィールドであれば中間一致検索となる。したがって、中間一致検索(単語の完全一致)をしたいのであれば、q=f1:*abc* とはせずに q=f1:abc とすればよろしい。q=f1:*abc* で検索できているように見えているのは、いろいろなパターンが考えられるが、文字列解析の過程で * が落とされて結果的に abc という単語検索になっている、などが考えられる。
では前方一致はどうだろう。前方一致検索をしたい場合は、転置インデックスの作り方を工夫する。具体的には EdgeNGramTokenizer(場面によっては EdgeNGramTokenFilter)を使ってインデックスを作成する。これを使うと、abcdefg というフィールド文字列は次のように単語分割され、インデックスが作られる。
a ab abc abcd abcde abcdef abcdefg
このフィールドに対し、abc という文字列で前方一致検索をしたければ、クエリ側はトークナイズせず、つまり KeywordTokenizer を使って(場合によっては LowerCaseFilter を組み合わせてもよい。その場合はインデックス側にも適用する。以下同じ)次のように検索する。
q=f1:abc
単語の完全一致検索が基本の検索エンジンで従来型の「前方一致検索」「中間一致検索」をしたい場合、* を使う検索は Solr あるあるであるが、気持ちはわかるが間違いである。これは次で述べる後方一致検索でも同様である。
同じく、フィールド値が abc で終わる文書を探すという意味を込めて後方一致検索をしたいというので q=f1:*abc というクエリを発行するのも Solr あるあるであるが、誤りである。Lucene の転置インデックスは単語がソートされた状態で格納されていることを利用して二分探索される。したがって、単語の末尾が一致しているかどうかを確認するには、転置インデックス上の単語を総なめしなければならなくなってしまう。そのため、高速検索を信条とするLuceneにはそのような検索は用意されていない(APIを使って総なめすることは可能)。では Lucene/Solr で後方一致検索はできないのかというとそんなことはなく、「文字列を逆転させて前方一致にする」というテクニックを使う。具体的にはインデックス側、クエリ側双方で ReverseStringFilter を適用する。
インデックス側では、KeywordTokenizer+ReverseStringFilter+EdgeNGramTokenFilter を用いる。すると、abcdefg という文字列は、次のようにインデックス中に展開される。
g gf gfe gfed gfedc gfedcb gfedcba
クエリ側は KeywordTokenizer+ReverseStringFilter を適用する。すると、efg という文字列の後方一致検索は、q=f1:efg と投げればよい。すると当該フィールドで文字列が逆転された上で検索されることで、gfe がヒットする。
ファセットによる絞り込み検索で fq を使わず、q パラメータに AND で絞り込み条件を追加していくのもよく見る Solr あるあるであるが、これも誤りである。fq による検索結果の絞り込みも、AND 条件追加による検索の絞り込みも、返される文書集合は同じである。しかし文書の順番(ランキング)を見てみれば両者は異なることがわかる。メインのクエリ(q パラメータ)に AND で絞り込み条件を追加してしまうと、それはスコア計算の対象になってしまうので、AND を使う方法だと絞り込むたびにランキングが異なってしまう。つまりユーザーから見ると、ファセットのリンクをクリックするたびに文書の表示順が異なってしまう。これではユーザーが混乱するだろう。また、filterCache がうまく使われない、という別の問題もはらんでいる。
ファセットによる絞り込み検索では、AND を使わず fq を使う、と覚えておこう。
「スコアが0.5未満の文書は足切りして返却されないようにしたい」などといった要望も Solr あるあるであるが、これもナンセンスであり、実施しようとして奮闘すること自体、時間の無駄である。
スコア関数のパラメーターはそれがベクトル空間モデルベースの TFIDFSimilarity であれ、確率モデルベースの BM25Similarity であれ、クエリと各文書である。スコアはクエリを固定したときに、各文書に付与される当該クエリとの関連度合いを表現したものであるから、クエリが変わってしまうと相互に比較することに意味がない。クエリ A でスコア 0.4 を獲得した文書がクエリ A に非常に関連しているが、クエリ B でスコア 0.6 を獲得した文書がクエリ B にたいして関連しているとは見えない、ということがおおいにありえる。
ハイライト機能(検索キーワードを含む文書スニペットを切り出し、キーワードを太字など目立つように表示する機能)について、Solr が持っているハイライト機能を使わずに、Webアプリケーション(JavaScript含む)でがんばってしまう、というのもたまに見る Solr あるあるである。Solr にハイライト機能があることを知らずに自前で実装してしまうパターン、Solrにハイライト機能があることは知っているが、自前の方がよいと信じて自前で実装してしまうパターンがあるようだ。前者は知らなかったということでお気の毒という他ない。後者は、なぜそのような発想になるのかよくわからない。Solr のハイライト機能はそれほどよくできている。自前で頑張る方式のメリットをあえて上げるならば、たとえば、サーバ側のCPU負荷を下げるため、ユーザーのブラウザにてJavaScriptでハイライトする場合、が考えられるかもしれない。しかしそのメリットの裏で、次の機能を備える Solr の非常によくできたハイライト機能を使わないデメリットをアプリケーションに実装してしまっていることに気づいているだろうか。
ということで、Solr のハイライト機能は検索アプリケーションのことをよく考慮して用意されており、大変よくできている。自前で用意するというのはたいていの場合誤っていると断言できる。
なお、検索結果一覧表示時にハイライトスニペットを作成せず、ユーザーのクリックにより当該文書表示時に検索キーワードを太字など目立つように表示する機能は、ここでいうハイライト機能とは異なる。ここでいうハイライト機能は、検索結果一覧表示時に、ユーザーに適切な文書をすばやく選択してもらうためのアシスト機能に相当する。ユーザーはハイライトスニペットを、文書をクリックするかどうかの参考にするため、Solr のハイライト機能はぜひ積極的に利用しよう(ただし、商品の写真を検索結果一覧に表示するようなECサイトの検索アプリのようなものは除く)。
ここで上げた以外にも、RDB の正規化されたテーブルをそのまま Solr のインデックススキーマにマッピングし Solr の JOIN を使うあるあるや、親子階層のファセットを自前で頑張るあるある、クエリを投げる際に常にダブルクォーテーションでくくってしまうあるあるなどもある。
Solr は OSS になって10年を超えるソフトウェアとなった。機能も十分豊富で、やりたいことの多くはすでに Solr が持っていることも多い。アプリケーションで頑張る前にぜひマニュアルや書籍等で該当する機能がないか、使い方がアーキテクチャに沿ったものになっているか、今一度考えてみることをお勧めしたい。
さて、今年はおそらく本記事が最後となるでしょう。更新頻度が少なくなったのにもかかわらず、本年もご愛読いただきありがとうございました。皆様、よいお年をお迎えください!
]]>今回私が気になっているのが、LUCENE-6819 による修正である。
Deprecate index-time boosts?
https://issues.apache.org/jira/browse/LUCENE-6819
タイトルだけ見ると、「インデクシング時ブースト(重み付け)を止めよう」、もっと深読みすると「クエリ時に重み付けが指定できるからインデクシング時に重み付けしなくてもいんじゃね?」ととらえてしまう方もいるかもしれない。もちろん、インデクシング時重み付けとクエリ時重み付けでは、大きく意味や目的が異なる。[1]
本記事では、LUCENE-6819 と関連する他の修正と合わせ、Lucene 7.0 以降で fieldNorm がどのようになるか、簡単に解説しよう。
LUCENE-6819このチケットでは、タイトル通り、インデクシング時のboostを止めよう、というものである。もう少し細かく言うと、fieldNorm という、各ドキュメントの各フィールド(ただし、omitNorms=trueのフィールドを除く)ごとに1バイトでエンコーディングされた値の、次の式からboostを取り除こう、というものである。
fieldNorm = lengthNorm * boost
ちなみにこの式は、BM25Similarity が登場する以前の、Lucene/Solr の標準であったベクトル空間モデルによるランキング計算(各ドキュメントのクエリに対する類似度計算)に強く依存している。Lucene/Solr はコサイン類似度ベースだが、上のように計算された fieldNorm を加味しており、BM25 で考慮されている文書長はとっくに加味されている。[2]
さて、これからboostを除けば、次のようになる。
fieldNorm = lengthNorm
lengthNorm はデフォルトで次のように計算される。
lengthNorm = 1 / sqrt(numTerms)
numTermsは当該フィールドの単語数である。したがって、lengthNorm は単語数が1の時は 1 / sqrt(1) = 1 / 1 = 1、単語数が2の時は 1 / sqrt(2) = 1 / 1.414213562373095… = 0.70710678118655、という具合になるので、0 < lengthNorm <= 1 となる。しかし、Lucene 6.x 以前では、boost が lengthNorm にかかった fieldNorm を保存しなければならず、boost の値はユーザの設定次第なので最悪 Float.MAX_VALUE になってしまうことも考えられる。そこで 0 から Float.MAX_VALUE の広大な値域をわずか1バイトにマッピングしているのが Lucene 6.x である(下図)。
しかし、ほとんどのユーザーはインデクシング時重み付けなどしないので、上図グレーの部分がもったいない。boostがかけられる可能性を考えて1バイトの半分を残しているのだが、ほとんどのユーザーはboost=1なので、非常にもったいない、かつ、低精度な使い方になっている。
そこで、LUCENE-6819 では:
LUCENE-6819 ではまだ fieldNorm の1バイトが有効活用されていない。そこでこのチケットでは、フィールド長(単語数)をSmallFloat.intoTobyte4(numTerms)でエンコードして保存するように修正された。たとえば単語数が1000のときは、SmallFloat.intoTobyte4(1000)が87となり、これが記録される。デコードにはSmallFloat.byte4ToInt()が使われる。SmallFloat.byte4ToInt(87)は984となる。ちなみに、1〜40はエンコードしても1〜40を出力する(変わらない)。長さ41から徐々にエンコードによる誤差が大きくなる(下図)。
Lucene 6.x では boost / sqrt(numTerms) の計算結果が1バイトに圧縮されて記録されていたが、Lucene 7.0以降では numTerms がそのままエンコードされて1バイトに記録される。もはや BM25Similarity が標準なので、1 / sqrt(numTerms) を計算する意味自体がない、ということだ。
Lucene/Solr 7.0 以降も omitNorms 指定は相変わらず有効である。
ロンウイットの社内勉強会で説明に使用したスライドも公開しているので、合わせて読んで欲しい。
[1] ロンウイット主催のトレーニングコース「Solr基礎」では、このような大事な基礎を丁寧に教えています。お申し込みはこちらから。
[2] ロンウイット主催のトレーニングコース「Apache Mahout & Sparkではじめる機械学習」では、機械学習のさまざまなモデルやアルゴリズムを丁寧に解説しています。ベーシックな機械学習の解説にとどまらず、本記事にあるような、検索エンジンのランキングの元になるスコア計算の考え方なども含み、ソフトウェア開発の実際の現場で役に立つ内容を網羅しています。お申し込みはこちらから。
]]>Solrでランキング学習というと、まもなくリリースされる Solr 6.4.0 に含まれる SOLR-8542 を思い浮かべる方もいるかもしれない。しかしここでは、「第19回 Lucene/Solr 勉強会」でシーマークの山本社長が発表したNLP4Lを使った方法を採り上げたい。以下の理由からSOLR-8542よりはるかに使い方が簡単だからだ。
また、NLP4Lはランキング学習の論文で紹介される一般的な特徴であるTF、IDF、TF*IDFなどが扱えるのに対し、SOLR-8542はこれらの特徴は扱えない(扱う予定もなさそう)。さらにSOLR-8542はモデルを利用する部分でSolrに依存するところが大きいが、NLP4Lでは大事な部分はLuceneレベルで行われ、LTRの主要な部分の実装がコンパクトである。したがってElasticsearch対応も比較的簡単である。もちろんSOLR-8542の方が優れている部分もあり、使用する特徴をSolrのクエリ式で書けるところはNLP4Lよりも柔軟で優位性があるといえる。
LTRは教師あり学習を基本とする。教師データのイメージは、「あるクエリについてそれぞれの文書がどのくらい関連しているか」というラベル(NLP4Lではrelevance degreeと呼んでいる)がつけられている一連のレコードである。この形式のデータを学習するアルゴリズムはPointwiseアプローチと呼ばれる。他にも「あるクエリについて文書ペアがどちらがより関連度が高いか」というラベルがついたデータを扱うPairwise、「あるクエリについて文書集合が関連度順にどのようにリストされるか」というラベルがついたデータを扱うListwiseがある。
前述の3番目の「教師データの準備方法」について、NLP4Lでは付属の「アノテーションGUI」を使った方法と、「アクセスログ(aka クリックログ、NLP4Lでは特別に「インプレッションログ」と呼んで通常のアクセスログと区別している)からクリックモデルを計算して関連度を自動算出する」方法が用意されている。本記事では説明の簡略化のため前者の「アノテーションGUI」を使った方法を採り上げる。しかし本番システムで人手でアノテーションを行うのはなにかと大変だ。本記事を読んでランキング学習の手順を理解したら、ぜひ後者の「アクセスログからクリックモデルを計算して関連度を自動算出する」方法に挑戦していただきたい。
以下、NLP4Lでのランキング学習の手順の概要である。この順番で以降説明を進める。
前述の通り、NLP4Lには詳細なLTRのためのマニュアルが付属するのでここであらためて記事にする必要もないかもしれないが、本記事では対象文書セットとしてlivedoorニュースコーパスを用いることで、一連の操作方法をより具体的に説明している。興味のある方は、下記手順通りにインストールと設定を進めれば、Solrでランキング学習の実際を体験することができる。より詳細を知りたい場合にはNLP4Lのマニュアルをご覧いただきたい。
適当なディレクトリにてSolr 6.3.0(本記事執筆時の最新)をダウンロード、展開する。本記事では/opt/nlp4l/solr-6.3.0ディレクトリにSolrを展開するとして話を進める。
$ pwd /opt/nlp4l $ wget http://ftp.tsukuba.wide.ad.jp/software/apache/lucene/solr/6.3.0/solr-6.3.0.tgz $ tar xvzf solr-6.3.0.tgz
Solrを起動し、collection1という名前のコアを作成する。
$ cd solr-6.3.0 $ ./bin/solr start $ ./bin/solr create_core -c collection1 -d sample_techproducts_configs
適当なディレクトリにてgithubで公開されているNLP4L/solrプロジェクトをダウンロード、ビルド、Solrにデプロイする。
$ pwd /somewhere/NLP4L $ git clone https://github.com/NLP4L/solr.git $ cd solr $ mvn package $ cp target/nlp4l-solr-1.1-SNAPSHOT.jar /opt/nlp4l/solr-6.3.0/server/solr-webapp/webapp/WEB-INF/lib
NLP4L/solrはtypesafe社のconfigライブラリが必要なので、以下のように入手、ビルド、Solrにデプロイする。
$ cd /somewhere $ wget https://github.com/typesafehub/config/archive/v1.3.1.tar.gz $ tar xvzf v1.3.1.tar.gz $ cd config-1.3.1 $ sbt package $ cp config/target/config-1.3.0-20170120T044439.jar /opt/nlp4l/solr-6.3.0/server/solr-webapp/webapp/WEB-INF/lib
Solrに付属のJettyのwebapp設定を次のように行う。
$ cd /opt/nlp4l/solr-6.3.0 $ vi server/solr-webapp/webapp/WEB-INF/web.xml
fileReceiver org.nlp4l.solr.servlet.FileReceiver root_path /opt/nlp4l/solr-6.3.0/server/solr/collection1 fileReceiver /nlp4l/receive/file
また、選択可能な特徴を以下のようにJSON設定ファイルに用意する。
$ vi server/solr/collection1/conf/ltr_features.conf
{ "features": [ { "name": "TF in title", "class": "org.nlp4l.solr.ltr.FieldFeatureTFExtractorFactory", "params": { "field": "title" } }, { "name": "TF in body", "class": "org.nlp4l.solr.ltr.FieldFeatureTFExtractorFactory", "params": { "field": "body" } }, { "name": "IDF in title", "class": "org.nlp4l.solr.ltr.FieldFeatureIDFExtractorFactory", "params": { "field": "title" } }, { "name": "IDF in body", "class": "org.nlp4l.solr.ltr.FieldFeatureIDFExtractorFactory", "params": { "field": "body" } }, { "name": "TF*IDF in title", "class": "org.nlp4l.solr.ltr.FieldFeatureTFIDFExtractorFactory", "params": { "field": "title" } }, { "name": "TF*IDF in body", "class": "org.nlp4l.solr.ltr.FieldFeatureTFIDFExtractorFactory", "params": { "field": "body" } } ] }
また、solrconfig.xmlを以下のように編集する。
$ vi server/solr/collection1/conf/solrconfig.xml
標準の /select リクエストハンドラのdefaultsパラメータに以下を追加する。
edismax title body
NLP4L-LTRからの特徴抽出リクエストに応答するためのリクエストハンドラを以下のように設定する。
ltr_features.conf
最後に、PRankクエリを呼び出すPRankQParserPluginを次のように設定する(PRank以外にRankingSVMが選択可能)。
collection1/conf/ltr_features.conf collection1/conf/ltr_model.conf
またSolrあるあるであるが、Elevation Component関連の設定もここで削除しておこう。
livedoor ニュースコーパスをSolrに登録するために、まずスキーマを設定する(APIを通じてスキーマ変更するのが推奨だが、面倒なのでエディタで編集してしまう)。
$ vi server/solr/collection1/conf/managed-schema
url
されに不要なcopyField設定は取り除いておく。ここでSolr設定の変更を有効化するためにコアのリロードまたは再起動を行う。
そしてlivedoor ニュースコーパスを入手し、Solrに登録する。
$ cd /somewhere $ wget http://www.rondhuit.com/download/livedoor-news-data.tar.gz $ tar xvzf livedoor-news-data.tar.gz $ /opt/nlp4l/solr-6.3.0/bin/post *.xml
NLP4L/nlp4l プロジェクトを github から以下のように入手し起動する。
$ cd /somewhere/NLP4L $ git clone https://github.com/NLP4L/nlp4l.git $ cd nlp4l $ cp conf/application.conf.sample conf/application.conf $ vi conf/application.conf $ ./activator run
そしてWebブラウザから http://localhost:9000/ にアクセスする。NLP4L-LTRというメニューを選び、画面上部に表示されるメニューのConfigをクリックする。そして<<New>>リンクをクリックすると、新しいConfig設定をするように促される。次の表を参考に設定する。
パラメータ名 | 説明 | 本記事での設定値 |
---|---|---|
Name | この設定の名前 | test |
Annotation Type | アノテーションの種別。現在はpointwiseしか選べない。これは現在のNLP4Lが学習アルゴリズムがPointwiseのものしか扱えない、という意味ではない。実際、NLP4LがサポートしているRankingSVMはPairwiseであるが、Pointwise形式のデータを内部でPairwiseに変換している。 | pointwise |
Relevance Degree | pointwiseにて人手でつける星の数をいくつからいくつまでとするか。 | 0 to 3 |
Trainer Factory Class | 学習アルゴリズムのファクトリクラス名を指定する。PRankTrainerFactoryまたはRankingSVMTrainerFactoryが選べる。 | org.nlp4l.ltr.support.procs.PRankTrainerFactory |
- settings | 学習の繰り返し数。 | { "numIterations": 2000 } |
Search URL | 教師データ作成時、人手でアノテーションする際、連携先となるSolrのURL。 | http://localhost:8983/solr/collection1/select?q=${query}&wt=json&hl=true&hl.fl=title,body&f.body.hl.snippets=3&hl.simple.pre=<b style="background-color:yellow">&hl.simple.post=</b> |
Feature Extract URL | 特徴抽出する際の連携先となるSolrのURL。solrconfig.xmlに設定したリクエストハンドラのURLを記載する。 | http://localhost:8983/solr/collection1/features |
Feature Extract Config | 特徴抽出時に参照する、Solr側に配置されたJSON設定ファイル名。 | ltr_features.conf |
Document Unique Field | Solrのユニークキーのフィールド名。 | url |
Document Title Field | Solr文書のタイトルフィールド名。人手によるアノテーション実施時に、タイトルとして画面表示されるフィールド名を指定する。 | title |
Document Body Field | Solr文書の本文フィールド名。人手によるアノテーション実施時に、本文として画面表示されるフィールド名を指定する。 | body |
Deployer Factory Class | 学習して得たモデルファイルをSolrにデプロイするときに使用するクラスのファクトリ。 | org.nlp4l.ltr.support.procs.HttpFileTransferDeployerFactory |
- settings | 上記クラスのURL。 | { "deployToUrl": "http://localhost:8983/solr/nlp4l/receive/file", "deployToFile": "conf/ltr_model.conf" } |
[Save]ボタンをクリックして[Load]をクリックすると、画面上部にtestという名前のConfigがロードされたことが示され、Query、Annotation、Feature、Trainingというメニュー項目が現れる。
教師データを作成する場合はQueryまたはAnnotationメニューを使う。なお前述の通り、本記事ではアノテーションGUIを使った教師データの作成方法を説明する。アクセスログ(インプレッションログ)からクリックモデルを計算することで教師データを安価に用意したい場合は、Queryメニューの[Import]ボタンをクリックしてログファイルをロードする。
Queryメニューにはファイルを選択するボタンがあり、これをクリックするとクエリ一覧が記載されたテキストファイルを選べるようになる。このテキストファイルには1行1クエリを記載しておく。このファイルをロードすると、下図のようにクエリが表示される。
このクエリのリンクをクリックすると連携先のSolrにてクエリが実行され、Annotation画面に結果が表示される。この画面ではSearchテキストボックスに選択したクエリが表示されているが、Queryメニューから始めなくても、Annotationメニューを選んでこのSearchテキストボックスに直接クエリを入れても同じである。
Annotation画面にて各クエリと返された文書の関連度を考えながら、右側に表示されている星の数を適切に選んでいく。1画面分終わったら、[Save]ボタンで保存し、[Next]で次のクエリに進む。一通り終了したら次のステップである特徴抽出に進む。
Featureメニューを選択する。すると下図のようなプログレスバーが現れる。[Extract]で特徴抽出が開始するが、すでにプログレスバーが緑色になっているときは、まず[Clear]ボタンをクリックしてから[Extract]を行う。終了するとDONEと表示される。
Trainigメニューを選択し、左側にある<<New>>をクリックする。するとSolrに配置した設定ファイル(ltr_features.conf)にしたがって特徴を選ぶチェックボックスが現れる。このチェックボックスで適当に特徴を選んで[Start]をクリックする。ここでも学習の進行状況がプログレスバーで示され、終了すると下のようにモデルファイルの内容が表示される。ここではPRankを選択したので、各特徴に対する重みと、3つの閾値(星の数を最大3にしたことに由来)が返される。
{ "model" : { "name" : "prank", "type" : "prank", "weights" : [ { "name" : "TF in title", "weight" : -113 }, { "name" : "TF in body", "weight" : -14 }, { "name" : "IDF in body", "weight" : -322.44384765625 } ], "bs" : [ -2818, -2374, 6450 ] } }
ランキング学習が終了すると[Deploy]ボタンが表示されるので、これをクリックすることで連携先のSolrにHTTPで転送される。
Solr側ではこの学習モデルを有効化するために、コアのリロードが必要である。Solrの管理画面からコアのリロードを行うのが簡単だ。
NLP4L-LTRはSOLR-8542とは異なり、Solr標準のリランクを使って呼び出すのでrq={!rerank …}パラメータを利用する。前述のsolrconfig.xmlに設定したprank queryParserを呼び出すには次のように検索を実行する。
http://localhost:8983/solr/collection1/select?indent=on&q=社会人 学生&rq={!rerank reRankQuery=$rqq}&rqq={!prank}社会人 学生&debugQuery=on
Solr標準のリランクは、(前述のsolrconfig.xmlの設定にしたがい)最初に通常のedismaxクエリを実行し、次に上位の200クエリ(reRankDocsのデフォルト)についてランキング学習モデルに基づいた再ランキングを行う。デバッグ情報のスコア詳細欄を見ると最初の2行は次のようになっている。
14.37461 = combined first and second pass score using class org.apache.solr.search.ReRankQParserPlugin$ReRankQueryRescorer 10.37461 = first pass score
最初の行は2段階クエリのスコアの和が14.37461になっていることを示し、次の行は第1段階のクエリ(ここでは通常のedismax)のスコアが10.37461であることを示している。3行目以降はedismaxのスコア詳細なのでここではスキップしよう。
第2段階のクエリ、つまりPRankのクエリで計算したスコア合計は2.0であり、その詳細は下記のブロックで示されている。
2.0 = second pass score 2.0 = is the index of bs(-2818.0,-2374.0,6450.0) > -2338.074707 sum of: -113.0 = weight: -113.0 * feature: 1.0 sum of: 0.0 = no matching terms 0.0 = no matching terms 1.0 = freq: 1 -84.0 = weight: -14.0 * feature: 6.0 sum of: 2.0 = freq: 2 3.0 = freq: 3 1.0 = freq: 1 -2141.0747 = weight: -322.44385 * feature: 6.640147 sum of: 2.762864 = log(numDocs: 7368/docFreq: 465) 0.55211145 = log(numDocs: 7368/docFreq: 4242) 3.3251717 = log(numDocs: 7368/docFreq: 265)
2.0というスコアは2倍(reRankWeightのデフォルト)され4.0となり、最終スコア合計が14.37461となる。2.0というスコアはどうやって計算されたかというと、モデルで与えられた重みを使って計算した結果が-2338.074707となるが、これは境界値の[-2374.0,6450.0]の範囲内にあるため2.0となる。
livedoorニュースコーパスはフィールド数、文書数とも少ないので十分な教師データを作るのが難しいが、実運用されているフィールド数、文書数とも大きなデータにおいて、ランキング学習は通常のSimilarityが算出するスコアをより良く改善することが期待できる。ぜひ試していただきたい。
]]>KEAはニュージーランドのワイカト大学で開発されている、自然言語で書かれた文書からキーフレーズ(キーワード)を自動抽出するプログラムである。KEAはKeyphrase Extraction Algorithmの略であり、KEAプログラムを構成するアルゴリズムそのものを指す場合もある。
文書に付加されたキーフレーズは、当該文書の意味的メタデータであり、文書の極端に短いサマリーとも言える。そのため、文書を読み込む時間がない場合にその文書のメタデータであるキーフレーズのリストをながめるだけでおおまかな内容をつかむことができる。たとえば限られた時間である調べ物をしていたとしよう。その調査に関係しそうな文書は目の前に山積みされているが、すべてに目を通している時間はない。そんなときは、文書に振られているキーフレーズをまずながめて、関係しそうな文書だけを選んで読み始めることができる。
学術論文などではその論文の著者によってキーフレーズが付加されているものが多い。しかしながら一般の文書や書籍はキーフレーズがつけられていないものがほとんどである。KEAはそのような文書から自動的にキーフレーズを抽出しようというプログラムである。KEAは著者によりキーフレーズが付加された文書を読み込んでその特徴を学習し、キーフレーズがつけられていない未知の文書からキーフレーズを自動抽出するという、教師あり機械学習プログラムである。KEAは言語(英語や日本語など)に関係なく動作可能なアルゴリズムとなっている。
本稿はLuceneインデックスからキーフレーズを抽出しようという話なので、キーフレーズ抽出と情報検索の関係についても触れておこう。
まずKEAの作者らはキーワードという言葉ではなく、キーフレーズという言葉を使っている点に注目したい。キーワードというと重要な(キー)1つの単語を連想するが、ワードではなくフレーズ(成句)という言葉を使うことで、キーとなる1つ以上の単語の連なりも文書から抽出できるということが強調されている。
Luceneインデックスからキーフレーズが抽出できたとすると、情報検索にはどんないいことがあるだろうか。まず思い浮かぶのがクエリのサジェスチョン(またの名をオートコンプリート)である。Luceneインデックスはどうしても単語単位で文字列が管理されているので、サジェスチョンも単語単位となってしまう。しかしキーとなるフレーズが自動抽出できれば連続した複数の単語が一度にサジェストでき、ぐっとありがたみが増す。もしかして検索も同様である。
また検索結果一覧を表示するときに、ハイライト機能の代わりもしくはハイライト機能と一緒に当該文書のキーフレーズを表示することでユーザの文書選択の助けとすることも考えられる。さらにはファセット(絞り込み検索)のキーとすることも考えられるので、キーフレーズが抽出できることのメリットは大きい。
KEAの論文は格別難しいことが書いてあるわけではないので、時間のある方にはぜひ一読をお勧めする。本稿ではのちのちLuceneライブラリを使ったKEAの実装を解説するので、それが理解できるよう最低限の説明を行うことにしよう。
KEAにおける処理は「学習過程」と「キーフレーズ抽出過程」に大別されるが、両者に共通な処理としてキーフレーズの候補をリストアップするプロセスが存在する。キーフレーズ候補は機械的にリストアップされる。そのようにリストアップされた多数のキーフレーズ候補から、学習時はキーフレーズになるなりやすさ(またはなりにくさ)を学習する。そしてキーフレーズ抽出時は学習した確率モデルを参照して、多数のキーフレーズ候補をスコアづけしてスコアの大きいものから順にキーフレーズ候補を表示する。実際のキーフレーズ抽出時はそのように順位付けされたキーフレーズ候補のリストから適当なところで足切りを行う。
本稿で解説するKEAの実装は、Luceneを大いに活用する。学習のプログラムでは、既知の(著者によりキーフレーズがつけられた)文書が入ったLuceneインデックスを作成した後そこからモデルファイルを作成する。未知の(キーフレーズがつけられていない)文書からキーフレーズを抽出するプログラムでは、その文書をLuceneインデックス登録してからキーフレーズを抽出する。
KEAではキーフレーズの候補として、1つから最大3つの連続する単語を列挙する。たとえば、次のような文書があったとしよう。
Tokyo governor likes to go to Yugawara.
すると、KEAでは次のように10個の候補となるキーフレーズを列挙する。
キーフレーズ候補の列挙においては、ストップワードから始まるフレーズやストップワードで終わるフレーズは候補とならない。よって、単独のtoのみならず、like(s) toやto goなどは候補としてリストアップされない。また情報検索やNLPでよく行われる文字や単語の正規化もこの段階で行われる。よって、TokyoやYugawaraはtokyoやyugawaraに、likesはステミングされてlikeになる。
KEAでは単純ベイズ分類器を用いて教師ありデータ(著者によりキーフレーズがつけられた文書データ)からキーフレーズ候補におけるキーフレーズになるなりやすさ(P[yes])となりにくさ(P[no])を学習する。具体的には次のような式を用いる。
P[yes] = Y / (Y + N) * Pt[t|yes] * Pd[d|yes]
P[no] = N / (Y + N) * Pt[t|no] * Pd[d|no]
ここで"Y / (Y + N)"や"N / (Y + N)"の部分はキーフレーズ候補から求められる事前確率である。先の例文でアスタリスクがつけられたものがキーフレーズだとすると、つぎのようになる。
Y / (Y + N) = 2 / 10
N / (Y + N) = 8 / 10
KEAでは文書D中のフレーズPを表現するのに2つの特徴量を用いている。1つめはTF*IDFであり、次のように計算される。
TFIDF = freq(P,D) / size(D) (-log(df(P) / N))
freq(P,D)は文書D中にフレーズPが出現する回数、size(D)は文書の単語数、df(P)はフレーズPを含む文書数、Nは全文書数である。なおlogの底は2とする。
2つめの特徴量はfirst occurrence(別名、距離)と呼ばれるものである。これはフレーズPが文書Dに最初に登場する位置をsize(D)で割ったものである。たとえば先の例でtokyoは1/7、governorは2/7となる。
2つの特徴量は正の実数となるが、KEAでは連続値を離散化した上で各離散値ごとの事後確率を計算する。前述の式のPt[t|yes](またはPt[t|no])はTF*IDFの事後確率、Pd[d|yes](またはPd[d|no])は距離の事後確率である。
未知の文書Dからキーフレーズ抽出を行うには、Dから列挙したキーフレーズ候補について前述の2つの特徴量を計算してP[yes]とP[no]を求め、最終的に次の式を使ってスコアを算出して降順にソートし、適当なところで足切りする。
score(P,D) = P[yes] / (P[yes] + P[no])
なお、未知文書はモデルに含まれていないため、TF*IDFを計算する際は、df(P)とNは1を加算する。
では前述のアルゴリズムを元に、Luceneライブラリをフル活用してKEAプログラム(KEA-luceneと呼ぶことにする)を自作してみよう。ここで紹介するプログラムはGithubに公開している。なお、プログラムはわかりやすさを優先し、ディレクトリ名などが意図的にハードコーディングされている。
ところでKEAを実装するのになぜLuceneを使うのだろうか。KEAに限らず自然言語処理のツールを実装する場合、単語の数を数えたり、ある単語を含む文書の数を数えたり、それ以前に文書を単語に区切ったりすることがよく行われる。Luceneはこういった処理を行うのによく整備されたAPIを備えている。さらにはLuceneの転置インデックス(以下単にインデックスと呼ぶ)は単語辞書としても優秀だ。特にKEA-luceneのために使用したLucene APIを以下に紹介しよう。
Luceneでは、文章を単語に区切るのにAnalyzerクラスを用いる。KEA-luceneでは、トークナイズのためにStandardTokenizerを、小文字への正規化のためにLowerCaseFilterを、そして単語N-gramをサポートするためにShingleFilterを使っている。残念ながらKEAのストップワードの考え方はLuceneのStopFilterでは実現できないので、KEAStopFilterという独自TokenFilterを実装している。そして最終的に次のようにKEAAnalyzerを組み上げた。
public class KEAAnalyzer extends Analyzer { private final int n; public KEAAnalyzer(int n){ this.n = n; } @Override protected TokenStreamComponents createComponents(String fieldName) { Tokenizer source = new StandardTokenizer(); TokenStream lcf = new LowerCaseFilter(source); if(n == 1){ TokenStream stf = new KEAStopFilter(lcf, n, Commons.stopWords, Commons.beginStopWords, Commons.endStopWords); return new TokenStreamComponents(source, stf); } else{ assert n >= 2; ShingleFilter shf = new ShingleFilter(lcf, n, n); shf.setOutputUnigrams(false); KEAStopFilter keasf = new KEAStopFilter(shf, n, Commons.stopWords, Commons.beginStopWords, Commons.endStopWords); return new TokenStreamComponents(source, keasf); } } }
KEAの論文によると、conjunctions, articles, particles, prepositions, pronouns, anomalous verbs, adjectives および adverbs の各品詞からストップワードリストを構成している。これはLuceneがリストアップしているストップワードよりもはるかに多い。KEA-luceneではLuceneのストップワードに独自にストップワードを追加した。
さらにKEA-luceneでは単語N-gramのNを1から3に変えて個別のフィールドにインデックス登録するため、LuceneのPerFieldAnalyzerWrapperを次のように使用している。
public static Analyzer getKEAAnalyzer(String fieldName){ Mapamap = new HashMap<>(); amap.put(Commons.getFieldName(fieldName, 1), new KEAAnalyzer(1)); amap.put(Commons.getFieldName(fieldName, 2), new KEAAnalyzer(2)); amap.put(Commons.getFieldName(fieldName, 3), new KEAAnalyzer(3)); return new PerFieldAnalyzerWrapper(new StandardAnalyzer(), amap); }
Luceneは単語をキーにした転置インデックスを作成する。これを単語辞書として使うのは実に自然であり理にかなっているといえるだろう。フィールドfieldの単語を最初から最後まで走査するには次のようにする。ただしirはLuceneインデックスをオープンしているIndexReaderのオブジェクトである。
Terms terms = MultiFields.getTerms(ir, field); TermsEnum te = terms.iterator(); for(BytesRef rawPhrase = te.next(); rawPhrase != null; rawPhrase = te.next()){ String phrase = rawPhrase.utf8ToString(); : }
Luceneのインデックスはただ単に単語がリストアップされているだけではない。前述の2つの特徴量を計算するために必要となる統計量も保存されている。たとえばfreq(P,D)は文書D中にフレーズPが出現する回数であるが、これはMultiFieldsのgetTermDocsEnum()メソッドを使用して取得したPostingsEnumオブジェクトに対しnextDoc()で文書Dを特定した後、freq()を呼び出すことで取得できる。
またdf(P)はフレーズPを含む文書数、Nは全文書数であるがこれは次のようにして取得できる。
int dfreq = ir.docFreq(new Term(field, phrase)); : int n = ir.numDocs();
さらにはフレーズの距離を計算するためにポジション情報も必要になってくるが、これはMultiFieldsのgetTermPositionsEnum()メソッドを使ってPostingsEnumオブジェクトを取得後、advance()メソッドで文書を特定した後nextPosition()で最初のフレーズのポジションを求める。KEA-luceneでは1-gram, 2-gram, 3-gramを独立したフィールドにしているので、nextPosition()で取得したポジションはそのまま距離計算に用いて問題ない。
KEA-luceneではKEAModelBuilderがモデルの学習を行っている。このプログラムはここからダウンロードできる教師データ(MAUI.tar.gzを展開しさらにその中にあるfao30.tar.gzを展開する)をdata/fao30/ディレクトリに配置されることを前提に書かれているので注意していただきたい。
KEAではモデルの学習は単純ベイズを使っているので、学習には各種統計量をカウントした上で統計量の割り算を行う。割り算を計算しているのは後述のKeyphraseExtractor(2)プログラムであり、KEAModelBuilderプログラムでは統計量を求めているだけなので、正確にはモデルの学習というよりはモデルファイルを出力しているに過ぎない。
KEAModelBuilderが出力するモデルファイルは次のようなスペース区切りのテキストファイルである。
$ head features-model.txt 0.000852285 0.231328 false 0.000284095 0.980489 false 0.000426143 0.0124768 false 0.000426143 0.429134 false 2.01699e-05 0.0479968 false 0.000284095 0.0160665 false 0.000426143 0.726610 false 0.000136409 0.000752663 false 0.000226198 0.379661 false 0.000284095 0.478057 false
1つのレコードは1つのキーフレーズ候補を表している。最初の数値はTF*IDFで、2番目の数値は距離を表している。3列目はキーフレーズ(true)か否か(false)を表現するクラスである。
KEAModelBuilderプログラムは上記のようなモデルファイルを出力するために教師データからLuceneインデックスを作成し、Luceneインデックスを単語辞書とみなしてiterateしながら特徴量を計算しつつ、上記のモデルファイルを出力する。
参考までに処理時間を示しておこう。私のMacBook Pro(Processor 2.3 GHz Intel Core i7)では5分程度かかった。Luceneインデックスを作成するのは一瞬だが、Luceneインデックスを走査しながらモデルファイルを出力するのに少々時間がかかっている。
KEAでは特徴量の実数値をそのまま用いるのではなく、MDLP(最小記述長原理)により求めた離散値にマッピングして用いる。ここではMDLPの計算のためにRを使って次のように求めた。
data <- read.table('features-model.txt') mdlp(data)$cutp [[1]] [1] 0.0003538965 0.0013242950 0.0041024750 [[2]] [1] 0.0003553105 0.0144056500 0.0697899000
ここで得られた結果は、次のプログラムKeyphraseExtractor(2)で使用するので、cutp-model.txtというファイル名で保存しておく。
$ cat cutp-model.txt 0.0003538965 0.0013242950 0.0041024750 0.0003553105 0.0144056500 0.0697899000
RによるMDLP計算は同じ環境で30分程度かかった。
ではいよいよLuceneインデックスからキーフレーズを抽出してみよう。プログラムはKeyphraseExtractorとKeyphraseExtractor2である。前者はKEAModelBuilderで作成したインデックスのうち試しに既知のファイルa0011e00.txt(このファイル名もプログラム中にハードコーディングしてある)からキーフレーズを抽出するものである。KeyphraseExtractor2の方はfao780(前述のMAUI.tar.gzを展開した中にある)内の未知の文書t0073e.txtからキーフレーズを抽出するプログラムとなっている。
KeyphraseExtractorの方は既存のLuceneインデックスから統計量を得ているのに対し、KeyphraseExtractor2の方は新規の文書から各種統計量を得るためにLuceneインデックスを作成するところから行っている。また、KeyphraseExtractor2の方はモデルに含まれない新規文書であるために、df(P) / N の計算部分で分母、分子とも1が加算されているところも異なる。
KeyphraseExtractorとKeyphraseExtractor2はともにLuceneインデックスに登録済みの文書を特定し、その文書におけるキーフレーズ候補をリストアップしながらスコアを計算する。したがって単語辞書をiterateするよりもLuceneのTermVectorを使いたい。そこでLuceneドキュメント登録は次のようにFieldTypeのsetStoreTermVectors(true)を呼んでいる。
static Document getDocumentWithTermVectors(String fn, String content) throws IOException { Document doc = new Document(); doc.add(new StringField(FILE_NAME, fn, Field.Store.YES)); doc.add(new StoredField(DOC_SIZE_FIELD_NAME, Commons.getDocumentSize(content))); FieldType ft = new FieldType(); ft.setStored(true); ft.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS); ft.setStoreTermVectors(true); doc.add(new Field(Commons.getFieldName(FIELD_NAME, 1), content, ft)); doc.add(new Field(Commons.getFieldName(FIELD_NAME, 2), content, ft)); doc.add(new Field(Commons.getFieldName(FIELD_NAME, 3), content, ft)); return doc; }
キーフレーズ候補は前述のスコア計算式によりscore(P,D)が計算され降順にソートされる。KEA-luceneではそのためにLuceneのPriorityQueueを用いている。ところで、KEAでは特徴量として連続値ではなく離散値を使っているため、score(P,D)が多くのキーフレーズ候補間で同点になる事象が論文で指摘されている。その場合はTF*IDFを使って同点決勝を行うということなので、PriorityQueueの実装は次のようになっている。
static class KeyphraseScorePQ extends PriorityQueue{ public KeyphraseScorePQ(int maxSize) { super(maxSize); } @Override public boolean lessThan(KeyphraseScore arg0, KeyphraseScore arg1) { if(arg0.score < arg1.score) return true; else if(arg0.score == arg1.score){ // tie breaker return arg0.tfidf < arg1.tfidf; } else return false; } }
またPriorityQueue中下位のキーフレーズ候補が、それより上位のキーフレーズ候補のサブフレーズであった場合ははじかれるように実装する必要もある。
実行結果を見てみよう。KeyphraseExtractorを実行して抽出されたキーフレーズの上位20件は次のようである。なお、括弧内の2つの数値は、スコアとTF*IDF特徴量を表している(スコアが同点の時はTFIDF値が高い方が上位にきているのがわかるだろう)。
animal (0.311211,0.001698) standards setting (0.102368,0.014469) sps (0.102368,0.009091) chains (0.102368,0.008121) food chains (0.102368,0.008038) food safety (0.102368,0.008004) sps standards (0.102368,0.006865) value chain (0.102368,0.005468) setting process (0.102368,0.005250) standards setting process (0.102368,0.004846) livestock food (0.102368,0.004823) oie (0.102368,0.004501) poor to cope (0.102368,0.004442) animal health (0.102368,0.004255) animal production (0.089053,0.000425) consultation (0.076506,0.004025) assisting the poor (0.076506,0.002827) sanitary and technical (0.076506,0.001615) requirements assisting (0.076506,0.001615) dynamics of sanitary (0.076506,0.001615)
これに対し人手でつけられたキーフレーズは次のようである(fao30データは複数の人間が思い思いにキーフレーズをつけているので、一回でもキーフレーズと認識されたフレーズをここではリストアップしている)。
$ cat $(find fao30 -name a0011e00.key)|sort|uniq animal health animal production animal products capacity building consumers developing countries development policies disease surveillance domestic markets empowerment fao food chains food safety food security hygiene information dissemination livestock markets meat hygiene mechanics phytosanitary measures poverty public health regulations risk analysis risk management rural population standards technical aid technology trade veterinary hygiene world
両者に共通して現れるキーフレーズは次のようになる。
food chains, food safety, animal health, animal production
決して正解率が高いとは言えないが、抽出されたキーフレーズはフレーズとして不自然なものが少ないように見える。自動抽出されたフレーズとしてはいいセン行っているのではないだろうか。
またKeyphraseExtractor2を実行して未知の文書からキーフレーズ抽出した結果は次のようである。同じく上位20件を表示している。
post harvest (0.628181,0.004842) vegetables (0.311211,0.002956) root crops (0.311211,0.002523) fruits (0.311211,0.002316) vegetables and root (0.311211,0.002161) food losses (0.311211,0.001900) harvest food losses (0.311211,0.001582) post harvest food (0.311211,0.001582) fresh produce (0.102368,0.011087) packing (0.102368,0.007199) decay (0.102368,0.005210) containers (0.102368,0.004370) prevention of post (0.089053,0.001231) fruits vegetables (0.089053,0.000472) handling (0.076506,0.002345) storage (0.076506,0.002177) tubers (0.076506,0.001889) marketing (0.076506,0.001474) roots (0.076506,0.001389) potatoes (0.029596,0.003984)
一方で、人手でつけられたキーフレーズは以下である。
$ cat t0073e.key Fruits Marketing Packaging Postharvest losses Postharvest technology Root crops Vegetables
こちらも結構妥当なフレーズが抽出されている。情報検索のキーワードサジェスチョンなどに用いるには十分な精度といってもいいだろう。
本稿は教師あり機械学習でキーフレーズを抽出するKEAを、あえてLuceneライブラリを活用することで実装した。KEAプログラムはアルゴリズムが簡単明瞭なので、キーフレーズ候補の各種統計量を得るためにLucene APIをどのように使えるのか、プログラムから読み取りやすいのではないかと考えている。
読者が本稿を通じてLuceneライブラリへ興味を持ったり、ライブラリへの知識を少しでも広げていただけたなら幸いである。
本稿を執筆した最初のプログラムのバージョンでは、以下の項目については時間の制約から実装していない。
KEA-luceneはLucene学習者向けにあえてハードコーディングをしているが、汎用的なものを適宜NLP4Lを通じて提供する予定である。
]]>過去にはこちらで説明している方法で行ったりもしたが、今回はいわゆる外来語に限って言葉を収集するもので、はるかに精度がよい(測ってはいないが・・・)。また実装は NLP4L を通じて公開しているので、誰でも試せる。また理屈がわかれば、日本語Wikipediaに限らず収集できる。
日本語文書は次のようにカタカナ語とその語源の英語(等)のアルファベット文字列が近い距離に配置されて書かれることが頻繁にある。
エンターテインメント(英: entertainment)とは、人々を楽しませる娯楽をいう。(Wikipedia「エンターテインメント」より抜粋)
そこで文書を大量に収集して互いに近い距離にあるカタカナ語とアルファベット文字列を類義語のペアとして Lucene/Solr に使えるテキストファイルに出力することを考える。しかし、近い場所に書かれているすべてのペアが必ずしも同じ意味を持つとは限らない。たとえば、次のような文章では間違った結果を得てしまう。
コンピューターはFORTRANのおかげでより身近になった。
文章を相当大量に収集してカタカナ語とアルファベット文字列の共起を調べることでも可能かもしれないが、文章を大量に収集することは誰でもできることではない。
NLP4L には英単語とカタカナ語の Transliteration のプログラムと学習データがついている。
このプログラムを使うとカタカナ語から英単語の綴りを推定できる。この推定値と文章から拾ったアルファベット文字列を比較し、文字列が似ていれば(適当に決めた編集距離以下なら)拾ったカタカナ語とアルファベット文字列は同じ意味を持つとして類義語辞書に出力する。
手順は次の通り。
以上の手順は日本語Wikipediaから抽出するやり方だが、社内文書や専門書などからも収集できる。
先にあげたエンターテインメントやインタフェースのように、カタカナ語に表記揺れがある場合も次のように収集できるのも興味深い。
entertainment,エンターテイメント,エンターテインメント interface,インタフェース,インターフェース pennsylvania,ペンシルバニア,ペンシルベニア
うまく使えば検索の再現率向上に大いに役立つだろう。
]]>最近の Java 9 のコミット (e5b66323ae45) がディレクトリへの fsync を破壊した件について
こんにちは。
Apache Lucene コミッターを代表して投稿します。Apache Lucene/SolrとElasticsearchの両方を先ごろテストしたところHotspotまわりを中心に問題が見つかったことはご存知かと思います。そこで、JDK 9のプレビュー版ビルド40を利用するよう自分たちのテストインフラを先日アップデートしました。これは主にJigsawまわりの問題を確認することが目的でしたが、いくら頑張ってもこのビルドでは問題が発生しないのです。
ところが残念ながらOpenJDK 9の最近のコミットでは問題が発生しており(http://hg.openjdk.java.net/jdk9/jdk9/jdk/rev/e5b66323ae45)、それに相当するのがhttps://bugs.openjdk.java.net/browse/JDK-8066915です。この問題に関連し、自分たちでもhttps://issues.apache.org/jira/browse/LUCENE-6169というスレッドを立てました。
まず現状を説明します。Apache Luceneは write once アプローチ(すべてのファイルで一度だけ書き込みが行われる)を使っています。Luceneの所定の「コミットポイント」で「コミット」する場合は一時的なファイル名に書き込み、それからこのファイル(およびすべての関連ファイル)でfsyncを実行する形になります。これはファイルチャネルではfc.force()をコールするだけ簡単です。このコミットの最終的な「パブリッシュ」は「Files.move(Path, Path, StandardCopyOption.ATOMIC_MOVE)」を使ったアトムのリネームで行われます。
この方法は停電のような大きな問題でもない限りうまくいきますが、そのような問題が発生すると、POSIX OSではリネーム処理が全く見えなくなる可能性があります。LinuxのMANページ(http://linux.die.net/man/2/fsync)にはすべてに以下の記述があります。
「fsync() のコールはファイルのあるディレクトリ内のエントリがディスクに書き込まれたことを必ずしも保証するものではない。そのため、ディレクトリのファイル記述子にも fsync() を明示する必要がある。」
基本的に、現在はApache Luceneでも以下の手順で同じことをしています。ディレクトリ上でFileChannel (READ用)をオープンし、 fc.force() をコールします。もちろん、(動作することは以前から分かっているものの)実際にはJava APIにこの記述がないため、われわれは「有利な推測」に基づいてこれを行うことになります。Windowsではうまくいかないなど(*)、IOExceptionの例外は必ず発生します。
そこで問題になるのが、前述のコミットではこのアプローチがOpenJDK 9のFileSystemExceptionでうまくいかないことです。FileSystemExceptionはディレクトリです。社外のLuceneリリースは(前述のように)例外をすべて受け入れるため大丈夫なのですが、テストインフラでは、少なくともLinuxとMacOSXでは動いてしまうのです。
最新のコードはhttp://goo.gl/vKhtsWにあります。
どうしてもサポートされているOSのディレクトリでfsyncができるようにしておきたいのです。前述のコミットを8u40や7u80のリリースには戻したくないのです。Java 9なら代替案を検討できるのですが。
意見や対応方法に関するアドバイスを下さい。
Uwe
*)ただ、このファイルシステムのセマンティクスはファイルが確実に見えるようにしているため、実際のところは問題ではありません。話がちょっとそれます(ディレクトリを読み込み用に開くと「Access Denied」がJava IOExceptionにマッピングされますが、これは全く問題ありません)。
]]>「専門用語」とは、Wikipediaによれば、「ある特定の職業に従事する者や、ある特定の学問の分野、業界等の間でのみ使用され、通用する言葉・用語群」である。そこで本記事では、ある特定の分野の文書が登録されたインデックスと、別の分野の文書が登録されたインデックスを比較し、互いに相手方のインデックスに含まれない単語集合を表示することで専門用語を抽出することにする。
Solrとインデックスの準備ここで紹介する方法はSolr 1.4 以降であれば動作する(はず)。Solrを持っていない人はダウンロードして用意しよう。そして下記のschema.xmlを指定してSolrを起動する。
<?xml version="1.0" encoding="UTF-8" ?> <schema name="example" version="1.5"> <types> <fieldType name="string" class="solr.StrField" sortMissingLast="true" omitNorms="true"/> <fieldType name="int" class="solr.TrieIntField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/> <fieldType name="long" class="solr.TrieLongField" precisionStep="0" positionIncrementGap="0"/> <fieldType name="date" class="solr.TrieDateField" omitNorms="true" precisionStep="0" positionIncrementGap="0"/> <fieldType name="text_ja" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="false"> <analyzer> <tokenizer class="solr.JapaneseTokenizerFactory" mode="search"/> <filter class="solr.JapaneseBaseFormFilterFactory"/> <filter class="solr.CJKWidthFilterFactory"/> <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/> <filter class="solr.LowerCaseFilterFactory"/> </analyzer> </fieldType> </types> <fields> <field name="url" type="string" indexed="true" stored="true" required="true" /> <field name="cat" type="string" indexed="true" stored="true"/> <field name="date" type="date" indexed="true" stored="true"/> <field name="title" type="text_ja" indexed="true" stored="true"/> <field name="body" type="text_ja" indexed="true" stored="false" multiValued="true"/> </fields> <uniqueKey>url</uniqueKey> </schema>
文書データはここではlivedoorニュースコーパスを用いる。以下のようにwgetでlivedoorニュースコーパスを入手して展開しておく。
$ wget http://www.rondhuit.com/download/livedoor-news-data.tar.gz $ tar xvzf livedoor-news-data.tar.gz
そしてSolrを起動して展開して得られたファイルのうちひとつ(例としてここでは「独女通信(dokujo-tsushin.xml)」をSolrに登録する。
$ ./post.sh dokujo-tsushin.xmlTermsComponentで単語リストを取得
次に、SolrのTermsComponentを使ってインデックスから単語一覧を取得する。次のcurlコマンドを実行すると、bodyフィールドでの使用頻度の多い順に上位1万語の単語一覧が得られる。
$ curl "http://localhost:8983/solr/collection1/terms?terms.fl=body&terms.limit=10001&omitHeader=true&wt=json&indent=true" | tail -n 10001|head -n 10000|cut -d , -f1|cut -d ¥" -f2 > dokujo-tsushin.txt $ head -n 30 dokujo-tsushin.txt が する た て で と に の は を も だ いる ない ある こと から なる れる か という 人 う や です ます よう ん たい 思う
headコマンドで上位30位の頻出単語を表示しているが、前掲のschema.xmlファイルを見ていただくとわかるとおり、ストップワードの処理を一切していないので、およそ検索には役に立たない(そして専門用語でもない)単語が並んでいるのがわかるだろう。
次に異なる分野の文書を登録して同様に単語一覧を表示する。そのためにここでいったんSolrを停止してインデックスを削除し、再度起動して別の分野の文書(ここでは例として「スポーツウォッチ(sports-watch.xml)」)を登録する。そして同様にTermsComponentを使って単語一覧を得る。
$ ./post.sh sports-watch.xml $ curl "http://localhost:8983/solr/collection1/terms?terms.fl=body&terms.limit=10001&omitHeader=true&wt=json&indent=true" | tail -n 10001|head -n 10000|cut -d , -f1|cut -d ¥" -f2 > sports-watch.txt $ head -n 30 sports-watch.txt の は する た に が で と を て だ 日 も いる ない れる なる から ある こと か という です ます ん その 日本 1 選手 語る2つの単語集合を比較する
最後に2つの単語集合を比較して差分を表示するプログラムを書く。ここでは以下のような簡単なScalaプログラムを作成した。
package terms import scala.io.Source object TermsExtractor { def main(args: Array[String]): Unit = { val set1 = termsSet("/Users/koji/work/ldcc/dokujo-tsushin.txt") val set2 = termsSet("/Users/koji/work/ldcc/sports-watch.txt") println(set1 &~ set2) println(set2 &~ set1) } def termsSet(file: String): Set[String] = { val src = Source.fromFile(file, "UTF-8") var result : Set[String] = Set() try{ src.getLines.foreach{ line: String => result = result + line } result } finally { src.close } } }
実行すると次のように2行の出力が得られる。1行目は「独女通信」にのみ出現する単語であり、2行目は「スポーツウォッチ」にだけ出現する単語である。頻出していたストップワードっぽい単語はきれいに消えていることがわかる。
Set(今風, 味の素, 呼び名, 愛車, ヨネスケ, 週末, 空き, 華子, 姓名, 時折, 越える, サン, 思い当たる, はしご, ロードショ, 新鮮味, 調べる, 札, 執筆, 麻美, 養子, 駅, ミッション, フミ, 初恋, 鉄則, 落とし穴, さゆり, メモリ, 飲み食い, 和子, 避妊, 苦痛, アカシックレコードリーディング, ラッキ, 本体, 真美子, よそ見, マザー, 尚更, だれ, 盛, sweet, 余震, 心得, アドレス, 器用貧乏, 渋谷, 次男, 法的, 知人, 集まり, ミニサイズ, 皆無, へえ, 眺める, 洋子, 膨大, あっぱれ, 数少ない, ハッピ, 手数料, ローラ, 安価, 波風, 甲斐, 統計, 労働省, 主演, ntt, 以下省略) Set(緊迫, かしぐ, ほとぼり, 在米, フジ, 見れる, フェンシング, 連夜, 被, 205, 兼備, 2014, 藤田, 長島, 曙, 颯爽, プロレスラ, セーフコ, 説く, はしる, 知良, アスリートファン, 鳥肌, 主審, 沸く, 周知, ちょう, 円熟, オールナイト, 卓, あしらい, 不用意, 栗山, 火蓋, やらす, 弘和, 修一, 一角, 平泳ぎ, 98, ゴールデンゴールズ, 演説, 敗, 今井, アリエン, スプリングス, 戦後, beautiful, しょうもない, 113, 敗者, 野, 仰天, 素性, 聖, sunday, 井端, イーグルススカウト, 批評, スルーパス, 前線, 弁解, ユース, クロー, 人情, 用水, やぎ, cb, クーラ, 興行, 選考, mmaplanet, きまる, 哲也, 織る, ラピュタ, グルノーブル, ロマン, クロアチア, brad, 山里, wbc, 権限, pm, ぶっつける, 抜擢, いづみ, アフリカ, 以下省略)
これをもってそれぞれの分野の専門用語であると言い切るには難があるが、どちらかが「独女通信」でどちらかが「スポーツウォッチ」だという2択の問題を出されたならば、ほとんどの人が正解するくらいには専門用語っぽい出力が得られているといえるのではないだろうか。
]]>word2vec for Lucene はテキストファイルの代わりにLuceneインデックスを入力コーパスとして用いる。オリジナルの word2vec が扱うテキストファイルは単語単位で分かち書きされている必要がある。この点は英語テキストなどでは問題ないが、単語で分かち書きされていない日本語などではあらかじめMeCabなどの形態素解析器や分かち書きツールを用いて単語で分かち書きされたテキストファイルを用意しておかなければならない。また英語であっても大文字→小文字変換や単語テキストの直後に続くピリオド/カンマの削除など、事前の正規化作業が必要になってくる。word2vec for Lucene では Luceneインデックスを入力コーパスとして用いるため、事前の正規化作業が不要である。
以下では簡単に試していただけるよう、word2vec for Lucene のデモを実行する手順を説明する。大まかな手順は:
1.word2vec for Lucene のダウンロード
2.デモデータの準備
3.Lucene/Solrインデックスの準備(デモデータの登録)
4.単語ベクトルの作成
5.単語ベクトルで遊ぶ
という感じである。すでにLuceneインデックスを持っている方は手順2〜3の代わりに手持ちのLuceneインデックスを手順4で指定することにしてもよい。word2vec for LuceneはLucene 4.10.2ベースで作成したが、(試していないが)たいていのLucene 4.x インデックスで動作するものと思われる。したがって、Solr 4.x はもちろんのこと、Elasticsearch(バージョンはよく知らないが1.x系以降)で作成したインデックスも指定できる。
手順5で楽しい結果を得るためにはそれなりに大きなコーパスが必要である。数百万文書など大きなLuceneインデックスを持っている方はぜひ word2vec for Lucene を試していただきたい。
以下手順を示すが、簡単のために /Users/koji/work ディレクトリ以下に Solr 環境と word2vec for Lucene 環境をインストールする想定で話を進める。
手順1.word2vec for Lucene のダウンロード$ pwd /Users/koji/work $ git clone https://github.com/kojisekig/word2vec-lucene.git手順2.デモデータの準備
$ pwd /Users/koji/work $ cd word2vec-lucene # オリジナルの word2vec でも使用されている英語コーパス text8 をダウンロードして Solr 形式に変換 $ ant t8-solr $ ls -l text8* -rw-r--r-- 1 koji staff 100000000 6 9 2006 text8 -rw-r--r-- 1 koji staff 100017005 12 3 13:44 text8.txt -rw-r--r-- 1 koji staff 100017078 12 3 13:44 text8.xml -rw-r--r-- 1 koji staff 31344016 12 3 13:40 text8.zip手順3.Lucene/Solrインデックスの準備(デモデータの登録)
コンソールを2つ用意して最初のコンソールでSolrを起動する。
$ pwd /Users/koji/work # Solrのダウンロード $ wget http://ftp.meisei-u.ac.jp/mirror/apache/dist/lucene/solr/4.10.2/solr-4.10.2.tgz $ tar xvzf solr-4.10.2.tgz # Solrの起動 $ cd solr-4.10.2/example $ java -Dsolr.solr.home=/Users/koji/work/word2vec-lucene/solrhome -Dsolr.dir=/Users/koji/word/solr-4.10.2 -jar start.jar
2つめのコンソールではtext8コーパスをSolrに登録する。
$ pwd /Users/koji/work $ cd word2vec-lucene # Solr形式に変換した英語コーパスをSolrに登録 $ ./post.sh collection1 text8.xml手順4.単語ベクトルの作成
demo-word2vec.sh スクリプトにSolrコア名を指定してword2vecを実行する。実行結果はvectors.txtというファイルに出力される。
$ pwd /Users/koji/work $ cd word2vec-lucene $ ./demo-word2vec.sh collection1 $ ls -l vectors.txt -rw-r--r-- 1 koji staff 136053041 12 3 15:31 vectors.txt手順5.単語ベクトルで遊ぶ
demo-distance.sh を実行するとvectors.txtファイルを読み込んで入力待ちになる。そこで単語を入力すると、その単語に最も近い(コサインを計算)ベクトルを持つ単語上位40個を表示する。
$ ./demo-distance.sh cat Word: cat Position in vocabulary: 2601 Word Cosine distance ------------------------------------------------------------------------ cats 0.511078 dog 0.471308 dogs 0.469539 sighthound 0.452233 bobtail 0.436424 tapir 0.424105
demo-analogy.sh を実行するとvectors.txtファイルを読み込んで入力待ちになるので、3つの単語をスペース区切りで入力する。以下ではman king womanと入力しているが、「manに対するkingの関係を、womanに適用するとどうなるか」という意味である。するとqueenが期待値になるが、実行結果もそのようになる。
$ ./demo-analogy.sh man king woman日本語で試す場合
以上の手順はオリジナルの word2vec でも使用している text8 という英語の正規化済みのコーパスを使った手順であるが、以下では日本語の(正規化済みでない)コーパスを使った手順も紹介しよう。デモデータは livedoor ニュースコーパスを用いる。ロンウイットのサイトから Solr ネイティブ形式に加工した livedoor ニュースコーパスをダウンロードする。
$ pwd /Users/koji/work $ cd word2vec-lucene $ mkdir work $ cd work $ pwd /Users/koji/work/word2vec-lucene/work $ wget http://www.rondhuit.com/download/livedoor-news-data.tar.gz $ tar xvzf livedoor-news-data.tar.gz $ cd ..
ダウンロードした livedoorニュースコーパスをSolrに登録し、word2vecを実行する。コア名にはldccを指定する。また、-aオプションでLuceneのAnalyzerクラスを指定する。また以下では-fオプションで出力先ベクトルファイル名を指定している(指定しないとデフォルトのvectors.txtとなってしまい、先ほど作成した英語の単語ベクトルファイルを上書きしてしまう)。
$ ./post.sh ldcc work/*.xml $ ./demo-word2vec.sh ldcc -a org.apache.lucene.analysis.ja.JapaneseAnalyzer -f vectors-ldcc.txt $ ./demo-distance.sh -f vectors-ldcc.txt 結婚 野球デモデータ以外のLuceneインデックスで試す場合の注意点
word2vec for Lucene で配布しているデモ実行スクリプト demo-word2vec.sh を見てもらうとわかるように、いくつかのパラメータがハードコーディングで指定されている。以下はLuceneに関連するパラメータである。これを手持ちのLuceneインデックスに合うように適宜指定しなおせばLucene 4.x インデックスのデータでword2vecが動くはずである。
オプション | 説明 |
---|---|
-index | Luceneインデックスのディレクトリを指定する。 |
-field | word2vec を実行するLuceneインデックスのフィールド名を指定する。このフィールドはindexedでかつstoredでなければならない。 |
-analyzer | -fieldオプションで指定されたフィールドのstoredデータをここで指定されたLuceneのAnalyzerクラスでanalyzeする。 |
-analyzer オプションについては、storedデータではなくTermVectorでもよかったのだが、TermVectorを保存しているLuceneインデックスは少なく、処理速度的にも疑問だったため、storedデータをここで指定するLuceneのAnalyzerクラスでanalyze する方針をとった。ではなぜindexedでなければいけないかというと、(オリジナル実装でも)word2vecではコーパスを2度読んでいて、1回目の読み込みで単語表を作成しているが、indexedであればそこは一瞬で完了し、なんといってもLuceneっぽいからである。また、word2vecで解析したくなるようなLuceneフィールドはたいていindexedでかつstoredと考えられるので、多くのLuceneインデックスオーナーにはこの条件は受け入れられるはずである。
相当数の文書が入っているLuceneインデックスを持っている方は、デモデータで動作を確認したあと、ぜひword2vec for Luceneを手持ちのLuceneインデックス上で試していただき、どんな結果になったか教えていただければありがたい。
]]>Apache などの Web サーバ上で動かして本格的に使う方法と、個人的にさくっと立ち上げて使う方法があり、ここでは後者を紹介する。この場合インストールは簡単で、brat を適当なディレクトリにダウンロードして展開する。
$ mkdir work $ cd work $ wget http://weaver.nlplab.org/~brat/releases/brat-v1.3_Crunchy_Frog.tar.gz $ tar xvzf brat-v1.3_Crunchy_Frog.tar.gzそして、次のようにインストールのスクリプトを実行して質問に答えれば終わりである。
$ cd brat-v1.3_Crunchy_Frog $ ./install.sh # brat にログインするユーザ名を新規設定する。 Please the user name that you want to use when logging into brat editor # パスワードを入れる。 Please enter a brat password (this shows on screen) annotate # メールアドレスを入れる。 Please enter the administrator contact email example@brat.orgコーパスの準備
次にアノテーションを入れる対象のコーパスを data ディレクトリの下に用意する。ここでは livedoorニュースコーパスを使った例を示す。
$ wget http://www.rondhuit.com/download/ldcc-20120915.tar.gz $ tar xvzf ldcc-20120915.tar.gzコーパスファイルは UTF-8 で保存され、.txt の拡張子でなければならない。さらに、次のようにアノテーションを実行する .txt ファイルひとつひとつに対応する空の .ann ファイルをあらかじめ用意しておく必要がある。
$ find text -name '*.txt' | sed -e 's|¥.txt|.ann|g' | xargs touch固有表現タグ設定と brat の起動
brat は係り受けのような構造的アノテーションもつけることができるが、本記事では固有表現タグをつける例を示す。デフォルトの状態でもいくつかの固有表現タグが設定されているが、ここでは独自タグを設定してみる。livedoorニュースコーパスを tar で展開したときにできた text ディレクトリの直下に、次のように2つの設定ファイルを作成する。
$ cd text $ cat > annotation.conf <<EOF > [entities] > ORGANIZATION > FACILITY > PERSON > TITLE > LOCATION > > [relations] > [events] > [attributes] > EOF $ $ cat > visual.conf <<EOF > [labels] > > [drawing] > ORGANIZATION bgColor:#8fb2ff > FACILITY bgColor:#aaaaee > PERSON bgColor:#ffccaa > TITLE bgColor:#7fe2ff > LOCATION bgColor:#6fffdf > EOF最初の設定ファイルはタグの設定、2つめはタグの色の設定である。なおこれらは必要最低限の設定である。設定ファイルを完璧に設定したい方は、brat ディレクトリ内に設定ファイルがあるので、そちらを参照していただきたい。
$ cd ../.. $ python standalone.pybrat へのログイン
上の起動時に表示された URL http://127.0.0.1:8001 にブラウザからアクセスする。すると、次のような画面が表示される。
まだログインしていないのでここは一旦 [Cancel] をクリックする。そして上のブルーの部分にマウスポインタを持って行くと、右上にログインボタンが現れるのでクリックする。すると、次のログイン画面が表示されるので、前のインストール時に指定したユーザ情報(editor / annotate)を使ってログインする。
ログインしたらコーパスコレクションを選択する。それにはタブキーを押下するか、上のメニューから [Collection] を選んでクリックする。すると、次のコーパスコレクション選択画面になる。
ここでは text ディレクトリの下の movie-enter ディレクトリの下から適当なファイルを選択する。すると、そのテキストファイルが表示されるので、その中の「宮崎あおい」に PERSON タグをつけるためマウスドラッグする。すると、次のようにタグ一覧がポップアップ表示される。
ここでは当然 PERSON を選択して [OK] をクリックする。これを繰り返していくと、次のようにテキストがアノテーションされる。
このときすでに、サーバサイドの対応する .ann ファイルには次のようにアノテーション情報がシンプルなフォーマットで記録されている。
$ cat movie-enter-5840081.ann T1 PERSON 81 86 宮崎あおい T2 PERSON 87 90 堺雅人 T3 TITLE 1046 1048 さん T4 PERSON 447 450 堺雅人 T5 PERSON 975 977 宮崎 T6 TITLE 977 979 さん T7 PERSON 980 981 堺 T8 TITLE 981 983 さん T9 PERSON 1021 1023 宮崎 T10 PERSON 1196 1197 堺 T11 PERSON 1245 1247 宮崎 T12 PERSON 1254 1255 堺 T13 PERSON 947 949 宮崎タグをつけた順番で記録されているので、プログラムで読み込むときは、3〜4列でソートする必要があるだろう。
SolrとMahoutのトレーニングコース、ただいま2月の受講者を募集中です!
Mahout トレーニングコース受講者のインタビュー記事はこちら!
Solrのパフォーマンスを一段高めるHeliosearchという新しいオープンソースプロジェクトに真っ先に追加された機能がオフヒープNativeフィルタだ。
大容量JVMヒープの潜在的問題JVMは大きいヒープの処理が決してうまくなかった。ヒープが大きいとガーベッジコレクション作業が難しくなり、GCポーズによって長時間システムが停止し、ほかの処理が一切できなくなる場合も多い。そのためクエリ/リクエストタイムアウトが発生したり、SolrCloudモードでzookeeperセッションのタイムアウトまでも発生する可能性がある。
オフヒープフィルタ非常に高度なフィルタキャッシング機能を用意しているHeliosearch/Solrだが、アプリケーションによっては膨大なメモリを使用する可能性がある。大きく存続期間の長いオブジェクトの方が、JVMヒープから取り出して明示的に管理するメリットを得られる。オフヒープメモリはガーベッジコレクタから見えないのだ。
Heliosearchフィルタ(Solr DocSetオブジェクト)はオフヒープでアロケートされるようになっており、不要になればすぐに解放できるよう参照がカウントされるようになった。また、JVM GCはこのようなメモリブロックのコピー処理で時間を無駄にする必要がなくなった。このことはGCの長時間ポーズを排除し、リクエストスループットを改善するのに役立っている。
テスト設定各方面から報告のあった長時間のGCポーズを再現するにはかなりいろいろなことを試す必要があると考えていたが、何と1回目でいきなりこれが再現されてしまった。ヒープサイズは報告のように大きなものではなく現状は小さく収まっている。ヒープが大きいとGCポーズも延びるようだ。
テストの詳細
Apache Solrコマンドライン:
$ java -jar -Xmx4G start.jarHeliosearch/Solrコマンドライン:
$ java -jar start.jar
Apache Solr実行時はOOM例外を回避するためにヒープサイズを4Gバイトに設定する必要があった。搭載可能なRAMは最大8Gだったため、残りのメモリはインデックスファイルのキャッシング用としてOSに残しておきたかった(そうしないと速度が大幅に落ちてしまう)。
GCの結果2万クエリリクエストを行ったGCの実行結果を以下のグラフに示す。 グレーの棒はGCに要した時間、赤線はヒープの実サイズ、そして青線はヒープの実使用量を示している。
SolrSolrでの長時間ポーズをテスト中に外から見るのはさらに容易だった。ログが有効になっていたためリクエストがすべてログメッセージを残し、ターミナルでは高速スクロールが発生した。そして、GCの大規模なコンパクションが発生するとターミナルのスクロールは突然停止した。
Heliosearchガーベッジコレクションの処理が短時間ですむためにHeliosearch GCグラフの方が完了は早い。長時間の完全なGCポーズがほとんど発生しておらず、ほかのGCポーズも大幅に減少していることに注目したい。
クエリの待ち時間
このグラフは、2万クエリを実行したときの後半の1万クエリ(ホットスポットとキャッシュが落ち着いたタイミングを見計らうため)の待ち時間をパーセントで示している。
Query Throughputグラフはオフヒープデータ構造によって解放できるガーベッジコレクションに必要なCPU処理時間を示している。もちろん、これは極端な結果だ。大量のガーベッジコレクションが頻繁に発生している場合はデータ構造をオフヒープにすることで実現するスループット向上の方が適当だろう。
プロセス(トップ経由で外部から監視)の常駐メモリ最大使用量は5回にわたって計測した。
Apache Solr | Heliosearch | |
最小 | 3.8GB | 3.6GB |
最大 | 4.3GB | 3.7GB |
今回の簡単なテストにより、オフヒープフィルタが大きな異常値を減らして全体のクエリスループットを高め、長時間のGCポーズを排除してリクエストの予測を容易にすることが判明した。
ご自身でお試しいただき、HeliosearchForumで感想をお聞かせいただきたい。
SolrとMahoutのトレーニングコース、ただいま2月の受講者を募集中です!
]]>/** * Calculates the standard deviation statistic * * @return Standard deviation statistic */ private double getStandardDeviation() { if (count <= 1.0D) { return 0.0D; } return Math.sqrt(((count * sumOfSquares) - (sum * sum)) / (count * (count - 1.0D))); }
String[] KEYS = {"あいだ", "あいぼう", "あたぼう", "いいだ"};
public class TestFSA { public static void main(String[] args) throws IOException { String[] KEYS = {"あいだ", "あいぼう", "あたぼう", "いいだ"}; // (1) final Object outputValue = new Object(); // (2) NoOutputs outputs = NoOutputs.getSingleton(); // (3) Builder<Object> builder = new Builder<Object>(INPUT_TYPE.BYTE4, outputs); // (4) BytesRef scratchBytes = new BytesRef(); IntsRef scratchInts = new IntsRef(); for (String key : KEYS) { // (5) scratchBytes.copyChars(key); builder.add(Util.toIntsRef(scratchBytes, scratchInts), outputValue); } FST<Object> fst = builder.finish(); // (6) getAndPrint(fst, "あかだ"); // (7) getAndPrint(fst, "いまだ"); getAndPrint(fst, "いいだ"); getAndPrint(fst, "いいだあ"); getAndPrint(fst, "いい"); getAndPrint(fst, "あたぼう"); } static void getAndPrint(FST<Object> fst, String key) throws IOException { Object value = Util.get(fst, new BytesRef(key)); System.out.printf("%s is %s¥n", key, value == null ? "not accepted" : "accepted"); } }
あかだ is not accepted いまだ is not accepted いいだ is accepted いいだあ is not accepted いい is not accepted あたぼう is accepted]]>
Apache LuceneとSolrは、バージョン3.1では64ビット版WindowsおよびSolarisの両システム、バージョン3.3では64ビットLinuxシステムにおいてMMapDirectoryをデフォルトで採用している。ただし、システムがここ数バージョンとは異なる動作をするようになったため、今回の変更にLuceneとSolrのユーザが混乱している。LuceneとSolrのメーリングリストでは、消費する物理メモリが突然3倍になった理由に疑問を抱くユーザや、リソース使用量に不満を訴えるシステム管理者の投稿が多数見られる。また、MMapDirectoryの代わりに、動作の遅いSimpleFSDirectoryやNIOFSDirectory(JVMバグ#6265734に起因してWindowsではさらに遅い)を有効にするようsolrconfig.xmlの設定を推奨する意見がコンサルタントからも出始めている。慎重に検討した結果これらのプラットフォームではMMapDirectoryの使用がベストだと判断したLuceneコミッターからすれば、これはどちらかと言えば不快な反応になる。Lucene/Solrがこれまでより格段に優れたパフォーマンスを発揮することが分かっているからだ。今回の変更の背景に関して間違った情報が出回っているため、この素晴らしい検索エンジンが最適でない形で各所にインストールされてしまう。
このブログでは、カーネル内の仮想メモリ処理に関する基本的な事実と、それを主にLuceneのパフォーマンス向上に利用するための方法について説明しようと思う(「仮想メモリ超入門」である)。また、さまざまな人々が投稿するブログやメーリングリストが間違っていて、MMapDirectoryの用途に矛盾するという理由についても明らかにしていく。後半では、mmapエラーなどや、Javaのこなれないヒープアロケーションに起因した最適でないパフォーマンスの回避に必要な詳しいコンフィギュレーションと必要な設定を紹介する。
仮想メモリ[1]まずOSのカーネルを考えてみたい。ソフトウェアでI/O処理を行うのは無知なアプローチだ。これは1970年代から行われており、そのパターンは単純なものだ。ディスク上のデータを処理するときは常にシステムカーネルでsyscallを実行し、ポインタを何らかのバッファ(例:Javaのbyte[]アレイ)に渡し、ディスクとの間でバイトをやりとりし、バッファの中身をパースしてプログラムを実行する。(処理能力を消費しすぎる可能性があることから)あまり多くのsyscallを行いたくない場合はソフトウェアでバッファを大きく取って内部でのディスクとのデータ同期を減らすようにする。(RAMDirectoryを使用するなどして)Luceneインデックス全体をJavaのヒープメモリに読み込むようアドバイスするのにはこのような理由もある。
しかし、Linux、Windows(NT以降)、MacOS X、あるいはSolarisといったモダンOSはすべて、洗練されたファイルシステムキャッシュとメモリ管理機能を使用することで1970年代式のコードより格段に優れたアプローチを用意している。「仮想メモリ」と呼ばれるこの機能は、Luceneインデックスのように巨大で広大な空間を占めるデータ構造処理の優れた代替手段となっている。仮想メモリはコンピュータアーキテクチャに不可欠な部分となっており、インプリメンテーションにはハードウェアサポートが必須で、MMU(メモリ管理ユニット)としてCPUに組み込まれているのが一般的だ。その仕組みはかなりシンプルで、各プロセスが独自に仮想アドレス空間を取得し、そこにライブラリ、ヒープ、そしてスタックの各空間をマッピングする。だいたいの場合、このアドレス空間はオフセット0から始まり、アドレスポインタの再配置が一切不要であるためプログラムコードの読み込みが簡単になっている。どのプロセスからも断片化していない連続した大きなアドレス空間が作業空間として見える。このアドレス空間は物理メモリと一切関連性がないことから「仮想メモリ」と呼ばれ、プロセスからもそのように見える。この広大なアドレス空間は、ソフトウェアを使って実メモリであるかのようにアクセスできる。ほかのプロセスもメモリを消費し、独自に仮想アドレス空間を持っていることは意識する必要がない。基盤OSはCPU内のMMUと連携し、最初のアクセス時にこれらの仮想アドレスを実メモリにマッピングする。これはページテーブルと呼ばれるもので行われ、それがMMUハードウェア内のTLB(頻繁にアクセスされるページをキャッシュする変換索引バッファ)によってバックアップされている。こうすることで、OSは実行中のすべてのプロセスの必要メモリを利用可能な実メモリに分散し、実行中のプログラムから完全に透過的に見えるようにすることができる。
仮想メモリ図(Wikipediaより引用[1]、http://en.wikipedia.org/wiki/File:Virtual_memory.svg。CC BY-SA 3.0許諾)
この仮想化を利用すればOSはもう1つのことができるようになる。物理メモリが十分にないときは、プロセスが使わなくなったページを「スワップアウト」したり、物理メモリを別のプロセス用に解放したり、もっと重要なファイルシステム操作をキャッシングできるようになる。ページアウトされた仮想アドレスにプロセスがアクセスしようとすると、それがメインメモリに再ロードされ、プロセスが利用可能になる。プロセスは何もする必要がなく、完全に透過的になっている。これは利用可能なメモリ容量の把握が一切必要ないためアプリケーションにとっては素晴らしいことだが、メモリの負荷が非常に高いLuceneのようなアプリケーションでは問題にもつながる。
Luceneと仮想メモリここで、インデックス全体もしくはその大半を「メモリ」(これが仮想メモリにすぎないことは先刻承知だ)に読み込む例を見ていく。RAMDirectoryをアロケートし、そこにインデックスファイルをすべて読み込むとOSの足を引っ張ることになる。OSはディスクアクセスを最適化しようとするため、すべてのディスクI/Oが物理メモリにキャッシング済みだ。これらのキャッシュの内容をすべて自分の仮想アドレス空間にコピーして膨大な量の物理メモリを消費している(しかも、コピー処理の開始を待たなくてはならない)。物理メモリには制限があるので、OSはもちろん大きなRAMDirectoryをスワップアウトする判断を下す必要があるのだが、それはどこに行くのだろうか?再びディスク上(OSのスワップファイル上)に戻るのだ。実際、われわれはディスクから読み込むものすべてをページアウトさせるOSカーネルと格闘している[2]。したがって、インデックスの読み込み時間最適化にRAMDirectoryは得策ではない。加えて、RAMDirectoryの方がガーベッジコレクションや並行処理関連で問題が多いのだ。スワップ領域にデータがあるため、自分のヒープ管理下にあるメモリの解放はJavaのガーベッジコレクタにとって大変な作業となる。そのためにディスクI/Oが増加し、インデックスアクセス速度が低下し、ガーベッジコレクタの動作が原因で検索コードの待ち時間が分単位に達する。
一方、もしインデックスのバッファリングにRAMDirectoryを使わずNIOFSDirectoryやSimpleFSDirectoryを使うと、ディスクやファイルシステムのキャッシュとJavaヒープ内のバッファとの間でデータブロックをコピーするためにコードがOSカーネルに対して多数のsyscallを行う必要が出るなど、さらに代償を払わなくてはならなくなる。検索リクエストがある度に何度もくり返して行う必要があるのだ。
メモリマッピングファイル前述の問題に対するソリューションが、仮想メモリと「mmap」[3] というカーネル機能を使ってディスクファイルにアクセスするMMapDirectoryだ。
先のアプローチでは、ファイルシステムキャッシュとローカルJavaヒープの間でデータをコピーするのにsyscallに頼っていたが、ファイルシステムキャッシュに直接アクセスすればいいのではないか。これこそまさにmmapの仕事なのだ。
基本的に、mmapはLuceneインデックスをスワップファイルとして処理するようなことをする。mmap () syscallはOSカーネルに指示を出し、インデックスファイル全体を前述の仮想アドレス空間に仮想的にマッピングし、それらをLuceneのプロセスが利用可能なRAMのように見せる。そうすれば、大きなbyte[]アレイ(JavaではJavaコードが安全に利用できるようByteBufferインターフェースによってカプセル化される)になるかのようにディスク上のインデックスファイルをアクセスできるようになる。Luceneのコードからこの仮想アドレス空間にアクセスすればsyscallsは一切不要で、プロセッサのMMUとTLBがマッピングをすべて代わりに処理してくれる。データがディスク上にしかない場合は、MMUが割り込みを発生させ、OSカーネルがデータをファイルシステムキャッシュに読み込む。既にキャッシュされている場合は、MMU/TLBがそれをファイルシステムキャッシュ内の物理メモリに直接マッピングする。これでネイティブメモリアクセスに変わることになる。バッファのページイン/ページアウトを気にする必要はなく、これらはすべてOSカーネルによって処理される。さらに、並行処理の問題も全くなく、標準のbyte[]アレイに対する唯一のオーバーヘッドはJavaのByteBufferインターフェースによって生じるラッピングだけとなる(それでも本物のbyte[]アレイより速度は落ちるが、Javaからmmapを使う方法はこれしかなく、Luceneに付属するどのディレクトリインプリメンテーションよりもこちらの方がはるかに高速だ)。また、OSキャッシュ上で直接処理を行うため物理メモリも全く無駄にせず、前述のJava GC問題がすべて回避される。
Lucene/Solrアプリケーションへの影響は?OSの足を引っ張るような開発は避けるべきなので、ヒープスペース(-Xmx Javaオプション)のアロケートはできるだけ小さく抑えたい。また、インデックスアクセスがOSキャッシュへの直接渡しに依存することを覚えておきたい。これは、Javaのガーベッジコレクタともかなり相性が良い。
OSカーネルがファイルシステムキャッシュとして利用できるよう可能な限り多くの物理メモリを解放する。また、Luceneのコードがそこで直接動作するためディスクとメモリ間のページング/スワッピング回数が減少することを覚えておきたい。ただし、Luceneアプリケーションにヒープをアロケートしすぎるとパフォーマンスに支障が生じる。MMapDirectoryではLuceneがこれを必要としないのだ。
これが64ビットのOSやJava仮想マシンでしか予想通りの動作をしないのはなぜだろうか?
32ビットプラットフォームの制限の1つに、ポインタサイズがあり、ポインタは4 Gバイトである0から232-1までのアドレスにしかアクセスすることができない。大半のOSは残りのアドレス空間をデバイスなどのために予約しておくため、アドレス空間を3 Gバイトに制限している。つまり、プロセスに与えられるリニアアドレス空間の合計は3 Gバイトに制限され、これより大きいファイルは大きいbyte[]アレイとしてこの「小さい」アドレス空間にマッピングできないことになる。そして、この大型ファイルをマッピングすると、仮想空間(「番地」に相当するようなアドレス)はなくなってしまう。既存のシステムの物理メモリサイズがこの大きさを既に超えていることから、リソースを無駄にせずファイルをマッピングするアドレス空間はない(ここでは物理メモリではなく「アドレス空間」)。
だが、64ビットプラットフォームでは異なってくる。264-1は巨大な数字で、1800京バイトを越えるため、実際のところはアドレス空間に制限はない。残念ながら、大半のハードウェア(MMU、CPUのバスシステム)やOSはこのアドレス空間をユーザモードアプリケーション(Windows:43ビット)用に47ビットに制限している[4]。しかし、それでもTバイト単位のデータをマッピングするためのスペースは残っている。
一般的な誤解仮想メモリに関するこれまでの説明を注意深く読んでいれば、以下が正しいことは簡単に検証できる。
MMapDirectoryは余計なメモリを消費せず、マッピングされたインデックスファイルのサイズはサーバが搭載する物理メモリの制限を受けない。mmap ()ファイルではメモリではなくアドレス空間だけを予約する。64ビットプラットフォームのアドレス空間は無償で利用できることを覚えておきたい。
MMapDirectoryはインデックス全体を物理メモリに読み込むことはしない。では、なぜそうしなければならないのだろうか?OSには、容易なアクセスを可能にするためファイルをアドレス空間にマッピングするよう要求しているだけで、それ以上の要求は決してしていない。JavaとOSはファイル全体をRAMに読み込むオプション(十分な余裕があれば)を任意で提供するが、Luceneはこのオプションを使わない(今後のバージョンで追加の可能性はある)。
MMapDirectoryは「top」から膨大な量のメモリが伝わるとサーバに過負荷を与えない。「top」(Linux)にはメモリ関連の3つのコラムがある「VIRT」、「RES」、および「SHR」だ。最初(VIRT、仮想)のものはアロケートされた仮想アドレス空間を伝える(さらに、これは64ビットプラットフォームでは無償だ)。IndexWriterでマージが行われている場合、この数字はインデックスサイズや物理メモリの数倍にもなる。IndexReaderが1つしかオープンされていない場合は、アロケートされたヒープスペース(-Xmx)にインデックスサイズを加えたものとほぼ同じになり、プロセスが使用する物理メモリにはならない。2番目のコラム(RES、常駐)のメモリはプロセスが処理用にアロケートした(物理)メモリ量を示し、Javaヒープスペース以下に収まる。最後のコラム(SHR、共有)はアロケートされた仮想アドレス空間がほかのプロセスと共有しているサイズを示す。MMapDirectoryを使用する複数のJavaアプリケーションが同じインデックスをアクセスしている場合はこの数字が大きくなっていく。一般的には、共有システムライブラリ、JARファイル、そしてプロセス実行イメージ本体(これもmmapされる)が必要とするスペースが見えるようになる。
MMapDirectoryを最適利用するためのmyOSand Java VMのコンフィギュレーションまず第一に、LinuxディストリビューションとSolaris/Windowsのデフォルト設定は全く問題ない。しかし、(理解不足から)何でも自分でコントロールしたがる偏執的なシステム管理者もおり、これによりアプリケーションがアロケート可能な仮想アドレス空間の最大量が制限を受けてしまう。そこで、「ulimit -v」「ulimit -m」の両方が「unlimited」を表示することを確認したい。そうしないと、MMapDirectoryがインデックスのオープン時に「mmap failed」を表示する可能性がある。それぞれのセグメントが多い膨大な数のインデックスを持つシステムでもこのエラーが発生する場合は、/etc/sysctl.confのカーネルパラメータをチューニングする必要があるかもしれない。vm.max_map_countのデフォルト値は65530だが、これを引き上げる必要があるかもしれない。WindowsとSolarisの両システムでも同様のセッティングが可能だと思うが、その使用方法は読者ご自身でお調べいただきたい。
Java VMのコンフィギュレーションに関してはメモリ要件を検討し直したい。ヒープスペースは本当に必要な量だけ用意して、OSにはできるだけ多くを残しておきたい。経験則として、Lucene/Solrが動作するJavaに物理メモリの四分の一以上をヒープスペースとして使ってはならず、残りのメモリはOSのキャッシュ用に空けておきたい。サーバ上で動作するアプリケーションが増えたらそれに合わせて調整を行う。従来通り、物理メモリは多ければ多い方が良いが、インデックスサイズと同量までの物理メモリは不要だ。頻繁に使用されるページはカーネルがインデックスからうまくページインしてくれる。
システムを最適にコンフィギュレーションしたかどうか調べるには、「top」(正しい説明は前記参照)と同様のコマンドである「iotop」(Ubuntu Linuxなどでは「apt-get install iotop」でインストール可能)の両方を見る。Luceneプロセスでシステムのスワップイン/スワップアウトが多い場合は、おそらく使いすぎなのでヒープサイズを減らす。ディスクI/Oが多い場合はmmappedされたファイルが四六時中ページイン/アウトされないようRUM(Simon Willnauer語録)を追加購入したい。そして、最終手段はSSDになる。
mmapをお楽しみあれ。
検索窓をつけたとたん、いろいろ過去に書いて忘れていた記事が一気に発掘できるようになった。たとえば、2年前まで動いていた検索機能について書いた記事を読むと、当時はクローラーにHeritrixを使っていたとある。今はManifoldCFを使ってホームページ、このブログ記事、soleami、SlideShareなどをクロールしている。
取り急ぎとにかく立ち上げることを目標にしたこのデモの最初のバージョンでは、単純なファセットとハイライト、そしてもしかして検索しかサポートしていないが、サジェストや類義語の自動生成などを今後は追加していきたい。
「もしかして検索」について少し説明しよう。たとえば、「ヤクルト」などと検索してみると、そういう単語はインデックス内にないので、「もしかして:リクルート」などとサジェストされる。
日本語のもしかして検索は、弊社製品サブスクリプションで提供している機能をここでは使っているが、実はその機能を実装する前に、LuceneコミュニティにLUCENE-3888として機能改善の提案をしている。当時は日本語の要請を飲んでもらうのに時間をかける余裕がなかったのでそのままになっているが、その気持ちはRobertに受け継がれAnalyzing Suggesterとして実装された(はずである。弊社ではまだ試していない)。
ただ、日本語でもしかして検索をやる場合はこれだけでは十分ではなく、インデクシングの前処理で専門用語抽出をしておく必要がある。
5月からSolrCloudあらため「Solr 4.2 クラウド分散運用」としてトレーニングコースが新設されました。5月受講者募集中です!
]]>public void search(Weight weight, Collector collector) throws IOException { // iterate through all segment readers & execute the search for (int i = 0; i < subReaders.length; i++) { // pass the reader to the collector collector.setNextReader(subReaders[i], docStarts[i]); final Scorer scorer = ...; if (scorer != null) { // score documents on this segment scorer.score(collector); } } }
DirectoryReader reader = DirectoryReader.open(directory); IndexSearcher searcher = new IndexSearcher(reader); Query query = new QueryParser("fieldname", analyzer).parse(“text”); TopDocs hits = searcher.search(query, 10); ScoreDoc[] docs = hits.scoreDocs; Document doc1 = searcher.doc(docs[0].doc); // alternative: Document doc2 = reader.document(docs[1].doc);
Terms terms = SlowCompositeReaderWrapper.wrap(directoryReader).terms(“field”);