1. Problem and Motivation
Full-text search and real-time typeahead autocomplete are ubiquitous across modern applications—from e-commerce product catalogs (Amazon, Shopify) and social networks (X, LinkedIn) to document knowledge bases (Notion, GitHub).
Delivering search at scale presents unique architectural hurdles that standard relational databases cannot solve:
+---------------------------------------------------------------------------------------------------+
| THE FULL-TEXT SEARCH PROBLEM SPACE |
+---------------------------------------------------------------------------------------------------+
| 1. The Inadequacy of Relational B+ Trees: B+ Trees index fixed columns. Querying variable text |
| with wildcards (`LIKE '%distributed%'`) forces O(N) sequential table scans across terabytes. |
| 2. Multi-Term Boolean Intersections: A user searches for "high throughput distributed stream". |
| The system must find the intersection of documents containing all four terms among billions of |
| candidates within sub-20 milliseconds. |
| 3. Probabilistic Relevance Ranking: Not all matching documents are equal. The engine must score |
| relevance dynamically based on keyword scarcity, frequency, and document length (BM25). |
| 4. Sub-10ms Typeahead Latency: When a user types a keystroke ("dis"), suggestions must render in |
| under 10 milliseconds before the user types the next character. |
| 5. Scalability & Scatter-Gather Sharding: Terabytes of text cannot reside on a single machine; |
| queries must be distributed across hundreds of shards and merged seamlessly in memory. |
+---------------------------------------------------------------------------------------------------+
2. Mental Model & Architectural Overview
A distributed search platform decouples real-time Typeahead Autocomplete from the Distributed Inverted Index Cluster:
1. User Keystroke Stream & In-Memory Prefix Trie / FST
Establishes the ingress and structural boundary across User Keystroke Stream & In-Memory Prefix Trie / FST: Client triggers query after 150ms debounce GET /autocomplete?q=distr; Finite State Transducer (FST) in RAM: (root) → 'd' → 'i' → 's' → 't' → 'r' Nodes store top-K pre-computed completions weighted by historical query frequency; 1. "distributed systems" (score: 98).
2. Full Search Request & Coordinator Node (Scatter Phase)
Coordinates core data-plane execution and state transitions through Full Search Request & Coordinator Node (Scatter Phase): POST /search { "query": "distributed consensus" }; Pagination: page=1, size=20; Parses query terms into boolean filter AST. Broadcasts search query concurrently across all primary shards: Shard 0, 1, 2.
3. Document Ingestion & Refresh Cycle (Every 1 Second)
Enforces durability, state convergence, and output verification across Document Ingestion & Refresh Cycle (Every 1 Second): Kafka / CDC → Index Writer Documents written to in-memory RAM buffer Append-only Translog (WAL) on disk; RAM buffer flushed to OS Page Cache as an Immutable Lucene Segment File Searchable in Near-Real-Time (NRT); Dozens of small segments degrade search. Background merge thread coalesces small segments into a large segment.
The Core Systems:
- Sub-10ms Typeahead Autocomplete Tier:
- Backed by an in-memory Prefix Trie or Finite State Transducer (FST).
- Evaluates prefix lookups in microseconds without touching disk storage.
- Employs Levenshtein Automata to resolve typos on-the-fly.
- Distributed Inverted Index Cluster (Scatter-Gather):
- Data is partitioned into primary shards distributed across data nodes.
- A Coordinator Node receives the incoming user query, broadcasts the request to all shards (Scatter), collects local top hits, performs a global priority queue merge (Gather), and retrieves document source payloads only for the top-ranked results.
- Immutable Lucene Segments:
- Shards write incoming documents to in-memory buffers.
- Every 1 second, buffers are flushed to the OS Page Cache as Immutable Segments, enabling Near-Real-Time (NRT) searchability with zero read-lock contention.
- A background thread coalesces smaller segments into larger ones via a Tiered Merge Policy.
Architectural Blueprint: Geospatial Proximity Indexing (Geohash, QuadTree & Uber H3)
Real-time ride-dispatch, food delivery, and local search platforms require sub-10ms Geospatial -Nearest-Neighbor (-NN) queries over millions of moving entities:
- Why Naive
LAT / LONB+Tree Indexes Fail: A standard B+Tree index on(latitude, longitude)is 1-dimensional; in aWHERE lat BETWEEN ... AND lon BETWEEN ...bounding-box query, only the first column uses a contiguous range scan while the second column degrades into a full scan across matching latitude bands. - Space-Filling Curves (
Geohash/ Z-Order Morton Codes):- Interleaves latitude and longitude bits into a base-32 string prefix (e.g.,
9q8yykcell). Sharing a longer prefix guarantees spatial proximity, turning 2D radius queries into fast 1D prefix scans (WHERE geohash LIKE '9q8yy%'). - Boundary Edge Case: Two points 10 meters apart across a prime meridian or cell boundary can have completely different prefixes, requiring queries to always fetch the target cell plus its 8 surrounding neighbor cells (9 prefixes total).
- Interleaves latitude and longitude bits into a base-32 string prefix (e.g.,
- Adaptive Partitioning (
QuadTree& GoogleS2) vs. Hexagonal Grids (Uber H3):- In-Memory
QuadTree: Recursively subdivides 2D rectangles into 4 quadrants whenever node capacity exceeds points (e.g., ), naturally adapting resolution between dense city centers and sparse oceans. Uber H3Hierarchical Hexagonal Index: Rectangles have two types of neighbors (edge-sharing at distance and corner-sharing at ). Hexagons have 6 equidistant neighbors whose center-to-center distances are identical in all directions, making radialkRing(originCell, k)expansion, surge-pricing smoothing, and ETA gradient calculation mathematically uniform across 16 hierarchical resolutions.
- In-Memory
3. Terminology & Core Mechanics
- Inverted Index: A data structure mapping distinct words (terms) to a sorted list of Document IDs (the Postings List) where those words appear.
- Term Dictionary: The lexicographically sorted directory of all unique terms across an index, stored as an in-memory FST (Finite State Transducer) or B-Tree.
- Postings List: A compressed array of document IDs containing a specific term, augmented with term frequency and positional offsets.
- Roaring Bitmap: A compressed bitmap data structure that stores set integers efficiently, enabling blazing-fast bitwise
AND/ORintersections between postings lists. - BM25 (Best Matching 25): A probabilistic relevance ranking function that calculates the score of a document relative to a search query based on term frequency saturation and document length normalization.
- Scatter-Gather: A distributed query pattern where a coordinator broadcasts a request to all data shards, collects their individual top candidate hits, and merges them into a globally sorted result set.
- Segment: An individual, self-contained, immutable inverted index file on disk. A Lucene shard is composed of multiple segments.
4. Internal Workings & Algorithms
1. Inverted Index Structure & Postings Compression
Consider three documents:
- Doc 1:
"distributed systems and consensus" - Doc 2:
"distributed storage engine" - Doc 3:
"consensus protocols and consensus algorithms"
Tokenize & Normalize (Lowercase, Stemming, Stop-word removal)
|
v
+-------------------------------------------------------------------------------+
| INVERTED INDEX TABLE |
+----------------------+-------------------+------------------------------------+
| Term (Dictionary) | Document Frequency| Postings List (DocID, [Positions]) |
+----------------------+-------------------+------------------------------------+
| "algorithm" | 1 | [Doc 3: pos 4] |
| "consensus" | 2 | [Doc 1: pos 3], [Doc 3: pos 0, 3] |
| "distributed" | 2 | [Doc 1: pos 0], [Doc 2: pos 0] |
| "engine" | 1 | [Doc 2: pos 2] |
| "protocol" | 1 | [Doc 3: pos 1] |
| "storage" | 1 | [Doc 2: pos 1] |
| "system" | 1 | [Doc 1: pos 1] |
+----------------------+-------------------+------------------------------------+
Boolean Intersection via Roaring Bitmaps:
When a user searches for "distributed AND consensus":
- Lookup
"distributed"in Term Dictionary Postings:[Doc 1, Doc 2]. - Lookup
"consensus"in Term Dictionary Postings:[Doc 1, Doc 3]. - Intersect using Roaring Bitmaps: Because postings are sorted integers, intersections execute in sub-microsecond CPU SIMD instructions without scanning non-matching documents.
2. Sub-10ms Autocomplete via Prefix Tries & FSTs
A Trie (Prefix Tree) organizes strings such that all descendants of a node share a common prefix:
(root)
|
'd'
|
'i'
|
's'
|
't'
|
'r' <-- [Pre-computed Top-K Suggestions stored at this node!]
/ \ 1. "distributed systems" (score 98)
'i' 'a' 2. "distributed locks" (score 84)
/ \
"distribution" "distract"
Memory Optimization: Finite State Transducers (FST)
In production (Lucene/Elasticsearch), standard Tries consume excessive memory due to pointer overhead. Lucene uses Finite State Transducers (FSTs):
- FSTs share not only common prefixes (from root downwards) but also common suffixes (from leaves upwards).
- Compresses a dictionary of 10,000,000 words into just 15–30 MB of RAM, fitting completely in CPU L3 cache!
5. Mathematical Models: The BM25 Relevance Equation
Modern search engines rank documents using the Okapi BM25 probabilistic scoring formula. Given a query with terms , the score of document is:
Where:
- : Raw term frequency of term in document .
- : Length of document in words.
- : Average document length across the entire index corpus.
- : Term frequency saturation parameter (typically ).
- : Document length normalization parameter (typically ).
- : Total number of documents in the index.
- : Number of documents containing term .
Relevance
Score ^
| Linear TF (Naive TF-IDF: Susceptible to Keyword Stuffing)
| /
| /
| ---------/ BM25 Saturation Curve (Asymptote = k1 + 1)
| /
| /
| /
+-------------------------------------------> Term Frequency f(q, D)
Why BM25 Outperforms Classical TF-IDF:
- Term Frequency Saturation (): As , the term factor approaches . Repeating the word “distributed” 50 times in an article will not score 50 times higher than an article mentioning it 3 times!
- Length Normalization (): If a term appears twice in a 20-word tweet, that term is far more representative of the document than if it appears twice in an 8,000-word book. The parameter penalizes excessively long documents.
- Non-Negative Lucene 8+ IDF: Adding inside ensures common terms () still yield small positive weights rather than negative scores.
Quantitative Sizing: Postings Memory, FST Compression & Scatter-Gather Tail Latency
Consider an index of documents with average length ( total token occurrences) and a dictionary of distinct terms:
- Postings List Footprint: Uncompressed 32-bit
(docId, tf)pairs would consume . Using delta-encoded Frame-of-Reference (FOR) / PForDelta bit-packing ( per posting), on-disk postings shrink to across the cluster. - Term Dictionary Memory (Pointer Trie vs. FST): A naive object-pointer Trie with nodes at consumes of JVM heap per shard replica, whereas a prefix-and-suffix-minimized Lucene FST packs all terms into off-heap ( reduction).
- Scatter-Gather Tail-Latency Amplification: When a coordinator fans out a query across independent shards and waits for all responses, if each shard independently experiences a GC or disk stall with probability : For shards, ! More than one in four user queries hits single-shard latency unless mitigated via adaptive replica selection, hedged requests, and bounded shard counts.
6. Real Implementation & Systems Comparison
| Feature | Elasticsearch / OpenSearch | Apache Solr | Algolia | Meilisearch |
|---|---|---|---|---|
| Underlying Engine | Apache Lucene (Java) | Apache Lucene (Java) | Custom C++ Engine | Custom Rust Engine |
| Primary Architecture | Sharded Master/Data Nodes | SolrCloud (ZooKeeper) | Global Multi-Tenant Cluster | Single-Node Embedded / Sharded |
| Autocomplete Engine | Completion Suggester (FST) | Suggester Component | In-Memory Prefix Indexes | Radix Trees / LMDB |
| Segment Merging | TieredMergePolicy | Tiered / LogByteSize | Real-Time Delta Updates | In-Place LMDB B+ Tree |
| Search Latency | (Large corpus) | (Hosted) |
7. Production Code Engine: Inverted Index & Trie Autocomplete
The following complete TypeScript implementation demonstrates:
- An Inverted Index with tokenizer, postings list, and BM25 scoring.
- An in-memory Prefix Trie providing sub-millisecond typeahead autocomplete with frequency weighting.
/**
* Production-Grade Search & Autocomplete Engine
* Inverted Index with BM25 Scoring and Prefix Trie Autocomplete.
*/
export interface Document {
id: number;
title?: string;
text: string;
}
export class InvertedIndex {
// Term -> Map of DocID to Term Frequency
private dictionary = new Map<string, Map<number, number>>();
private docLengths = new Map<number, number>();
private totalDocs = 0;
private totalLength = 0;
// BM25 Hyperparameters
private readonly k1 = 1.2;
private readonly b = 0.75;
public addDocument(doc: Document): void {
const tokens = this.tokenize(doc.text);
this.totalDocs++;
this.docLengths.set(doc.id, tokens.length);
this.totalLength += tokens.length;
for (const token of tokens) {
if (!this.dictionary.has(token)) {
this.dictionary.set(token, new Map());
}
const postings = this.dictionary.get(token)!;
postings.set(doc.id, (postings.get(doc.id) ?? 0) + 1);
}
}
public search(query: string): { docId: number; score: number }[] {
const queryTokens = this.tokenize(query);
if (queryTokens.length === 0 || this.totalDocs === 0) return [];
const avgdl = this.totalLength / this.totalDocs;
const scores = new Map<number, number>();
for (const term of queryTokens) {
const postings = this.dictionary.get(term);
if (!postings) continue;
const n = postings.size; // Document frequency for term
// Calculate IDF
const idf = Math.log(1 + (this.totalDocs - n + 0.5) / (n + 0.5));
for (const [docId, freq] of postings.entries()) {
const docLen = this.docLengths.get(docId) ?? avgdl;
// BM25 Term Weight
const numerator = freq * (this.k1 + 1);
const denominator = freq + this.k1 * (1 - this.b + this.b * (docLen / avgdl));
const termScore = idf * (numerator / denominator);
scores.set(docId, (scores.get(docId) ?? 0) + termScore);
}
}
return Array.from(scores.entries())
.map(([docId, score]) => ({ docId, score }))
.sort((a, b) => b.score - a.score);
}
private tokenize(text: string): string[] {
return text
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.split(/\s+/)
.filter((t) => t.length > 0);
}
}
/**
* High-Speed In-Memory Prefix Trie for Typeahead Autocomplete
*/
export class TrieNode {
public children = new Map<string, TrieNode>();
public suggestions: { term: string; score: number }[] = [];
}
export class TypeaheadAutocomplete {
private root = new TrieNode();
public insert(phrase: string, score: number): void {
let current = this.root;
const clean = phrase.toLowerCase().trim();
for (const char of clean) {
if (!current.children.has(char)) {
current.children.set(char, new TrieNode());
}
current = current.children.get(char)!;
// Update pre-computed top-K suggestions at each prefix node
this.updateSuggestions(current, phrase, score);
}
}
public suggest(prefix: string, maxResults = 5): string[] {
let current = this.root;
const clean = prefix.toLowerCase().trim();
for (const char of clean) {
if (!current.children.has(char)) {
return []; // Prefix not found
}
current = current.children.get(char)!;
}
return current.suggestions.slice(0, maxResults).map((s) => s.term);
}
private updateSuggestions(node: TrieNode, phrase: string, score: number): void {
// Add or update phrase in node's suggestion list
const existingIdx = node.suggestions.findIndex((s) => s.term === phrase);
if (existingIdx !== -1) {
node.suggestions[existingIdx].score = score;
} else {
node.suggestions.push({ term: phrase, score });
}
// Sort descending by score and keep top 10
node.suggestions.sort((a, b) => b.score - a.score);
if (node.suggestions.length > 10) {
node.suggestions.pop();
}
}
}
function runDemo(): void {
console.log('=== Distributed Search & Typeahead Autocomplete Architecture ===');
const index = new InvertedIndex();
index.addDocument({ id: 1, title: 'DDIA', text: 'Reliable scalable maintainable distributed systems replication' });
index.addDocument({ id: 2, title: 'Database Internals', text: 'B-Trees LSM-Trees storage engines replication consensus' });
console.log('Indexed 2 technical architecture reference books');
const searchResults = index.search('replication systems');
console.log('Search matches for replication systems: ' + searchResults.length);
for (const r of searchResults) console.log(' Doc ID ' + r.docId + ' (BM25: ' + r.score.toFixed(3) + ')');
const autocomplete = new TypeaheadAutocomplete();
autocomplete.insert('distributed transactions', 95);
autocomplete.insert('distributed systems', 90);
autocomplete.insert('database indexing', 70);
const suggestions = autocomplete.suggest('dist', 2);
console.log('Autocomplete suggestions for dist: [' + suggestions.join(', ') + ']');
}
runDemo();
8. Immutable Segments & The Tiered Merge Policy
In Lucene and Elasticsearch, an index is not a single file—it is a collection of immutable segments:
[ New Documents Arriving ]
|
v
[ In-Memory Indexing Buffer ] <-- Translog (WAL) writes to disk simultaneously
|
| refresh (Default: Every 1 second)
v
[ Segment 1 (RAM Page Cache) ] --> Searchable immediately in Near-Real-Time (NRT)!
[ Segment 2 (RAM Page Cache) ]
[ Segment 3 (RAM Page Cache) ]
|
| Background TieredMergePolicy triggers
v
[ Coalesced Master Segment 4 ] <-- Old segments deleted; tombstones expunged!
- Near-Real-Time (NRT) Refresh: When a buffer refreshes, data is written to the OS Page Cache as a new segment without calling
fsync. Sockets and queries can search it within 1 second. - Tiered Merge Policy: Having 100 small segments forces search queries to execute 100 separate lookups. Background merge threads coalesce segments of similar size into a single larger segment, resolving deletions and optimizing search read speeds.
9. Failure Modes, Deep Pagination Traps & Recovery Strategies
The Deep Pagination Trap:
A client requests page 5,000 with:
GET /search?q=phone&from=100000&size=20
Across a 10-shard cluster, every single shard must sort and return 100,020 documents to the coordinator. The coordinator node must deserialize and sort in memory before discarding the first 1,000,000!
+---------------------------------------------------------------------------------------------------+
| PAGINATION PATTERNS COMPARISON |
+---------------------------------------------------------------------------------------------------+
| Pattern | Mechanism | Complexity | Production Suitability|
+----------------+----------------------------------------------+-------------+-----------------------+
| from / size | Offset-based: Shards send all preceding hits | O(S * from) | Catastrophic for from>10k|
| scroll API | Point-in-Time snapshot cursor | O(size) | Batch export/ETL only |
| search_after | Cursor based on last document sort values | O(size) | Gold Standard for UI |
+---------------------------------------------------------------------------------------------------+
| Failure Mode | Symptom | Root Cause | Production Recovery & Mitigation |
|---|---|---|---|
| Coordinator JVM OOM Crash | CircuitBreakingException / node crash | Deep from + size offset queries across shards | Enforce index.max_result_window = 10000 and mandate stateless search_after + Point-in-Time (PIT) cursors |
| Scatter-Gather Tail Spikes | Cluster jumps from to | Single straggler shard pausing on JVM GC or disk merge | Enable Adaptive Replica Selection (ARS) routing around slow replicas and throttle background merge I/O |
| Segment Explosion (“Too Many Open Files”) | High search latency & FD exhaustion | Aggressive refresh_interval () under heavy bulk writes | Increase refresh_interval to 5s–30s during bulk indexing and allow TieredMergePolicy to catch up |
| Dynamic Mapping Explosion | Master node metadata freeze | Untrusted JSON keys creating distinct indexed fields | Enforce dynamic: strict schema mappings and index arbitrary key-value attributes using flattened fields |
10. Knowledge Check
Review the core search principles, then complete the 4-question interactive assessment quiz at the end of this lesson:
- Inverted Index Postings: Explain why mapping terms to sorted Document IDs enables microsecond boolean intersections.
- BM25 Saturation: Describe why asymptotic term saturation prevents keyword stuffing from dominating relevance.
- Prefix Tries vs. FSTs: Compare memory consumption between traditional pointer-based Tries and shared-suffix Finite State Transducers.
- Deep Pagination Mitigation: Articulate why
search_aftercursor sorting scales cleanly compared to offset-basedfrom/size.
11. Practical Exercises & Deliverables
Exercise 1: Implement Roaring Bitmap Set Intersection
Problem: Postings lists for common keywords (“data”, “system”) contain millions of integers.
Deliverable:
- Implement a 16-bit chunked bitmap structure that stores dense integer ranges as bit arrays and sparse ranges as sorted integer arrays.
- Benchmark intersecting two lists of 500,000 Document IDs using your bitmap vs. standard array intersection.
- Measure the speedup and memory compression factor.
Exercise 2: Benchmark BM25 vs. Raw Term Frequency
Problem: Demonstrate the resilience of BM25 against keyword stuffing.
Deliverable:
- Using the
InvertedIndexclass from Section 7, index 100 technical articles. - Create an adversarial document that repeats the keyword “database” 500 times in a low-relevance sentence.
- Query “database replication”. Verify that BM25 ranks an authoritative 300-word article above the stuffed spam document.
View Reference Architecture & Worked Solutions (APP-A05-001)
1. Reference Solution — Exercise 1: Implement Roaring Bitmap Set Intersection
- Roaring Container Design (16-Bit High/Low Partitioning):
- Split each 32-bit
docIdinto its upper 16 bits (chunkKey = docId >>> 16) and lower 16 bits (low16 = docId & 0xFFFF). - Within each -element chunk:
- If cardinality , store a sorted
Uint16Array(at most ). - If cardinality , promote to a fixed -bit (
Uint32Array(2048)= ) dense bitset container.
- If cardinality , store a sorted
- Split each 32-bit
- Intersection Implementation & Speedup Analysis:
export function intersectBitsetChunks(a: Uint32Array, b: Uint32Array): Uint32Array { const out = new Uint32Array(2048); // 2048 * 32 = 65,536 bits for (let i = 0; i < 2048; i++) { out[i] = a[i] & b[i]; // 32 document IDs intersected per single CPU instruction! } return out; }- Compression & Speedup: For dense Document IDs spanning 16 chunks (), a raw 32-bit integer array consumes and requires branch-heavy scalar comparisons, whereas 16 Roaring bitset containers consume ( memory compression) and intersect in branch-free bitwise
ANDoperations ( faster).
- Compression & Speedup: For dense Document IDs spanning 16 chunks (), a raw 32-bit integer array consumes and requires branch-heavy scalar comparisons, whereas 16 Roaring bitset containers consume ( memory compression) and intersect in branch-free bitwise
2. Reference Solution — Exercise 2: Benchmark BM25 vs. Raw Term Frequency
- Quantitative Comparison (
"database replication"Query):- Suppose corpus size is documents with average length , where
"database"appears in documents () and"replication"appears in documents (). - Document A (Authoritative 300-word article: , , ):
- Since , the denominator normalization .
- .
- Document B (Adversarial 600-word spam repeating
"database"500 times, , ):- Under Raw TF-IDF, scores vs. ’s , allowing spam to win by !
- Under BM25, ‘s term factor saturates at the asymptote and incurs length penalty :
- Consequently, BM25 ranks the authoritative article () more than higher than the 500-keyword spam document ().
- Suppose corpus size is documents with average length , where
12. Summary & Architectural Decision Guide
- Decouple Typeahead from Full-Text Scatter-Gather: Serve keystroke autocomplete from in-memory Prefix Tries or Lucene Finite State Transducers (FSTs) with pre-computed top- completions so keystrokes never trigger full-shard inverted-index scans.
- Use BM25 Over Unbounded TF-IDF: Configure saturation () and length normalization () to defend relevance against repetitive keyword stuffing and document-length skew.
- Keep Shard Counts Bounded: Because scatter-gather tail latency scales as , prefer fewer, well-sized shards ( per Lucene shard) with Adaptive Replica Selection rather than over-sharding small indices.
- Ban Deep Offset Pagination: Cap
from + sizeat and require deterministicsearch_aftersort-value cursors for infinite scroll and deep traversal.
13. Authoritative References & Version Context
- Stephen Robertson & Hugo Zaragoza (Foundations and Trends in Information Retrieval, 2009): The Probabilistic Relevance Framework: BM25 and Beyond (canonical derivation of Okapi BM25 term saturation and length normalization).
- Daniel Lemire et al. (Software: Practice and Experience, 2016): Consistently Faster and Smaller Compressed Bitmaps with Roaring (16-bit hybrid array/bitmap/run container intersections used in Apache Lucene).
- Apache Lucene Core Documentation —
BM25Similarity&org.apache.lucene.util.fst: Apache Lucene 9.x Core Javadocs (non-negative BM25 IDF formula, FST suffix sharing, andTieredMergePolicy; baseline Lucene 9.x). - Elasticsearch Official Guide — Paginate Search Results (
search_after& PIT): Elasticsearch Pagination Architecture (scatter-gather heap cost offrom/sizevs. Point-in-Time cursor pagination; baseline Elasticsearch 8.x / OpenSearch 2.x).