関口宏司のLuceneブログ

OSS検索ライブラリのLuceneおよびそのサブプロジェクト(Solr/Tika/Mahoutなど)について
Lucene コミット時の動作(翻訳)
Lucene インデックスのコミット時の動作について詳述されたメール(原文)を見つけたのでその翻訳を掲載しよう。メールそのものは、Lucene コミッターの Uwe 氏が最近の Java 9 のコミットによってディレクトリへの fsync() 動作がうまくいかなくなったことについて最近の Lucene コミュニティの状況について説明し、さらにはコミュニティに解決方法を問うたものとなっている。

最近の Java 9 のコミット (e5b66323ae45) がディレクトリへの fsync を破壊した件について

こんにちは。

Apache Lucene コミッターを代表して投稿します。Apache Lucene/SolrとElasticsearchの両方を先ごろテストしたところHotspotまわりを中心に問題が見つかったことはご存知かと思います。そこで、JDK 9のプレビュー版ビルド40を利用するよう自分たちのテストインフラを先日アップデートしました。これは主にJigsawまわりの問題を確認することが目的でしたが、いくら頑張ってもこのビルドでは問題が発生しないのです。

ところが残念ながらOpenJDK 9の最近のコミットでは問題が発生しており(http://hg.openjdk.java.net/jdk9/jdk9/jdk/rev/e5b66323ae45)、それに相当するのがhttps://bugs.openjdk.java.net/browse/JDK-8066915です。この問題に関連し、自分たちでもhttps://issues.apache.org/jira/browse/LUCENE-6169というスレッドを立てました。

まず現状を説明します。Apache Luceneは write once アプローチ(すべてのファイルで一度だけ書き込みが行われる)を使っています。Luceneの所定の「コミットポイント」で「コミット」する場合は一時的なファイル名に書き込み、それからこのファイル(およびすべての関連ファイル)でfsyncを実行する形になります。これはファイルチャネルではfc.force()をコールするだけ簡単です。このコミットの最終的な「パブリッシュ」は「Files.move(Path, Path, StandardCopyOption.ATOMIC_MOVE)」を使ったアトムのリネームで行われます。

この方法は停電のような大きな問題でもない限りうまくいきますが、そのような問題が発生すると、POSIX OSではリネーム処理が全く見えなくなる可能性があります。LinuxのMANページ(http://linux.die.net/man/2/fsync)にはすべてに以下の記述があります。

「fsync() のコールはファイルのあるディレクトリ内のエントリがディスクに書き込まれたことを必ずしも保証するものではない。そのため、ディレクトリのファイル記述子にも fsync() を明示する必要がある。」

基本的に、現在はApache Luceneでも以下の手順で同じことをしています。ディレクトリ上でFileChannel (READ用)をオープンし、 fc.force() をコールします。もちろん、(動作することは以前から分かっているものの)実際にはJava APIにこの記述がないため、われわれは「有利な推測」に基づいてこれを行うことになります。Windowsではうまくいかないなど(*)、IOExceptionの例外は必ず発生します。

そこで問題になるのが、前述のコミットではこのアプローチがOpenJDK 9のFileSystemExceptionでうまくいかないことです。FileSystemExceptionはディレクトリです。社外のLuceneリリースは(前述のように)例外をすべて受け入れるため大丈夫なのですが、テストインフラでは、少なくともLinuxとMacOSXでは動いてしまうのです。

最新のコードはhttp://goo.gl/vKhtsWにあります。

どうしてもサポートされているOSのディレクトリでfsyncができるようにしておきたいのです。前述のコミットを8u40や7u80のリリースには戻したくないのです。Java 9なら代替案を検討できるのですが。

  • Java 7やJava 8の既存のセマンティクスを残し、どうしてもFileChannelからREAD/WRITEをしたい場合は単純にエラーを発生させるのでしょうか?基盤OSとlibcはこのようにして処理を行っています。 ファイル、ディレクトリ、デバイスなど、ファイル記述子はどこでもオープンできますが、この記述子ではできない処理もあり、例外やエラーが発生する場合もあります。
  • ディレクトリをfsyncするための新しいAPIを追加する(どのファイルタイプでもおそらく可能)。Files.fsync(Path)のようにでしょうか? Windowsではディレクトリではこれがうまくいかないのでしょうか?上記のリンクにあるIOUtils.fsync()のような形です。

意見や対応方法に関するアドバイスを下さい。

Uwe

*)ただ、このファイルシステムのセマンティクスはファイルが確実に見えるようにしているため、実際のところは問題ではありません。話がちょっとそれます(ディレクトリを読み込み用に開くと「Access Denied」がJava IOExceptionにマッピングされますが、これは全く問題ありません)。

| 関口宏司 | Luceneクラス解説 | 10:27 | comments(0) | trackbacks(0) |
StatsComponent の標準偏差について
Solr の StatsComponent では標準偏差(stddev)が表示されるが、それは同じくStatsComponentで計算されるsumOfSquares を使った、次の近似式で計算されている:

  /**
   * Calculates the standard deviation statistic
   * 
   * @return Standard deviation statistic
   */
  private double getStandardDeviation() {
    if (count <= 1.0D) {
      return 0.0D;
    }

    return Math.sqrt(((count * sumOfSquares) - (sum * sum)) / (count * (count - 1.0D)));
  }


なぜ近似式を使っているかというと、forループを一度回すだけで計算したいためだ。分散や標準偏差を定義通りに計算しようとすると、最初に平均を求め(これでforループを一回回す必要がある)、その平均を使って各値との差の2乗を足していく必要がある(これでforループがもう一度必要)。StatsComponentではforループを一度しか回したくないので、その一回だけでsum(合計)、sumOfSquares(各値の2乗の合計)、min(最小)、max(最大)およびcount(件数)を計算または取得する。そして最後にmean(平均)と標準偏差を近似式で計算している。

そしてその近似式であるが、こちらの方のブログによると、あまりよろしくない結果を出してしまうようである。

これよりも良い近似式で同じく1パスで計算する方法がこちらに紹介されているが、特に私は今のところ急いで正確な標準偏差が必要になるわけでもないので、備忘録を残しておくだけにとどめておこう。
| 関口宏司 | Luceneクラス解説 | 16:54 | comments(0) | trackbacks(0) |
Lucene 4.4 の FST を使って FSA を構築する
Lucene 4.4(別に4.4と特定することもないのだが)のFSTを使ってFSA(有限状態オートマトン)を実装する機会があったので、簡単に使い方を紹介しよう。ここでは次のような単語をFSAに登録し、あとからFSA辞書に単語があるかどうかを問い合わせられるプログラムを作成する。

String[] KEYS = {"あいだ", "あいぼう", "あたぼう", "いいだ"};


プログラムは次の通りで、非常に簡単である。

public class TestFSA {

  public static void main(String[] args) throws IOException {
    String[] KEYS = {"あいだ", "あいぼう", "あたぼう", "いいだ"};     // (1)
    final Object outputValue = new Object();                          // (2)
    
    NoOutputs outputs = NoOutputs.getSingleton();                     // (3)
    Builder<Object> builder = new Builder<Object>(INPUT_TYPE.BYTE4, outputs);    // (4)
    BytesRef scratchBytes = new BytesRef();
    IntsRef scratchInts = new IntsRef();
    for (String key : KEYS) {                                         // (5)
      scratchBytes.copyChars(key);
      builder.add(Util.toIntsRef(scratchBytes, scratchInts), outputValue);
    }
    FST<Object> fst = builder.finish();                               // (6)

    getAndPrint(fst, "あかだ");                                       // (7)
    getAndPrint(fst, "いまだ");
    getAndPrint(fst, "いいだ");
    getAndPrint(fst, "いいだあ");
    getAndPrint(fst, "いい");
    getAndPrint(fst, "あたぼう");
  }
  
  static void getAndPrint(FST<Object> fst, String key) throws IOException {
    Object value = Util.get(fst, new BytesRef(key));
    System.out.printf("%s is %s¥n", key, value == null ? "not accepted" : "accepted");
  }
}


以下簡単にプログラムの説明をする。
  1. FSA辞書に登録する単語一覧である。(5)のfor文の中でこの順番に登録されるが、あらかじめソートされている必要がある。
  2. FSTはもともと登録する単語に関連した値(出力)を持たせることができるが、今回は単なるFSAとして使うので、出力値は不要である。しかし、nullは指定できないので、ここで固定にダミーのObjectを生成している。
  3. 出力の不要なFSAではNoOutputsを取得する。
  4. (3)で取得したoutputsを指定してFSTのbuilderを取得する。
  5. (1)の単語をbuilderにadd()する。
  6. 単語を登録し終わったら、builderからFSTを生成する。
  7. FSA辞書を使って単語を引いてみる。実際にはfstパッケージ内のUtilクラスのget()メソッドを使って、出力を得ている。単語がない場合(FSAが文字列を受理しない場合)、結果はnullとなる。nullでなければ単語は辞書に登録されている。


実行結果は次のようになる。プログラムのコンパイルと実行には、lucene-core-4.4.0.jarがあればよい。

あかだ is not accepted
いまだ is not accepted
いいだ is accepted
いいだあ is not accepted
いい is not accepted
あたぼう is accepted
| 関口宏司 | Luceneクラス解説 | 17:13 | comments(0) | trackbacks(0) |
64ビットプラットフォームで絶対お勧めのLuceneのMMapDirectory (Uwe氏のブログより)
前々回にUweさんのブログを紹介したが、その際にUweさん本人より「MMapDirectoryの記事もお奨めだよ!」と背中を押されたので、ここに日本語訳を掲載する:
心配無用 - よくありがちな誤解を解く

Apache LuceneとSolrは、バージョン3.1では64ビット版WindowsおよびSolarisの両システム、バージョン3.3では64ビットLinuxシステムにおいてMMapDirectoryをデフォルトで採用している。ただし、システムがここ数バージョンとは異なる動作をするようになったため、今回の変更にLuceneとSolrのユーザが混乱している。LuceneとSolrのメーリングリストでは、消費する物理メモリが突然3倍になった理由に疑問を抱くユーザや、リソース使用量に不満を訴えるシステム管理者の投稿が多数見られる。また、MMapDirectoryの代わりに、動作の遅いSimpleFSDirectoryやNIOFSDirectory(JVMバグ#6265734に起因してWindowsではさらに遅い)を有効にするようsolrconfig.xmlの設定を推奨する意見がコンサルタントからも出始めている。慎重に検討した結果これらのプラットフォームではMMapDirectoryの使用がベストだと判断したLuceneコミッターからすれば、これはどちらかと言えば不快な反応になる。Lucene/Solrがこれまでより格段に優れたパフォーマンスを発揮することが分かっているからだ。今回の変更の背景に関して間違った情報が出回っているため、この素晴らしい検索エンジンが最適でない形で各所にインストールされてしまう。

このブログでは、カーネル内の仮想メモリ処理に関する基本的な事実と、それを主にLuceneのパフォーマンス向上に利用するための方法について説明しようと思う(「仮想メモリ超入門」である)。また、さまざまな人々が投稿するブログやメーリングリストが間違っていて、MMapDirectoryの用途に矛盾するという理由についても明らかにしていく。後半では、mmapエラーなどや、Javaのこなれないヒープアロケーションに起因した最適でないパフォーマンスの回避に必要な詳しいコンフィギュレーションと必要な設定を紹介する。

仮想メモリ[1]

まずOSのカーネルを考えてみたい。ソフトウェアでI/O処理を行うのは無知なアプローチだ。これは1970年代から行われており、そのパターンは単純なものだ。ディスク上のデータを処理するときは常にシステムカーネルでsyscallを実行し、ポインタを何らかのバッファ(例:Javaのbyte[]アレイ)に渡し、ディスクとの間でバイトをやりとりし、バッファの中身をパースしてプログラムを実行する。(処理能力を消費しすぎる可能性があることから)あまり多くのsyscallを行いたくない場合はソフトウェアでバッファを大きく取って内部でのディスクとのデータ同期を減らすようにする。(RAMDirectoryを使用するなどして)Luceneインデックス全体をJavaのヒープメモリに読み込むようアドバイスするのにはこのような理由もある。

しかし、Linux、Windows(NT以降)、MacOS X、あるいはSolarisといったモダンOSはすべて、洗練されたファイルシステムキャッシュとメモリ管理機能を使用することで1970年代式のコードより格段に優れたアプローチを用意している。「仮想メモリ」と呼ばれるこの機能は、Luceneインデックスのように巨大で広大な空間を占めるデータ構造処理の優れた代替手段となっている。仮想メモリはコンピュータアーキテクチャに不可欠な部分となっており、インプリメンテーションにはハードウェアサポートが必須で、MMU(メモリ管理ユニット)としてCPUに組み込まれているのが一般的だ。その仕組みはかなりシンプルで、各プロセスが独自に仮想アドレス空間を取得し、そこにライブラリ、ヒープ、そしてスタックの各空間をマッピングする。だいたいの場合、このアドレス空間はオフセット0から始まり、アドレスポインタの再配置が一切不要であるためプログラムコードの読み込みが簡単になっている。どのプロセスからも断片化していない連続した大きなアドレス空間が作業空間として見える。このアドレス空間は物理メモリと一切関連性がないことから「仮想メモリ」と呼ばれ、プロセスからもそのように見える。この広大なアドレス空間は、ソフトウェアを使って実メモリであるかのようにアクセスできる。ほかのプロセスもメモリを消費し、独自に仮想アドレス空間を持っていることは意識する必要がない。基盤OSはCPU内のMMUと連携し、最初のアクセス時にこれらの仮想アドレスを実メモリにマッピングする。これはページテーブルと呼ばれるもので行われ、それがMMUハードウェア内のTLB(頻繁にアクセスされるページをキャッシュする変換索引バッファ)によってバックアップされている。こうすることで、OSは実行中のすべてのプロセスの必要メモリを利用可能な実メモリに分散し、実行中のプログラムから完全に透過的に見えるようにすることができる。

仮想メモリ図(Wikipediaより引用[1]、http://en.wikipedia.org/wiki/File:Virtual_memory.svg。CC BY-SA 3.0許諾)
仮想メモリ図(Wikipediaより引用[1]、http://en.wikipedia.org/wiki/File:Virtual_memory.svg。CC BY-SA 3.0許諾)

この仮想化を利用すればOSはもう1つのことができるようになる。物理メモリが十分にないときは、プロセスが使わなくなったページを「スワップアウト」したり、物理メモリを別のプロセス用に解放したり、もっと重要なファイルシステム操作をキャッシングできるようになる。ページアウトされた仮想アドレスにプロセスがアクセスしようとすると、それがメインメモリに再ロードされ、プロセスが利用可能になる。プロセスは何もする必要がなく、完全に透過的になっている。これは利用可能なメモリ容量の把握が一切必要ないためアプリケーションにとっては素晴らしいことだが、メモリの負荷が非常に高いLuceneのようなアプリケーションでは問題にもつながる。

Luceneと仮想メモリ

ここで、インデックス全体もしくはその大半を「メモリ」(これが仮想メモリにすぎないことは先刻承知だ)に読み込む例を見ていく。RAMDirectoryをアロケートし、そこにインデックスファイルをすべて読み込むとOSの足を引っ張ることになる。OSはディスクアクセスを最適化しようとするため、すべてのディスクI/Oが物理メモリにキャッシング済みだ。これらのキャッシュの内容をすべて自分の仮想アドレス空間にコピーして膨大な量の物理メモリを消費している(しかも、コピー処理の開始を待たなくてはならない)。物理メモリには制限があるので、OSはもちろん大きなRAMDirectoryをスワップアウトする判断を下す必要があるのだが、それはどこに行くのだろうか?再びディスク上(OSのスワップファイル上)に戻るのだ。実際、われわれはディスクから読み込むものすべてをページアウトさせるOSカーネルと格闘している[2]。したがって、インデックスの読み込み時間最適化にRAMDirectoryは得策ではない。加えて、RAMDirectoryの方がガーベッジコレクションや並行処理関連で問題が多いのだ。スワップ領域にデータがあるため、自分のヒープ管理下にあるメモリの解放はJavaのガーベッジコレクタにとって大変な作業となる。そのためにディスクI/Oが増加し、インデックスアクセス速度が低下し、ガーベッジコレクタの動作が原因で検索コードの待ち時間が分単位に達する。

一方、もしインデックスのバッファリングにRAMDirectoryを使わずNIOFSDirectoryやSimpleFSDirectoryを使うと、ディスクやファイルシステムのキャッシュとJavaヒープ内のバッファとの間でデータブロックをコピーするためにコードがOSカーネルに対して多数のsyscallを行う必要が出るなど、さらに代償を払わなくてはならなくなる。検索リクエストがある度に何度もくり返して行う必要があるのだ。

メモリマッピングファイル

前述の問題に対するソリューションが、仮想メモリと「mmap」[3] というカーネル機能を使ってディスクファイルにアクセスするMMapDirectoryだ。

先のアプローチでは、ファイルシステムキャッシュとローカルJavaヒープの間でデータをコピーするのにsyscallに頼っていたが、ファイルシステムキャッシュに直接アクセスすればいいのではないか。これこそまさにmmapの仕事なのだ。

基本的に、mmapはLuceneインデックスをスワップファイルとして処理するようなことをする。mmap () syscallはOSカーネルに指示を出し、インデックスファイル全体を前述の仮想アドレス空間に仮想的にマッピングし、それらをLuceneのプロセスが利用可能なRAMのように見せる。そうすれば、大きなbyte[]アレイ(JavaではJavaコードが安全に利用できるようByteBufferインターフェースによってカプセル化される)になるかのようにディスク上のインデックスファイルをアクセスできるようになる。Luceneのコードからこの仮想アドレス空間にアクセスすればsyscallsは一切不要で、プロセッサのMMUとTLBがマッピングをすべて代わりに処理してくれる。データがディスク上にしかない場合は、MMUが割り込みを発生させ、OSカーネルがデータをファイルシステムキャッシュに読み込む。既にキャッシュされている場合は、MMU/TLBがそれをファイルシステムキャッシュ内の物理メモリに直接マッピングする。これでネイティブメモリアクセスに変わることになる。バッファのページイン/ページアウトを気にする必要はなく、これらはすべてOSカーネルによって処理される。さらに、並行処理の問題も全くなく、標準のbyte[]アレイに対する唯一のオーバーヘッドはJavaのByteBufferインターフェースによって生じるラッピングだけとなる(それでも本物のbyte[]アレイより速度は落ちるが、Javaからmmapを使う方法はこれしかなく、Luceneに付属するどのディレクトリインプリメンテーションよりもこちらの方がはるかに高速だ)。また、OSキャッシュ上で直接処理を行うため物理メモリも全く無駄にせず、前述のJava GC問題がすべて回避される。

Lucene/Solrアプリケーションへの影響は?

OSの足を引っ張るような開発は避けるべきなので、ヒープスペース(-Xmx Javaオプション)のアロケートはできるだけ小さく抑えたい。また、インデックスアクセスがOSキャッシュへの直接渡しに依存することを覚えておきたい。これは、Javaのガーベッジコレクタともかなり相性が良い。

OSカーネルがファイルシステムキャッシュとして利用できるよう可能な限り多くの物理メモリを解放する。また、Luceneのコードがそこで直接動作するためディスクとメモリ間のページング/スワッピング回数が減少することを覚えておきたい。ただし、Luceneアプリケーションにヒープをアロケートしすぎるとパフォーマンスに支障が生じる。MMapDirectoryではLuceneがこれを必要としないのだ。

これが64ビットのOSやJava仮想マシンでしか予想通りの動作をしないのはなぜだろうか?

32ビットプラットフォームの制限の1つに、ポインタサイズがあり、ポインタは4 Gバイトである0から232-1までのアドレスにしかアクセスすることができない。大半のOSは残りのアドレス空間をデバイスなどのために予約しておくため、アドレス空間を3 Gバイトに制限している。つまり、プロセスに与えられるリニアアドレス空間の合計は3 Gバイトに制限され、これより大きいファイルは大きいbyte[]アレイとしてこの「小さい」アドレス空間にマッピングできないことになる。そして、この大型ファイルをマッピングすると、仮想空間(「番地」に相当するようなアドレス)はなくなってしまう。既存のシステムの物理メモリサイズがこの大きさを既に超えていることから、リソースを無駄にせずファイルをマッピングするアドレス空間はない(ここでは物理メモリではなく「アドレス空間」)。

だが、64ビットプラットフォームでは異なってくる。264-1は巨大な数字で、1800京バイトを越えるため、実際のところはアドレス空間に制限はない。残念ながら、大半のハードウェア(MMU、CPUのバスシステム)やOSはこのアドレス空間をユーザモードアプリケーション(Windows:43ビット)用に47ビットに制限している[4]。しかし、それでもTバイト単位のデータをマッピングするためのスペースは残っている。

一般的な誤解

仮想メモリに関するこれまでの説明を注意深く読んでいれば、以下が正しいことは簡単に検証できる。

MMapDirectoryは余計なメモリを消費せず、マッピングされたインデックスファイルのサイズはサーバが搭載する物理メモリの制限を受けない。mmap ()ファイルではメモリではなくアドレス空間だけを予約する。64ビットプラットフォームのアドレス空間は無償で利用できることを覚えておきたい。

MMapDirectoryはインデックス全体を物理メモリに読み込むことはしない。では、なぜそうしなければならないのだろうか?OSには、容易なアクセスを可能にするためファイルをアドレス空間にマッピングするよう要求しているだけで、それ以上の要求は決してしていない。JavaとOSはファイル全体をRAMに読み込むオプション(十分な余裕があれば)を任意で提供するが、Luceneはこのオプションを使わない(今後のバージョンで追加の可能性はある)。

MMapDirectoryは「top」から膨大な量のメモリが伝わるとサーバに過負荷を与えない。「top」(Linux)にはメモリ関連の3つのコラムがある「VIRT」、「RES」、および「SHR」だ。最初(VIRT、仮想)のものはアロケートされた仮想アドレス空間を伝える(さらに、これは64ビットプラットフォームでは無償だ)。IndexWriterでマージが行われている場合、この数字はインデックスサイズや物理メモリの数倍にもなる。IndexReaderが1つしかオープンされていない場合は、アロケートされたヒープスペース(-Xmx)にインデックスサイズを加えたものとほぼ同じになり、プロセスが使用する物理メモリにはならない。2番目のコラム(RES、常駐)のメモリはプロセスが処理用にアロケートした(物理)メモリ量を示し、Javaヒープスペース以下に収まる。最後のコラム(SHR、共有)はアロケートされた仮想アドレス空間がほかのプロセスと共有しているサイズを示す。MMapDirectoryを使用する複数のJavaアプリケーションが同じインデックスをアクセスしている場合はこの数字が大きくなっていく。一般的には、共有システムライブラリ、JARファイル、そしてプロセス実行イメージ本体(これもmmapされる)が必要とするスペースが見えるようになる。

MMapDirectoryを最適利用するためのmyOSand Java VMのコンフィギュレーション

まず第一に、LinuxディストリビューションとSolaris/Windowsのデフォルト設定は全く問題ない。しかし、(理解不足から)何でも自分でコントロールしたがる偏執的なシステム管理者もおり、これによりアプリケーションがアロケート可能な仮想アドレス空間の最大量が制限を受けてしまう。そこで、「ulimit -v」「ulimit -m」の両方が「unlimited」を表示することを確認したい。そうしないと、MMapDirectoryがインデックスのオープン時に「mmap failed」を表示する可能性がある。それぞれのセグメントが多い膨大な数のインデックスを持つシステムでもこのエラーが発生する場合は、/etc/sysctl.confのカーネルパラメータをチューニングする必要があるかもしれない。vm.max_map_countのデフォルト値は65530だが、これを引き上げる必要があるかもしれない。WindowsとSolarisの両システムでも同様のセッティングが可能だと思うが、その使用方法は読者ご自身でお調べいただきたい。

Java VMのコンフィギュレーションに関してはメモリ要件を検討し直したい。ヒープスペースは本当に必要な量だけ用意して、OSにはできるだけ多くを残しておきたい。経験則として、Lucene/Solrが動作するJavaに物理メモリの四分の一以上をヒープスペースとして使ってはならず、残りのメモリはOSのキャッシュ用に空けておきたい。サーバ上で動作するアプリケーションが増えたらそれに合わせて調整を行う。従来通り、物理メモリは多ければ多い方が良いが、インデックスサイズと同量までの物理メモリは不要だ。頻繁に使用されるページはカーネルがインデックスからうまくページインしてくれる。

システムを最適にコンフィギュレーションしたかどうか調べるには、「top」(正しい説明は前記参照)と同様のコマンドである「iotop」(Ubuntu Linuxなどでは「apt-get install iotop」でインストール可能)の両方を見る。Luceneプロセスでシステムのスワップイン/スワップアウトが多い場合は、おそらく使いすぎなのでヒープサイズを減らす。ディスクI/Oが多い場合はmmappedされたファイルが四六時中ページイン/アウトされないようRUM(Simon Willnauer語録)を追加購入したい。そして、最終手段はSSDになる。

mmapをお楽しみあれ。



| 関口宏司 | Luceneクラス解説 | 23:15 | comments(0) | trackbacks(0) |
Lucene 4.0で実施されたIndexReaderの主なリファクタリング(Uwe氏のブログより)
ある案件でLucene 4.0におけるIndexReaderの変更点について調べる必要があった。いろいろ調べているうちにLuceneコミッターのUwe Schindler さんのブログに行き着いた。こちらの記事が変更における背景などに触れていてなかなかよかった。どうせなら日本語訳を掲載したいと思いUweさんに聞いたところ、快諾をいただけたので、以下に拙訳を掲載する:
本記事はSearchWorkingsに掲載された記事の再掲です。

Luceneは誕生当初よりIndexReaderとIndexWriterのAPIを通じてインデックスを直接的に読み書きできるようにしていました。しかし、APIは現実を反映していませんでした。過去にはIndexWriterからの視点ではこれは価値がありましたが、インデックスを読むときにこれはいくつかの問題の原因となっていました。Luceneインデックスは論理的には単一ですが実際にはそうではありません。最新のLucene trunkの開発ではタイプセーフと性能のために現実に触れられるようにしようとしています。しかし、Composite、AtomicそしてDirectoryReaderの詳細に入る前に、少しこれまでの経緯をおさらいしてみましょう。

バージョン2.9/3.0以降、LuceneはトップレベルのIndexReaderを直接検索実行することからセグメント毎に検索する方法に舵を切りました。Simon Willnauerさんが彼のブログで説明しているとおり、これによりoptimizeはもはや検索性能を最適化するのには必要ではなくなったことを意味します。実際、optimizeは検索を遅くし、最適化後はファイルシステムとLuceneインデックス内部のキャッシュを無効化してしまいます。

標準のLuceneインデックスはいくつかのセグメントと呼ばれるものから成ります。セグメントはそれ自体小さなインデックスとみなせます。インデックス作成中はLuceneは新しいドキュメントを分割されたセグメントに書き出します。ある閾値にセグメント数が達すると、セグメントはマージされます(マージをビジュアルに表現したMike McCandlessさんのブログはこちら):

Luceneのマージのしくみ

Lucene 2.9より以前は、インデックスが複数のセグメントに分かれていようと、セグメントは1つの大きなインデックスのように扱われていました。しかし2.9からはセグメント毎に働くようにシフトしてきました。そして現在は、Luceneのほとんどすべての構成とコンポーネントはセグメント毎に動作するようになっています。とりわけこれによりLuceneは再オープン時にインデックスのすべてではなく、実際の変更分のみをロードします。それでも利用者からは1つの大きな論理インデックスに見えます(下記IndexSearcherのコード例参照)が、内部ではセグメント毎に動作しています:

public void search(Weight weight, Collector collector) throws IOException {  
  // iterate through all segment readers & execute the search  
  for (int i = 0; i < subReaders.length; i++) {  
    // pass the reader to the collector  
    collector.setNextReader(subReaders[i], docStarts[i]);  
    final Scorer scorer = ...;  
    if (scorer != null) { // score documents on this segment  
      scorer.score(collector);  
    }
  }  
}


しかし、論理インデックスとセグメント間の差異は一貫的にコード階層には反映されていませんでした。Lucene 3.xでは、まだサブReaderをイテレートせずトップレベル(論理)Readerにて検索を実行していたのでした。そのため複数セグメントからなるインデックスでは検索は劇的に遅くなりました。そういうことで昔のLuceneではインデックスをよくoptimizeするよう指導されていました。

もう少し詳しく問題を説明しましょう。DirectoryのIndexReaderは内部的にはSegmentReaderを内包したMultiReaderです。もしMultiReaderにTermEnumやポスティングを問い合わせるとそれはオンザフライでサブReaderのタームやポスティングをマージします。このマージ手続きはプライオリティキューまたは類似のデータ構造を用い、それによりサブReaderの数に応じて深刻な性能劣化を引き起こします。

このような内部的な制限にもかかわらず、MultiReaderと組み合わせてSegmentReaderを利用することはLuceneにおいて上位レベルの構造に影響を与えました。インデックスを非転置にしたFieldCacheは検索時にインデックスされた値によって検索結果をソートしたりドキュメント/値のルックアップに使われます。トップレベルReaderを非転置にするとFieldCacheの二重化を引き起こし、同じ内容のキャッシュを複数生成してしまいます。

Lucene 4.0のタイプセーブIndexReader

Lucene 4.0は当初からタームとポスティングデータをMultiReaderまたはDirectoryReader(ディスク上のインデックスをIndexReader.open(Directory)でオープンしたときに返されるReaderの実装)などのコンポジットなReaderから取得できないように設計されました。Lucene trunkの最初のバージョンは非SegmentReaderからFieldやTermsEnumやDocsEnumを取得しようとすると単純にUnsupportedOperationExceptionを投げていました。このタイプセーフの欠如のせいでプログラマーはIndexReaderがcompositeかatomicかをチェックしなければポスティングを取得できるかどうか判断できませんでした。

Lucene 4.0における主なAPI変更のひとつであるLUCENE-2858では、インデックスとそのセグメント上のLuceneクライアントコード視点を完全に変更しました。抽象クラスIndexReaderは検索結果を表示するのにストアフィールドをアクセスするのに本質的なメソッドだけを持つようにリファクタリングされました。それはもはやインデックスからタームまたはポスティングを取得することはできません。削除ドキュメントに関してももはや不可視です。Luceneはクエリリライトやドキュメント収集といった手続きを自動的にサブReaderに委譲します。

もしインデックスの詳細に立ち入り自分のクエリを書きたいのなら、新しい抽象サブクラスであるAtomicReaderまたはCompositeReaderを詳しく調べてください:

AtomicReaderインスタンスは現時点でTerm、ポスティング、DocValueおよびFieldCacheの唯一のソースです。クエリはセグメント毎のアトミックなReader上で実行されるようになり、FieldCacheはAtomicReaderによってキーづけされます。対するCompositeReaderはそれが保持するメンバーを取得するユーティリティメソッドを持っています。しかし気をつけてください、そのメンバーはアトミックである必要はありません。タイプセーフの次に私たちが行ったのは、抽象クラスIndexReaderからIndexCommitとバージョン番号の概念を取り除いたことでした。これらのIndexWriterと関連する機能はDirectoryReaderに移行されました。Lucene trunkにおけるクエリの実行は以下のようになります:

DirectoryReader reader = DirectoryReader.open(directory);  
IndexSearcher searcher = new IndexSearcher(reader);  
Query query = new QueryParser("fieldname", analyzer).parse(“text”);  
TopDocs hits = searcher.search(query, 10);  
ScoreDoc[] docs = hits.scoreDocs;  
Document doc1 = searcher.doc(docs[0].doc);  
// alternative:  
Document doc2 = reader.document(docs[1].doc);


見慣れたコードですね?実際、API利用者にはこの主なリファクタリングは大きな変更をもたらしません。もしアップグレード中にコンパイルエラーが起こったら、それは性能ボトルネックを示している可能性があります。

Filterにおけるセグメント毎セマンティクスの強制

もしカスタムFilterを使ったコードを持っているなら、Luceneの別の新しいクラス階層に気づいているかもしれません(LUCENE-2831参照):IndexReaderContextの仲間であるAtomic/CompositeReaderContextです。これはだいぶ前に追加されましたが、AtomicやCompositeのReaderたちと関連深いものです。

セグメント毎検索に向かうLucene 2.9ではそれを扱えない多くのカスタムクエリとフィルタが開発されました。たとえば、いくつかのFilter実装は渡されたIndexReaderをIndexSearcherへ渡されたIndexReaderと同等であると期待しました。明らかにこのパラダイムシフトは多くのアプリケーション、とりわけこれらクロスセグメントのデータ構造を利用するアプリケーション(Apache Solrのような)を壊しました。

Lucene 4.0では、私たちはIndexReaderContextというSearcherプライベートなReader階層を導入しました。クエリやフィルタを実行中、Luceneはもはや生のReaderをQuery、FilterもしくはCollectionに渡さなくなりました。その代わり、コンポーネントはAtomicReaderContext(本質的な階層葉)を通じてトップレベルReaderに関連したドキュメントベースのような関連プロパティとして用意されます。これによりクエリとフィルタはセグメント毎であるにもかかわらずドキュメントベースIDのロジックにビルドアップされます。

トップレベルReaderはまだ使えるか?

それでもまだトップレベルReaderを使う正当なユースケースはあります。たとえば、インデックスへのアトミックなビューが欲しい場合です。オートコンプリートやファセットなどで完全なインデックスのすべてのタームが欲しい場合があげられます。このためLuceneはAtomicReaderをエミュレートするSlowCompositeReaderWrapperのようなラッパーユーティリティを用意しています。注意:アトミックなエミュレーターの利用は、タームやポスティングやDocValueやFieldCacheのマージが必要になり深刻なスローダウンの原因となるので注意してください。

Terms terms = SlowCompositeReaderWrapper.wrap(directoryReader).terms(“field”);


残念なことに、Apache Solrはこういったコードを多く使っています。Solrのファセットやフィルタリングはアトミックなセグメント毎に動作するように書き換えられなければなりません!これらのプラグインやコンポーネントはSolrIndexSearcherが提供しているアトミックなビューをSolrIndexSearcher.getAtomicReader()を通じて取得しています。

もしメモリ効率がよく速い検索アプリケーションを書きたいのなら、Solr 4.0を使うのではなくLuceneが提供している新しいファセットモジュールとSearcherManagerを使って独自のアプリケーションを書くことをお奨めします!

| 関口宏司 | Luceneクラス解説 | 19:14 | comments(0) | trackbacks(0) |
KeywordAttribute について
近くの人に質問されたのでそれをネタに簡単に解説。

KeywordAttributeはトークンが「キーワードである」ということを下流のフィルターチェインに伝達するために使われる。

上流のTokenFilterなりTokenizerが任意のトークンのKeywordAttributeのsetKeyword(true)を呼んで「キーワードである」ことの印をつけ、下流のTokenFilterが処理しようとするトークンが「キーワードかどうか」を判断して自分の処理をすべきかどうかをisKeyword()を呼んで決める。

大抵は、lucene-gosenなどのJapaneseKatakanaStemFilterなどのstemmerでisKeyword()が呼ばれ、キーワードでない場合にステミング処理をしよう、という具合に使われる。

たとえば英語のstemmerはある条件下で末尾の"s"を削除するが、これを今は亡きsolrjs(SolrのJavaScriptクライアントAPI)に適用すると、末尾の"s"がとれてsolrjとなってしまい、これは今も健在のSolrのJavaクライアントAPIというまったく別物に変換されてしまう。これではまずいので、「solrjsはキーワードである」と印をつけておけば(setKeyword(true))、これは英語のstemmerで処理されなくなる。

Solrでは、schema.xmlにてKeywordMarkerFilterFactoryを使ってprotectedに「キーワード一覧」を記したファイル名を指定することができる。

なお、通常solrjsはキーワードであると印がつけられていたりするわけではないので、(Luceneが使われている)Apacheのバグチケット管理システムJIRAなどでsolrjsを検索すると、意図しないステミング処理がされてsolrjが多数ヒットしてしまう。これを避けるには、末尾に"*"をつけて"solrjs*"とする。するとLuceneのクエリパーサーの仕様により、ワイルドカード記号がつけられた文字列はAnalyzerをスキップするので、うまく"solrjs"を検索できる。

早くもSolr 3.5に対応したロンウイットのSolrトレーニング・・・Solr 3.5 1月 トレーニング受講者募集中

| 関口宏司 | Luceneクラス解説 | 10:27 | comments(0) | trackbacks(0) |
IndexSearcher.close() の削除(4.0)
Lucene 4.0 でNo-op(空実装)であったIndexSearcherクラスのclose()メソッドが削除された。

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

Lucene 3.5の時点で、IndexSearcherは自らIndexReaderインスタンスを作成して検索する方法がdeprecatedとなっており、プログラマが別途作成したIndexReaderをIndexSearcherのコンストラクタに渡す方法が推奨されていた。同時にこのときの修正でtrunk(4.0)のIndexSearcherから、IndexReaderを引数に取らないコンストラクタが完全に削除された。

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

今回はこれをさらに推し進めた形となった。

早くもSolr 3.5に対応したロンウイットのSolrトレーニング・・・Solr 3.5 1月 トレーニング受講者募集中

| 関口宏司 | Luceneクラス解説 | 11:04 | comments(0) | trackbacks(0) |
FieldValueFilter (3.6)
とにかくそのフィールドに値が入っていたら(インデックスされていたら)OK、というFilterクラス、FieldValueFilterがLucene 3.6/4.0に追加された:

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

使い方は、FieldValueFilterのコンストラクタに調べたい対象フィールド名を渡してFilterオブジェクトを作成し、IndexSearcherのsearch()メソッドに渡せばよい。

プログラム例
public final class TestFieldValueFilter {

  static final String INDEX = "index";
  static final String F_ID = "id";
  static final String F_VALUE = "value";
  static Analyzer analyzer = new WhitespaceAnalyzer(Version.LUCENE_36);
  static Directory dir;

  public static void main(String[] args) throws Exception {
    dir = FSDirectory.open(new File(INDEX));
    makeIndex();
    searchIndex(false);
    searchIndex(true);
  }

  static void makeIndex() throws IOException {
    IndexWriterConfig config = new IndexWriterConfig(Version.LUCENE_36, analyzer);
    IndexWriter writer = new IndexWriter(dir, config);
    writer.addDocument(doc("one", "value 1"));
    writer.addDocument(doc("zero"));
    writer.addDocument(doc("two", "value 1", "value 2"));
    writer.close();
  }
  
  static Document doc(String id, String... values){
    Document doc = new Document();
    doc.add(new Field(F_ID, id, Store.YES, Index.ANALYZED));
    if(values != null){
      for(String value : values){
        doc.add(new Field(F_VALUE, value, Store.YES, Index.ANALYZED));
      }
    }
    return doc;
  }
  
  static void searchIndex(boolean negate) throws IOException {
    System.out.println("¥n=== negate=" + negate);
    IndexReader reader = IndexReader.open(dir);
    IndexSearcher searcher = new IndexSearcher(reader);
    Filter filter = new FieldValueFilter(F_VALUE, negate);
    TopDocs docs = searcher.search(new MatchAllDocsQuery(), filter, 10);
    for(ScoreDoc scoreDoc : docs.scoreDocs){
      Document doc = searcher.doc(scoreDoc.doc);
      System.out.printf("¥tid=%s¥n", doc.get(F_ID));
    }
    searcher.close();
    reader.close();
  }
}


実行結果
=== negate=false
	id=one
	id=two

=== negate=true
	id=zero


FieldValueFilterのコンストラクタにはboolean型の2つめの引数negateを取るものがあり、上記プログラムではそれを用いている。negateがfalseの場合は値がひとつでもあるものをフィルターを通過させるが、negate=trueの場合は逆の意味となる。



早くもSolr 3.5に対応したロンウイットのSolrトレーニング・・・Solr 3.5 1月 トレーニング受講者募集中

Solr トレーニングコースパンフレットダウンロードはこちら
| 関口宏司 | Luceneクラス解説 | 10:29 | comments(0) | trackbacks(0) |
N-gramに最適化されたPhraseQuery (3.5)
NGramPhraseQueryという、n-gram用に最適化されたPhraseQueryの拡張クラスがまもなく追加される。最適化のアイディアはHyper Estraierの平林さんの論文から拝借したものである。

たとえば、2-gramの場合、「ファイル」を検索するとき、PhraseQueryでは「ファ/0」「ァイ/1」「イル/2」を検索するが、次のように作成したNGramPhraseQueryは:

PhraseQuery query = new NGramPhraseQuery(2);
query.add("ファ");
query.add("ァイ");
query.add("イル");


検索するときに自動的に冗長単語を削除する最適化がなされ、「ファ/0」「イル/2」を検索する。これにより、最大50%の検索速度向上が可能となる。なお、NGramPhraseQueryのコンストラクタに渡された引数は、n-gramのn(サイズ)である。

参考:https://issues.apache.org/jira/browse/LUCENE-3426

ランチが付いてますます充実のロンウイットのSolrトレーニング・・・Solr 3.3 10月 トレーニング受講者募集中

| 関口宏司 | Luceneクラス解説 | 09:36 | comments(0) | trackbacks(0) |
マージの完了を知る
(問)Luceneでインデックスマージの終了を知るには?

(答)IndexWriter.waitForMerge()IndexWriter.waitForMerges()を呼ぶ。
| 関口宏司 | Luceneクラス解説 | 20:42 | comments(0) | trackbacks(0) |
+ Solrによるブログ内検索
+ PROFILE
 123456
78910111213
14151617181920
21222324252627
28293031   
<< October 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