This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 2300ad54 perf: certify full-text Top-K and reuse write buffer settings 
(#1245)
2300ad54 is described below

commit 2300ad5463f9acf29d9f8045a4091162f647b463
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 28 17:54:38 2026 +0800

    perf: certify full-text Top-K and reuse write buffer settings (#1245)
---
 benchmarks/full-text/README.md                     |  59 +++++++
 benchmarks/full-text/sample-results.md             | 173 +++++++++++++++++++++
 crates/paimon/Cargo.toml                           |   4 +
 .../paimon/examples/full_text_top_k_benchmark.rs   | 162 +++++++++++++++++++
 crates/paimon/src/spec/core_options.rs             |  13 +-
 crates/paimon/src/table/full_text_index_adapter.rs | 137 +++++++++++++++-
 .../paimon/src/table/full_text_search_builder.rs   |  53 ++++++-
 .../table/full_text_search_builder/execution.rs    |   7 +-
 .../table/full_text_search_builder/parity_tests.rs | 123 +++++++++++++++
 .../src/table/full_text_search_builder/read.rs     |  16 +-
 crates/paimon/src/table/internal.rs                |  10 +-
 .../src/table/sorted_global_index_build_builder.rs |   2 +-
 .../sorted_global_index_build_builder/sort.rs      |   5 +-
 .../sorted_global_index_build_builder/tests.rs     |  70 ++++++++-
 .../sorted_global_index_build_builder/writer.rs    |   2 +-
 docs/src/benchmark.md                              |  13 ++
 docs/src/getting-started.md                        |   4 +-
 17 files changed, 821 insertions(+), 32 deletions(-)

diff --git a/benchmarks/full-text/README.md b/benchmarks/full-text/README.md
new file mode 100644
index 00000000..390d1d4e
--- /dev/null
+++ b/benchmarks/full-text/README.md
@@ -0,0 +1,59 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+# Full-text Top-K benchmark
+
+Run the production full-text scan/read path against a temporary local 
filesystem
+catalog. Each mode uses the same data, index archives and snapshot. The program
+checks exact row IDs and scores on every run, warms both modes, alternates 
their
+execution order, and emits JSON with every sample plus p50 and p95 latency.
+
+```bash
+cargo run --release --locked -p paimon --features internal-api \
+  --example full_text_top_k_benchmark -- \
+  10000 10 10 2500
+```
+
+Arguments are rows, measured iterations per mode, K, and rows per index shard.
+All must be positive. Increase rows and iterations for a representative 
workload.
+The `skewed` corpus gives each shard distinct high scores followed by many 
common
+low-score matches. The `tied` corpus has identical text in every row and 
exercises
+the exhaustive fallback required by Paimon's row-ID tie-break.
+
+The benchmark uses the `internal-api` feature's exhaustive reference reader for
+the baseline. Production searches select the optimization automatically without
+a table option. The fast path is limited to FAST-mode global full-text
+reads over disjoint index row ranges, with no raw-data sources, deletion 
vectors
+or later deletion exclusions. Native search still evaluates the complete query
+DSL and applies its allow-list before selecting candidates. A shard requests
+K+1 hits and retains K only if the score boundary is strict and the selected 
row
+IDs are distinct. Boundary ties fall back to the original full candidate limit.
+FULL/DETAIL, overlapping ranges, and deletion/raw-fallback paths remain 
exhaustive.
+Primary-key and raw temporary-index searches keep their existing behavior.
+
+This is a synthetic, warm-cache local benchmark, not an object-store, 
cold-cache,
+Lance, or whole-system memory comparison. It reports no cross-shard BM25 corpus
+normalization change. Record the revision, compiler, CPU, RAM, OS and command 
with
+results. The JSON field `optimized_build` must be true for performance reports.
+
+## Local sample
+
+A [sample run with raw timings](sample-results.md) records 100,000 matching 
rows,
+four shards, K=10 and 20 iterations on an Apple M4 Pro. It illustrates this
+synthetic workload only; use the protocol above to evaluate other workloads.
diff --git a/benchmarks/full-text/sample-results.md 
b/benchmarks/full-text/sample-results.md
new file mode 100644
index 00000000..50c8e91e
--- /dev/null
+++ b/benchmarks/full-text/sample-results.md
@@ -0,0 +1,173 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+# Local full-text Top-K sample
+
+- Date: 2026-10-28.
+- Source: `87dc89ca`, before moving the exhaustive comparison to the internal
+  benchmark reader. The raw timings below describe that revision.
+- Compiler: Rust 1.98.0, Cargo release profile.
+- Machine: Apple M4 Pro, 48 GiB RAM, macOS 27.0.1.
+- Storage: temporary local filesystem; both modes warmed before measurement.
+- Workload: 100,000 matching documents, four shards, K=10; 20 samples per mode
+  with alternating order. Every run asserts identical IDs and scores.
+
+```bash
+cargo run --release --locked -p paimon --features internal-api \
+  --example full_text_top_k_benchmark -- \
+  100000 20 10 25000
+```
+
+The command above uses the current internal benchmark entry; the original
+measurement selected the same exhaustive and certified collectors via a table 
option.
+
+| Corpus | Exhaustive p50 | Certified p50 | Exhaustive p95 | Certified p95 |
+| --- | ---: | ---: | ---: | ---: |
+| skewed | 65.680 ms | 1.300 ms | 71.194 ms | 1.584 ms |
+| tied | 61.498 ms | 59.544 ms | 65.693 ms | 65.988 ms |
+
+The skewed corpus has a strict per-shard score cutoff. The tied corpus requires
+exhaustive fallback to preserve row-ID ordering. These are warm-cache synthetic
+local results, not a general speedup claim or a comparison with Lance. The
+benchmark does not measure peak RSS, cloud requests, or cold-cache behavior.
+
+Raw JSON output (milliseconds):
+
+```json
+{
+  "cache": "warm; alternating path order",
+  "certified_ms": [
+    1.493625,
+    1.3470419999999999,
+    1.584334,
+    1.265542,
+    1.442334,
+    1.35375,
+    1.527584,
+    1.574292,
+    1.428708,
+    1.104042,
+    1.428958,
+    0.9946670000000001,
+    1.1383750000000001,
+    1.020333,
+    2.98675,
+    1.178459,
+    1.2998750000000001,
+    1.14975,
+    1.281458,
+    1.223833
+  ],
+  "certified_p50_ms": 1.2998750000000001,
+  "certified_p95_ms": 1.584334,
+  "corpus": "skewed",
+  "exhaustive_ms": [
+    63.123042000000005,
+    64.412542,
+    65.869167,
+    61.668333,
+    73.404917,
+    67.536125,
+    62.193084000000006,
+    67.91274999999999,
+    65.6795,
+    66.166958,
+    65.237833,
+    62.372875,
+    71.194334,
+    64.104334,
+    65.31225,
+    65.99804099999999,
+    68.3735,
+    68.70008299999999,
+    63.100249999999996,
+    70.09375
+  ],
+  "exhaustive_p50_ms": 65.6795,
+  "exhaustive_p95_ms": 71.194334,
+  "iterations": 20,
+  "k": 10,
+  "optimized_build": true,
+  "rows": 100000,
+  "shards": 4,
+  "storage": "local temporary filesystem"
+}
+```
+
+```json
+{
+  "cache": "warm; alternating path order",
+  "certified_ms": [
+    60.786959,
+    65.987917,
+    63.890249999999995,
+    66.674334,
+    64.062834,
+    58.520042000000004,
+    64.140084,
+    59.344916999999995,
+    59.02675,
+    59.543541,
+    56.94875,
+    59.583791999999995,
+    58.512415999999995,
+    58.018041,
+    59.953208000000004,
+    56.848166,
+    61.324375,
+    60.756958,
+    56.141957999999995,
+    55.769375000000004
+  ],
+  "certified_p50_ms": 59.543541,
+  "certified_p95_ms": 65.987917,
+  "corpus": "tied",
+  "exhaustive_ms": [
+    61.498125,
+    61.992708,
+    64.92604200000001,
+    63.23775,
+    60.250625,
+    65.69324999999999,
+    63.07212500000001,
+    69.43341699999999,
+    62.332499999999996,
+    59.03325,
+    59.881334,
+    56.568625,
+    61.359958,
+    58.073375000000006,
+    61.913416999999995,
+    61.050541,
+    62.432625,
+    61.058542,
+    57.116375,
+    63.045041
+  ],
+  "exhaustive_p50_ms": 61.498125,
+  "exhaustive_p95_ms": 65.69324999999999,
+  "iterations": 20,
+  "k": 10,
+  "optimized_build": true,
+  "rows": 100000,
+  "shards": 4,
+  "storage": "local temporary filesystem"
+}
+```
+
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index 74169546..b655d260 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -172,3 +172,7 @@ cc = "1"
 [dev-dependencies]
 axum = { version = "0.7", features = ["macros", "tokio", "http1", "http2"] }
 libc = "0.2"
+
+[[example]]
+name = "full_text_top_k_benchmark"
+required-features = ["internal-api"]
diff --git a/crates/paimon/examples/full_text_top_k_benchmark.rs 
b/crates/paimon/examples/full_text_top_k_benchmark.rs
new file mode 100644
index 00000000..1194180c
--- /dev/null
+++ b/crates/paimon/examples/full_text_top_k_benchmark.rs
@@ -0,0 +1,162 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Compare certified and exhaustive FTS on the same local table and snapshot.
+//! cargo run --release -p paimon --features internal-api --example 
full_text_top_k_benchmark -- [rows] [iterations] [k] [rows-per-shard]
+
+use arrow_array::{RecordBatch, StringArray};
+use arrow_schema::{DataType as ArrowType, Field, Schema as ArrowSchema};
+use paimon::catalog::Identifier;
+use paimon::spec::{DataType, Schema, VarCharType};
+use paimon::table::Table;
+use paimon::vector_search::ScoredRowIds;
+use paimon::{CatalogFactory, Options};
+use std::collections::HashMap;
+use std::error::Error;
+use std::sync::Arc;
+use std::time::Instant;
+
+async fn search(table: &Table, enabled: bool, k: usize) -> 
paimon::Result<ScoredRowIds> {
+    let mut builder = table.new_full_text_search_builder();
+    builder
+        .with_text_column("body")
+        .with_query_text("paimon")
+        .with_limit(k);
+    let reader = builder.new_read()?;
+    let plan = builder.new_scan()?.scan().await?;
+    let result = if enabled {
+        reader.read(plan).await?
+    } else {
+        paimon::table::internal::read_full_text_exhaustive(&reader, 
plan).await?
+    };
+    result.into_row_ids()
+}
+
+fn percentile(samples: &mut [f64], percentile: usize) -> f64 {
+    samples.sort_by(f64::total_cmp);
+    samples[(samples.len() * percentile).div_ceil(100).saturating_sub(1)]
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn Error>> {
+    let defaults = [20_000, 10, 10, 5_000];
+    let mut args = std::env::args().skip(1);
+    let mut config = defaults;
+    for (value, default) in config.iter_mut().zip(defaults) {
+        *value = args
+            .next()
+            .map(|s| s.parse::<usize>())
+            .transpose()?
+            .unwrap_or(default);
+        if *value == 0 {
+            return Err("all arguments must be positive".into());
+        }
+    }
+    if args.next().is_some() {
+        return Err("expected rows iterations k rows-per-shard".into());
+    }
+    let [rows, iterations, k, shard_rows] = config;
+    let directory = tempfile::tempdir()?;
+    let catalog = CatalogFactory::create(Options::from_map(HashMap::from([(
+        "warehouse".into(),
+        directory.path().to_string_lossy().into_owned(),
+    )])))
+    .await?;
+    catalog
+        .create_database("bench", false, HashMap::new())
+        .await?;
+    for tied in [false, true] {
+        let name = if tied { "tied" } else { "skewed" };
+        let identifier = Identifier::new("bench", name);
+        let schema = Schema::builder()
+            .column("body", DataType::VarChar(VarCharType::string_type()))
+            .option("row-tracking.enabled", "true")
+            .option("data-evolution.enabled", "true")
+            .option("global-index.enabled", "true")
+            .option("global-index.row-count-per-shard", shard_rows.to_string())
+            .build()?;
+        catalog.create_table(&identifier, schema, false).await?;
+        let table = catalog.get_table(&identifier).await?;
+        let builder = table.new_write_builder();
+        let mut writer = builder.new_write()?;
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![Field::new(
+            "body",
+            ArrowType::Utf8,
+            true,
+        )]));
+        for start in (0..rows).step_by(4096) {
+            let texts = (start..(start + 4096).min(rows))
+                .map(|row| {
+                    let position = row % shard_rows;
+                    if tied {
+                        "paimon".to_string()
+                    } else if position < 64 {
+                        "paimon ".repeat(64 - position)
+                    } else {
+                        "paimon padding padding padding padding padding 
padding padding".to_string()
+                    }
+                })
+                .collect::<Vec<_>>();
+            writer
+                .write_arrow_batch(&RecordBatch::try_new(
+                    arrow_schema.clone(),
+                    vec![Arc::new(StringArray::from(texts))],
+                )?)
+                .await?;
+        }
+        builder
+            .try_new_commit()?
+            .commit(writer.prepare_commit().await?)
+            .await?;
+        let shards = table
+            .new_global_index_build_builder()
+            .with_index_type("full-text")
+            .with_index_column("body")
+            .execute()
+            .await?;
+        // Warm both paths and assert IDs and scores before collecting timings.
+        let expected = search(&table, false, k).await?;
+        let actual = search(&table, true, k).await?;
+        assert_eq!(actual.row_ids, expected.row_ids);
+        assert_eq!(actual.scores, expected.scores);
+        let mut timings = [Vec::new(), Vec::new()];
+        for iteration in 0..iterations {
+            for mode in [iteration % 2, 1 - iteration % 2] {
+                let started = Instant::now();
+                let actual = search(&table, mode == 1, k).await?;
+                let elapsed = started.elapsed().as_secs_f64() * 1000.0;
+                assert_eq!(actual.row_ids, expected.row_ids);
+                assert_eq!(actual.scores, expected.scores);
+                timings[mode].push(elapsed);
+            }
+        }
+        println!(
+            "{}",
+            serde_json::json!({
+                "corpus": name, "rows": rows, "k": k, "shards": shards,
+                "iterations": iterations, "optimized_build": 
!cfg!(debug_assertions),
+                "cache": "warm; alternating path order", "storage": "local 
temporary filesystem",
+                "exhaustive_ms": timings[0], "certified_ms": timings[1],
+                "exhaustive_p50_ms": percentile(&mut timings[0], 50),
+                "exhaustive_p95_ms": percentile(&mut timings[0], 95),
+                "certified_p50_ms": percentile(&mut timings[1], 50),
+                "certified_p95_ms": percentile(&mut timings[1], 95),
+            })
+        );
+    }
+    Ok(())
+}
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index 6d2d2013..89071ed7 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -1195,20 +1195,21 @@ impl<'a> CoreOptions<'a> {
         Ok(value)
     }
 
-    /// Maximum retained serialized sort keys, excluding the active Arrow 
batch,
-    /// format-writer buffers and the two keys used by a disk merge.
-    pub fn sorted_index_sort_memory(&self) -> crate::Result<usize> {
+    /// Write buffer budget, matching Java `CoreOptions.WRITE_BUFFER_SIZE`.
+    /// Sorted global-index builds use it for retained serialized sort keys,
+    /// excluding the active Arrow batch, writer buffers and disk-merge keys.
+    pub fn write_buffer_size(&self) -> crate::Result<usize> {
         let raw = self
             .options
-            .get("sorted-index.sort-memory")
+            .get("write-buffer-size")
             .map(String::as_str)
-            .unwrap_or("64mb");
+            .unwrap_or("256mb");
         let bytes = parse_memory_size(raw)
             .and_then(|size| usize::try_from(size).ok())
             .filter(|size| *size > 0);
         bytes.ok_or_else(|| crate::Error::ConfigInvalid {
             message: format!(
-                "Option 'sorted-index.sort-memory' must be a positive memory 
size, got: {raw}"
+                "Option 'write-buffer-size' must be a positive memory size, 
got: {raw}"
             ),
         })
     }
diff --git a/crates/paimon/src/table/full_text_index_adapter.rs 
b/crates/paimon/src/table/full_text_index_adapter.rs
index 680d1c3d..42ae2340 100644
--- a/crates/paimon/src/table/full_text_index_adapter.rs
+++ b/crates/paimon/src/table/full_text_index_adapter.rs
@@ -28,12 +28,26 @@ use std::sync::{mpsc, Arc, Mutex};
 
 const FULL_TEXT_RANGE_READ_CONCURRENCY: usize = 8;
 
+#[cfg(test)]
 pub(crate) async fn search_full_text_index(
     reader: Box<dyn FileRead>,
     file_name: String,
     query: String,
     limit: usize,
     include_row_ids: Option<RoaringTreemap>,
+) -> crate::Result<FullTextSearchResult> {
+    search_full_text_index_top_k(reader, file_name, query, limit, 
include_row_ids, None).await
+}
+
+/// The caller may request bounded candidates only when no later stage removes,
+/// replaces or deduplicates hits across shards. Ties at the boundary fall 
back.
+pub(crate) async fn search_full_text_index_top_k(
+    reader: Box<dyn FileRead>,
+    file_name: String,
+    query: String,
+    limit: usize,
+    include_row_ids: Option<RoaringTreemap>,
+    top_k: Option<usize>,
 ) -> crate::Result<FullTextSearchResult> {
     let handle =
         tokio::runtime::Handle::try_current().map_err(|error| 
crate::Error::UnexpectedError {
@@ -48,6 +62,7 @@ pub(crate) async fn search_full_text_index(
             &query,
             limit,
             include_row_ids,
+            top_k,
         )
     })
     .await
@@ -72,6 +87,7 @@ pub(crate) async fn search_full_text_file(
             &query,
             limit,
             include_row_ids,
+            None,
         )
     })
     .await
@@ -87,20 +103,56 @@ fn search_native_index<R: SeekRead + 'static>(
     query: &str,
     limit: usize,
     include_row_ids: Option<RoaringTreemap>,
+    top_k: Option<usize>,
 ) -> crate::Result<FullTextSearchResult> {
     let reader = FullTextArchiveReader::from_seek_read(input)
         .map_err(|error| native_error(file_name, error))?;
-    let hits = match include_row_ids {
-        Some(include) => reader.search_with_include(query, limit, &include),
-        None => reader.search(query, limit),
-    }
-    .map_err(|error| native_error(file_name, error))?;
+    let search = |limit| {
+        match &include_row_ids {
+            Some(include) => reader.search_with_include(query, limit, include),
+            None => reader.search(query, limit),
+        }
+        .map_err(|error| native_error(file_name, error))
+    };
+    let hits = if let Some(k) = top_k.filter(|k| *k > 0 && *k < limit) {
+        let candidate_count = k.saturating_add(1).min(limit);
+        let mut hits = search(candidate_count)?;
+        if certify_top_k(&mut hits, k) {
+            hits
+        } else {
+            search(limit)?
+        }
+    } else {
+        search(limit)?
+    };
     Ok(FullTextSearchResult {
         row_ids: hits.row_ids,
         scores: hits.scores,
     })
 }
 
