関口宏司のLuceneブログ

OSS検索ライブラリのLuceneおよびそのサブプロジェクト(Solr/Tika/Mahoutなど)について
ホワイトペーパー「Excelで学ぶ!Luceneのスコア計算」公開
ホワイトペーパー「Excelで学ぶ!Luceneのスコア計算」を公開した。

http://www.rondhuit.com/download.html
| 関口宏司 | Luceneスコアリング | 09:18 | comments(0) | trackbacks(0) |
短いフィールドにはコンスタントなtfが有効な件
タイトルのような短いフィールドには、tfを考慮しない方がいい場合がある。たとえば、"New York"というタイトルのDVDを探しているとき、DefaultSimilarityのtf()が効いていると、ずばり"New York"というタイトルよりも"New York, New York"の方がスコアが高くなってしまう:

New York, New York : 0.5945349
0.5945349 = (MATCH) sum of:
  0.29726744 = (MATCH) weight(title:new in 0), product of:
    0.70710677 = queryWeight(title:new), product of:
      0.5945349 = idf(docFreq=2, maxDocs=2)
      1.1893445 = queryNorm
    0.42039964 = (MATCH) fieldWeight(title:new in 0), product of:
      1.4142135 = tf(termFreq(title:new)=2)
      0.5945349 = idf(docFreq=2, maxDocs=2)
      0.5 = fieldNorm(field=title, doc=0)
  0.29726744 = (MATCH) weight(title:york in 0), product of:
    0.70710677 = queryWeight(title:york), product of:
      0.5945349 = idf(docFreq=2, maxDocs=2)
      1.1893445 = queryNorm
    0.42039964 = (MATCH) fieldWeight(title:york in 0), product of:
      1.4142135 = tf(termFreq(title:york)=2)
      0.5945349 = idf(docFreq=2, maxDocs=2)
      0.5 = fieldNorm(field=title, doc=0)

New York : 0.5254995
0.5254995 = (MATCH) sum of:
  0.26274976 = (MATCH) weight(title:new in 1), product of:
    0.70710677 = queryWeight(title:new), product of:
      0.5945349 = idf(docFreq=2, maxDocs=2)
      1.1893445 = queryNorm
    0.3715843 = (MATCH) fieldWeight(title:new in 1), product of:
      1.0 = tf(termFreq(title:new)=1)
      0.5945349 = idf(docFreq=2, maxDocs=2)
      0.625 = fieldNorm(field=title, doc=1)
  0.26274976 = (MATCH) weight(title:york in 1), product of:
    0.70710677 = queryWeight(title:york), product of:
      0.5945349 = idf(docFreq=2, maxDocs=2)
      1.1893445 = queryNorm
    0.3715843 = (MATCH) fieldWeight(title:york in 1), product of:
      1.0 = tf(termFreq(title:york)=1)
      0.5945349 = idf(docFreq=2, maxDocs=2)
      0.625 = fieldNorm(field=title, doc=1)


このようなときはDefaultSimilarityのtf()をオーバライドして常に1が返るようにする。すると:

New York : 0.5254995
0.5254995 = (MATCH) sum of:
  0.26274976 = (MATCH) weight(title:new in 1), product of:
    0.70710677 = queryWeight(title:new), product of:
      0.5945349 = idf(docFreq=2, maxDocs=2)
      1.1893445 = queryNorm
    0.3715843 = (MATCH) fieldWeight(title:new in 1), product of:
      1.0 = tf(termFreq(title:new)=1)
      0.5945349 = idf(docFreq=2, maxDocs=2)
      0.625 = fieldNorm(field=title, doc=1)
  0.26274976 = (MATCH) weight(title:york in 1), product of:
    0.70710677 = queryWeight(title:york), product of:
      0.5945349 = idf(docFreq=2, maxDocs=2)
      1.1893445 = queryNorm
    0.3715843 = (MATCH) fieldWeight(title:york in 1), product of:
      1.0 = tf(termFreq(title:york)=1)
      0.5945349 = idf(docFreq=2, maxDocs=2)
      0.625 = fieldNorm(field=title, doc=1)

