1
0

More than 1 year has passed since last update.

Lucene がやっていることをまとめてみた。

Posted at

Lucene のソースコードを読んでみました。

Java の勉強をしたいと思って、Lucene の ソースコードを読んでみました。簡単に言えば、下のコードの流れのソースコードを1つ1つ拾った感じです。

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.io.IOException;
import java.io.File;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.codecs.simpletext.SimpleTextCodec;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field.Store;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.queryparser.classic.QueryParser;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.queryparser.classic.ParseException;
import org.apache.lucene.util.Version;

public class LuceneSimpleTextCodecSample {

	private static File plaintextDir;
	private static String INDEX_ROOT_FOLDER = "/Users/coffeecup/Documents/programming/Java/lucene/lucene/lucene/core/src/java";

    private static File assureDirectoryExists(File dir) {
        if (!dir.exists()) {
            dir.mkdirs();
        }
        return dir;
    }

    private static void indexPlaintextDocuments() {
		plaintextDir = assureDirectoryExists(new File(INDEX_ROOT_FOLDER, "lucene-plaintext"));
		// 
		StandardAnalyzer analyzer = new StandardAnalyzer();
		IndexWriterConfig config = new IndexWriterConfig(analyzer);
		config.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
		config.setCodec(new SimpleTextCodec());
		try {
			Directory luceneDir = FSDirectory.open(plaintextDir.toPath());
			IndexWriter writer = new IndexWriter(luceneDir, config);
			writer.addDocument(Arrays.asList(
					new TextField("title", "The title of my first document", Store.YES),
					new TextField("content", "The content of the first document", Store.YES)
			));
			writer.addDocument(Arrays.asList(
					new TextField("title", "The title of my second document", Store.YES),
					new TextField("content", "The content of the second document", Store.YES)
			));
			writer.close();
		} catch (IOException e) {
			System.out.println(e);
		}
    }

    private static void searchPlaintextDocuments() throws IOException {
    	plaintextDir = assureDirectoryExists(new File(INDEX_ROOT_FOLDER, "lucene-plaintext"));
    	String searchText = "second";
    	//
    	Analyzer analyzer = new StandardAnalyzer();
    	try {
	    	QueryParser parser = new QueryParser("title", analyzer);
	    	Query query = parser.parse(searchText);

	    	Directory indexDir = FSDirectory.open(plaintextDir.toPath());
	    	IndexReader indexReader = DirectoryReader.open(indexDir);
	 		IndexSearcher indexSearcher = new IndexSearcher(indexReader);
	    	//
	    	TopDocs results = indexSearcher.search(query, 10);
	    	System.out.println(results.totalHits + " documents found.");
    		for (ScoreDoc scoredoc : results.scoreDocs) {
    			Document doc = indexSearcher.doc(scoredoc.doc);
    			System.out.println("File: " + doc.get("content"));
    		}
    		
    		
    	} catch(ParseException e) {
    		System.out.println(e);
    	}
    }

	public static void main(String[] args) throws IOException {
		indexPlaintextDocuments();
		searchPlaintextDocuments();
	}
}

ここでは、このコードのソースコードの流れを簡単に追っていきたいと思います(但し、低レイヤー部分は読み飛ばした部分もあります)。
また、過去に投稿した記事もあるので新しい部分は、IndexReaderIndexSearcher の部分になります。

Analyzer

該当コード

Analyzer analyzer = new StandardAnalyzer();

attribute やらで、該当単語を取得したり文字の位置を取得したり、していて複雑ですが、
簡単に言えば、Tokenizer で input を入力して、それを incrementTokenJFlex でトークナイズしています。

トークナイズは、少しだけチューニングできます。詳しくは、

などを見てください。

IndexWriter

		IndexWriterConfig config = new IndexWriterConfig(analyzer);
		config.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
		config.setCodec(new SimpleTextCodec());
		try {
			Directory luceneDir = FSDirectory.open(plaintextDir.toPath());
			IndexWriter writer = new IndexWriter(luceneDir, config);
			writer.addDocument(Arrays.asList(
					new TextField("title", "The title of my first document", Store.YES),
					new TextField("content", "The content of the first document", Store.YES)
			));
			writer.addDocument(Arrays.asList(
					new TextField("title", "The title of my second document", Store.YES),
					new TextField("content", "The content of the second document", Store.YES)
			));
			writer.close();
		} catch (IOException e) {
			System.out.println(e);
		}

