関口宏司のLuceneブログ

OSS検索ライブラリのLuceneおよびそのサブプロジェクト(Solr/Tika/Mahoutなど)について
(メモ)Katta = Lucene + Zookeeper + Hadoop + and more!?
Kattaプレゼンテーション
http://joa23.files.wordpress.com/2008/09/katta-overview.pdf
| 関口宏司 | その他のOSS検索エンジン | 13:21 | comments(0) | trackbacks(0) |
LuceneとSennaの比較:特徴語検索
Sennaではクエリー書式に"*S"を指定すると、指定した文書(文字列)に含まれる「特徴語」を含む文書を検索できる。Luceneでは同様のことはcontribのMoreLikeThisで可能である。

Luceneの通常の検索プログラムでは、検索結果(文書一覧)はsearcherにqueryを渡して取得する。つまり、次のような具合である:



searcher(query)



MoreLikeThisではqueryの代わりに文書を渡して文書に関連した文書を取得する。つまり、次のような具合だ:



searcher(doc)



しかし実際にはこのようなメソッドはない。searcher()はqueryを取るのみである。そこでMoreLikeThisの出番となる。

MoreLikeThisにはlike()メソッドというのがあり、like()メソッドに文書を渡すと、その文書の「特徴語」からなるquery(BooleanQuery)を生成してくれる。つまり、次のように書くことができる:



searcher(MoreLikeThis.like(doc))



like()メソッドが文書から取り出す特徴語は、Senna同様、文書の頻出語である。しかし容易に想像できるとおり、頻出語にはゴミが非常に多く混ざってしまう。そこでLuceneのMoreLikeThisでは、ストップワードを指定したり、Analyzerを指定したり、その他ゴミ語を取り除くための各種setterメソッドが用意されている。

MoreLikeThisを使う手順は次のようになる(疑似コード):



IndexReader reader = IndexReader.open( dir );
IndexSearcher searcher = new IndexSearcher( dir );
MoreLikeThis mlt = new MoreLikeThis( reader );
mlt.setAnalyzer( analyzer );
mlt.setFieldNames( fieldName );
mlt.setStopWords( stopWords );
Query query = mlt.like( new StringReader( srcDocument ) );
// queryを使って検索する



Analyzerを指定できるので、やろうと思えば類義語展開/正規化もできる。これにより適切な関連文書を提示できる。
| 関口宏司 | その他のOSS検索エンジン | 23:42 | comments(5) | trackbacks(1) |
Ludiaパフォーマンスチューニングセミナー
先週の金曜日に「第1回 Ludiaパフォーマンスチューニングセミナー」に出席した。

http://d.hatena.ne.jp/ludia/20080326

定員10名のところ、私も含め4名の出席者であった。この内容で19,800円なのでもっと多くてもよいと思った。

有償のセミナーなのでここでそのテクニックを紹介するわけにはいかないが、PostgreSQLのチューニングをよく知らない人はもちろん、ある程度知っている人にもお勧めである。

チューニングというのは手探りでしていると自分が正しい道に進んでいるかわからないので、どこで止めていいのか判断できない。

しかしセミナーで「このような症状で遅いときは、EXPLAINでここを見る。XXXという理由で遅くなっているので、YYYパラメータをいじって解消できる」という理屈から教えてもらえるので、効率のよいチューニングができるようになる。

演習問題も7問もあり、それぞれ違った角度からのチューニング方法(パラメータ設定)を解説するものとなっていて、Ludia開発者には大変お勧めのセミナーであろう。

私にとってはどうだったかというと、ludia.max_n_sort_resultパラメータの意味を少し誤解していてこれを検索リクエストのセッションごとに正しく設定すると格段に速くなる、ということを学んだ。

これまではこのパラメータの意味を「order byをする前の結果セットの最大行数」であると解釈し、そのために200万件のレコードがあるなら200万とセットしなければ正しい検索結果を得られないと思い込んでいた。そうではなく、このパラメータには検索結果一覧表示に必要な件数をセットすればよい、と認識を正すことができた。

たとえば、検索結果ページに1〜10件目を表示する検索リクエストセッションでは:



demo=# set ludia.max_n_sort_result to 10;



とやれば十分である。このあとにselectの全文検索リクエストをスコアのorder byつきで実行すれば、スコア上位10件のものが取得できる。

さらに、11〜20件目を表示する検索リクエストセッションでは:



demo=# set ludia.max_n_sort_result to 20;



とする必要がある。そして「order by スコア offset 10 limit 10」で全文検索を実行する。

以上により、単純な検索では200万行でも同時100ユーザの負荷をかけて動作するようになった。この結果はいずれSolrとの比較資料として公開したいと思っている。=> 公開しました。

問題は、ファセットカウントの取得と絞り込み検索である。これは未だ速くなっていない。

ファセットカウントというのは、ユーザに絞り込み用のナビゲーションリンクを表示するときにリンクの脇に"()"をつけて絞り込み後の件数を表示するために使用される数値情報のことである。

ファセットカウントの例としては、このブログの検索メインページやその検索結果ページのリンクを参照していただきたい。ちなみにこれはSolrで実現しているものである。

さて、Ludiaではファセットカウントを取得するのに、私は次のように実行している:



demo=# select count(*) from auction_item where description @@ '*D+ 音楽 ビデオ' and price <= 9999;



これは「音楽 ビデオ」というキーワードを含むもののうち、価格帯が1円〜9999円のものの件数を取得しようとしているクエリである。この場合は、全文検索以外の条件が使われているため、カウントの取得に際してpgs2getnhits()は使用できない。