New York, New York : 0.42039964
0.42039964 = (MATCH) sum of:
  0.21019982 = (MATCH) weight(title:new in 0), product of:
    0.70710677 = queryWeight(title:new), product of:
      0.5945349 = idf(docFreq=2, maxDocs=2)
      1.1893445 = queryNorm
    0.29726744 = (MATCH) fieldWeight(title:new in 0), product of:
      1.0 = tf(termFreq(title:new)=2)
      0.5945349 = idf(docFreq=2, maxDocs=2)
      0.5 = fieldNorm(field=title, doc=0)
  0.21019982 = (MATCH) weight(title:york in 0), product of:
    0.70710677 = queryWeight(title:york), product of:
      0.5945349 = idf(docFreq=2, maxDocs=2)
      1.1893445 = queryNorm
    0.29726744 = (MATCH) fieldWeight(title:york in 0), product of:
      1.0 = tf(termFreq(title:york)=2)
      0.5945349 = idf(docFreq=2, maxDocs=2)
      0.5 = fieldNorm(field=title, doc=0)


サンプルプログラムは次の通り。searchIndex()メソッド内のコメントを有効/無効にすると切り替えられる:

public class TestNewYork {

  static final String[] TITLES = { "New York, New York", "New York" };
  static final String F = "title";
  static Directory dir = new RAMDirectory();
  static Analyzer analyzer = new StandardAnalyzer( Version.LUCENE_CURRENT );
  
  public static void main(String[] args) throws Exception {
    makeIndex();
    searchIndex();
  }

  static void makeIndex() throws IOException {
    IndexWriter writer = new IndexWriter( dir, analyzer, true, MaxFieldLength.LIMITED );
    for( String title : TITLES ){
      Document doc = new Document();
      doc.add( new Field( F, title, Store.YES, Index.ANALYZED ) );
      writer.addDocument( doc );
    }
    writer.close();
  }
  
  static void searchIndex() throws Exception {
    QueryParser parser = new QueryParser( F, analyzer );
    Query query = parser.parse( "new york" );
    IndexSearcher searcher = new IndexSearcher( dir, true );
    //searcher.setSimilarity( new ConstantTfSimilarity() );
    TopDocs docs = searcher.search( query, 10 );
    for( ScoreDoc scoreDoc : docs.scoreDocs ){
      Document doc = searcher.doc( scoreDoc.doc );
      float score = scoreDoc.score;
      System.out.println( doc.get( F ) + " : " + score );
      Explanation e = searcher.explain( query, scoreDoc.doc );
      System.out.println( e.toString() );
    }
    searcher.close();
  }
  
  static class ConstantTfSimilarity extends DefaultSimilarity {
    public float tf( float freq ){
      return 1;
    }
  }
}
| 関口宏司 | Luceneスコアリング | 11:50 | comments(0) | trackbacks(0) |
boostをゼロにして検索すると・・・
次のようなラーメン店のデータがあり、フィールドshopに登録されるとする:



static final String[] SHOPS = {
"ラーメン 1", "ラーメン 2", "ラーメン 3", "ラーメン 4", "ラーメン 5",
"ラーメン 6", "ラーメン 7", "ラーメン 8", "ラーメン 9", "ラーメン 10",
"ラーメン 11", "ラーメン 12", "ラーメン 13", "ラーメン 14", "ラーメン 15"
};



そして同じ文書の別フィールドwardに次のようにラーメン店の場所が登録されるとする:



static final String[] WARDS = {
"江東区", "江東区", "江東区", "新宿区", "文京区",
"江東区", "江東区", "江東区", "新宿区", "文京区",
"江東区", "江東区", "江東区", "新宿区", "文京区"
};



ここで次のように「江東区または新宿区のラーメン店」を検索するQueryを作成して検索する:



QueryParser parser = new QueryParser( "shop", analyzer );
Query query = parser.parse( "+ラーメン +(ward:江東区 OR ward:新宿区)" );
TopDocs docs = searcher.search( query, 100 );



取得したdocsを表示(スコアとDocument内容を整形して)すると、次のようになる:



1.1294092 : ラーメン 4@新宿区
1.1294092 : ラーメン 9@新宿区
1.1294092 : ラーメン 14@新宿区
0.53457046 : ラーメン 1@江東区
0.53457046 : ラーメン 2@江東区
0.53457046 : ラーメン 3@江東区
0.53457046 : ラーメン 6@江東区
0.53457046 : ラーメン 7@江東区
0.53457046 : ラーメン 8@江東区
0.53457046 : ラーメン 11@江東区
0.53457046 : ラーメン 12@江東区
0.53457046 : ラーメン 13@江東区



上記のように「江東区」と「新宿区」ではDocument数が異なるために地域属性のIDFが違ってきてスコアが新宿が高くなってしまう。

地域のような属性フィールドではスコアを計算したくないとき、通常はFilterを使うことが考えられるが、Filterは場合によっては使いたくないときもあるだろう。

そんなときは、上記の検索式の地域属性の項のBOOSTを次のようにゼロに設定するとよい:



Query query = parser.parse( "+ラーメン +(ward:江東区 OR ward:新宿区)^0" );



これで実行すると、次のように地域間でスコアの違いがなくなる:



0.5846634 : ラーメン 1@江東区
0.5846634 : ラーメン 2@江東区
0.5846634 : ラーメン 3@江東区
0.5846634 : ラーメン 4@新宿区
0.5846634 : ラーメン 6@江東区
0.5846634 : ラーメン 7@江東区
0.5846634 : ラーメン 8@江東区
0.5846634 : ラーメン 9@新宿区
0.5846634 : ラーメン 11@江東区
0.5846634 : ラーメン 12@江東区
0.5846634 : ラーメン 13@江東区
0.5846634 : ラーメン 14@新宿区


| 関口宏司 | Luceneスコアリング | 00:18 | comments(1) | trackbacks(0) |
(アイディア)逆ページランク
経済アナリスト 森永 卓郎氏のコラム「役所のマインドコントロールから脱け出せ!」を読んだ。これによると財務省のホームページでは、同省の都合の悪い記述は脚注などで小さい文字で表示されているらしい。

そこで思いついたのがタイトルのアイディアだ。私はかねてより官公庁や自治体などのホームページを横断検索するデモを作りたいと思って(2年以上経つがなかなか着手できないでいるのだが、それはこの際どうでもよい)いる。そのサイトで「小さい文字ほど重要である」「階層が深いほど重要である」「被リンクが少ないほど重要である」とみなす、その名も「逆ページランク」というのを実装してみてはどうだろう。

官公庁のホームページでは国民に知らせたくない重要な情報ほど文字を小さくしているようなので、この冗談のようなアイディアは案外いいセン行くのではないか。

この方法によると、<H1>よりも<H4>が、<B>よりも通常文字列が、通常文字列よりも<font size="-1">がより国民にとって重要になってくるのであり、高いスコアを与えて検索結果の上位にランキングさせるのである。なんじゃそりゃ。
| 関口宏司 | Luceneスコアリング | 22:17 | comments(3) | trackbacks(0) |
人名がヒットしたときはスコアを上げる
前回紹介したペイロードのプログラムではSenの出力である品詞情報をペイロード領域に登録し、TermEnumとTermPositionsで全Termのペイロードを取得するというものであった。これにより、名詞など指定した品詞の単語をカウントして多く出現する単語上位10個を表示した。

これはこれで今までのLuceneでは実現が面倒なプログラムが比較的簡単に書けて面白いが、「品詞(ペイロード情報)で検索できないのか」という疑問が当然のことながら湧くだろう。

ペイロードに関する計画には将来はペイロードで検索できるようなことが書かれている。しかしながら現在は、ペイロードで検索はできない。

それは、Luceneのファイルフォーマット情報を見れば、ペイロードは.prxファイルに格納されており(そのためTermPositionsインタフェースでアクセスできるようになっている)、転置索引に登録されている単語とは異なるため、検索に適した状態になっていないことからもうなずける。

ただし、ペイロード情報をスコア計算に使用することは可能である。今回はそのためのQueryクラスであるBoostingTermQueryを使って、検索語が人名のときは他の単語よりもスコアを上げる、というプログラムを作成してみる。

検索に使用するデータは次のとおりである:


private static final String[] CONTENTS = {
"政調会長は中川さんです。",
"総理大臣は安倍さんです。"
};


これに対し、検索語に次の2つを使用する:


private static final String T1 = "安倍";
private static final String T2 = "政調";


さて、これらの検索語のうち人名である「安倍」がヒットしたときだけ、そのドキュメントのスコアが他のドキュメントのスコアよりも上がるようにする、というのが今回作成するプログラムだが、その前にまずは通常のBooleanQueryでOR検索をしてみよう。プログラムは次のようになる:


IndexSearcher searcher = new IndexSearcher( dir );
BooleanQuery query = new BooleanQuery();
Query tq1 = new TermQuery( new Term( F, T1 ) );
Query tq2 = new TermQuery( new Term( F, T2 ) );
query.add( tq1, Occur.SHOULD );
query.add( tq2, Occur.SHOULD );
Hits hits = searcher.search( query );
for( int i = 0; i < hits.length(); i++ ){
Document doc = hits.doc( i );
int id = hits.id( i );
float score = hits.score( i );
System.out.println( score + " : " + doc.get( F ) );
//Explanation exp = searcher.explain( query, id );
//System.out.println( exp.toString() );
}
searcher.close();


