Open14

【読書メモ】Lucene in Action

togatogatogatoga

Lucene in Actionを読んでいく。
本の出版が2010年でLuceneのバージョン3.0で今のバージョンでは動かないと思う。
更に自分はJavaを全く知らないので多分色々とハマるがとりあえず頑張っていこうと思う。
作業用リポジトリ
まずはLucneをローカル環境で動かすために頑張る。開発環境はVsCodeではなくIntelliJ + Gradleを使う。
セットアップにはこの動画が役に立った。IntelliJ + maven
https://www.youtube.com/watch?v=Qd7C9vsHJbs
依存関係はこのサイトから適当に引っ張ってきた。
https://mvnrepository.com/artifact/org.apache.lucene

リンク集

Lucene公式ドキュメント
https://lucene.apache.org/core/10_0_0/index.html

togatogatogatoga

Chapter1 Meet Luceneのサンプルコードからハマってる。早速FSDirectoryのパスを解決するのが難しい。
IntelliJ側ではエラーが出てたけど普通に動いた。キャッシュクリアと再起動したらうまく行った。
https://qiita.com/sekainohsan/items/83d82992b9b051f26553

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を使えと言われた。
https://www.perplexity.ai/search/lucenenotextfield-type-storedt-HXraacMaSwm3lL1MksLEXw

StringFieldとTextFieldとStoredFieldの違い
https://self-learning-java-tutorial.blogspot.com/2021/07/lucene-stringfield-textfield-and.html

Field.Store.YESとField.Store.NOの違い
https://stackoverflow.com/questions/32940650/lucene-field-store-yes-versus-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

本で紹介されているリポジトリがあるのでできるだけこれを使うようにする。
https://github.com/marevol/lia2e

togatogatogatoga

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として扱われる。

togatogatogatoga

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とスコアを持っている。

togatogatogatoga

How Lucene models content

LuceneとDatabaseの違いについて色々と述べられている。LuceneはデータベースのようなSchemaを持つ必要がなくて好きにindexにフィールドを追加できる。
Luceneはindex時にflattenとdenormalize(非正規化)が必要である。

Denormalization

XMLやデータベースはドキュメントを構造的に扱うことができる。XMLならネストtag。データベースなら外部キーを通じてテーブル同士を紐付けることができる。
Luceneのドキュメントはflatなので作成時には再帰や結合を非正規化する必要がある。

togatogatogatoga

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がどんな情報とファイルを含んでいるかは以下が参考になる。ただ今のところはよくわからないので飛ばす。
https://www.m3tech.blog/entry/2021/08/24/162205
https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/codecs/lucene100/package-summary.html#package.description

segmentファイルと呼ばれる特別なファイルがあり、segments_<N>という名前で全ての有効なセグメントを参照している。
Luceneはこのファイルを開き参照されているsegmentファイルを開く。<N>の値は「the generation」と呼ばれ、変更がインデックスにコミットするたびに1ずつ増加する。
時間とともにインデックスには多くのsegmentが蓄積される。特にWriterを頻繁にopen/closeすると。
IndexWriterは定期的にsegmentを選択肢、それらを1つの新しいsegmentにmergeして統合する。古いsegmentは削除する。
segmentの選択はMergePolicyによって管理され、マージが選択されるとMergeSchedulerによって実行される。

togatogatogatoga

IndexingTestで書かれているTestCaseをextendは非推奨らしく。@Testアノテーションで実装するのがデフォルト。
https://stackoverflow.com/questions/2635839/junit-confusion-use-extends-testcase-or-test
これを見ながら書き直すsetupは@BeforeEachで実装する。
https://junit.org/junit5/docs/current/user-guide/

RAMDirectoryはdeprecatedらしく今ならByteBuffersDirectoryを使うのが良さそう。

https://kazuhira-r.hatenablog.com/entry/2024/05/19/181838

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.

https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/store/ByteBuffersDirectory.html

