関口宏司のLuceneブログ

OSS検索ライブラリのLuceneおよびそのサブプロジェクト(Solr/Tika/Mahoutなど)について
(メモ)LuceneはApache Harmony上で動作
LuceneはApache Harmony(Apacheで開発されているオープンソースのJava SE)上で動作する:

http://wiki.apache.org/harmony/Apache_Lucene
| 関口宏司 | Lucene自由自在 | 07:50 | comments(0) | trackbacks(0) |
柔軟なインデックス構造を目指して進化するLucene
先日、ドキュメントをセグメントファイルに書き出すクラスであるDocumentsWriterのリファクタリングをしよう、というチケットがコミットされた。インデックスへの書き込みのプロセスをよりモジュール化して、将来の新しいインデクシング関連の機能追加はプラグインの追加で対応できるようにしよう、というのが狙いである:

https://issues.apache.org/jira/browse/LUCENE-1301

これはまた、フレキシブルインデクシングと呼ばれる新機能に対応するための第一歩でもある。

現在、Luceneのインデックスファイルの構造は公開されているとおり、固定的なものである(それでもTermVectorやPayloadのオプションがON/OFFできたり、Field値の文字列の登録をON/OFFできたり、圧縮する/しないを選択することができたり、FieldのlengthNormをON/OFFできたりと、他の多くのOSS検索エンジンよりも相当に柔軟ではある)。

これを将来はpositionやfrequencyを省略したり、逆にpositionに対して付加情報を追加できるようにしたり、圧縮アルゴリズムを自由に選択できるようにしたり・・・という具合にインデックスへの登録内容の自由度をさらに高められるようにする。

アプリケーションの要件によって、インデックスに文書の関連情報を登録するのにも使えるほか、余分な情報を登録しないことでインデクシングや検索の性能向上にも貢献すると考えられる。

たとえば早速、term frequency情報を省略できるようにする、というパッチが提案されている:

https://issues.apache.org/jira/browse/LUCENE-1340

frequencyを省略すればスコアにも影響が出るだろうが、スコアによる順位よりもフィールド値によるソートを多用するアプリケーションであれば気にならない。またフィールド単位でfrequency情報を省略するかどうか選べるので、frequencyがそもそも不要なフィルタ系のフィールド(県名や性別、ユーザ権限など)であればこれを導入するメリットは大きい。そのようなフィールドでfrequency情報の登録を行わないようにすれば、インデクシング時間を短くしたり、ディスクI/Oが減ることで検索性能を向上させたり、メモリ消費量を少なくしたり、ディスク消費量を減らせたり・・・と、少し考えただけでも相当な効果が望めるものである。

| 関口宏司 | Lucene自由自在 | 23:40 | comments(0) | trackbacks(0) |
IndexDeletionPolicyとSnapshotDeletionPolicy
Luceneのインデックスファイルはマージのタイミングで新しいセグメントファイルが作成されていく。ファイル名はxxx.cfsやsegments_yyyとなりxxxやyyyの部分はインクリメントされていくのでインデックスへの文書の登録中や最適化中であっても以前の(オープン時の)インデックスの内容で検索が可能となっている。

しかし古いインデックスのセグメントファイルはいつかのタイミングで消さなければならない。

Lucene 2.1以前はreaderがオープンしていないときに古いセグメントファイルを消していたが、Lucene 2.2からこのタイミングをコントロールできるようにIndexDeletionPolicyというインタフェースが導入された。デフォルトではKeepOnlyLastCommitDeletionPolicyが使われるようになっており、セグメントのコミットが完了したときに次のメソッドonCommit()が実行されるようになっている:



public final class KeepOnlyLastCommitDeletionPolicy implements IndexDeletionPolicy {
:
public void onCommit(List commits) {
// Note that commits.size() should normally be 2 (if not
// called by onInit above):
int size = commits.size();
for(int i=0;i<size-1;i++) {
((IndexCommitPoint) commits.get(i)).delete();
}
}
}



消されないまま残っているセグメントはコミットのListとして渡されるので、最新を残してdelete()しているのがわかる。

仮に次のような「何もしない」NullDeletionPolicyというものを定義してIndexWriterのコンストラクタに指定して使うようにすれば、インデックス内のファイルは消されずに増え続けることが観察できるだろう:



public class NullDeletionPolicy implements IndexDeletionPolicy {
public void onCommit(List commits) throws IOException {
}
public void onInit(List commits) throws IOException {
}
}



このようなIndexDeletionPolicyを使って、インデックスのある時点のスナップショットを取得できるSnapshotDeletionPolicyクラスがLucene 2.3に導入された。このクラスはインデックスのスナップショットを取得するsnapshot()メソッドと、取得したスナップショットを開放するrelease()メソッドを持つクラスである。snapshot()とrelease()呼び出しの間で取得したスナップショットのバックアップを取るような応用が考えられる。

具体的なコードは次のようになる:



SnapshotDeletionPolicy sdp = new SnapshotDeletionPolicy( new KeepOnlyLastCommitDeletionPolicy() );
dir = FSDirectory.getDirectory( "index" );
Backup backup = new Backup( dir, sdp );
IndexWriter writer = new IndexWriter( dir, true, analyzer, true, sdp, MaxFieldLength.LIMITED );
:
public class Backup {
FSDirectory dir;
SnapshotDeletionPolicy sdp;
Backup( FSDirectory dir, SnapshotDeletionPolicy sdp ){
this.dir = dir;
this.sdp = sdp;
}
void backup( String postfix ) throws IOException{
IndexCommitPoint icp = sdp.snapshot();
try{
// スナップショットのセグメントファイルを取得
Collection files = icp.getFileNames();
// =================================
// ここでバックアップを作成
// =================================
}
finally{
sdp.release();
}
}
}



「ここでバックアップを作成」の部分にバックアップのコードを挿入すれば、インデックスの更新を止めることなく検索で使用している最新のインデックスのバックアップを取ることができるようになる。
| 関口宏司 | Lucene自由自在 | 09:16 | comments(0) | trackbacks(0) |
TermVectorMapper
Lucene 2.3から導入されたTermVectorMapperにより、term vectorによりアクセスしやすくなった。