ちなみに、auction_itemテーブルは:



demo=# ¥d auction_item
Table "public.auction_item"
Column | Type | Modifiers
---------------+-----------------------------+-----------
auction_id | character varying(16) | not null
category_id | character varying(16) |
category_path | character varying(256) |
title | character varying(128) | not null
url | character varying(128) |
seller_id | character varying(24) |
image1_url | character varying(256) |
image2_url | character varying(256) |
image3_url | character varying(256) |
init_price | integer | not null
price | integer |
bids | integer |
start_time | timestamp without time zone |
end_time | timestamp without time zone |
description | character varying(8192) |
price_group | integer |
date_group | integer |
Indexes:
"auction_item_pkey" PRIMARY KEY, btree (auction_id)
"fidx" fulltext ((description::text))
"idx_date" btree (end_time)
"idx_date_group" btree (date_group)
"idx_price" btree (price)
"idx_price_group" btree (price_group)
demo=#



となっており、上記のクエリのEXPLAINの実行結果は:



demo=# explain select count(*) from auction_item where description @@ '*D+ 音楽 ビデオ' and price <= 9999;
QUERY PLAN

--------------------------------------------------------------------------------

Aggregate (cost=3760.76..3760.77 rows=1 width=0)
-> Bitmap Heap Scan on auction_item (cost=11.95..3758.76 rows=797 width=0)
Recheck Cond: ((description)::text @@ '*D+ 音楽 ビデオ'::text)
Filter: (price <= 9999)
-> Bitmap Index Scan on fidx (cost=0.00..11.75 rows=1000 width=0)
Index Cond: ((description)::text @@ '*D+ 音楽 ビデオ'::text)
(6 rows)
demo=#



となっている。

ファセットカウントを取得するときは、当然価格帯が1円〜9999円以外のものも必要となってくるので、前述のselect count(*)を必要なだけpriceの条件を変えながら複数回実行することが必要となってくる。負荷がけマシンから1ユーザでも大変重いクエリとなってしまった。

なお、複数回のselectを実行するときは、"SET TRANSACTION ISOLATION LEVEL SERIALIZABLE"を実行している。

ファセットカウントを取得する別の方法として、価格帯ごとに単純な数値を割り当て、別に作成したカラムにその数値をセットして、複数回のselect count(*)を実行する代わりにgroup byで一度に取得できるようにする、という方法がある。そうすると、次のように実行できる:



demo=# select price_group,count(*) from auction_item where description @@ '*D+ 音楽 ビデオ' group by price_group;
price_group | count
-------------+-------
5 | 338
1 | 122
4 | 235
3 | 282
2 | 390
(5 rows)
demo=#



このときのクエリプランは次のようになる:



demo=# explain select price_group,count(*) from auction_item where description @@ '*D+ 音楽 ビデオ' group by price_group;
QUERY PLAN

--------------------------------------------------------------------------------
-
HashAggregate (cost=3761.31..3761.33 rows=1 width=4)
-> Bitmap Heap Scan on auction_item (cost=12.00..3756.31 rows=1000 width=4)

Recheck Cond: ((description)::text @@ '*D+ 音楽 ビデオ'::text)
-> Bitmap Index Scan on fidx (cost=0.00..11.75 rows=1000 width=0)
Index Cond: ((description)::text @@ '*D+ 音楽 ビデオ'::text)
(5 rows)
demo=#



しかし、このgroup byの方法でも速くならなかった。PostgreSQLのプロンプトから使っている分にはそれほど遅く感じないが、負荷がけマシンから検索語を変えながら連続して実行すると、重さが際立ってくるのであった。

主なパラメータは次のようになっている:



demo=# select * from pgs2getoption();
-[ RECORD 1 ]------+--------
max_n_sort_result | 1000000
enable_seqscan | off
seqscan_flags | 1
sen_index_flags | 31
max_n_index_cache | 16
initial_n_segments | 512

demo=# show work_mem;
-[ RECORD 1 ]--
work_mem | 10MB

demo=# show shared_buffers;
-[ RECORD 1 ]--+-----
shared_buffers | 32MB

demo=#



Ludiaに詳しい方でもしも他にいじったほうがよさそうなパラメータやその他のチューニング箇所をご存知でしたら、コメントに書いていただけますと助かります。
| 関口宏司 | その他のOSS検索エンジン | 14:24 | comments(0) | trackbacks(0) |
LuceneとSennaの比較:検索性能・・・?
Senna/LudiaとLucene/Solrを比較する資料を作ろうかと考え、ためしに負荷試験をやっているがこれがなかなか手ごわい。

Ludiaの性能が予想以上に出ないので資料化ができないのである。postgresql.confの値を変えたりしてみるがだめだ。

そもそもRDBMSは全文検索のために作られたエンジンではないので、私はもともと全文検索機能をプラグインするというアイディアには懐疑的だ。postgresql.confの値も全文検索機能の性能向上のためにいじる部分が少ないように思う。

同じ「クエリ」といっても、トランザクションをしっかり保証するRDBMSと性能第一の全文検索エンジンでは、エンジンの仕事量がぜんぜん違うはずなのだ。

負荷試験ではLudiaのレコード数を20分の1くらいに落としてようやくLucene/Solrと同程度の性能(レスポンス&QPS)が出るようにはなった。