IndexWriter.MaxFieldLength.UNLIMITEDがなくなりました。Luceneはデフォルトでフィールドの長さ制限が無いらしい。
Field.Store.YESField.Store.NOField.Index.NOField.Index.NOT_ANALYZEDField.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.DocStatsnumDocs経由で取得する必要があるっぽい。
https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/index/IndexWriter.DocStats.html

Deleting documents from an index

ドキュメントの削除の方法について解説されている。基本的にはTermやQueryにマッチするドキュメントを削除する。APIが用意されている。一つのドキュメントを削除したい場合は、それぞれのドキュメントにuniqueな値のFieldを追加する必要がある。(データベースのprimary keyと一緒の概念)
writer.optimizeするテストとそうでないテストの解説があるが、今のLuceneでは廃止されているみたい。commitするとmaxDocnumDocsはそれぞれ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しない場合はmaxDocnumDocsの値は変わらない(削除が反映されていない)
commitするとmaxDocnumDocsともに削除したドキュメント数に反映される。直感的には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でのドキュメント更新は前のドキュメントを削除して、新しいドキュメントをインデックスるに追加する。これは新しいドキュメントは全てのフィールドの情報を持っている。たとえ変更されていなくても。
TextFieldvalueの文字列にスペースが含まれる文字列があるとうまく検索できないな。Den Haagは落ちるけどDenHaagならテストが通る。IndexWriterWhiteSpaceAnalyzerだったのでSearcher側も``whiteSpaceAnalyzer`に変更して通った。

togatogatogatoga

Field options

Fieldに関する説明。大量のオプションが用意されていたけど現代だとTermFieldStringFieldStoredFieldが用意されているので不要そう。

Filed Options for indexing

indexingに関するいくつかオプションがある。これらはTermFieldStringFieldStoredFieldに置き換えられる。

  • 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で使われるものとか。現代だとFieldTypesetOmitNormsを使って指定するらしい。

https://www.elastic.co/guide/en/elasticsearch/reference/current/norms.htmlhttps://www.elastic.co/guide/en/elasticsearch/reference/current/norms.html

LengthNormがどうスコア計算に絡むかの解説があった
https://lucene.apache.org/core/4_0_0/core/org/apache/lucene/search/similarities/TFIDFSimilarity.html#formula_norm

Field options for storing fields

フィールドの値を保存するかのどうかのオプションこれは現代のLuceneでも使われている。

  • Field.Store.YES
    • 元のstringIndexReaderが取得可能となる
    • 検索結果に含めたいときは有効化する
    • もしインデックスサイズが気になるなら巨大なフィールドでは保存しない
  • Field.Store.NO
    • フィールドの値を保存しない
    • Analyzeはするけどフィールドの値を保存しない事がある
      • web pageの中身自体は検索結果に要らない場合など

圧縮するためのCompressionToolsが用意されていたがすでに消えてるらしい
https://issues.apache.org/jira/browse/LUCENE-7322

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は記録しない)
  • TermVector.WITH_OFFSETS
    • termと出現頻度は記録しそれぞれのtermのoffsetは記録する(positionは記録しない)
    • offsetはstartとend character positionである
  • TermVector.WITH_POSITIONS_OFFSETS
    • 上2つの合わせ技
  • TermVector.NO
    • TermVectorに関しては何も記録しない
      今だとFieldTypeを使って置き換えるらしい。
FieldType fieldType = new FieldType();
fieldType.setStoreTermVectors(true);
fieldType.setStoreTermVectorPositions(true);
fieldType.setStoreTermVectorOffsets(true);

Field field = new Field("fieldName", "fieldValue", fieldType);

positionoffset周りはこのスライドがめっちゃ参考になった。
https://speakerdeck.com/kampersanda/elasticsearch-no-character-filter-deyunikodozheng-gui-hua-surutotokunnoohusetutogazurerubaguheno-workaround-search-engineering-tech-talk-2024-spring

offsetはバイト数ではなく文字数のため鈴木なら(end - start)は2になる。

Reader, TokenStream, and byte[] field values

Field系にいくつかのconstructorが用意されている。今までは単一のStringのみだった。基本的にはReaderTokenStreambyte[]を受け付けるかの違いである。
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);
}

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

Norms

normsは短いフィールドでヒットした場合、スコアが高くなるようになるためのboosting係数、これはLuceneのスコアリング全体を俯瞰したほうが理解できそう。
原典である「Lucene’s Practical Scoring Function」を当たるのが良さそう。
動画ならAlcia Solid Projectさんのがわかり易い。
https://www.youtube.com/watch?v=V7WVdlUSOco&ab_channel=AIciaSolidProject
ざっと読み直して、ここらへんのフィールドの平均値を採用して改善したのがBM25なんだよなあと思った。
norms自体は一つのドキュメントに対してそれぞれの検索可能なフィールドに対してone-byte必要である。(float8?)
single byte norms are deadらしいが詳細分からず。。。
https://trifork.nl/blog/simon-says-single-byte-norms-are-dead/

togatogatogatoga

Indexing numbers, dates, and times

商業ではプロダクトの価格や重さや高さが重要である。ビデオ検索エンジンならそれぞれのビデオの長さをインデックスするかもしれない。プレスリリースや記事ならタイムスタンプ。現代の検索アプリが直面する重要な数値の属性の例である。

Indexing numbers

数字をindexingが重要である2つの一般的なシナリオがある。
1つはindexされるテキストに埋め込まれた数字。"Be sure to include Form 1099 in your tax return"なら1099という数字を検索したいかもしれない。一般的なAnalyzerなら数字はtokenとして採用して検索可能となる。一方SimpleAnalyzerStopAnalyzerは数字をtoken streamから落とす。
もうひとつはフィールドに単一の数字があり、数字としてindexしたい。そして厳密な一致、範囲指定、sortingなどに使う。具体的な例としてはカタログに載っている製品の価格など。NumericFieldというのが用意されているが現代だともっと便利なIntField,LongField,floatField,DoubleField, NumericDocValuesFieldが用意されている。
昔だとtrieを使って管理していたが現代の実装は分からず。。。Perplexity先生に聞いたらLucene6.0からはポイントベース表現が採用されたらしいです。神!!
PointValuesという形式でKd-Treeが使われてるらしい。数値検索とかでKd-Treeとは...
https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/index/PointValues.html

Indexing dates and times

datesは適当に数値に指定インデックスすれば良い。Elasticsearchmの内部的にはUTCに変換してlong numberとして扱ってるらしい。
https://github.com/search?q=repo%3Aelastic%2Felasticsearch+date+field&type=code

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を使う。

Lucene2.9からnear-real-time searchが導入された。詳細は3.2.5で説明。ここはElasticsearchのNear real-time searchが多分最新でわかりやすい。基本的にハードウェアキャッシュに書き込んで即座に検索に反映させる(refresh)

Optimizing an index

インデックスの検索時にそれぞれのsegmentを検索し結果を統合する。巨大なインデックスを扱うアプリケーションはインデックスを最適化することで検索パフォーマンスが向上する。
最適化では多くのセグメントを一つまたは少数のセグメントに統合しすることで最適化されたインデックスは少ないfile descriptorで済む。
ここでの最適化は検索のスピードでありindexingのスピードではない。
IndexWriteroptimize関数がいくつかあったがすべてdeprecated. インデックスを一つまたは指定の数のsegmentにまとめる関数だった。同じ処理はforceMergeで可能。
https://lucene.apache.org/core/3_5_0/api/core/org/apache/lucene/index/IndexWriter.html#optimize()

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の作者)
https://blog.mikemccandless.com/2011/02/visualizing-lucenes-segment-merges.html
MergePolicyについて日本語の解説記事
https://ameblo.jp/principia-ca/entry-10892036569.html
TieredMergePolicyに関するLuceneのドキュメント
https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/index/TieredMergePolicy.html

  • LogByteSizeMergePolicy
    • 隣接するセグメントのみがマージ対象
    • 一般的に非効率だが時系列データに対しては使われる
    • Elasticsearchでは時系列データに対してはLogByteSizeMergePolicyが使用されている
  • TieredMergePolicy
    • 非隣接セグメントもマージ対象
    • 一度にマージするセグメント数(setMaxMergeAtOnce(int))
    • tierごとに許可されるセグメント数(setSegmentsPerTier(double))
    • cascade mergesは発生しない
  1. インデックス内に何個のsegmentsが存在できるかの”予算”を計算
  2. もしインデックスの予算が超過していたら、セグメントのサイズの降順でsortする
  3. 最も少ないmerge costを探す。

merge costは以下の要素の組み合わせによって計算される。

  1. "skew(傾き)"
    2. 最大サイズのセグメントのサイズ / 最初サイズのセグメントのサイズ
  2. 合計マージサイズ
  3. 回収される削除の割合

「小さいskew」「小さいマージサイズ」「回収される削除の割合が大きい」ものほど優先的にmergeされる。
お気持ち的に、同じサイズ同士のセグメント、合計マージサイズが小さい、削除済みドキュメントが多く含まれるセグメントとほどマージ対象になりやすい。
マージ後のセグメントがsetMaxMergedSegmentMB(double)を超えてたら、より少ないセグメントをマージする。
Tierが良くわからないなあ。セグメントのサイズがTierなのかなあ。同じTierごとにこのMerge処理が行われるという認識で良いのかな?

TieredMergePolicyの解説記事。神!!
https://www.outcoldman.com/en/archive/2017/07/13/elasticsearch-explaining-merge-settings/

全segmentに対してN^2の候補に対して一番merge costが小さいものを選んで消している。これを指定のセグメント数になるまで繰り返している。もちろん途中で消すやつは候補から外している。
TieredMergePolicy.javaallowed 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をしていく。

togatogatogatoga

Ohter directory implementations

ファイルシステムを読み書きするDirectoryを実装した具象くらすは全て抽象クラスのFSDirectoryがベースとなっている。SimpleFSDirectory, MMapDirectoryNIOFSDirectoryの3つが紹介されている。SimpleFSDirectoryは削除されている。これは並列化のパフォーマンスが低いらしい。結構最近まで残っていてLucene8.6だとdeprecatedだけど存在した。
https://lucene.apache.org/core/8_6_0/core/org/apache/lucene/store/SimpleFSDirectory.html

  • 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に置き換えられた
  • FileSwitchDirectory
    • 2つのディレクトリ間を切り替えるDirectory
    • あるインデックスファイルはRAMDDirectoryでそれ以外はMMApDirectoryみたいな使い方ができる

Concurrency, thread safety, and locking issues

複数のJVMsからのインデックスアクセス、スレッドセーフなIndexReaderIndexWriter、それらのルールを供するために用いられるロック機構について取り扱う

Thread and multi-JVM safety

複数のread-onlyなIndexReadersが一つのインデックスをopenすることは問題ではない。
一度に一つのインデックスに対して開くことができるライターは1つだけである。Luceneはこれを矯正するために書き込みロックファイルを使用する。IndexWriterが作られると書き込みロックが取得される。IndexWriterがcloseしたときだけ書き込みロックがリリースされる。
IndexReadersdeleteDocumentとかのAPIがあってwriterとしても振る舞えたけどLucene 4.0から廃止されたらしい。ヤバすぎる。
https://lucene.apache.org/core/3_6_1/api/core/org/apache/lucene/index/IndexReader.html

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を取るかは実装依存でMMapDirectoryNativeFSLockFactoryを使っている。(java.nioのOS locking)

Debugging indexing

IndexWriterConfigにsetInfoStream(System.out)をつけるとデバッグログが出せる。ログ見てもよく分からん。

togatogatogatoga

Advanced indexing concepts

Lucene's indexingの発展的トピックを掘り下げていく。飛ばしてもいいよといったがやる。IndexReaderで削除周りをやると言ってるが多分できなそう。

  • Luceneがいつ新しいsegmentが作成するかを決めるか
  • Luceneのtransactionのセマンティック

Deleting documents with IndexReader

IndexReaderが削除できたやんちゃな時代があった。しかもIndexReaderがwrite系するときは他のIndexWriterをcloseしないといけない、罠すぎる。
https://issues.apache.org/jira/browse/LUCENE-3606

Reclaiming disk space used by deleted documents

Luceneではドキュメントの削除はbit arrayにドキュメントが削除されたことをmarkするだけである。ドキュメントはdiskに存在し続ける。ドキュメントのtermsである転置インデックスは至るところに配布されており、ドキュメント削除時に領域を回収するのは現実的ではない。segmentがmergeされてようやくドキュメントの領域が回収される。
expungeDeletesという削除済みのドキュメントの領域を全て回収するAPIが存在する。現代ではforceMergeDeletesに変更されている。名前が紛らわしいかったらしいです。
https://issues.apache.org/jira/browse/LUCENE-3577

Buffering and flushing

新しいドキュメントがLucene indexに追加もしくは削除が発生すると、直接ディスクに書き込むのではなくメモリ上にバッファーされる。これはI/Oを減らすことでパフォーマンスを向上させるためである。定期的に変更はDirectoryのインデックスに新しいsegmentとしてflushされる。

3つの基準によってIndexWriterはflushをトリガーする。細かい値はIndexWriterConfigで設定可能である。

  • バッファー領域が一杯になったらflushする
    • バッファー領域はsetRAMBufferSizeMBで設定可能
  • 特定の数以上のドキュメントが追加されたらflushする
    • setMaxBufferedDocsで設定可能
  • 削除されたtermsの数がしきい値を上回ったらflush
    • setMaxBufferedDeleteTermsで設定可能だったが現代だとAPI自体が削除
    • Luceneが即座に削除と更新をするようになり不要になったらしい

flushが起こるとwriterは新しいsegmentとdeletionファイルを作成する。しかしwriterが変更をコミットしてreaderがreopenしない限り使用できない。
flushはバッファーされた変更をインデックスに書き込む。commitは変更を永続化とインデックス内で可視化するために行われる。

Index commits

IndexReaderIndexSearcherは最後のコミット時点のインデックスしか見れない。ただし例外としてnear-real-time search機能はコミットされていない変更を検索できる。
commitはコストが高いオペレーションであり、頻繁に呼び出すとindexingのスループットが下がる。
何らかの理由で全ての変更を破棄したい場合はrollback()を呼び出すことでIndexWriterの最後のコミットからのセッション内の変更を破棄できる。

IndexWriterのcommit中に行うstepである。

  1. (step1)バッファリングされたドキュメントと削除をflushする
  2. (step2)新しく作成されたすべてのファイルを同期する。新しくflushされたファイルだけではなく、最後のコミットが呼びだあされてからまたはIndexWriterがopenしてから完了したマージによって生成されたファイルも含まれる
  3. (step3)次のsegments_Nファイルを書き込み同期する。これが完了するとIndexReaderは最後のコミット以降に行われた変更全てが見える
  4. IndexDeletionPolicyを呼び出して古いコミットを削除する。

最後のコミットによってのみ参照されていない古いindexファイルは新しいコミットが完了するまで削除されない。
コミット間の間隔が長いと、頻繁にコミットを行うよりdisk領域を必然的に消費する。
Luceneインデックスがデータベスなどの外部トランザクションリソースと相互作用している場合、two-phase commitを可能にするためLuceneが公開している高度なAPIが必要となる。

two-phase commit

prepareCommitはstep1とstep2、ほとんどのstep3を実行する。新しいsegment_Nがreaderに見えるようになる直前で停止する。prepareCommitを呼び出した後はrollbackで変更を破棄するか、またはcommitで完了するかのどちらかを呼び出す。

INDEXDELETIONPOLICY

IndexDeletionPolicyIndexWriterに古いコミットを削除しても安全かどうかを伝える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で追加されたインデックスは常に見えてるか、全く見えないかのいずれかである
  • 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

IndexWriterMergeSchedulerのサブクラスを使ってmergeを実行する。defaultではConcurrentMergeSchedulerが現代でも使われているらしい。これはスレッドを使いバックグランドでmergeを実行する。

togatogatogatoga

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でも似たようなものを見たことがある。 QueryParseranalyzerが必要で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

SimpleAnalyzerLetterTokenizerLowerCaseFilterなのでQueryを構築する前にtermsを全て小文字化する。
https://lucene.apache.org/core/10_0_0/analysis/common/org/apache/lucene/analysis/core/SimpleAnalyzer.html

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の間にある
togatogatogatoga

Using IndexSearcher

IndexSearcherを掘り下げていく。paginationやnear-real-time searchなど。

Creating an IndexSearcher

IndexSearcher -> IndexReader -> Directory -> 実際のディレクトリ
という依存をしておりIndexSearcherQueryを受け取り検索してTopDocsを返す。
IndexReaderはすべての重要な作業を行い、全てのindexファイルを開いてlow-levelなread APIを公開している。
一方IndexSearcherは薄いベニヤ板のようなものである。
IndexReaderをopenするにはコストがかかるため、検索には1つのインスタンスを使いまわすし、必要なときだけ新しいのを開くのがベストである。
IndexSearcherをDirectoryから直接作れたがLucene 10の時点では不可能になっている。Lucene 3.6.2の時点では少なくともdeprecated。IndexSearcherを閉じるとIndexReaderも閉じてしまい非効率だから?
https://lucene.apache.org/core/3_6_2/api/core/org/apache/lucene/search/IndexSearcher.html#IndexSearcher(org.apache.lucene.store.Directory)
https://stackoverflow.com/questions/10531239/opening-directory-through-indexreader-or-indexsearcherin-lucene

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)
  • TopFieldDocs search(Query query, Filter filter, int n, Sort sort)
    • 現代ではFilterが消えてTopFieldDocs search(Query query, int n, Sort sort)となっている
  • 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 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形式になったらしい。
https://lucene.apache.org/core/9_9_1/core/org/apache/lucene/search/IndexSearcher.html#doc(int)

sortを使用した場合はベストスコアのドキュメントがtopにならないかもしれない。

Paing through results

ユーザーに表示する結果は高々10~20の関連したドキュメントのみである。ページネーションの実装にはいくつのアプローチがある。

  • 最初の検索時に複数ページの分の結果を集めておいて、ユーザーが検索結果を閲覧してる愛ただはScoreDocsIndexSearcherのインスタンスが利用可能であることを保つ
  • 毎回新しいページへのクエリを再発行する

再発行(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件を取得できる。
https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/search/IndexSearcher.html#searchAfter(org.apache.lucene.search.ScoreDoc,org.apache.lucene.search.Query,int)
deep-pagingとは
https://stp-the-wld.blogspot.com/2013/01/lucenesolrdeep-paging.html

near-real-time searchはLucene 2.9から導入された機能で、writerをcloseしたりcommitする必要なく変更を高速に検索できる。紹介されているコードが古かったのでLucene 4.0を参考コードにして書いた。昔はIndexWriter.getReaderみたいなAPIがあったらしいが当然なくなっている。
https://blog.mikemccandless.com/2011/06/lucenes-near-real-time-search-is-fast.html

togatogatogatoga

Understanding Lucene scoring

Luceneの検索スコア計算についての説明。現代のBM25ではなくLucene 3.0時点でのスコア計算の話。ここではDefaultSimilarity(現代だとTFIDFSimilarity)を例に挙げて説明する。

How Lucene scores

without further adoは「前置きはこれくらいにして」「本題に入りましょう」という意味らしい。
以下がスコア計算の数式である。

\sum_{t \in q} \left( tf(t \in d) \cdot idf(t)^2 \cdot boost(t.field \in d) \cdot lengthNorm(t.field \in d) \right) \cdot coord(q,d) \cdot queryNorm(q)
  • qはクエリ
  • tqのそれぞれのterm
  • dtにマッチする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)のスコアを比較可能にする
          • 分散環境においてもidf値はshardごとに計算される
        • idf値の影響を抑制することである程度スコアを比較できるようになる

https://lucene.apache.org/core/3_0_3/api/core/org/apache/lucene/search/Similarity.html
https://lucene.apache.org/core/3_0_3/api/core/org/apache/lucene/search/DefaultSimilarity.html
https://atraetech.hatenablog.com/entry/lucene-practical-scoring-function