Lucene 2.2以前はTermFreqVectorクラスを使うしかなかったが、TermFreqVectorは次のようなインタフェースであり、あまり使い勝手がよいものではなかった:



public interface TermFreqVector {
public String getField();
public int size();
public String[] getTerms();
public int[] getTermFrequencies();
:
}



上記のように、単語と出現回数はString[]、int[]という具合に配列で戻ってくる。よって出現回数の多いもの順に並べるというようなことをしたいときは、Comparatorを別途用意し、Collections.sort()などを使ってソートする必要があり面倒である。Lucene本でもそのようにしている(P282-283)。

これに対してTermVectorMapperは次のようなインタフェースを持つ:



public abstract class TermVectorMapper {
:
public abstract void setExpectations(String field, int numTerms,
boolean storeOffsets, boolean storePositions);
public abstract void map(String term, int frequency,
TermVectorOffsetInfo [] offsets, int [] positions);
:
}



このTermVectorMapperの実装クラスを用意して次のようにIndexReaderに渡すと、適宜上記のメソッドが呼び出される。これによりTermFreqVectorよりも扱いやすいオブジェクトでterm vectorが取得できるようになった:



public void getTermFreqVector(int docNumber, String field, TermVectorMapper mapper);



TermVectorMapperは自分で書くこともできるが、Lucene 2.3にはSortedTermVectorMapper、FieldSortedTermVectorMapperおよびPositionBasedTermVectorMapperの3つのTermVectorMapperがすでに用意されている。このうちSortedTermVectorMapperはもっとも基本的なTermVectorMapperであり、コンストラクタに渡したComparatorによってソートされたTermVectorEntryのSetが次のメソッドで取得できるようになっている:



public SortedSet getTermVectorEntrySet();



以下ではこのSortedTermVectorMapperを使ったプログラムを紹介する。

TermVectorMapperのサンプルプログラム

「サンプルプログラム」というと「Hello, world!」という文字列を表示したりするのがプログラマの間では常識のようになっている。Wikipediaでも「Hello world program」という項目で説明されているほどだ(Wikipediaによれば、この有名な文字列を広める元となったThe C Programming Languageのサンプルプログラムでは"hello, world"だそうだ):

Hello world program
http://en.wikipedia.org/wiki/Hello_world

とりあえずこのような文字列が決まっているとサンプルを提示するほうとしてはいろいろ悩まなくていいので簡単だ。

ところが私はLuceneのサンプルプログラムを示すときに「Hello, world!」はあまり使わない。というか、これまで使ったことがない。なぜなら2語しかないのでつまらないからだ。

私の場合、Luceneのサンプルプログラムで使うデータは「サザエさん」「メガネは顔の一部です」「内閣総理大臣所信表明演説」と大体決まっている。

「サザエさん」のデータは転置索引の説明に使ったり、次のようにLucene入門用プログラムのデータに使ったりしている:

はじめてのLucene全文検索プログラム
http://lucene.jugem.jp/?eid=67

形態素解析やN-gramのAnalyzerの出力には「メガネは顔の一部です」をという文字列を使っている。

そしてこの中で最も長文な「内閣総理大臣所信表明演説」は、TermFreqVectorのサンプルで使用することが多い。Lucene本でもTermFreqVectorのサンプルでは執筆当時の小泉首相の所信表明演説を分析し、「改革」や「郵政」「民営」「化」が頻出語トップ10に現れて面白かった。

安倍首相のときは、(TermFreqVectorではなくPayloadであったが)「美しい国」を連呼していることが見て取れることがわかった:

記事から名詞だけを取り出す
http://lucene.jugem.jp/?eid=133

そこで今回のTermVectorMapperは福田首相の所信表明演説をデータとして使用することにする。

第168回国会における福田内閣総理大臣所信表明演説
平成19年10月1日
http://www.kantei.go.jp/jp/hukudaspeech/2007/10/01syosin.html

TermVectorMapperを使って頻出語トップ10を表示するプログラムは次のようになる:



public class FileTermVector {

static Directory dir = new RAMDirectory();
static Analyzer analyzer = new JapaneseAnalyzer();
static final String F = "F";
static final int MAX = 10;

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

static void makeIndex( String file ) throws IOException {
BufferedReader br = new BufferedReader( new FileReader( file ) );
IndexWriter writer = new IndexWriter( dir, analyzer, MaxFieldLength.LIMITED );
Document doc = new Document();
for( String line = br.readLine(); line != null; line = br.readLine() ){
doc.add( new Field( F, line, Store.YES, Index.TOKENIZED, TermVector.YES ) );
}
writer.addDocument( doc );
writer.close();
br.close();
}

static void printSortedTVM() throws IOException {
IndexReader reader = IndexReader.open( dir );
SortedTermVectorMapper mapper = new SortedTermVectorMapper( new TermVectorEntryFreqSortedComparator() );
reader.getTermFreqVector( 0, F, mapper );
SortedSet ss = mapper.getTermVectorEntrySet();
int count = 0;
for( Iterator i = ss.iterator(); i.hasNext() && count < MAX; count++ ){
TermVectorEntry entry = (TermVectorEntry)i.next();
System.out.println( entry.getTerm() + " : " + entry.getFrequency() + " 回" );
}
reader.close();
}
}



TermFreqVectorを使って同様のことを行うLucene本のプログラムと比べると、だいぶシンプルなプログラムになっている。

実行するには、分析したいテキストデータを適当なファイル名のファイルで用意しておき、プログラムの第一引数でそのファイル名を渡してやると頻出単語とその出現回数の表が画面に表示される:



社会 : 26 回
国民 : 24 回
的 : 24 回
改革 : 19 回
向ける : 18 回
問題 : 18 回
進める : 15 回
行政 : 14 回
取り組む : 13 回
地方 : 13 回



どうせなので小泉首相、安倍首相、福田首相で結果を比較してみたところ、次のようになった。

小泉安倍福田
改革23改革18社会26
国民22国民16国民24
171424
郵政14皆様12改革19
民営1311向ける18
10取り組む11問題18
民間8地方11進める15
社会8地域9行政14
地方7日本9取り組む13
公務員6環境9地方13


