Lucene8:検索ヒット時に元文書のオフセット位置を取得する

LuceneのAPIはバージョンごとにコロコロと仕様が変わるので、検索してやり方を見つけても全く役に立たない場合がある。今回悩んだのは、検索がヒットした時に元文書のどの位置かを見出すことだ。本当に資料となるものが無い。諦めようかと思った位だが、以下のやり方を試してみた。使ったのはLucene 8.2.0である。

※もちろん、SolrやElasticSearchを使えば簡単なのかもしれない(試していない)が、今回はLuceneをアプリに組み込む前提で調査している。

オフセットを書き込む

まずは、インデックス付けの際に元文書のオフセットを同時に格納するように指示する。これは、TermVectorというところに格納されるようだ。通常、トークン化される文字列フィールドを格納するには、TextFieldを使うのだが、これではだめである。この上位を使わねばならない。

ちなみにTextFieldのコードを抜粋すると、以下である。

public final class TextField extends Field {
  public static final FieldType TYPE_NOT_STORED = new FieldType();
  public static final FieldType TYPE_STORED = new FieldType();
  static {
    TYPE_NOT_STORED.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS);
    TYPE_NOT_STORED.setTokenized(true);
    TYPE_NOT_STORED.freeze();

    TYPE_STORED.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS);
    TYPE_STORED.setTokenized(true);
    TYPE_STORED.setStored(true);
    TYPE_STORED.freeze();
  }
  public TextField(String name, String value, Store store) {
    super(name, value, store == Store.YES ? TYPE_STORED : TYPE_NOT_STORED);
  }
}

なんのことは無い、FieldTypeにフィールドの属性を設定してFieldのコンストラクタを呼び出しているだけである。したがって、独自のTextField2を作成し、以下の属性を与えてやればいい。

    TYPE_NOT_STORED.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS);
    TYPE_NOT_STORED.setTokenized(true);
    TYPE_NOT_STORED.setStoreTermVectors(true);  // これ
    TYPE_NOT_STORED.setStoreTermVectorOffsets(true);  // これ
    TYPE_NOT_STORED.freeze();

    TYPE_STORED.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS);
    TYPE_STORED.setTokenized(true);
    TYPE_STORED.setStored(true);
    TYPE_STORED.setStoreTermVectors(true);  // これ
    TYPE_STORED.setStoreTermVectorOffsets(true);  // これ
    TYPE_STORED.freeze();

書き込みでは、Documentを作成するが、そのドキュメントのフィールドを見てみると、TermVector, TermVectorOffsetsが格納されていることがわかる。

Document doc = .....ドキュメント作成
IndexedField fld1 = doc.getField("fld1")
System.out.println(fld1);

などとすると、

indexed,tokenized,termVectorOffsets<fld1:FLD1>

と表示される。

オフセットを取得する

以下を作成してみた。

      TopDocs hits = ....
      IndexReader reader = ...
      for (ScoreDoc scoreDoc : hits.scoreDocs) {
        Document doc = indexSearcher.doc(scoreDoc.doc);
        Offsets offsets = Offsets.create(reader, scoreDoc.doc, フィールド名集合) 

import java.io.*; import java.util.*; import java.util.stream.*; import org.apache.lucene.index.*; import org.apache.lucene.search.*; public class Offsets { Map<String, List<Offset>>map = new HashMap<>(); void add(String field, List<Offset>offsetList) { map.put(field, offsetList); } @Override public String toString() { return map.entrySet().stream() .map(e-> e.getKey() + "=" + e.getValue().stream().map(o->o.toString()).collect(Collectors.joining(","))) .collect(Collectors.joining("\n")); } public static Offsets create(IndexReader reader, int docId, Set<String> fields) throws IOException { Offsets offsets = new Offsets(); for (String field : fields) { System.out.println("" + reader.getDocCount(field)); Terms terms = reader.getTermVector(docId, field); if (terms == null) continue; TermsEnum termaEnum = terms.iterator(); Set<Offset> offsetSet = new HashSet<>(); while (termaEnum.next() != null) { PostingsEnum postings = termaEnum.postings(null, PostingsEnum.OFFSETS); while (postings.nextDoc() != DocIdSetIterator.NO_MORE_DOCS) { int freq = postings.freq(); for (int i = 0; i < freq; i++) { postings.nextPosition(); Offset offset = new Offset(postings.startOffset(), postings.endOffset()); offsetSet.add(offset); } } } offsets.add(field, offsetSet.stream().sorted().collect(Collectors.toList()) ); } return offsets; } }
public class Offset implements Comparable<Offset> {

  public final int start;
  public final int end;

  public Offset(int start, int end) {
    this.start = start;
    this.end = end;
  }

  @Override
  public int hashCode() {
    return start + end * 7;    
  }

  @Override
  public boolean equals(Object o) {
    if (!(o instanceof Offset)) return false;
    Offset that = (Offset)o;
    return this.start == that.start &&
          this.end == that.end;
  }

  @Override
  public int compareTo(Offset that) {
    return this.start - that.start;
  }

  @Override
  public String toString() {
    return start + ":" + end;
  }
}

しかし、どうも動作が不安定で、うまく行ってないようだ。

まとめ

インデックス付のときに、オフセットを同時に書き込まなければいけないのも無駄だし、そのオフセット値を取得する方法も全く資料がなかった。今回四苦八苦して試してみたが、結果はうまく行ってないように思われる。