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 7d12424d perf(vector-search): execute PK-vector leaves concurrently 
(#647)
7d12424d is described below

commit 7d12424da183e6c80c898cf6c4a140ffe4e0562c
Author: Junrui Lee <[email protected]>
AuthorDate: Tue Aug 4 23:45:00 2026 +0800

    perf(vector-search): execute PK-vector leaves concurrently (#647)
---
 crates/paimon/src/spec/core_options.rs            |   40 +
 crates/paimon/src/table/pk_vector_orchestrator.rs |   60 +-
 crates/paimon/src/table/vector_search_builder.rs  |  295 +++--
 crates/paimon/src/vindex/executor.rs              |  136 ++-
 crates/paimon/src/vindex/pkvector/ann.rs          |  414 +++++--
 crates/paimon/src/vindex/pkvector/bucket.rs       | 1298 ++++++++++++++++++---
 crates/paimon/src/vindex/reader.rs                |   12 +
 7 files changed, 1799 insertions(+), 456 deletions(-)

diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index e33cd09b..1039800d 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -123,6 +123,15 @@ const DYNAMIC_BUCKET_TARGET_ROW_NUM_OPTION: &str = 
"dynamic-bucket.target-row-nu
 const DEFAULT_DYNAMIC_BUCKET_TARGET_ROW_NUM: i64 = 200_000;
 const DEFAULT_GLOBAL_INDEX_ROW_COUNT_PER_SHARD: i64 = 100_000;
 const DEFAULT_GLOBAL_INDEX_THREAD_NUM: i64 = 32;
+const MAX_GLOBAL_INDEX_THREAD_NUM: i64 = {
+    let tokio_max = (usize::MAX >> 3) as u64;
+    let i32_max = i32::MAX as u64;
+    if tokio_max < i32_max {
+        tokio_max as i64
+    } else {
+        i32_max as i64
+    }
+};
 const DEFAULT_GLOBAL_INDEX_FALLBACK_SCAN_MAX_SIZE: i64 = 256 * 1024 * 1024;
 const BLOB_AS_DESCRIPTOR_OPTION: &str = "blob-as-descriptor";
 pub(crate) const BLOB_FIELD_OPTION: &str = "blob-field";
@@ -664,6 +673,15 @@ impl<'a> CoreOptions<'a> {
                 source: None,
             });
         }
+        if value > MAX_GLOBAL_INDEX_THREAD_NUM {
+            return Err(crate::Error::DataInvalid {
+                message: format!(
+                    "Option '{}' must not exceed {}, got: {}",
+                    GLOBAL_INDEX_THREAD_NUM_OPTION, 
MAX_GLOBAL_INDEX_THREAD_NUM, value
+                ),
+                source: None,
+            });
+        }
         Ok(value as usize)
     }
 