3人の演説で共通に現れる単語には薄く色付けしてある。

これを見ると、小泉首相はトップに「改革」がきており、同じ改革路線を継承した形の安倍首相も「改革」がトップに来ているのがわかる。なぜか3人とも「国民」が2番目にきている。

逆に他の2人に(トップ10に)出現しない単語のそれぞれのトップは、小泉首相が「郵政」、安倍首相が「皆様」、福田首相が「向ける」となっている。この中で「皆様」に興味が引かれた私は、どのような使われ方をしているのかを調べた。すると、「国民の皆様」「野党の皆様」「被災者の皆様」「地方の皆様」という使われ方をしているのがわかり、いまさらながら若い首相の苦労が偲ばれたのだった。

なおプログラムの実行にあたっては、StopFilterやPOSFilterに「ストップワード」や「ストップ品詞」を適切に設定する必要がある。

(参考データ)

第168回国会における安倍内閣総理大臣所信表明演説
平成19年9月10日
http://www.kantei.go.jp/jp/abespeech/2007/09/10syosin.html

第163回国会における小泉内閣総理大臣所信表明演説
平成17年9月26日
http://www.kantei.go.jp/jp/koizumispeech/2005/09/26syosin.html

| 関口宏司 | Lucene自由自在 | 20:37 | comments(13) | trackbacks(0) |
Lucene 2.3でエンティティ抽出
現在、Lucene 2.3 RC1が公開中である。前回のリリースLucene 2.2からあっという間だった。

Lucene 2.2のリリース時は2.2の新機能を紹介しようと思い、サンプルプログラムを書きながら紹介記事を書いたりしたものだが、全部紹介しきらないうちにやがて新機能は「新」機能ではなくなり、そして今回の2.3のリリースを迎えることとなってしまった。

いったい、コミッターたちは自分たちの仕事をいつやっているのだろう。

ところで、Lucene 2.2のときは単語にペイロードを登録できるようになったので、それを中心に次のような利用例を紹介した:

記事から名詞だけを取り出す
http://lucene.jugem.jp/?eid=133


人名がヒットしたときはスコアを上げる
http://lucene.jugem.jp/?eid=134


今回は似たような雰囲気のする「エンティティ抽出」を、Lucene 2.3で新しく追加されたSinkTokenizerとTeeTokenFilterを使って実装する方法を紹介しよう。

エンティティ抽出とは?

エンティティ抽出(entity extraction)とは文書から人名・地名・会社名・日付などを(自動)抽出することをいう。特に情報検索では抽出された「エンティティ」で絞り込み検索用のリンクを自動的に生成するなどの応用が可能となる。

つい先ほどマイクロソフトによる買収の発表があった検索エンジン大手のFAST社の製品はエンティティ抽出の機能を持つらしい。そんなFAST社による「Entity Extraction」という用語の説明はこうだ:

http://www.fastsearch.com/glossary.aspx?m=48&amid=291

また、多言語形態素解析器などで知られるベイシス・テクノロジはエンティティ抽出のための製品も持っている:

Rosette固有表現抽出システム
http://www.basistech.co.jp/entity-extraction/


SinkTokenizerとTeeTokenFilter

今回紹介するエンティティ抽出のサンプルは、上記製品のように本格的なものではなく、形態素解析器(SenTokenizer)が出力するトークンのタイプを単純に判断して行うものとする。

ところで、Lucene 2.3になる前からSenTokenizerはトークンのタイプで「人名」や「地名」などとして品詞情報を出力できたので、やろうと思えば今までもエンティティ抽出は可能だった。今回あえてLucene 2.3でエンティティ抽出を紹介するのは、Lucene 2.3で追加されたSinkTokenizerとTeeTokenFilterでエンティティ抽出のようなプログラムを書くのが楽になった、ということがあげられる。

SinkTokenizerとTeeTokenFilterは必ず組み合わせて使うことが前提である。SinkTokenizerはトークンを保持するバッファを内蔵する。そのトークンはTeeTokenFilterを通じて受信(sink)する。TeeTokenFilterは他の一般のTokenFilterと同様、上流のTokenizerからTokenizeされたトークンを受け取り、あたかもLinuxのteeコマンドのようにトークンをコピーして一方をSinkTokenizerに渡す。もともとのトークンは下流のTokenFilterに渡す。そうしておいて、SinkTokenizerに滞留したトークンを別のTokenFilterで処理してエンティティ抽出を行うのだ。

SinkTokenizerとTeeTokenFilterを使ってエンティティ抽出を行うサンプルプログラムを次に示す:



public class SampleEntityExtraction {

static final String SEN_CONF = "c:/sen-1.2.2.1/conf/sen.xml";
static final String INDEX = "index";
static final String F_BODY = "body";
static final String F_PERSON = "person";
static final String F_ORG = "org";
static final String T_PERSON = "名詞-固有名詞-人名";
static final String T_ORG = "名詞-固有名詞-組織";
static final String CONTENT = "ここに適当な記事を入れる";

public static void main(String[] args) throws IOException {

final SinkTokenizer sinkPerson = new SinkTokenizer( null );
final SinkTokenizer sinkOrg = new SinkTokenizer( null );

Analyzer analyzer = new Analyzer() {
public TokenStream tokenStream(String field, Reader in) {
try {
return new TeeTokenFilter( new TeeTokenFilter( new SenTokenizer( in, SEN_CONF ), sinkPerson ), sinkOrg );
} catch (IOException e) {
throw new RuntimeException( e );
}
}
};

TokenFilter exPerson = new EntityExtractor( sinkPerson, T_PERSON );
TokenFilter exOrg = new EntityExtractor( sinkOrg, T_ORG );

IndexWriter writer = new IndexWriter( INDEX, analyzer, true );
Document doc = new Document();
doc.add( new Field( F_BODY, CONTENT, Store.YES, Index.TOKENIZED ) );
doc.add( new Field( F_PERSON, exPerson ) );
doc.add( new Field( F_ORG, exOrg ) );
writer.addDocument( doc );
writer.close();

System.out.println( "===== 抽出された人名エンティティ =====" );
printTerms( F_PERSON );

System.out.println( "¥n===== 抽出された組織エンティティ =====" );
printTerms( F_ORG );
}

static void printTerms( String field ) throws IOException {
IndexReader reader = IndexReader.open( INDEX );
TermEnum terms = reader.terms( new Term( field, "" ) );
while( terms.next() ){
Term t = terms.term();
if( !t.field().equals( field ) ) break;
System.out.println( t.text() );
}
terms.close();
reader.close();
}

static class EntityExtractor extends TokenFilter {

String type;

protected EntityExtractor(TokenStream stream, String type ) {
super(stream);
this.type = type;
}

public Token next() throws IOException {
Token t;
while( true ){
t = input.next();
if( t == null ) return null;
if( t.type().startsWith( type ) )
return t;
}
}
}
}