ここでやっていることは、IndexWriterConfig で 書き出していくインデックス の設定を初期化します。ここでは 暗号化の方法を、SimpleTextCodec にしています。
このインデックスの書き出しは、SimpleTextStoredFieldsWriter で行っていて、その呼び出しは IndexingChainstoredFieldsConsumer 行っています。
詳しくは、

を見てみてください。

QueryParser

QueryParser parser = new QueryParser("title", analyzer);

ここの実装は、JavaCC を使っていて、ここ に実装が載っています。
詳細は、

に譲りますが、Query でパースした結果が出てきます。

IndexReader

Directory indexDir = FSDirectory.open(plaintextDir.toPath());
IndexReader indexReader = DirectoryReader.open(indexDir);

ここでは、ファイルを開いています。
ファイルを開いているのが、FSDirectory.openMMapDirectorylookupProvider で呼び出される org.apache.lucene.store.MemorySegmentIndexInputProvider から openInput 関数になり、readByte などの関数が MemorySegmentIndexInput に定義されています(これの継承元である DataInput を使うこともあります )。
DirectoryReader.open では、ここ がスタート地点になり、主に doBody の内容を回しています。
まず

SegmentInfos sis =
    SegmentInfos.readCommit(directory, segmentFileName, minSupportedMajorVersion);

の部分で、インデックスを作ったフォルダでの segments_ のファイルを開きます。この readCommit を見れば分かりますが、ここでは Segmentファイルが正しいかどうか(Magic)・Codec の種類・Lucene のバージョンを取得しています。詳細は、公式 を見れば概要が見て取れますが、parseSegmentInfos公式 で言うところの SegName, SegID, SegCodec, DelGen, DeletionCount, FieldInfosGen, DocValuesGen, UpdatesFiles をパースしています。そして、その結果を infos.add(siPerCommit); で追加しています。
その結果は、SegmentInfos sis に代入されて、

          for (int i = sis.size() - 1; i >= 0; i--) {
            readers[i] =
                new SegmentReader(sis.info(i), sis.getIndexCreatedVersionMajor(), IOContext.READ);
          }

の for文 で SegmentInfos ごとに SegmentReader が初期化されます。この SegmentReader で重要なのが、SegmentCoreReaders で ここで 主たるインデックスファイルである、scf ファイルを開いています。
ここで、

cfsDir = cfsReader = codec.compoundFormat().getCompoundReader(dir, si.info, context);

のように SimpleTextCompoundFormat が出てくるのですが、その最初の方の

    // first get to TOC:
    DecimalFormat df =
        new DecimalFormat(OFFSETPATTERN, DecimalFormatSymbols.getInstance(Locale.ROOT));
    long pos = in.length() - TABLEPOS.length - OFFSETPATTERN.length() - 1;
    in.seek(pos);
    SimpleTextUtil.readLine(in, scratch);
    assert StringHelper.startsWith(scratch.get(), TABLEPOS);
    long tablePos = -1;
    try {
      tablePos = df.parse(stripPrefix(scratch, TABLEPOS)).longValue();
    } catch () ...

を見れば、scfファイルの最後から内容を取って来ているのが見て取れます。結果は、fileNames, StartOffsets, endOffsets の配列を生成していますね。これらの配列を使って、fileLength (長さ取得)・openInput (インプット取得, ポインター利用) などをしています。
この後は、それぞれ、coreFieldInfos は infファイル部分を、fields は pstファイル部分からフィールド情報を、fieldsReaderOrig は fldファイル部分から各文書のポインタ情報を、と情報を取得しています。

そして最後に StandardDirectoryReader に戻り、StandardDirectoryReader の初期化をしてその結果を返しています。ここまでが、DirectoryReader.open の概要でした。

IndexSearcher

最後に、

	 		IndexSearcher indexSearcher = new IndexSearcher(indexReader);
	    	//
	    	TopDocs results = indexSearcher.search(query, 10);
	    	System.out.println(results.totalHits + " documents found.");
    		for (ScoreDoc scoredoc : results.scoreDocs) {
    			Document doc = indexSearcher.doc(scoredoc.doc);
    			System.out.println("File: " + doc.get("content"));
    		}

の部分を読んでみましょう。主に見ていくのは、IndexSearcher の初期化部分 new IndexSearcher(indexReader) と、検索部分 indexSearcher.search(query, 10) です。

まずは、IndexSearcher の初期化を見てみましょう。
読んでみると、

this(r.getContext(), executor);

r.getContext() という部分に当たると思います。これは、DirectoryReader が継承している CompositeReader のメソッドで、CompositeReaderContext.create で Builder を開いています。それも、LeafReaderContext を初期化して返しているだけなので内容は簡単ですね。後は普通に初期化しているだけです。