実行結果は次のようになり、同点スコアなので登録順にドキュメントが表示される:


0.35355338 : 政調会長は中川さんです。
0.35355338 : 総理大臣は安倍さんです。


BoostingTermQueryを使う

では上記のQueryにBoostingTermQueryの項を加えて品詞が「人名」のとき(品詞は名詞だが、Senの品詞情報に「人名」が含まれる)にスコアが上がるようにする。BoostingTermQueryの項を加えると、次のようになる:


:
BooleanQuery query = new BooleanQuery();
Query tq1 = new TermQuery( new Term( F, T1 ) );
Query tq2 = new TermQuery( new Term( F, T2 ) );
query.add( tq1, Occur.SHOULD );
query.add( tq2, Occur.SHOULD );
Query btq1 = new BoostingTermQuery( new Term( F, T1 ) );
Query btq2 = new BoostingTermQuery( new Term( F, T2 ) );
query.add( btq1, Occur.SHOULD );
query.add( btq2, Occur.SHOULD );

Hits hits = searcher.search( query );
:


BoostingTermQueryとしてはコードは以上だが、この状態ではまだ「人名のときにスコアを上げる」というコードがどこにも入っていない。ではどうするかというと、Lucene 2.2からBoostingTermQueryがペイロードからスコアを計算するためのSimilarityの新しいメソッドscorePayload()というのが追加された。DefaultSimilarityではこのメソッドが常に1を返すようになっているが、BoostingTermQueryをきちんと動作させるには、ペイロードの値を見て適当なfloat値を返すscorePayload()を実装する必要があるのだ。

Similarityを実装する

ではSimilarityを実装しよう。オーバーライドするのはscorePayload()だけなので、DefaultSimilarityを継承して次のようにすればよい:


class PayloadSimilarity extends DefaultSimilarity {
private static final String POS_NAME = "人名";
public float scorePayload(byte[] payload, int offset, int length){
return isName( payload, offset, length ) ? 2.0f : 1.0f;
}


private static boolean isName( byte[] payload, int offset, int length ){
String paystr = new String( payload, offset, length );
return paystr.indexOf( POS_NAME ) >= 0 ? true : false;
}
}


そして実装したPayloadSimilarityクラスを検索する前にIndexSearcherに次のように設定する:


IndexSearcher searcher = new IndexSearcher( dir );
searcher.setSimilarity( new PayloadSimilarity() );
:


これでプログラムを再度実行してみると、次のように「安倍」が含まれているほうが「政調」を含むドキュメントよりも高いスコアを獲得し、上位に表示される:


0.6035534 : 総理大臣は安倍さんです。
0.4267767 : 政調会長は中川さんです。


最終的なプログラムは次のようになる:


public class TestBoostingTermQuery {

private static final String[] CONTENTS = {
"政調会長は中川さんです。",
"総理大臣は安倍さんです。"
};
private static final String F = "f";
private static final String T1 = "安倍";
private static final String T2 = "政調";
private static Directory dir;
private static Analyzer analyzer = new JapaneseAnalyzer();

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

private static void makeIndex() throws IOException{
IndexWriter writer = new IndexWriter( dir, analyzer );
for( String content : CONTENTS ){
Document doc = new Document();
Field f = new Field( F, content, Store.YES, Index.TOKENIZED );
f.setOmitNorms( true );
doc.add( f );
writer.addDocument( doc );
}
writer.close();
}

private static void searchIndex() throws IOException{
IndexSearcher searcher = new IndexSearcher( dir );
searcher.setSimilarity( new PayloadSimilarity() );
BooleanQuery query = new BooleanQuery();
Query tq1 = new TermQuery( new Term( F, T1 ) );
Query tq2 = new TermQuery( new Term( F, T2 ) );
query.add( tq1, Occur.SHOULD );
query.add( tq2, Occur.SHOULD );
Query btq1 = new BoostingTermQuery( new Term( F, T1 ) );
Query btq2 = new BoostingTermQuery( new Term( F, T2 ) );
query.add( btq1, Occur.SHOULD );
query.add( btq2, Occur.SHOULD );
Hits hits = searcher.search( query );
for( int i = 0; i < hits.length(); i++ ){
Document doc = hits.doc( i );
int id = hits.id( i );
float score = hits.score( i );
System.out.println( score + " : " + doc.get( F ) );
//Explanation exp = searcher.explain( query, id );
//System.out.println( exp.toString() );
}
searcher.close();
}

static class PayloadSimilarity extends DefaultSimilarity {
private static final String POS_NAME = "人名";
public float scorePayload(byte[] payload, int offset, int length){
return isName( payload, offset, length ) ? 2.0f : 1.0f;
}

private static boolean isName( byte[] payload, int offset, int length ){
String paystr = new String( payload, offset, length );
return paystr.indexOf( POS_NAME ) >= 0 ? true : false;
}
}
}