このプログラムを実行すると、次のようになる:



===== 抽出された人名エンティティ =====

梅田
熊谷
福田
章二


===== 抽出された組織エンティティ =====
公明党
共産党
大阪大
府連
民主党
社民
自民
自民党



分析対象の記事は、橋下徹弁護士、熊谷貞俊元大阪大大学院教授、梅田章二弁護士らが立候補するという大阪府知事選に関するもので、次を拝借した:

自民、橋下氏推薦見送り 大阪府知事選10日告示
http://www.tokyo-np.co.jp/s/article/2008010801000867.html


「橋下」が認識されていないこと、「熊谷貞俊」が「熊谷」「貞」と抽出されている以外は、きれいにエンティティが抽出できているのがわかるだろう(辞書をメンテナンスすればこれらも拾うことができる)。

この出力結果を漫然と見ているだけでは形態素解析器が出力している品詞情報との違いがわかりにくく、ありがたみが感じられないだろう。

このプログラムで重要なのは、抽出されたエンティティがオリジナルの文書と関連付けられて索引付けされているところだ。したがって、検索システムと組み合わせて次のような絞り込み検索用のリンクが自動的に作れるようになるのである:



===== 人名で絞り込む =====
梅田(1)
熊谷(1)
福田(2)

===== 組織名で絞り込む =====
公明党(1)
共産党(1)
大阪大(1)
民主党(3)
自民党(4)




| 関口宏司 | Lucene自由自在 | 01:49 | comments(1) | trackbacks(0) |
Luceneでベイズ分類(experimental)
Luceneのインデックスをベイズ分類に応用しようという実験的なパッチが提案されている:

LUCENE-1039

こちらの理論には素人なのでアルゴリズムは軽くスルーして、プログラムと実行結果を示す。

これを使えば、文章を学習させたLuceneインデックスを使って、別の新しい文章の分類ができる。

たとえば、こんな具合に「関西弁」と「標準語」を自動分類してくれる:



*****
どうなってまんねん => 関西弁
*****
どうなっているのですか => 標準語
*****
あほちゃうか => 関西弁
*****
ばかですね => 標準語



完璧では当然ないので、次のように間違うこともある(2番目は「政治」ではなく「芸能」と分類して欲しい):



*****
女優の広末涼子がイメージキャラクターを務める日本コカ・コーラ「からだ巡茶」の新CM「さよならハロー・フープ」編の撮影が行われた。 => 芸能
*****
榊原といえば先日車上荒らしの被害にあったばかり。 => 政治
*****
会談で大連立が話題になったかどうかについて「いろんな話をしたが、そこまで至らなかった」と否定した。首相官邸で記者団の質問に答えた。 => 政治
*****
(与野党)共通の認識を持った政治運営がなされる可能性が出てきた。場合によって大連立も考えられる => 政治



ただ、学習の量が足りないだけなのかもしれない。

プログラムは次のとおりである:



public class TestClassifier {

// 学習用文章
private static final String[][] MATERIALS = {
{ "なにいうてまんねん", "関西弁" },
{ "なにをいってるんですか", "標準語" },
{ "そうでんねん", "関西弁" },
{ "そうです", "標準語" },
{ "あほちゃうか", "関西弁" },
{ "ばかですね", "標準語" }
};

// テスト用文章
private static final String[] TESTS = {
"どうなってまんねん", "どうなっているのですか",
"あほちゃうか", "ばかですね" // 学習済みのものをチェック
};

public static void main(String[] args) throws IOException {
Instances instances = learn();
for( String test : TESTS )
classify( instances, test );
instances.close();
}

private static InstanceFactory getInstanceFactory(){
return new InstanceFactory() {
public Document factory(String text, String _class) {
Document doc = new Document();
doc.add(new Field("class", _class, Field.Store.YES,
Field.Index.NO_NORMS));
doc.add(new Field("text", text, Field.Store.YES, Field.Index.NO,
Field.TermVector.NO));
doc.add(new Field("text/ngrams/start", text, Field.Store.NO, Field.Index.TOKENIZED,
Field.TermVector.YES));
doc.add(new Field("text/ngrams/inner", text, Field.Store.NO, Field.Index.TOKENIZED,
Field.TermVector.YES));
doc.add(new Field("text/ngrams/end", text, Field.Store.NO, Field.Index.TOKENIZED,
Field.TermVector.YES));
return doc;
}
Analyzer analyzer = new Analyzer() {
private int minGram = 1;
private int maxGram = 2;
public TokenStream tokenStream(String fieldName, Reader reader){
String conf = System.getProperty("sen.home") + System.getProperty("file.separator") + "conf" +
System.getProperty("file.separator") + "sen.xml";
TokenStream ts = null;
try{
ts = new SenTokenizer(reader,conf);
}
catch(IOException ignored){}
ts = new LowerCaseFilter(ts);
if (fieldName.endsWith("/ngrams/start")) {
ts = new EdgeNGramTokenFilter(ts, EdgeNGramTokenFilter.Side.FRONT,
minGram, maxGram);
} else if (fieldName.endsWith("/ngrams/inner")) {
ts = new NGramTokenFilter(ts, minGram, maxGram);
} else if (fieldName.endsWith("/ngrams/end")) {
ts = new EdgeNGramTokenFilter(ts, EdgeNGramTokenFilter.Side.BACK,
minGram, maxGram);
}
return ts;
}
};

public Analyzer getAnalyzer() {
return analyzer;
}
};
}

private static Instances learn() throws IOException{
Directory dir = new RAMDirectory();
new IndexWriter(dir, null, true).close();

Instances instances = new Instances(dir, getInstanceFactory(), "class");
for( String[] mat : MATERIALS )
instances.addInstance( mat[0], mat[1] );
instances.flush();
return instances;
}

private static void classify( Instances instances, String test ) throws IOException{
// 以下のどちらかを使用
BayesianClassifier classifier = new NaiveBayesClassifier();
//BayesianClassifier classifier = new FishersMethodClassifier();
System.out.println( "*****" );
System.out.println( test + " => " + classifier.classify(instances, test )[0].getClassification());
}
}



