結果のページネーション

ほとんどの検索アプリケーションでは、(スコアまたはその他の基準でソートされた)「上位」一致結果がユーザーに表示されます。

多くのアプリケーションでは、これらのソートされた結果の UI は、固定数の一致結果を含む「ページ」でユーザーに表示され、ユーザーは通常、最初の数ページ分の結果を超えて結果を見ることはありません。

基本的なページネーション

Solr では、この基本的なページネーション検索は `start` および `rows` パラメータを使用してサポートされており、この一般的な動作のパフォーマンスは、`queryResultCache` を利用し、`queryResultWindowSize` 構成オプションを予期されるページサイズに基づいて調整することで調整できます。

基本的なページネーションの例

単純なページネーションについて考える最も簡単な方法は、必要なページ番号(「最初の」ページ番号を「0」として扱う)に 1 ページあたりの行数を掛けることです。次の擬似コードのように

function fetch_solr_page($page_number, $rows_per_page) {
  $start = $page_number * $rows_per_page
  $params = [ q = $some_query, rows = $rows_per_page, start = $start ]
  return fetch_solr($params)
}

インデックスの更新が基本的なページネーションに与える影響

Solr へのリクエストで指定された `start` パラメータは、クライアントが Solr に現在の「ページ」の開始点として使用してほしい、完全なソート済み一致リストにおける**絶対**「オフセット」を示します。

クエリに一致する順序付きドキュメントのシーケンスに影響を与えるインデックス変更(ドキュメントの追加または削除など)が、クライアントからの連続した結果ページのリクエスト間で発生した場合、これらの変更により、同じドキュメントが複数のページで返されたり、結果セットの縮小または拡大に伴いドキュメントが「スキップ」されたりする可能性があります。

たとえば、次のような 26 個のドキュメントを含むインデックスを考えてみましょう。

ID 名前

1

A

2

B

…​

26

Z

続いて、次のリクエストとインデックスの変更がインターリーブされます。

  • クライアントが `q=**:**&rows=5&start=0&sort=name asc` をリクエストします

    • ID が `1-5` のドキュメントがクライアントに返されます

  • ドキュメント ID `3` が削除されます

  • クライアントが `q=**:**&rows=5&start=5&sort=name asc` を使用して「ページ #2」をリクエストします

    • ドキュメント `7-11` が返されます

    • ドキュメント `6` はスキップされました。これは、ソートされたすべての一致結果セットの 5 番目のドキュメントになったためです。「ページ #1」の新しいリクエストで返されます。

  • ID が `90`、`91`、`92` の 3 つの新しいドキュメントが追加されます。3 つのドキュメントすべてに `A` という名前が付いています。

  • クライアントが `q=**:**&rows=5&start=10&sort=name asc` を使用して「ページ #3」をリクエストします

    • ドキュメント `9-13` が返されます

    • ドキュメント 910、および 11 は、ソート結果のリスト内で後方に移動したため、ページ #2 とページ #3 の両方で返されるようになりました。

典型的な状況では、インデックスの変更によるページング検索への影響は、ユーザーエクスペリエンスに大きな影響を与えません。これは、比較的静的なコレクションでは変更が非常にまれにしか発生しないか、ユーザーがデータのコレクションが常に進化していることを認識し、結果セット内でドキュメントが上下に移動することを予期しているためです。

「ディープページング」のパフォーマンスの問題

Solr 検索の結果が、単純なページングされたユーザーインターフェースで使用されるわけではない場合があります。

外部システムに供給するために Solr から非常に多数のソートされた結果を取得する場合、start または rows パラメーターに非常に大きな値を使用すると、非常に非効率的になる可能性があります。 startrows を使用したページングでは、Solr は現在のページに取得する必要があるすべての一致するドキュメントだけでなく、前のページに表示されていたすべてのドキュメントもメモリ内で計算(およびソート)する必要があります。