Senからは品詞情報の他にも単語の読みがなも出力されるので、ペイロードの使い道として検討してみても面白いかもしれない。
| 関口宏司 | Luceneスコアリング | 09:29 | comments(0) | trackbacks(0) |
2つの単語間の距離をスコアに反映する
次のような5つのドキュメントから単語"AAA"と"BBB"を含むドキュメントを検索したいとする:



private static final String[] CONTENTS = {
"AAA CCC DDD EEE",
"BBB CCC DDD EEE",
"AAA CCC DDD BBB",
"AAA CCC BBB DDD",
"AAA BBB CCC DDD"
};



プログラムは簡単で、次のようになる:



public class Test {

private static final String[] CONTENTS = {
"AAA CCC DDD EEE",
"BBB CCC DDD EEE",
"AAA CCC DDD BBB",
"AAA CCC BBB DDD",
"AAA BBB CCC DDD"
};
private static final String F = "f";
private static Directory dir;
private static Analyzer analyzer = new WhitespaceAnalyzer();

public static void main(String[] args) throws IOException {
makeIndex();
searchIndex();
}

private static void makeIndex() throws IOException{
dir = new RAMDirectory();
IndexWriter writer = new IndexWriter( dir, analyzer );
for( String content : CONTENTS ){
Document doc = new Document();
doc.add( new Field( F, content, Store.YES, Index.TOKENIZED ) );
writer.addDocument( doc );
}
writer.close();
}

private static void searchIndex() throws IOException{
IndexSearcher searcher = new IndexSearcher( dir );
Term ta = new Term( F, "AAA" );
Term tb = new Term( F, "BBB" );
BooleanQuery bq = new BooleanQuery();
bq.add( new TermQuery( ta ), Occur.SHOULD );
bq.add( new TermQuery( tb ), Occur.SHOULD );
Hits hits = searcher.search( bq );
for( int i = 0; i < hits.length(); i++ ){
Document doc = hits.doc( i );
float score = hits.score( i );
System.out.println( score + "¥t" + doc.get( F ) );
}
searcher.close();
}
}



この実行結果は次のようになり、単語"AAA"と"BBB"両方を含むドキュメントがどちらかひとつだけを含むドキュメントよりも高いスコアを獲得していることがわかる:



0.70710677 AAA CCC DDD BBB
0.70710677 AAA CCC BBB DDD
0.70710677 AAA BBB CCC DDD
0.17677669 AAA CCC DDD EEE
0.17677669 BBB CCC DDD EEE



このプログラムでは、単語"AAA"と"BBB"両方を含む3つのドキュメントがすべて同じスコアとなっている。

ここで単語"AAA"と"BBB"が検索ユーザから見て相互に関連性の高い単語の場合を考えてみる。たとえば、「Tomcat上でHibernateを使ったサンプルプログラム」をインターネットから探すときに、プログラマが検索窓に「Tomcat Hibernate」と入力して2つの単語が登場する1つの記事を探す、というような場面である。

そしてその検索結果一覧をクリックして記述された記事(仮にブログとする)を読んだときに、TomcatとHibernateが同一ページに現れているものの、実は全然関係ない2つの記事にTomcatとHibernateが別々に書かれていてがっかりした、という経験は誰しも一度はあるだろう。

このようなとき、2つの単語間の距離をスコアに反映し、近いものほど高いスコアになるようにできれば、ある程度このようなガッカリ感は防ぐことができると考えられる。

Luceneでは2つ(以上)の単語の距離をスコアに反映して近いものほど高いスコアを獲得させるようにすることが簡単にできる。これにはPhraseQueryとslopを使用する。

slopはPhraseQueryで使用される成句とみなせる2つの単語間の最大距離を示すパラメータである(詳しくはLucene本190ページを参照)。

このslop値を適当な範囲までとってスコアに加算するようにプログラムを修正する。上記のサンプルプログラムを次のように書き換えれば、単語"AAA"と"BBB"の距離が近いものほどスコアが上がるようになる:




private static void searchIndex() throws IOException{
IndexSearcher searcher = new IndexSearcher( dir );
Term ta = new Term( F, "AAA" );
Term tb = new Term( F, "BBB" );
BooleanQuery bq = new BooleanQuery();
bq.add( new TermQuery( ta ), Occur.SHOULD );
bq.add( new TermQuery( tb ), Occur.SHOULD );
PhraseQuery pq = new PhraseQuery();
pq.add( ta );
pq.add( tb );
pq.setSlop( 2 );
bq.add( pq, Occur.SHOULD );
Hits hits = searcher.search( bq );
for( int i = 0; i < hits.length(); i++ ){
Document doc = hits.doc( i );
float score = hits.score( i );
System.out.println( score + "¥t" + doc.get( F ) );
}
searcher.close();
}



実行結果は次のとおりとなる:



1.0 AAA BBB CCC DDD
0.8047378 AAA CCC BBB DDD
0.7182335 AAA CCC DDD BBB
0.055555556 AAA CCC DDD EEE
0.055555556 BBB CCC DDD EEE



Solrではこの機能を使って、「ユーザが入力した検索式の全体をPhraseQueryに変換し、単語間相互の距離が近いドキュメントほどスコアを高くする」ということをDisMaxRequestHandlerにて行っている。
| 関口宏司 | Luceneスコアリング | 10:44 | comments(0) | trackbacks(0) |
FieldNormModifier
「長けりゃいいってものじゃない」

この意見には誰もが首肯するところであろう。たとえば超大作と宣伝された映画や小説などを見たり読んだりした後に、やたら長いだけで内容的に満足できずひどくがっかりした、というは経験は多くの人にあるだろう。映画や小説のようなものはエンターテイメントなのでまだいいが、たとえばプログラミングの仕事で調べ物をするとき、検索で引っかかった記事がやたら長いだけで開発のヒントにならなかった、というのは仕事の能率に影響してしまうので、そういう記事は検索結果の表示順で下位にランキングして欲しい。

このような要求を完全に満たすことは難しいが、Luceneでは現実的な解としてランキングを決めるスコア計算の一部分であるlengthNorm()がこの「長けりゃいいってものじゃない」という人の気持ちを代弁するものとして提供されている。このメソッドは、同じ検索キーワードを同じ個数含む記事があるときに、より短いほうの記事のスコアを上昇させる役割がある。短い記事に含まれるほうが、より密度が濃いと考えられるからである(参考:Lucene本 p203)。

しかし先日、次のような顧客に会った:

「長くたっていいじゃないか」

だってにんげんだもの・・・思わずそう下の句をつけたした私であった(もちろん心の中で)。そうだ。私のブログ記事はしばしば技術的なこと以外の内容がだらだらと続き、プロジェクトで忙しいプログラマの不興をかっているのではないかと気になることもしばしばだ。そのため、「長くてもいいんです」というその言葉がこの際どうでもいいことだが、深く心に沁みたものである。

それはともかく、このlengthNorm()は当該フィールドのトークン数を引数に取る関数なので、検索実行時に計算する必要がない。そのためLuceneではフィールドのboost値を掛けた結果をfieldNormとして記録してしまっている。そのため、SimilarityをオーバーライドしてlengthNorm()を書き換えた場合、インデックスを再作成する必要があるのであった。

ところが最近、Luceneのソースを見ているときに、trunkにFieldNormModifierなるクラスがあることを発見した。このソースを読むと、どうやら既存のインデックスのfieldNorm値を書き換えるコマンドラインツールのようである。これは、次のように使う:



$ java org.apache.lucene.index.FieldNormModifier インデックスパス名 Similarityクラス名 フィールド名1 [フィールド名2 ...]



こうすると、引数で指定したインデックスのフィールドのfieldNorm値が、指定したSimilarityのlengthNorm()を使って再計算されて書き換えられる(ただし、boostは指定できないのでlengthNorm()だけで調整する必要がある)。Similarityクラス名を指定するところに代わりに-nを指定すると、fieldNorm値が1.0と記録されるようになるので単に「固定値にしたい」というときに便利である。

なお、FieldNormModifierはcontribに入っているものなので、次のようにビルドする必要がある:



$ pwd
/home/foo/bar/jakarta/lucene/java/trunk
$ cd contrib/miscellaneous
$ ant



上記のようにビルドすると、trunk/build/contrib/miscディレクトリにlucene-misc-2.2-dev.jarファイルができるので、trunk直下でantを実行して作成したlucene-core-2.2-dev.jarファイルとともに用いて実行する(Lucene 2.1と組み合わせては使用できないので注意)。

