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-vector-index.git


The following commit(s) were added to refs/heads/main by this push:
     new 2a8d88a  ivfpq: Reuse query distance tables across IVF lists (#67)
2a8d88a is described below

commit 2a8d88ac67b40944c9728195a3ecbc298afe06cc
Author: jerry <[email protected]>
AuthorDate: Mon Aug 3 21:40:15 2026 +0800

    ivfpq: Reuse query distance tables across IVF lists (#67)
---
 c/test_vindex.c                                    |  12 +
 core/Cargo.toml                                    |   4 +
 core/benches/ivfpq_batch_reuse_bench.rs            | 165 ++++++++
 core/src/index.rs                                  |  39 +-
 core/src/io.rs                                     |   6 +-
 core/src/ivfpq.rs                                  | 454 +++++++++++++++++++--
 cpp/test_vindex.cpp                                |   2 +
 ffi/src/lib.rs                                     | 137 +++++++
 include/paimon_vindex.hpp                          |  21 +-
 .../paimon/index/vector/VectorSearchParams.java    |  47 ++-
 .../index/vector/VectorIndexJavaApiTest.java       |  13 +
 jni/src/lib.rs                                     |  28 ++
 python/paimon_vindex/__init__.py                   |  52 ++-
 python/paimon_vindex/_ffi.py                       |  34 ++
 python/tests/test_vindex.py                        |  22 +
 15 files changed, 980 insertions(+), 56 deletions(-)

diff --git a/c/test_vindex.c b/c/test_vindex.c
index fbd1c93..ac8fd50 100644
--- a/c/test_vindex.c
+++ b/c/test_vindex.c
@@ -339,6 +339,18 @@ static void run_roundtrip(
     }
     assert_id_in_cluster(batch_ids[0], 0);
     assert_id_in_cluster(batch_ids[1], 1);
+    struct PaimonVindexSearchParamsV2 batch_params_v2 = {
+        .top_k = 1,
+        .search_width = batch_params.search_width,
+        .width = batch_params.width,
+        .ivfpq_batch_table_reuse = PAIMON_VINDEX_IVFPQ_BATCH_TABLE_REUSE_OFF,
+        .ivfpq_batch_table_reuse_max_bytes = 1};
+    if (paimon_vindex_reader_search_batch_v2(
+            reader, queries, 2, batch_params_v2, batch_ids, batch_distances, 
2) != 0) {
+        fail_ffi("reader search batch v2 failed");
+    }
+    assert_id_in_cluster(batch_ids[0], 0);
+    assert_id_in_cluster(batch_ids[1], 1);
     paimon_vindex_reader_free(reader);
     free(buf.data);
     free(data);
diff --git a/core/Cargo.toml b/core/Cargo.toml
index 4b3c126..317afa5 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -53,3 +53,7 @@ harness = false
 [[bench]]
 name = "diskann_bench"
 harness = false
+
+[[bench]]
+name = "ivfpq_batch_reuse_bench"
+harness = false
diff --git a/core/benches/ivfpq_batch_reuse_bench.rs 
b/core/benches/ivfpq_batch_reuse_bench.rs
new file mode 100644
index 0000000..1445015
--- /dev/null
+++ b/core/benches/ivfpq_batch_reuse_bench.rs
@@ -0,0 +1,165 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use paimon_vindex_core::distance::MetricType;
+use paimon_vindex_core::io::{write_index, IVFPQIndexReader, PosWriter};
+use paimon_vindex_core::ivfpq::{
+    search_batch_reader_with_reuse_mode, IVFPQIndex, IvfPqBatchTableReuseMode,
+};
+use rand::rngs::StdRng;
+use rand::{Rng, SeedableRng};
+use std::hint::black_box;
+use std::io::Cursor;
+use std::time::Instant;
+
+const D: usize = 768;
+const M: usize = 16;
+const NLIST: usize = 256;
+const NPROBE: usize = 8;
+const NQ: usize = 256;
+const K: usize = 10;
+const ROWS_PER_LIST: usize = 390;
+const ROUNDS: usize = 100;
+
+#[derive(Clone, Copy)]
+struct CpuTimes {
+    user: f64,
+    system: f64,
+}
+
+#[derive(Clone, Copy)]
+struct Sample {
+    wall: f64,
+    user: f64,
+    system: f64,
+}
+
+#[cfg(unix)]
+fn cpu_times() -> CpuTimes {
+    let mut usage = std::mem::MaybeUninit::<libc::rusage>::uninit();
+    let result = unsafe { libc::getrusage(libc::RUSAGE_SELF, 
usage.as_mut_ptr()) };
+    assert_eq!(result, 0);
+    let usage = unsafe { usage.assume_init() };
+    CpuTimes {
+        user: usage.ru_utime.tv_sec as f64 + usage.ru_utime.tv_usec as f64 / 
1_000_000.0,
+        system: usage.ru_stime.tv_sec as f64 + usage.ru_stime.tv_usec as f64 / 
1_000_000.0,
+    }
+}
+
+#[cfg(not(unix))]
+fn cpu_times() -> CpuTimes {
+    CpuTimes {
+        user: 0.0,
+        system: 0.0,
+    }
+}
+
+fn search(
+    reader: &mut IVFPQIndexReader<Cursor<Vec<u8>>>,
+    queries: &[f32],
+    mode: IvfPqBatchTableReuseMode,
+) -> ((Vec<i64>, Vec<f32>), Sample) {
+    let cpu_before = cpu_times();
+    let started = Instant::now();
+    let result = search_batch_reader_with_reuse_mode(reader, queries, NQ, K, 
NPROBE, mode).unwrap();
+    let wall = started.elapsed().as_secs_f64();
+    let cpu_after = cpu_times();
+    black_box(result.0.iter().fold(0i64, |sum, id| sum.wrapping_add(*id)));
+    (
+        result,
+        Sample {
+            wall,
+            user: cpu_after.user - cpu_before.user,
+            system: cpu_after.system - cpu_before.system,
+        },
+    )
+}
+
+fn percentile(samples: &[Sample], percentile: usize, value: impl Fn(&Sample) 
-> f64) -> f64 {
+    let mut values = samples.iter().map(value).collect::<Vec<_>>();
+    values.sort_by(f64::total_cmp);
+    values[(percentile * values.len()).div_ceil(100).saturating_sub(1)]
+}
+
+fn main() {
+    let mut rng = StdRng::seed_from_u64(42);
+    let mut index = IVFPQIndex::new(D, NLIST, M, MetricType::InnerProduct, 
false);
+    index.quantizer_centroids = (0..NLIST * D)
+        .map(|_| rng.gen_range(-1.0f32..1.0))
+        .collect();
+    index.pq.centroids = (0..M * index.pq.ksub * index.pq.dsub)
+        .map(|_| rng.gen_range(-1.0f32..1.0))
+        .collect();
+    for list_id in 0..NLIST {
+        let first_id = list_id * ROWS_PER_LIST;
+        index.ids[list_id] = (first_id..first_id + ROWS_PER_LIST)
+            .map(|id| id as i64)
+            .collect();
+        index.codes[list_id] = (0..ROWS_PER_LIST * M).map(|_| 
rng.gen()).collect();
+    }
+    let queries = (0..NQ * D)
+        .map(|_| rng.gen_range(-1.0f32..1.0))
+        .collect::<Vec<_>>();
+    let mut bytes = Vec::new();
+    write_index(&index, &mut PosWriter::new(&mut bytes)).unwrap();
+
+    let modes = [
+        IvfPqBatchTableReuseMode::Off,
+        IvfPqBatchTableReuseMode::On,
+        IvfPqBatchTableReuseMode::Auto,
+    ];
+    let mut readers = [
+        IVFPQIndexReader::open(Cursor::new(bytes.clone())).unwrap(),
+        IVFPQIndexReader::open(Cursor::new(bytes.clone())).unwrap(),
+        IVFPQIndexReader::open(Cursor::new(bytes)).unwrap(),
+    ];
+    let expected = search(&mut readers[0], &queries, modes[0]).0;
+    assert_eq!(search(&mut readers[1], &queries, modes[1]).0, expected);
+    assert_eq!(search(&mut readers[2], &queries, modes[2]).0, expected);
+
+    let mut samples = [Vec::new(), Vec::new(), Vec::new()];
+    let orders = [[0, 1, 2], [1, 2, 0], [2, 0, 1]];
+    for round in 0..ROUNDS {
+        for &mode_index in &orders[round % orders.len()] {
+            let (_, sample) = search(&mut readers[mode_index], &queries, 
modes[mode_index]);
+            samples[mode_index].push(sample);
+        }
+    }
+
+    println!(
+        "shape: d={D} m={M} nlist={NLIST} nprobe={NPROBE} nq={NQ} vectors={} 
threads={} rounds={ROUNDS}",
+        NLIST * ROWS_PER_LIST,
+        rayon::current_num_threads()
+    );
+    
println!("mode,percentile,wall_ms,user_cpu_ms,sys_cpu_ms,total_cpu_ms,cpu/wall");
+    for (mode, samples) in ["off", "on", "auto"].into_iter().zip(&samples) {
+        for p in [50, 90, 95, 99] {
+            let wall = percentile(samples, p, |sample| sample.wall);
+            let user = percentile(samples, p, |sample| sample.user);
+            let system = percentile(samples, p, |sample| sample.system);
+            let total = percentile(samples, p, |sample| sample.user + 
sample.system);
+            println!(
+                "{mode},p{p},{:.3},{:.3},{:.3},{:.3},{:.2}",
+                wall * 1_000.0,
+                user * 1_000.0,
+                system * 1_000.0,
+                total * 1_000.0,
+                total / wall
+            );
+        }
+    }
+}
diff --git a/core/src/index.rs b/core/src/index.rs
index 41d16bc..cc2ed7c 100644
--- a/core/src/index.rs
+++ b/core/src/index.rs
@@ -33,11 +33,12 @@ use crate::ivfflat_io::{
     search_batch_ivfflat_reader, search_batch_ivfflat_reader_roaring_filter, 
write_ivfflat_index,
     IVFFlatIndexReader, IVFFLAT_MAGIC,
 };
-pub use crate::ivfpq::IvfPqBatchTableReuseMode;
 use crate::ivfpq::{
-    search_batch_reader_roaring_filter_with_reuse_mode, 
search_batch_reader_with_reuse_mode,
-    search_with_reader, search_with_reader_roaring_filter, IVFPQIndex,
+    search_batch_reader_roaring_filter_with_reuse_mode_and_budget,
+    search_batch_reader_with_reuse_mode_and_budget, search_with_reader,
+    search_with_reader_roaring_filter, IVFPQIndex,
 };
+pub use crate::ivfpq::{IvfPqBatchTableReuseMode, 
DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES};
 use crate::ivfrq::IVFRQIndex;
 pub use crate::ivfrq_io::IVFRQSearchStats;
 use crate::ivfrq_io::{
@@ -991,6 +992,7 @@ pub struct VectorSearchParams {
     pub search_width: SearchWidth,
     pub width: usize,
     pub ivfpq_batch_table_reuse: IvfPqBatchTableReuseMode,
+    pub ivfpq_batch_table_reuse_max_bytes: usize,
 }
 
 impl VectorSearchParams {
@@ -1000,6 +1002,7 @@ impl VectorSearchParams {
             search_width: SearchWidth::IvfNProbe,
             width: nprobe,
             ivfpq_batch_table_reuse: IvfPqBatchTableReuseMode::Auto,
+            ivfpq_batch_table_reuse_max_bytes: 
DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
         }
     }
 
@@ -1009,6 +1012,7 @@ impl VectorSearchParams {
             search_width: SearchWidth::DiskAnnLSearch,
             width: l_search,
             ivfpq_batch_table_reuse: IvfPqBatchTableReuseMode::Auto,
+            ivfpq_batch_table_reuse_max_bytes: 
DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
         }
     }
 
@@ -1018,6 +1022,7 @@ impl VectorSearchParams {
             search_width: SearchWidth::Auto,
             width: 0,
             ivfpq_batch_table_reuse: IvfPqBatchTableReuseMode::Auto,
+            ivfpq_batch_table_reuse_max_bytes: 
DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
         }
     }
 
@@ -1026,6 +1031,11 @@ impl VectorSearchParams {
         self
     }
 
+    pub fn with_ivfpq_batch_table_reuse_max_bytes(mut self, max_bytes: usize) 
-> Self {
+        self.ivfpq_batch_table_reuse_max_bytes = max_bytes;
+        self
+    }
+
     pub fn configured_ivf_nprobe(self) -> Option<usize> {
         (self.search_width == SearchWidth::IvfNProbe).then_some(self.width)
     }
@@ -1035,7 +1045,11 @@ impl VectorSearchParams {
     }
 
     fn validate(self) -> io::Result<()> {
-        validate_positive(self.top_k, "top_k")
+        validate_positive(self.top_k, "top_k")?;
+        validate_positive(
+            self.ivfpq_batch_table_reuse_max_bytes,
+            "IVF-PQ batch table reuse max bytes",
+        )
     }
 
     fn resolve_ivf_nprobe(
@@ -1773,13 +1787,14 @@ impl<R: SeekRead> VectorIndexReader<R> {
                     params.top_k,
                     total_vectors,
                     |nprobe| {
-                        search_batch_reader_with_reuse_mode(
+                        search_batch_reader_with_reuse_mode_and_budget(
                             reader,
                             queries,
                             query_count,
                             params.top_k,
                             nprobe,
                             params.ivfpq_batch_table_reuse,
+                            params.ivfpq_batch_table_reuse_max_bytes,
                         )
                     },
                 )
@@ -1890,7 +1905,7 @@ impl<R: SeekRead> VectorIndexReader<R> {
                     params.top_k,
                     matching_count.unwrap_or(total_vectors),
                     |nprobe| {
-                        search_batch_reader_roaring_filter_with_reuse_mode(
+                        
search_batch_reader_roaring_filter_with_reuse_mode_and_budget(
                             reader,
                             queries,
                             query_count,
@@ -1898,6 +1913,7 @@ impl<R: SeekRead> VectorIndexReader<R> {
                             nprobe,
                             roaring_filter_bytes,
                             params.ivfpq_batch_table_reuse,
+                            params.ivfpq_batch_table_reuse_max_bytes,
                         )
                     },
                 )
@@ -2925,12 +2941,23 @@ mod tests {
             params.ivfpq_batch_table_reuse,
             IvfPqBatchTableReuseMode::Auto
         );
+        assert_eq!(params.ivfpq_batch_table_reuse_max_bytes, 512 * 1024 * 
1024);
         assert_eq!(
             params
                 .with_ivfpq_batch_table_reuse(IvfPqBatchTableReuseMode::Off)
                 .ivfpq_batch_table_reuse,
             IvfPqBatchTableReuseMode::Off
         );
+        assert_eq!(
+            params
+                .with_ivfpq_batch_table_reuse_max_bytes(128 * 1024 * 1024)
+                .ivfpq_batch_table_reuse_max_bytes,
+            128 * 1024 * 1024
+        );
+        assert!(params
+            .with_ivfpq_batch_table_reuse_max_bytes(0)
+            .validate()
+            .is_err());
     }
 
     #[test]
diff --git a/core/src/io.rs b/core/src/io.rs
index f66eade..87b7dd3 100644
--- a/core/src/io.rs
+++ b/core/src/io.rs
@@ -893,7 +893,7 @@ impl<R: SeekRead> IVFPQIndexReader<R> {
     pub(crate) fn for_each_streamed_list_chunk(
         &mut self,
         list_id: usize,
-        mut consume: impl FnMut(&[i64], &[u8]),
+        mut consume: impl FnMut(&ProductQuantizer, &[i64], &[u8]),
     ) -> io::Result<()> {
         self.ensure_loaded()?;
         let count = self.list_counts[list_id] as usize;
@@ -964,7 +964,7 @@ impl<R: SeekRead> IVFPQIndexReader<R> {
                     .pread(&mut [ReadRequest::new(chunk_offset, 
payload.codes_mut())])?;
             }
             let row_end = row_start + chunk_rows;
-            consume(&ids[row_start..row_end], payload.codes());
+            consume(&self.pq, &ids[row_start..row_end], payload.codes());
             row_start = row_end;
         }
         Ok(())
@@ -1349,7 +1349,7 @@ mod tests {
         let mut actual_ids = Vec::new();
         let mut actual_codes = Vec::new();
         streamed_reader
-            .for_each_streamed_list_chunk(0, |ids, codes| {
+            .for_each_streamed_list_chunk(0, |_, ids, codes| {
                 actual_ids.extend_from_slice(ids);
                 actual_codes.extend_from_slice(codes);
             })
diff --git a/core/src/ivfpq.rs b/core/src/ivfpq.rs
index f0c35ad..75afcf1 100644
--- a/core/src/ivfpq.rs
+++ b/core/src/ivfpq.rs
@@ -19,7 +19,7 @@ use crate::distance::{
     fvec_inner_product, fvec_madd, fvec_normalize, pq_distance_four_codes, 
pq_distance_from_table,
     MetricType,
 };
-use crate::index_io_util::{ivf_payload_is_oversized, MAX_IVF_BATCH_READ_BYTES};
+use crate::index_io_util::ivf_payload_is_oversized;
 use crate::io::{IVFPQIndexReader, InvertedListPayload, SeekRead};
 use crate::kmeans::{self, KMeansConfig};
 use crate::opq::OPQMatrix;
@@ -29,6 +29,7 @@ use roaring::RoaringTreemap;
 use std::borrow::Cow;
 use std::collections::HashSet;
 use std::io;
+use std::sync::OnceLock;
 
 pub trait RowIdFilter: Sync {
     fn contains(&self, id: i64) -> bool;
@@ -402,8 +403,8 @@ impl IVFPQIndex {
                 let coarse_dists = &all_coarse_dists[qi];
 
                 let mut heap = TopKHeap::new(k);
-                let mut sim_table = vec![0.0f32; m * ksub];
-
+                let mut sim_table = Vec::new();
+                let mut non_residual_table_ready = false;
                 let ip_table = if use_precomputed {
                     let mut t = vec![0.0f32; m * ksub];
                     self.pq.compute_inner_product_table(query, &mut t);
@@ -422,6 +423,15 @@ impl IVFPQIndex {
                         continue;
                     }
 
+                    if sim_table.is_empty() {
+                        sim_table.resize(m * ksub, 0.0);
+                    }
+                    if !self.by_residual && !non_residual_table_ready {
+                        self.pq
+                            .compute_distance_table(query, self.metric, &mut 
sim_table);
+                        non_residual_table_ready = true;
+                    }
+
                     // Precomputed sim_table omits ||q-c||²; add it as dis0.
                     // Non-precomputed path computes from residual_query, 
already full distance.
                     let dis0 = if use_precomputed {
@@ -438,7 +448,7 @@ impl IVFPQIndex {
                             -2.0,
                             &mut sim_table,
                         );
-                    } else {
+                    } else if self.by_residual {
                         self.compute_list_table(query, list_id, &mut 
sim_table);
                     }
 
@@ -924,6 +934,7 @@ fn has_matching_rows(matching_rows: Option<&MatchingRows>) 
-> bool {
 // Below this size, table construction and Rayon scheduling dominate the saved
 // per-query/list distance-table work.
 const MIN_EPHEMERAL_PRECOMPUTE_QUERIES: usize = 64;
+pub const DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES: usize = 512 * 1024 * 1024;
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 #[repr(u32)]
@@ -948,6 +959,7 @@ fn ephemeral_precomputed_table_fits_budget(
     query_scratch_count: usize,
     m: usize,
     ksub: usize,
+    max_bytes: usize,
 ) -> bool {
     if matching_list_count == 0 {
         return false;
@@ -958,7 +970,7 @@ fn ephemeral_precomputed_table_fits_budget(
         .and_then(|tables| tables.checked_mul(m))
         .and_then(|values| values.checked_mul(ksub))
         .and_then(|values| values.checked_mul(std::mem::size_of::<f64>()))
-        .is_some_and(|bytes| bytes <= MAX_IVF_BATCH_READ_BYTES)
+        .is_some_and(|bytes| bytes <= max_bytes)
 }
 
 #[cfg(test)]
@@ -1312,6 +1324,9 @@ struct ReaderSearchContext<'a> {
     q: &'a [f32],
     ip_table: &'a [f32],
     use_precomputed: bool,
+    shared_sim_table: Option<&'a OnceLock<Vec<f32>>>,
+    #[cfg(test)]
+    distance_table_builds: Option<&'a std::sync::atomic::AtomicUsize>,
     d: usize,
     m: usize,
     ksub: usize,
@@ -1401,6 +1416,7 @@ pub fn search_with_reader_filter<R: SeekRead>(
     } else {
         Vec::new()
     };
+    let shared_sim_table = OnceLock::new();
 
     let mut heap = TopKHeap::new(k);
 
@@ -1428,14 +1444,25 @@ pub fn search_with_reader_filter<R: SeekRead>(
         let first_list = read_list_ids[batch_start];
         if ivf_payload_is_oversized(reader.list_payload_len(first_list)?) {
             let (_, _, dis0) = lists_to_read[batch_start];
-            let sim_table = reader_sim_table(reader, first_list, &q, 
&ip_table, use_precomputed);
+            let sim_table = by_residual
+                .then(|| reader_sim_table(reader, first_list, &q, &ip_table, 
use_precomputed));
             let pq_nbits = reader.pq.nbits;
             let transposed_codes = reader.transposed_codes;
             let mut scratch = ReaderScanScratch::default();
-            reader.for_each_streamed_list_chunk(first_list, |ids, codes| {
+            reader.for_each_streamed_list_chunk(first_list, |pq, ids, codes| {
                 let positions = matching_rows(ids, filter);
+                if positions.as_ref().is_some_and(MatchingRows::is_empty) {
+                    return;
+                }
+                let sim_table = sim_table.as_deref().unwrap_or_else(|| {
+                    shared_sim_table.get_or_init(|| {
+                        let mut table = vec![0.0f32; m * ksub];
+                        pq.compute_distance_table(&q, metric, &mut table);
+                        table
+                    })
+                });
                 scan_reader_codes(
-                    &sim_table,
+                    sim_table,
                     codes,
                     ids,
                     m,
@@ -1478,6 +1505,9 @@ pub fn search_with_reader_filter<R: SeekRead>(
             q: &q,
             ip_table: &ip_table,
             use_precomputed,
+            shared_sim_table: (!by_residual).then_some(&shared_sim_table),
+            #[cfg(test)]
+            distance_table_builds: None,
             d,
             m,
             ksub,
@@ -1543,9 +1573,18 @@ fn scan_reader_list(
     if matching_rows.is_some_and(MatchingRows::is_empty) {
         return;
     }
-    fill_reader_sim_table(entry.list_id, ctx, &mut scratch.sim_table);
+    let sim_table = if let Some(table) = ctx.shared_sim_table {
+        table.get_or_init(|| {
+            let mut sim_table = Vec::new();
+            fill_reader_sim_table(entry.list_id, ctx, &mut sim_table);
+            sim_table
+        })
+    } else {
+        fill_reader_sim_table(entry.list_id, ctx, &mut scratch.sim_table);
+        &scratch.sim_table
+    };
     scan_reader_codes(
-        &scratch.sim_table,
+        sim_table,
         entry.codes(),
         &entry.ids,
         ctx.m,
@@ -1564,6 +1603,12 @@ fn fill_reader_sim_table(list_id: usize, ctx: 
&ReaderSearchContext<'_>, sim_tabl
     let m = ctx.m;
     let ksub = ctx.ksub;
     sim_table.resize(m * ksub, 0.0);
+    #[cfg(test)]
+    if !ctx.use_precomputed {
+        if let Some(builds) = ctx.distance_table_builds {
+            builds.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+        }
+    }
     if ctx.use_precomputed {
         let tab_base = list_id * m * ksub;
         fvec_madd(
@@ -1598,6 +1643,9 @@ fn reader_sim_table<R: SeekRead>(
         q: query,
         ip_table,
         use_precomputed,
+        shared_sim_table: None,
+        #[cfg(test)]
+        distance_table_builds: None,
         d: reader.d,
         m: reader.m,
         ksub: reader.ksub,
@@ -1701,7 +1749,36 @@ pub fn search_batch_reader_with_reuse_mode<R: SeekRead>(
     nprobe: usize,
     reuse_mode: IvfPqBatchTableReuseMode,
 ) -> io::Result<(Vec<i64>, Vec<f32>)> {
-    search_batch_reader_filter_with_reuse_mode(reader, queries, nq, k, nprobe, 
None, reuse_mode)
+    search_batch_reader_with_reuse_mode_and_budget(
+        reader,
+        queries,
+        nq,
+        k,
+        nprobe,
+        reuse_mode,
+        DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
+    )
+}
+
+pub fn search_batch_reader_with_reuse_mode_and_budget<R: SeekRead>(
+    reader: &mut IVFPQIndexReader<R>,
+    queries: &[f32],
+    nq: usize,
+    k: usize,
+    nprobe: usize,
+    reuse_mode: IvfPqBatchTableReuseMode,
+    reuse_max_bytes: usize,
+) -> io::Result<(Vec<i64>, Vec<f32>)> {
+    search_batch_reader_filter_with_reuse_mode_and_budget(
+        reader,
+        queries,
+        nq,
+        k,
+        nprobe,
+        None,
+        reuse_mode,
+        reuse_max_bytes,
+    )
 }
 
 /// Big batch search with an optional row-id filter.
@@ -1732,6 +1809,28 @@ pub fn search_batch_reader_filter_with_reuse_mode<R: 
SeekRead>(
     nprobe: usize,
     filter: Option<&dyn RowIdFilter>,
     reuse_mode: IvfPqBatchTableReuseMode,
+) -> io::Result<(Vec<i64>, Vec<f32>)> {
+    search_batch_reader_filter_with_reuse_mode_and_budget(
+        reader,
+        queries,
+        nq,
+        k,
+        nprobe,
+        filter,
+        reuse_mode,
+        DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
+    )
+}
+
+pub fn search_batch_reader_filter_with_reuse_mode_and_budget<R: SeekRead>(
+    reader: &mut IVFPQIndexReader<R>,
+    queries: &[f32],
+    nq: usize,
+    k: usize,
+    nprobe: usize,
+    filter: Option<&dyn RowIdFilter>,
+    reuse_mode: IvfPqBatchTableReuseMode,
+    reuse_max_bytes: usize,
 ) -> io::Result<(Vec<i64>, Vec<f32>)> {
     search_batch_reader_filter_with_reuse_mode_and_observer(
         reader,
@@ -1741,7 +1840,10 @@ pub fn search_batch_reader_filter_with_reuse_mode<R: 
SeekRead>(
         nprobe,
         filter,
         reuse_mode,
+        reuse_max_bytes,
         |_| {},
+        #[cfg(test)]
+        None,
     )
 }
 
@@ -1763,7 +1865,9 @@ fn search_batch_reader_filter_with_observer<R: SeekRead>(
         nprobe,
         filter,
         IvfPqBatchTableReuseMode::Auto,
+        DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
         &mut observe_ephemeral_precomputed_lists,
+        None,
     )
 }
 
@@ -1775,7 +1879,9 @@ fn 
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
     nprobe: usize,
     filter: Option<&dyn RowIdFilter>,
     reuse_mode: IvfPqBatchTableReuseMode,
+    reuse_max_bytes: usize,
     mut observe_ephemeral_precomputed_lists: impl FnMut(usize),
+    #[cfg(test)] distance_table_builds: 
Option<&std::sync::atomic::AtomicUsize>,
 ) -> io::Result<(Vec<i64>, Vec<f32>)> {
     reader.ensure_loaded()?;
     let d = reader.d;
@@ -1856,10 +1962,17 @@ fn 
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
     }
     unique_lists.sort_unstable_by_key(|&list_id| reader.list_offsets[list_id]);
 
+    let reuse_required_bytes = nq
+        .checked_mul(m)
+        .and_then(|values| values.checked_mul(ksub))
+        .and_then(|values| values.checked_mul(std::mem::size_of::<f32>()));
+    let reused_query_tables_fit_budget =
+        reuse_required_bytes.is_some_and(|bytes| bytes <= reuse_max_bytes);
     let use_precomputed = reuse_mode != IvfPqBatchTableReuseMode::Off
         && metric == MetricType::L2
         && by_residual
-        && !reader.precomputed_table.is_empty();
+        && !reader.precomputed_table.is_empty()
+        && reused_query_tables_fit_budget;
     let allow_ephemeral_precomputed = reader.pq.nbits == 8
         && metric == MetricType::L2
         && by_residual
@@ -1883,6 +1996,36 @@ fn 
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
     } else {
         Vec::new()
     };
+    // Non-residual tables depend only on the query and PQ codebook, so every
+    // probed list for that query can share one table.
+    let reuse_non_residual_tables = reader.pq.nbits == 8
+        && !by_residual
+        && nprobe > 1
+        && match reuse_mode {
+            IvfPqBatchTableReuseMode::Off => false,
+            IvfPqBatchTableReuseMode::On => true,
+            IvfPqBatchTableReuseMode::Auto => {
+                let (active_query_count, probe_count) = all_probe_indices
+                    .iter()
+                    .map(|probes| {
+                        probes
+                            .iter()
+                            .filter(|&&list_id| reader.list_counts[list_id] > 
0)
+                            .count()
+                    })
+                    .fold((0usize, 0usize), |(active, total), probes| {
+                        (active + usize::from(probes > 0), total + probes)
+                    });
+                nq >= MIN_EPHEMERAL_PRECOMPUTE_QUERIES
+                    && should_use_ephemeral_precomputation(0, 
active_query_count, probe_count)
+            }
+        }
+        && reused_query_tables_fit_budget;
+    let shared_sim_tables = if reuse_non_residual_tables {
+        (0..nq).map(|_| OnceLock::new()).collect::<Vec<_>>()
+    } else {
+        Vec::new()
+    };
     let mut stable_pq_norms = None;
 
     let mut heaps = (0..nq).map(|_| TopKHeap::new(k)).collect::<Vec<_>>();
@@ -1897,17 +2040,19 @@ fn 
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
                         .position(|&list_id| list_id == first_list)
                         .map(|probe_rank| {
                             let query = &processed[query_index * 
d..(query_index + 1) * d];
-                            let sim_table = reader_sim_table(
-                                reader,
-                                first_list,
-                                query,
-                                if use_precomputed {
-                                    &all_ip_tables[query_index]
-                                } else {
-                                    &[]
-                                },
-                                use_precomputed,
-                            );
+                            let sim_table = 
(!reuse_non_residual_tables).then(|| {
+                                reader_sim_table(
+                                    reader,
+                                    first_list,
+                                    query,
+                                    if use_precomputed {
+                                        &all_ip_tables[query_index]
+                                    } else {
+                                        &[]
+                                    },
+                                    use_precomputed,
+                                )
+                            });
                             let dis0 = if use_precomputed {
                                 all_coarse_dists[query_index][probe_rank]
                             } else {
@@ -1922,9 +2067,27 @@ fn 
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
             // The loop is sequential across queries. Reuse one chunk-sized
             // distance buffer instead of retaining one per query.
             let mut distances = Vec::new();
-            reader.for_each_streamed_list_chunk(first_list, |ids, codes| {
+            reader.for_each_streamed_list_chunk(first_list, |pq, ids, codes| {
                 let positions = matching_rows(ids, filter);
+                if positions.as_ref().is_some_and(MatchingRows::is_empty) {
+                    return;
+                }
                 for (query_index, dis0, sim_table) in &query_tables {
+                    let sim_table = sim_table.as_deref().unwrap_or_else(|| {
+                        shared_sim_tables[*query_index].get_or_init(|| {
+                            let mut table = vec![0.0f32; m * ksub];
+                            pq.compute_distance_table(
+                                &processed[*query_index * d..(*query_index + 
1) * d],
+                                metric,
+                                &mut table,
+                            );
+                            #[cfg(test)]
+                            if let Some(builds) = distance_table_builds {
+                                builds.fetch_add(1, 
std::sync::atomic::Ordering::Relaxed);
+                            }
+                            table
+                        })
+                    });
                     scan_reader_codes(
                         sim_table,
                         codes,
@@ -1985,6 +2148,7 @@ fn 
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
                 query_scratch_count,
                 m,
                 ksub,
+                reuse_max_bytes,
             )
             && (reuse_mode == IvfPqBatchTableReuseMode::On
                 || should_use_ephemeral_precomputation(
@@ -2033,6 +2197,9 @@ fn 
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
                         &[]
                     },
                     use_precomputed,
+                    shared_sim_table: reuse_non_residual_tables.then(|| 
&shared_sim_tables[qi]),
+                    #[cfg(test)]
+                    distance_table_builds,
                     d,
                     m,
                     ksub,
@@ -2122,6 +2289,28 @@ fn 
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
         }
     }
 
+    if !by_residual && 
std::env::var_os("PAIMON_VINDEX_LOG_IVFPQ_BATCH_REUSE").is_some() {
+        use std::io::Write;
+
+        let tables_built = shared_sim_tables
+            .iter()
+            .filter(|table| table.get().is_some())
+            .count();
+        let _ = writeln!(
+            std::io::stderr().lock(),
+            "[paimon-vindex] ivfpq_batch_table_reuse 
strategy=non_residual_query_table \
+             mode={reuse_mode:?} enabled={reuse_non_residual_tables} used={} 
metric={} \
+             pq_bits={} nq={nq} nprobe={nprobe} unique_lists={} filtered={} 
required_bytes={:?} \
+             budget_bytes={reuse_max_bytes} tables_built={tables_built}",
+            tables_built > 0,
+            metric.as_str(),
+            reader.pq.nbits,
+            unique_lists.len(),
+            filter.is_some(),
+            reuse_required_bytes,
+        );
+    }
+
     Ok((result_ids, result_dists))
 }
 
@@ -2153,9 +2342,31 @@ pub fn 
search_batch_reader_roaring_filter_with_reuse_mode<R: SeekRead>(
     nprobe: usize,
     roaring_filter_bytes: &[u8],
     reuse_mode: IvfPqBatchTableReuseMode,
+) -> io::Result<(Vec<i64>, Vec<f32>)> {
+    search_batch_reader_roaring_filter_with_reuse_mode_and_budget(
+        reader,
+        queries,
+        nq,
+        k,
+        nprobe,
+        roaring_filter_bytes,
+        reuse_mode,
+        DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
+    )
+}
+
+pub fn search_batch_reader_roaring_filter_with_reuse_mode_and_budget<R: 
SeekRead>(
+    reader: &mut IVFPQIndexReader<R>,
+    queries: &[f32],
+    nq: usize,
+    k: usize,
+    nprobe: usize,
+    roaring_filter_bytes: &[u8],
+    reuse_mode: IvfPqBatchTableReuseMode,
+    reuse_max_bytes: usize,
 ) -> io::Result<(Vec<i64>, Vec<f32>)> {
     let filter = decode_roaring_filter(roaring_filter_bytes)?;
-    search_batch_reader_filter_with_reuse_mode(
+    search_batch_reader_filter_with_reuse_mode_and_budget(
         reader,
         queries,
         nq,
@@ -2163,6 +2374,7 @@ pub fn 
search_batch_reader_roaring_filter_with_reuse_mode<R: SeekRead>(
         nprobe,
         Some(&filter),
         reuse_mode,
+        reuse_max_bytes,
     )
 }
 
@@ -2413,9 +2625,11 @@ mod tests {
             nprobe,
             filter,
             reuse_mode,
+            DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
             |count| {
                 precomputed_lists.fetch_add(count, Ordering::Relaxed);
             },
+            None,
         )
         .unwrap();
 
@@ -3081,27 +3295,35 @@ mod tests {
 
     #[test]
     fn ephemeral_precomputation_respects_batch_memory_budget() {
-        let max_values =
-            crate::index_io_util::MAX_IVF_BATCH_READ_BYTES / 
std::mem::size_of::<f64>();
+        let max_values = DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES / 
std::mem::size_of::<f64>();
         let max_list_values = max_values / 3;
         assert!(ephemeral_precomputed_table_fits_budget(
             1,
             1,
             1,
-            max_list_values
+            max_list_values,
+            DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
         ));
         assert!(!ephemeral_precomputed_table_fits_budget(
             1,
             1,
             1,
-            max_list_values + 1
+            max_list_values + 1,
+            DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
+        ));
+        assert!(!ephemeral_precomputed_table_fits_budget(
+            0,
+            1,
+            1,
+            1,
+            DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
         ));
-        assert!(!ephemeral_precomputed_table_fits_budget(0, 1, 1, 1));
         assert!(!ephemeral_precomputed_table_fits_budget(
             usize::MAX,
             usize::MAX,
             usize::MAX,
-            usize::MAX
+            usize::MAX,
+            DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
         ));
     }
 
@@ -3571,6 +3793,154 @@ mod tests {
         }
     }
 
+    #[test]
+    fn 
inner_product_batch_table_reuse_modes_preserve_results_and_control_table_builds()
 {
+        use crate::io::{write_index, IVFPQIndexReader, PosWriter};
+
+        let d = 16;
+        let nlist = 4;
+        let m = 4;
+        let n = 600;
+        let nq = 8;
+        let k = 5;
+        let nprobe = nlist;
+        let data = generate_clustered_data(n, d, nlist, 43);
+        let ids = (0..n as i64).collect::<Vec<_>>();
+        let mut index = IVFPQIndex::new(d, nlist, m, MetricType::InnerProduct, 
false);
+        index.train(&data, n);
+        index.add(&data, &ids, n);
+
+        let mut bytes = Vec::new();
+        write_index(&index, &mut PosWriter::new(&mut bytes)).unwrap();
+        let distance_table_builds = AtomicUsize::new(0);
+        let mut reader = 
IVFPQIndexReader::open(Cursor::new(bytes.clone())).unwrap();
+
+        let (batch_ids, batch_distances) = 
search_batch_reader_filter_with_reuse_mode_and_observer(
+            &mut reader,
+            &data[..nq * d],
+            nq,
+            k,
+            nprobe,
+            None,
+            IvfPqBatchTableReuseMode::On,
+            DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
+            |_| {},
+            Some(&distance_table_builds),
+        )
+        .unwrap();
+
+        assert_eq!(distance_table_builds.load(Ordering::Relaxed), nq);
+
+        let distance_table_builds = AtomicUsize::new(0);
+        let mut reader = 
IVFPQIndexReader::open(Cursor::new(bytes.clone())).unwrap();
+        search_batch_reader_filter_with_reuse_mode_and_observer(
+            &mut reader,
+            &data[..nq * d],
+            nq,
+            k,
+            nprobe,
+            None,
+            IvfPqBatchTableReuseMode::On,
+            nq * m * 256 * std::mem::size_of::<f32>() - 1,
+            |_| {},
+            Some(&distance_table_builds),
+        )
+        .unwrap();
+        assert_eq!(
+            distance_table_builds.load(Ordering::Relaxed),
+            nq * nprobe,
+            "the direct path should be used when reused tables exceed the 
configured budget"
+        );
+
+        let mut direct_reader = 
IVFPQIndexReader::open(Cursor::new(bytes.clone())).unwrap();
+        let (direct_ids, direct_distances) = 
search_batch_reader_with_reuse_mode(
+            &mut direct_reader,
+            &data[..nq * d],
+            nq,
+            k,
+            nprobe,
+            IvfPqBatchTableReuseMode::Off,
+        )
+        .unwrap();
+        assert_eq!(batch_ids, direct_ids);
+        assert_eq!(batch_distances, direct_distances);
+
+        for query_index in 0..nq {
+            let mut scalar_reader = 
IVFPQIndexReader::open(Cursor::new(bytes.clone())).unwrap();
+            let query = &data[query_index * d..(query_index + 1) * d];
+            let (scalar_ids, scalar_distances) =
+                search_with_reader(&mut scalar_reader, query, k, 
nprobe).unwrap();
+            let result = query_index * k..(query_index + 1) * k;
+            assert_eq!(&batch_ids[result.clone()], scalar_ids.as_slice());
+            assert_eq!(&batch_distances[result], scalar_distances.as_slice());
+        }
+
+        let large_nq = MIN_EPHEMERAL_PRECOMPUTE_QUERIES;
+        let distance_table_builds = AtomicUsize::new(0);
+        let mut reader = 
IVFPQIndexReader::open(Cursor::new(bytes.clone())).unwrap();
+        search_batch_reader_filter_with_reuse_mode_and_observer(
+            &mut reader,
+            &data[..large_nq * d],
+            large_nq,
+            k,
+            nprobe,
+            None,
+            IvfPqBatchTableReuseMode::Auto,
+            DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
+            |_| {},
+            Some(&distance_table_builds),
+        )
+        .unwrap();
+        assert_eq!(
+            distance_table_builds.load(Ordering::Relaxed),
+            large_nq,
+            "Auto should reuse tables when the batch and probe work amortize 
them"
+        );
+
+        let distance_table_builds = AtomicUsize::new(0);
+        let mut reader = 
IVFPQIndexReader::open(Cursor::new(bytes.clone())).unwrap();
+        search_batch_reader_filter_with_reuse_mode_and_observer(
+            &mut reader,
+            &data[..nq * d],
+            nq,
+            k,
+            nprobe,
+            None,
+            IvfPqBatchTableReuseMode::Auto,
+            DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
+            |_| {},
+            Some(&distance_table_builds),
+        )
+        .unwrap();
+        assert_eq!(
+            distance_table_builds.load(Ordering::Relaxed),
+            nq * nprobe,
+            "Auto should keep the direct path for small batches"
+        );
+
+        let distance_table_builds = AtomicUsize::new(0);
+        let empty_filter = HashSet::new();
+        let mut reader = IVFPQIndexReader::open(Cursor::new(bytes)).unwrap();
+        search_batch_reader_filter_with_reuse_mode_and_observer(
+            &mut reader,
+            &data[..nq * d],
+            nq,
+            k,
+            nprobe,
+            Some(&empty_filter),
+            IvfPqBatchTableReuseMode::On,
+            DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
+            |_| {},
+            Some(&distance_table_builds),
+        )
+        .unwrap();
+        assert_eq!(
+            distance_table_builds.load(Ordering::Relaxed),
+            0,
+            "empty filters should not build query distance tables"
+        );
+    }
+
     #[test]
     fn test_batch_reader_search_with_roaring_filter_bytes() {
         use crate::io::{write_index, IVFPQIndexReader, PosWriter};
@@ -3985,7 +4355,7 @@ mod tests {
     }
 
     #[test]
-    fn batch_table_reuse_off_ignores_resident_precomputed_tables() {
+    fn resident_precomputed_tables_respect_batch_reuse_mode_and_budget() {
         use crate::io::{write_index, IVFPQIndexReader, PosWriter};
 
         let d = 16;
@@ -4029,6 +4399,26 @@ mod tests {
         .unwrap();
 
         assert_eq!(actual, expected, "Off must ignore resident reuse tables");
+
+        let distance_table_builds = AtomicUsize::new(0);
+        let actual = search_batch_reader_filter_with_reuse_mode_and_observer(
+            &mut optimized_reader,
+            queries,
+            nq,
+            k,
+            nlist,
+            None,
+            IvfPqBatchTableReuseMode::On,
+            1,
+            |_| {},
+            Some(&distance_table_builds),
+        )
+        .unwrap();
+        assert_eq!(
+            actual, expected,
+            "resident reuse tables must be ignored when query tables exceed 
the budget"
+        );
+        assert_eq!(distance_table_builds.load(Ordering::Relaxed), nq * nlist);
     }
 
     #[test]
diff --git a/cpp/test_vindex.cpp b/cpp/test_vindex.cpp
index d5a6a61..248c809 100644
--- a/cpp/test_vindex.cpp
+++ b/cpp/test_vindex.cpp
@@ -227,6 +227,8 @@ static void run_roundtrip(
     auto batch_params = expected_index_type == PAIMON_VINDEX_INDEX_TYPE_DISKANN
         ? paimon::vindex::SearchParams::diskann(1, 100)
         : paimon::vindex::SearchParams{1, 4};
+    batch_params.ivfpq_batch_table_reuse = 
PAIMON_VINDEX_IVFPQ_BATCH_TABLE_REUSE_OFF;
+    batch_params.ivfpq_batch_table_reuse_max_bytes = 1;
     auto batch = reader.search_batch(queries.data(), 2, batch_params);
     ASSERT_EQ(batch.ids.size(), 2);
     assert_id_in_cluster(batch.ids[0], 0);
diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs
index 7a5368b..807c322 100644
--- a/ffi/src/lib.rs
+++ b/ffi/src/lib.rs
@@ -22,6 +22,7 @@ use paimon_vindex_core::index::{
     IvfPqBatchTableReuseMode, SearchWidth, VectorIndexConfig, 
VectorIndexMetadata,
     VectorIndexReadPlan, VectorIndexReader, VectorIndexReaderOptions, 
VectorIndexTrainer,
     VectorIndexTraining, VectorIndexWriter, VectorSearchParams,
+    DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
 };
 use paimon_vindex_core::io::{ReadRequest, SeekRead, SeekReadCapabilities, 
SeekWrite};
 use std::cell::RefCell;
@@ -46,6 +47,11 @@ pub const PAIMON_VINDEX_SEARCH_WIDTH_AUTO: u32 = 0;
 pub const PAIMON_VINDEX_SEARCH_WIDTH_IVF_NPROBE: u32 = 1;
 pub const PAIMON_VINDEX_SEARCH_WIDTH_DISKANN_L_SEARCH: u32 = 2;
 
+pub const PAIMON_VINDEX_IVFPQ_BATCH_TABLE_REUSE_OFF: u32 = 0;
+pub const PAIMON_VINDEX_IVFPQ_BATCH_TABLE_REUSE_ON: u32 = 1;
+pub const PAIMON_VINDEX_IVFPQ_BATCH_TABLE_REUSE_AUTO: u32 = 2;
+pub const PAIMON_VINDEX_DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES: usize = 512 
* 1024 * 1024;
+
 thread_local! {
     static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
 }
@@ -266,6 +272,16 @@ pub struct PaimonVindexSearchParams {
     pub width: usize,
 }
 
+#[repr(C)]
+#[derive(Clone, Copy)]
+pub struct PaimonVindexSearchParamsV2 {
+    pub top_k: usize,
+    pub search_width: u32,
+    pub width: usize,
+    pub ivfpq_batch_table_reuse: u32,
+    pub ivfpq_batch_table_reuse_max_bytes: usize,
+}
+
 #[repr(C)]
 #[derive(Clone, Copy)]
 pub struct PaimonVindexReaderOptions {
@@ -526,9 +542,31 @@ fn search_params_from_ffi(params: 
PaimonVindexSearchParams) -> Result<VectorSear
         search_width,
         width: params.width,
         ivfpq_batch_table_reuse: IvfPqBatchTableReuseMode::Auto,
+        ivfpq_batch_table_reuse_max_bytes: 
DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
     })
 }
 
+fn search_params_v2_from_ffi(
+    params: PaimonVindexSearchParamsV2,
+) -> Result<VectorSearchParams, String> {
+    let mut result = search_params_from_ffi(PaimonVindexSearchParams {
+        top_k: params.top_k,
+        search_width: params.search_width,
+        width: params.width,
+    })?;
+    result.ivfpq_batch_table_reuse = match params.ivfpq_batch_table_reuse {
+        PAIMON_VINDEX_IVFPQ_BATCH_TABLE_REUSE_OFF => 
IvfPqBatchTableReuseMode::Off,
+        PAIMON_VINDEX_IVFPQ_BATCH_TABLE_REUSE_ON => 
IvfPqBatchTableReuseMode::On,
+        PAIMON_VINDEX_IVFPQ_BATCH_TABLE_REUSE_AUTO => 
IvfPqBatchTableReuseMode::Auto,
+        value => return Err(format!("invalid IVF-PQ batch table reuse mode: 
{value}")),
+    };
+    if params.ivfpq_batch_table_reuse_max_bytes == 0 {
+        return Err("IVF-PQ batch table reuse max bytes must be 
positive".to_string());
+    }
+    result.ivfpq_batch_table_reuse_max_bytes = 
params.ivfpq_batch_table_reuse_max_bytes;
+    Ok(result)
+}
+
 // ======================== Trainer / Writer ========================
 
 #[no_mangle]
@@ -940,6 +978,37 @@ pub unsafe extern "C" fn paimon_vindex_reader_search_batch(
     })
 }
 
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vindex_reader_search_batch_v2(
+    handle: *mut PaimonVindexReaderHandle,
+    queries: *const f32,
+    query_count: usize,
+    params: PaimonVindexSearchParamsV2,
+    out_ids: *mut i64,
+    out_distances: *mut f32,
+    result_len: usize,
+) -> c_int {
+    ffi_status(|| {
+        let handle = unsafe { reader_mut(handle) }?;
+        let query_len = checked_len(query_count, handle.inner.dimension(), 
"queries")?;
+        let queries = unsafe { const_slice(queries, query_len, "queries") }?;
+        let params = search_params_v2_from_ffi(params)?;
+        let expected_len = checked_len(query_count, params.top_k, "batch 
result")?;
+        let (ids, distances) = handle
+            .inner
+            .search_batch(queries, query_count, params)
+            .map_err(|e| format!("search_batch: {}", e))?;
+        copy_search_result(
+            &ids,
+            &distances,
+            out_ids,
+            out_distances,
+            result_len,
+            expected_len,
+        )
+    })
+}
+
 #[no_mangle]
 pub unsafe extern "C" fn paimon_vindex_reader_search_batch_with_roaring_filter(
     handle: *mut PaimonVindexReaderHandle,
@@ -974,6 +1043,40 @@ pub unsafe extern "C" fn 
paimon_vindex_reader_search_batch_with_roaring_filter(
     })
 }
 
+#[no_mangle]
+pub unsafe extern "C" fn 
paimon_vindex_reader_search_batch_with_roaring_filter_v2(
+    handle: *mut PaimonVindexReaderHandle,
+    queries: *const f32,
+    query_count: usize,
+    params: PaimonVindexSearchParamsV2,
+    roaring_filter: *const u8,
+    roaring_filter_len: usize,
+    out_ids: *mut i64,
+    out_distances: *mut f32,
+    result_len: usize,
+) -> c_int {
+    ffi_status(|| {
+        let handle = unsafe { reader_mut(handle) }?;
+        let query_len = checked_len(query_count, handle.inner.dimension(), 
"queries")?;
+        let queries = unsafe { const_slice(queries, query_len, "queries") }?;
+        let filter = unsafe { const_slice(roaring_filter, roaring_filter_len, 
"roaring_filter") }?;
+        let params = search_params_v2_from_ffi(params)?;
+        let expected_len = checked_len(query_count, params.top_k, "batch 
result")?;
+        let (ids, distances) = handle
+            .inner
+            .search_batch_with_roaring_filter(queries, query_count, params, 
filter)
+            .map_err(|e| format!("search_batch_with_roaring_filter: {}", e))?;
+        copy_search_result(
+            &ids,
+            &distances,
+            out_ids,
+            out_distances,
+            result_len,
+            expected_len,
+        )
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1085,6 +1188,10 @@ mod tests {
 
     #[test]
     fn ffi_search_parameters_preserve_diskann_width() {
+        assert_eq!(
+            PAIMON_VINDEX_DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
+            DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES
+        );
         let params = search_params_from_ffi(PaimonVindexSearchParams {
             top_k: 10,
             search_width: PAIMON_VINDEX_SEARCH_WIDTH_DISKANN_L_SEARCH,
@@ -1098,5 +1205,35 @@ mod tests {
             params.ivfpq_batch_table_reuse,
             IvfPqBatchTableReuseMode::Auto
         );
+        assert_eq!(
+            params.ivfpq_batch_table_reuse_max_bytes,
+            DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES
+        );
+    }
+
+    #[test]
+    fn ffi_v2_search_parameters_preserve_batch_reuse_options() {
+        let params = search_params_v2_from_ffi(PaimonVindexSearchParamsV2 {
+            top_k: 10,
+            search_width: PAIMON_VINDEX_SEARCH_WIDTH_IVF_NPROBE,
+            width: 16,
+            ivfpq_batch_table_reuse: PAIMON_VINDEX_IVFPQ_BATCH_TABLE_REUSE_ON,
+            ivfpq_batch_table_reuse_max_bytes: 32 * 1024 * 1024,
+        })
+        .unwrap();
+
+        assert_eq!(params.ivfpq_batch_table_reuse, 
IvfPqBatchTableReuseMode::On);
+        assert_eq!(params.ivfpq_batch_table_reuse_max_bytes, 32 * 1024 * 1024);
+
+        for (mode, max_bytes) in [(3, 1), 
(PAIMON_VINDEX_IVFPQ_BATCH_TABLE_REUSE_AUTO, 0)] {
+            assert!(search_params_v2_from_ffi(PaimonVindexSearchParamsV2 {
+                top_k: 10,
+                search_width: PAIMON_VINDEX_SEARCH_WIDTH_IVF_NPROBE,
+                width: 16,
+                ivfpq_batch_table_reuse: mode,
+                ivfpq_batch_table_reuse_max_bytes: max_bytes,
+            })
+            .is_err());
+        }
     }
 }
diff --git a/include/paimon_vindex.hpp b/include/paimon_vindex.hpp
index 6fa01f1..0b3e8e6 100644
--- a/include/paimon_vindex.hpp
+++ b/include/paimon_vindex.hpp
@@ -228,6 +228,9 @@ struct SearchParams {
     size_t top_k = 0;
     uint32_t search_width = PAIMON_VINDEX_SEARCH_WIDTH_AUTO;
     size_t width = 0;
+    uint32_t ivfpq_batch_table_reuse = 
PAIMON_VINDEX_IVFPQ_BATCH_TABLE_REUSE_AUTO;
+    size_t ivfpq_batch_table_reuse_max_bytes =
+        PAIMON_VINDEX_DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES;
 
     SearchParams(size_t top_k, size_t nprobe)
         : top_k(top_k),
@@ -256,6 +259,16 @@ struct SearchParams {
         return params;
     }
 
+    PaimonVindexSearchParamsV2 to_ffi_v2() const {
+        PaimonVindexSearchParamsV2 params;
+        params.top_k = top_k;
+        params.search_width = search_width;
+        params.width = width;
+        params.ivfpq_batch_table_reuse = ivfpq_batch_table_reuse;
+        params.ivfpq_batch_table_reuse_max_bytes = 
ivfpq_batch_table_reuse_max_bytes;
+        return params;
+    }
+
 private:
     SearchParams() = default;
 };
@@ -603,11 +616,11 @@ public:
         SearchResult result;
         result.ids.resize(result_len);
         result.distances.resize(result_len);
-        check(paimon_vindex_reader_search_batch(
+        check(paimon_vindex_reader_search_batch_v2(
             require_open(),
             queries,
             query_count,
-            params.to_ffi(),
+            params.to_ffi_v2(),
             result.ids.data(),
             result.distances.data(),
             result_len));
@@ -625,11 +638,11 @@ public:
         SearchResult result;
         result.ids.resize(result_len);
         result.distances.resize(result_len);
-        check(paimon_vindex_reader_search_batch_with_roaring_filter(
+        check(paimon_vindex_reader_search_batch_with_roaring_filter_v2(
             require_open(),
             queries,
             query_count,
-            params.to_ffi(),
+            params.to_ffi_v2(),
             filter,
             filter_len,
             result.ids.data(),
diff --git 
a/java/src/main/java/org/apache/paimon/index/vector/VectorSearchParams.java 
b/java/src/main/java/org/apache/paimon/index/vector/VectorSearchParams.java
index ef5fdde..a491af5 100644
--- a/java/src/main/java/org/apache/paimon/index/vector/VectorSearchParams.java
+++ b/java/src/main/java/org/apache/paimon/index/vector/VectorSearchParams.java
@@ -19,6 +19,8 @@ package org.apache.paimon.index.vector;
 
 public final class VectorSearchParams {
 
+    public static final long DEFAULT_IVF_PQ_BATCH_TABLE_REUSE_MAX_BYTES = 512L 
* 1024 * 1024;
+
     static final int SEARCH_WIDTH_AUTO = 0;
     static final int SEARCH_WIDTH_IVF_NPROBE = 1;
     static final int SEARCH_WIDTH_DISKANN_L_SEARCH = 2;
@@ -27,26 +29,37 @@ public final class VectorSearchParams {
     private final int searchWidth;
     private final int width;
     private final int ivfPqBatchTableReuseMode;
+    private final long ivfPqBatchTableReuseMaxBytes;
 
     public VectorSearchParams(int topK, int nprobe) {
         this(
                 topK,
                 SEARCH_WIDTH_IVF_NPROBE,
                 nprobe,
-                IvfPqBatchTableReuseMode.AUTO.code());
+                IvfPqBatchTableReuseMode.AUTO.code(),
+                DEFAULT_IVF_PQ_BATCH_TABLE_REUSE_MAX_BYTES);
     }
 
     private VectorSearchParams(
-            int topK, int searchWidth, int width, int 
ivfPqBatchTableReuseMode) {
+            int topK,
+            int searchWidth,
+            int width,
+            int ivfPqBatchTableReuseMode,
+            long ivfPqBatchTableReuseMaxBytes) {
         this.topK = topK;
         this.searchWidth = searchWidth;
         this.width = width;
         this.ivfPqBatchTableReuseMode = ivfPqBatchTableReuseMode;
+        this.ivfPqBatchTableReuseMaxBytes = ivfPqBatchTableReuseMaxBytes;
     }
 
     public static VectorSearchParams automatic(int topK) {
         return new VectorSearchParams(
-                topK, SEARCH_WIDTH_AUTO, 0, 
IvfPqBatchTableReuseMode.AUTO.code());
+                topK,
+                SEARCH_WIDTH_AUTO,
+                0,
+                IvfPqBatchTableReuseMode.AUTO.code(),
+                DEFAULT_IVF_PQ_BATCH_TABLE_REUSE_MAX_BYTES);
     }
 
     public static VectorSearchParams ivf(int topK, int nprobe) {
@@ -54,7 +67,8 @@ public final class VectorSearchParams {
                 topK,
                 SEARCH_WIDTH_IVF_NPROBE,
                 nprobe,
-                IvfPqBatchTableReuseMode.AUTO.code());
+                IvfPqBatchTableReuseMode.AUTO.code(),
+                DEFAULT_IVF_PQ_BATCH_TABLE_REUSE_MAX_BYTES);
     }
 
     public static VectorSearchParams diskAnn(int topK, int lSearch) {
@@ -62,7 +76,8 @@ public final class VectorSearchParams {
                 topK,
                 SEARCH_WIDTH_DISKANN_L_SEARCH,
                 lSearch,
-                IvfPqBatchTableReuseMode.AUTO.code());
+                IvfPqBatchTableReuseMode.AUTO.code(),
+                DEFAULT_IVF_PQ_BATCH_TABLE_REUSE_MAX_BYTES);
     }
 
     public int topK() {
@@ -85,19 +100,37 @@ public final class VectorSearchParams {
         return ivfPqBatchTableReuseMode;
     }
 
+    public long ivfPqBatchTableReuseMaxBytes() {
+        return ivfPqBatchTableReuseMaxBytes;
+    }
+
     public VectorSearchParams 
withIvfPqBatchTableReuse(IvfPqBatchTableReuseMode mode) {
         if (mode == null) {
             throw new IllegalArgumentException("IVF-PQ batch table reuse mode 
is null");
         }
-        return new VectorSearchParams(topK, searchWidth, width, mode.code());
+        return new VectorSearchParams(
+                topK, searchWidth, width, mode.code(), 
ivfPqBatchTableReuseMaxBytes);
     }
 
     public VectorSearchParams withIvfPqBatchTableReuse(String mode) {
         return 
withIvfPqBatchTableReuse(IvfPqBatchTableReuseMode.fromString(mode));
     }
 
+    public VectorSearchParams withIvfPqBatchTableReuseMaxBytes(long maxBytes) {
+        if (maxBytes <= 0) {
+            throw new IllegalArgumentException(
+                    "IVF-PQ batch table reuse max bytes must be positive");
+        }
+        return new VectorSearchParams(
+                topK, searchWidth, width, ivfPqBatchTableReuseMode, maxBytes);
+    }
+
     public VectorSearchParams withLSearch(int lSearch) {
         return new VectorSearchParams(
-                topK, SEARCH_WIDTH_DISKANN_L_SEARCH, lSearch, 
ivfPqBatchTableReuseMode);
+                topK,
+                SEARCH_WIDTH_DISKANN_L_SEARCH,
+                lSearch,
+                ivfPqBatchTableReuseMode,
+                ivfPqBatchTableReuseMaxBytes);
     }
 }
diff --git 
a/java/src/test/java/org/apache/paimon/index/vector/VectorIndexJavaApiTest.java 
b/java/src/test/java/org/apache/paimon/index/vector/VectorIndexJavaApiTest.java
index 507d79c..fed0feb 100644
--- 
a/java/src/test/java/org/apache/paimon/index/vector/VectorIndexJavaApiTest.java
+++ 
b/java/src/test/java/org/apache/paimon/index/vector/VectorIndexJavaApiTest.java
@@ -74,6 +74,19 @@ public class VectorIndexJavaApiTest {
     private static void testIvfPqBatchTableReuseMode() {
         VectorSearchParams defaults = new VectorSearchParams(10, 4);
         assertEquals(IvfPqBatchTableReuseMode.AUTO, 
defaults.ivfPqBatchTableReuse());
+        assertEquals(512L * 1024 * 1024, 
defaults.ivfPqBatchTableReuseMaxBytes());
+
+        VectorSearchParams customBudget =
+                defaults.withIvfPqBatchTableReuseMaxBytes(128L * 1024 * 1024);
+        assertEquals(128L * 1024 * 1024, 
customBudget.ivfPqBatchTableReuseMaxBytes());
+        assertThrows(
+                IllegalArgumentException.class,
+                new ThrowingRunnable() {
+                    @Override
+                    public void run() {
+                        defaults.withIvfPqBatchTableReuseMaxBytes(0);
+                    }
+                });
 
         VectorSearchParams enabled =
                 defaults.withIvfPqBatchTableReuse(IvfPqBatchTableReuseMode.ON);
diff --git a/jni/src/lib.rs b/jni/src/lib.rs
index a189483..9fb9afa 100644
--- a/jni/src/lib.rs
+++ b/jni/src/lib.rs
@@ -415,6 +415,10 @@ fn search_params(env: &mut JNIEnv, params: JObject) -> 
Result<VectorSearchParams
     let width = call_int_method(env, &params, "width")?;
     let ivfpq_batch_table_reuse =
         ivfpq_batch_table_reuse_mode(call_int_method(env, &params, 
"ivfPqBatchTableReuseMode")?)?;
+    let ivfpq_batch_table_reuse_max_bytes = positive_jlong_to_usize(
+        call_long_method(env, &params, "ivfPqBatchTableReuseMaxBytes")?,
+        "IVF-PQ batch table reuse max bytes",
+    )?;
     if top_k < 0 || width < 0 {
         return Err(format!(
             "invalid search parameters: topK={}, searchWidth={}, width={}",
@@ -435,6 +439,7 @@ fn search_params(env: &mut JNIEnv, params: JObject) -> 
Result<VectorSearchParams
         search_width,
         width: width as usize,
         ivfpq_batch_table_reuse,
+        ivfpq_batch_table_reuse_max_bytes,
     })
 }
 
@@ -453,6 +458,19 @@ fn call_int_method(env: &mut JNIEnv, object: &JObject, 
name: &str) -> Result<jin
         .map_err(|e| format!("VectorSearchParams.{}(): {}", name, e))
 }
 
+fn call_long_method(env: &mut JNIEnv, object: &JObject, name: &str) -> 
Result<jlong, String> {
+    env.call_method(object, name, "()J", &[])
+        .and_then(|value| value.j())
+        .map_err(|e| format!("VectorSearchParams.{}(): {}", name, e))
+}
+
+fn positive_jlong_to_usize(value: jlong, name: &str) -> Result<usize, String> {
+    if value <= 0 {
+        return Err(format!("{name} must be positive"));
+    }
+    usize::try_from(value).map_err(|_| format!("{name} exceeds usize"))
+}
+
 // --- Unified Trainer / Writer API ---
 
 #[no_mangle]
@@ -1056,4 +1074,14 @@ mod tests {
         );
         assert!(ivfpq_batch_table_reuse_mode(3).is_err());
     }
+
+    #[test]
+    fn ivfpq_batch_table_reuse_budget_must_be_positive() {
+        assert_eq!(
+            positive_jlong_to_usize(64 * 1024 * 1024, "reuse max 
bytes").unwrap(),
+            64 * 1024 * 1024
+        );
+        assert!(positive_jlong_to_usize(0, "reuse max bytes").is_err());
+        assert!(positive_jlong_to_usize(-1, "reuse max bytes").is_err());
+    }
 }
diff --git a/python/paimon_vindex/__init__.py b/python/paimon_vindex/__init__.py
index 99037b9..bba1ef0 100644
--- a/python/paimon_vindex/__init__.py
+++ b/python/paimon_vindex/__init__.py
@@ -29,6 +29,7 @@ from ._ffi import lib
 
 _SIZE_T_MAX = ctypes.c_size_t(-1).value
 _UINT64_MAX = ctypes.c_uint64(-1).value
+_DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES = 512 * 1024 * 1024
 
 
 def _size_t(value, name: str, *, allow_zero: bool) -> int:
@@ -121,6 +122,12 @@ class SearchWidth(IntEnum):
     DISKANN_L_SEARCH = 2
 
 
+class IvfPqBatchTableReuseMode(IntEnum):
+    OFF = 0
+    ON = 1
+    AUTO = 2
+
+
 @dataclass(frozen=True)
 class VectorIndexMetadata:
     index_type: str
@@ -154,12 +161,22 @@ class SearchParams:
     top_k: int
     search_width: SearchWidth = SearchWidth.AUTO
     width: int = 0
+    ivfpq_batch_table_reuse: IvfPqBatchTableReuseMode = (
+        IvfPqBatchTableReuseMode.AUTO
+    )
+    ivfpq_batch_table_reuse_max_bytes: int = (
+        _DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES
+    )
 
     def __post_init__(self):
         try:
             search_width = SearchWidth(self.search_width)
         except (TypeError, ValueError) as exc:
             raise ValueError("search_width is invalid") from exc
+        try:
+            reuse_mode = IvfPqBatchTableReuseMode(self.ivfpq_batch_table_reuse)
+        except (TypeError, ValueError) as exc:
+            raise ValueError("IVF-PQ batch table reuse mode is invalid") from 
exc
         top_k = _size_t(self.top_k, "top_k", allow_zero=False)
         if search_width == SearchWidth.AUTO:
             width = _size_t(self.width, "automatic search width", 
allow_zero=True)
@@ -172,20 +189,37 @@ class SearchParams:
                 else "l_search"
             )
             width = _size_t(self.width, name, allow_zero=False)
+        reuse_max_bytes = _size_t(
+            self.ivfpq_batch_table_reuse_max_bytes,
+            "IVF-PQ batch table reuse max bytes",
+            allow_zero=False,
+        )
         object.__setattr__(self, "top_k", top_k)
         object.__setattr__(self, "search_width", search_width)
         object.__setattr__(self, "width", width)
+        object.__setattr__(self, "ivfpq_batch_table_reuse", reuse_mode)
+        object.__setattr__(
+            self, "ivfpq_batch_table_reuse_max_bytes", reuse_max_bytes
+        )
 
     @classmethod
     def automatic(cls, top_k: int):
         return cls(top_k=top_k)
 
     @classmethod
-    def ivf(cls, top_k: int, nprobe: int):
+    def ivf(
+        cls,
+        top_k: int,
+        nprobe: int,
+        ivfpq_batch_table_reuse=IvfPqBatchTableReuseMode.AUTO,
+        
ivfpq_batch_table_reuse_max_bytes=_DEFAULT_IVFPQ_BATCH_TABLE_REUSE_MAX_BYTES,
+    ):
         return cls(
             top_k=top_k,
             search_width=SearchWidth.IVF_NPROBE,
             width=nprobe,
+            ivfpq_batch_table_reuse=ivfpq_batch_table_reuse,
+            
ivfpq_batch_table_reuse_max_bytes=ivfpq_batch_table_reuse_max_bytes,
         )
 
     @classmethod
@@ -203,6 +237,15 @@ class SearchParams:
             self.width,
         )
 
+    def to_ffi_v2(self):
+        return _ffi.PaimonVindexSearchParamsV2(
+            self.top_k,
+            int(self.search_width),
+            self.width,
+            int(self.ivfpq_batch_table_reuse),
+            self.ivfpq_batch_table_reuse_max_bytes,
+        )
+
 
 def _check_error(message="operation failed"):
     err = lib.paimon_vindex_last_error()
@@ -774,7 +817,7 @@ class VectorIndexReader:
                 f"queries length {queries.size} does not match nq * dimension "
                 f"{queries.shape[0] * self._metadata.dimension}"
             )
-        ffi_params = params.to_ffi()
+        ffi_params = params.to_ffi_v2()
         result_len = queries.shape[0] * params.top_k
         ids = np.empty((queries.shape[0], params.top_k), dtype=np.int64)
         distances = np.empty((queries.shape[0], params.top_k), 
dtype=np.float32)
@@ -782,7 +825,7 @@ class VectorIndexReader:
         with self._native_handle_lock:
             self._require_open()
             if filter_bytes is None:
-                rc = lib.paimon_vindex_reader_search_batch(
+                rc = lib.paimon_vindex_reader_search_batch_v2(
                     self._handle,
                     queries.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),
                     queries.shape[0],
@@ -793,7 +836,7 @@ class VectorIndexReader:
                 )
             else:
                 filter_buf, filter_len, _ = self._filter_args(filter_bytes)
-                rc = lib.paimon_vindex_reader_search_batch_with_roaring_filter(
+                rc = 
lib.paimon_vindex_reader_search_batch_with_roaring_filter_v2(
                     self._handle,
                     queries.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),
                     queries.shape[0],
@@ -832,6 +875,7 @@ class VectorIndexReader:
 
 
 __all__ = [
+    "IvfPqBatchTableReuseMode",
     "SearchParams",
     "VectorIndexMetadata",
     "VectorIndexReadPlan",
diff --git a/python/paimon_vindex/_ffi.py b/python/paimon_vindex/_ffi.py
index f86936f..02cd900 100644
--- a/python/paimon_vindex/_ffi.py
+++ b/python/paimon_vindex/_ffi.py
@@ -146,6 +146,16 @@ class PaimonVindexSearchParams(Structure):
     ]
 
 
+class PaimonVindexSearchParamsV2(Structure):
+    _fields_ = [
+        ("top_k", c_size_t),
+        ("search_width", c_uint32),
+        ("width", c_size_t),
+        ("ivfpq_batch_table_reuse", c_uint32),
+        ("ivfpq_batch_table_reuse_max_bytes", c_size_t),
+    ]
+
+
 class PaimonVindexReaderOptions(Structure):
     _fields_ = [
         ("memory_budget_bytes", c_size_t),
@@ -292,6 +302,17 @@ lib.paimon_vindex_reader_search_batch.argtypes = [
 ]
 lib.paimon_vindex_reader_search_batch.restype = c_int
 
+lib.paimon_vindex_reader_search_batch_v2.argtypes = [
+    c_void_p,
+    POINTER(c_float),
+    c_size_t,
+    PaimonVindexSearchParamsV2,
+    POINTER(c_int64),
+    POINTER(c_float),
+    c_size_t,
+]
+lib.paimon_vindex_reader_search_batch_v2.restype = c_int
+
 lib.paimon_vindex_reader_search_batch_with_roaring_filter.argtypes = [
     c_void_p,
     POINTER(c_float),
@@ -304,3 +325,16 @@ 
lib.paimon_vindex_reader_search_batch_with_roaring_filter.argtypes = [
     c_size_t,
 ]
 lib.paimon_vindex_reader_search_batch_with_roaring_filter.restype = c_int
+
+lib.paimon_vindex_reader_search_batch_with_roaring_filter_v2.argtypes = [
+    c_void_p,
+    POINTER(c_float),
+    c_size_t,
+    PaimonVindexSearchParamsV2,
+    POINTER(c_uint8),
+    c_size_t,
+    POINTER(c_int64),
+    POINTER(c_float),
+    c_size_t,
+]
+lib.paimon_vindex_reader_search_batch_with_roaring_filter_v2.restype = c_int
diff --git a/python/tests/test_vindex.py b/python/tests/test_vindex.py
index 8c638f4..950f714 100644
--- a/python/tests/test_vindex.py
+++ b/python/tests/test_vindex.py
@@ -23,6 +23,7 @@ import numpy as np
 import pytest
 
 from paimon_vindex import (
+    IvfPqBatchTableReuseMode,
     SearchParams,
     VectorIndexReader,
     VectorIndexTrainer,
@@ -70,6 +71,19 @@ def 
test_python_search_parameters_remain_algorithm_specific():
     automatic = SearchParams.automatic(top_k=10).to_ffi()
     assert automatic.search_width == 0
     assert automatic.width == 0
+    assert (
+        SearchParams.ivf(10, 16).ivfpq_batch_table_reuse_max_bytes
+        == 512 * 1024 * 1024
+    )
+
+    batch = SearchParams.ivf(
+        top_k=10,
+        nprobe=16,
+        ivfpq_batch_table_reuse=IvfPqBatchTableReuseMode.ON,
+        ivfpq_batch_table_reuse_max_bytes=32 * 1024 * 1024,
+    ).to_ffi_v2()
+    assert batch.ivfpq_batch_table_reuse == 1
+    assert batch.ivfpq_batch_table_reuse_max_bytes == 32 * 1024 * 1024
 
 
 @pytest.mark.parametrize(
@@ -81,6 +95,14 @@ def 
test_python_search_parameters_remain_algorithm_specific():
         lambda: SearchParams.ivf(
             top_k=5, nprobe=ctypes.c_size_t(-1).value + 1
         ),
+        lambda: SearchParams.ivf(
+            top_k=5, nprobe=2, ivfpq_batch_table_reuse=3
+        ),
+        lambda: SearchParams.ivf(
+            top_k=5,
+            nprobe=2,
+            ivfpq_batch_table_reuse_max_bytes=0,
+        ),
     ],
 )
 def 
test_python_search_parameters_reject_values_that_ctypes_would_wrap(factory):

Reply via email to