start=0&rows=1000000 のリクエストは、Solr が 100 万個のドキュメントのセットをメモリ内で保持およびソートする必要があるため、明らかに非効率的ですが、同様に start=999000&rows=1000 のリクエストも、同じ理由で同様に非効率的です。 Solr は、最初に一致するソート済み結果の最初の 999000 件を決定せずに、一致するドキュメントの 999001 番目目の結果がソート順でどれであるかを計算できません。

インデックスが分散されている場合(SolrCloud モードで実行する場合によくあることです)、100 万個のドキュメントが**各シャード**から取得されます。 10 シャードのインデックスの場合、これらのクエリパラメータに一致する 1000 個のドキュメントを特定するために、1000 万個のエントリを取得してソートする必要があります。

多数のソート済み結果の取得:カーソル

ソート済み結果の次のページをリクエストするために "start" パラメータを増やす代わりに、Solr は "カーソル" を使用して結果をスキャンすることをサポートしています。

Solr のカーソルは、サーバーに状態情報をキャッシュしない論理的な概念です。代わりに、クライアントに返された最後のドキュメントのソート値を使用して、ソート値の順序付けられた空間における論理的なポイントを表す「マーク」が計算されます。その「マーク」は、Solr にどこから続行するかを指示するために、後続のリクエストのパラメータで指定できます。

カーソルの使用

Solr でカーソルを使用するには、cursorMark パラメータに * の値を指定します。これは、start=0 と同様に、Solr に「ソート済み結果の先頭から開始する」ように指示する方法と考えることができますが、カーソルを使用する必要があることも Solr に通知します。

上位 N 件のソート済み結果(N は rows パラメータを使用して制御できます)を返すことに加えて、Solr レスポンスには nextCursorMark という名前のエンコードされた文字列も含まれます。次に、レスポンスから nextCursorMark 文字列値を取得し、次のリクエストの cursorMark パラメータとして Solr に返します。必要な数のドキュメントを取得するか、返された nextCursorMark が既に指定した cursorMark と一致するまで(結果がこれ以上ないことを示します)、このプロセスを繰り返すことができます。

カーソル使用時の制約

Solr リクエストで cursorMark パラメータを使用する際に注意すべき重要な制約がいくつかあります。

  1. cursorMarkstart は相互に排他的なパラメータです。

    • リクエストには start パラメータを含めないか、「0」の値で指定する必要があります。

  2. timeAllowed リクエストパラメータを使用する場合、部分的な結果が返される場合があります。 responseHeader"partialResults": true が含まれている場合に示されるように、検索が完了する前に時間が経過した場合、一致するドキュメントの一部がスキップされている可能性があります。さらに、cursorMarknextCursorMark と一致する場合、結果がこれ以上ないことを確認できません。

    この状況では、timeAllowed を増やしてクエリを再発行することを検討してください。 responseHeader"partialResults": true が含まれなくなり、cursorMarknextCursorMark と一致する場合、結果はなくなります。

  3. sort 句には uniqueKey フィールド(asc または desc)を含める必要があります。

    id が uniqueKey フィールドの場合、id ascname asc, id desc などのソートパラメータはどちらも正常に機能しますが、name asc 単独では機能しません。

  4. NOW に対する相対的な計算を含む 日付演算ベースの 関数を含むソートは、すべてのドキュメントが後続のすべてのリクエストで新しいソート値を取得するため、混乱を招く結果になります。これは、ドキュメントが更新されなくても、カーソルが終了せず、常に同じドキュメントが繰り返し返されるという結果につながる可能性があります。

    この状況では、すべてのカーソルリクエストで NOW リクエストパラメータ の固定値を選択して再利用します。

カーソルマーク値は、結果の各ドキュメントのソート値に基づいて計算されます。これは、ソート値が同一の複数のドキュメントがある場合、それらのいずれかが結果ページの最後のドキュメントであると、同一のカーソルマーク値が生成されることを意味します。その場合、その cursorMark を使用した後続のリクエストは、同一のマーク値を持つドキュメントのどれをスキップする必要があるかを知ることができません。ソート基準の句として uniqueKey フィールドを使用する必要があるため、決定論的な順序が返され、すべての cursorMark 値がドキュメントシーケンス内の一意のポイントを識別することが保証されます。