Luceneを使ったアプリケーションの運用を開始したはいいが、記事の長さはスコアに反映したくないということに後から気がついてしまい、巨大なインデックスを前に途方にくれていたプロジェクトメンバーには朗報かもしれない。
| 関口宏司 | Luceneスコアリング | 13:50 | comments(0) | trackbacks(0) |
QueryParserを使用した範囲検索
ここ数ヶ月ノートPC使用中、いわゆる「ブルースクリーン」の出現で悩まされている。
時期的には、もともと購入時の512Mメモリに1G追加したときからの発生なので、まずはメモリを疑い、Memtest86というツールを使ってチェックしてみたのだが、問題ないようだ。

次にハードディスクを疑い、OS(Windowsである)付属のディスクチェックツールを使ったりしているのだが、悪いところは見つからない。メーカーに修理に出すという手段があるのかもしれないが、その間持ち運べるPCがなくなるのは困る。Vistaも出たことだし(というのは自分が納得するための言い訳に過ぎないが)DuoCoreのノートを買ったほうがいいだろうか、などと考えているところだ。そんなことで、最近は価格.comによく訪れてノートPCの価格を調べたりしている。

というわけで、本日のネタデータはある日ある機種のノートPC価格のショップ比較を使用することにする。それは次のようなものであった:

ショップAショップBショップCショップD
198000198000180000208000


上記のようなデータがLuceneのインデックスに索引付けされているとする(価格はpriceというフィールド名とする)。ここでQueryParserを使って次のような検索式で検索を実行する:



price:[000000 TO 199999]



検索式の意味は、価格が20万円以下のものを探す、ということだ。すると、現在のLuceneバージョン2.1を使うと、次のような結果が得られる(プログラムはこの記事の最後に掲載する):



1.0 ショップA 198000
1.0 ショップB 198000
1.0 ショップC 180000



ここでショップ名の前に表示されている実数は各ドキュメントのスコアである。スコアが同じなので、ドキュメントIDの順(=ドキュメントの登録順)に結果が表示されている。使用目的から考えれば、価格の安い順に並んだ方が親切であるが、それは単にソートの問題なのでここではおいておく。

同じプログラムを、一つ前のバージョンであるLucene 2.0で実行してみる。すると、次のように面白い結果となる:



1.0 ショップC 180000
0.5783994 ショップA 198000
0.5783994 ショップB 198000



Lucene 2.0ではショップAとショップBのドキュメントのスコアが1.0より小さくなり、そのためにLucene 2.1の結果と表示順が異なる結果となった。

この原因は何かというと、Lucene 2.1での次の改善が実施されたためである:

http://issues.apache.org/jira/browse/LUCENE-703

これは何かというと、QueryParserで範囲検索の検索式をQueryに展開する際、Lucene 2.0まではRangeQueryに展開していたものを、Lucene 2.1からはConstantScoreRangeQueryに変更した、というものである。なぜそのような変更がなされたかというと、範囲検索のようなものはもともと、スコアを計算してランキングをするようなものじゃないだろう、というような理由があるためだ。しかしながら、Luceneでは1.4.3までは範囲検索としてはRangeQueryがあり、QueryParserでは範囲検索の検索式をRangeQueryに展開していた(Lucene 2.0まで)。

RangeQueryは実際の検索を実行する際は、TermQueryをBooleanQueryで組み合わせたQueryに展開される。これはWildcardQueryなどでも同様である。どのように展開されるかは、Query.rewrite()を実行して得られたQueryのtoString()を見てみるとよい。今回のサンプルデータでは、RangeQueryは次のように展開される:



price:180000 price:198000



これでLucene 2.0ではスコアが異なる理由がわかるであろう。つまり、価格が198000円のものは180000円のものよりありふれているのでIDF値が低くなってしまうのである。

理屈はそうかもしれないが、結果はいかにも奇妙なものに見える。それがLucene 2.1でRangeQueryがConstantScoreRangeQueryで置き換えられた理由だ。ConstantScoreRangeQueryはその名のとおり固定スコアを返すQueryであり、検索の際もBooleanQueryに展開しない。そのため、TooManyClauses例外が発生してしまう心配もなくなるのである。

今回使用したプログラムを以下に示す。赤字の部分のコメントアウトをはずして実行してみると、いろいろな示唆が得られるであろう:



public class TestQueryParser {

private static final String[] SHOPS = { "ショップA", "ショップB", "ショップC", "ショップD" };
private static final String[] PRICES = { "198000", "198000", "180000", "208000" };
private static final String F_SHOP = "shop";
private static final String F_PRICE = "price";
private static Directory dir;
private static Analyzer analyzer = new WhitespaceAnalyzer();

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

private static void makeIndex() throws IOException{
dir = new RAMDirectory();
// Lucene 2.0に切り替えても大丈夫なように古いコンストラクタを使用
IndexWriter writer = new IndexWriter( dir, analyzer, true );
for( int i = 0; i < SHOPS.length; i++ ){
Document doc = new Document();
doc.add( new Field( F_SHOP, SHOPS[i], Store.YES, Index.NO ) );
doc.add( new Field( F_PRICE, PRICES[i], Store.YES, Index.UN_TOKENIZED ) );
writer.addDocument( doc );
}
writer.close();
}

private static void searchIndex() throws IOException, ParseException{
QueryParser qp = new QueryParser( F_SHOP, analyzer );
Query query = qp.parse( "price:[000000 TO 199999]" );

//QueryParserがどのよなQueryに一次展開しているかを見る
//System.out.println( query.toString() );


IndexSearcher searcher = new IndexSearcher( dir );

// Query.rewrite()の結果を見る
//Query primitive = query.rewrite( searcher.getIndexReader() );
//System.out.println( primitive.toString() );


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 + "¥t" + doc.get( F_SHOP ) + "¥t" + doc.get( F_PRICE ) );

// スコアの根拠を見る
//Explanation exp = searcher.explain( query, hits.id( i ) );
//System.out.println( exp.toString() );

}
searcher.close();
}
}



なお、Lucene 2.1のQueryParserでの範囲検索の検索式はデフォルトでConstantScoreRangeQueryに展開されるようになったが、以前のようにRangeQueryに展開させたいときは、setUseOldRangeQuery(true)を使用すればよい。
| 関口宏司 | Luceneスコアリング | 13:17 | comments(0) | trackbacks(0) |
スコア計算デモのソースコードの公開
Luceneのスコア計算のデモのソースコードを弊社のホームページからダウンロードできるようにしたので、活用していただきたい。

RONDHUITのデモページ
http://www.rondhuit.com/demonstration.html
| 関口宏司 | Luceneスコアリング | 22:08 | comments(0) | trackbacks(0) |
スコア計算の様子がわかるデモ公開
Luceneのスコア計算の様子を簡単に見られるようにしたデモを作成したので、公開する:

Apache Lucene Score DEMO
Apache Lucene Score DEMO
http://demo.rondhuit-search.com/etcscoredemo/


スコアとは、検索結果を表示するときに文書の表示順位を決めるための数値のことである。

スコア計算については(笑点の話などムリヤリ感も多少あるが)Lucene本にやさしく解説してあるのでここでは触れないが、このデモについて簡単に説明する。

以下はデモで使用している検索対象となる文書である:

idtitlecontent
0Java BookJava programming is required to write Lucene application.
1Java ProgrammingJava is a popular computer language. I like Java.
2Perl BookPerl is not a kind of jewelry. It is a programming language.


ソース文書は英語であるが、その意図は単語の切れ目がはっきりしていてわかりやすく、スコア計算の学習に適していると判断したためである。ちなみにインデクシング時と検索時にはStandardAnalyzerを使用しており、デフォルトのストップワードがそのまま適用される。

文書はtitleとcontentフィールドからなっており、どちらもTOKENIZEDである。

使い方は、デモページの下半分に並んでいるボタンをクリックして検索を実行するだけの簡単操作だ。結果はページの上部に表示される。

たとえば、[tfの効果を見る]ボタンをクリックすると、tf項の効果が(割と)出やすい検索を発行して結果を表示する。ここでは"java"という単語をcontentフィールドに対して検索するので、2件の文書がヒットする。そのとき、"java"を数多く含んでいる方がtfが高くなり、スコア計算の結果、「Java Programming」が「Java Book」より上位にランクされる様子がわかる、という具合だ。

最後のボタン以外のテキスト入力領域はREAD ONLYになっており、ボタンをクリックしてそれぞれの項目の効果を簡単に見られるようになっている。

最後のフォームの部分は検索窓に自由に検索語を入力し、検索を実行できるようになっている。ここでは検索質問文字列をQueryに変換するのにQueryParserを使用しているので、QueryParserのコマンド(重みづけ、あいまい検索、ワイルドカード検索、・・・)がそのまま使用できる。

デモのソースコードはロンウイットのページで公開する予定だが、現在ホームページを整備中なので、今しばらくお待ちいただきたい。
| 関口宏司 | Luceneスコアリング | 12:00 | comments(0) | trackbacks(0) |
+ Solrによるブログ内検索
+ PROFILE
   1234
567891011
12131415161718
19202122232425
262728293031 
<< August 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