【読書メモ】Lucene in Action
Lucene in Actionを読んでいく。
本の出版が2010年でLuceneのバージョン3.0で今のバージョンでは動かないと思う。
更に自分はJavaを全く知らないので多分色々とハマるがとりあえず頑張っていこうと思う。
作業用リポジトリ
まずはLucneをローカル環境で動かすために頑張る。開発環境はVsCodeではなくIntelliJ + Gradleを使う。
セットアップにはこの動画が役に立った。IntelliJ + maven
依存関係はこのサイトから適当に引っ張ってきた。
リンク集
Lucene公式ドキュメント
Chapter1 Meet Luceneのサンプルコードからハマってる。早速FSDirectory
のパスを解決するのが難しい。
IntelliJ側ではエラーが出てたけど普通に動いた。キャッシュクリアと再起動したらうまく行った。
protected Document getDocument(File f) throws Exception {
Document doc = new Document();
doc.add(new Field("contents", new FileReader(f)));
doc.add(new Field("filename", f.getName(),
Field.Store.YES, Field.Index.NOT_ANALYZED));
doc.add(new Field("fullpath", f.getCanonicalPath(),
Field.Store.YES, Field.Index.NOT_ANALYZED));
return doc;
}
Field
が無いと怒られている。変わりにTextField
を使うのかな?NOT_ANALYZED
がどこにも無いので何を使えば良いかわからない。
Perplexity先生に聞いたらStringField.TYPE_STORED
を使えと言われた。
StringFieldとTextFieldとStoredFieldの違い
Field.Store.YESとField.Store.NOの違いField.Store.YES
じゃないと駄目。おそらく検索スニペットの取得とかだと思う。
こんな文章を突っ込んで動かしてみた。
Apache Lucene is a high-performance, full-featured search engine library written entirely in Java. It is a technology suitable for nearly any application that requires structured search, full-text search, faceting, nearest-neighbor search across high-dimensionality vectors, spell correction or query suggestions.
index
配下に以下のファイルが作成された。
% ls index
_0.cfe _0.cfs _0.si segments_1 write.lock
本で紹介されているリポジトリがあるのでできるだけこれを使うようにする。
Indexingのcoreクラスについての説明。ここらへんの話はElasticsearchを触っていると当たり前だなと思った。
IndexWriter
indexを作成したり既存のインデックスをopenするクラス。インデックス内のドキュメントを作成・削除・更新を行う。あくまでも書き込み専用なので読み込みや検索はできない。IndexWriterはindexを保存する領域が必要でそれはDirectoryである。
Directory
DirectoryはLucene indexの場所を表すabstract classであり、subclassが用途にあったindexを格納する。
例ではFSDirectoryを使用していてファイルシステムのディレクトリにファイルを保存する。
DirectoryはIndexWriterのconstructorに渡す。
Analayzer
textはインデックスされる前にanalyzerに渡される。Analayzerはテキストからインデックスされるtokenを抽出し残り(stopwordsなど)を削除する。
Document
Documentのclassはfieldの集合を表している。ドキュメントのフィールドはドキュメントやドキュメントに関連するメタデータを表す。メタデータ(著者名、タイトル、副題、修正日)はドキュメントのFieldとして別々に保存される。
Field
indexの書くDocumentは少なくとも1つ以上の名前付きFieldが含まれる。
検索時にすべてのFieldのテキストは連結され巨大な一つのテキストFieldとして扱われる。
Searchingのcoreクラスについての説明。これもElasticsearch知っていると大体聞いたことある。
IndexSeearcher
IndexSearcherはIndexWriterがindexingしたものを検索する。IndexSearcherはindexをread-onlyで開くクラスである。
Term
Termは検索の基本的な単位である。Field Objectと似たようにフィールド名とフィールドの単語から構成される。Termはindexingのprocessでも関係がある。しかしLucene内部の話である。通常は考える必要がない。
Query
Luceneには様々な具象化したQueryのsubclassが存在する。最も基本的なTermQuery。他にはBooleanQuery,PhraseQuery,PrefixQuery,PhrasePrefixQuery,TermRangeQuery,NumericRangeQuery,FilteredQuery,SpanQuery。
様々なutilityメソッドが含まれる。最も面白い関数としてはsetBoostがある。これはLuceneにあるサブクエリが他のサブクエリより最終スコアの割合を増やすことを可能とする。
TermQuery
Luceneがサポートしてる最もシンプルなクエリのタイプである。
TopDocs
上位N件の検索結果に対するシンプルなポインターを管理するクラスである。docIDとスコアを持っている。
How Lucene models content
LuceneとDatabaseの違いについて色々と述べられている。LuceneはデータベースのようなSchemaを持つ必要がなくて好きにindexにフィールドを追加できる。
Luceneはindex時にflattenとdenormalize(非正規化)が必要である。
Denormalization
XMLやデータベースはドキュメントを構造的に扱うことができる。XMLならネストtag。データベースなら外部キーを通じてテーブル同士を紐付けることができる。
Luceneのドキュメントはflatなので作成時には再帰や結合を非正規化する必要がある。
Understanding the indexing process
Luceneのドキュメントをindexするには、ほんの数種類のpublic APIだけで良く。外見上は単純だが、内部では3つの主要な機能的に異なるグループに分けられる。
- PDF/XML/Microsoft Wordからテキスト抽出
- 抽出したテキストからコンテントを保持したFieldを持ったDocumentのインスタンスを作成
- Fieldのテキストはanalyzedされtokenのstreamが作成される
- 最終的にtokenは
segmented architecture
のindexに追加される
which allowed us to easily slurp their
content and use it to populate Field instances
ここでのslurpは内容を一気に読み込むみたいな意味らしい
Once you’ve created Lucene documents populated with fields, you can call Index-
Writer’s addDocument method and hand your data off to Lucene to index
ここのhand your data offでデータを転送するという意味らしい
Analysis
テキストをtokenに分割してあとに対してオプション操作を実行する
- Tokenの小文字化(LowerCaseFilter)
- a,an,the,in,onなどの助詞の削除(StopFilter)
- foxesをfoxに直すtokenの語幹に還元処理(PorterStemFilter)
analysisプロセスではindexに書き込むためのtokenのstreamを作成する。
Adding to the index
一般的に言われる転置インデックスの話。
Luceneのインデックスは複数のsegmentから構成される。実際に複数回Indexerを動かすと複数のファイルが生成される。
[main][togatoga] ~/src/github.com/togatoga-playground/lucene-in-action
% ls index
_0.cfe _0.cfs _0.si segments_1 write.lock
それぞれのsegmentは独立のindexであり、indexされた全てのドキュメントの部分集合を持っている。
新しいsegmentはwriterが追加されたドキュメントや保留中の削除をディレクトリにフラッシュするたびに作成される。
デフォルトではcompound file(cfs)で保存される。様々なファイルがあったらしいが今は単一ファイルで管理されているらしい。単一ファイルにすることで開くべきファイルを減らすことでfile descriptorの数を減らして検索時に高速化している。
cfsがどんな情報とファイルを含んでいるかは以下が参考になる。ただ今のところはよくわからないので飛ばす。
segmentファイルと呼ばれる特別なファイルがあり、segments_<N>
という名前で全ての有効なセグメントを参照している。
Luceneはこのファイルを開き参照されているsegmentファイルを開く。<N>の値は「the generation」と呼ばれ、変更がインデックスにコミットするたびに1ずつ増加する。
時間とともにインデックスには多くのsegmentが蓄積される。特にWriterを頻繁にopen/closeすると。
IndexWriterは定期的にsegmentを選択肢、それらを1つの新しいsegmentにmergeして統合する。古いsegmentは削除する。
segmentの選択はMergePolicy
によって管理され、マージが選択されるとMergeSchedulerによって実行される。
IndexingTest
で書かれているTestCaseをextendは非推奨らしく。@Test
アノテーションで実装するのがデフォルト。
これを見ながら書き直すsetupは@BeforeEach
で実装する。
RAMDirectory
はdeprecatedらしく今ならByteBuffersDirectory
を使うのが良さそう。
Important: Note that MMapDirectory is nearly always a better choice as it uses OS caches more effectively (through memory-mapped buffers). A heap-based directory like this one can have the advantage in case of ephemeral, small, short-lived indexes when disk syncs provide an additional overhead.
IndexWriter.MaxFieldLength.UNLIMITED
がなくなりました。Luceneはデフォルトでフィールドの長さ制限が無いらしい。
Field.Store.YES
、Field.Store.NO
、Field.Index.NO
、Field.Index.NOT_ANALYZED
、Field.Index.ANALAYZED
と色々と指定があってややこしい。現代だとそもそもTextField
とかStoredField
などが用意されている。
-
Field.Store.YES/NO
- フィールドの値をインデックスに保存するかどうか
- YESなら検索結果に含めることができる、NOなら検索結果に含めることができない
-
Field.Index.NO/NOT_ANALYZED/ANALYZED
- フィールドをインデックス化しない
- これがNOなら検索対象にならない、しかし
Field.Store.YES
なら値の保存はできる -
NOT_ANALYZED
ならフィールドの値をanalyzerをせずにインデックス化(単一の語句として格納) -
ANALYZED
ならanalyzerを用いてトークン化して保存(所謂全文検索)
これらを組み合わせることでトークン化(Field.Index.ANALYZED
)するけど、検索結果には含めない(Field.Store.NO
)みたいなことができる。
あとJUnitの最新スタイルとだとテスト関数は@Test
にして関数のprefixにtest
をつけないらしい、これはjunit4系のスタイル。
JUnitの動かし方が分からない。。。ディレクトリ右クリックしてRun Tests
で行けた。パッケージエラーでよく割らかないけどgradleのimplementationに追加したらうまく行った。
dependencies {
testImplementation platform('org.junit:junit-bom:5.10.0')
testImplementation 'org.junit.jupiter:junit-jupiter'
implementation 'org.junit.jupiter:junit-jupiter-api:5.5.1'
implementation group: 'org.apache.lucene', name: 'lucene-core', version: '10.0.0'
implementation group: 'org.apache.lucene', name: 'lucene-analysis-common', version: '10.0.0'
implementation group: 'org.apache.lucene', name: 'lucene-queryparser', version: '10.0.0'
}
昔だとIndexWriterにnumDocs()
が直接合ったけど今だとIndexWriter.DocStats
のnumDocs
経由で取得する必要があるっぽい。
Deleting documents from an index
ドキュメントの削除の方法について解説されている。基本的にはTermやQueryにマッチするドキュメントを削除する。APIが用意されている。一つのドキュメントを削除したい場合は、それぞれのドキュメントにuniqueな値のFieldを追加する必要がある。(データベースのprimary keyと一緒の概念)
writer.optimize
するテストとそうでないテストの解説があるが、今のLuceneでは廃止されているみたい。commitするとmaxDoc
とnumDocs
はそれぞれ1ずつ減る。maxDocs
自体はまだflushされていないdocsと削除されたdocsの数も含めるらしい。
public static final class DocStats {
/**
* The total number of docs in this index, counting docs not yet flushed (still in the RAM
* buffer), and also counting deleted docs. <b>NOTE:</b> buffered deletions are not counted. If
* you really need these to be counted you should call {@link IndexWriter#commit()} first.
*/
public final int maxDoc;
/**
* The total number of docs in this index, counting docs not yet flushed (still in the RAM
* buffer), but not counting deleted docs.
*/
public final int numDocs;
private DocStats(int maxDoc, int numDocs) {
this.maxDoc = maxDoc;
this.numDocs = numDocs;
}
}
buffered deletion
は反映されないと書かれているのでcommit
しない場合はmaxDoc
とnumDocs
の値は変わらない(削除が反映されていない)
commit
するとmaxDoc
とnumDocs
ともに削除したドキュメント数に反映される。直感的にはmaxDoc
はそのままだと思った。一方flush
の場合はmaxDoc
は変わらずnumDocs
は反映される。
以下実際のテストコード
@Test
public void deleteBeforeCommit() throws IOException {
try (IndexWriter writer = getWriter()) {
assertEquals(2, writer.getDocStats().numDocs);
writer.deleteDocuments(new Term("id", "1"));
assertEquals(2, writer.getDocStats().maxDoc);
assertEquals(2, writer.getDocStats().numDocs);
}
}
@Test
public void deleteAfterCommit() throws IOException {
try (IndexWriter writer = getWriter()) {
assertEquals(2, writer.getDocStats().numDocs);
writer.deleteDocuments(new Term("id", "1"));
writer.commit();
assertEquals(1, writer.getDocStats().maxDoc);
assertEquals(1, writer.getDocStats().numDocs);
}
}
@Test
public void deleteAfterFlush() throws IOException {
try (IndexWriter writer = getWriter()) {
assertEquals(2, writer.getDocStats().numDocs);
writer.deleteDocuments(new Term("id", "1"));
writer.flush();
assertEquals(2, writer.getDocStats().maxDoc);
assertEquals(1, writer.getDocStats().numDocs);
}
}
flush
はメモリーのデータをsegmentファイルに書き込むので反映される。ここの違いについてはもう少し理解してから。
tryの中でwriterを呼び出すと自動的にcommit
が呼び出されるぽいな。挙動を確かめるときにはtryで書くのはあまり良くないかも知れない。
After the for loop, we close the writer,
which commits all changes to the director
flush
だけでは削除すらも反映されないみたい。以下のようなテストを書いて確かめた。segmentの世界ではnumDocs
は減っているがまだ検索可能になっているらしい。これをcommit
することで実際に検索にヒットしなくなる。ただしreaderとsearcherは新しく読み込み直さないと反映されないぽい。
@Test
public void deleteAfterFlush() throws IOException {
IndexWriter writer = getWriter();
assertEquals(2, writer.getDocStats().numDocs);
writer.deleteDocuments(new Term("id", "1"));
writer.flush();
assertEquals(2, writer.getDocStats().maxDoc);
assertEquals(1, writer.getDocStats().numDocs);
DirectoryReader reader = DirectoryReader.open(directory);
IndexSearcher searcher = new IndexSearcher(reader);
TopDocs hits = searcher.search(new TermQuery(new Term("id", "1")), 1);
assertEquals(1, hits.totalHits.value());
// commit the changes
writer.commit();
reader = DirectoryReader.open(directory);
searcher = new IndexSearcher(reader);
hits = searcher.search(new TermQuery(new Term("id", "1")), 1);
assertEquals(0, hits.totalHits.value());
}
Updating documents in the index
Luceneでのドキュメント更新は前のドキュメントを削除して、新しいドキュメントをインデックスるに追加する。これは新しいドキュメントは全てのフィールドの情報を持っている。たとえ変更されていなくても。
TextField
のvalue
の文字列にスペースが含まれる文字列があるとうまく検索できないな。Den Haag
は落ちるけどDenHaag
ならテストが通る。IndexWriter
はWhiteSpaceAnalyzer
だったのでSearcher
側も``whiteSpaceAnalyzer`に変更して通った。
Field options
Field
に関する説明。大量のオプションが用意されていたけど現代だとTermField
やStringField
やStoredField
が用意されているので不要そう。
Filed Options for indexing
indexingに関するいくつかオプションがある。これらはTermField
、StringField
、StoredField
に置き換えられる。
-
Index.ANALYZED
- フィールドの値を
analyzer
を用いてtokenに分割 -
TermField
に置き換え可能 - 検索可能
- フィールドの値を
-
Index.NOT_ANALYZED
- フィールドの値を単一の
token
として扱う - 用途はexact matchなフィールドに対して使用する
- 電話番号、URL、ファイルパスなど
- 検索可能
-
StoredField
に置き換え可能
- フィールドの値を単一の
-
Index.ANALYZED_NO_NORMS
-
Index.ANALYZED
とほぼ同じ - インデックスに
norms information
は保存しない -
norms
自体はフィールド間の重み付けに使われている?- 短いFieldでのマッチほど重要など
-
-
Index.NOT_ANALYZED_NO_NORMS
-
Index.NOT_ANALYZED
とほぼ同じ - 一般的なAnalyzeが不要なフィールドなのでスコア計算も必要無い
- 電話番号とか
-
-
Index.NO
- 検索不能
-
StoredField
に置き換え可能
norms周りは全然知識なかったけど、スコアリング計算で必要な情報で必要ないものはfalseにしておいたほうが良いと書いてあった。単純なfilter
で使われるものとか。現代だとFieldType
のsetOmitNorms
を使って指定するらしい。
LengthNorm
がどうスコア計算に絡むかの解説があった
Field options for storing fields
フィールドの値を保存するかのどうかのオプションこれは現代のLuceneでも使われている。
-
Field.Store.YES
- 元の
string
をIndexReader
が取得可能となる - 検索結果に含めたいときは有効化する
- もしインデックスサイズが気になるなら巨大なフィールドでは保存しない
- 元の
-
Field.Store.NO
- フィールドの値を保存しない
- Analyzeはするけどフィールドの値を保存しない事がある
- web pageの中身自体は検索結果に要らない場合など
圧縮するためのCompressionTools
が用意されていたがすでに消えてるらしい
Field options for term vectors
検索時にuniqueな全てのtermを取得したいことがある。
- 一般的な活用としてはmatchedしたtokensに対するハイライトの高速化である。(これの詳細はSection 8.3 8.4)
- 「類似ドキュメントを検索」というリンクを有効化し、クリックすると元のドキュメントの重要な用語を使用して新しい検索を実行
- Jaccard係数とか?
- ドキュメントのカテゴリ分類(Section 5.9)
Term Vectorsとはインデックスされたフィールドと保存されたフィールドの混合である。
保存されたフィールドと似ている。なぜなら与えられたドキュメントの全てのterm vector fieldを素早く取得できるから。
Term VectorsはドキュメントIDでキー付されており、副次的にはTermによってキー付けされている。つまり一つのドキュメントに対する小さな転置インデックスを表している。
sotred field
は元の文字列がそのまま格納されているが、term vectorsはanalyzerによって生成された個別のtermsを保存する。
それぞれのフィールドの全てのtermやドキュメント内のそのtermの出現頻度が取得でき、辞書順でsortされている。
実際にElasticsearchのTerms vectors APIを叩いて試したら実際にtermは辞書順に並んでいてterm_freqが含まれていた。
TermVectorに関するOptionはたくさんあるが当然Lucene10.0の世界では存在しない。
-
TermVector.YES
- termと出現頻度は記録するがpositionやoffsetの情報は保存しない
-
TermVector.WITH_POSITIONS
- termと出現頻度は記録しそれぞれの
term
がどこのpositionだけ記録する(offsetは記録しない)
- termと出現頻度は記録しそれぞれの
-
TermVector.WITH_OFFSETS
- termと出現頻度は記録しそれぞれのtermのoffsetは記録する(positionは記録しない)
- offsetはstartとend character positionである
-
TermVector.WITH_POSITIONS_OFFSETS
- 上2つの合わせ技
-
TermVector.NO
- TermVectorに関しては何も記録しない
今だとFieldType
を使って置き換えるらしい。
- TermVectorに関しては何も記録しない
FieldType fieldType = new FieldType();
fieldType.setStoreTermVectors(true);
fieldType.setStoreTermVectorPositions(true);
fieldType.setStoreTermVectorOffsets(true);
Field field = new Field("fieldName", "fieldValue", fieldType);
position
とoffset
周りはこのスライドがめっちゃ参考になった。
offset
はバイト数ではなく文字数のため鈴木
なら(end - start)は2になる。
Reader, TokenStream, and byte[] field values
Field
系にいくつかのconstructorが用意されている。今までは単一のString
のみだった。基本的にはReader
かTokenStream
かbyte[]
を受け付けるかの違いである。
Reader
ならString
と違って文字列を全てメモリに乗せる必要がない。TokenStream
はすでにanalyzeした結果を渡す必要がある。byte[]
はバイナリ軽視のフィールドに対して保存する。あまり用途が分からない。
Field options for sorting
検索結果は基本的にスコア順にsortされているがこれを日付順やメールアドレス順に変更したい場合もある、その場合は適切なField
でインデックスする必要がある。数値の場合はNumericField
(現代ならDoubleField
など、基本的にこいつらはField
のwrapper class)
Multivalued fields
著者名など同じフィールドだけど、valueが複数ある場合について。非常に単純で同じフィールド名に対してadd
すれば良い。
Document doc = new Document();
for (String author : authors) {
doc.add(new StringField("author", author));
}
Boosting documents and fields
index-timeにドキュメントやFieldに対してboostする関数があったらしい。ただindex-timeのboostingはdeprecatedなのですでに存在しない(それはそうR.I.P)こういったことをしたいならsearch-timeにやるべきなのは自然でBoostQuery
なんてものが用意されている。
String lowerDomain = getSenderDomain().toLowerCase();
if (isImportant(lowerDomain)) {
doc.setBoost(1.5F);
} else if (isUnimportant(lowerDomain)) {
doc.setBoost(0.1F);
}
Norms
norms
は短いフィールドでヒットした場合、スコアが高くなるようになるためのboosting係数、これはLuceneのスコアリング全体を俯瞰したほうが理解できそう。
原典である「Lucene’s Practical Scoring Function」を当たるのが良さそう。
動画ならAlcia Solid Projectさんのがわかり易い。
ざっと読み直して、ここらへんのフィールドの平均値を採用して改善したのがBM25なんだよなあと思った。
norms
自体は一つのドキュメントに対してそれぞれの検索可能なフィールドに対してone-byte必要である。(float8?)
single byte norms are deadらしいが詳細分からず。。。
Indexing numbers, dates, and times
商業ではプロダクトの価格や重さや高さが重要である。ビデオ検索エンジンならそれぞれのビデオの長さをインデックスするかもしれない。プレスリリースや記事ならタイムスタンプ。現代の検索アプリが直面する重要な数値の属性の例である。
Indexing numbers
数字をindexingが重要である2つの一般的なシナリオがある。
1つはindexされるテキストに埋め込まれた数字。"Be sure to include Form 1099 in your tax return"なら1099という数字を検索したいかもしれない。一般的なAnalyzerなら数字はtokenとして採用して検索可能となる。一方SimpleAnalyzer
やStopAnalyzer
は数字をtoken streamから落とす。
もうひとつはフィールドに単一の数字があり、数字としてindexしたい。そして厳密な一致、範囲指定、sortingなどに使う。具体的な例としてはカタログに載っている製品の価格など。NumericField
というのが用意されているが現代だともっと便利なIntField
,LongField
,floatField
,DoubleField
, NumericDocValuesField
が用意されている。
昔だとtrie
を使って管理していたが現代の実装は分からず。。。Perplexity先生に聞いたらLucene6.0からはポイントベース表現が採用されたらしいです。神!!
PointValues
という形式でKd-Tree
が使われてるらしい。数値検索とかでKd-Tree
とは...
Indexing dates and times
datesは適当に数値に指定インデックスすれば良い。Elasticsearchmの内部的にはUTCに変換してlong numberとして扱ってるらしい。
Field truncation
and MaxFieldLength.LIMITED, which means fields are truncated at 10,000 terms. You can also instantiate MaxFieldLength with your own limit.
昔はフィールドあたり10,000termsまでの制限で無制限にするにはMaxFieldLength.UNLIMITED
みたいなことをしないと駄目だったが、今はデフォルトが制限なし。もし制限したい場合ならLimitTokenCountFilter
を使う。
Near-real-time search
Lucene2.9からnear-real-time searchが導入された。詳細は3.2.5で説明。ここはElasticsearchのNear real-time searchが多分最新でわかりやすい。基本的にハードウェアキャッシュに書き込んで即座に検索に反映させる(refresh)
Optimizing an index
インデックスの検索時にそれぞれのsegmentを検索し結果を統合する。巨大なインデックスを扱うアプリケーションはインデックスを最適化することで検索パフォーマンスが向上する。
最適化では多くのセグメントを一つまたは少数のセグメントに統合しすることで最適化されたインデックスは少ないfile descriptorで済む。
ここでの最適化は検索のスピードでありindexingのスピードではない。
IndexWriter
にoptimize
関数がいくつかあったがすべてdeprecated. インデックスを一つまたは指定の数のsegmentにまとめる関数だった。同じ処理はforceMerge
で可能。
This method has been deprecated, as it is horribly inefficient and very rarely justified. Lucene's multi-segment search performance has improved over time, and the default TieredMergePolicy now targets segments with deletions.
If a single computer is doing both indexing and searching,
consider scheduling optimize after hours or over the weekend so that it doesn’t inter-
fere with ongoing searching.
週末にoptimize
するスケジュールを組めと言ってるのでdeprecatedになるのはお察し。最適化は大量のディスクスペースが必要。古いsegmentをマージして新しいsegmentができるまで、古いsegmentは消せないため。必要な容量はス大体約3倍必要らしい。
TieredMergePolicy
というのが良くわからない。Luceneのmergeを可視化して説明した記事(Lucene in Actionの作者)
MergePolicyについて日本語の解説記事
TieredMergePolicy
に関するLuceneのドキュメント
- LogByteSizeMergePolicy
- 隣接するセグメントのみがマージ対象
- 一般的に非効率だが時系列データに対しては使われる
- Elasticsearchでは時系列データに対してはLogByteSizeMergePolicyが使用されている
- TieredMergePolicy
- 非隣接セグメントもマージ対象
- 一度にマージするセグメント数(setMaxMergeAtOnce(int))
- tierごとに許可されるセグメント数(setSegmentsPerTier(double))
- cascade mergesは発生しない
- インデックス内に何個のsegmentsが存在できるかの”予算”を計算
- もしインデックスの予算が超過していたら、セグメントのサイズの降順でsortする
- 最も少ないmerge costを探す。
merge costは以下の要素の組み合わせによって計算される。
- "skew(傾き)"
2. 最大サイズのセグメントのサイズ / 最初サイズのセグメントのサイズ - 合計マージサイズ
- 回収される削除の割合
「小さいskew」「小さいマージサイズ」「回収される削除の割合が大きい」ものほど優先的にmergeされる。
お気持ち的に、同じサイズ同士のセグメント、合計マージサイズが小さい、削除済みドキュメントが多く含まれるセグメントとほどマージ対象になりやすい。
マージ後のセグメントがsetMaxMergedSegmentMB(double)
を超えてたら、より少ないセグメントをマージする。
Tierが良くわからないなあ。セグメントのサイズがTierなのかなあ。同じTierごとにこのMerge処理が行われるという認識で良いのかな?
TieredMergePolicyの解説記事。神!!
全segmentに対してN^2の候補に対して一番merge costが小さいものを選んで消している。これを指定のセグメント数になるまで繰り返している。もちろん途中で消すやつは候補から外している。
TieredMergePolicy.java
のallowed segments
の計算式が全く分からなかったが上記のブログを読んで完全に理解した。
// Compute max allowed segments for the remainder of the index
long levelSize = Math.max(minSegmentBytes, floorSegmentBytes);
long bytesLeft = totIndexBytes;
while (true) {
final double segCountLevel = bytesLeft / (double) levelSize;
if (segCountLevel < segsPerTier || levelSize == maxMergedSegmentBytes) {
allowedSegCount += Math.ceil(segCountLevel);
break;
}
allowedSegCount += segsPerTier;
bytesLeft -= segsPerTier * levelSize;
levelSize = Math.min(maxMergedSegmentBytes, levelSize * mergeFactor);
}
Tierっていうのは各セグメントではなく20MB,200MB,2GBというサイズがtierである。各tierごとに固定で持てるsegment数(segmentPerTier
)が10だとする。そうなると20MB、200MB、2GBのセグメントがそれぞれ10個ずつの状態が理想系である。トータルのbyteサイズが分かるならこの理想形となるsegment数(allowedSegCount
)が計算できる。そのallowedSegCount
よりeligible count(sortedInfos.size())
が超えてるならmergeをしていく。
Ohter directory implementations
ファイルシステムを読み書きするDirectory
を実装した具象くらすは全て抽象クラスのFSDirectory
がベースとなっている。SimpleFSDirectory
, MMapDirectory
、NIOFSDirectory
の3つが紹介されている。SimpleFSDirectory
は削除されている。これは並列化のパフォーマンスが低いらしい。結構最近まで残っていてLucene8.6だとdeprecatedだけど存在した。
-
NIOFSDirectory
- Java NIO(New I/O)APIを使ってファイルシステムとやり取りしている
- 同期せずに同じファイルを異なるスレッドで読むことができる
- Windoes環境では
Sun's JRE
内のFileChannel.read
バグが原因で非推奨
-
MMapDirectory
- 読み込み時にmemory-mapped I/Oを使用しlockもしない
- インデックスサイズと同じメモリを消費する
- 64bit JREが最適、32bit JREならインデックスサイズが仮想メモリ空間と比較して小さいこと
-
RAMDirectory
- APIとして削除されて
ByteBuffersDirectory
に置き換えられた
- APIとして削除されて
-
FileSwitchDirectory
- 2つのディレクトリ間を切り替えるDirectory
- あるインデックスファイルは
RAMDDirectory
でそれ以外はMMApDirectory
みたいな使い方ができる
Concurrency, thread safety, and locking issues
複数のJVMsからのインデックスアクセス、スレッドセーフなIndexReader
やIndexWriter
、それらのルールを供するために用いられるロック機構について取り扱う
Thread and multi-JVM safety
複数のread-onlyなIndexReaders
が一つのインデックスをopenすることは問題ではない。
一度に一つのインデックスに対して開くことができるライターは1つだけである。Luceneはこれを矯正するために書き込みロックファイルを使用する。IndexWriter
が作られると書き込みロックが取得される。IndexWriter
がcloseしたときだけ書き込みロックがリリースされる。
IndexReaders
にdeleteDocument
とかのAPIがあってwriter
としても振る舞えたけどLucene 4.0から廃止されたらしい。ヤバすぎる。
Accessing an index over a remote file system
複数のJVMを異なるコンピュータ上で同じインデックスにアクセスさせる場合、リモートファイルを介してインデックスへのアクセスを公開する必要があります。
一般的な構成は単一の専用のコンピュータがローカルファイルシステムに保存したインデックスに書き込み、複数のコンピュータがリモートファイルシステムを介してそのインデックスに検索を実行する。
このような構成は動きますが、ローカルファイルに保存された検索よりはるかに遅い。
検索を行う各コンピュータのローカルファイルシステムにインデックスのコピーを複製するのがベストです。
正直、ここらへんはしんどすぎると思うのでLucene単体を動かして運用できる気がしない。。。
問題はwriter
が変更をコミットした後にもう一つのコンピュータのreader
や異なるwriter
がインデックスをopenまたはreopenしたときに発生する。
ほとんどのファイルシステムではopenなファイルに対しては削除から保護します。Windowsでは開いているファイルの削除は許可しない。ほとんどのUnixフィルシステムは削除は進行するが、全てのopenなファイルハンドラーが閉じられるまで、ファイルの実際のbyteはディスク上に割り当てられたまま。これを「delete on last close
」と言うらしい(知らんかった)
ただNFSだと単にファイル削除をしてしまうため、次のI/O操作ではIOException
が発生する。(Stale NFS file handleと呼ぶらしい)
Index locking
IndexWriterは一つだけしか持てない。これはwrite.lockで実現している。試しに同じディレクトリ(インデックス)を参照しているテストを同時に動かしたらorg.apache.lucene.store.LockObtainFailedException: Lock held by this virtual machine
で死んだ。どうやってlockを取るかは実装依存でMMapDirectory
はNativeFSLockFactory
を使っている。(java.nioのOS locking)
Debugging indexing
IndexWriterConfigにsetInfoStream(System.out)
をつけるとデバッグログが出せる。ログ見てもよく分からん。
Advanced indexing concepts
Lucene's indexingの発展的トピックを掘り下げていく。飛ばしてもいいよといったがやる。IndexReader
で削除周りをやると言ってるが多分できなそう。
- Luceneがいつ新しいsegmentが作成するかを決めるか
- Luceneのtransactionのセマンティック
Deleting documents with IndexReader
IndexReaderが削除できたやんちゃな時代があった。しかもIndexReaderがwrite系するときは他のIndexWriterをcloseしないといけない、罠すぎる。
Reclaiming disk space used by deleted documents
Luceneではドキュメントの削除はbit arrayにドキュメントが削除されたことをmarkするだけである。ドキュメントはdiskに存在し続ける。ドキュメントのtermsである転置インデックスは至るところに配布されており、ドキュメント削除時に領域を回収するのは現実的ではない。segmentがmergeされてようやくドキュメントの領域が回収される。
expungeDeletes
という削除済みのドキュメントの領域を全て回収するAPIが存在する。現代ではforceMergeDeletes
に変更されている。名前が紛らわしいかったらしいです。
Buffering and flushing
新しいドキュメントがLucene indexに追加もしくは削除が発生すると、直接ディスクに書き込むのではなくメモリ上にバッファーされる。これはI/Oを減らすことでパフォーマンスを向上させるためである。定期的に変更はDirectory
のインデックスに新しいsegmentとしてflushされる。
3つの基準によってIndexWriter
はflushをトリガーする。細かい値はIndexWriterConfig
で設定可能である。
- バッファー領域が一杯になったらflushする
- バッファー領域は
setRAMBufferSizeMB
で設定可能
- バッファー領域は
- 特定の数以上のドキュメントが追加されたらflushする
-
setMaxBufferedDocs
で設定可能
-
- 削除されたtermsの数がしきい値を上回ったらflush
-
setMaxBufferedDeleteTerms
で設定可能だったが現代だとAPI自体が削除 - Luceneが即座に削除と更新をするようになり不要になったらしい
- https://issues.apache.org/jira/browse/LUCENE-7868
- 並列に削除と更新することでパフォーマンスが向上したらしい
-
flushが起こるとwriter
は新しいsegmentとdeletionファイルを作成する。しかしwriter
が変更をコミットしてreader
がreopenしない限り使用できない。
flushはバッファーされた変更をインデックスに書き込む。commitは変更を永続化とインデックス内で可視化するために行われる。
Index commits
IndexReader
やIndexSearcher
は最後のコミット時点のインデックスしか見れない。ただし例外としてnear-real-time search機能はコミットされていない変更を検索できる。
commitはコストが高いオペレーションであり、頻繁に呼び出すとindexingのスループットが下がる。
何らかの理由で全ての変更を破棄したい場合はrollback()
を呼び出すことでIndexWriter
の最後のコミットからのセッション内の変更を破棄できる。
IndexWriter
のcommit中に行うstepである。
- (step1)バッファリングされたドキュメントと削除をflushする
- (step2)新しく作成されたすべてのファイルを同期する。新しくflushされたファイルだけではなく、最後のコミットが呼びだあされてからまたは
IndexWriter
がopenしてから完了したマージによって生成されたファイルも含まれる - (step3)次の
segments_N
ファイルを書き込み同期する。これが完了するとIndexReader
は最後のコミット以降に行われた変更全てが見える -
IndexDeletionPolicy
を呼び出して古いコミットを削除する。
最後のコミットによってのみ参照されていない古いindexファイルは新しいコミットが完了するまで削除されない。
コミット間の間隔が長いと、頻繁にコミットを行うよりdisk領域を必然的に消費する。
Luceneインデックスがデータベスなどの外部トランザクションリソースと相互作用している場合、two-phase commitを可能にするためLuceneが公開している高度なAPIが必要となる。
two-phase commit
prepareCommit
はstep1とstep2、ほとんどのstep3を実行する。新しいsegment_Nがreaderに見えるようになる直前で停止する。prepareCommit
を呼び出した後はrollback
で変更を破棄するか、またはcommit
で完了するかのどちらかを呼び出す。
INDEXDELETIONPOLICY
IndexDeletionPolicy
はIndexWriter
に古いコミットを削除しても安全かどうかを伝えるclassである。defaultはKeepOnlyLastCommitDeletionPolicy
であり最新のコミット以外全てを削除する。ほとんどのケースはこれで問題ないが、NFSを介してインデックスを共有する場合はアプリケーションの固有ロジックに基づいてPolicyを設定する必要がある。
MANAGING MULTIPLE INDEX COMMITS
基本的にLucene indexはたった一つの最後にコミットされたcommitしか存在しない。custom deletion policyを実装した場合は複数のコミットがインデックスに存在する。DirectoryReader.listCommits()
でインデックス内のコミットを取得することができる。IndexReader
は古いコミットで検索ができ、同じロジックでIndexWriter
も古いコミットに対して書き込みができる。前のコミットにロールバックし、その時点から新しいドキュメントのインデックス作成を開始できる。rollback
と似ているがこれは既にインデックスにコミットされてしまった変更に対してもrollbackが実行できる。
ACID transactions and index consistency
LuceneはACIDトランザクションモデルを実装している。
- Atomic
-
writer
による変更はindexにコミットされるか、何もされないのどちらかである。中間状態は存在しない
-
- Consistency
- indexは常に一貫している。
addIndexes
で追加されたインデックスは常に見えてるか、全く見えないかのいずれかである
- indexは常に一貫している。
- Isolation
-
IndexWriter
で変更を行っている間、新しくopenしたIndexReaderにはコミットが成功するまで変更が見えない
-
- Durability
- インデックスは一貫性を保ち、最後に成功したコミットに含まれる全ての変更が保持される
Merging
indexにたくさんのsegmentがあると、IndexWriter
はsegmentのいくつかを選び一つのsegmentにmergeする。
mergeにはいくつか利点が存在する。
- segmentの数を減るので検索パフォーマンスが向上する、またfile descriptorのlimit制限も回避できる
- 削除されたドキュメントが占有していた領域を開放することができる。たとえ1つのsegmentのmergeだとしてもbyte数は減る
MeregPolicy
という抽象クラスをsubclassがいつmergeをスべきかどうかを決定する。新しいsegmentがflushされたり、前回のmerge選択が完了したならば、MergePolicy
はmergeが今必要かどうかを判断する。
Luceneには2つのcorepolicyが存在する。それらはLogMergePolicy
のsubclassである。IndexWriter
のデフォルトではLogByteSizeMergePolicy
を使われていると書いてあるが、今はTieredMergePolicy
である。LogByteSizeMergePolicy
はセグメントの全てのファイルのbyte数からsegmentのサイズを決定している。LogDocMergePolicy
はセグメントのドキュメント数によってsegment数を決定している。
独自のMergePolicy
も実装できる、例えばoff peakまで巨大なmergeを遅らせるなど。
LogByteSizeMergePolicy
はそれぞれのsegmentをbyteサイズに基づいたレベルに振り分ける。それぞれのレベル内のセグメントのバイト数(LogByteSizeMergePolicy
)の場合merge factor
を超えると、同じレベル内のsegmentに対してmergeが発生する。
LogMergePolicyのfindMerges
の実装を読んでも理解するのが非常に難しい。。。かなり頑張ってお気持ちを理解したかも知れない。
SegmentInfosはsegmentの生成順で並んでいてmergeしたsegmentも元となったsegmentの左端の位置に挿入し直す。(applyMergeChange
)
そうなるとSegmentInfosは大体サイズ順でsegmentが並んでいてstart,endによるrange mergeでは偏ったmergeが発生しにくい。なので全セグメントをなめてmax levelに属するものだけ取り出してmergeするみたいな処理が不要である。右端からlevelに属するものが見つかれば左側は同じレベル以上に属する可能性が高いから。
MERGESCHEDULER
IndexWriter
はMergeScheduler
のサブクラスを使ってmerge
を実行する。defaultではConcurrentMergeScheduler
が現代でも使われているらしい。これはスレッドを使いバックグランドでmergeを実行する。
Adding search to your application
検索にフォーカスしたchapterである。
主要なsearch API
- IndexSearcher
- インデックスを検索するためのgateway
- Query
- 特定のクエリタイプをカプセル化した具象のsubclass
- QueryParser
-
Query
objectのhuman-entered(and readable)な表現
-
- TopDocs
- 検索結果の上位ドキュメント
- ScoreDoc
- マッチしたドキュメントIDを保持
ユーザーは上位のドキュメントにアクセスするので結果の全てのドキュメントを取得する必要はない。
Implementing a simple search feature
Luceneで検索する際にプログラムでクエリを構築するか、QueryParserを使用してユーザーが入力したテキストをQuery
に変換するかを選択できる。
Parsing a user-entered query expression: QueryParser
QueryParser
はユーザーのテキストクエリをQuery
objectに変換する。"+JUNIT +ANT -MOCK"
や"mock OR junit"みたいなクエリがかける。これはElasticsearchでのprofilerでも似たようなものを見たことがある。
QueryParserは
analyzerが必要でqueryのテキストを
termsに分解するためである。
contentsに対するクエリは
"+JUNIT +ANT -MOCK"で大文字だが、
contents`のtermsはインデックス時には小文字だった。実際に検索ヒットした"Ant in Action"の中身
author=Steve Loughran,Erik Hatcher
title=Ant in Action
isbn=193239480X
pubmonth=200707
subject=apache ant build tool junit java development
url=http://www.manning.com/loughran
SimpleAnalyzer
はLetterTokenizer
とLowerCaseFilter
なのでQuery
を構築する前にterms
を全て小文字化する。
analysisで重要な点はインデックスされた実際のterms
で検索をする必要がある。QueryPaser
はanalyzerを必要とするが、TermsQuery
や3.4節で説明されるクエリは`を必要としない。しかしインデックスされたtermsと一致する必要があります。おそらく数字とかdateに関するクエリだと思われる。
USING QUERYPASER
QueryParser
はfile nameとanalyzerを指定してインスタンス化する。昔はVersion
も必要だった。
// Lucene 3.0
QueryParser parser = new QueryParser(Version matchVersion,
String field,
Analyzer analyzer)
// Lucene 10.0
QueryParser parser = new QueryParser("contents", new SimpleAnalyzer());
QueryPaser.parse
は失敗するとParseException
の例外を投げる。QueryParser
は複数のtermsが使用されたときにdefault operator(OR
)を使用するなど様座な設定が存在する。これらの設定にはlocale,phrase slop, fuzzy query,date resolution,ワイルドカードクエリを小文字化するかどうかも含まれる。
実際にコンストラクター覗いてみると様々な設定が存在する。
protected QueryParserBase() {
super((Analyzer)null);
this.operator = OR_OPERATOR;
this.multiTermRewriteMethod = MultiTermQuery.CONSTANT_SCORE_BLENDED_REWRITE;
this.allowLeadingWildcard = false;
this.phraseSlop = 0;
this.fuzzyMinSim = 2.0F;
this.fuzzyPrefixLength = 0;
this.locale = Locale.getDefault();
this.timeZone = TimeZone.getDefault();
this.dateResolution = null;
this.fieldToDateResolution = null;
this.determinizeWorkLimit = 10000;
}
HANDLING BASIC QUERY EXPRESSIONS WITH QUERYPARSER
QueryParser
が扱える様々なexpressionが存在する。
QueryParserに引数にfieldのisbn
を指定して実際のクエリにもfieldを指定できる。これはisbn
の方はdefault fieldらしく明示的に指定されていないfieldに対して用いられる。ややこしい。
QueryParser parser = new QueryParser("isbn", analyzer);
Query query = parser.parse("title: 全文検索 AND title: 入門");
- java
- java junit
- java OR junit
- +java +junit
- java and junit
- title:ant
- タイトルにantが含まれる
- title:extreme -subject:sports
- タイトルにextremeが含まれていてsubjectにsportsが含まれない
- title: extreme AND NOT subject:sports
- title:"junit in action"
- phraseマッチで"junit in action"がtitleに含まれる
- title:"junit action"~5
- Proximity Search
- "junit action"という単語が指定されたslop値(5)以内にヒットするかどうか
- ヒット例
- "JUnit in Action"(slop 1)
- "JUnit Testing in Action"(slop 2)
- "Junit for Java Developers in Action"(slop 5)
- 日本語のときはどうなるんだろうか。ぱっとみ一単語だけど分解されることもあるので
- java*
- javaから始まるtermsが含まれるかどうか
- javaspaces, javaserver, java.netなど
- java~
- Fuzzy Search(編集距離)
- マッチ例
- java
- jawa
- lava
- jave
- デフォルト値は0.5で
java~0.8
のように類似度を調整できる
- lastmodified: [1/1/09 TO 12/31/09]
-
lastmodified
フィールドが2009/1/1から2009/12/31の間にある
-
Using IndexSearcher
IndexSearcher
を掘り下げていく。paginationやnear-real-time searchなど。
Creating an IndexSearcher
IndexSearcher -> IndexReader -> Directory -> 実際のディレクトリ
という依存をしておりIndexSearcher
はQuery
を受け取り検索してTopDocs
を返す。
IndexReader
はすべての重要な作業を行い、全てのindexファイルを開いてlow-levelなread APIを公開している。
一方IndexSearcher
は薄いベニヤ板のようなものである。
IndexReader
をopenするにはコストがかかるため、検索には1つのインスタンスを使いまわすし、必要なときだけ新しいのを開くのがベストである。
IndexSearcherをDirectoryから直接作れたがLucene 10の時点では不可能になっている。Lucene 3.6.2の時点では少なくともdeprecated。IndexSearcherを閉じるとIndexReaderも閉じてしまい非効率だから?
IndexReader
はいつもある時点(point-in-time)でのインデックスのスナップショットを検索する。
インデックスの変更を含めて検索したい場合、新しいreaderをopenする必要がある。新しいIndexReader
を取得するためにIndexReader.reopen
が用意されていたが現代ではdeprecatedである。代わりにopenIfChanged
を使用する。
Performing searches
IndexSearcher
インスタンスが利用できるメインとなるsearch方法は以下である。このchapterではシンプルにsearch(Query, int)
だけ利用する。他はchapter5,6で説明する。
- TopDocs search(Query query, int n)
-
n
は何件のTopDocs
を返すかのパラメーター
-
- TopDocs search(Query query, Filter filter, int n)
- filterを指定できる関数
- 現代ではAPIが存在しない、deprecated時代は
BooleanQuery
を使えと書いてあった - https://lucene.apache.org/core/5_5_5/core/org/apache/lucene/search/IndexSearcher.html#search-org.apache.lucene.search.Query-org.apache.lucene.search.Filter-org.apache.lucene.search.Collector-
- TopFieldDocs search(Query query, Filter filter, int n, Sort sort)
- 現代ではFilterが消えて
TopFieldDocs search(Query query, int n, Sort sort)
となっている
- 現代ではFilterが消えて
- void search(Query query, Collector results)
- それぞれのドキュメントに対してカスタムロジックを実装する場合に使用する
- 現代も残っているがdeprecatedで代わりに
search(Query, CollectorManager)
を用いる- concurrencyをサポートするためらしい
- void search(Query query, Filter filter, Collector results)
- ほぼ上と一緒。
Working with TopDocs
TopDocs
が検索結果を取得するために公開しているmethodsやattributesは少ない。
- totalHits
- マッチしたドキュメントの数
- scoreDocs
- クエリのtopヒット(ScoreDocのarray)
- デフォルトではスコアの昇順に並んでいる
- getMaxScore()
- 現代では削除されている。
- TopDocs.maxScore removed
TopDocs.maxScore is removed. IndexSearcher and TopFieldCollector no longer have an option to compute the maximum score when sorting by field. If you need to know the maximum score for a query, the recommended approach is to run a separate query:
TopDocs topHits = searcher.search(query, 1);
float maxScore = topHits.scoreDocs.length == 0 ? Float.NaN : topHits.scoreDocs[0].score;
Thanks to other optimizations that were added to Lucene 8, this query will be able to efficiently select the top-scoring document without having to visit all matches.
ScoreDoc
インスタンスはscore,doc(document ID), shardIndexを保持している。Document IDはドキュメントに保存されたフィールドを取得するのに使うことができる。IndexSearcher.document(int)
を使って取得できた。今はこの方法では取得できない。現代だとStoredFields
を通して実際のdocument
を取得している。
StoredFields storedFields = searcher.storedFields();
for (int i = 0; i < hits.scoreDocs.length; i++) {
Document hitDoc = storedFields.document(hits.scoreDocs[i].doc);
System.out.println(hitDoc.get("filename"));
}
IndexSearcher.document(int)
はLucene 9.9.1の段階ではdeprecatedだが存在していたらしくてLucene 10.0からStoredFields
形式になったらしい。
sort
を使用した場合はベストスコアのドキュメントがtopにならないかもしれない。
Paing through results
ユーザーに表示する結果は高々10~20の関連したドキュメントのみである。ページネーションの実装にはいくつのアプローチがある。
- 最初の検索時に複数ページの分の結果を集めておいて、ユーザーが検索結果を閲覧してる愛ただは
ScoreDocs
とIndexSearcher
のインスタンスが利用可能であることを保つ - 毎回新しいページへのクエリを再発行する
再発行(Requerying
)は良いアプローチであり、Requering
ならユーザーごとの状態を保存する必要がない。
一見Requering
は無駄に見えるが、Luceneの驚異的なスピードが十分に補う。(more than compensate
は~を補ってあまりある。)
現代のOSのI/Oキャッシュのおかげで必要なデータはRAMにキャッシュされているためRequering
は高速である。
Requering
を行うために元の検索が再実行される。その場合はマッチする数を多くして結果を目的のページから表示する。これはページ単位が20件で3ページを表示したい場合は60件取ってきて40~60までを表示すること。
現代のLuceneだとTopDocs searchAfter(ScoreDoc after, Query query, int numHits)
が用意されており、最後のページの結果(ScoreDoc
)を渡すことで、次のページ以降のn件を取得できる。
deep-paging
とは
Near-real-time search
near-real-time searchはLucene 2.9から導入された機能で、writer
をcloseしたりcommitする必要なく変更を高速に検索できる。紹介されているコードが古かったのでLucene 4.0を参考コードにして書いた。昔はIndexWriter.getReader
みたいなAPIがあったらしいが当然なくなっている。
Understanding Lucene scoring
Luceneの検索スコア計算についての説明。現代のBM25ではなくLucene 3.0時点でのスコア計算の話。ここではDefaultSimilarity
(現代だとTFIDFSimilarity
)を例に挙げて説明する。
How Lucene scores
without further ado
は「前置きはこれくらいにして」「本題に入りましょう」という意味らしい。
以下がスコア計算の数式である。
-
q
はクエリ -
t
はq
のそれぞれのterm -
d
はt
にマッチするdocument
それぞれの数式の要素は以下である。
-
tf(t \in d) - Term Frequency値
- ドキュメント
d
に何回term
tが出現するか
-
idf(t) - Inverse document frequency
-
term
tがドキュメントの集合全体にどれだけ出現するか - 一般的によく出現する
term
のスコアは小さく、レアな単語ほど出現回数を大きくする
-
boost(t.field \in d) - ドキュメントのフィールドに対する
boost
値 - デフォルト値は1
- 昔はindexing時に設定したがそれは廃止された。今だとクエリ時に
boost
値を設定する
- ドキュメントのフィールドに対する
-
lengthNorm(t.field \in d) - フィールドの正規化値(normalization value)
-
DefaultSimilarity
では となっている\frac {1} {\sqrt(numTerms)} -
term
の数が少ない比較的短いフィールドにマッチするほどスコアが高くなる
-
coord(q, d) - 調整係数(Coordination factor)
- ドキュメントに含まれるクエリの
term
の数 -
DefaultSimilarity
では以下である\frac {overlap} {maxOverlap} -
はドキュメントにマッチしたクエリのoverlap term
数 -
はクエリのmaxOverlap term
数
- クエリの
term
が全てマッチするほどスコアが高い。
-
queryNorm(q) - クエリの正規価値(normalization value)
- クエリの各
term
の重み(weight)のルートの和 - 数式は
\frac {1} {\sqrt{sumOfSquaredWeights}} -
とは?sumOfSquaredWeights \sum_{t \in q} idf(t)^2 - 本当はクエリやtermに対してboost値があるが省略
- これらを合わせると
\frac {1} {\sqrt{\sum_{t \in q} idf(t)^2}} - この係数はランキングには影響を与えない
- 全てのドキュメントに対して同じ値になるから
- なぜ必要なの?
- 異なるクエリ(またはインデックス or shard)のスコアを比較可能にする
- 分散環境においても
値はshardごとに計算されるidf
- 分散環境においても
-
値の影響を抑制することでidf ある程度
スコアを比較できるようになる
- 異なるクエリ(またはインデックス or shard)のスコアを比較可能にする