これくらいの性能差であれば資料化できるかもしれないが、私のPostgreSQL(Sennaの性能ではなくPostgreSQLのせいだろう)のチューニング技量のせいかもしれない。なので資料化するのではなく性能比較ができるパッケージか何かを作成し、各自で比較してもらえるようにする方法に変えようかと考えている。

ということで負荷試験はちょっと中止にしよう。土曜の夕方からLudiaで夜通し検索&チューニングを模索して気がついたら日付が変わって日曜日だ。

疲労した頭で浮かんだのが次の久々の替え歌である。


〜 Ludiaユーザに捧げる詩 〜

サーチング・オールナイト
(もんた&ブラザーズ、ダンシング・オールナイトのメロディーで♪)

インストールはずむ心
やさしい日本語マニュアル
CREATE INDEXのあとで
無邪気にクエリしてる

Searchin' all night SQLにすれば
Searchin' all night トランザクションが開始する
Searchin' all night このままずっと
Searchin' all night セッションを閉じて

なにげない近傍検索ひとつ
それだけで崩れてしまう

あぶなげな検索エンジンと知らず
チューニングを手さぐりしてた

Tunin' all night SQLにすれば
Tunin' all night トランザクションが開始する
Tunin' all night このままずっと
Tunin' all night セッションを閉じて

このクエリで最後のテスト
どちらからともなくそう決めて
思い出をなぞるようにクリック
カットオーバー前の夜のように

Searchin' all night SQLにすれば
Searchin' all night トランザクションが開始する
Searchin' all night このままずっと
Searchin' all night セッションを閉じて

Tunin' all night SQLにすれば
Tunin' all night トランザクションが開始する
Tunin' all night このままずっと
Tunin' all night セッションを閉じて
| 関口宏司 | その他のOSS検索エンジン | 03:26 | comments(1) | trackbacks(0) |
LuceneとSennaの比較:近傍検索
Sennaは近傍検索ができるということなので、次のようにLudiaで試してみたがうまくいかなかった:



demo=> create table test (col text);
CREATE TABLE
demo=> insert into test values ('a b');
INSERT 0 1
demo=> insert into test values ('a 1 b');
INSERT 0 1
demo=> insert into test values ('a 1 2 b');
INSERT 0 1
demo=> insert into test values ('a 1 2 3 b');
INSERT 0 1
demo=> create index tidx on test using fulltext(col);
CREATE INDEX
demo=> select col,pgs2getscore(ctid,'tidx') from test where col @@ '*N5 "a b"' order by pgs2getscore;
col | pgs2getscore
-----+--------------
a b | 5
(1 row)
demo=>



*Nの数値を広げても"a b"しかヒットしなかった。

Ludiaのマニュアルを読むと、「シーケンシャルスキャンが選択された場合は近傍検索ができない」とあり、「シーケンシャルスキャンが選択されたときはエラーにすることが設定で可能」とある。たしかに上の場合はEXPLAINで調べるとシーケンシャルスキャンになっている。

シーケンシャルスキャンが選択されてしまうのはデータ件数が少ないからかも(?)と仮定し、ためしに別のテーブル(200万件超のレコードが入っている)でやってみると、見事に動作した。しかしこんなことでは本番システムでは安心して使えないだろう。

Luceneではこのような制限はもちろんなく、安全・簡単に近傍検索が行える。QueryParserを使った場合は、"a b"~5という具合に記述すればよい。

同じデータに対してのLuceneでの実行結果は次のようになる:



0.9710705 : a b
0.54932046 : a 1 b
0.44851825 : a 1 2 b
0.33987468 : a 1 2 3 b



コロンの左に表示されているスコアにも注目して欲しい。Luceneの近傍検索では単語同士がより近い文書ほどスコアが高くなる。

Senna/Ludiaでは単純にtfしかみないため、上記のデータの場合はすべて同スコアとなってしまう。
| 関口宏司 | その他のOSS検索エンジン | 12:42 | comments(0) | trackbacks(0) |
LuceneとSennaの比較:ストップワードの取り扱い
ストップワードだけからなる映画のタイトル一覧、というのを見つけた(というかMLに投稿されていた):

http://wunderwood.org/most_casual_observer/2007/05/invisible_titles.html

ほとんどの検索エンジンはストップワードをインデックスに登録しない。ストップワードは出現頻度が高く、通常検索には使用されない単語なので、そのような単語をはじくことでインデックスを「ヘルシー」に保つ効果がある。しかし上記の例のようなこともある。映画やDVDを検索するサイトを構築することを考えた場合、このようにストップワードだけからなる映画のタイトルが存在することをうっかり忘れると大変なことになるであろう。その映画はタイトルでは検索できないことになってしまう。もちろん、どんな単語をストップワードとするかは自由に定義できるが、この例での根本的な解決にならない。

Luceneではストップワードの定義やストップワード自体の使用有無はAnalyzerごとに自由に決めることができる。そしてFieldごとにAnalyzerは自由に選ぶことが可能だ。したがって、ストップワードだけからなる可能性があるタイトルフィールドにはストップワードを使わないAnalyzerを適用する、と決めることが可能である。「タイトル」のようなフィールドは短い文なので、それだけストップワードも説明文にある同じ単語よりも存在意義(価値)があると考えられる。したがってタイトルにはストップワードをあえて定義しない、という選択は納得できる。

