diff --git a/docs/sorting.md b/docs/sorting.md index 5ca9a8301..dfe5681e4 100644 --- a/docs/sorting.md +++ b/docs/sorting.md @@ -96,6 +96,16 @@ With the combination of `ISearchResult.Skip` and `maxResults`, we can tell Lucen * Skip over a certain number of results without allocating them and tell Lucene * only allocate a certain number of results after skipping +### Deep Paging +When using Lucene.NET as the Examine provider it is possible to more efficiently perform deep paging. +Steps: +1. Build and execute your query as normal. +2. Cast the ISearchResults from IQueryExecutor.Execute to ILuceneSearchResults +3. Store ILuceneSearchResults.SearchAfter (SearchAfterOptions) for the next page. +4. Create the same query as the previous request. +5. When calling IQueryExecutor.Execute. Pass in new LuceneQueryOptions(skip,take, SearchAfterOptions); Skip will be ignored, the next take documents will be retrieved after the SearchAfterOptions document. +6. Repeat Steps 2-5 for each page. + ### Example ```cs diff --git a/src/Examine.Core/EmptySearchResults.cs b/src/Examine.Core/EmptySearchResults.cs index 77d5763dc..84c68e399 100644 --- a/src/Examine.Core/EmptySearchResults.cs +++ b/src/Examine.Core/EmptySearchResults.cs @@ -24,7 +24,8 @@ IEnumerator IEnumerable.GetEnumerator() public long TotalItemCount => 0; - public IEnumerable Skip(int skip) + + public IEnumerable Skip(int skip) { return Enumerable.Empty(); } @@ -34,4 +35,4 @@ public IEnumerable SkipTake(int skip, int? take = null) return Enumerable.Empty(); } } -} \ No newline at end of file +} diff --git a/src/Examine.Core/Search/QueryOptions.cs b/src/Examine.Core/Search/QueryOptions.cs index ec026eaf2..50f2a3756 100644 --- a/src/Examine.Core/Search/QueryOptions.cs +++ b/src/Examine.Core/Search/QueryOptions.cs @@ -24,7 +24,14 @@ public QueryOptions(int skip, int? take = null) Take = take ?? DefaultMaxResults; } + /// + /// The number of documents to skip in the result set. + /// public int Skip { get; } + + /// + /// The number of documents to take in the result set. + /// public int Take { get; } } } diff --git a/src/Examine.Lucene/Search/ILuceneSearchResults.cs b/src/Examine.Lucene/Search/ILuceneSearchResults.cs new file mode 100644 index 000000000..1f45ea1da --- /dev/null +++ b/src/Examine.Lucene/Search/ILuceneSearchResults.cs @@ -0,0 +1,19 @@ +namespace Examine.Lucene.Search +{ + /// + /// Lucene.NET Search Results + /// + public interface ILuceneSearchResults : ISearchResults + { + /// + /// Options for Searching After. Used for efficent deep paging. + /// + SearchAfterOptions SearchAfter { get; } + + /// + /// Returns the maximum score value encountered. Note that in case + /// scores are not tracked, this returns . + /// + float MaxScore { get; } + } +} diff --git a/src/Examine.Lucene/Search/LuceneQueryOptions.cs b/src/Examine.Lucene/Search/LuceneQueryOptions.cs new file mode 100644 index 000000000..96ee34d97 --- /dev/null +++ b/src/Examine.Lucene/Search/LuceneQueryOptions.cs @@ -0,0 +1,41 @@ +using Examine.Search; + +namespace Examine.Lucene.Search +{ + /// + /// Lucene.NET specific query options + /// + public class LuceneQueryOptions : QueryOptions + { + /// + /// Constructor + /// + /// Number of result documents to skip. + /// Optional number of result documents to take. + /// Optionally skip to results after the results from the previous search execution. Used for efficent deep paging. + /// Whether to track the maximum document score. For best performance, if not needed, leave false. + /// Whether to Track Document Scores. For best performance, if not needed, leave false. + public LuceneQueryOptions(int skip, int? take = null, SearchAfterOptions searchAfter = null, bool trackDocumentScores = false, bool trackDocumentMaxScore = false) + : base(skip, take) + { + TrackDocumentScores = trackDocumentScores; + TrackDocumentMaxScore = trackDocumentMaxScore; + SearchAfter = searchAfter; + } + + /// + /// Whether to Track Document Scores. For best performance, if not needed, leave false. + /// + public bool TrackDocumentScores { get; } + + /// + /// Whether to track the maximum document score. For best performance, if not needed, leave false. + /// + public bool TrackDocumentMaxScore { get; } + + /// + /// Options for Searching After. Used for efficent deep paging. + /// + public SearchAfterOptions SearchAfter { get; } + } +} diff --git a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs index 1ef6f0e1e..374afa856 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs @@ -15,6 +15,7 @@ namespace Examine.Lucene.Search public class LuceneSearchExecutor { private readonly QueryOptions _options; + private readonly LuceneQueryOptions _luceneQueryOptions; private readonly IEnumerable _sortField; private readonly ISearchContext _searchContext; private readonly Query _luceneQuery; @@ -24,6 +25,7 @@ public class LuceneSearchExecutor internal LuceneSearchExecutor(QueryOptions options, Query query, IEnumerable sortField, ISearchContext searchContext, ISet fieldsToLoad) { _options = options ?? QueryOptions.Default; + _luceneQueryOptions = _options as LuceneQueryOptions; _luceneQuery = query ?? throw new ArgumentNullException(nameof(query)); _fieldsToLoad = fieldsToLoad; _sortField = sortField ?? throw new ArgumentNullException(nameof(sortField)); @@ -78,47 +80,119 @@ public ISearchResults Execute() var maxResults = Math.Min((_options.Skip + 1) * _options.Take, MaxDoc); maxResults = maxResults >= 1 ? maxResults : QueryOptions.DefaultMaxResults; + int numHits = maxResults; - ICollector topDocsCollector; SortField[] sortFields = _sortField as SortField[] ?? _sortField.ToArray(); - if (sortFields.Length > 0) - { - topDocsCollector = TopFieldCollector.Create( - new Sort(sortFields), maxResults, false, false, false, false); - } - else - { - topDocsCollector = TopScoreDocCollector.Create(maxResults, true); - } + Sort sort = null; + FieldDoc scoreDocAfter = null; + Filter filter = null; using (ISearcherReference searcher = _searchContext.GetSearcher()) { - searcher.IndexSearcher.Search(_luceneQuery, topDocsCollector); + if (sortFields.Length > 0) + { + sort = new Sort(sortFields); + sort.Rewrite(searcher.IndexSearcher); + } + if (_luceneQueryOptions != null && _luceneQueryOptions.SearchAfter != null) + { + //The document to find results after. + scoreDocAfter = GetScoreDocAfter(_luceneQueryOptions); + + // We want to only collect only the actual number of hits we want to take after the last document. We don't need to collect all previous/next docs. + numHits = _options.Take >= 1 ? _options.Take : QueryOptions.DefaultMaxResults; + } TopDocs topDocs; + ICollector topDocsCollector; + bool trackMaxScore = _luceneQueryOptions == null ? false : _luceneQueryOptions.TrackDocumentMaxScore; + bool trackDocScores = _luceneQueryOptions == null ? false : _luceneQueryOptions.TrackDocumentScores; + if (sortFields.Length > 0) { - topDocs = ((TopFieldCollector)topDocsCollector).GetTopDocs(_options.Skip, _options.Take); + bool fillFields = true; + topDocsCollector = TopFieldCollector.Create(sort, numHits, scoreDocAfter, fillFields, trackDocScores, trackMaxScore, false); } else { - topDocs = ((TopScoreDocCollector)topDocsCollector).GetTopDocs(_options.Skip, _options.Take); + topDocsCollector = TopScoreDocCollector.Create(numHits, scoreDocAfter, true); + } + + if (scoreDocAfter != null && sort != null) + { + topDocs = searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, filter, _options.Take, sort, trackDocScores, trackMaxScore); + } + else if (scoreDocAfter != null && sort == null) + { + topDocs = searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, _options.Take); + } + else + { + searcher.IndexSearcher.Search(_luceneQuery, topDocsCollector); + if (sortFields.Length > 0) + { + topDocs = ((TopFieldCollector)topDocsCollector).GetTopDocs(_options.Skip, _options.Take); + } + else + { + topDocs = ((TopScoreDocCollector)topDocsCollector).GetTopDocs(_options.Skip, _options.Take); + } } var totalItemCount = topDocs.TotalHits; - var results = new List(); + var results = new List(topDocs.ScoreDocs.Length); for (int i = 0; i < topDocs.ScoreDocs.Length; i++) { var result = GetSearchResult(i, topDocs, searcher.IndexSearcher); results.Add(result); } + var searchAfterOptions = GetSearchAfterOptions(topDocs); + float maxScore = topDocs.MaxScore; - return new LuceneSearchResults(results, totalItemCount); + return new LuceneSearchResults(results, totalItemCount, maxScore, searchAfterOptions); } } - private ISearchResult GetSearchResult(int index, TopDocs topDocs, IndexSearcher luceneSearcher) + private static FieldDoc GetScoreDocAfter(LuceneQueryOptions luceneQueryOptions) + { + FieldDoc scoreDocAfter; + var searchAfter = luceneQueryOptions.SearchAfter; + + object[] searchAfterSortFields = new object[0]; + if (luceneQueryOptions.SearchAfter.Fields != null && luceneQueryOptions.SearchAfter.Fields.Length > 0) + { + searchAfterSortFields = luceneQueryOptions.SearchAfter.Fields; + } + if (searchAfter.ShardIndex != null) + { + scoreDocAfter = new FieldDoc(searchAfter.DocumentId, searchAfter.DocumentScore, searchAfterSortFields, searchAfter.ShardIndex.Value); + } + else + { + scoreDocAfter = new FieldDoc(searchAfter.DocumentId, searchAfter.DocumentScore, searchAfterSortFields); + } + + return scoreDocAfter; + } + + private static SearchAfterOptions GetSearchAfterOptions(TopDocs topDocs) + { + if (topDocs.TotalHits > 0) + { + if (topDocs.ScoreDocs.LastOrDefault() is FieldDoc lastFieldDoc && lastFieldDoc != null) + { + return new SearchAfterOptions(lastFieldDoc.Doc, lastFieldDoc.Score, lastFieldDoc.Fields?.ToArray(), lastFieldDoc.ShardIndex); + } + if (topDocs.ScoreDocs.LastOrDefault() is ScoreDoc scoreDoc && scoreDoc != null) + { + return new SearchAfterOptions(scoreDoc.Doc, scoreDoc.Score, new object[0], scoreDoc.ShardIndex); + } + } + return null; + } + + private LuceneSearchResult GetSearchResult(int index, TopDocs topDocs, IndexSearcher luceneSearcher) { // I have seen IndexOutOfRangeException here which is strange as this is only called in one place // and from that one place "i" is always less than the size of this collection. @@ -141,8 +215,8 @@ private ISearchResult GetSearchResult(int index, TopDocs topDocs, IndexSearcher doc = luceneSearcher.Doc(docId); } var score = scoreDoc.Score; - var result = CreateSearchResult(doc, score); - + var shardIndex = scoreDoc.ShardIndex; + var result = CreateSearchResult(doc, score, shardIndex); return result; } @@ -152,7 +226,7 @@ private ISearchResult GetSearchResult(int index, TopDocs topDocs, IndexSearcher /// The doc to convert. /// The score. /// A populated search result object - private ISearchResult CreateSearchResult(Document doc, float score) + private LuceneSearchResult CreateSearchResult(Document doc, float score, int shardIndex) { var id = doc.Get("id"); @@ -161,7 +235,7 @@ private ISearchResult CreateSearchResult(Document doc, float score) id = doc.Get(ExamineFieldNames.ItemIdFieldName); } - var searchResult = new SearchResult(id, score, () => + var searchResult = new LuceneSearchResult(id, score, () => { //we can use lucene to find out the fields which have been stored for this particular document var fields = doc.Fields; @@ -190,7 +264,7 @@ private ISearchResult CreateSearchResult(Document doc, float score) } return resultVals; - }); + }, shardIndex); return searchResult; } diff --git a/src/Examine.Lucene/Search/LuceneSearchExtensions.cs b/src/Examine.Lucene/Search/LuceneSearchExtensions.cs index 37decee14..0661975a5 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExtensions.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using Examine.Search; using Lucene.Net.Search; @@ -50,5 +50,21 @@ public static BooleanOperation ToBooleanOperation(this Occur o) return BooleanOperation.Or; } } + /// + /// Executes the query + /// + public static ILuceneSearchResults ExecuteWithLucene(this IQueryExecutor queryExecutor, QueryOptions options = null) + { + if(queryExecutor is LuceneBooleanOperation + || queryExecutor is LuceneSearchQuery) + { + var results = queryExecutor.Execute(options); + if(results is ILuceneSearchResults luceneSearchResults) + { + return luceneSearchResults; + } + } + throw new NotSupportedException("QueryExecutor is not Lucene.NET"); + } } } diff --git a/src/Examine.Lucene/Search/LuceneSearchResult.cs b/src/Examine.Lucene/Search/LuceneSearchResult.cs new file mode 100644 index 000000000..96f19650c --- /dev/null +++ b/src/Examine.Lucene/Search/LuceneSearchResult.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Examine.Lucene.Search +{ + public class LuceneSearchResult : SearchResult, ISearchResult + { + public LuceneSearchResult(string id, float score, Func>> lazyFieldVals, int shardId) + : base(id, score, lazyFieldVals) + { + ShardIndex = shardId; + } + + public int ShardIndex { get; } + } +} diff --git a/src/Examine.Lucene/Search/LuceneSearchResults.cs b/src/Examine.Lucene/Search/LuceneSearchResults.cs index 92258d940..6ddc01a44 100644 --- a/src/Examine.Lucene/Search/LuceneSearchResults.cs +++ b/src/Examine.Lucene/Search/LuceneSearchResults.cs @@ -1,23 +1,33 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; namespace Examine.Lucene.Search { - public class LuceneSearchResults : ISearchResults + public class LuceneSearchResults : ILuceneSearchResults { - public static LuceneSearchResults Empty { get; } = new LuceneSearchResults(Array.Empty(), 0); + public static LuceneSearchResults Empty { get; } = new LuceneSearchResults(Array.Empty(), 0,float.NaN, default); private readonly IReadOnlyCollection _results; - public LuceneSearchResults(IReadOnlyCollection results, int totalItemCount) + public LuceneSearchResults(IReadOnlyCollection results, int totalItemCount,float maxScore, SearchAfterOptions searchAfterOptions) { _results = results; TotalItemCount = totalItemCount; + MaxScore = maxScore; + SearchAfter = searchAfterOptions; } public long TotalItemCount { get; } + /// + /// Returns the maximum score value encountered. Note that in case + /// scores are not tracked, this returns . + /// + public float MaxScore { get; } + + public SearchAfterOptions SearchAfter { get; } + public IEnumerator GetEnumerator() => _results.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } diff --git a/src/Examine.Lucene/Search/SearchAfterOptions.cs b/src/Examine.Lucene/Search/SearchAfterOptions.cs new file mode 100644 index 000000000..8a6ae3306 --- /dev/null +++ b/src/Examine.Lucene/Search/SearchAfterOptions.cs @@ -0,0 +1,39 @@ +namespace Examine.Lucene.Search +{ + /// + /// Options for Searching After. Used for efficent deep paging. + /// + public class SearchAfterOptions + { + + public SearchAfterOptions(int documentId, float documentScore, object[] fields, int shardIndex) + { + DocumentId = documentId; + DocumentScore = documentScore; + Fields = fields; + ShardIndex = shardIndex; + } + + /// + /// The Id of the last document in the previous result set. + /// The search will search after this document + /// + public int DocumentId { get; } + + /// + /// The Score of the last document in the previous result set. + /// The search will search after this document + /// + public float DocumentScore { get; } + + /// + /// The index of the shard the doc belongs to + /// + public int? ShardIndex { get; } + + /// + /// Search fields. Should contain null or J2N.Int + /// + public object[] Fields { get; } + } +} diff --git a/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs b/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs index 8aac33f6f..286167b25 100644 --- a/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs +++ b/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs @@ -56,8 +56,8 @@ public void Allow_Leading_Wildcards() }).NativeQuery("*dney")); var results1 = query1.Execute(); - - Assert.AreEqual(2, results1.TotalItemCount); + + Assert.AreEqual(2, results1.TotalItemCount); } } @@ -1949,7 +1949,7 @@ public void Execute_With_Take_Max_Results() { for (int i = 0; i < 1000; i++) { - indexer.IndexItems(new[] {ValueSet.FromObject(i.ToString(), "content", new { Content = "hello world" })}); + indexer.IndexItems(new[] { ValueSet.FromObject(i.ToString(), "content", new { Content = "hello world" }) }); } indexer.IndexItems(new[] { ValueSet.FromObject(2000.ToString(), "content", new { Content = "donotfind" }) }); @@ -2490,7 +2490,7 @@ public void Paging_With_Skip_Take() var results = sc .Execute(QueryOptions.SkipTake(pageIndex * pageSize, pageSize)) - .ToList(); + .ToList(); Assert.AreEqual(2, results.Count); pageIndex++; @@ -2556,6 +2556,135 @@ public void Given_SkipTake_Returns_ExpectedTotals(int skip, int take, int expect } } + [Test] + public void SearchAfter_Sorted_Results_Returns_Different_Results() + { + var analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion); + using (var luceneDir = new RandomIdRAMDirectory()) + using (var indexer = GetTestIndex(luceneDir, analyzer)) + { + indexer.IndexItems(new[] { + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "umbraco", headerText = "world", writerName = "administrator" }), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "umbraco", headerText = "umbraco", writerName = "administrator" }), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "umbraco", headerText = "umbraco", writerName = "administrator" }), + ValueSet.FromObject(4.ToString(), "content", + new { nodeName = "umbraco", headerText = "nz", writerName = "administrator" }), + ValueSet.FromObject(5.ToString(), "content", + new { nodeName = "hello", headerText = "world", writerName = "blah" }) + }); + + var searcher = indexer.Searcher; + + //Arrange + var sc = searcher.CreateQuery("content") + .Field("writerName", "administrator") + .OrderByDescending(new SortableField("id", SortType.Int)); + var luceneOptions = new LuceneQueryOptions(0, 2); + //Act + + //There are 4 results + // First query skips 0 and takes 2. + var luceneResults = sc.ExecuteWithLucene(luceneOptions); + Assert.IsNotNull(luceneResults); + Assert.IsNotNull(luceneResults.SearchAfter, "Search After details should be available"); + var luceneResults1List = luceneResults.ToList(); + Assert.IsTrue(luceneResults1List.Any(x => x.Id == "1")); + Assert.IsTrue(luceneResults1List.Any(x => x.Id == "2")); + + // Second query result continues after result 1 (zero indexed), Takes 1, should not include any of the results before or include the SearchAfter docid / scoreid + var searchAfter = new SearchAfterOptions(luceneResults.SearchAfter.DocumentId, + luceneResults.SearchAfter.DocumentScore, + luceneResults.SearchAfter.Fields, + luceneResults.SearchAfter.ShardIndex.Value); + var luceneOptions2 = new LuceneQueryOptions(0, 1, searchAfter); + var luceneResults2 = sc.ExecuteWithLucene(luceneOptions2); + var luceneResults2List = luceneResults2.ToList(); + Assert.IsTrue(luceneResults2List.Any(x => x.Id == "3"), $"Expected to contain next result after docId {luceneResults.SearchAfter.DocumentId}"); + Assert.IsNotNull(luceneResults2); + + Assert.IsFalse(luceneResults2List.Any(x => luceneResults.ToList().Any(y => y.Id == x.Id)), "Results should not overlap"); + + // Third query result continues after result 2 (zero indexed), Takes 1 + var searchAfter2 = new SearchAfterOptions(luceneResults2.SearchAfter.DocumentId, luceneResults2.SearchAfter.DocumentScore, luceneResults2.SearchAfter.Fields, luceneResults2.SearchAfter.ShardIndex.Value); + var luceneOptions3 = new LuceneQueryOptions(0, 1, searchAfter2); + var luceneResults3 = sc.ExecuteWithLucene(luceneOptions3); + Assert.IsNotNull(luceneResults3); + var luceneResults3List = luceneResults3.ToList(); + Assert.IsTrue(luceneResults3List.Any(x => x.Id == "4"), $"Expected to contain next result after docId {luceneResults2.SearchAfter.DocumentId}"); + Assert.IsFalse(luceneResults3.ToList().Any(x => luceneResults2.Any(y => y.Id == x.Id)), "Results should not overlap"); + Assert.IsFalse(luceneResults3.ToList().Any(x => luceneResults.Any(y => y.Id == x.Id)), "Results should not overlap"); + + Assert.AreNotEqual(luceneResults.First().Id, luceneResults2.First().Id, "Results should be different"); + } + } + + [Test] + public void SearchAfter_NonSorted_Results_Returns_Different_Results() + { + var analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion); + using (var luceneDir = new RandomIdRAMDirectory()) + using (var indexer = GetTestIndex(luceneDir, analyzer)) + { + indexer.IndexItems(new[] { + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "umbraco", headerText = "world", writerName = "administrator" }), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "umbraco", headerText = "umbraco", writerName = "administrator" }), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "umbraco", headerText = "umbraco", writerName = "administrator" }), + ValueSet.FromObject(4.ToString(), "content", + new { nodeName = "umbraco", headerText = "nz", writerName = "administrator" }), + ValueSet.FromObject(5.ToString(), "content", + new { nodeName = "hello", headerText = "world", writerName = "blah" }) + }); + + var searcher = indexer.Searcher; + + //Arrange + var sc = searcher.CreateQuery("content") + .Field("writerName", "administrator"); + var luceneOptions = new LuceneQueryOptions(0, 2); + //Act + + //There are 4 results + // First query skips 0 and takes 2. + var luceneResults = sc.ExecuteWithLucene(luceneOptions); + Assert.IsNotNull(luceneResults); + Assert.IsNotNull(luceneResults.SearchAfter, "Search After details should be available"); + var luceneResults1List = luceneResults.ToList(); + Assert.IsTrue(luceneResults1List.Any(x => x.Id == "1")); + Assert.IsTrue(luceneResults1List.Any(x => x.Id == "2")); + + // Second query result continues after result 1 (zero indexed), Takes 1, should not include any of the results before or include the SearchAfter docid / scoreid + var searchAfter = new SearchAfterOptions(luceneResults.SearchAfter.DocumentId, + luceneResults.SearchAfter.DocumentScore, + luceneResults.SearchAfter.Fields, + luceneResults.SearchAfter.ShardIndex.Value); + var luceneOptions2 = new LuceneQueryOptions(0, 1, searchAfter); + var luceneResults2 = sc.ExecuteWithLucene(luceneOptions2); + var luceneResults2List = luceneResults2.ToList(); + Assert.IsTrue(luceneResults2List.Any(x => x.Id == "3"), $"Expected to contain next result after docId {luceneResults.SearchAfter.DocumentId}"); + Assert.IsNotNull(luceneResults2); + + Assert.IsFalse(luceneResults2List.Any(x => luceneResults.ToList().Any(y => y.Id == x.Id)), "Results should not overlap"); + + // Third query result continues after result 2 (zero indexed), Takes 1 + var searchAfter2 = new SearchAfterOptions(luceneResults2.SearchAfter.DocumentId, luceneResults2.SearchAfter.DocumentScore, luceneResults2.SearchAfter.Fields, luceneResults2.SearchAfter.ShardIndex.Value); + var luceneOptions3 = new LuceneQueryOptions(0, 1, searchAfter2); + var luceneResults3 = sc.ExecuteWithLucene(luceneOptions3); + Assert.IsNotNull(luceneResults3); + var luceneResults3List = luceneResults3.ToList(); + Assert.IsTrue(luceneResults3List.Any(x => x.Id == "4"), $"Expected to contain next result after docId {luceneResults2.SearchAfter.DocumentId}"); + Assert.IsFalse(luceneResults3.ToList().Any(x => luceneResults2.Any(y => y.Id == x.Id)), "Results should not overlap"); + Assert.IsFalse(luceneResults3.ToList().Any(x => luceneResults.Any(y => y.Id == x.Id)), "Results should not overlap"); + + Assert.AreNotEqual(luceneResults.First().Id, luceneResults2.First().Id, "Results should be different"); + } + } + #if NET6_0_OR_GREATER [Test] public void Range_DateOnly()