なかなか楽しいプログラムなので、試したい人がいるかもしれない。そこで、LUCENE-1039のパッチの当て方とビルドの方法を簡単に説明しよう。

<1> パッチは大抵の場合、その時点の最新trunkのソースに合わせて提出されるため、trunkのソースをSVNから取得する:



$ svn co http://svn.apache.org/repos/asf/lucene/java/trunk



これ以降、上記のオペレーションでできたtrunkディレクトリを$LUCENE_HOMEとする。

<2> LUCENE-1039からパッチファイルLUCENE-1039.txtをダウンロードして$LUCENE_HOMEに置き、次のようにパッチを当てる:



$ cd $LUCENE_HOME
$ patch -p0 < LUCENE-1039.txt



<3> LUCENE-1039のパッチがJava 5.0前提になっているので、次のbuild.propertiesを作成して$LUCENE_HOMEに配置する:



javac.source=1.5
javac.target=1.5



<4> Lucene本体をビルドする:



$ cd $LUCENE_HOME
$ ant



<5> contribのAnalyzerをビルドする:



$ cd $LUCENE_HOME/contrib/analyzers
$ ant



<6> contribのclassifierをビルドする。今回のパッチにはbuild.xmlが含まれないので、contrib/analyzers/build.xmlをコピーして次のように編集してcontrib/classifier/build.xmlとする:



<?xml version="1.0"?>
<project name="classifier" default="default">

<description>
Classifier
</description>

<import file="../contrib-build.xml"/>
</project>



そしてclassifierをビルドする:



$ cd $LUCENE_HOME/contrib/classifier
$ ant



以上のビルドで$LUCENE_HOME/build/以下にできた3つのJARファイルを使って前述のプログラムをコンパイル、実行できる。
| 関口宏司 | Lucene自由自在 | 22:29 | comments(0) | trackbacks(1) |
RailsアプリからSolrを使う全文検索のデモ
RailsアプリケーションからSolrを使う全文検索のデモを作成したので公開する。

http://www.rondhuit-demo.com/yademo/

Rails+Solr全文検索のデモ

デモシステムの全体構成は、次のようになっている。

Rails+Solr全文検索のデモシステム構成

デモシステムは、「フロントアプリサーバ」と「検索エンジンサーバ」の2つのサーバプロセスからなる。

「検索エンジンサーバ」はTomcatの上にSolrをデプロイして構成している。「フロントアプリサーバ」にはRailsで作られたアプリケーションが載っており、これがバックエンドの「検索エンジンサーバ」に検索のリクエストを送っている。そしてSolrから返ってきた検索結果をHTMLにしてブラウザに戻している。

上図の「フロントアプリサーバ」にはRailsという記述がないが、Flareと書いてあるのがRailsで構築されたSolrクライアントアプリケーションだ。このFlareがsolr-rubyを使ってSolrとの検索リクエスト/レスポンスの処理を行っている。

デモデータには「Yahoo!オークション」の商品データ約201万件を使用している。このデモデータはYahoo!のWebサービスAPIを使用して2007年7月から9月頃にかけてクロールして収集したものである。

検索の結果表示される商品説明はクロールしたHTMLをそのまま表示しているため、検索結果画面が多少乱れたり、画像抜けが起こったりする部分がある。また、本デモにはYahoo!オークションとしての機能は一切なく、単なる全文検索システムのデモであることをあらかじめお断りしておく。

このデモの使い方や特徴を以下で簡単に述べる。

検索キーワードサジェスチョン

Flareのセールスポイントの一つが「検索キーワードのサジェスチョン」機能である。これはGoogle Suggestなどでおなじみのものだ。

Flareの検索キーワードサジェスチョン機能

「検索キーワードのサジェスチョン」は検索キーワードの最初の数文字を入力すると、ユーザが検索ボタンをクリックする前にシステムがバックグラウンドで検索を行い、検索ヒット数と共に検索キーワードの候補をユーザに提示してくれるものだ。

ユーザにとって「タイプ量が節約できる」「他人の検索キーワードからヒントが得られる」「検索ボタンをクリックする前にどのくらい絞り込めるのかがわかる」など、多くのメリットがあるのがこの機能の特徴である。Flareはこれを最初から提供している。しかもRailsプログラマの期待を裏切ることなく、簡単な「規約」にしたがうだけでこの強力な機能がUIについてくるので製作側にとってもメリットが大きい。

Flareのサジェスチョン機能はGoogleと異なり、あくまでもLuceneのインデックスを使ってサジェスチョンしているので、サジェスチョンの単位が「語」にとどまっている。また、その仕組み上から背後では膨大な計算が行われており、ドキュメント数やユーザ数が多い場合に応答速度の点で若干難がある。さらに上図のようにカタカナ1文字で「ソ」と入力する場面は(ローマ字変換入力の場合)日本語ではあまりないと考えられるので、日本語での利用を考えたときには別の仕組みが必要である。したがって、Flareの「検索キーワードのサジェスチョン」は使えるアプリケーションかどうかをよく考えて導入するのがよいだろう。なお、これらの問題点を解消した「検索キーワードのサジェスチョン」を弊社で開発中である。

絞り込み検索

FlareのデフォルトのUIでは、画面右側に自動的に絞り込み検索用のリンクが作成・表示される。Yahoo!オークションの商品データを使用したこのデモでは、Yahoo!オークションのカテゴリ(category)と出品者(seller_id)の上位20位までが表示されている。