カーソルの例

すべてのドキュメントの取得

ここに示す擬似コードは、カーソルを使用してクエリに一致するすべてのドキュメントを取得する際に必要な基本的なロジックを示しています。

// when fetching all docs, you might as well use a simple id sort
// unless you really need the docs to come back in a specific order
$params = [ q => $some_query, sort => 'id asc', rows => $r, cursorMark => '*' ]
$done = false
while (not $done) {
  $results = fetch_solr($params)
  // do something with $results
  if ($params[cursorMark] == $results[nextCursorMark]) {
    $done = true
  }
  $params[cursorMark] = $results[nextCursorMark]
}

SolrJ を使用すると、この擬似コードは次のようになります。

SolrQuery q = (new SolrQuery(some_query)).setRows(r).setSort(SortClause.asc("id"));
String cursorMark = CursorMarkParams.CURSOR_MARK_START;
boolean done = false;
while (! done) {
  q.set(CursorMarkParams.CURSOR_MARK_PARAM, cursorMark);
  QueryResponse rsp = solrServer.query(q);
  String nextCursorMark = rsp.getNextCursorMark();
  doCustomProcessingOfResults(rsp);
  if (cursorMark.equals(nextCursorMark)) {
    done = true;
  }
  cursorMark = nextCursorMark;
}

curl を使用して手動でこれを行う場合、リクエストのシーケンスは次のようになります。

$ curl '...&rows=10&sort=id+asc&cursorMark=*'
{
  "response":{"numFound":32,"start":0,"docs":[
    // ... 10 docs here ...
  ]},
  "nextCursorMark":"AoEjR0JQ"}
$ curl '...&rows=10&sort=id+asc&cursorMark=AoEjR0JQ'
{
  "response":{"numFound":32,"start":0,"docs":[
    // ... 10 more docs here ...
  ]},
  "nextCursorMark":"AoEpVkRCREIxQTE2"}
$ curl '...&rows=10&sort=id+asc&cursorMark=AoEpVkRCREIxQTE2'
{
  "response":{"numFound":32,"start":0,"docs":[
    // ... 10 more docs here ...
  ]},
  "nextCursorMark":"AoEmbWF4dG9y"}
$ curl '...&rows=10&sort=id+asc&cursorMark=AoEmbWF4dG9y'
{
  "response":{"numFound":32,"start":0,"docs":[
    // ... 2 docs here because we've reached the end.
  ]},
  "nextCursorMark":"AoEpdmlld3Nvbmlj"}
$ curl '...&rows=10&sort=id+asc&cursorMark=AoEpdmlld3Nvbmlj'
{
  "response":{"numFound":32,"start":0,"docs":[
    // no more docs here, and note that the nextCursorMark
    // matches the cursorMark param we used
  ]},
  "nextCursorMark":"AoEpdmlld3Nvbmlj"}

後処理に基づいて最初の *N* 個のドキュメントを取得する

カーソルは Solr の観点からはステートレスであるため、クライアントコードは、十分な情報が得られたらすぐに追加の結果の取得を停止できます。

while (! done) {
  q.set(CursorMarkParams.CURSOR_MARK_PARAM, cursorMark);
  QueryResponse rsp = solrServer.query(q);
  String nextCursorMark = rsp.getNextCursorMark();
  boolean hadEnough = doCustomProcessingOfResults(rsp);
  if (hadEnough || cursorMark.equals(nextCursorMark)) {
    done = true;
  }
  cursorMark = nextCursorMark;
}

インデックスの更新によるカーソルの影響

基本的なページングとは異なり、カーソルページングは、一致するドキュメントの完成したソート済みリストへの絶対的な「オフセット」の使用に依存しません。代わりに、リクエストで指定された cursorMark は、そのドキュメントの**絶対**ソート値に基づいて、返された最後のドキュメントの**相対**位置に関する情報をカプセル化します。これは、基本的なページングと比較して、カーソルを使用する場合、インデックスの変更の影響がはるかに小さいことを意味します。基本的なページングについて説明したときと同じサンプルインデックスを考えてみます。