+fn certify_top_k(hits: &mut crate::ftindex::reader::FullTextHits, k: usize) -> 
bool {
+    if hits.row_ids.len() <= k {
+        return true;
+    }
+    // Tantivy's tie-break uses document addresses, while Paimon uses row IDs.
+    // Fetch one extra hit to prove that no excluded document can tie the 
cutoff.
+    // Duplicate row IDs also require the exhaustive path before deduplication.
+    let distinct = hits.row_ids[..k]
+        .iter()
+        .collect::<std::collections::HashSet<_>>();
+    if distinct.len() != k
+        || !hits.scores[k - 1].is_finite()
+        || !hits.scores[k].is_finite()
+        || hits.scores[k - 1] <= hits.scores[k]
+    {
+        return false;
+    }
+    hits.row_ids.truncate(k);
+    hits.scores.truncate(k);
+    true
+}
+
 fn native_error(file_name: &str, error: impl std::fmt::Display) -> 
crate::Error {
     crate::Error::UnexpectedError {
         message: format!("Failed to read full-text index '{file_name}': 
{error}"),
@@ -254,6 +306,81 @@ mod tests {
         bytes
     }
 
+    #[test]
+    fn certificate_requires_strict_scores_and_distinct_row_ids() {
+        use crate::ftindex::reader::FullTextHits;
+        let mut strict = FullTextHits {
+            row_ids: vec![9, 3, 1],
+            scores: vec![3.0, 2.0, 1.0],
+        };
+        assert!(certify_top_k(&mut strict, 2));
+        assert_eq!(strict.row_ids, [9, 3]);
+        for (row_ids, scores) in [
+            (vec![9, 3, 1], vec![3.0, 2.0, 2.0]),
+            (vec![9, 9, 1], vec![3.0, 2.0, 1.0]),
+            (vec![9, 3, 1], vec![3.0, f32::NAN, 1.0]),
+        ] {
+            let mut hits = FullTextHits {
+                row_ids: row_ids.clone(),
+                scores,
+            };
+            assert!(!certify_top_k(&mut hits, 2));
+            assert_eq!(hits.row_ids, row_ids);
+        }
+    }
+
+    #[tokio::test]
+    async fn bounded_candidates_match_exhaustive_and_ties_fall_back() {
+        for tied in [false, true] {
+            let mut writer = 
FullTextIndexWriter::new(FullTextIndexConfig::new()).unwrap();
+            // Deliberately reverse row IDs so document-address ties cannot 
stand
+            // in for the public row-ID tie-break.
+            for (id, text) in [
+                (9, "paimon"),
+                (3, "paimon padding"),
+                (1, "paimon padding padding padding"),
+            ] {
+                writer
+                    .add_document(id, if tied { "paimon" } else { text })
+                    .unwrap();
+            }
+            let mut data = Vec::new();
+            writer.write(&mut PosWriter::new(&mut data)).unwrap();
+            let query = r#"{"match":{"query":"paimon"}}"#.to_string();
+            let search = |top_k| {
+                search_full_text_index_top_k(
+                    Box::new(BytesFileRead {
+                        data: Bytes::from(data.clone()),
+                        ranges: Default::default(),
+                    }),
+                    "bounded.index".into(),
+                    query.clone(),
+                    3,
+                    None,
+                    top_k,
+                )
+            };
+            let exhaustive = search(None).await.unwrap();
+            let bounded = search(Some(1)).await.unwrap();
+            assert_eq!(bounded.row_ids.len(), if tied { 3 } else { 1 });
+            let expected = crate::full_text::SearchResult::new(
+                exhaustive.row_ids.into_iter().map(|id| id as u64).collect(),
+                exhaustive.scores,
+            )
+            .top_k(1);
+            let actual = crate::full_text::SearchResult::new(
+                bounded.row_ids.into_iter().map(|id| id as u64).collect(),
+                bounded.scores,
+            )
+            .top_k(1);
+            assert_eq!(actual.row_ids, expected.row_ids);
+            assert_eq!(actual.scores, expected.scores);
+            if tied {
+                assert_eq!(actual.row_ids, [1]);
+            }
+        }
+    }
+
     #[test]
     fn test_range_backed_search_works_on_current_thread_runtime() {
         let runtime = tokio::runtime::Builder::new_current_thread()
diff --git a/crates/paimon/src/table/full_text_search_builder.rs 
b/crates/paimon/src/table/full_text_search_builder.rs
index dfebafab..0f573240 100644
--- a/crates/paimon/src/table/full_text_search_builder.rs
+++ b/crates/paimon/src/table/full_text_search_builder.rs
@@ -26,7 +26,7 @@ use crate::spec::{
     IndexManifestEntry, Predicate, ROW_ID_FIELD_NAME,
 };
 use crate::table::data_file_reader::DataFileReader;
-use crate::table::full_text_index_adapter::{search_full_text_file, 
search_full_text_index};
+use crate::table::full_text_index_adapter::{search_full_text_file, 
search_full_text_index_top_k};
 use crate::table::global_index_scanner::{
     deleted_row_ranges_for_data_evolution_dvs, 
unindexed_ranges_for_global_index_entries,
     RowRangeIndex,
@@ -259,6 +259,7 @@ struct FullTextSearchEvaluation<'a> {
     partition_filter: Option<&'a Predicate>,
     row_filter: Option<&'a Predicate>,
     source_splits: Option<&'a [crate::table::SearchScanSplit]>,
+    allow_top_k: bool,
 }
 
 #[cfg(test)]
@@ -362,12 +363,25 @@ async fn evaluate_full_text_search_with_executor(
             fulltext_entries,
             indexed_search.include_row_ids.as_ref(),
         )?;
+        // Raw fallback can replace indexed hits, and deletion vectors are 
applied
+        // after the shard searches. Those paths require exhaustive candidates.
+        let top_k = (evaluation.allow_top_k
+            && search_mode == GlobalIndexSearchMode::Fast
+            && deleted_row_index.is_none()
+            && !core_options.deletion_vectors_enabled()
+            && evaluation.source_splits.is_none_or(|splits| {
+                splits
+                    .iter()
+                    .all(|split| !matches!(split, 
crate::table::SearchScanSplit::Raw { .. }))
+            })
+            && disjoint_full_text_ranges(&plans))
+        .then_some(search.limit);
         let results = if let Some(executor) = executor {
             let table = evaluation.table.ok_or_else(|| 
crate::Error::DataInvalid {
                 message: "Scheduled full-text search requires table 
context".into(),
                 source: None,
             })?;
-            execution::search_indexes(table, plans, &search.query_text, 
executor).await?
+            execution::search_indexes(table, plans, &search.query_text, top_k, 
executor).await?
         } else {
             let results = plans
                 .into_iter()
@@ -383,18 +397,18 @@ async fn evaluate_full_text_search_with_executor(
                     let local_filter = plan.local_filter;
                     let row_range_start = global_meta.row_range_start;
                     let row_range_end = global_meta.row_range_end;
-                    // Compound query leaves must see every candidate in the 
shard
-                    // before Boolean/boost composition and the final global 
Top-K.
+                    // The native engine evaluates the complete query before 
selecting hits.
                     let limit = candidate_limit(row_range_start, 
row_range_end)?;
                     Ok::<_, crate::Error>(async move {
                         let input = evaluation.file_io.new_input(&path)?;
                         let reader = Box::new(input.reader().await?);
-                        let result = search_full_text_index(
+                        let result = search_full_text_index_top_k(
                             reader,
                             file_name,
                             query_text,
                             limit,
                             local_filter,
+                            top_k,
                         )
                         .await?;
                         Ok::<_, crate::Error>(
@@ -498,6 +512,16 @@ struct FullTextIndexSearchPlan {
     local_filter: Option<RoaringTreemap>,
 }
 
+fn disjoint_full_text_ranges(plans: &[FullTextIndexSearchPlan]) -> bool {
+    let mut ranges = plans
+        .iter()
+        .filter_map(|plan| plan.entry.index_file.global_index_meta.as_ref())
+        .map(|meta| (meta.row_range_start, meta.row_range_end))
+        .collect::<Vec<_>>();
+    ranges.sort_unstable();
+    ranges.windows(2).all(|pair| pair[0].1 < pair[1].0)
+}
+
 fn plan_full_text_index_searches(
     entries: Vec<&IndexManifestEntry>,
     include_row_ids: Option<&RoaringTreemap>,
@@ -1016,6 +1040,7 @@ mod tests {
                 partition_filter: None,
                 row_filter: None,
                 source_splits: None,
+                allow_top_k: true,
             },
             &[],
             &search,
@@ -1172,6 +1197,7 @@ mod tests {
                 partition_filter: None,
                 row_filter: None,
                 source_splits: None,
+                allow_top_k: true,
             },
             &[entry],
             &search,
@@ -1259,6 +1285,7 @@ mod tests {
                 partition_filter: None,
                 row_filter: None,
                 source_splits: None,
+                allow_top_k: true,
             },
             &[entry, missing_empty_filter_entry],
             &search,
@@ -1372,6 +1399,22 @@ mod tests {
         bytes
     }
 
+    #[test]
+    fn top_k_requires_disjoint_inclusive_shard_ranges() {
+        let entries = [
+            full_text_index_entry("a", 0, 9),
+            full_text_index_entry("b", 10, 19),
+        ];
+        let plans = plan_full_text_index_searches(entries.iter().collect(), 
None).unwrap();
+        assert!(disjoint_full_text_ranges(&plans));
+        let entries = [
+            full_text_index_entry("a", 0, 9),
+            full_text_index_entry("b", 9, 19),
+        ];
+        let plans = plan_full_text_index_searches(entries.iter().collect(), 
None).unwrap();
+        assert!(!disjoint_full_text_ranges(&plans));
+    }
+
     #[test]
     fn 
test_plan_full_text_index_searches_partitions_once_and_preserves_order() {
         let entries = [
diff --git a/crates/paimon/src/table/full_text_search_builder/execution.rs 
b/crates/paimon/src/table/full_text_search_builder/execution.rs
index 26260898..53c6b9d8 100644
--- a/crates/paimon/src/table/full_text_search_builder/execution.rs
+++ b/crates/paimon/src/table/full_text_search_builder/execution.rs
@@ -30,6 +30,8 @@ const ROWS: &str = "fulltext-rows";
 struct IndexWork {
     entry: IndexManifestEntry,
     query: String,
+    #[serde(default)]
+    top_k: Option<usize>,
     local_filter: Option<Vec<u8>>,
 }
 
@@ -101,6 +103,7 @@ pub(super) async fn search_indexes(
     table: &Table,
     plans: Vec<FullTextIndexSearchPlan>,
     query: &str,
+    top_k: Option<usize>,
     executor: &dyn SearchTaskExecutor,
 ) -> crate::Result<Vec<SearchResult>> {
     let mut plans = plans.into_iter();
@@ -118,6 +121,7 @@ pub(super) async fn search_indexes(
                     encode(IndexWork {
                         entry: plan.entry,
                         query: query.to_string(),
+                        top_k,
                         local_filter: 
plan.local_filter.as_ref().map(encode_bitmap).transpose()?,
                     })?,
                 )
@@ -193,12 +197,13 @@ pub(crate) async fn execute_search_task(
             );
             let input = table.file_io().new_input(&path)?;
             let reader = Box::new(input.reader().await?);
-            let result = search_full_text_index(
+            let result = search_full_text_index_top_k(
                 reader,
                 work.entry.index_file.file_name.clone(),
                 work.query,
                 limit,
                 work.local_filter.map(decode_bitmap).transpose()?,
+                work.top_k,
             )
             .await?;
             encode(Hits::from_search(
diff --git a/crates/paimon/src/table/full_text_search_builder/parity_tests.rs 
b/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
index f2f37752..6abae912 100644
--- a/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
+++ b/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
@@ -1095,3 +1095,126 @@ async fn 
index_only_data_plan_applies_reader_partitions_across_commits() {
         assert_eq!(user_ids(&table, result).await, expected);
     }
 }
+
+#[tokio::test]
+async fn certified_top_k_preserves_local_and_scheduled_filtered_results() {
+    let table = new_table(false).copy_with_options(HashMap::from([(
+        "full-text-index.search-mode".into(),
+        "fast".into(),
+    )]));
+    let schema = Arc::new(ArrowSchema::new(vec![
+        Field::new("id", ArrowType::Int32, true),
+        Field::new("text", ArrowType::Utf8, true),
+        Field::new("label", ArrowType::Utf8, true),
+        Field::new("pt", ArrowType::Int32, true),
+    ]));
+    let mut writer = TableWrite::new(&table, 
"distinct-scores".into()).unwrap();
+    writer
+        .write_arrow_batch(
+            &RecordBatch::try_new(
+                schema,
+                vec![
+                    Arc::new(Int32Array::from((0..8).collect::<Vec<_>>())) as 
ArrayRef,
+                    Arc::new(StringArray::from(
+                        (0..8)
+                            .map(|id| "paimon ".repeat(8 - id % 4))
+                            .collect::<Vec<_>>(),
+                    )),
+                    Arc::new(StringArray::from(vec![
+                        "drop", "keep", "keep", "keep", "drop", "keep", 
"keep", "keep",
+                    ])),
+                    Arc::new(Int32Array::from(vec![0; 8])),
+                ],
+            )
+            .unwrap(),
+        )
+        .await
+        .unwrap();
+    TableCommit::new(table.clone(), "distinct-scores".into())
+        .commit(writer.prepare_commit().await.unwrap())
+        .await
+        .unwrap();
+    index(&table, "full-text", "text").await;
+    let filter = PredicateBuilder::new(table.schema().fields())
+        .equal("label", Datum::String("keep".into()))
+        .unwrap();
+    let mut builder = query(&table);
+    builder.with_filter(filter).with_limit(2);
+    let read = builder.new_read().unwrap();
+    let plan = builder.new_scan().unwrap().scan().await.unwrap();
+    let exhaustive = CandidateCountExecutor {
+        inner: WireExecutor {
+            table: table.clone(),
+            work: Default::default(),
+            batch_sizes: Default::default(),
+            corrupt_scores: false,
+        },
+        expected_candidates: 3,
+    };
+    let expected = read
+        .read_exhaustive(plan.clone(), Some(&exhaustive))
+        .await
+        .unwrap()
+        .into_row_ids()
+        .unwrap();
+    assert!(exhaustive
+        .inner
+        .work
+        .lock()
+        .unwrap()
+        .iter()
+        .all(|task| task["payload"]["top_k"].is_null()));
+    let local = read
+        .read(plan.clone())
+        .await
+        .unwrap()
+        .into_row_ids()
+        .unwrap();
+    let executor = WireExecutor {
+        table: table.clone(),
+        work: Default::default(),
+        batch_sizes: Default::default(),
+        corrupt_scores: false,
+    };
+    let checked = CandidateCountExecutor {
+        inner: executor,
+        expected_candidates: 2,
+    };
+    let remote = read
+        .read_with_executor(plan, &checked)
+        .await
+        .unwrap()
+        .into_row_ids()
+        .unwrap();
+    assert_eq!(local.row_ids, expected.row_ids);
+    assert_eq!(local.scores, expected.scores);
+    assert_eq!(remote.row_ids, expected.row_ids);
+    assert_eq!(remote.scores, expected.scores);
+    let work = checked.inner.work.lock().unwrap();
+    assert!(work.iter().all(|task| task["payload"]["top_k"] == 2));
+}
+
+struct CandidateCountExecutor {
+    inner: WireExecutor,
+    expected_candidates: usize,
+}
+
+#[async_trait::async_trait]
+impl crate::table::SearchTaskExecutor for CandidateCountExecutor {
+    async fn execute(
+        &self,
+        tasks: Vec<crate::table::SearchTask>,
+    ) -> crate::Result<Vec<crate::table::SearchTaskResult>> {
+        let results = self.inner.execute(tasks).await?;
+        assert!(!results.is_empty());
+        for result in &results {
+            let value: serde_json::Value = 
serde_json::from_slice(&result.serialize()?).unwrap();
+            assert_eq!(
+                value["payload"]["row_ids"].as_array().unwrap().len(),
+                self.expected_candidates,
+                "worker must return the expected candidate count"
+            );
+        }
+        Ok(results)
+    }
+}
diff --git a/crates/paimon/src/table/full_text_search_builder/read.rs 
b/crates/paimon/src/table/full_text_search_builder/read.rs
index 81502982..b12bd5ea 100644
--- a/crates/paimon/src/table/full_text_search_builder/read.rs
+++ b/crates/paimon/src/table/full_text_search_builder/read.rs
@@ -86,7 +86,7 @@ impl FullTextRead {
         &self,
         plan: FullTextScanPlan,
     ) -> crate::Result<crate::table::GlobalIndexResult> {
-        self.read_internal(plan, None).await
+        self.read_internal(plan, None, true).await
     }
 
     /// Execute shard work through a scheduler while retaining the plan's
@@ -96,13 +96,24 @@ impl FullTextRead {
         plan: FullTextScanPlan,
         executor: &dyn crate::table::SearchTaskExecutor,
     ) -> crate::Result<crate::table::GlobalIndexResult> {
-        self.read_internal(plan, Some(executor)).await
+        self.read_internal(plan, Some(executor), true).await
+    }
+
+    /// Exhaustive reference path for internal benchmarks and parity tests.
+    #[cfg(any(feature = "internal-api", test))]
+    pub(crate) async fn read_exhaustive(
+        &self,
+        plan: FullTextScanPlan,
+        executor: Option<&dyn crate::table::SearchTaskExecutor>,
+    ) -> crate::Result<crate::table::GlobalIndexResult> {
+        self.read_internal(plan, executor, false).await
     }
 
     async fn read_internal(
         &self,
         plan: FullTextScanPlan,
         executor: Option<&dyn crate::table::SearchTaskExecutor>,
+        allow_top_k: bool,
     ) -> crate::Result<crate::table::GlobalIndexResult> {
         
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
         if executor.is_some() && 
!self.table.schema().core_options().data_evolution_enabled() {
@@ -268,6 +279,7 @@ impl FullTextRead {
                 partition_filter: partition_filter.as_ref(),
                 row_filter: row_filter.as_ref(),
                 source_splits: Some(plan.splits()),
+                allow_top_k,
             },
             &entries,
             &search,
diff --git a/crates/paimon/src/table/internal.rs 
b/crates/paimon/src/table/internal.rs
index b840f113..497b57d5 100644
--- a/crates/paimon/src/table/internal.rs
+++ b/crates/paimon/src/table/internal.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Unstable execution bridge for the repository's language bindings.
+//! Unstable execution bridge for the repository's language bindings and 
benchmarks.
 //!
 //! Source plans are the public input to search readers. Candidate and scoring
 //! requests below are internal execution stages: Core owns their ordering and
@@ -60,6 +60,14 @@ pub async fn read_full_text_with_executor(
     reader.read_with_executor(plan, executor).await
 }
 
+/// Run the exhaustive reference collector for benchmarks without a table 
option.
+pub async fn read_full_text_exhaustive(
+    reader: &super::FullTextRead,
+    plan: super::FullTextScanPlan,
+) -> crate::Result<GlobalIndexResult> {
+    reader.read_exhaustive(plan, None).await
+}
+
 pub async fn execute_hybrid_with_executor(
     builder: &HybridSearchBuilder<'_>,
     executor: &dyn SearchTaskExecutor,
diff --git a/crates/paimon/src/table/sorted_global_index_build_builder.rs 
b/crates/paimon/src/table/sorted_global_index_build_builder.rs
index 238f02dc..8d6be185 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder.rs
@@ -151,7 +151,7 @@ impl<'a> SortedGlobalIndexBuildBuilder<'a> {
         super::global_index_build_common::validate_build_table(self.table)?;
         let records_per_range = core_options.sorted_index_records_per_range()?;
         if index_type != FM_GLOBAL_INDEX_TYPE {
-            core_options.sorted_index_sort_memory()?;
+            core_options.write_buffer_size()?;
         }
         let write_options = if index_type == FM_GLOBAL_INDEX_TYPE {
             
GlobalIndexWriteOptions::FM(FMOptions::from_options(&resolved_options)?.write)
diff --git a/crates/paimon/src/table/sorted_global_index_build_builder/sort.rs 
b/crates/paimon/src/table/sorted_global_index_build_builder/sort.rs
index 68e1ecd2..dee45255 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder/sort.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder/sort.rs
@@ -58,7 +58,10 @@ impl IndexSorter {
             .ok_or_else(|| io_error(std::io::Error::other("index key size 
overflow")))?;
         if bytes > self.budget {
             return Err(Error::ResourceExhausted {
-                message: format!("Sorted index key requires {bytes} bytes, 
exceeding sorted-index.sort-memory={}", self.budget),
+                message: format!(
+                    "Sorted index key requires {bytes} bytes, exceeding 
write-buffer-size={}",
+                    self.budget
+                ),
             });
         }
         if bytes > self.budget - self.bytes {
diff --git a/crates/paimon/src/table/sorted_global_index_build_builder/tests.rs 
b/crates/paimon/src/table/sorted_global_index_build_builder/tests.rs
index d393a321..a47de3da 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder/tests.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder/tests.rs
@@ -844,7 +844,7 @@ async fn assert_btree_build_version(version: u32, budget: 
&str) {
     let mut options = table_options("10");
     options.insert("read.batch-size".into(), "1".into());
     options.insert("btree-index.file-version".into(), version.to_string());
-    options.insert("sorted-index.sort-memory".into(), budget.to_string());
+    options.insert("write-buffer-size".into(), budget.to_string());
     options.insert(
         "btree-index.bloom-filter.enabled".to_string(),
         "true".to_string(),
@@ -1637,7 +1637,7 @@ async fn 
test_detail_mode_defers_manifest_pruning_for_unindexed_ranges() {
 async fn test_execute_writes_bitmap_index_manifest_and_java_file() {
     let table_path = "memory:/test_bitmap_global_index_builder_e2e";
     let mut options = table_options("10");
-    options.insert("sorted-index.sort-memory".into(), "128b".into());
+    options.insert("write-buffer-size".into(), "128b".into());
     let table = test_table_with_path(table_path, options);
     setup_dirs(&table).await;
 
@@ -1742,7 +1742,7 @@ async fn 
test_execute_writes_bitmap_index_manifest_and_java_file() {
 async fn test_execute_multivalue_index_and_array_queries_end_to_end() {
     let table_path = "memory:/test_multivalue_global_index_builder_e2e";
     let table = multivalue_table(table_path).copy_with_options(HashMap::from([
-        ("sorted-index.sort-memory".into(), "128b".into()),
+        ("write-buffer-size".into(), "128b".into()),
         ("read.batch-size".into(), "1".into()),
     ]));
     setup_dirs(&table).await;
@@ -2806,7 +2806,7 @@ async fn composite_ranges(
 async fn test_composite_btree_build_query_coverage_and_drop() {
     for version in [1, 2] {
         let mut options = table_options("2");
-        options.insert("sorted-index.sort-memory".into(), "128b".into());
+        options.insert("write-buffer-size".into(), "128b".into());
         options.insert("btree-index.file-version".into(), version.to_string());
         options.insert("btree-index.bloom-filter.enabled".into(), 
"true".into());
         let table =
@@ -3639,10 +3639,10 @@ async fn 
test_composite_btree_preserves_exact_column_names() {
 }
 
 #[tokio::test]
-async fn invalid_sort_budget_is_rejected_before_an_empty_build() {
+async fn invalid_write_buffer_size_is_rejected_before_an_empty_build() {
     for value in ["0", "-1mb", "invalid"] {
         let mut options = table_options("10");
-        options.insert("sorted-index.sort-memory".into(), value.into());
+        options.insert("write-buffer-size".into(), value.into());
         let table = test_table(options);
         let error = table
             .new_global_index_build_builder()
@@ -3651,6 +3651,62 @@ async fn 
invalid_sort_budget_is_rejected_before_an_empty_build() {
             .await
             .unwrap_err();
         assert!(matches!(error, Error::ConfigInvalid { .. }));
-        assert!(error.to_string().contains("sorted-index.sort-memory"));
+        assert!(error.to_string().contains("write-buffer-size"));
     }
 }
+
+#[tokio::test]
+async fn write_buffer_size_limits_keys_and_builder_options_override_table() {
+    let table = test_table_with_path(
+        "memory:/index_build_write_buffer_override",
+        table_options("10"),
+    );
+    setup_dirs(&table).await;
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    writer
+        .write_arrow_batch(&data_batch(vec![1, 2, 3], vec!["alice", "bob", 
"alice"]))
+        .await
+        .unwrap();
+    builder
+        .new_commit()
+        .commit(writer.prepare_commit().await.unwrap())
+        .await
+        .unwrap();
+    let table = 
table.copy_with_options(HashMap::from([("write-buffer-size".into(), 
"1b".into())]));
+    let before = table
+        .snapshot_manager()
+        .get_latest_snapshot_id()
+        .await
+        .unwrap();
+    let error = table
+        .new_global_index_build_builder()
+        .with_index_column("name")
+        .build()
+        .await
+        .unwrap_err();
+    assert!(matches!(error, Error::ResourceExhausted { .. }));
+    assert!(error.to_string().contains("write-buffer-size"));
+    assert_eq!(
+        table
+            .snapshot_manager()
+            .get_latest_snapshot_id()
+            .await
+            .unwrap(),
+        before
+    );
+    assert_eq!(
+        table
+            .new_global_index_build_builder()
+            .with_index_column("name")
+            .with_options(HashMap::from([("write-buffer-size".into(), 
"128b".into())]))
+            .execute()
+            .await
+            .unwrap(),
+        1
+    );
+    let predicate = PredicateBuilder::new(table.schema().fields())
+        .equal("name", Datum::String("alice".into()))
+        .unwrap();
+    assert_eq!(scan_ids(&table, predicate).await, vec![1, 3]);
+}
diff --git 
a/crates/paimon/src/table/sorted_global_index_build_builder/writer.rs 
b/crates/paimon/src/table/sorted_global_index_build_builder/writer.rs
index 1523e91d..83ec21c1 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder/writer.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder/writer.rs
@@ -81,7 +81,7 @@ impl SortedGlobalIndexBuildBuilder<'_> {
         } else {
             let mut options = self.table.schema().options().clone();
             options.extend(self.options.clone());
-            let budget = 
crate::spec::CoreOptions::new(&options).sorted_index_sort_memory()?;
+            let budget = 
crate::spec::CoreOptions::new(&options).write_buffer_size()?;
             extract_index_rows(
                 self.table,
                 shard,
diff --git a/docs/src/benchmark.md b/docs/src/benchmark.md
index 9c433ea8..59f64813 100644
--- a/docs/src/benchmark.md
+++ b/docs/src/benchmark.md
@@ -303,3 +303,16 @@ useful as a reproducible engineering checkpoint, not a 
universal performance
 ranking. A broader study should repeat complete benchmark processes, separate
 cold and warm cache states, and include object storage and multi-node
 environments.
+
+## Full-text Top-K
+
+The repository also includes a synthetic full-text benchmark comparing 
certified
+per-shard Top-K with exhaustive candidate collection on the same local table 
and
+snapshot. It checks exact row IDs and scores, alternates execution order, and
+reports latency for distinct-score and boundary-tie corpora. See the
+[benchmark 
protocol](https://github.com/apache/paimon-rust/tree/main/benchmarks/full-text).
+
+The optimization is selected automatically for FAST-mode global searches with
+disjoint index ranges and no raw sources or deletion exclusions. Other paths 
and boundary ties retain
+exhaustive evaluation. The benchmark uses an internal reference reader to 
compare
+against exhaustive candidate collection without adding a table option.
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index d64108bd..5bcce1a8 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -252,8 +252,8 @@ and additional index types are not supported.
 ## Scalar Global Index Build Memory
 
 BTree, Bitmap, and Multivalue global index builds stream projected Arrow 
batches
-and sort serialized keys within `sorted-index.sort-memory` (default `64mb`).
-This is a Rust build setting and does not change index files or shard 
boundaries.
+and sort serialized keys within `write-buffer-size` (default `256mb`), matching
+Java's sort buffer configuration. This does not change index files or shard 
boundaries.
 Set it as a table property or pass it through the index builder's 
`with_options`.
 
 When keys exceed the sort buffer, the build writes temporary runs to the system

Reply via email to