絞り込み検索では、リンクをクリックするだけで簡単に検索結果を絞り込むことができるようになっているため、ユーザからの人気が高い機能の一つである。この機能もFlareの簡単な「規約」にしたがうだけで使うことができる。

検索条件の保存

Flareでは検索窓に入力した検索キーワードと、絞り込み検索でクリックしたリンクを「検索条件」としてCookieに保存している。その内容は画面左上のFlareのロゴ(このロゴ画像はいくつかあるベータ版のうちのひとつであり、将来変わる可能性がある)の下に表示されている。

Flareの検索条件の保存機能

検索のたびにこの条件は追加・保存されるので、新たな検索のときは[clear constraints]のリンクをクリックしてそれまでの検索条件を消去する。あるいは、各検索条件の右隣にある小さな[x]のアイコンをクリックして当該検索条件だけを削除することもできる。さらに、検索条件をクリックすると、検索条件をその場で変更して再検索もできる。

Flareの検索条件の編集機能

このUIはRailsプログラマにはおなじみのものだろう。

| 関口宏司 | Lucene自由自在 | 12:47 | comments(0) | trackbacks(1) |
クチコミの評判の悪い店のランキングを下げる
私はLuceneおよびSolr(最近はこちらの方がLuceneよりも多い)のコンサルティングやサポートを顧客に提供することを生業としている。

いろいろな顧客の話を聞いていると、ランキング(検索結果のドキュメントの表示順のこと)を意図的に操作して出力したい、という要望が予想以上に多いのに驚く。

たとえば、「自分の家から一番近いレストランを探す(地理検索、地図検索)」で書いたようなある場所(現在地や最寄り駅)から近い順にお店情報を表示する、などである。

あるいは、「FunctionQueryの実用的なサンプル」で紹介したように、検索語の出現位置でランキングを操作する、という要求もあった。

この他にもサイト運営者への「掲載料」の支払い額で企業がランクわけがされていて、掲載料をたくさん支払っている優良顧客の情報をtf*idfとは関係なく上位に持ってきてほしい、という要望も意外に多い。

このような、ランキングへの顧客の多様な要求に柔軟に対応できるのもLuceneの隠れた大きな特徴のひとつである。

今回はBoostingQueryを使って「クチコミの評判の悪い店」のランキングを意図的に下げる、ということを実装してみる。

BoostingQueryとは

BoostingQueryは、オリジナルのQueryインスタンスorigQueryに、マイナス評価を与えるnegativeQueryを組み合わせ、さらにマイナス評価に該当したときに指定した降格係数(まったくの私の造語だが、1未満のboost値のこと)を掛けて最終的なスコアとするQueryである。BoostingQueryのインスタンスを得るには、降格係数をdemoteBoostとすると、次のようにプログラミングする:



Query query = new BoostingQuery( origQuery, negativeQuery, demoteBoost );



あとは普通にqueryをIndexSearcherのsearch()に渡せばよい。

BoostingQueryのnegativeQueryは、仕様をちょっと聞くとBooleanQueryのMUST_NOTとなんか似ている。しかし、両者はまったく異なるものである。

BooleanQueryのMUST_NOTは、指定したQueryにヒットするドキュメントは検索結果には含まれない、まったくの「否定」となる。

一方、BoostingQueryのnegativeQueryはMUST_NOTのように「否定」ではないので、negativeQueryにヒットしたドキュメントが検索結果からはじかれる、ということはない。ただ、negativeQueryにヒットしたドキュメントのスコアはdemoteBoostの値が掛けられるのである。それにより、origQueryにヒットしたドキュメントのうち、さらにnegativeQueryに該当するドキュメントのスコアを下げることができる(あるいは、demoteBoostを1より大きくとることで、negativeQuery(この場合はpositiveQueryと呼ぶべきであろう)に該当するドキュメントのスコアを上げることができる)。

なお、BoostingQueryはLucene coreではなくcontrib/queriesにあるので、contrib/queriesよりlucene-queries-x.x.jarを持ってきてクラスパス上に配置する必要がある。

BoostingQueryのサンプルプログラム

まず、次のようなサンプルドキュメントとサンプルの検索文字列を用意する:



private static final String[] EVALS = {
// 店名/メニュー/評判はプログラム試験用の創作であり、実在のお店やデータとは一切関係ありません。
"ビストロ ジオ・ジアンテのサーモンのクリームパスタはまずい。",
"リストランテ・ピノリットのサーモンのクリームパスタはおいしい。",
"リストランテ・ピノリットのイベリコ生ハムのサラダはおいしくない。",
"ビストロ ジオ・ジアンテのイベリコ生ハムのサラダはお奨めです。"
};
private static final String[] QUERIES = { "クリームパスタ", "生ハムのサラダ" };



これをBoostingQueryを使わずに普通に検索すると、次のような結果になるだろう:



WARNING!!: 店名/メニュー/評判はプログラム試験用の創作であり、実在のお店やデータとは一切関係ありません。

===== 「クリームパスタ」を検索します =====
1.0 ビストロ ジオ・ジアンテのサーモンのクリームパスタはまずい。
1.0 リストランテ・ピノリットのサーモンのクリームパスタはおいしい。

===== 「生ハムのサラダ」を検索します =====
1.0 リストランテ・ピノリットのイベリコ生ハムのサラダはおいしくない。
1.0 ビストロ ジオ・ジアンテのイベリコ生ハムのサラダはお奨めです。



スコア(検索結果の前に表示されている実数)はどちらの検索文字列でも1.0と等しくなるので、この場合のランキングはドキュメントの登録順に表示されている。そして、「まずい」や「おいしくない」というマイナス評価のドキュメントを先に登録しているので、「おいしい」や「お奨めです」より前に表示されてしまっている。

このプログラムを、demoteBoost=0.1にしたBoostingQueryを使って検索すると、次のようになる:



WARNING!!: 店名/メニュー/評判はプログラム試験用の創作であり、実在のお店やデータとは一切関係ありません。

===== 「クリームパスタ」を検索します =====
1.0 リストランテ・ピノリットのサーモンのクリームパスタはおいしい。
0.1 ビストロ ジオ・ジアンテのサーモンのクリームパスタはまずい。