@@ -1578,6 +1596,28 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_global_index_thread_num_rejects_values_above_max() {
+        assert!(MAX_GLOBAL_INDEX_THREAD_NUM as usize <= 
tokio::sync::Semaphore::MAX_PERMITS);
+
+        let too_big = (MAX_GLOBAL_INDEX_THREAD_NUM + 1).to_string();
+        let options = 
HashMap::from([(GLOBAL_INDEX_THREAD_NUM_OPTION.to_string(), too_big)]);
+        let err = CoreOptions::new(&options)
+            .global_index_thread_num()
+            .expect_err("thread-num above maximum should fail");
+        assert!(matches!(err, crate::Error::DataInvalid { message, .. }
+                if message.contains("must not exceed")));
+
+        let at_max = HashMap::from([(
+            GLOBAL_INDEX_THREAD_NUM_OPTION.to_string(),
+            MAX_GLOBAL_INDEX_THREAD_NUM.to_string(),
+        )]);
+        assert_eq!(
+            CoreOptions::new(&at_max).global_index_thread_num().unwrap(),
+            MAX_GLOBAL_INDEX_THREAD_NUM as usize
+        );
+    }
+
     #[test]
     fn test_sorted_index_records_per_range_rejects_invalid_values() {
         for value in ["0", "-1", "abc"] {
diff --git a/crates/paimon/src/table/pk_vector_orchestrator.rs 
b/crates/paimon/src/table/pk_vector_orchestrator.rs
index 2278b0d2..1a816bd9 100644
--- a/crates/paimon/src/table/pk_vector_orchestrator.rs
+++ b/crates/paimon/src/table/pk_vector_orchestrator.rs
@@ -28,17 +28,15 @@ use std::sync::Arc;
 
 use roaring::RoaringTreemap;
 
-use futures::stream::{self, StreamExt, TryStreamExt};
-use tokio::sync::Semaphore;
-
 use crate::deletion_vector::DeletionVector;
 use crate::spec::BinaryRow;
 use crate::table::data_file_reader::DataFileReader;
 use crate::table::pk_vector_indexed_split_read::PkVectorIndexedSplit;
 use crate::table::source::{DataSplit, DataSplitBuilder, RowRange};
+use crate::vindex::executor::drain_indexed_jobs;
 use crate::vindex::pkvector::ann::PkVectorAnnSearcher;
 use crate::vindex::pkvector::bucket::{
-    bucket_search_batch, BucketActiveFile, BucketAnnSegment, 
ExactFileSearchFuture,
+    bucket_search_batch, BucketActiveFile, BucketAnnSegment, 
ExactFileSearchFuture, SearchBudget,
 };
 use crate::vindex::pkvector::metric::{java_float_compare, VectorSearchMetric};
 use crate::vindex::pkvector::result::PkVectorSearchResult;
@@ -379,7 +377,7 @@ impl PkVectorOrchestrator {
         metric: VectorSearchMetric,
         limit: usize,
         indexed_limit: usize,
-        ann_searcher: Option<&dyn PkVectorAnnSearcher>,
+        ann_searcher: Option<Arc<dyn PkVectorAnnSearcher>>,
         exact_file_search: &(dyn for<'s, 'a> Fn(
             usize,
             &'s PkVectorSearchSplit,
@@ -430,17 +428,12 @@ impl PkVectorOrchestrator {
     /// count) is applied per query / once as appropriate.
     ///
     /// `concurrency` is the global fan-out limit (Java 
`GLOBAL_INDEX_THREAD_NUM`):
-    /// `1` runs the buckets and their files strictly sequentially, larger 
values fan
-    /// them out with `buffer_unordered`. To match Java's single shared
-    /// `GlobalIndexReadThreadPool`, a single [`Semaphore`] budget of 
`concurrency`
-    /// permits is shared across BOTH the per-bucket and per-exact-file 
fan-outs and
-    /// acquired only around leaf exact-file I/O, so total in-flight exact-file
-    /// searches are capped at `concurrency` overall — not `concurrency` per 
bucket,
-    /// which would allow up to `concurrency * concurrency`. Each bucket's 
per-query
-    /// results feed per-query cross-bucket global Top-K heaps, which are
-    /// order-independent, so the output does not depend on which bucket 
finished
-    /// first; results are collected and merged into the correct per-query 
slot after
-    /// the parallel stage.
+    /// `1` runs buckets and their leaves strictly sequentially, while larger 
values
+    /// fan them out concurrently. A shared [`SearchBudget`] gates every ANN 
segment
+    /// and exact-file leaf across all buckets, so a query cannot multiply the 
limit
+    /// at each nesting level. The process-global side of the budget also 
bounds
+    /// concurrent work across separate queries. Each query's final 
cross-bucket
+    /// Top-K merge is order-independent, so completion order does not affect 
output.
     #[allow(clippy::too_many_arguments)]
     #[allow(clippy::type_complexity)]
     pub(crate) async fn search_candidates_batch(
@@ -450,7 +443,7 @@ impl PkVectorOrchestrator {
         metric: VectorSearchMetric,
         limit: usize,
         indexed_limit: usize,
-        ann_searcher: Option<&dyn PkVectorAnnSearcher>,
+        ann_searcher: Option<Arc<dyn PkVectorAnnSearcher>>,
         exact_file_search: &(dyn for<'s, 'a> Fn(
             usize,
             &'s PkVectorSearchSplit,
@@ -497,14 +490,11 @@ impl PkVectorOrchestrator {
         let mut exact_candidates: Vec<Vec<PkVectorCandidate>> =
             (0..queries.len()).map(|_| Vec::new()).collect();
 
-        // One shared concurrency budget for the WHOLE search, mirroring 
Java's single
-        // `GlobalIndexReadThreadPool`: the per-bucket and per-exact-file 
fan-outs draw
-        // slots from the SAME N permits, so total in-flight exact-file I/O is 
capped at
-        // N across all buckets and files (not N per bucket, which would allow 
N*N).
-        // Only leaf exact-file work acquires a permit; bucket orchestration 
never holds
-        // one, so it cannot starve leaf work. `concurrency <= 1` takes the 
strictly
-        // sequential path at both levels and needs no budget.
-        let search_budget = (concurrency > 1).then(|| 
Arc::new(Semaphore::new(concurrency)));
+        // One shared budget for the whole search. Every ANN and exact-file 
leaf
+        // draws from the same query-local permits and process-global pool; 
bucket
+        // orchestration itself never holds a permit, so it cannot starve leaf 
work.
+        // The budget also applies at concurrency 1 to preserve process-wide 
bounds.
+        let search_budget = Some(SearchBudget::production(concurrency));
 
         // One lazy future per bucket. Each builds its own DV map + per-file 
search
         // closure, searches all queries against the bucket, and returns 
per-query
@@ -513,6 +503,7 @@ impl PkVectorOrchestrator {
         // below, so the sequential branch observes buckets in strict split 
order.
         let per_bucket = splits.iter().enumerate().map(|(split_index, split)| {
             let search_budget = search_budget.clone();
+            let ann_searcher = ann_searcher.clone();
             async move {
                 let dvs = build_bucket_dv_map(&self.reader, split).await?;
                 // Adapt the split-scoped search closure to bucket_search's 
per-file
@@ -589,7 +580,7 @@ impl PkVectorOrchestrator {
 
         // Drive the per-bucket futures. `concurrency == 1` uses a strictly
         // sequential loop so buckets are searched in split order; larger 
values fan
-        // them out with `buffer_unordered`. Either way each bucket's 
per-query lists
+        // them out through `drain_indexed_jobs`. Either way each bucket's 
per-query lists
         // are collected and only then folded into the per-query candidate
         // accumulators, and the final per-query `global_top_k` is 
order-independent
         // (deterministic `candidate_cmp`), so the result does not depend on 
bucket
@@ -602,10 +593,7 @@ impl PkVectorOrchestrator {
                 }
                 out
             } else {
-                stream::iter(per_bucket)
-                    .buffer_unordered(concurrency)
-                    .try_collect::<Vec<_>>()
-                    .await?
+                drain_indexed_jobs(per_bucket, concurrency).await?
             };
         for tagged in collected {
             for (query_index, (indexed, exact)) in 
tagged.into_iter().enumerate() {
@@ -1256,9 +1244,17 @@ mod e2e_tests {
         hits: Vec<PkVectorSearchResult>,
     }
     impl PkVectorAnnSearcher for FakeAnn {
+        fn load_segment(
+            &self,
+            _segment: &BucketAnnSegment,
+        ) -> futures::future::BoxFuture<'static, crate::Result<Bytes>> {
+            Box::pin(async { Ok(Bytes::new()) })
+        }
+
         fn search_batch(
             &self,
             _segment: &BucketAnnSegment,
+            _segment_bytes: Bytes,
             queries: &[&[f32]],
             _metric: VectorSearchMetric,
             _limit: usize,
@@ -1283,7 +1279,7 @@ mod e2e_tests {
         query: &[f32],
         metric: VectorSearchMetric,
         limit: usize,
-        ann: Option<&dyn PkVectorAnnSearcher>,
+        ann: Option<Arc<dyn PkVectorAnnSearcher>>,
         search: &(dyn for<'a> Fn(
             &'a BucketActiveFile,
             &'a [&'a [f32]],
@@ -1429,7 +1425,7 @@ mod e2e_tests {
             &[0.0, 0.0],
             VectorSearchMetric::L2,
             3,
-            Some(&ann),
+            Some(Arc::new(ann)),
             &factory,
             &opts,
         )
diff --git a/crates/paimon/src/table/vector_search_builder.rs 
b/crates/paimon/src/table/vector_search_builder.rs
index b2d67597..f1e22b20 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -51,9 +51,10 @@ use crate::table::{
 };
 use crate::vector_search::{GlobalIndexIOMeta, SearchResult, VectorSearch};
 use crate::vindex::executor::{
-    drain_indexed_jobs, ensure_global_index_executor_capacity, 
execute_global_index,
+    acquire_process_global_search_permit, drain_indexed_jobs,
+    ensure_global_index_executor_capacity, execute_global_index_with_guard,
 };
-use crate::vindex::pkvector::ann::VindexAnnSearcher;
+use crate::vindex::pkvector::ann::{AnnSegmentSource, PkVectorAnnSearcher, 
VindexAnnSearcher};
 use crate::vindex::pkvector::bucket::{BucketActiveFile, BucketAnnSegment, 
ExactFileSearchFuture};
 use crate::vindex::pkvector::exact::validate_query;
 use crate::vindex::pkvector::metric::VectorSearchMetric;
@@ -99,20 +100,21 @@ impl VectorIndexBackend {
     }
 }
 
-async fn execute_vindex_searches<S: SeekRead + 'static>(
+async fn execute_vindex_searches<S: SeekRead + 'static, G: Send + 'static>(
     io_meta: GlobalIndexIOMeta,
     options: HashMap<String, String>,
     vector_searches: Vec<VectorSearch>,
     source: S,
     file_name: String,
     shard_concurrency: usize,
+    guard: G,
 ) -> crate::Result<Vec<Option<HashMap<u64, f32>>>> {
     let panic_context = if vector_searches.len() > 1 {
         "vindex global-index batch search task failed"
     } else {
         "vindex global-index search task failed"
     };
-    execute_global_index(panic_context, move || {
+    execute_global_index_with_guard(panic_context, guard, move || {
         let mut reader = VindexVectorGlobalIndexReader::new(io_meta, options)
             .with_batch_shard_concurrency(shard_concurrency);
         reader
@@ -125,6 +127,13 @@ async fn execute_vindex_searches<S: SeekRead + 'static>(
     .await
 }
 
+fn current_tokio_runtime_handle() -> crate::Result<tokio::runtime::Handle> {
+    tokio::runtime::Handle::try_current().map_err(|error| 
crate::Error::UnexpectedError {
+        message: "Vector index range reader requires a Tokio 
runtime".to_string(),
+        source: Some(Box::new(error)),
+    })
+}
+
 pub struct VectorSearchBuilder<'a> {
     table: &'a Table,
     vector_column: Option<String>,
@@ -667,7 +676,7 @@ pub(crate) fn ensure_no_reserved_read_columns(fields: 
&[DataField]) -> crate::Re
 }
 
 /// Batch PK-vector search core shared by the single and batch builders: plan 
ONE
-/// per-bucket split set, segment preload, ANN scorer, exact-fallback search
+/// per-bucket split set, lazy segment loader, ANN scorer, exact-fallback 
search
 /// closure, and residual allow-list (all query-independent), then run
 /// `search_candidates_batch` ONCE so N queries share the opened readers. Per
 /// query, the approximate candidates are exact-reranked (when a refine factor 
is
@@ -732,7 +741,7 @@ async fn plan_and_search_pk_candidates_batch(
     // `primary_key_vector_distance_metric` returns a validated name; re-parse 
into
     // the enum for the numeric semantics.
     let metric = 
VectorSearchMetric::parse(&core.primary_key_vector_distance_metric(pk_col)?)?;
-    // Fan-out limit for the per-bucket and per-exact-file search (Java
+    // Fan-out limit for bucket orchestration plus ANN and exact-file leaves 
(Java
     // `GLOBAL_INDEX_THREAD_NUM`); `1` reproduces strictly sequential 
execution.
     let concurrency = core.global_index_thread_num()?;
     let index_type = core.primary_key_vector_index_type(pk_col)?;
@@ -824,14 +833,9 @@ async fn plan_and_search_pk_candidates_batch(
         Vec::new(),
     );
 
-    // Real ANN scorer: preload each segment's bytes (keyed by resolved, 
globally
-    // unique path) and drive the vindex reader from memory. The reader is 
opened
-    // once per segment and every query in the batch is searched against it,
-    // mirroring the shared-reader batch search.
-    let segment_bytes = preload_segment_bytes(table.file_io(), 
&plan.splits).await?;
-    // Fail loud on a config/segment metric mismatch before scoring, mirroring 
Java
-    // `PkVectorAnnSegmentSearcher.search`.
-    verify_pk_vector_segment_metrics(&plan.splits, &segment_bytes, metric, 
backend)?;
+    // Real ANN scorer + loader. Each segment source is opened lazily inside 
its
+    // bucket leaf and dropped after scoring. Lumina keeps its buffered-byte 
path;
+    // vindex remains range-backed and reads only metadata and probed lists.
     let options = {
         let mut o = table.schema().options().clone();
         o.extend(query_options.clone());
@@ -839,34 +843,97 @@ async fn plan_and_search_pk_candidates_batch(
     };
     let search_options = options.clone();
     let field_name = pk_col.to_string();
-    let scorer: crate::vindex::pkvector::ann::BatchScorer = Box::new(
-        move |segment: &BucketAnnSegment, searches: &[VectorSearch]| {
-            let data = segment_bytes
-                .get(&segment.path)
-                .ok_or_else(|| crate::Error::DataInvalid {
-                    message: "missing preloaded ANN bytes for 
segment".to_string(),
-                    source: None,
-                })?
-                .clone();
+
+    let loader_io = table.file_io().clone();
+    let loader_range_read_permits = 
Arc::new(tokio::sync::Semaphore::new(concurrency));
+    let loader: crate::vindex::pkvector::ann::SourceSegmentLoader = Box::new(
+        move |segment: &BucketAnnSegment| {
+            let io = loader_io.clone();
+            let range_read_permits = Arc::clone(&loader_range_read_permits);
+            let path = segment.path.clone();
+            let file_size = segment.file_size;
+            Box::pin(async move {
+                let input = io.new_input(&path)?;
+                match backend {
+                    VectorIndexBackend::Lumina => input
+                        .read()
+                        .await
+                        .map(AnnSegmentSource::Buffered)
+                        .map_err(|error| crate::Error::DataInvalid {
+                            message: format!("failed to read ANN index file 
'{path}': {error}"),
+                            source: None,
+                        }),
+                    VectorIndexBackend::Vindex => {
+                        let file_reader =
+                            input
+                                .reader()
+                                .await
+                                .map_err(|error| crate::Error::DataInvalid {
+                                    message: format!(
+                                        "failed to open ANN index file 
'{path}' for range reads: {error}"
+                                    ),
+                                    source: None,
+                                })?;
+                        Ok(AnnSegmentSource::Vindex(
+                            VindexFileReader::new_with_permits(
+                                Arc::new(file_reader),
+                                current_tokio_runtime_handle()?,
+                                range_read_permits,
+                                file_size,
+                                path,
+                            ),
+                        ))
+                    }
+                }
+            })
+        },
+    );
+
+    let scorer: crate::vindex::pkvector::ann::SourceBatchScorer = Box::new(
+        move |segment: &BucketAnnSegment, source: AnnSegmentSource, searches: 
&[VectorSearch]| {
             let io_meta = GlobalIndexIOMeta::new(
                 segment.path.clone(),
                 segment.file_size,
                 segment.index_meta.clone(),
             );
-            match backend {
-                VectorIndexBackend::Lumina => {
+            match (backend, source) {
+                (VectorIndexBackend::Lumina, AnnSegmentSource::Buffered(data)) 
=> {
+                    let lumina_metric =
+                        
LuminaIndexMeta::deserialize(&segment.index_meta)?.metric()?;
+                    verify_segment_metric(metric, 
VectorSearchMetric::from_lumina(lumina_metric))?;
                     let mut reader = 
LuminaVectorGlobalIndexReader::new(io_meta, options.clone());
                     reader.visit_batch_vector_search(searches, |_| 
Ok(Cursor::new(data)))
                 }
-                VectorIndexBackend::Vindex => {
+                (VectorIndexBackend::Vindex, AnnSegmentSource::Vindex(source)) 
=> {
                     let mut reader = 
VindexVectorGlobalIndexReader::new(io_meta, options.clone())
                         .with_batch_shard_concurrency(concurrency);
-                    reader.visit_batch_vector_search(searches, |_| 
Ok(Cursor::new(data)))
+                    reader.load_validated(
+                        |_| Ok(source),
+                        |metadata| {
+                            verify_segment_metric(
+                                metric,
+                                
VectorSearchMetric::from_vindex(metadata.metric),
+                            )
+                        },
+                    )?;
+                    reader.search_batch(searches)
+                }
+                (VectorIndexBackend::Lumina, AnnSegmentSource::Vindex(_))
+                | (VectorIndexBackend::Vindex, AnnSegmentSource::Buffered(_)) 
=> {
+                    Err(crate::Error::DataInvalid {
+                        message: format!(
+                            "ANN segment '{}' was loaded with the wrong 
backend source",
+                            segment.path
+                        ),
+                        source: None,
+                    })
                 }
             }
         },
     );
-    let ann_searcher = VindexAnnSearcher::new(field_name, scorer);
+    let ann_searcher: Arc<dyn PkVectorAnnSearcher> = 
Arc::new(VindexAnnSearcher::new_with_source(
+        field_name, scorer, loader,
+    ));
 
     // Residual (post-recall) filtering: for each candidate file, re-read its
     // physical rows and keep the positions whose rows satisfy the filter. The
@@ -983,7 +1050,7 @@ async fn plan_and_search_pk_candidates_batch(
             metric,
             limit,
             indexed_limit,
-            Some(&ann_searcher),
+            Some(ann_searcher),
             &factory,
             &search_options,
             skip_exact_fallback,
@@ -1253,7 +1320,7 @@ impl<'a> BatchVectorSearchBuilder<'a> {
         // Vec.
         let read_type = self.resolve_materialize_read_type()?;
 
-        // One shared plan / segment preload / residual across all N queries; 
the
+        // One shared plan / lazy segment loader / residual across all N 
queries; the
         // per-query candidate lists come back in strict input order. Any query
         // error (or a shared-plan error) propagates here, so no partial Vec is
         // returned.
@@ -1484,6 +1551,7 @@ async fn evaluate_batch_vector_search(
                 options.extend(search_options.clone());
                 let input = evaluation.file_io.new_input(&path);
                 async move {
+                    let permit = 
acquire_process_global_search_permit(concurrency).await?;
                     let input = input?;
                     let query_count = vector_searches.len();
                     let io_meta =
@@ -1501,8 +1569,9 @@ async fn evaluate_batch_vector_search(
                                     source: None,
                                 }
                             })?;
-                            execute_global_index(
+                            execute_global_index_with_guard(
                                 "Lumina global-index batch search task failed",
+                                permit,
                                 move || {
                                     let mut reader =
                                         
LuminaVectorGlobalIndexReader::new(io_meta, options);
@@ -1539,6 +1608,7 @@ async fn evaluate_batch_vector_search(
                                         source,
                                         file_name,
                                         concurrency,
+                                        permit,
                                     )
                                     .await?
                                 }
@@ -1559,6 +1629,7 @@ async fn evaluate_batch_vector_search(
                                         Cursor::new(data),
                                         file_name,
                                         concurrency,
+                                        permit,
                                     )
                                     .await?
                                 }
@@ -1752,87 +1823,19 @@ async fn residual_positions_by_file(
     Ok(out)
 }
 
-/// Preload every ANN segment's bytes into a map keyed by the resolved 
(globally
-/// unique) segment path. The scorer closure reads from this map so the vindex
-/// reader is driven from memory without per-search IO.
-async fn preload_segment_bytes(
-    file_io: &FileIO,
-    splits: &[PkVectorSearchSplit],
-) -> crate::Result<HashMap<String, Vec<u8>>> {
-    let mut out = HashMap::new();
-    for split in splits {
-        for segment in &split.ann_segments {
-            if out.contains_key(&segment.path) {
-                continue;
-            }
-            let input = file_io.new_input(&segment.path)?;
-            let bytes = input.read().await.map_err(|e| 
crate::Error::DataInvalid {
-                message: format!("failed to read ANN index file '{}': {e}", 
segment.path),
-                source: None,
-            })?;
-            out.insert(segment.path.clone(), bytes.to_vec());
-        }
-    }
-    Ok(out)
-}
-
-/// Fail loud when an ANN segment was trained with a metric other than the
-/// configured one, mirroring the search-time `checkArgument` in Java
-/// `PkVectorAnnSegmentSearcher.search`. Opens each distinct segment's 
preloaded
-/// bytes once and compares its trained metric against `configured`.
-fn verify_pk_vector_segment_metrics(
-    splits: &[PkVectorSearchSplit],
-    segment_bytes: &HashMap<String, Vec<u8>>,
+fn verify_segment_metric(
     configured: VectorSearchMetric,
-    backend: VectorIndexBackend,
+    segment_metric: VectorSearchMetric,
 ) -> crate::Result<()> {
-    let mut checked: HashSet<&str> = HashSet::new();
-    for split in splits {
-        for segment in &split.ann_segments {
-            if !checked.insert(segment.path.as_str()) {
-                continue;
-            }
-            let segment_metric = match backend {
-                VectorIndexBackend::Lumina => {
-                    // Lumina records its metric in the serialized index 
metadata
-                    // (`index_meta`), not in the segment file bytes.
-                    let lumina_metric =
-                        
LuminaIndexMeta::deserialize(&segment.index_meta)?.metric()?;
-                    VectorSearchMetric::from_lumina(lumina_metric)
-                }
-                VectorIndexBackend::Vindex => {
-                    let bytes = segment_bytes.get(&segment.path).ok_or_else(|| 
{
-                        crate::Error::DataInvalid {
-                            message: format!(
-                                "missing preloaded ANN bytes for segment '{}'",
-                                segment.path
-                            ),
-                            source: None,
-                        }
-                    })?;
-                    let reader = 
VIndexReader::open(Cursor::new(bytes.clone())).map_err(|e| {
-                        crate::Error::DataInvalid {
-                            message: format!(
-                                "failed to open ANN index file '{}' for metric 
check: {e}",
-                                segment.path
-                            ),
-                            source: Some(Box::new(e)),
-                        }
-                    })?;
-                    VectorSearchMetric::from_vindex(reader.metadata().metric)
-                }
-            };
-            if segment_metric != configured {
-                return Err(crate::Error::DataInvalid {
-                    message: format!(
-                        "ANN segment metric {} does not match configured 
metric {}",
-                        segment_metric.as_str(),
-                        configured.as_str()
-                    ),
-                    source: None,
-                });
-            }
-        }
+    if segment_metric != configured {
+        return Err(crate::Error::DataInvalid {
+            message: format!(
+                "ANN segment metric {} does not match configured metric {}",
+                segment_metric.as_str(),
+                configured.as_str()
+            ),
+            source: None,
+        });
     }
     Ok(())
 }
@@ -4430,20 +4433,6 @@ mod tests {
         bytes
     }
 
-    /// A `PkVectorSearchSplit` carrying a single ANN segment addressed by 
`path`.
-    fn pk_split_with_segment(path: &str) -> PkVectorSearchSplit {
-        let mut split = pk_search_split(0, vec![pk_data_file("file-a", 3, 
Some(0))]);
-        let source_meta = crate::spec::PrimaryKeyIndexSourceMeta::new(
-            1,
-            
vec![crate::spec::PrimaryKeyIndexSourceFile::new("file-a".to_string(), 
3).unwrap()],
-        )
-        .unwrap();
-        let mut segment = BucketAnnSegment::for_test(source_meta);
-        segment.path = path.to_string();
-        split.ann_segments = vec![segment];
-        split
-    }
-
     fn pk_split_with_lumina_segment(path: &str, metric: &str) -> 
PkVectorSearchSplit {
         let mut split = pk_search_split(0, vec![pk_data_file("file-a", 3, 
Some(0))]);
         let source_meta = crate::spec::PrimaryKeyIndexSourceMeta::new(
@@ -4465,31 +4454,35 @@ mod tests {
     }
 
     #[test]
-    fn verify_pk_vector_segment_metrics_accepts_matching_lumina_metric() {
+    fn verify_segment_metric_accepts_matching_lumina_metric() {
         // Lumina segment metadata says cosine; configured cosine => Ok. No 
segment
         // file bytes are needed on the Lumina path.
-        let splits = vec![pk_split_with_lumina_segment("seg-lumina", 
"cosine")];
-        let segment_bytes = HashMap::new();
-        verify_pk_vector_segment_metrics(
-            &splits,
-            &segment_bytes,
+        let split = pk_split_with_lumina_segment("seg-lumina", "cosine");
+        let segment = &split.ann_segments[0];
+        let lumina_metric = LuminaIndexMeta::deserialize(&segment.index_meta)
+            .unwrap()
+            .metric()
+            .unwrap();
+        verify_segment_metric(
             VectorSearchMetric::Cosine,
-            VectorIndexBackend::Lumina,
+            VectorSearchMetric::from_lumina(lumina_metric),
         )
         .expect("matching lumina metric must pass");
     }
 
     #[test]
-    fn verify_pk_vector_segment_metrics_rejects_mismatched_lumina_metric() {
+    fn verify_segment_metric_rejects_mismatched_lumina_metric() {
         // Lumina segment metadata says l2; configured inner_product => fail 
loud,
         // naming both metrics.
-        let splits = vec![pk_split_with_lumina_segment("seg-lumina", "l2")];
-        let segment_bytes = HashMap::new();
-        let err = verify_pk_vector_segment_metrics(
-            &splits,
-            &segment_bytes,
+        let split = pk_split_with_lumina_segment("seg-lumina", "l2");
+        let segment = &split.ann_segments[0];
+        let lumina_metric = LuminaIndexMeta::deserialize(&segment.index_meta)
+            .unwrap()
+            .metric()
+            .unwrap();
+        let err = verify_segment_metric(
             VectorSearchMetric::InnerProduct,
-            VectorIndexBackend::Lumina,
+            VectorSearchMetric::from_lumina(lumina_metric),
         )
         .expect_err("mismatched lumina metric must fail loud");
         assert!(
@@ -4520,31 +4513,25 @@ mod tests {
     }
 
     #[test]
-    fn verify_pk_vector_segment_metrics_accepts_matching_metric() {
+    fn verify_segment_metric_accepts_matching_vindex_metric() {
         // Real IVF segment trained with L2; configured metric L2 => Ok.
-        let bytes = build_vindex_segment_bytes("l2");
-        let splits = vec![pk_split_with_segment("seg-l2")];
-        let segment_bytes = HashMap::from([("seg-l2".to_string(), bytes)]);
-        verify_pk_vector_segment_metrics(
-            &splits,
-            &segment_bytes,
+        let bytes = bytes::Bytes::from(build_vindex_segment_bytes("l2"));
+        let reader = VIndexReader::open(Cursor::new(bytes)).unwrap();
+        verify_segment_metric(
             VectorSearchMetric::L2,
-            VectorIndexBackend::Vindex,
+            VectorSearchMetric::from_vindex(reader.metadata().metric),
         )
         .expect("matching metric must pass");
     }
 
     #[test]
-    fn verify_pk_vector_segment_metrics_rejects_mismatched_metric() {
+    fn verify_segment_metric_rejects_mismatched_vindex_metric() {
         // Real IVF segment trained with L2; configured metric Cosine => fail 
loud.
-        let bytes = build_vindex_segment_bytes("l2");
-        let splits = vec![pk_split_with_segment("seg-l2")];
-        let segment_bytes = HashMap::from([("seg-l2".to_string(), bytes)]);
-        let err = verify_pk_vector_segment_metrics(
-            &splits,
-            &segment_bytes,
+        let bytes = bytes::Bytes::from(build_vindex_segment_bytes("l2"));
+        let reader = VIndexReader::open(Cursor::new(bytes)).unwrap();
+        let err = verify_segment_metric(
             VectorSearchMetric::Cosine,
-            VectorIndexBackend::Vindex,
+            VectorSearchMetric::from_vindex(reader.metadata().metric),
         )
         .expect_err("mismatched metric must fail loud");
         assert!(
diff --git a/crates/paimon/src/vindex/executor.rs 
b/crates/paimon/src/vindex/executor.rs
index 76b8a185..173b5d7d 100644
--- a/crates/paimon/src/vindex/executor.rs
+++ b/crates/paimon/src/vindex/executor.rs
@@ -22,7 +22,7 @@ use std::panic::{catch_unwind, AssertUnwindSafe};
 use std::sync::atomic::{AtomicUsize, Ordering};
 use std::sync::{Arc, Mutex, OnceLock};
 use std::time::Duration;
-use tokio::sync::oneshot;
+use tokio::sync::{oneshot, Semaphore};
 
 type Job = Box<dyn FnOnce() + Send + 'static>;
 
@@ -30,6 +30,9 @@ const DEFAULT_IO_BOUND_WORKERS: usize = 32;
 const IO_BOUND_WORKERS_PER_CPU: usize = 4;
 const WORKER_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
 
+static PROCESS_GLOBAL_SEMAPHORE: OnceLock<Semaphore> = OnceLock::new();
+static PROCESS_GLOBAL_CAPACITY: AtomicUsize = AtomicUsize::new(0);
+
 struct ExecutorState {
     receiver: Receiver<Job>,
     max_workers: AtomicUsize,
@@ -249,6 +252,64 @@ pub(crate) fn 
ensure_global_index_executor_capacity(max_workers: usize) {
     global_executor().ensure_capacity(max_workers);
 }
 
+/// Grow a shared semaphore monotonically. The successful CAS owns one disjoint
+/// capacity delta, so concurrent callers neither over-add permits nor shrink 
the
+/// high-watermark established by an earlier, larger query configuration.
+fn grow_semaphore_capacity(
+    tracked_capacity: &AtomicUsize,
+    semaphore: &Semaphore,
+    capacity: usize,
+) -> usize {
+    loop {
+        let current = tracked_capacity.load(Ordering::SeqCst);
+        if capacity <= current {
+            return 0;
+        }
+        if tracked_capacity
+            .compare_exchange(current, capacity, Ordering::SeqCst, 
Ordering::SeqCst)
+            .is_ok()
+        {
+            let added = capacity - current;
+            semaphore.add_permits(added);
+            return added;
+        }
+    }
+}
+
+/// Java's shared executor is initially sized to `availableProcessors()` and is
+/// only replaced by a larger pool. A smaller configured `thread-num` limits 
one
+/// query through its per-query scheduler or semaphore, but does not shrink
+/// cross-query process capacity below the machine's available parallelism.
+fn effective_process_global_capacity(requested: usize) -> usize {
+    requested.max(default_worker_count()).max(1)
+}
+
+/// Return the process-global permit pool, growing both it and the dedicated 
CPU
+/// executor to the same monotonic high-watermark. This caps aggregate work 
across
+/// queries while preserving Java's available-CPU floor.
+fn process_global_semaphore(capacity: usize) -> &'static Semaphore {
+    let effective = effective_process_global_capacity(capacity);
+    ensure_global_index_executor_capacity(effective);
+    let semaphore = PROCESS_GLOBAL_SEMAPHORE.get_or_init(|| {
+        PROCESS_GLOBAL_CAPACITY.store(effective, Ordering::SeqCst);
+        Semaphore::new(effective)
+    });
+    grow_semaphore_capacity(&PROCESS_GLOBAL_CAPACITY, semaphore, effective);
+    semaphore
+}
+
+pub(crate) async fn acquire_process_global_search_permit(
+    capacity: usize,
+) -> crate::Result<tokio::sync::SemaphorePermit<'static>> {
+    process_global_semaphore(capacity)
+        .acquire()
+        .await
+        .map_err(|error| crate::Error::UnexpectedError {
+            message: "global-index process concurrency budget was 
closed".to_string(),
+            source: Some(Box::new(error)),
+        })
+}
+
 /// Runs bounded global-index jobs to completion, restores submission order, 
and
 /// returns the first error by submission index. Dedicated executor jobs 
cannot be
 /// interrupted after they start, so short-circuiting would only hide 
background
@@ -282,6 +343,23 @@ where
     execute_on(global_executor(), panic_context, task).await
 }
 
+pub(crate) async fn execute_global_index_with_guard<T, F, G>(
+    panic_context: &'static str,
+    guard: G,
+    task: F,
+) -> crate::Result<T>
+where
+    T: Send + 'static,
+    F: FnOnce() -> crate::Result<T> + Send + 'static,
+    G: Send + 'static,
+{
+    execute_global_index(panic_context, move || {
+        let _guard = guard;
+        task()
+    })
+    .await
+}
+
 async fn execute_on<T, F>(
     executor: &GlobalIndexExecutor,
     panic_context: &'static str,
@@ -499,6 +577,39 @@ mod tests {
             .expect("submitted task stopped before reporting completion");
     }
 
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn guarded_task_keeps_guard_after_waiter_cancellation() {
+        let semaphore = Arc::new(Semaphore::new(1));
+        let permit = semaphore.clone().acquire_owned().await.unwrap();
+        let (started_sender, started_receiver) = oneshot::channel();
+        let (release_sender, release_receiver) = std::sync::mpsc::channel();
+        let join = tokio::spawn(async move {
+            execute_global_index_with_guard("guarded task failed", permit, 
move || {
+                let _ = started_sender.send(());
+                let _ = release_receiver.recv();
+                Ok(())
+            })
+            .await
+        });
+        started_receiver.await.unwrap();
+
+        join.abort();
+        assert!(join.await.unwrap_err().is_cancelled());
+        assert!(
+            tokio::time::timeout(Duration::from_millis(50), 
semaphore.clone().acquire_owned())
+                .await
+                .is_err(),
+            "cancelling the waiter released the guard while the task was still 
running"
+        );
+
+        release_sender.send(()).unwrap();
+        let _recovered_permit =
+            tokio::time::timeout(Duration::from_secs(1), 
semaphore.acquire_owned())
+                .await
+                .expect("guard was not released after the task finished")
+                .unwrap();
+    }
+
     #[test]
     fn cancelling_started_search_keeps_current_thread_runtime_live() {
         let runtime = tokio::runtime::Builder::new_current_thread()
@@ -600,4 +711,27 @@ mod tests {
         );
         assert!(!second_ran.load(Ordering::SeqCst));
     }
+
+    #[test]
+    fn semaphore_capacity_is_monotonic_high_watermark() {
+        let tracked = AtomicUsize::new(0);
+        let semaphore = Semaphore::new(0);
+        assert_eq!(grow_semaphore_capacity(&tracked, &semaphore, 4), 4);
+        assert_eq!(semaphore.available_permits(), 4);
+        assert_eq!(tracked.load(Ordering::SeqCst), 4);
+        assert_eq!(grow_semaphore_capacity(&tracked, &semaphore, 7), 3);
+        assert_eq!(semaphore.available_permits(), 7);
+        assert_eq!(grow_semaphore_capacity(&tracked, &semaphore, 4), 0);
+        assert_eq!(grow_semaphore_capacity(&tracked, &semaphore, 7), 0);
+        assert_eq!(tracked.load(Ordering::SeqCst), 7);
+        assert_eq!(semaphore.available_permits(), 7);
+    }
+
+    #[test]
+    fn process_capacity_floors_at_available_parallelism() {
+        let cores = default_worker_count();
+        assert_eq!(effective_process_global_capacity(1), cores);
+        assert_eq!(effective_process_global_capacity(cores + 100), cores + 
100);
+        assert!(effective_process_global_capacity(0) >= 1);
+    }
 }
diff --git a/crates/paimon/src/vindex/pkvector/ann.rs 
b/crates/paimon/src/vindex/pkvector/ann.rs
index ab50196a..76cf6aa4 100644
--- a/crates/paimon/src/vindex/pkvector/ann.rs
+++ b/crates/paimon/src/vindex/pkvector/ann.rs
@@ -18,13 +18,20 @@
 use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
+use bytes::Bytes;
+use futures::future::BoxFuture;
+
 use super::bucket::BucketAnnSegment;
 use super::data_invalid;
 use super::metric::{java_float_compare, VectorSearchMetric};
 use super::result::PkVectorSearchResult;
 use crate::deletion_vector::DeletionVector;
-use crate::spec::{PrimaryKeyIndexSourceFile, PrimaryKeyIndexSourceMeta};
+use crate::spec::{
+    PrimaryKeyIndexSourceFile as PkVectorSourceFile,
+    PrimaryKeyIndexSourceMeta as PkVectorSourceMeta,
+};
 use crate::vector_search::VectorSearch;
+use crate::vindex::range_reader::VindexFileReader;
 
 /// Build the live-row-id mask for the ANN reader's `include_row_ids` filter, 
in
 /// segment-ordinal space (source files concatenated in order). Mirrors Java
@@ -46,7 +53,7 @@ use crate::vector_search::VectorSearch;
 /// no deletion vector is relevant — nothing to mask. Otherwise returns the 
masked
 /// live ids.
 pub(crate) fn build_live_row_ids(
-    source_files: &[PrimaryKeyIndexSourceFile],
+    source_files: &[PkVectorSourceFile],
     active_source_files: &HashSet<String>,
     deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
     residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
@@ -122,7 +129,7 @@ pub(crate) fn build_live_row_ids(
 /// sorted BEST_FIRST.
 pub(crate) fn map_ann_results(
     scored: &[(u64, f32)],
-    source_meta: &PrimaryKeyIndexSourceMeta,
+    source_meta: &PkVectorSourceMeta,
     active_source_files: &HashSet<String>,
     deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
     residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
@@ -172,18 +179,37 @@ pub(crate) fn map_ann_results(
 /// One ANN segment's search dependency for the bucket kernel. Bucket tests 
fake
 /// this (mirroring Java's mock of `PkVectorAnnSegmentSearcher`).
 ///
-/// `Send + Sync` so a `&dyn PkVectorAnnSearcher` can be held across the 
`.await`
-/// points of the async search path (the returned future is spawned on a `Send`
-/// runtime by callers such as the DataFusion integration).
+/// `Send + Sync` so an `Arc<dyn PkVectorAnnSearcher>` can be cloned into 
concurrent
+/// leaf futures and moved onto the dedicated global-index executor.
 pub(crate) trait PkVectorAnnSearcher: Send + Sync {
+    /// Load one buffered ANN segment. Runs on the async side of the bucket 
leaf,
+    /// BEFORE the blocking score, so the bytes are loaded lazily per segment 
and
+    /// dropped after that leaf.
+    /// Returns a `'static` boxed future so it borrows nothing from 
`self`/`segment`
+    /// past the await (production clones `FileIO` + the path into the future).
+    fn load_segment(&self, segment: &BucketAnnSegment) -> BoxFuture<'static, 
crate::Result<Bytes>>;
+
+    /// Production source loader. Must NOT acquire a search-concurrency permit:
+    /// the bucket leaf already holds one across both this load and the 
subsequent
+    /// score, so a second acquisition would deadlock at capacity 1.
+    fn load_segment_source(
+        &self,
+        segment: &BucketAnnSegment,
+    ) -> BoxFuture<'static, crate::Result<AnnSegmentSource>> {
+        let future = self.load_segment(segment);
+        Box::pin(async move { future.await.map(AnnSegmentSource::Buffered) })
+    }
+
     /// Search one ANN segment for a batch of query vectors, returning one
     /// BEST_FIRST result list per query (outer index aligned to `queries`). 
The
     /// live-row mask (residual ∩ DV) is query-independent, so it is built 
once and
-    /// shared across all queries; only the per-query scores differ.
+    /// shared across all queries; only the per-query scores differ. Buffered 
callers
+    /// pass the bytes from `load_segment` by value so they cannot outlive the 
leaf.
     #[allow(clippy::too_many_arguments)]
     fn search_batch(
         &self,
         segment: &BucketAnnSegment,
+        segment_bytes: Bytes,
         queries: &[&[f32]],
         metric: VectorSearchMetric,
         limit: usize,
@@ -193,12 +219,45 @@ pub(crate) trait PkVectorAnnSearcher: Send + Sync {
         residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
     ) -> crate::Result<Vec<Vec<PkVectorSearchResult>>>;
 
+    #[allow(clippy::too_many_arguments)]
+    fn search_batch_source(
+        &self,
+        segment: &BucketAnnSegment,
+        segment_source: AnnSegmentSource,
+        queries: &[&[f32]],
+        metric: VectorSearchMetric,
+        limit: usize,
+        active_source_files: &HashSet<String>,
+        deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
+        search_options: &HashMap<String, String>,
+        residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
+    ) -> crate::Result<Vec<Vec<PkVectorSearchResult>>> {
+        match segment_source {
+            AnnSegmentSource::Buffered(bytes) => self.search_batch(
+                segment,
+                bytes,
+                queries,
+                metric,
+                limit,
+                active_source_files,
+                deletion_vectors,
+                search_options,
+                residual_ranges,
+            ),
+            AnnSegmentSource::Vindex(_) => Err(data_invalid(
+                "ANN searcher does not support a range-backed segment source",
+            )),
+        }
+    }
+
     /// Single-query wrapper over `search_batch`: searches the one query and
     /// returns its result list. Asserts the batch produced exactly one list.
+    #[allow(dead_code)]
     #[allow(clippy::too_many_arguments)]
     fn search(
         &self,
         segment: &BucketAnnSegment,
+        segment_bytes: Bytes,
         query: &[f32],
         metric: VectorSearchMetric,
         limit: usize,
@@ -209,6 +268,40 @@ pub(crate) trait PkVectorAnnSearcher: Send + Sync {
     ) -> crate::Result<Vec<PkVectorSearchResult>> {
         let mut results = self.search_batch(
             segment,
+            segment_bytes,
+            &[query],
+            metric,
+            limit,
+            active_source_files,
+            deletion_vectors,
+            search_options,
+            residual_ranges,
+        )?;
+        if results.len() != 1 {
+            return Err(data_invalid(format!(
+                "ANN batch search returned {} result lists for a single query",
+                results.len()
+            )));
+        }
+        Ok(results.pop().expect("length checked to be 1"))
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn search_source(
+        &self,
+        segment: &BucketAnnSegment,
+        segment_source: AnnSegmentSource,
+        query: &[f32],
+        metric: VectorSearchMetric,
+        limit: usize,
+        active_source_files: &HashSet<String>,
+        deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
+        search_options: &HashMap<String, String>,
+        residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
+    ) -> crate::Result<Vec<PkVectorSearchResult>> {
+        let mut results = self.search_batch_source(
+            segment,
+            segment_source,
             &[query],
             metric,
             limit,
@@ -228,40 +321,144 @@ pub(crate) trait PkVectorAnnSearcher: Send + Sync {
 }
 
 /// Batch scorer seam: drives the underlying vindex ANN reader for a batch of
-/// searches over ONE segment, opening the reader once and searching each query
-/// against it (mirroring Java's shared-reader `visitBatchVectorSearch`). 
Returns
+/// searches over ONE segment, opening the reader once and issuing one backend 
batch
+/// search (mirroring Java's shared-reader `visitBatchVectorSearch`). Returns
 /// one `ordinal -> score` map (higher-is-better) per input search, aligned to 
the
 /// `searches` slice. Any negative labels are skipped by the existing `vindex`
 /// reader (`collect_results` drops `row_id < 0`), so this seam only ever 
yields
 /// non-negative `u64` ordinals — no signed-label handling is needed 
downstream.
 ///
-/// The production scorer drives 
`VindexVectorGlobalIndexReader::visit_batch_vector_search`
-/// with a segment's index bytes; tests inject a synthetic scorer. The 
adapter's
+/// The production scorer drives one backend reader from a typed segment 
source;
+/// tests inject a synthetic buffered scorer. The adapter's
 /// own logic (live-row masking, ordinal mapping, deletion checks, ordering) is
 /// exercised independently of the scorer.
+#[cfg(test)]
 pub(crate) type BatchScorer = Box<
-    dyn Fn(&BucketAnnSegment, &[VectorSearch]) -> 
crate::Result<Vec<Option<HashMap<u64, f32>>>>
+    dyn Fn(
+            &BucketAnnSegment,
+            Bytes,
+            &[VectorSearch],
+        ) -> crate::Result<Vec<Option<HashMap<u64, f32>>>>
+        + Send
+        + Sync,
+>;
+
+pub(crate) type SourceBatchScorer = Box<
+    dyn Fn(
+            &BucketAnnSegment,
+            AnnSegmentSource,
+            &[VectorSearch],
+        ) -> crate::Result<Vec<Option<HashMap<u64, f32>>>>
         + Send
         + Sync,
 >;
 
-/// Structural vindex-backed `PkVectorAnnSearcher`. Composes the pure helpers
-/// (`build_live_row_ids`, `map_ann_results`) around the batch scorer seam.
+pub(crate) enum AnnSegmentSource {
+    Buffered(Bytes),
+    Vindex(VindexFileReader),
+}
+
+/// Test-only buffered loader retained for simple fake searchers.
+#[cfg(test)]
+pub(crate) type SegmentLoader =
+    Box<dyn Fn(&BucketAnnSegment) -> BoxFuture<'static, crate::Result<Bytes>> 
+ Send + Sync>;
+
+/// Production loader for either buffered Lumina data or a range-backed vindex
+/// source. It runs before the dedicated-executor score and must not acquire a
+/// second search permit; the bucket leaf already holds one across both phases.
+pub(crate) type SourceSegmentLoader = Box<
+    dyn Fn(&BucketAnnSegment) -> BoxFuture<'static, 
crate::Result<AnnSegmentSource>> + Send + Sync,
+>;
+
+/// Structural ANN-backed `PkVectorAnnSearcher`. Composes the pure helpers
+/// (`build_live_row_ids`, `map_ann_results`) around the batch scorer seam, and
+/// carries the async segment loader so each segment source is opened lazily 
in its
+/// own bucket leaf and dropped after scoring (no up-front all-segments map).
 pub(crate) struct VindexAnnSearcher {
     field_name: String,
-    scorer: BatchScorer,
+    scorer: SourceBatchScorer,
+    loader: SourceSegmentLoader,
 }
 
 impl VindexAnnSearcher {
-    pub(crate) fn new(field_name: String, scorer: BatchScorer) -> Self {
-        Self { field_name, scorer }
+    #[cfg(test)]
+    pub(crate) fn new(field_name: String, scorer: BatchScorer, loader: 
SegmentLoader) -> Self {
+        let source_scorer: SourceBatchScorer =
+            Box::new(move |segment, source, searches| match source {
+                AnnSegmentSource::Buffered(bytes) => scorer(segment, bytes, 
searches),
+                AnnSegmentSource::Vindex(_) => Err(data_invalid(
+                    "buffered ANN scorer received a range-backed segment 
source",
+                )),
+            });
+        let source_loader: SourceSegmentLoader = Box::new(move |segment| {
+            let future = loader(segment);
+            Box::pin(async move { future.await.map(AnnSegmentSource::Buffered) 
})
+        });
+        Self::new_with_source(field_name, source_scorer, source_loader)
+    }
+
+    pub(crate) fn new_with_source(
+        field_name: String,
+        scorer: SourceBatchScorer,
+        loader: SourceSegmentLoader,
+    ) -> Self {
+        Self {
+            field_name,
+            scorer,
+            loader,
+        }
     }
 }
 
 impl PkVectorAnnSearcher for VindexAnnSearcher {
+    fn load_segment(&self, segment: &BucketAnnSegment) -> BoxFuture<'static, 
crate::Result<Bytes>> {
+        let future = (self.loader)(segment);
+        Box::pin(async move {
+            match future.await? {
+                AnnSegmentSource::Buffered(bytes) => Ok(bytes),
+                AnnSegmentSource::Vindex(_) => Err(data_invalid(
+                    "range-backed ANN segment cannot be converted to buffered 
bytes",
+                )),
+            }
+        })
+    }
+
+    fn load_segment_source(
+        &self,
+        segment: &BucketAnnSegment,
+    ) -> BoxFuture<'static, crate::Result<AnnSegmentSource>> {
+        (self.loader)(segment)
+    }
+
     fn search_batch(
         &self,
         segment: &BucketAnnSegment,
+        segment_bytes: Bytes,
+        queries: &[&[f32]],
+        metric: VectorSearchMetric,
+        limit: usize,
+        active_source_files: &HashSet<String>,
+        deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
+        search_options: &HashMap<String, String>,
+        residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
+    ) -> crate::Result<Vec<Vec<PkVectorSearchResult>>> {
+        self.search_batch_source(
+            segment,
+            AnnSegmentSource::Buffered(segment_bytes),
+            queries,
+            metric,
+            limit,
+            active_source_files,
+            deletion_vectors,
+            search_options,
+            residual_ranges,
+        )
+    }
+
+    fn search_batch_source(
+        &self,
+        segment: &BucketAnnSegment,
+        segment_source: AnnSegmentSource,
         queries: &[&[f32]],
         metric: VectorSearchMetric,
         limit: usize,
@@ -292,7 +489,7 @@ impl PkVectorAnnSearcher for VindexAnnSearcher {
             }
             searches.push(search);
         }
-        let scored_batch = (self.scorer)(segment, &searches)?;
+        let scored_batch = (self.scorer)(segment, segment_source, &searches)?;
         if scored_batch.len() != queries.len() {
             return Err(data_invalid(format!(
                 "ANN batch scorer returned {} result maps for {} queries",
@@ -327,12 +524,24 @@ mod tests {
     use super::*;
     use roaring::RoaringBitmap;
 
-    fn source_meta(files: &[(&str, i64)]) -> PrimaryKeyIndexSourceMeta {
+    /// A trivial loader returning empty bytes — the synthetic scorers below 
ignore
+    /// their `segment_bytes` (they model behavior above physical index 
decoding).
+    fn empty_loader() -> SegmentLoader {
+        Box::new(|_: &BucketAnnSegment| Box::pin(async { Ok(Bytes::new()) }))
+    }
+
+    /// Build a `VindexAnnSearcher` with a trivial (empty-bytes) loader, for 
tests
+    /// that only exercise the scorer/adapter logic.
+    fn vindex_searcher(field: &str, scorer: BatchScorer) -> VindexAnnSearcher {
+        VindexAnnSearcher::new(field.to_string(), scorer, empty_loader())
+    }
+
+    fn source_meta(files: &[(&str, i64)]) -> PkVectorSourceMeta {
         let files = files
             .iter()
-            .map(|(name, rows)| 
PrimaryKeyIndexSourceFile::new((*name).to_string(), *rows).unwrap())
+            .map(|(name, rows)| PkVectorSourceFile::new((*name).to_string(), 
*rows).unwrap())
             .collect();
-        PrimaryKeyIndexSourceMeta::new(1, files).unwrap()
+        PkVectorSourceMeta::new(1, files).unwrap()
     }
 
     fn dv(deleted: &[u32]) -> Arc<DeletionVector> {
@@ -349,7 +558,7 @@ mod tests {
 
     #[test]
     fn test_build_live_row_ids_none_when_all_active_and_no_relevant_dv() {
-        let files = [PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap()];
+        let files = [PkVectorSourceFile::new("f0".into(), 3).unwrap()];
         let active = active_set(&["f0"]);
         // All active + empty map -> None.
         assert!(build_live_row_ids(&files, &active, &HashMap::new(), None)
@@ -368,8 +577,8 @@ mod tests {
         // f0 rows 0..3 (global 0,1,2), f1 rows 0..2 (global 3,4). f1 is 
inactive,
         // so its whole ordinal range is masked out; f0 stays fully live. No 
DV.
         let files = vec![
-            PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap(),
-            PrimaryKeyIndexSourceFile::new("f1".into(), 2).unwrap(),
+            PkVectorSourceFile::new("f0".into(), 3).unwrap(),
+            PkVectorSourceFile::new("f1".into(), 2).unwrap(),
         ];
         let live = build_live_row_ids(&files, &active_set(&["f0"]), 
&HashMap::new(), None)
             .unwrap()
@@ -381,8 +590,8 @@ mod tests {
     fn test_build_live_row_ids_masks_deleted_positions_with_file_offsets() {
         // f0 rows 0..3 (global 0,1,2), f1 rows 0..2 (global 3,4).
         let files = vec![
-            PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap(),
-            PrimaryKeyIndexSourceFile::new("f1".into(), 2).unwrap(),
+            PkVectorSourceFile::new("f0".into(), 3).unwrap(),
+            PkVectorSourceFile::new("f1".into(), 2).unwrap(),
         ];
         let mut dvs = HashMap::new();
         dvs.insert("f0".to_string(), dv(&[1])); // deletes global 1
@@ -483,10 +692,10 @@ mod tests {
         let seen_has_filter = Arc::new(Mutex::new(false));
         let scorer_limit = Arc::clone(&seen_limit);
         let scorer_has_filter = Arc::clone(&seen_has_filter);
-        let searcher = VindexAnnSearcher::new(
-            "embedding".to_string(),
+        let searcher = vindex_searcher(
+            "embedding",
             Box::new(
-                move |_segment: &BucketAnnSegment, searches: &[VectorSearch]| {
+                move |_segment: &BucketAnnSegment, _bytes: Bytes, searches: 
&[VectorSearch]| {
                     let search = &searches[0];
                     *scorer_limit.lock().unwrap() = search.limit;
                     *scorer_has_filter.lock().unwrap() = 
search.include_row_ids.is_some();
@@ -498,12 +707,15 @@ mod tests {
             ),
         );
         let segment = BucketAnnSegment::for_test({
-            use crate::spec::{PrimaryKeyIndexSourceFile, 
PrimaryKeyIndexSourceMeta};
-            PrimaryKeyIndexSourceMeta::new(
+            use crate::spec::{
+                PrimaryKeyIndexSourceFile as PkVectorSourceFile,
+                PrimaryKeyIndexSourceMeta as PkVectorSourceMeta,
+            };
+            PkVectorSourceMeta::new(
                 1,
                 vec![
-                    PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap(),
-                    PrimaryKeyIndexSourceFile::new("f1".into(), 5).unwrap(),
+                    PkVectorSourceFile::new("f0".into(), 3).unwrap(),
+                    PkVectorSourceFile::new("f1".into(), 5).unwrap(),
                 ],
             )
             .unwrap()
@@ -513,6 +725,7 @@ mod tests {
         let results = searcher
             .search(
                 &segment,
+                Bytes::new(),
                 &[0.0, 0.0],
                 VectorSearchMetric::L2,
                 2,
@@ -534,23 +747,26 @@ mod tests {
 
     #[test]
     fn test_vindex_adapter_rejects_non_positive_limit() {
-        let searcher = VindexAnnSearcher::new(
-            "embedding".to_string(),
-            Box::new(|_: &BucketAnnSegment, searches: &[VectorSearch]| {
-                Ok(vec![None; searches.len()])
-            }),
+        let searcher = vindex_searcher(
+            "embedding",
+            Box::new(
+                |_: &BucketAnnSegment, _bytes: Bytes, searches: 
&[VectorSearch]| {
+                    Ok(vec![None; searches.len()])
+                },
+            ),
         );
         let segment = BucketAnnSegment::for_test({
-            use crate::spec::{PrimaryKeyIndexSourceFile, 
PrimaryKeyIndexSourceMeta};
-            PrimaryKeyIndexSourceMeta::new(
-                1,
-                vec![PrimaryKeyIndexSourceFile::new("f0".into(), 1).unwrap()],
-            )
-            .unwrap()
+            use crate::spec::{
+                PrimaryKeyIndexSourceFile as PkVectorSourceFile,
+                PrimaryKeyIndexSourceMeta as PkVectorSourceMeta,
+            };
+            PkVectorSourceMeta::new(1, 
vec![PkVectorSourceFile::new("f0".into(), 1).unwrap()])
+                .unwrap()
         });
         let err = searcher
             .search(
                 &segment,
+                Bytes::new(),
                 &[0.0, 0.0],
                 VectorSearchMetric::L2,
                 0,
@@ -565,23 +781,26 @@ mod tests {
 
     #[test]
     fn test_vindex_adapter_empty_scorer_result_is_empty() {
-        let searcher = VindexAnnSearcher::new(
-            "embedding".to_string(),
-            Box::new(|_: &BucketAnnSegment, searches: &[VectorSearch]| {
-                Ok(vec![None; searches.len()])
-            }),
+        let searcher = vindex_searcher(
+            "embedding",
+            Box::new(
+                |_: &BucketAnnSegment, _bytes: Bytes, searches: 
&[VectorSearch]| {
+                    Ok(vec![None; searches.len()])
+                },
+            ),
         );
         let segment = BucketAnnSegment::for_test({
-            use crate::spec::{PrimaryKeyIndexSourceFile, 
PrimaryKeyIndexSourceMeta};
-            PrimaryKeyIndexSourceMeta::new(
-                1,
-                vec![PrimaryKeyIndexSourceFile::new("f0".into(), 1).unwrap()],
-            )
-            .unwrap()
+            use crate::spec::{
+                PrimaryKeyIndexSourceFile as PkVectorSourceFile,
+                PrimaryKeyIndexSourceMeta as PkVectorSourceMeta,
+            };
+            PkVectorSourceMeta::new(1, 
vec![PkVectorSourceFile::new("f0".into(), 1).unwrap()])
+                .unwrap()
         });
         let results = searcher
             .search(
                 &segment,
+                Bytes::new(),
                 &[0.0, 0.0],
                 VectorSearchMetric::L2,
                 2,
@@ -609,8 +828,8 @@ mod tests {
         // entry (empty allow). Result: f0 keeps {0} (1 is residual-allowed but
         // deleted, 2 not residual-allowed); f1 contributes nothing.
         let files = vec![
-            PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap(),
-            PrimaryKeyIndexSourceFile::new("f1".into(), 2).unwrap(),
+            PkVectorSourceFile::new("f0".into(), 3).unwrap(),
+            PkVectorSourceFile::new("f1".into(), 2).unwrap(),
         ];
         let mut dvs = HashMap::new();
         dvs.insert("f0".to_string(), dv(&[1]));
@@ -627,8 +846,8 @@ mod tests {
         // f0 rows global 0,1,2; f1 rows global 3,4. residual allows f0={2}, 
f1={1}.
         // f1 physical pos 1 -> global 3 + 1 = 4. Result {2, 4}. No DV.
         let files = vec![
-            PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap(),
-            PrimaryKeyIndexSourceFile::new("f1".into(), 2).unwrap(),
+            PkVectorSourceFile::new("f0".into(), 3).unwrap(),
+            PkVectorSourceFile::new("f1".into(), 2).unwrap(),
         ];
         let mut residual = HashMap::new();
         residual.insert("f0".to_string(), treemap(&[2]));
@@ -648,7 +867,7 @@ mod tests {
     fn 
test_build_live_row_ids_residual_some_returns_mask_even_when_all_active_no_dv() 
{
         // All active, no DV: without residual this returns None. With a 
residual
         // present, a mask is always required.
-        let files = [PrimaryKeyIndexSourceFile::new("f0".into(), 3).unwrap()];
+        let files = [PkVectorSourceFile::new("f0".into(), 3).unwrap()];
         let mut residual = HashMap::new();
         residual.insert("f0".to_string(), treemap(&[0, 2]));
         let live = build_live_row_ids(
@@ -705,10 +924,10 @@ mod tests {
         use std::sync::{Arc, Mutex};
         let seen_rows: Arc<Mutex<Option<Vec<u64>>>> = 
Arc::new(Mutex::new(None));
         let scorer_rows = Arc::clone(&seen_rows);
-        let searcher = VindexAnnSearcher::new(
-            "embedding".to_string(),
+        let searcher = vindex_searcher(
+            "embedding",
             Box::new(
-                move |_segment: &BucketAnnSegment, searches: &[VectorSearch]| {
+                move |_segment: &BucketAnnSegment, _bytes: Bytes, searches: 
&[VectorSearch]| {
                     *scorer_rows.lock().unwrap() = searches[0]
                         .include_row_ids
                         .as_ref()
@@ -723,6 +942,7 @@ mod tests {
         searcher
             .search(
                 &segment,
+                Bytes::new(),
                 &[0.0, 0.0],
                 VectorSearchMetric::L2,
                 2,
@@ -740,24 +960,27 @@ mod tests {
         // The single-query `search` wrapper must return exactly what
         // `search_batch(&[q])[0]` returns for the same inputs.
         let make = || {
-            VindexAnnSearcher::new(
-                "embedding".to_string(),
-                Box::new(|_: &BucketAnnSegment, searches: &[VectorSearch]| {
-                    let mut out = Vec::with_capacity(searches.len());
-                    for _ in searches {
-                        let mut scores = HashMap::new();
-                        scores.insert(3u64, 0.5f32); // -> (f1, 0)
-                        scores.insert(0u64, 0.25f32); // -> (f0, 0)
-                        out.push(Some(scores));
-                    }
-                    Ok(out)
-                }),
+            vindex_searcher(
+                "embedding",
+                Box::new(
+                    |_: &BucketAnnSegment, _bytes: Bytes, searches: 
&[VectorSearch]| {
+                        let mut out = Vec::with_capacity(searches.len());
+                        for _ in searches {
+                            let mut scores = HashMap::new();
+                            scores.insert(3u64, 0.5f32); // -> (f1, 0)
+                            scores.insert(0u64, 0.25f32); // -> (f0, 0)
+                            out.push(Some(scores));
+                        }
+                        Ok(out)
+                    },
+                ),
             )
         };
         let meta = source_meta(&[("f0", 3), ("f1", 5)]);
         let single = make()
             .search(
                 &BucketAnnSegment::for_test(meta.clone()),
+                Bytes::new(),
                 &[0.0, 0.0],
                 VectorSearchMetric::L2,
                 2,
@@ -771,6 +994,7 @@ mod tests {
         let batch = make()
             .search_batch(
                 &BucketAnnSegment::for_test(meta),
+                Bytes::new(),
                 &[query],
                 VectorSearchMetric::L2,
                 2,
@@ -788,28 +1012,31 @@ mod tests {
     fn test_search_batch_returns_independent_per_query_results() {
         // Two queries route to different synthetic scores; each result list is
         // mapped from that query's own scores, with a shared live-row mask.
-        let searcher = VindexAnnSearcher::new(
-            "embedding".to_string(),
-            Box::new(|_: &BucketAnnSegment, searches: &[VectorSearch]| {
-                let mut out = Vec::with_capacity(searches.len());
-                for (i, _) in searches.iter().enumerate() {
-                    let mut scores = HashMap::new();
-                    // Query 0 -> ordinal 0 (f0,0); query 1 -> ordinal 3 
(f1,0).
-                    if i == 0 {
-                        scores.insert(0u64, 0.5f32);
-                    } else {
-                        scores.insert(3u64, 0.5f32);
+        let searcher = vindex_searcher(
+            "embedding",
+            Box::new(
+                |_: &BucketAnnSegment, _bytes: Bytes, searches: 
&[VectorSearch]| {
+                    let mut out = Vec::with_capacity(searches.len());
+                    for (i, _) in searches.iter().enumerate() {
+                        let mut scores = HashMap::new();
+                        // Query 0 -> ordinal 0 (f0,0); query 1 -> ordinal 3 
(f1,0).
+                        if i == 0 {
+                            scores.insert(0u64, 0.5f32);
+                        } else {
+                            scores.insert(3u64, 0.5f32);
+                        }
+                        out.push(Some(scores));
                     }
-                    out.push(Some(scores));
-                }
-                Ok(out)
-            }),
+                    Ok(out)
+                },
+            ),
         );
         let q0: &[f32] = &[0.0, 0.0];
         let q1: &[f32] = &[1.0, 1.0];
         let results = searcher
             .search_batch(
                 &BucketAnnSegment::for_test(source_meta(&[("f0", 3), ("f1", 
5)])),
+                Bytes::new(),
                 &[q0, q1],
                 VectorSearchMetric::L2,
                 2,
@@ -828,9 +1055,9 @@ mod tests {
     fn test_search_batch_fails_loud_on_result_count_mismatch() {
         // A batch scorer that returns the wrong number of result maps is 
corruption
         // and must fail loud, not be silently padded/truncated.
-        let searcher = VindexAnnSearcher::new(
-            "embedding".to_string(),
-            Box::new(|_: &BucketAnnSegment, _: &[VectorSearch]| {
+        let searcher = vindex_searcher(
+            "embedding",
+            Box::new(|_: &BucketAnnSegment, _: Bytes, _: &[VectorSearch]| {
                 // Only one map returned regardless of query count.
                 Ok(vec![None])
             }),
@@ -840,6 +1067,7 @@ mod tests {
         let err = searcher
             .search_batch(
                 &BucketAnnSegment::for_test(source_meta(&[("f0", 3)])),
+                Bytes::new(),
                 &[q0, q1],
                 VectorSearchMetric::L2,
                 2,
diff --git a/crates/paimon/src/vindex/pkvector/bucket.rs 
b/crates/paimon/src/vindex/pkvector/bucket.rs
index 4666d403..c52a72e5 100644
--- a/crates/paimon/src/vindex/pkvector/bucket.rs
+++ b/crates/paimon/src/vindex/pkvector/bucket.rs
@@ -20,7 +20,6 @@ use std::collections::{BinaryHeap, HashMap, HashSet};
 use std::sync::Arc;
 
 use futures::future::BoxFuture;
-use futures::stream::{self, StreamExt, TryStreamExt};
 use tokio::sync::{OwnedSemaphorePermit, Semaphore};
 
 use super::ann::PkVectorAnnSearcher;
@@ -28,7 +27,10 @@ use super::data_invalid;
 use super::metric::{java_float_compare, VectorSearchMetric};
 use super::result::PkVectorSearchResult;
 use crate::deletion_vector::DeletionVector;
-use crate::spec::PrimaryKeyIndexSourceMeta;
+use crate::spec::PrimaryKeyIndexSourceMeta as PkVectorSourceMeta;
+use crate::vindex::executor::{
+    acquire_process_global_search_permit, drain_indexed_jobs, 
execute_global_index,
+};
 
 /// Search one uncovered data file for its per-query exact Top-K. Returns one
 /// bounded, BEST_FIRST list per query (outer index aligns to the `queries` 
slice
@@ -43,9 +45,16 @@ pub(crate) type ExactFileSearchFuture<'a> =
 /// segment ordinals back to physical `(data file, position)` and drives 
live-row
 /// masking; the remaining fields address the segment's index file for the ANN
 /// scorer that reads it.
+///
+/// `Clone` so one segment's search inputs can be moved into a dedicated 
global-index
+/// executor leaf (the ANN CPU search runs off the async worker). Cloning 
copies only
+/// the segment's addressing metadata (path, size, `index_meta`, source meta). 
Each
+/// leaf opens its own segment source lazily and drops it after scoring; vindex
+/// sources stay range-backed instead of holding the full index bytes.
+#[derive(Clone)]
 pub(crate) struct BucketAnnSegment {
-    pub source_meta: PrimaryKeyIndexSourceMeta,
-    /// Resolved index-file path (globally unique; the scorer's preload key).
+    pub source_meta: PkVectorSourceMeta,
+    /// Resolved index-file path (globally unique; the key the loader reads 
by).
     pub path: String,
     pub file_size: u64,
     pub index_meta: Vec<u8>,
@@ -55,7 +64,7 @@ pub(crate) struct BucketAnnSegment {
 impl BucketAnnSegment {
     /// Build a segment with dummy index-file fields for tests that exercise 
only
     /// `source_meta`-driven logic.
-    pub(crate) fn for_test(source_meta: PrimaryKeyIndexSourceMeta) -> Self {
+    pub(crate) fn for_test(source_meta: PkVectorSourceMeta) -> Self {
         Self {
             source_meta,
             path: "seg".to_string(),
@@ -205,33 +214,114 @@ pub(crate) fn covered_source_files(
     covered
 }
 
-/// Acquire one slot from the shared global-index search concurrency budget, 
if a
-/// budget is set. The returned guard holds the slot until it is dropped, so 
the
-/// caller must keep it alive for the duration of the leaf exact-file I/O it 
gates.
+/// Concurrency budget for one search's global-index leaf work. Mirrors Java's
+/// two-level shape: a shared process pool with an available-CPU floor, plus a
+/// per-query limit (`per_query`) that keeps a single query from occupying more
+/// than its configured `thread_num` slots.
+///
+/// - `production(n)` — the real read path: per-query semaphore of `n` permits 
AND
+///   the shared process pool. Used even when `n <= 1`; concurrent queries may
+///   share the CPU-sized process pool, matching Java's cached executor plus 
its
+///   per-caller `SemaphoredDelegatingExecutor`.
+/// - `per_query_only(sem)` / `shared_for_test(sem)` — tests only: an explicit
+///   per-query semaphore with NO process-global gating, so a shared static 
cannot
+///   pollute a cap assertion.
 ///
-/// A `None` budget means the leaf runs ungated (no cap) — the function does 
not
-/// require any particular `concurrency` value; the orchestrator simply passes
-/// `None` on the strictly sequential `concurrency <= 1` path (which needs no
-/// gating). A `Some` budget is a single [`Semaphore`] shared across every 
bucket
-/// and every exact file of one search, so total in-flight exact-file I/O is 
capped
-/// at N regardless of how many buckets and files fan out — mirroring Java's 
single
-/// shared `GlobalIndexReadThreadPool`. Only leaf exact-file work acquires a 
permit;
-/// bucket orchestration never holds one, so it cannot starve leaf work (the 
async
-/// analogue of Java's "start from the caller" note in
-/// `PrimaryKeyVectorRead.searchBuckets`).
+/// A `None` budget (passed only by low-level tests that opt out of gating) 
runs the
+/// leaf ungated; production never passes `None`.
+#[derive(Clone)]
+pub(crate) struct SearchBudget {
+    /// Per-query permit source: caps ONE query's in-flight leaves at 
`thread_num`.
+    per_query: Option<Arc<Semaphore>>,
+    /// When set, also draw from the process-global semaphore. Its effective 
size
+    /// is at least the available CPU count and grows with larger requested 
values,
+    /// matching Java's shared cached executor. `None` skips global gating 
(tests).
+    process_global_capacity: Option<usize>,
+}
+
+impl SearchBudget {
+    /// The production budget: per-query cap of `concurrency` plus the shared
+    /// process pool. Applied even when `concurrency <= 1`.
+    pub(crate) fn production(concurrency: usize) -> Self {
+        Self {
+            per_query: Some(Arc::new(Semaphore::new(concurrency.max(1)))),
+            process_global_capacity: Some(concurrency.max(1)),
+        }
+    }
+
+    /// A per-query-only budget for tests that assert a single query's cap in
+    /// isolation. No process-global gating, so a static initialized by 
another test
+    /// cannot change the observed peak.
+    #[cfg(test)]
+    pub(crate) fn per_query_only(permits: usize) -> Self {
+        Self {
+            per_query: Some(Arc::new(Semaphore::new(permits))),
+            process_global_capacity: None,
+        }
+    }
+
+    /// A budget backed by an explicit, caller-provided semaphore, with NO
+    /// process-global gating. Two concurrent searches given the SAME 
`Arc<Semaphore>`
+    /// share one cap deterministically — the "deliberately shared" test seam 
that
+    /// exercises cross-query capping (what the process-global static does in
+    /// production) without touching the shared static (so tests stay 
isolated).
+    #[cfg(test)]
+    pub(crate) fn shared_for_test(semaphore: Arc<Semaphore>) -> Self {
+        Self {
+            per_query: Some(semaphore),
+            process_global_capacity: None,
+        }
+    }
+
+    /// Acquire one slot: per-query permit FIRST, then the process-global 
permit.
+    /// This ordering prevents a job from holding a scarce global permit while 
it
+    /// merely waits for its own query-local permit. Both guards are returned 
and
+    /// must be kept alive for the duration of the leaf work (an ANN segment's
+    /// blocking CPU search or an exact file's async I/O).
+    async fn acquire(&self) -> crate::Result<SearchPermit> {
+        let per_query = match &self.per_query {
+            Some(sem) => Some(acquire_owned(sem.clone()).await?),
+            None => None,
+        };
+        let process_global = match self.process_global_capacity {
+            Some(cap) => 
Some(acquire_process_global_search_permit(cap).await?),
+            None => None,
+        };
+        Ok(SearchPermit {
+            _per_query: per_query,
+            _process_global: process_global,
+        })
+    }
+}
+
+/// Guard holding the acquired permits (per-query and/or process-global) until 
it is
+/// dropped. Held for the whole leaf, including inside the dedicated executor 
task
+/// (whose work continues if the awaiting future is dropped), so the budget 
stays
+/// accurate.
+struct SearchPermit {
+    _per_query: Option<OwnedSemaphorePermit>,
+    _process_global: Option<tokio::sync::SemaphorePermit<'static>>,
+}
+
+async fn acquire_owned(sem: Arc<Semaphore>) -> 
crate::Result<OwnedSemaphorePermit> {
+    sem.acquire_owned()
+        .await
+        .map_err(|e| crate::Error::UnexpectedError {
+            message: "global-index search concurrency budget was 
closed".to_string(),
+            source: Some(Box::new(e)),
+        })
+}
+
+/// Acquire one slot from a search's concurrency budget, if one is set. The 
returned
+/// guard holds the slot(s) until dropped, so the caller must keep it alive 
for the
+/// duration of the leaf work it gates. A `None` budget (passed only by 
low-level
+/// tests) runs the leaf ungated; production always supplies a `SearchBudget`,
+/// including on the `concurrency <= 1` sequential path.
 async fn acquire_search_permit(
-    budget: &Option<Arc<Semaphore>>,
-) -> crate::Result<Option<OwnedSemaphorePermit>> {
+    budget: &Option<SearchBudget>,
+) -> crate::Result<Option<SearchPermit>> {
     match budget {
-        Some(semaphore) => {
-            let permit = semaphore.clone().acquire_owned().await.map_err(|e| {
-                crate::Error::UnexpectedError {
-                    message: "global-index search concurrency budget was 
closed".to_string(),
-                    source: Some(Box::new(e)),
-                }
-            })?;
-            Ok(Some(permit))
-        }
+        Some(budget) => Ok(Some(budget.acquire().await?)),
         None => Ok(None),
     }
 }
@@ -245,6 +335,18 @@ pub(crate) struct BucketSearchResult {
     pub(crate) exact: Vec<PkVectorSearchResult>,
 }
 
+/// One completed leaf of the merged per-bucket search: either an ANN segment's
+/// per-query hit lists (fold into the INDEXED heaps) or an uncovered exact 
file's
+/// per-query hit lists (fold into the EXACT heaps). ANN and exact leaves run 
in one
+/// combined concurrency window under the shared budget (mirroring Java's 
single
+/// `allOf` over ANN + exact futures) rather than ANN-fully-then-exact; the 
tag says
+/// which heap set each result feeds. Both carry one list per query (the 
single-query
+/// path uses a 1-element outer vec).
+enum BucketLeaf {
+    Indexed(Vec<Vec<PkVectorSearchResult>>),
+    Exact(Vec<Vec<PkVectorSearchResult>>),
+}
+
 /// ANN + exact data-file fallback search for one snapshot bucket. Mirrors Java
 /// `org.apache.paimon.index.pkvector.PrimaryKeyVectorBucketSearch.search`.
 ///
@@ -260,7 +362,7 @@ pub(crate) struct BucketSearchResult {
 #[allow(clippy::too_many_arguments)]
 #[allow(clippy::type_complexity)]
 pub(crate) async fn bucket_search(
-    ann_searcher: Option<&dyn PkVectorAnnSearcher>,
+    ann_searcher: Option<Arc<dyn PkVectorAnnSearcher>>,
     ann_segments: &[BucketAnnSegment],
     active_files: &[BucketActiveFile],
     deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
@@ -281,7 +383,7 @@ pub(crate) async fn bucket_search(
     skip_exact_fallback: bool,
     residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
     concurrency: usize,
-    search_budget: Option<Arc<Semaphore>>,
+    search_budget: Option<SearchBudget>,
 ) -> crate::Result<BucketSearchResult> {
     if indexed_limit == 0 {
         return Err(data_invalid("vector search limit must be positive"));
@@ -353,9 +455,20 @@ pub(crate) async fn bucket_search(
     // skips them, so the lazy exact-reader factory is never invoked for those 
files.
     let covered = covered_source_files(ann_segments, active_files);
 
-    // The ANN searcher is synchronous CPU work (no `.await`), so iterating 
segments
-    // sequentially is intentional: fanning it out would need 
`spawn_blocking`. Only
-    // the exact-fallback file reads below (which are async I/O) are 
parallelized.
+    // ANN segment search. Each segment's scorer is synchronous CPU work (the
+    // faiss-like vindex search, which may itself use Rayon), so running it 
inline
+    // would monopolize the async worker and serialize every segment across the
+    // whole read. Instead each segment is a dedicated-executor leaf gated by 
the
+    // shared `search_budget`, mirroring Java's single 
`GlobalIndexReadThreadPool`
+    // where every ANN segment is a pool task. `concurrency <= 1` still goes 
through
+    // the dedicated executor (so a long CPU search never blocks the runtime) 
but strictly
+    // one at a time.
+    //
+    // Structural validation of ALL segments happens before any leaf launches, 
so a
+    // malformed later segment fails loud without half the segments having 
already
+    // scored. Row-count validation runs BEFORE resolving the searcher so a
+    // corruption diagnostic is not masked by an "ANN search is not configured"
+    // error when both are wrong.
     for segment in ann_segments {
         // An active ANN source with a mismatched row count is corruption (the
         // ordinal-to-position mapping would be wrong). An inactive source (no
@@ -372,34 +485,54 @@ pub(crate) async fn bucket_search(
                 }
             }
         }
-        let searcher = ann_searcher.ok_or_else(|| data_invalid("ANN search is 
not configured"))?;
-        for result in searcher.search(
-            segment,
-            query,
-            metric,
-            indexed_limit,
-            &active_source_files,
-            deletion_vectors,
-            search_options,
-            residual_ranges,
-        )? {
-            add_candidate(&mut indexed_heap, result, indexed_limit);
-        }
     }
+    let searcher = if ann_segments.is_empty() {
+        None
+    } else {
+        Some(
+            ann_searcher
+                .as_ref()
+                .ok_or_else(|| data_invalid("ANN search is not configured"))?
+                .clone(),
+        )
+    };
+
+    // Merged leaf stream: ANN segment searches and uncovered exact-file 
searches run
+    // in ONE combined concurrency window under the shared budget — mirroring 
Java's
+    // single `allOf` over the ANN and exact futures — instead of 
ANN-fully-then-exact.
+    // Leaves are ordered ANN-first (segment order), then exact (active-file 
order), so
+    // `drain_indexed_jobs` (which awaits ALL leaves and returns the 
lowest-ordinal
+    // error) yields a deterministic error: first ANN error by segment order, 
else
+    // first exact error by active-file order. At `concurrency <= 1` the 
ANN-first
+    // order reproduces sequential ANN-then-exact execution. Each leaf is 
tagged so its
+    // results fold into the correct heap; heaps are order-independent, so 
completion
+    // order does not affect the final Top-K.
+    //
+    // Owned exact-query slice (one element for the single-query path) so 
exact leaves
+    // borrow no locals across the await.
+    let queries: [&[f32]; 1] = [query];
+
+    // ANN leaves: shared owned inputs, one dedicated-executor scorer per 
segment.
+    let ann_shared = searcher.as_ref().map(|searcher| {
+        (
+            searcher.clone(),
+            residual_ranges.map(|r| Arc::new(r.clone())),
+            Arc::new(active_source_files.clone()),
+            Arc::new(deletion_vectors.clone()),
+            Arc::new(search_options.clone()),
+            Arc::<[f32]>::from(query.to_vec()),
+        )
+    });
 
+    // Eligible uncovered exact files (active-file order) with their exclusion
+    // predicate; a file with no residual-allowed rows is skipped without 
reading.
+    #[allow(clippy::type_complexity)]
+    let mut exact_tasks: Vec<(&BucketActiveFile, Box<dyn Fn(i64) -> bool + 
Sync>)> = Vec::new();
     if !skip_exact_fallback {
-        // Collect the eligible uncovered files (active-file order preserved) 
with
-        // their per-position exclusion predicate. A file with no 
residual-allowed
-        // rows is skipped without reading.
-        #[allow(clippy::type_complexity)]
-        let mut tasks: Vec<(&BucketActiveFile, Box<dyn Fn(i64) -> bool + 
Sync>)> = Vec::new();
         for file in active_files {
             if covered.contains(&file.file_name) {
                 continue;
             }
-            // Residual allow-list: when present, only rows whose physical 
position
-            // passes the predicate may produce candidates. A file with no 
entry (or
-            // an empty entry) has no allowed rows, so it is skipped without 
reading.
             let residual_allowed: Option<&roaring::RoaringTreemap> = match 
residual_ranges {
                 Some(ranges) => match ranges.get(&file.file_name) {
                     Some(allowed) if !allowed.is_empty() => Some(allowed),
@@ -408,46 +541,79 @@ pub(crate) async fn bucket_search(
                 None => None,
             };
             let dv = deletion_vectors.get(&file.file_name).cloned();
-            tasks.push((file, Box::new(position_excluder(dv, 
residual_allowed))));
+            exact_tasks.push((file, Box::new(position_excluder(dv, 
residual_allowed))));
         }
+    }
 
-        // Search each uncovered file for its exact Top-K. The caller passes a
-        // single-query slice and each search returns one per-query list. The
-        // per-file results feed the bounded heap, which is order-independent, 
so
-        // the merge does not depend on which file finished first. 
`concurrency == 1`
-        // takes a plain sequential loop so the file visit order is strictly
-        // deterministic; larger values fan the file searches out with
-        // `buffer_unordered`, each acquiring one slot of the shared 
`search_budget`
-        // so total in-flight exact-file I/O across all buckets is capped at N.
-        let queries: [&[f32]; 1] = [query];
-        let per_file: Vec<Vec<PkVectorSearchResult>> = if concurrency <= 1 {
-            let mut out = Vec::with_capacity(tasks.len());
-            for (file, is_excluded) in &tasks {
-                let per_query =
-                    exact_file_search(file, &queries, metric, exact_limit, 
is_excluded.as_ref())
-                        .await?;
-                out.push(single_query_result(per_query)?);
+    // Build the ANN-first-then-exact leaf futures.
+    let mut leaves: Vec<BoxFuture<'_, crate::Result<BucketLeaf>>> = Vec::new();
+    if let Some((
+        searcher,
+        residual_arc,
+        active_source_files,
+        deletion_vectors,
+        search_options,
+        query_owned,
+    )) = ann_shared
+    {
+        for segment in ann_segments {
+            let searcher = searcher.clone();
+            let segment = segment.clone();
+            let active_source_files = active_source_files.clone();
+            let deletion_vectors = deletion_vectors.clone();
+            let search_options = search_options.clone();
+            let residual_arc = residual_arc.clone();
+            let query_owned = query_owned.clone();
+            let budget = search_budget.clone();
+            leaves.push(Box::pin(async move {
+                // One permit spans BOTH the async load and the dedicated 
executor
+                // score. `load_segment_source` must not acquire a second 
permit or
+                // it would deadlock at capacity 1.
+                let permit = acquire_search_permit(&budget).await?;
+                let segment_source = 
searcher.load_segment_source(&segment).await?;
+                let hits = execute_global_index("ANN segment search task 
failed", move || {
+                    let _permit = permit;
+                    searcher.search_source(
+                        &segment,
+                        segment_source,
+                        &query_owned,
+                        metric,
+                        indexed_limit,
+                        &active_source_files,
+                        &deletion_vectors,
+                        &search_options,
+                        residual_arc.as_deref(),
+                    )
+                })
+                .await?;
+                Ok(BucketLeaf::Indexed(vec![hits]))
+            }));
+        }
+    }
+    for (file, is_excluded) in &exact_tasks {
+        let queries = &queries;
+        let budget = search_budget.clone();
+        leaves.push(Box::pin(async move {
+            let _permit = acquire_search_permit(&budget).await?;
+            let per_query =
+                exact_file_search(file, queries, metric, exact_limit, 
is_excluded.as_ref()).await?;
+            Ok(BucketLeaf::Exact(vec![single_query_result(per_query)?]))
+        }));
+    }
+
+    // Drive all leaves in one window; drain-all + lowest-ordinal error.
+    let outs = drain_indexed_jobs(leaves.into_iter(), concurrency).await?;
+    for leaf in outs {
+        match leaf {
+            BucketLeaf::Indexed(per_query) => {
+                for result in per_query.into_iter().next().unwrap_or_default() 
{
+                    add_candidate(&mut indexed_heap, result, indexed_limit);
+                }
             }
-            out
-        } else {
-            stream::iter(tasks.iter().map(|(file, is_excluded)| {
-                let queries = &queries;
-                let budget = search_budget.clone();
-                async move {
-                    let _permit = acquire_search_permit(&budget).await?;
-                    let per_query =
-                        exact_file_search(file, queries, metric, exact_limit, 
is_excluded.as_ref())
-                            .await?;
-                    single_query_result(per_query)
+            BucketLeaf::Exact(per_query) => {
+                for result in per_query.into_iter().next().unwrap_or_default() 
{
+                    add_candidate(&mut exact_heap, result, exact_limit);
                 }
-            }))
-            .buffer_unordered(concurrency)
-            .try_collect::<Vec<_>>()
-            .await?
-        };
-        for results in per_file {
-            for result in results {
-                add_candidate(&mut exact_heap, result, exact_limit);
             }
         }
     }
@@ -473,7 +639,7 @@ pub(crate) async fn bucket_search(
 #[allow(clippy::too_many_arguments)]
 #[allow(clippy::type_complexity)]
 pub(crate) async fn bucket_search_batch(
-    ann_searcher: Option<&dyn PkVectorAnnSearcher>,
+    ann_searcher: Option<Arc<dyn PkVectorAnnSearcher>>,
     ann_segments: &[BucketAnnSegment],
     active_files: &[BucketActiveFile],
     deletion_vectors: &HashMap<String, Arc<DeletionVector>>,
@@ -494,7 +660,7 @@ pub(crate) async fn bucket_search_batch(
     skip_exact_fallback: bool,
     residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
     concurrency: usize,
-    search_budget: Option<Arc<Semaphore>>,
+    search_budget: Option<SearchBudget>,
 ) -> crate::Result<Vec<BucketSearchResult>> {
     if queries.is_empty() {
         return Err(data_invalid("vector search requires at least one query"));
@@ -504,7 +670,7 @@ pub(crate) async fn bucket_search_batch(
     // principle differ).
     if queries.len() == 1 {
         let single = bucket_search(
-            ann_searcher,
+            ann_searcher.clone(),
             ann_segments,
             active_files,
             deletion_vectors,
@@ -597,9 +763,15 @@ pub(crate) async fn bucket_search_batch(
         files_by_name.keys().map(|name| name.to_string()).collect();
     let covered = covered_source_files(ann_segments, active_files);
 
-    // The ANN searcher is synchronous CPU work (no `.await`), so iterating 
segments
-    // sequentially is intentional: fanning it out would need 
`spawn_blocking`. Only
-    // the exact-fallback file reads below (which are async I/O) are 
parallelized.
+    // ANN segment search (multi-query). As in the single-query path, each 
segment's
+    // synchronous scorer runs as a dedicated-executor leaf gated by the shared
+    // `search_budget` so segments across all buckets share one budget and 
never
+    // monopolize an async worker. All structural validation runs before any 
leaf
+    // launches. One shared reader per segment searches every query; the 
per-query
+    // hits fan into their own bounded heaps after the parallel stage.
+    // Row-count validation runs BEFORE resolving the searcher so a corruption
+    // diagnostic is not masked by an "ANN search is not configured" error 
when both
+    // are wrong (mirrors the single-query path and Java's up-front checks).
     for segment in ann_segments {
         for source in segment.source_meta.source_files() {
             if let Some(active) = files_by_name.get(source.file_name()) {
@@ -611,39 +783,40 @@ pub(crate) async fn bucket_search_batch(
                 }
             }
         }
-        let searcher = ann_searcher.ok_or_else(|| data_invalid("ANN search is 
not configured"))?;
-        // One shared reader per segment searches all queries; fan the 
per-query
-        // hits into their own bounded heaps.
-        let per_query = searcher.search_batch(
-            segment,
-            queries,
-            metric,
-            indexed_limit,
-            &active_source_files,
-            deletion_vectors,
-            search_options,
-            residual_ranges,
-        )?;
-        if per_query.len() != queries.len() {
-            return Err(data_invalid(format!(
-                "ANN batch search returned {} result lists for {} queries",
-                per_query.len(),
-                queries.len()
-            )));
-        }
-        for (results, heap) in 
per_query.into_iter().zip(indexed_heaps.iter_mut()) {
-            for result in results {
-                add_candidate(heap, result, indexed_limit);
-            }
-        }
     }
+    let searcher = if ann_segments.is_empty() {
+        None
+    } else {
+        Some(
+            ann_searcher
+                .as_ref()
+                .ok_or_else(|| data_invalid("ANN search is not configured"))?
+                .clone(),
+        )
+    };
+
+    // Merged leaf stream (see the single-query `bucket_search` for the 
rationale):
+    // ANN segment searches and uncovered exact-file searches run in ONE 
combined
+    // concurrency window under the shared budget, ANN-first then exact, 
drained
+    // together with a deterministic lowest-ordinal error. Each leaf carries 
one hit
+    // list per query; the fan-in zips per-query results into the per-query 
heaps.
+    let query_count = queries.len();
+    let ann_shared = searcher.as_ref().map(|searcher| {
+        let queries_owned: Arc<Vec<Vec<f32>>> =
+            Arc::new(queries.iter().map(|q| q.to_vec()).collect());
+        (
+            searcher.clone(),
+            residual_ranges.map(|r| Arc::new(r.clone())),
+            Arc::new(active_source_files.clone()),
+            Arc::new(deletion_vectors.clone()),
+            Arc::new(search_options.clone()),
+            queries_owned,
+        )
+    });
 
+    #[allow(clippy::type_complexity)]
+    let mut exact_tasks: Vec<(&BucketActiveFile, Box<dyn Fn(i64) -> bool + 
Sync>)> = Vec::new();
     if !skip_exact_fallback {
-        // Eligible uncovered files (active-file order preserved) with their
-        // per-position exclusion predicate; a file with no residual-allowed 
rows is
-        // skipped without reading.
-        #[allow(clippy::type_complexity)]
-        let mut tasks: Vec<(&BucketActiveFile, Box<dyn Fn(i64) -> bool + 
Sync>)> = Vec::new();
         for file in active_files {
             if covered.contains(&file.file_name) {
                 continue;
@@ -656,44 +829,89 @@ pub(crate) async fn bucket_search_batch(
                 None => None,
             };
             let dv = deletion_vectors.get(&file.file_name).cloned();
-            tasks.push((file, Box::new(position_excluder(dv, 
residual_allowed))));
+            exact_tasks.push((file, Box::new(position_excluder(dv, 
residual_allowed))));
         }
+    }
 
-        // One shared stream per file scores every query into its own per-query
-        // list. The per-file lists feed per-query bounded heaps 
(order-independent),
-        // so the fan-in does not depend on which file finished first: collect 
every
-        // file's per-query result, then merge. `concurrency == 1` uses a 
strictly
-        // sequential loop; larger values fan the file searches out with
-        // `buffer_unordered`, each acquiring one slot of the shared 
`search_budget`
-        // so total in-flight exact-file I/O across all buckets is capped at N.
-        let per_file: Vec<Vec<Vec<PkVectorSearchResult>>> = if concurrency <= 
1 {
-            let mut out = Vec::with_capacity(tasks.len());
-            for (file, is_excluded) in &tasks {
-                let per_query =
-                    exact_file_search(file, queries, metric, exact_limit, 
is_excluded.as_ref())
-                        .await?;
-                out.push(validate_per_query_len(per_query, queries.len())?);
-            }
-            out
-        } else {
-            stream::iter(tasks.iter().map(|(file, is_excluded)| {
-                let budget = search_budget.clone();
-                async move {
-                    let _permit = acquire_search_permit(&budget).await?;
-                    let per_query =
-                        exact_file_search(file, queries, metric, exact_limit, 
is_excluded.as_ref())
-                            .await?;
-                    validate_per_query_len(per_query, queries.len())
+    let mut leaves: Vec<BoxFuture<'_, crate::Result<BucketLeaf>>> = Vec::new();
+    if let Some((
+        searcher,
+        residual_arc,
+        active_source_files,
+        deletion_vectors,
+        search_options,
+        queries_owned,
+    )) = ann_shared
+    {
+        for segment in ann_segments {
+            let searcher = searcher.clone();
+            let segment = segment.clone();
+            let active_source_files = active_source_files.clone();
+            let deletion_vectors = deletion_vectors.clone();
+            let search_options = search_options.clone();
+            let residual_arc = residual_arc.clone();
+            let queries_owned = queries_owned.clone();
+            let budget = search_budget.clone();
+            leaves.push(Box::pin(async move {
+                let permit = acquire_search_permit(&budget).await?;
+                let segment_source = 
searcher.load_segment_source(&segment).await?;
+                let per_query = execute_global_index("ANN segment search task 
failed", move || {
+                    let _permit = permit;
+                    let query_refs: Vec<&[f32]> =
+                        queries_owned.iter().map(|q| q.as_slice()).collect();
+                    let per_query = searcher.search_batch_source(
+                        &segment,
+                        segment_source,
+                        &query_refs,
+                        metric,
+                        indexed_limit,
+                        &active_source_files,
+                        &deletion_vectors,
+                        &search_options,
+                        residual_arc.as_deref(),
+                    )?;
+                    if per_query.len() != query_count {
+                        return Err(data_invalid(format!(
+                            "ANN batch search returned {} result lists for {} 
queries",
+                            per_query.len(),
+                            query_count
+                        )));
+                    }
+                    Ok(per_query)
+                })
+                .await?;
+                Ok(BucketLeaf::Indexed(per_query))
+            }));
+        }
+    }
+    for (file, is_excluded) in &exact_tasks {
+        let budget = search_budget.clone();
+        leaves.push(Box::pin(async move {
+            let _permit = acquire_search_permit(&budget).await?;
+            let per_query =
+                exact_file_search(file, queries, metric, exact_limit, 
is_excluded.as_ref()).await?;
+            Ok(BucketLeaf::Exact(validate_per_query_len(
+                per_query,
+                query_count,
+            )?))
+        }));
+    }
+
+    let outs = drain_indexed_jobs(leaves.into_iter(), concurrency).await?;
+    for leaf in outs {
+        match leaf {
+            BucketLeaf::Indexed(per_query) => {
+                for (results, heap) in 
per_query.into_iter().zip(indexed_heaps.iter_mut()) {
+                    for result in results {
+                        add_candidate(heap, result, indexed_limit);
+                    }
                 }
-            }))
-            .buffer_unordered(concurrency)
-            .try_collect::<Vec<_>>()
-            .await?
-        };
-        for per_query in per_file {
-            for (results, heap) in 
per_query.into_iter().zip(exact_heaps.iter_mut()) {
-                for result in results {
-                    add_candidate(heap, result, exact_limit);
+            }
+            BucketLeaf::Exact(per_query) => {
+                for (results, heap) in 
per_query.into_iter().zip(exact_heaps.iter_mut()) {
+                    for result in results {
+                        add_candidate(heap, result, exact_limit);
+                    }
                 }
             }
         }
@@ -714,18 +932,27 @@ pub(crate) async fn bucket_search_batch(
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::spec::PrimaryKeyIndexSourceFile;
+    use crate::spec::PrimaryKeyIndexSourceFile as PkVectorSourceFile;
     use crate::vindex::pkvector::ann::PkVectorAnnSearcher;
     use crate::vindex::pkvector::exact::exact_search;
     use crate::vindex::pkvector::reader::test_support::ArrayReader;
+    use bytes::Bytes;
     use roaring::RoaringBitmap;
 
-    fn meta(files: &[(&str, i64)]) -> PrimaryKeyIndexSourceMeta {
-        PrimaryKeyIndexSourceMeta::new(
+    /// Trivial ANN-segment loader for fakes: returns empty owned bytes (the 
fakes
+    /// model behavior above physical index decoding and ignore 
`segment_bytes`).
+    fn empty_ann_loader(
+        _segment: &BucketAnnSegment,
+    ) -> futures::future::BoxFuture<'static, crate::Result<Bytes>> {
+        Box::pin(async { Ok(Bytes::new()) })
+    }
+
+    fn meta(files: &[(&str, i64)]) -> PkVectorSourceMeta {
+        PkVectorSourceMeta::new(
             1,
             files
                 .iter()
-                .map(|(n, r)| PrimaryKeyIndexSourceFile::new((*n).into(), 
*r).unwrap())
+                .map(|(n, r)| PkVectorSourceFile::new((*n).into(), 
*r).unwrap())
                 .collect(),
         )
         .unwrap()
@@ -826,13 +1053,21 @@ mod tests {
     }
 
     /// Fake ANN searcher returning preset results and recording calls.
+    #[derive(Clone)]
     struct FakeAnnSearcher {
         result: Vec<PkVectorSearchResult>,
     }
     impl PkVectorAnnSearcher for FakeAnnSearcher {
+        fn load_segment(
+            &self,
+            segment: &BucketAnnSegment,
+        ) -> futures::future::BoxFuture<'static, crate::Result<Bytes>> {
+            empty_ann_loader(segment)
+        }
         fn search_batch(
             &self,
             _segment: &BucketAnnSegment,
+            _segment_bytes: Bytes,
             queries: &[&[f32]],
             _metric: VectorSearchMetric,
             _limit: usize,
@@ -882,7 +1117,7 @@ mod tests {
         let opts = HashMap::new();
 
         let out = bucket_search(
-            Some(&ann),
+            Some(Arc::new(ann.clone())),
             &[segment],
             &active_files,
             &dvs,
@@ -958,7 +1193,7 @@ mod tests {
         };
         let factory = unreachable_search();
         let out = bucket_search(
-            Some(&ann),
+            Some(Arc::new(ann.clone())),
             &[segment],
             &[active("data-1", 3)],
             &HashMap::new(),
@@ -1015,7 +1250,7 @@ mod tests {
         };
         let factory = unreachable_search();
         let out = bucket_search(
-            Some(&ann),
+            Some(Arc::new(ann.clone())),
             &[segment],
             &[active("data-1", 2)],
             &HashMap::new(),
@@ -1080,7 +1315,7 @@ mod tests {
             },
         );
         let out = bucket_search(
-            Some(&ann),
+            Some(Arc::new(ann.clone())),
             &[segment],
             &[active("data-1", 2), active("data-2", 2)],
             &HashMap::new(),
@@ -1229,7 +1464,7 @@ mod tests {
         let segment = BucketAnnSegment::for_test(meta(&[("data-1", 2)]));
         let factory = unreachable_search();
         let err = bucket_search(
-            Some(&ann),
+            Some(Arc::new(ann.clone())),
             &[segment],
             &[active("data-1", 3)],
             &HashMap::new(),
@@ -1279,7 +1514,7 @@ mod tests {
             },
         );
         let out = bucket_search(
-            Some(&ann),
+            Some(Arc::new(ann.clone())),
             &[segment],
             &[active("data-1", 2)],
             &HashMap::new(),
@@ -1384,7 +1619,7 @@ mod tests {
         let ann = FakeAnnSearcher { result: vec![] };
         let factory = unreachable_search();
         let err = bucket_search(
-            Some(&ann),
+            Some(Arc::new(ann.clone())),
             &[seg1, seg2],
             &[active("data-1", 2), active("data-2", 2)],
             &HashMap::new(),
@@ -1424,7 +1659,7 @@ mod tests {
         let ann = FakeAnnSearcher { result: vec![] };
         let factory = unreachable_search();
         let err = bucket_search(
-            Some(&ann),
+            Some(Arc::new(ann.clone())),
             &[seg1, seg2],
             &[active("data-1", 2), active("data-2", 2)],
             &HashMap::new(),
@@ -1827,7 +2062,7 @@ mod tests {
                 Some(vec![8.0, 0.0]),
             ]);
             bucket_search(
-                Some(&ann),
+                Some(Arc::new(ann.clone())),
                 &[BucketAnnSegment::for_test(meta(&[("ann.mosaic", 3)]))],
                 &active_files,
                 &dvs,
@@ -1853,7 +2088,7 @@ mod tests {
         ]);
         let query_ref: &[f32] = &query;
         let batch = bucket_search_batch(
-            Some(&ann),
+            Some(Arc::new(ann.clone())),
             &[segment],
             &active_files,
             &dvs,
@@ -2180,4 +2415,715 @@ mod tests {
             "parallel ranking must equal serial ranking"
         );
     }
+
+    /// ANN searcher probe that records the peak number of `search_batch` calls
+    /// running simultaneously. Each call does a real blocking sleep so 
overlapping
+    /// calls are observable; `peak` is the max concurrent count seen. Mirrors 
the
+    /// blocking CPU nature of the real vindex scorer.
+    struct PeakProbeAnn {
+        inflight: std::sync::Arc<std::sync::atomic::AtomicUsize>,
+        peak: std::sync::Arc<std::sync::atomic::AtomicUsize>,
+    }
+    impl PkVectorAnnSearcher for PeakProbeAnn {
+        fn load_segment(
+            &self,
+            segment: &BucketAnnSegment,
+        ) -> futures::future::BoxFuture<'static, crate::Result<Bytes>> {
+            empty_ann_loader(segment)
+        }
+        fn search_batch(
+            &self,
+            _segment: &BucketAnnSegment,
+            _segment_bytes: Bytes,
+            queries: &[&[f32]],
+            _metric: VectorSearchMetric,
+            _limit: usize,
+            _active_source_files: &HashSet<String>,
+            _dvs: &HashMap<String, Arc<DeletionVector>>,
+            _opts: &HashMap<String, String>,
+            _residual_ranges: Option<&HashMap<String, 
roaring::RoaringTreemap>>,
+        ) -> crate::Result<Vec<Vec<PkVectorSearchResult>>> {
+            use std::sync::atomic::Ordering::SeqCst;
+            let current = self.inflight.fetch_add(1, SeqCst) + 1;
+            self.peak.fetch_max(current, SeqCst);
+            std::thread::sleep(std::time::Duration::from_millis(50));
+            self.inflight.fetch_sub(1, SeqCst);
+            Ok(queries.iter().map(|_| Vec::new()).collect())
+        }
+    }
+
+    /// `n` ANN segments in one bucket, each with a distinct payload path and a
+    /// distinct (covered) source file. Returns the searcher + its peak 
counter so a
+    /// test can assert how many segment searches ran at once.
+    fn n_segment_bucket(
+        n: usize,
+    ) -> (
+        Vec<BucketAnnSegment>,
+        Vec<BucketActiveFile>,
+        std::sync::Arc<PeakProbeAnn>,
+        std::sync::Arc<std::sync::atomic::AtomicUsize>,
+    ) {
+        let mut segments = Vec::with_capacity(n);
+        let mut actives = Vec::with_capacity(n);
+        for i in 0..n {
+            let src = format!("cov-{i}");
+            segments.push(BucketAnnSegment {
+                source_meta: meta(&[(src.as_str(), 2)]),
+                path: format!("seg-{i}"),
+                file_size: 0,
+                index_meta: Vec::new(),
+            });
+            actives.push(active(&src, 2));
+        }
+        let peak = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
+        let probe = std::sync::Arc::new(PeakProbeAnn {
+            inflight: 
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
+            peak: peak.clone(),
+        });
+        (segments, actives, probe, peak)
+    }
+
+    #[tokio::test]
+    async fn ann_segments_search_in_parallel_at_concurrency_above_one() {
+        // Two ANN segments in one bucket. At concurrency 4 their (blocking) 
searches
+        // must overlap: peak simultaneous ANN calls must exceed 1. A serial 
ANN loop
+        // yields peak == 1 and fails this assertion.
+        let (segments, active_files, probe, peak) = n_segment_bucket(2);
+        let searcher: Arc<dyn PkVectorAnnSearcher> = probe;
+        let factory = unreachable_search();
+        let out = bucket_search(
+            Some(searcher),
+            &segments,
+            &active_files,
+            &HashMap::new(),
+            &factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            8,
+            8,
+            &HashMap::new(),
+            false,
+            None,
+            4,
+            Some(SearchBudget::per_query_only(4)),
+        )
+        .await
+        .unwrap();
+        assert!(out.indexed.is_empty());
+        assert!(
+            peak.load(std::sync::atomic::Ordering::SeqCst) >= 2,
+            "ANN segment searches must run in parallel at concurrency 4; 
observed peak {}",
+            peak.load(std::sync::atomic::Ordering::SeqCst)
+        );
+    }
+
+    #[tokio::test]
+    async fn ann_segments_search_serially_at_concurrency_one() {
+        // At concurrency 1 the ANN segments must NOT overlap: peak 
simultaneous
+        // calls is exactly 1 (still off the async worker on the dedicated 
executor,
+        // but one at a time). Guards the one-worker budget without regressing 
inline.
+        let (segments, active_files, probe, peak) = n_segment_bucket(3);
+        let searcher: Arc<dyn PkVectorAnnSearcher> = probe;
+        let factory = unreachable_search();
+        bucket_search(
+            Some(searcher),
+            &segments,
+            &active_files,
+            &HashMap::new(),
+            &factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            8,
+            8,
+            &HashMap::new(),
+            false,
+            None,
+            1,
+            None,
+        )
+        .await
+        .unwrap();
+        assert_eq!(
+            peak.load(std::sync::atomic::Ordering::SeqCst),
+            1,
+            "concurrency == 1 must search ANN segments one at a time"
+        );
+    }
+
+    #[tokio::test]
+    async fn ann_segment_search_respects_shared_budget() {
+        // Four ANN segments, shared budget of 2: at most 2 segment searches 
may run
+        // at once even though concurrency (fan-out width) is 4. Mirrors 
Java's single
+        // shared pool sized to threadNum capping total in-flight leaf work.
+        let (segments, active_files, probe, peak) = n_segment_bucket(4);
+        let searcher: Arc<dyn PkVectorAnnSearcher> = probe;
+        let factory = unreachable_search();
+        bucket_search(
+            Some(searcher),
+            &segments,
+            &active_files,
+            &HashMap::new(),
+            &factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            8,
+            8,
+            &HashMap::new(),
+            false,
+            None,
+            4,
+            Some(SearchBudget::per_query_only(2)),
+        )
+        .await
+        .unwrap();
+        let observed = peak.load(std::sync::atomic::Ordering::SeqCst);
+        assert!(
+            observed <= 2,
+            "shared budget must cap concurrent ANN segment searches at 2; 
observed {observed}"
+        );
+        assert!(
+            observed >= 2,
+            "test must actually exercise ANN overlap; observed {observed}"
+        );
+    }
+
+    #[tokio::test]
+    async fn malformed_ann_source_fails_before_any_segment_search_runs() {
+        // A segment whose ANN source row count disagrees with the active file 
is
+        // corruption. The mismatch must be detected during up-front structural
+        // validation, BEFORE any segment search leaf is spawned — so the 
probe's
+        // peak stays 0. Guards Codex's "validate all segments before 
launching any
+        // job" requirement.
+        let (mut segments, _active_files, probe, peak) = n_segment_bucket(2);
+        // Break segment 1's source row count vs the active file (active says 
2).
+        segments[1] = BucketAnnSegment {
+            source_meta: meta(&[("cov-1", 99)]),
+            path: "seg-1".to_string(),
+            file_size: 0,
+            index_meta: Vec::new(),
+        };
+        let searcher: Arc<dyn PkVectorAnnSearcher> = probe;
+        let factory = unreachable_search();
+        let err = bucket_search(
+            Some(searcher),
+            &segments,
+            &[active("cov-0", 2), active("cov-1", 2)],
+            &HashMap::new(),
+            &factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            8,
+            8,
+            &HashMap::new(),
+            false,
+            None,
+            4,
+            Some(SearchBudget::per_query_only(4)),
+        )
+        .await
+        .unwrap_err();
+        assert!(
+            err.to_string().contains("does not match"),
+            "expected a row-count mismatch error, got: {err}"
+        );
+        assert_eq!(
+            peak.load(std::sync::atomic::Ordering::SeqCst),
+            0,
+            "no ANN segment search may run when structural validation fails"
+        );
+    }
+
+    #[tokio::test]
+    async fn batch_ann_segments_search_in_parallel_at_concurrency_above_one() {
+        // Multi-query (2 queries) routes through the `bucket_search_batch` 
multi-query
+        // path (not the batch-of-one short-circuit), so this covers the batch 
ANN
+        // parallel loop specifically. Two segments at concurrency 4 must 
overlap.
+        let (segments, active_files, probe, peak) = n_segment_bucket(2);
+        let searcher: Arc<dyn PkVectorAnnSearcher> = probe;
+        let factory = unreachable_search();
+        let q0: &[f32] = &[0.0, 0.0];
+        let q1: &[f32] = &[1.0, 1.0];
+        let out = bucket_search_batch(
+            Some(searcher),
+            &segments,
+            &active_files,
+            &HashMap::new(),
+            &factory,
+            &[q0, q1],
+            VectorSearchMetric::L2,
+            8,
+            8,
+            &HashMap::new(),
+            false,
+            None,
+            4,
+            Some(SearchBudget::per_query_only(4)),
+        )
+        .await
+        .unwrap();
+        assert_eq!(out.len(), 2, "one result list per query");
+        assert!(
+            peak.load(std::sync::atomic::Ordering::SeqCst) >= 2,
+            "batch ANN segment searches must run in parallel at concurrency 4; 
observed peak {}",
+            peak.load(std::sync::atomic::Ordering::SeqCst)
+        );
+    }
+
+    /// ANN searcher whose `search_batch` panics, exercising the dedicated
+    /// executor's panic-to-error mapping.
+    struct PanicAnn;
+    impl PkVectorAnnSearcher for PanicAnn {
+        fn load_segment(
+            &self,
+            segment: &BucketAnnSegment,
+        ) -> futures::future::BoxFuture<'static, crate::Result<Bytes>> {
+            empty_ann_loader(segment)
+        }
+        fn search_batch(
+            &self,
+            _segment: &BucketAnnSegment,
+            _segment_bytes: Bytes,
+            _queries: &[&[f32]],
+            _metric: VectorSearchMetric,
+            _limit: usize,
+            _active_source_files: &HashSet<String>,
+            _dvs: &HashMap<String, Arc<DeletionVector>>,
+            _opts: &HashMap<String, String>,
+            _residual_ranges: Option<&HashMap<String, 
roaring::RoaringTreemap>>,
+        ) -> crate::Result<Vec<Vec<PkVectorSearchResult>>> {
+            panic!("scorer panic to exercise JoinError mapping");
+        }
+    }
+
+    #[tokio::test]
+    async fn ann_segment_scorer_panic_maps_to_unexpected_error() {
+        // A panic inside the dedicated-executor ANN leaf must surface as a 
mapped
+        // `UnexpectedError` ("ANN segment search task failed"), not abort the 
runtime
+        // or hang. Uses the parallel path (concurrency 4).
+        let (segments, active_files, _probe, _peak) = n_segment_bucket(1);
+        let searcher: Arc<dyn PkVectorAnnSearcher> = Arc::new(PanicAnn);
+        let factory = unreachable_search();
+        let err = bucket_search(
+            Some(searcher),
+            &segments,
+            &active_files,
+            &HashMap::new(),
+            &factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            8,
+            8,
+            &HashMap::new(),
+            false,
+            None,
+            4,
+            Some(SearchBudget::per_query_only(4)),
+        )
+        .await
+        .unwrap_err();
+        assert!(
+            err.to_string().contains("ANN segment search task failed"),
+            "panic must map to UnexpectedError, got: {err}"
+        );
+    }
+
+    /// ANN searcher that counts every completed `search_batch` call and 
returns an
+    /// `Err` (naming the segment's source file) for any segment whose source 
file is
+    /// in `fail_files`. A configurable pre-return spin makes the erroring 
segment
+    /// finish FIRST while the others are still running, so a short-circuiting 
drain
+    /// would surface a non-deterministic / non-lowest-index error and skip 
jobs.
+    struct DrainProbeAnn {
+        completed: std::sync::Arc<std::sync::atomic::AtomicUsize>,
+        fail_files: HashSet<String>,
+        slow_files: HashSet<String>,
+    }
+    impl PkVectorAnnSearcher for DrainProbeAnn {
+        fn load_segment(
+            &self,
+            segment: &BucketAnnSegment,
+        ) -> futures::future::BoxFuture<'static, crate::Result<Bytes>> {
+            empty_ann_loader(segment)
+        }
+        fn search_batch(
+            &self,
+            segment: &BucketAnnSegment,
+            _segment_bytes: Bytes,
+            queries: &[&[f32]],
+            _metric: VectorSearchMetric,
+            _limit: usize,
+            _active_source_files: &HashSet<String>,
+            _dvs: &HashMap<String, Arc<DeletionVector>>,
+            _opts: &HashMap<String, String>,
+            _residual_ranges: Option<&HashMap<String, 
roaring::RoaringTreemap>>,
+        ) -> crate::Result<Vec<Vec<PkVectorSearchResult>>> {
+            let file = segment.source_meta.source_files()[0]
+                .file_name()
+                .to_string();
+            // Non-failing "slow" segments block so they are still in flight 
when the
+            // fast-failing segment returns — proving the drain awaits them 
anyway.
+            if self.slow_files.contains(&file) {
+                std::thread::sleep(std::time::Duration::from_millis(80));
+            }
+            self.completed
+                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+            if self.fail_files.contains(&file) {
+                return Err(data_invalid(format!("boom in {file}")));
+            }
+            Ok(queries.iter().map(|_| Vec::new()).collect())
+        }
+    }
+
+    #[tokio::test]
+    async fn drain_awaits_all_jobs_and_returns_lowest_index_error() {
+        // 4 segments (seg-0..seg-3 over cov-0..cov-3). Segments 1 and 2 
error; 0 and 3
+        // are slow. The drain must (a) run ALL 4 scorers to completion even 
though
+        // seg-1 errors early, and (b) surface seg-1's error (lowest index), 
not seg-2's
+        // and not whichever finished first.
+        let (segments, active_files, _probe, _peak) = n_segment_bucket(4);
+        let completed = 
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
+        let searcher: Arc<dyn PkVectorAnnSearcher> = Arc::new(DrainProbeAnn {
+            completed: completed.clone(),
+            fail_files: HashSet::from(["cov-1".to_string(), 
"cov-2".to_string()]),
+            slow_files: HashSet::from(["cov-0".to_string(), 
"cov-3".to_string()]),
+        });
+        let factory = unreachable_search();
+        let err = bucket_search(
+            Some(searcher),
+            &segments,
+            &active_files,
+            &HashMap::new(),
+            &factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            8,
+            8,
+            &HashMap::new(),
+            false,
+            None,
+            4,
+            Some(SearchBudget::per_query_only(4)),
+        )
+        .await
+        .unwrap_err();
+        // (b) lowest-index erroring segment wins, deterministically.
+        assert!(
+            err.to_string().contains("boom in cov-1"),
+            "must surface the lowest-index error (cov-1), got: {err}"
+        );
+        // (a) every segment's scorer ran to completion — no job was skipped 
by an
+        // early short-circuit.
+        assert_eq!(
+            completed.load(std::sync::atomic::Ordering::SeqCst),
+            4,
+            "all 4 ANN segment jobs must run to completion before the error 
returns"
+        );
+    }
+
+    /// ANN searcher that records peak concurrent calls into an EXTERNALLY 
shared
+    /// counter, so two independent `bucket_search` invocations can observe 
their
+    /// combined in-flight count. Each call blocks briefly so overlap is 
observable.
+    struct SharedPeakAnn {
+        inflight: std::sync::Arc<std::sync::atomic::AtomicUsize>,
+        peak: std::sync::Arc<std::sync::atomic::AtomicUsize>,
+    }
+    impl PkVectorAnnSearcher for SharedPeakAnn {
+        fn load_segment(
+            &self,
+            segment: &BucketAnnSegment,
+        ) -> futures::future::BoxFuture<'static, crate::Result<Bytes>> {
+            empty_ann_loader(segment)
+        }
+        fn search_batch(
+            &self,
+            _segment: &BucketAnnSegment,
+            _segment_bytes: Bytes,
+            queries: &[&[f32]],
+            _metric: VectorSearchMetric,
+            _limit: usize,
+            _active_source_files: &HashSet<String>,
+            _dvs: &HashMap<String, Arc<DeletionVector>>,
+            _opts: &HashMap<String, String>,
+            _residual_ranges: Option<&HashMap<String, 
roaring::RoaringTreemap>>,
+        ) -> crate::Result<Vec<Vec<PkVectorSearchResult>>> {
+            use std::sync::atomic::Ordering::SeqCst;
+            let current = self.inflight.fetch_add(1, SeqCst) + 1;
+            self.peak.fetch_max(current, SeqCst);
+            std::thread::sleep(std::time::Duration::from_millis(60));
+            self.inflight.fetch_sub(1, SeqCst);
+            Ok(queries.iter().map(|_| Vec::new()).collect())
+        }
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+    async fn shared_budget_caps_ann_across_concurrent_searches() {
+        // Two independent searches (simulating two concurrent PK-vector 
queries), each
+        // with 2 ANN segments and concurrency 4, but sharing ONE budget 
semaphore of
+        // 2 permits (the deliberately-shared test seam). Their COMBINED 
in-flight ANN
+        // count must never exceed 2 — proving the shared budget caps ANN work 
ACROSS
+        // queries, which is what the process-global static does in 
production. Before
+        // the fix, each query built its own Semaphore, so the combined peak 
could
+        // reach 2 (segments) x 2 (queries) = 4.
+        let inflight = 
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
+        let peak = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
+        let shared = Arc::new(Semaphore::new(2));
+
+        let run = |budget: SearchBudget| {
+            let inflight = inflight.clone();
+            let peak = peak.clone();
+            async move {
+                let (segments, active_files, _p, _pk) = n_segment_bucket(2);
+                let searcher: Arc<dyn PkVectorAnnSearcher> =
+                    Arc::new(SharedPeakAnn { inflight, peak });
+                let factory = unreachable_search();
+                bucket_search(
+                    Some(searcher),
+                    &segments,
+                    &active_files,
+                    &HashMap::new(),
+                    &factory,
+                    &[0.0, 0.0],
+                    VectorSearchMetric::L2,
+                    8,
+                    8,
+                    &HashMap::new(),
+                    false,
+                    None,
+                    4,
+                    Some(budget),
+                )
+                .await
+                .unwrap();
+            }
+        };
+
+        // Both searches share the SAME budget semaphore.
+        let a = run(SearchBudget::shared_for_test(shared.clone()));
+        let b = run(SearchBudget::shared_for_test(shared.clone()));
+        tokio::join!(a, b);
+
+        let observed = peak.load(std::sync::atomic::Ordering::SeqCst);
+        assert!(
+            observed <= 2,
+            "shared budget must cap ANN across concurrent searches at 2; 
observed {observed}"
+        );
+        assert!(
+            observed >= 2,
+            "test must actually exercise cross-search overlap; observed 
{observed}"
+        );
+    }
+
+    /// ANN searcher whose score performs a BOUNDED two-way handshake with a 
matching
+    /// exact-file probe: it signals arrival, then waits (with a timeout) for 
the
+    /// exact leaf to signal too. If both arrive within the timeout they were 
in
+    /// flight simultaneously — deterministic overlap detection with no sleeps 
and no
+    /// unbounded wait. If they cannot overlap (e.g. a two-phase scheduler), 
the leaf
+    /// that runs first times out waiting for the peer and RETURNS (setting no 
overlap
+    /// flag), so the test fails cleanly on the assertion rather than hanging 
forever.
+    struct HandshakeAnn {
+        /// ANN sends here on arrival; the exact side receives it.
+        ann_here: std::sync::mpsc::SyncSender<()>,
+        /// ANN receives the exact side's arrival here.
+        exact_here: 
std::sync::Arc<std::sync::Mutex<std::sync::mpsc::Receiver<()>>>,
+        overlapped: std::sync::Arc<std::sync::atomic::AtomicBool>,
+    }
+    impl PkVectorAnnSearcher for HandshakeAnn {
+        fn load_segment(
+            &self,
+            segment: &BucketAnnSegment,
+        ) -> futures::future::BoxFuture<'static, crate::Result<Bytes>> {
+            empty_ann_loader(segment)
+        }
+        fn search_batch(
+            &self,
+            _segment: &BucketAnnSegment,
+            _segment_bytes: Bytes,
+            queries: &[&[f32]],
+            _metric: VectorSearchMetric,
+            _limit: usize,
+            _active_source_files: &HashSet<String>,
+            _dvs: &HashMap<String, Arc<DeletionVector>>,
+            _opts: &HashMap<String, String>,
+            _residual_ranges: Option<&HashMap<String, 
roaring::RoaringTreemap>>,
+        ) -> crate::Result<Vec<Vec<PkVectorSearchResult>>> {
+            // Runs on the blocking pool. Announce arrival, then wait 
(bounded) for the
+            // exact leaf. Both arriving proves overlap; a timeout means no 
overlap.
+            let _ = self.ann_here.try_send(());
+            let got = self
+                .exact_here
+                .lock()
+                .unwrap()
+                .recv_timeout(std::time::Duration::from_secs(5));
+            if got.is_ok() {
+                self.overlapped
+                    .store(true, std::sync::atomic::Ordering::SeqCst);
+            }
+            Ok(queries.iter().map(|_| Vec::new()).collect())
+        }
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+    async fn ann_and_exact_leaves_overlap_within_one_bucket() {
+        // One bucket with one ANN segment (covers "cov") AND one uncovered 
exact file
+        // ("ex"). The ANN leaf (blocking) and the exact leaf (via 
spawn_blocking) do a
+        // bounded two-way handshake: each signals arrival and waits (5s cap) 
for the
+        // other. Both arriving proves they overlap. A two-phase scheduler 
(all ANN,
+        // then all exact) cannot get both in flight, so the leaf that runs 
first
+        // times out waiting for the peer (which never co-runs) and 
`overlapped` stays
+        // false — the assertion fails cleanly within ~5s, not by hanging.
+        let (ann_tx, ann_rx) = std::sync::mpsc::sync_channel::<()>(1);
+        let (exact_tx, exact_rx) = std::sync::mpsc::sync_channel::<()>(1);
+        let ann_rx = std::sync::Arc::new(std::sync::Mutex::new(ann_rx));
+        let exact_rx = std::sync::Arc::new(std::sync::Mutex::new(exact_rx));
+        let overlapped = 
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
+
+        let segment = BucketAnnSegment::for_test(meta(&[("cov", 2)]));
+        let searcher: Arc<dyn PkVectorAnnSearcher> = Arc::new(HandshakeAnn {
+            ann_here: ann_tx,
+            exact_here: exact_rx.clone(),
+            overlapped: overlapped.clone(),
+        });
+
+        // Exact-file probe: rendezvous on the same channels via 
`spawn_blocking`
+        // (so it can block-wait alongside the ANN blocking leaf), also 
bounded.
+        let ex_ann_rx = ann_rx.clone();
+        let factory = as_search(
+            move |file: &BucketActiveFile,
+                  queries: &[&[f32]],
+                  _: VectorSearchMetric,
+                  _: usize,
+                  _: &(dyn Fn(i64) -> bool + Sync)|
+                  -> ExactFileSearchFuture<'_> {
+                let ex_ann_rx = ex_ann_rx.clone();
+                let exact_tx = exact_tx.clone();
+                let n = queries.len();
+                let _ = file;
+                Box::pin(async move {
+                    tokio::task::spawn_blocking(move || {
+                        let _ = exact_tx.try_send(());
+                        let _ = ex_ann_rx
+                            .lock()
+                            .unwrap()
+                            .recv_timeout(std::time::Duration::from_secs(5));
+                    })
+                    .await
+                    .expect("exact handshake task");
+                    Ok(vec![Vec::new(); n])
+                })
+            },
+        );
+
+        let out = bucket_search(
+            Some(searcher),
+            &[segment],
+            &[active("cov", 2), active("ex", 2)],
+            &HashMap::new(),
+            &factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            8,
+            8,
+            &HashMap::new(),
+            false, // NOT fast: exact fallback runs
+            None,
+            4,
+            Some(SearchBudget::per_query_only(4)),
+        )
+        .await
+        .unwrap();
+        assert!(out.indexed.is_empty() && out.exact.is_empty());
+        assert!(
+            overlapped.load(std::sync::atomic::Ordering::SeqCst),
+            "ANN and exact leaves of one bucket must overlap (both completed 
the handshake)"
+        );
+    }
+
+    /// ANN searcher whose `load_segment` records which segment paths it 
loaded and
+    /// hands the loaded bytes through to `search_batch`, which asserts it 
received
+    /// exactly those bytes. Proves the leaf loads per-segment via the seam 
(not from
+    /// an up-front map) and that the loaded bytes reach the scorer.
+    struct RecordingLoaderAnn {
+        loaded: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
+    }
+    impl PkVectorAnnSearcher for RecordingLoaderAnn {
+        fn load_segment(
+            &self,
+            segment: &BucketAnnSegment,
+        ) -> futures::future::BoxFuture<'static, crate::Result<Bytes>> {
+            let loaded = self.loaded.clone();
+            let path = segment.path.clone();
+            // Bytes are the segment path itself, so `search_batch` can verify 
the
+            // exact bytes from THIS segment's load arrived (not some other 
segment's).
+            Box::pin(async move {
+                loaded.lock().unwrap().push(path.clone());
+                Ok(Bytes::from(path.into_bytes()))
+            })
+        }
+        fn search_batch(
+            &self,
+            segment: &BucketAnnSegment,
+            segment_bytes: Bytes,
+            queries: &[&[f32]],
+            _metric: VectorSearchMetric,
+            _limit: usize,
+            _active_source_files: &HashSet<String>,
+            _dvs: &HashMap<String, Arc<DeletionVector>>,
+            _opts: &HashMap<String, String>,
+            _residual_ranges: Option<&HashMap<String, 
roaring::RoaringTreemap>>,
+        ) -> crate::Result<Vec<Vec<PkVectorSearchResult>>> {
+            // The bytes handed to the scorer must be exactly this segment's 
loaded
+            // bytes (its path), proving load→score threads the right payload.
+            assert_eq!(segment_bytes.as_ref(), segment.path.as_bytes());
+            Ok(queries.iter().map(|_| Vec::new()).collect())
+        }
+    }
+
+    #[tokio::test]
+    async fn ann_segment_bytes_are_loaded_lazily_per_segment_via_the_seam() {
+        // Two ANN segments; the searcher's `load_segment` is invoked once per 
segment
+        // during the bucket search (lazily, in each leaf), and the bytes it 
returns
+        // are the ones passed to that segment's scorer. This is the fused
+        // load→score path (no up-front all-segments preload map).
+        let loaded = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
+        let searcher: Arc<dyn PkVectorAnnSearcher> = 
Arc::new(RecordingLoaderAnn {
+            loaded: loaded.clone(),
+        });
+        let seg_a = BucketAnnSegment {
+            source_meta: meta(&[("cov-a", 2)]),
+            path: "seg-a".to_string(),
+            file_size: 0,
+            index_meta: Vec::new(),
+        };
+        let seg_b = BucketAnnSegment {
+            source_meta: meta(&[("cov-b", 2)]),
+            path: "seg-b".to_string(),
+            file_size: 0,
+            index_meta: Vec::new(),
+        };
+        let factory = unreachable_search();
+        bucket_search(
+            Some(searcher),
+            &[seg_a, seg_b],
+            &[active("cov-a", 2), active("cov-b", 2)],
+            &HashMap::new(),
+            &factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            8,
+            8,
+            &HashMap::new(),
+            false,
+            None,
+            4,
+            Some(SearchBudget::per_query_only(4)),
+        )
+        .await
+        .unwrap();
+        let mut got = loaded.lock().unwrap().clone();
+        got.sort();
+        assert_eq!(
+            got,
+            vec!["seg-a".to_string(), "seg-b".to_string()],
+            "each ANN segment must be loaded exactly once via load_segment 
during the search"
+        );
+    }
 }
diff --git a/crates/paimon/src/vindex/reader.rs 
b/crates/paimon/src/vindex/reader.rs
index 3dc9dda1..21603f17 100644
--- a/crates/paimon/src/vindex/reader.rs
+++ b/crates/paimon/src/vindex/reader.rs
@@ -125,6 +125,18 @@ impl VindexVectorGlobalIndexReader {
         self.ensure_loaded(stream_fn, |_| Ok(()))
     }
 
+    pub(crate) fn load_validated<S, F>(
+        &mut self,
+        stream_fn: impl FnOnce(&str) -> crate::Result<S>,
+        validate: F,
+    ) -> crate::Result<()>
+    where
+        S: SeekRead + 'static,
+        F: FnOnce(&VectorIndexMetadata) -> crate::Result<()>,
+    {
+        self.ensure_loaded(stream_fn, validate)
+    }
+
     pub(crate) fn metadata(&self) -> crate::Result<&VectorIndexMetadata> {
         self.metadata
             .as_ref()

Reply via email to