ID 名前

1

A

2

B

…​

26

Z

  • クライアントは q=:&rows=5&start=0&sort=name asc, id asc&cursorMark=* をリクエストします。

    • ID が 1-5 のドキュメントがクライアントに順番に返されます。

  • ドキュメント ID `3` が削除されます

  • クライアントは、前のレスポンスからの nextCursorMark を使用して、さらに 5 つのドキュメントをリクエストします。

    • ドキュメント 6-10 が返されます。既に返されたドキュメントの削除は、カーソルの相対位置には影響しません。

  • ID が `90`、`91`、`92` の 3 つの新しいドキュメントが追加されます。3 つのドキュメントすべてに `A` という名前が付いています。

  • クライアントは、前のレスポンスからの nextCursorMark を使用して、さらに 5 つのドキュメントをリクエストします。

    • ドキュメント 11-15 が返されます。既に過ぎたソート値を持つ新しいドキュメントの追加は、カーソルの相対位置には影響しません。

  • ドキュメント ID 1 が更新され、「名前」が Q に変更されます。

  • ドキュメント ID 17 が更新され、「名前」が A に変更されます。

  • クライアントは、前のレスポンスからの nextCursorMark を使用して、さらに 5 つのドキュメントをリクエストします。

    • 結果のドキュメントは、その順序で 16,1,18,19,20 です。

    • ドキュメント 1 のソート値がカーソル位置の*後*になるように変更されたため、ドキュメントはクライアントに 2 回返されます。

    • ドキュメント 17 のソート値がカーソル位置の*前*になるように変更されたため、ドキュメントは「スキップ」され、カーソルが進行し続けてもクライアントに返されません。

要するに、cursorMark を使用してクエリに一致するすべての結果を取得する場合、インデックスの変更によってドキュメントがスキップされたり、2 回返されたりする唯一の方法は、ドキュメントのソート値が変更された場合です。

ドキュメントが複数回返されないようにする 1 つの方法は、プライマリ(したがって、唯一の重要な)ソート基準として uniqueKey フィールドを使用することです。

この状況では、カーソルの使用中にどのように変更されても、各ドキュメントが 1 回だけ返されることが保証されます。

カーソルの「テーリング」

カーソルリクエストはステートレスであり、cursorMark 値は検索から返された最後のドキュメントの絶対ソート値をカプセル化するため、既に終了に達したカーソルから追加の結果の取得を「続行」できます。新しいドキュメントが結果の最後に追加された場合(または既存のドキュメントが更新された場合)。

これは、Unix で「tail -f」のようなものを使用するのと似ていると考えることができます。これがどのように役立つかについての最も一般的な例は、ドキュメントがインデックスに追加/更新された日時を記録する「タイムスタンプ」フィールドがある場合です。クライアントアプリケーションは、クエリに一致するドキュメントについて sort=timestamp asc, id asc を使用してカーソルを継続的にポーリングし、リクエスト基準に一致するドキュメントが追加または更新されたときに常に通知を受け取ることができます。

もう 1 つの一般的な例は、新しいドキュメントが作成されるたびに常に増加する uniqueKey 値があり、sort=id asc を使用してカーソルを継続的にポーリングして、新しいドキュメントについて通知を受けることができる場合です。

カーソルをテーリングするための擬似コードは、クエリに一致するすべてのドキュメントを処理するための初期の例からわずかに変更されただけです。

while (true) {
  $doneForNow = false
  while (not $doneForNow) {
    $results = fetch_solr($params)
    // do something with $results
    if ($params[cursorMark] == $results[nextCursorMark]) {
      $doneForNow = true
    }
    $params[cursorMark] = $results[nextCursorMark]
  }
  sleep($some_configured_delay)
}
特定の特殊なケースでは、`/export` ハンドラーが選択肢となる場合があります。