===== 「生ハムのサラダ」を検索します =====
1.0 ビストロ ジオ・ジアンテのイベリコ生ハムのサラダはお奨めです。
0.1 リストランテ・ピノリットのイベリコ生ハムのサラダはおいしくない。



BoostingQueryを使った全体のプログラムは次のとおりである:



public class TestBoostingQuery {

private static final String[] EVALS = {
// 店名/メニュー/評判はプログラム試験用の創作であり、実在のお店やデータとは一切関係ありません。
"ビストロ ジオ・ジアンテのサーモンのクリームパスタはまずい。",
"リストランテ・ピノリットのサーモンのクリームパスタはおいしい。",
"リストランテ・ピノリットのイベリコ生ハムのサラダはおいしくない。",
"ビストロ ジオ・ジアンテのイベリコ生ハムのサラダはお奨めです。"
};
private static final String[] QUERIES = { "クリームパスタ", "生ハムのサラダ" };
private static final String[] NEGATIVE_EVALS = {
"まずい", "おいしくない", "うまくない", "不味い", "拙い"
};
private static final String F = "f";
private static Analyzer analyzer = new JapaneseAnalyzer();
private static Directory dir = new RAMDirectory();

public static void main(String[] args) throws IOException, ParseException {
System.out.println( "WARNING!!: 店名/メニュー/評判はプログラム試験用の創作であり、実在のお店やデータとは一切関係ありません。" );
makeIndex();
for( String queryString : QUERIES ){
System.out.println( "¥n===== 「" + queryString + "」を検索します =====" );
searchIndex( queryString );
}
}

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

private static void searchIndex( final String q ) throws IOException, ParseException {
IndexSearcher searcher = new IndexSearcher( dir );
Query query = getQuery( q );
Hits hits = searcher.search( query );
for( int i = 0; i < hits.length(); i++ ){
System.out.println( hits.score( i ) + "¥t" + hits.doc( i ).get( F ) );
int id = hits.id( i );
}
searcher.close();
}

private static Query getQuery( final String q ) throws ParseException{
QueryParser qp = new QueryParser( F, analyzer );
Query orig = qp.parse( q );
BooleanQuery negEvals = new BooleanQuery();
for( String negative : NEGATIVE_EVALS )
negEvals.add( qp.parse( negative ), Occur.SHOULD );
return new BoostingQuery( orig, negEvals, 0.1F );
}
}



NEGATIVE_EVALSという文字列配列に「まずい」や「おいしくない」などのマイナス評価を示す文言を登録してあり、それをもとにnegativeQueryのインスタンスを作成している。当然のことながら、これらに合致しない文言は評判が悪いとは認識されない。
| 関口宏司 | Lucene自由自在 | 22:50 | comments(0) | trackbacks(0) |
WildcardTermEnumを使う
Luceneのインデックスにはフィールドごとに整理された単語帳がある。単語帳の単語はソートされた状態で格納されており、TermEnumを使ってその中の単語を列挙することができる。

次のプログラムは「通」という単語から開始してTermを列挙する例である:



private static void testTermEnum( IndexReader reader ) throws IOException{
TermEnum terms = reader.terms( new Term( F, "通" ) );
do {
Term term = terms.term();
if( !term.field().equals( F ) ) break;
System.out.print( "[" + term.text() + "] " );
}while( terms.next() );
terms.close();
}



TermEnumはTerm(=フィールド名+フィールド値)を列挙するものであるから、ループをまわしていくとTermがいつの間にか目的外のフィールドを指していることがありえるので、赤字の判定行が必要となる。

このプログラムをあるインデックスに対して実行すると、次のような出力が得られる:



[通] [通じる] [通す] [通ずる] [通り] [通り抜ける] [通り越す] [通る] [通れる] [通
代] [通例] [通信] [通則] [通力] [通勤] [通告] [通商] [通学] [通帳] [通常] [通気]
[通産省] [通用] [通知] [通称] [通算] [通訳] [通話] [通販] [通販生活] [通過] [通
達] [通関] [通電] [逝く] [逞しい] [速] [速い] [速める] [速やか] [速度] [速達]
...
...



上記のように「通」から始まり、単語帳の指定フィールドの単語が尽きるまでTermが列挙されるのがわかる。

ここでTermEnumの代わりにWildcardTermEnumを使うと「通」で開始する単語だけを列挙することが可能となる。

WildcardTermEnumは、次のように使う:



TermEnum terms = new WildcardTermEnum( reader, new Term( F, "通*" ) );



このときの出力は、次のようになる:



[通] [通じる] [通す] [通ずる] [通り] [通り抜ける] [通り越す] [通る] [通れる] [通
代] [通例] [通信] [通則] [通力] [通勤] [通告] [通商] [通学] [通帳] [通常] [通気]
[通産省] [通用] [通知] [通称] [通算] [通訳] [通話] [通販] [通販生活] [通過] [通
達] [通関] [通電]



「前方一致」だけでなく、「後方一致」も指定可能だ:



TermEnum terms = new WildcardTermEnum( reader, new Term( F, "*しい" ) );



このときの出力は、次のようになる:



[あしい] [あたらしい] [いやしい] [うれしい] [おいしい] [おかしい] [おしい] [おと
なしい] [お忙しい] [かわいらしい] [きびしい] [くわしい] [すばらしい] [ただしい]
[なつかしい] [なまめかしい] [ばかばかしい] [ふさわしい] [ほしい] [みずみずしい]
[むずかしい] [もどかしい] [やさしい] [ややこしい] [よろしい] [らしい] [わざとら
しい] [わずらわしい] [久しい] [乏しい] [優しい] [危なっかしい] [厳しい] [古めか
しい] [可愛らしい] [嘆かわしい] [好ましい] [妖しい] [嬉しい] [宜しい] [寂しい] [
心苦しい] [忙しい] [思しい] [怪しい] [恐ろしい] [恥ずかしい] [悔しい] [悪しい] [
悲しい] [惜しい] [愉しい] [愛らしい] [慌ただしい] [懐かしい] [新しい] [望ましい]
[楽しい] [欲しい] [正しい] [気難しい] [淋しい] [清々しい] [激しい] [煩わしい] [
珍しい] [瑞々しい] [生々しい] [生生しい] [痛々しい] [目覚しい] [相応しい] [等し
い] [紛らわしい] [素晴らしい] [美しい] [美味しい] [芳しい] [苦しい] [荒々しい] [
著しい] [見苦しい] [規則正しい] [詳しい] [輝かしい] [逞しい] [重々しい] [難しい]
[頼もしい] [馬鹿らしい] [騒々しい] [騒がしい]