では、search を見てみましょう。
search は、まず初見では searchAfterメソッド の CollectorManager が分かりづらいと思いますが、この中のオーバーライドした newCollector では 検索結果の文書ID から 結果の型である ScoreDoc を生成して、reduce では検索結果を TopDoc という型でまとめています。
search を全体で見ると、大きく分けて2つ大きなことをしている部分があります。
まず、ここにある createWeight

final Weight weight = createWeight(query, firstCollector.scoreMode(), 1);

次に、ここ にある scorer.score(...)

scorer.score(leafCollector, ctx.reader().getLiveDocs());

そして、1つ目の createWeight では ある単語が文書に出てくる回数などの統計情報を、2つ目の scorer.score(...) では クエリに対して実際に出てきた文書の情報を取得します。
ここではクエリは termQuery を使っていることで進めますが、それぞれで呼び出される重要なメソッドが、

reader.terms(field)

です。readerIndexSearcher.leaves から取って来ていますが、基本的には前に出てきた CompositeReaderContext.create で開いた Builder の内容を見れば分かりますね 単純に LeafReaderContext が入っているだけなのが分かりますね(ここら辺)。

この terms がどこから来ているのかが分かりづらいのですが、これは CodecReader から来ています。
内容は、下の通りになります。

  public final Terms terms(String field) throws IOException {
    ensureOpen();
    FieldInfo fi = getFieldInfos().fieldInfo(field);
    if (fi == null || fi.getIndexOptions() == IndexOptions.NONE) {
      // Field does not exist or does not index postings
      return null;
    }
    return getPostingsReader().terms(field);
  }

getFileldInfos は SegmentReader の部分で 上では省略しましたが si.info.getCodec().fieldInfosFormat(); の内容になります。
むしろ重要なのは、return getPostingsReader().terms(field); の部分になって、ここで 上で説明した core.fields を呼び出しています。その terms は ここ にあって、ここで SimpleTextTerms.loadTerms (コードはここ) を使って 単語(term)とそれに対応する 出て来た回数 と 出て来た文書数 を取得できるインデックスを作成しています。
インデックスを作成している様子は、ここのコード

            fstCompiler.add(
                Util.toIntsRef(lastTerm.get(), scratchIntsRef),
                outputs.newPair(
                    outputsOuter.newPair(lastDocsStart, skipPointer),
                    outputsInner.newPair((long) docFreq, totalTermFreq)));

から見てとることができます。
そこからこのインデックス fst を使って termsEnum.seekExact(term.bytes())) をして、interetor() から上の fst の構造体を取得しています。
これらの情報をもとに Weight を作ります。
ここまでが Weight 部分の説明でした。

次に、Score、つまり文書IDの一覧の取得の部分を読んでみます。
Score は void が返り値の search で計算されます。
leafCollector = collector.getLeafCollector(ctx); で Collector を初期化した後で、weight.bulkScorer で termWeight の scorer を呼び出します(コードはこちら)。
そうすると上の fst の時と同じように

final TermsEnum termsEnum = context.reader().terms(term.field()).iterator();

が呼ばれたのが見て取れると思います(コードはこちら)。
この Scorer の

            termsEnum.postings(
                null, scoreMode.needsScores() ? PostingsEnum.FREQS : PostingsEnum.NONE),

部分の posting が SimpleTextDocsEnum (コードだとここ) という readDoc が 文書ID(DOC) と頻出度(FREQ) を取得していきます。
そして、この readDoc を使った nextDoc を呼び出しているのが、defaultBulkScorer.score (コードはここ) で、ここの ScoreAll

        for (int doc = iterator.nextDoc();
            doc != DocIdSetIterator.NO_MORE_DOCS;
            doc = iterator.nextDoc()) {
          if ((acceptDocs == null || acceptDocs.get(doc)) && twoPhase.matches()) {
            collector.collect(doc);
          }
        }

で、nextDoc がある限り、collctor で docID を collect しています。
その後は、PriorityQueue の HitQueue で docID と score を ScoreDoc に変えて、最後の reduce で結果をまとめていますが、詳細は、

の記事に譲ります。


ここまでが、Lucene が Analyzer から IndexSearcher を実行するまでの流れでした。
ここまで読むのに毎日少しずつ読んで1ヶ月ちょっとかかったんですが、読んでみると意外と分かる内容でできていますね。
今後は、fst や set や AttributeSource など読み飛ばした部分を読むか、BooleanQuery など別の機能を読むか悩みどころです。

ここまでありがとうございました。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0