Senna(あるいは最近試用中のLudia)ではもっと話は簡単のようだ。なぜならストップワードという考え方自体がないからである(私の調べ方が甘い可能性があるので間違っているかもしれない)。Sennaではこういったことを何も考えずに投入しても、安全に上記の映画タイトルを索引付けして検索することが可能である。
| 関口宏司 | その他のOSS検索エンジン | 18:21 | comments(0) | trackbacks(0) |
LuceneとSennaの比較:スコア計算
最近全文検索エンジンLudia(1.4.0)を触る機会を得た。LudiaはSennaのPostgreSQLバインディングである(念のため)。

Ludiaでのクエリーは、演算子@@を使ったSQLを投げればいいだけの簡単操作である。

たとえば次の例はauction_itemというテーブルのdescriptionカラムに「音楽」と「ビデオ」の両方の単語を含むレコードを「全文検索」で検索するSQLを発行している。ただし、結果をスコア順およびauction_id順でソートした結果の最初の1件のみを取得している:



demo=> select auction_id,title,pgs2getscore(ctid,'fidx') from auction_item where
description @@ '*D+ 音楽 ビデオ' order by pgs2getscore desc,auction_id limit 1;
auction_id | title | pgs2getscore
------------+-----------------------------------------------------------+--------------
s62908788 | ★SONYソニーハードディスクオーディオレコーダーNAC-HD1新品 | 40
(1 row)
demo=>



なお、auction_itemのカラムdescriptionには全文検索インデックスfidxがあらかじめ作成されている。

ところでSennaについて前から気になっていることをこの結果を見てまた思い出した。それは検索結果のスコアのことだ。Sennaではどういう計算をしてスコアを算出しているのか前から気になっていたのだった。

上記のSQLで検索結果の全件を表示させてみると(SQLの最後の"limit 1"をはずす)、スコアは40がしばらく続き、ついで30がしばらく続き、25が続き、20が続き、・・・というような結果が得られる。この根拠はなんだろう。

ところでLuceneではスコアの計算式はSimilarityクラスのJavadocで紹介されている:

http://hudson.zones.apache.org/hudson/job/Lucene-trunk/javadoc/org/apache/lucene/search/Similarity.html
Luceneのスコア計算式


このような計算式をSennaのマニュアル上でもぜひ見たいところである。しかしGoogleでSennaのスコアの計算式を調べても出てこない。それどころかSennaのスコア算出の根拠を気にしているユーザがあまりいなさそうなのは私にとっては驚きだ。なぜなら検索結果の順番は、顧客からもっとも問い合わせの多い項目のひとつだからである。

Sennaはソースコードをオープンにしているので、ソースを丹念に調べればスコア算出の根拠も自分で調べられる、といわれれば確かにそのとおりだ。なので一応ソースを覗いてみる。ソースをダウンロードしてgrep scoreとすると、次のような行を見つけることができた:



:
./lib/query.c: *score += w * tf;
./lib/query.c: *score += w * tf;
./lib/query.c: *score += w * tf;
:



他にもいくつかの行がヒットしていたのでここだけではないと思うが、とりあえず上の行だけ見るとなんらかの重み(?・・・"w"なので・・・)にtfを掛けてそれをスコアに足しこんでいるようである。idfはSennaでは考慮しないのか?と思いGoogleで"senna idf"で調べると今度はひっかかり、Sennaのマニュアルに次の説明を見つけることができた:


sen_sel_similar
stringをわかち書きした語のうち、idf値が大きなsimilarity_threshold個の語のいずれかを含むレコードを検索します。


どうやら設定によりidfが考慮されるようである。ということは、デフォルトの状態ではtfのみがスコア計算のパラメータとなるのだろうか。早速確かめてみよう。

Luidaで次のSQLを実行して検索対象文書となるレコードを作成する:



demo=> create table test (col text);
CREATE TABLE
demo=> insert into test values ('a c a d a e');
INSERT 0 1
demo=> insert into test values ('a a a a a a');
INSERT 0 1
demo=> insert into test values ('a b');
INSERT 0 1
demo=>



次に転置索引を作成する:



demo=> create index tidx on test using fulltext(col);
CREATE INDEX
demo=>



この状態で「aまたはb」(OR検索)を含む文書を全文検索してみよう。すると次のようになる:



demo=> select col,pgs2getscore(ctid,'tidx') from test where col @@ '*DOR a b'
order by pgs2getscore desc;
col | pgs2getscore
-------------+--------------
a a a a a a | 30
a c a d a e | 15
a b | 10
(3 rows)
demo=>



予想通りtfのみが考慮された結果、aを最も多く含む文書がトップに表示され、その次にaをたくさん含む文書が2番目になり、最後にやっとbを含む文書が表示された。このデフォルトの動作はユーザを困惑させると思うのだが、どうだろう。

具体例にたとえるならば、「アップル または コンピュータ」というクエリを発行したユーザの気持ちとしては、アップルコンピュータのページが果物屋のページよりも先に表示されて欲しい、ということである。

ところがSennaのデフォルト設定では(いや、これはLudiaのデフォルト設定と言い換えたほうがいいかもしれない)、アップルが多数登場する果物屋のページが上位表示されてしまうのである。

Luceneではどうなるかというと、この例の場合はidfが大きな役割を果たし、bという単語の希少価値がスコアに大きく影響する。その結果、「a b」が最も上位に表示されるようになる。ついでtfの大きな「a a a a a a」となり最後に「a c a d a e」となる。つまりLuceneでは「アップル または コンピュータ」というクエリを発行したユーザは、「アップル」という単語が多数登場する果物屋のページに惑わされることなくアップルコンピュータのページに先にたどり着けるわけである。

これを実際に確かめるコードを次に示す:



public class TestScore {

static Directory dir;
static Analyzer analyzer = new WhitespaceAnalyzer();

public static void main(String[] args) throws IOException, ParseException {
dir = new RAMDirectory();
makeIndex();
searchIndex();
}

static void makeIndex() throws IOException {
IndexWriter writer = new IndexWriter( dir, analyzer );
writer.addDocument( getDocument( "a c a d a e" ) );
writer.addDocument( getDocument( "a a a a a a" ) );
writer.addDocument( getDocument( "a b" ) );
writer.close();
}

static Document getDocument( String value ){
Document doc = new Document();
doc.add( new Field( "f", value, Store.YES, Index.TOKENIZED ) );
return doc;
}

static void searchIndex() throws IOException, ParseException {
IndexSearcher searcher = new IndexSearcher( dir );
QueryParser qp = new QueryParser( "f", analyzer );
Query query = qp.parse( "a b" );
Hits hits = searcher.search( query );
for( int i = 0; i < hits.length(); i++ ){
Document doc = hits.doc( i );
float score = hits.score( i );
System.out.println( score + " : " + doc.get( "f" ) );
//Explanation explain = searcher.explain( query, hits.id( i ) );
//System.out.println( explain.toString() );

}
searcher.close();
}
}



実行結果は次のとおり、「a b」の文書が最も高いスコアを獲得する:



0.98479235 : a b
0.14789723 : a a a a a a
0.10457913 : a c a d a e



一部の顧客は検索結果の順位についてひどく敏感である。たとえばスポンサー企業から掲載料を受け取って検索結果としてその企業情報や商品などを表示するサービスを提供している顧客などだ。このようなときはその検索結果の順位についてきちんと根拠を説明できないといけない。場合によっては「10位に表示されてしまっている企業情報を1位に表示する」よう要望されることもある。そのためにはまず、現状のスコア値の根拠がなんなのかを知ることが絶対に必要になってくる。

こんなときに便利なのがLuceneのExplanationクラスである。前記の赤字部分のコメントアウトをはずして再度実行すると、次のようなスコア計算の根拠が表示される:



0.98479235 : a b
0.98479235 = (MATCH) sum of:
0.20126262 = (MATCH) weight(f:a in 2), product of:
0.4520737 = queryWeight(f:a), product of:
0.71231794 = idf(docFreq=3, numDocs=3)
0.63465154 = queryNorm
0.4451987 = (MATCH) fieldWeight(f:a in 2), product of:
1.0 = tf(termFreq(f:a)=1)
0.71231794 = idf(docFreq=3, numDocs=3)
0.625 = fieldNorm(field=f, doc=2)
0.78352976 = (MATCH) weight(f:b in 2), product of:
0.8919806 = queryWeight(f:b), product of:
1.4054651 = idf(docFreq=1, numDocs=3)
0.63465154 = queryNorm
0.8784157 = (MATCH) fieldWeight(f:b in 2), product of:
1.0 = tf(termFreq(f:b)=1)
1.4054651 = idf(docFreq=1, numDocs=3)
0.625 = fieldNorm(field=f, doc=2)



Lucene本にはtf、idf、lengthNorm(≒fieldNorm)、boostなどの解説がある。また、プログラムを実行できる環境をもっていない方は、弊社デモサイトの「スコア計算デモ」も参考になるだろう。
| 関口宏司 | その他のOSS検索エンジン | 01:43 | comments(0) | trackbacks(0) |
Wikia開発者がLuceneメーリングリストに投稿したメール
Wikia Search(Search Wikia?)が公開されて話題となっている。まだアルファ版とのことで、日本語などの検索語は文字化けしてしまうようだ。

先日、LuceneのメーリングリストにWikia開発者のDennis Kubes氏からメールが投稿された。それによると、Wikiaではソースだけでなく、クロールしたデータも公開するとのこと。

なかなか興味深いので、以下その全文を紹介しよう。なお、翻訳はいい加減なので、必要ならば原文も同時に参照していただきたい:

http://www.nabble.com/Wikia-search-goes-live-today-to14665259.html#a14665259


ちょっと忙しくて返事ができなくてすみません。^^;ゞ

私のことを知らない人のために自己紹介すると、私はNutchプロジェクトのコミッターです。Wikiaには7月前半からかかわっていて、11月からはより活発に活動しています。Wikiaの前はNutchベースのVisvo.comという別のサーチエンジンを支援していました。

念のため記しておくと、Search WikiaはNutch/Hadoop/Lucene/Solr/HBaseを使用しており、今後もサポートしていくでしょう。Search Wikiaはこれらのプロジェクトの開発とコミュニティを支援していくつもりです。また、変更部分を秘密にしておくこともしません。Search Wikiaが開発したすべてのものはオープンソースで無料で使えるものとみなされます。Apacheプロジェクトに対して行われた改良は、それぞれのプロジェクトを通じてコミュニティにすみやかにフィードバックいたします。

検索をオープンかつ透明にするにはソースコードだけでは十分ではありません。Search Wikiaのデータをも無償公開するつもりでいます。これはクロールしたデータ、リンクデータ、コンテンツの断片、そして完成したインデックスを人々がダウンロードできることを意味します。また、foowiと名づけられたSNS機能もおそらくApache Licenseでオープンソースプロジェクトとなり、ダウンロード・使用・改良がなされるでしょう。

Search Wikiaはそれ単独ではありません。Visvo.comとWikiaは協力し合ってすべてのデータとコードの改良をOSIに承認されたライセンスのもと、コミュニティーにリリースしてまいります。これには分散環境に配置されたhadoopの設定を管理するPythonのフレームワークや、フェッチとインデクシング処理の自動化や、分散検索サーバの管理を含みます。