| 関口宏司 | Lucene自由自在 | 12:20 | comments(0) | trackbacks(0) |
NGramTokenizerとEdgeNGramTokenFilter
Lucene 2.2のcontribにN-gramで単語を切り出すNGramTokenizerとEdgeNGramTokenFilterが追加された(同時にEdgeNGramTokenizerとNGramTokenFilterも追加されたが、これらはあまり使われない気がする)。

これまでLuceneのN-gram Analyzerといえば、CJKAnalyzerであった。これはCJK文字のときにだけ2文字単位で切り出すもので、bi-gramとも呼ばれるものである。

CJKAnalyzerのサンプルプログラムを以下に示す:



public class TestNgramAnalyzer {

private static Analyzer analyzer = new CJKAnalyzer();
private static final String TEXT = "メガネは顔の一部です。";

public static void main(String[] args) throws IOException {
TokenStream stream = analyzer.tokenStream( "F", new StringReader( TEXT ) );
StringBuffer sb = new StringBuffer();
for( Token token = stream.next(); token != null; token = stream.next() ){
//System.out.println( token.toString() );
sb.append( '[' ).append( token.termText() ).append( "] " );
}
stream.close();
System.out.println( TEXT + " => " + sb.toString() );
}
}



このプログラムを実行すると、次のようになる:



メガネは顔の一部です。 => [メガ] [ガネ] [ネは] [は顔] [顔の] [の一] [一部]
[部で] [です]



CJKAnalyzerは上記のように日本語の文字を2文字単位に切り出す。このとき、となりどうしのトークンの文字が互いに重なるように切り出すのがN-gramの特徴である。たとえば、「メガネ」は「メガ」と「ガネ」に分割される。こうすることで後に「メガネ」で検索するときにも「(メガ)(ガネ)」という成句で検索されるために文章中の「メガネ」がうまい具合に検索できるのだ。

CJKAnalyzerを使う動機としては、(形態素解析の)JapaneseAnalyzerと比較して辞書のメンテナンスが不要で流行語に強い、などが理由にあげられる。

一方、CJKAnalyzerを敬遠する理由としては、(Lucene本にも書いた例だが)「京都」で引いたときに「東京都」が引っかかってしまうとか(これはGoogleでもみられる現象だ)、1文字の単語が検索できない、などがあげられる。「京都」で「東京都」が検索できてしまうのは目をつぶれるとしても、後者は特に困った問題である。たとえば、先のサンプルの例文はCJKAnalyzerを使っているときは、「顔」という検索語では検索できない。

NGramTokenizer

NGramTokenizerはコンストラクタで指定した大きさのトークンを文章から切り出すTokenizerである。たとえば、次のように指定すると、bi-gramとして動作する:



new NGramTokenizer( reader, 2, 2 );



また次のようにすれば、3文字ずつ切り出すtri-gramとして働くようになる:



new NGramTokenizer( reader, 3, 3 );



これだけだとただ単にCJKAnalyzerがちょっと発展したくらいにしか思えないが、次のようにすることで1〜3文字を切り出すように動作するのがNGramTokenizerの便利なところだ:



new NGramTokenizer( reader, 1, 3 );



これをCJKAnalyzerの代わりに使用すれば、先ほどの「顔」で検索できない、という問題も解消できる。

具体的なプログラムを示すと、まずNGramTokenizerはAnalyzerではないので、使用するにはAnalyzerを作成する必要がある。簡単なAnalyzerは次のようなプログラムになるだろう:




static class MyAnalyzer extends Analyzer {
public TokenStream tokenStream(String fieldName, Reader reader) {
return new NGramTokenizer( reader, 1, 3 );
}
}



上記のMyAnalyzerを最初のプログラム例のCJKAnalyzerと入れ替えて使用する。そして実行すると、次のようになる:



メガネは顔の一部です。 => [メ] [ガ] [ネ] [は] [顔] [の] [一] [部] [で] [す] [。]
[メガ] [ガネ] [ネは] [は顔] [顔の] [の一] [一部] [部で] [です] [す。] [メガネ]
[ガネは] [ネは顔] [は顔の] [顔の一] [の一部] [一部で] [部です] [です。]



EdgeNGramTokenFilter

EdgeNGramTokenFilterの用途としてはおそらく、形態素解析で切り出された単語をさらにN-gramで分割するときに使用するものと考えられる。

先ほどのMyAnalyzerのプログラムをWhitespaceAnalyzerとEdgeNGramTokenFilterを使うように、次のように変更してみる:



static class MyAnalyzer extends Analyzer {
public TokenStream tokenStream(String fieldName, Reader reader) {
TokenStream stream = new WhitespaceTokenizer( reader );
return new EdgeNGramTokenFilter(stream, Side.FRONT, 1, 4 );
}
}



そして(WhitespaceAnalyzerなので)適当な英文を選んで最初のプログラムのTEXTに代入して実行すると、次のようになる:



The iPhone results sent A&T stock down slightly. => [T] [Th] [The] [i] [iP] [iPh
] [iPho] [r] [re] [res] [resu] [s] [se] [sen] [sent] [A] [A&] [A&T] [s] [st] [st
o] [stoc] [d] [do] [dow] [down] [s] [sl] [sli] [slig]



WhitespaceAnalyzerの代わりにJapaneseAnalyzerを使用すれば、日本語を形態素解析した後にそれぞれの単語をさらにN-gramで分割する、ということができるはずなので、興味のある人はやってみていただきたい。
| 関口宏司 | Lucene自由自在 | 01:15 | comments(0) | trackbacks(1) |
+ Solrによるブログ内検索
+ PROFILE
      1
2345678
9101112131415
16171819202122
23242526272829
3031     
<< December 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