Nutchの観点から、現在以下の2つの標準Nutchインストレーションとインデックスがあります。ひとつはISCにホスティングされているインデックスで、もうひとつはVisvoのオープンインデックスです。ISCインデックスにはおよそ3千5百万ページ、Visvoインデックスには5千万以上のページが格納されています。

http://search.isc.swlabs.org
http://open-index.visvo.com

メインのSearch Wikiaサイトはアイオワ州のバンカーにあるセキュアな地下ホスティング施設にホストされています(http://usshc.com/)。そしてこれらのインデックスを呼び出しています。したがってキャッシュされたページやexplain planが参照されたときは、リクエストはそれぞれのインデックスに飛んでいきます。

2つのインデックスはブラウザまたはWeb2.0ベースのクライアントでアクセス可能です。現在私たちはNUTCH-594を使ってXMLフォーマットとJSONフォーマットでこれらのインデックスの検索結果をサービスできるようにしています。たとえば、javaという検索語のリクエストは、次のようになります:

http://search.isc.swlabs.org/nutchsearch?query=java&hitsPerSite=1&lang=en&hitsPerPage=10&type=json
http://open-index.visvo.com/nutchsearch?query=java&hitsPerSite=1&lang=en&hitsPerPage=10&type=json

そんなわけで、私たちはデータがダウンロードできるように忙しく働いています。数日のうちに可能になるよう、願っています。もし質問や特定のデータをご希望であれば、ご自由に私にメールをください。

Dennis Kubes
| 関口宏司 | その他のOSS検索エンジン | 21:28 | comments(1) | trackbacks(0) |
LuceneとSennaの比較:basic API編(sen_records)
引き続きSennaのbasic APIとそれに相当するLuceneのAPIの比較を行う。

今回はSennaのbasic APIのもうひとつの型であるsen_recordsを取り上げる。

sen_recordsはマニュアルによれば「検索結果として返されるレコードの集合をメモリ上に一時的に格納するためのデータ型です」ということだ。

なのでまずは前回のように検索の擬似コードを書いてsen_recordsを取得してみよう。それは次のようになる:



// Senna初期化
sen_init(void);

// インデックスのオープン
sen_index index = sen_index_open( "/usr/foo/index" );

// 検索
sen_records records = sen_index_sel( index, "サザエ AND 姉" );
while( sen_records_next( records, key, score ) > 0 ){
// keyについて表示などの処理を行う
}

// インデックスのクローズ
sen_index_close(index);

// Senna終了
sen_fin(void);



こんな感じだろうか(くどういようだが間違っている可能性もある)。

同じ検索プログラムは、Luceneでは次のようになる:



// インデックスのオープン
IndexSearcher searcher = new IndexSearcher( "/usr/foo/index" );

// 検索
QueryParser parser = new QueryParser( "text", analyzer );
Query query = parser.parse( "サザエ AND 姉" );
Hits hits = searcher.search( query );
for( int i = 0; i < hits.length(); i++ ){
// idの取得
int id = hits.id( i );
// Documentの取得
Document doc = hits.doc( i );
// scoreの取得
float score = hits.score( i );
// 表示などの処理
}

// インデックスのクローズ
searcher.close();



こんな感じである。

Sennaではまずインデックスのインスタンスを取得するためにsen_index_open()関数を使用する。

LuceneではIndexWriterのときと同様、インデックスのインスタンスを取得するのではなく、検索用のIndexSearcherのインスタンスを取得する。

次にSennaでは取得したインデックスと検索文字列をsen_index_sel()関数に渡して検索結果であるsen_recordsを得る。なおこの関数の説明はマニュアルでは「indexから、(中略)stringを含む文書を取り出し、sen_recordsインスタンスとして返します」とあるだけでこのstringに「クエリー書式」で説明されている検索式が書けるのかどうかはっきりしなかった。擬似コードでは検索式が書けるものと解釈しているが私の理解が間違っている可能性もある。

そして取得したsen_recordsを引数にしてsen_records_next()関数を呼び、sen_records内のレコードポインタをひとつずつ進めることでレコードのキーを取得する。レコードの実体はSennaでは保存しないため、プログラマがキーを使ってどこからかもって来なければならない。

LuceneではIndexSearcherのsearch()メソッドにQueryオブジェクトを渡して検索結果であるHitsオブジェクトを得る。なのでsen_recordsがHitsに相当することになろう。

ところでLuceneのsearch()メソッドには検索式をそのまま渡すことはできず、検索式からQueryオブジェクトを作ってそのQueryオブジェクトを渡さなければならない。

通常このQueryオブジェクトを作るには、QueryParserという検索式文字列を解釈するパーサを使って検索式を解釈させてQueryを得る。手順としてはLuceneの方が面倒だが、クエリー書式と検索エンジンは別のものとして独立させているのがLuceneの特徴といえるのかもしれない。

なお、検索式を解釈するためにAnalyzerが必要となるが、通常はインデックスを作成したときのAnalyzerを指定する。たとえば、インデックスを作成したときに形態素解析を行った場合はQueryParserにも形態素解析のAnalyzerを、N-gramで作成した場合はQueryParserにもN-gramのAnalyzerを指定する。

以下はSennaの検索結果を保持するsen_recordsとLuceneのHitsのAPI対応表である:

SennaLucene備考
sen_records_next()iterator()で取得したHitIteratorを使ってイテレート通常Hitsはイテレータではなく配列的にアクセスする
sen_records_rewind()HitIteratorはjava.util.Iteratorインタフェースなのでrewindはない。しかし、HitIteratorを再取得で可能同上
sen_records_curr_score()Hits.score()スコアを返す
sen_records_curr_key()Luceneではキーという概念はない。intのIDは自動的に振られるキーの長さを返す
sen_records_nhits()Hits.length()ヒット件数を返す
sen_records_find()IDを調べたければHits.length()までfor文で回しHits.id()をチェックLuceneではIDは自動的に振られるので、調べる必要はあまりない
sen_records_close()GCにより解放ヒット文書のリソースの解放


Sennaのbasic APIとして紹介されている部分のLuceneとの比較は以上である。

ここまでの私の感想は、SennaのAPIマニュアルは読みやすく、数回読んだだけで(擬似コードだが)プログラムが書けてしまうのは非常に好感触だ(しつこいようだが、プログラムは私のマニュアル解釈ミスを含んでいる可能性があり、ただ書けた気になっているだけかもしれない)。

気になった点はLuceneの「フィールド」に相当する概念がないことである。まだbasic APIしか読んでいないので相当するものがあるのかもしれない。しかしもしないとすると、フィールドごとにインデックスを作成するということになってしまうのかもしれず、もしそうであればプログラマの作業が大変なものになる。

どのくらい面倒くさいか、具体例をあげてみよう。

たとえばタイトルフィールドと本文フィールドからなる単純な文書を検索する場合を考える。

まずインデックスのインスタンスを2つ取得しなければならない:



// タイトルインデックスをオープン
sen_index idxTitle = sen_index_open( "/usr/foo/index.title" );

// 本文インデックスをオープン
sen_index idxBody = sen_index_open( "/usr/foo/index.body" );



そして、検索を2つのインデックスに対して別々に行い、2つの検索結果セットを得る:



// タイトルインデックスを検索
sen_records titleRecs = sen_index_sel( idxTitle, "サザエ AND 姉" );

// 本文インデックスを検索
sen_records bodyRecs = sen_index_sel( idxBody, "サザエ AND 姉" );



ここでプログラマは2つの検索結果セットを取得して困惑することになる。片方のフィールドのみにヒットした文書もあれば両方のフィールドにヒットした文書もあるだろう。タイトルインデックスでは高スコアを獲得した文書が本文インデックスでは低いスコアにとどまった場合、その文書は何位にランキングさせればいいのだろう。こういったことをプログラマが処理しなければならなくなる。

この例ではタイトルフィールドと本文フィールドという2つのフィールドしかもたない文書を考えたが、実際のアプリケーションでは数十の必須/オプションフィールドが入り混じった複雑な文書を扱うことが多いので、この方法ではすぐに破綻してしまう。

この辺はTritonnやLudiaなどが隠蔽しているのかもしれないが、ではマージする際の基準はスコアなのか(スコアしかないだろう)。しかし、両方のインデックスの文書件数が異なる場合、idf値などが比較できないのではないか。あるいは「同じ文書件数である」と決め打ちしているのだろうか。

またそもそもSennaではどのようにスコア計算がされているのかがわからなかった。なので「クエリー書式」で一部のスコアに影響を与える「数値」を指定できるようになっているが、どのくらいを指定していいのかとまどうのではないだろうか。

ちなみにLuceneではスコア計算はSimilarityクラスのJavadocで明確に説明されている:

SimilarityのJavadoc
http://lucene.zones.apache.org:8080/hudson/job/Lucene-Nightly/javadoc/org/apache/lucene/search/Similarity.html

そんなわけで、basic APIを読んだ限りではLuceneの「フィールド」に相当する概念がないように見えるがないはずはないので、advancedやlow-levelまで読み込めばきっとあるにちがいない、と思ったのであった。
| 関口宏司 | その他のOSS検索エンジン | 19:07 | comments(0) | trackbacks(0) |
LuceneとSennaの比較:basic API編(sen_index)
引き続きLuceneとSennaの比較を行う。

今回はAPIのうちSennaでbasic APIと分類されている中のsen_index型に関するAPIと、Luceneでそれに相当するAPIの比較を行う。

まずSennaのマニュアルの印象だが、簡潔にして非常にわかりやすい。

なんとなくわかった気になったので、今回は一覧表ではなく擬似コードで表現してみる。

まずは、Sennaでインデックスを作成するプログラムである:



// Senna初期化
sen_init(void);

// インデックスの作成
sen_index index = sen_index_create("/usr/foo/index", 0, flags, 10, sen_enc_utf8);

// 新規文書の追加
sen_index_upd(index, 1, NULL, "カツオはサザエの弟" );
sen_index_upd(index, 2, NULL, "サザエはワカメの姉" );
sen_index_upd(index, 3, NULL, "ワカメはカツオの妹" );

// インデックスのクローズ
sen_index_close(index);

// Senna終了
sen_fin(void);



こんな感じであろうか(まちがってるかも)。

同じことは、Luceneでは次のようになる(擬似コード):



// インデックスの作成
IndexWriter writer = new IndexWriter( "/usr/foo/index", analyzer );

// 新規文書の追加(addDocはサブルーチン)
addDoc( writer, "カツオはサザエの弟" );
addDoc( writer, "サザエはワカメの姉" );
addDoc( writer, "ワカメはカツオの妹" );

// インデックスのクローズ
writer.close();



Luceneではこんな感じだ。上記のaddDoc()は長くなるので下記のようなサブルーチンにまとめてある:



void addDoc( IndexWriter writer, String str ){
Document doc = new Document();
doc.add( new Field( "text", str, Store.YES, Index.TOKENIZED ) );
writer.addDocument( doc );
}



Sennaではまず、プログラムの開始で「初期化」を、プログラムの終わりで「終了処理」というものをプロセスごとに呼ぶ必要がある。これに相当するものはLuceneにはない。

次にインデックスの作成は、Sennaではsen_index_create関数を使って作成し、インデックスのインスタンスを表現するsen_index型のオブジェクトを受け取る。

これに対しLuceneでは「インデックスのインスタンス」を表現するものはなく、代わりにその名のとおり「インデックスに書き込むインスタンス」であるIndexWriterをnewで作成する。

ここで注目すべきポイントがある。

Sennaではsen_index_create関数にflagを指定するが、flagには次の組み合わせを指定する:

説明
SEN_INDEX_NORMALIZE英文字の大文字/小文字、全角文字/半角文字を正規化してインデックスに登録する
SEN_INDEX_SPLIT_ALPHA英文字列を文字要素に分割する
SEN_INDEX_SPLIT_DIGIT数字文字列を文字要素に分割する
SEN_INDEX_SPLIT_SYMBOL記号文字列を文字要素に分割する
SEN_INDEX_NGRAM(形態素解析ではなく)n-gramを用いる
SEN_INDEX_DELIMITED(形態素解析ではなく)空白区切りで単語を区切る


Sennaではインデックス単位で文字列の正規化方法やトークナイズ(単語抽出)方法を決定するようだ。しかもそのオプションはずいぶん限定的のように見える。上の表以外のフィルタリングやシノニム展開をしたい場合、プログラマはどのように対処すればいいのだろうか?もっともそういった要求は、basic APIではなくadvanced APIやlow-level APIで処理できるのかもしれない(まだ読んでいないのでわからない)。

Luceneでも文字列分析(Analyzerで行う)の指定は一見インデックス単位のように見えるが、フィールドごとに動作を変えることができる。フィールドごとに分析方法を変える場合、LuceneではPerFieldAnalyzerWrapperを使って行う。Analyzerはあらかじめ用意されているものを使用してもいいし、自分で作ることもできる。Analyzerを通じてさまざまなトークンの抽出や展開、変換・削除などのフィルタリングが可能となっている。

次に文書の登録であるが、Sennaではsen_index_upd関数を使って行う。sen_index_updは新規追加だけではなく、既存文書の変更や削除も行えるようだ。この関数で面白いと思うのは、既存文書の変更や削除を行う際に文書IDだけでなく、登録済み文書の文字列も要求している点である(私のマニュアルの読み方が誤ってなければ)。文書IDを要求するのはわかるが文書の文字列まで渡す必要があるのはなぜだろう。もしかしたら、単語表から単語を削除するのに文書の文字列を必要としているのかもしれない。もしそうだとすると、文書IDと異なる文書文字列を変更や削除のときに渡してしまった場合、インデックスはどうなってしまうのだろう。インデックスの整合性がとれなくなってしまうのだろうか。

Luceneでは文書はDocumentクラスのオブジェクトである。Documentは複数のFieldからなり、Fieldに文書の文字列はもちろん、フィールドの名前(上記プログラムではフィールド名を"text"としている)、ストアするかどうか、および分析するかどうかなどを指定する(これら以外にもTermVectorの指定などがあるが省略する)。

Sennaは文書をストアしないことを特徴としている検索エンジンなので(将来バージョンではストアすることが選択できるようだ)、外部文書のプライマリキーである文書IDが索引付けの際に必要となる。Luceneは文書IDに相当するものはLuceneエンジンの方で自動的に振られる。もし自分で決めたプライマリキーをLuceneの文書に持たせたいなら、"id"というようなフィールドを作成し、そこにキー文字列を登録すればよい。

マニュアルをここまで読んだ限りでは、SennaはLuceneの「フィールド」に相当する概念がないように見える。もしかしたらSennaでは複数フィールドを実現するのに、複数のインデックスを同時にオープンする必要があるのかもしれない(この辺は私の理解が間違っている可能性は大いにある)。
| 関口宏司 | その他のOSS検索エンジン | 02:52 | comments(0) | trackbacks(1) |
+ Solrによるブログ内検索
+ PROFILE
    123
45678910
11121314151617
18192021222324
252627282930 
<< November 2018 >>
+ LINKS
検索エンジン製品 - 比較のポイント
商用検索エンジンを購入した企業担当者は読まないでください。ショックを受けますから・・・
>>製品比較 10のポイント
+ Lucene&Solrデモ
+ ThinkIT記事
+ RECOMMEND
Apache Solr入門 ―オープンソース全文検索エンジン
Apache Solr入門 ―オープンソース全文検索エンジン (JUGEMレビュー »)
関口 宏司,三部 靖夫,武田 光平,中野 猛,大谷 純
+ RECOMMEND
Lucene in Action
Lucene in Action (JUGEMレビュー »)
Erik Hatcher,Otis Gospodnetic,Mike McCandless
FastVectorHighlighterについて解説記事を寄稿しました。
+ RECOMMEND
+ SELECTED ENTRIES
+ RECENT COMMENTS
+ RECENT TRACKBACK
+ CATEGORIES
+ ARCHIVES
+ MOBILE
qrcode
+ SPONSORED LINKS