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 554b121  Expose optimize for search API (#40)
554b121 is described below

commit 554b12143f2e8de1c3975eaabd778dcc86b81e98
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Jun 12 10:19:14 2026 +0800

    Expose optimize for search API (#40)
---
 README.md                                          |  16 +++
 c/test_vindex.c                                    |   4 +
 core/src/index.rs                                  |  79 ++++++++++++++
 core/src/io.rs                                     |   9 ++
 core/src/ivfhnswsq_io.rs                           |  87 ++++++++++++++-
 core/src/sq.rs                                     | 119 +++++++++++++++++++++
 cpp/test_vindex.cpp                                |   2 +
 ffi/src/lib.rs                                     |  13 +++
 include/paimon_vindex.hpp                          |   4 +
 .../paimon/index/vector/VectorIndexNative.java     |   2 +
 .../paimon/index/vector/VectorIndexReader.java     |  11 ++
 .../index/vector/VectorIndexJavaApiTest.java       |   7 ++
 jni/src/lib.rs                                     |  17 +++
 python/paimon_vindex/__init__.py                   |   6 ++
 python/paimon_vindex/_ffi.py                       |   3 +
 python/tests/test_vindex.py                        |   6 ++
 16 files changed, 380 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index 393a61e..3ea1782 100644
--- a/README.md
+++ b/README.md
@@ -68,6 +68,17 @@ types:
 - `ef_search`: optional HNSW search breadth for `IVF_HNSW_FLAT` and
   `IVF_HNSW_SQ`. A value of `0` uses the default.
 
+Readers also expose an optional search warm-up API. Call
+`optimize_for_search` in Rust, C++, and Python,
+`paimon_vindex_reader_optimize_for_search` in the C ABI, or
+`optimizeForSearch` in Java after opening a reader and before repeated
+searches. The call builds in-memory search caches and does not change the
+serialized index format or search results. Currently `IVF_PQ` builds residual
+L2 precomputed tables for repeated PQ searches, `IVF_HNSW_SQ` builds SQ decode
+LUTs for filtered-search SQ scan and fallback paths, and other index types
+preload metadata. The `IVF_HNSW_SQ` LUTs are not expected to speed up the
+normal unfiltered HNSW graph-search path.
+
 ### Rust
 
 ```rust
@@ -97,6 +108,7 @@ writer.write(&mut out)?;
 
 let file = File::open("vectors.pvindex")?;
 let mut reader = VectorIndexReader::open(file)?;
+reader.optimize_for_search()?;
 let params = VectorSearchParams::with_ef_search(10, 16, 80);
 let (ids, distances) = reader.search(&query, params)?;
 ```
@@ -148,6 +160,7 @@ paimon_vindex_writer_free(writer);
 PaimonVindexReaderHandle *reader = paimon_vindex_reader_open(input_file);
 PaimonVindexMetadata metadata;
 paimon_vindex_reader_metadata(reader, &metadata);
+paimon_vindex_reader_optimize_for_search(reader);
 
 int64_t ids[10];
 float distances[10];
@@ -183,6 +196,7 @@ writer.write_index(output_file);
 
 paimon::vindex::Reader reader(input_file);
 auto metadata = reader.metadata();
+reader.optimize_for_search();
 auto result = reader.search(query.data(), 10, 16, 80);
 ```
 
@@ -215,6 +229,7 @@ try (VectorIndexWriter writer = new 
VectorIndexWriter(options)) {
 
 try (VectorIndexReader reader = new VectorIndexReader(vectorIndexInput)) {
     VectorIndexMetadata metadata = reader.metadata();
+    reader.optimizeForSearch();
     VectorSearchResult result = reader.search(query, 10, 16, 80);
 }
 ```
@@ -252,6 +267,7 @@ writer.add_vectors(row_ids, vectors)
 writer.write(output)
 
 reader = VectorIndexReader(VectorIndexInput(index_bytes))
+reader.optimize_for_search()
 ids, distances = reader.search(query, top_k=10, nprobe=16, ef_search=80)
 ```
 
diff --git a/c/test_vindex.c b/c/test_vindex.c
index 816dc80..916889e 100644
--- a/c/test_vindex.c
+++ b/c/test_vindex.c
@@ -166,6 +166,10 @@ static void test_basic_roundtrip(void) {
     ASSERT_EQ_I64(metadata.nlist, 2);
     ASSERT_EQ_I64(metadata.total_vectors, 4);
 
+    if (paimon_vindex_reader_optimize_for_search(reader) != 0) {
+        fail_ffi("reader optimize_for_search failed");
+    }
+
     const float query[] = {0.0f, 0.0f};
     int64_t result_ids[2] = {0};
     float result_distances[2] = {0};
diff --git a/core/src/index.rs b/core/src/index.rs
index e66796b..c0b890c 100644
--- a/core/src/index.rs
+++ b/core/src/index.rs
@@ -526,6 +526,17 @@ impl<R: SeekRead> VectorIndexReader<R> {
         self.metadata().total_vectors
     }
 
+    pub fn optimize_for_search(&mut self) -> io::Result<()> {
+        match self {
+            Self::IvfFlat(reader) => reader.ensure_loaded(),
+            Self::IvfPq(reader) => reader.optimize_for_search(),
+            Self::IvfHnswFlat(reader) => reader.ensure_loaded(),
+            // IVF_HNSW_SQ warms SQ scan/fallback structures used by filtered
+            // searches; normal unfiltered search primarily uses the HNSW 
graph.
+            Self::IvfHnswSq(reader) => reader.optimize_for_search(),
+        }
+    }
+
     pub fn search(
         &mut self,
         query: &[f32],
@@ -824,6 +835,22 @@ mod tests {
         assert_eq!(result_ids[0], 0);
     }
 
+    fn build_reader(config: VectorIndexConfig) -> 
(VectorIndexReader<Cursor<Vec<u8>>>, Vec<f32>) {
+        let d = config.dimension();
+        let nlist = config.nlist();
+        let n = 512;
+        let data = generate_clustered_data(n, d, nlist);
+        let ids = (0..n as i64).collect::<Vec<_>>();
+
+        let mut writer = VectorIndexWriter::new(config).unwrap();
+        writer.train(&data, n).unwrap();
+        writer.add_vectors(&ids, &data, n).unwrap();
+
+        let mut buf = Vec::new();
+        writer.write(&mut PosWriter::new(&mut buf)).unwrap();
+        (VectorIndexReader::open(Cursor::new(buf)).unwrap(), data)
+    }
+
     fn build_ivfflat_reader() -> VectorIndexReader<Cursor<Vec<u8>>> {
         let mut writer = VectorIndexWriter::new(VectorIndexConfig::IvfFlat {
             dimension: 1,
@@ -878,6 +905,58 @@ mod tests {
         });
     }
 
+    #[test]
+    fn optimize_for_search_preserves_results() {
+        for config in [
+            VectorIndexConfig::IvfFlat {
+                dimension: 8,
+                nlist: 4,
+                metric: MetricType::L2,
+            },
+            VectorIndexConfig::IvfPq {
+                dimension: 16,
+                nlist: 4,
+                m: 4,
+                metric: MetricType::L2,
+                use_opq: false,
+            },
+            VectorIndexConfig::IvfHnswFlat {
+                dimension: 8,
+                nlist: 4,
+                metric: MetricType::L2,
+                hnsw: HnswBuildParams::default(),
+            },
+            VectorIndexConfig::IvfHnswSq {
+                dimension: 8,
+                nlist: 4,
+                metric: MetricType::L2,
+                hnsw: HnswBuildParams::default(),
+            },
+        ] {
+            let d = config.dimension();
+            let nlist = config.nlist();
+            let params = VectorSearchParams::with_ef_search(5, nlist, 32);
+            let (mut baseline, data) = build_reader(config.clone());
+            let query = data[0..d].to_vec();
+            let expected = baseline.search(&query, params).unwrap();
+
+            let (mut optimized, _) = build_reader(config);
+            optimized.optimize_for_search().unwrap();
+            let actual = optimized.search(&query, params).unwrap();
+
+            assert_eq!(actual.0, expected.0);
+            assert_eq!(actual.1.len(), expected.1.len());
+            for (actual, expected) in actual.1.iter().zip(expected.1.iter()) {
+                assert!(
+                    (actual - expected).abs() < 1e-4,
+                    "optimized distance {} should match baseline {}",
+                    actual,
+                    expected
+                );
+            }
+        }
+    }
+
     #[test]
     fn unified_reader_rejects_unknown_magic() {
         let err = match VectorIndexReader::open(Cursor::new(vec![0xFF; 8])) {
diff --git a/core/src/io.rs b/core/src/io.rs
index 7570c82..60772de 100644
--- a/core/src/io.rs
+++ b/core/src/io.rs
@@ -648,6 +648,15 @@ impl<R: SeekRead> IVFPQIndexReader<R> {
         Ok(())
     }
 
+    pub fn optimize_for_search(&mut self) -> io::Result<()> {
+        self.ensure_loaded()?;
+        if self.metric == MetricType::L2 && self.by_residual && 
self.precomputed_table.is_empty() {
+            self.precomputed_table =
+                compute_precomputed_table(&self.quantizer_centroids, &self.pq, 
self.nlist, self.d);
+        }
+        Ok(())
+    }
+
     /// Read an inverted list's IDs and PQ codes.
     /// Calls ensure_loaded() if not yet loaded.
     pub fn read_inverted_list(&mut self, list_id: usize) -> 
io::Result<(Vec<i64>, Vec<u8>)> {
diff --git a/core/src/ivfhnswsq_io.rs b/core/src/ivfhnswsq_io.rs
index d5ea2dc..af159bc 100644
--- a/core/src/ivfhnswsq_io.rs
+++ b/core/src/ivfhnswsq_io.rs
@@ -29,9 +29,10 @@ use crate::io::{PreadCursor, ReadRequest, SeekRead, 
SeekWrite};
 use crate::ivfhnswsq::IVFHNSWSQIndex;
 use crate::ivfpq::RowIdFilter;
 use crate::kmeans;
-use crate::sq::ScalarQuantizer;
+use crate::sq::{ScalarQuantizer, ScalarQuantizerDecodeLut};
 use crate::topk::TopKHeap;
 use std::io;
+use std::sync::Arc;
 
 pub const IVF_HNSW_SQ_MAGIC: u32 = 0x49485351; // "IHSQ"
 pub const IVF_HNSW_SQ_VERSION: u32 = 1;
@@ -163,6 +164,7 @@ pub struct IVFHNSWSQIndexReader<R: SeekRead> {
     pub list_counts: Vec<i32>,
     pub list_graph_bytes_lens: Vec<i32>,
     pub list_payload_bytes_lens: Vec<i64>,
+    sq_decode_luts: Vec<Arc<ScalarQuantizerDecodeLut>>,
     loaded: bool,
 }
 
@@ -259,6 +261,7 @@ impl<R: SeekRead> IVFHNSWSQIndexReader<R> {
             list_counts: Vec::new(),
             list_graph_bytes_lens: Vec::new(),
             list_payload_bytes_lens: Vec::new(),
+            sq_decode_luts: Vec::new(),
             loaded: false,
         })
     }
@@ -315,6 +318,21 @@ impl<R: SeekRead> IVFHNSWSQIndexReader<R> {
         Ok(())
     }
 
+    pub fn optimize_for_search(&mut self) -> io::Result<()> {
+        self.ensure_loaded()?;
+        if self.sq_decode_luts.len() != self.nlist {
+            // These LUTs only help when search falls back to scanning SQ 
codes,
+            // for example filtered searches with a small candidate set. The
+            // normal unfiltered path searches decoded vectors in the HNSW 
graph.
+            self.sq_decode_luts = self
+                .list_sqs
+                .iter()
+                .map(|sq| Arc::new(sq.build_decode_lut()))
+                .collect();
+        }
+        Ok(())
+    }
+
     pub fn read_inverted_list(
         &mut self,
         list_id: usize,
@@ -588,6 +606,7 @@ impl<R: SeekRead> IVFHNSWSQIndexReader<R> {
             graph,
             centroid: Some(centroid),
             sq: self.list_sq(meta.list_id).clone(),
+            sq_decode_lut: 
self.list_sq_decode_lut(meta.list_id).map(Arc::clone),
         })
     }
 
@@ -599,6 +618,10 @@ impl<R: SeekRead> IVFHNSWSQIndexReader<R> {
         self.list_sqs.get(list_id).unwrap_or(&self.sq)
     }
 
+    fn list_sq_decode_lut(&self, list_id: usize) -> 
Option<&Arc<ScalarQuantizerDecodeLut>> {
+        self.sq_decode_luts.get(list_id)
+    }
+
     pub fn search(
         &mut self,
         query: &[f32],
@@ -643,6 +666,7 @@ impl<R: SeekRead> IVFHNSWSQIndexReader<R> {
                 &list.codes,
                 list.centroid.as_deref(),
                 &list.sq,
+                list.sq_decode_lut.as_deref(),
                 self.metric,
                 filter,
                 heap,
@@ -736,6 +760,7 @@ pub fn search_batch_ivfhnswsq_reader_filter<R: SeekRead>(
             graph: list.graph,
             centroid: list.centroid,
             sq: list.sq,
+            sq_decode_lut: list.sq_decode_lut,
         });
     }
 
@@ -752,6 +777,7 @@ pub fn search_batch_ivfhnswsq_reader_filter<R: SeekRead>(
                     &list.codes,
                     list.centroid.as_deref(),
                     &list.sq,
+                    list.sq_decode_lut.as_deref(),
                     reader.metric,
                     filter,
                     &mut heaps[qi],
@@ -783,6 +809,7 @@ pub fn search_batch_ivfhnswsq_reader_filter<R: SeekRead>(
                     &list.codes,
                     list.centroid.as_deref(),
                     &list.sq,
+                    list.sq_decode_lut.as_deref(),
                     reader.metric,
                     filter,
                     &mut heaps[qi],
@@ -824,6 +851,7 @@ struct GraphList {
     graph: HnswGraph,
     centroid: Option<Vec<f32>>,
     sq: ScalarQuantizer,
+    sq_decode_lut: Option<Arc<ScalarQuantizerDecodeLut>>,
 }
 
 #[derive(Clone, Copy)]
@@ -873,6 +901,7 @@ struct LoadedBatchList {
     graph: HnswGraph,
     centroid: Option<Vec<f32>>,
     sq: ScalarQuantizer,
+    sq_decode_lut: Option<Arc<ScalarQuantizerDecodeLut>>,
 }
 
 fn scan_sq_list(
@@ -881,6 +910,7 @@ fn scan_sq_list(
     codes: &[u8],
     centroid: Option<&[f32]>,
     sq: &ScalarQuantizer,
+    sq_decode_lut: Option<&ScalarQuantizerDecodeLut>,
     metric: MetricType,
     filter: Option<&dyn RowIdFilter>,
     heap: &mut TopKHeap,
@@ -892,10 +922,16 @@ fn scan_sq_list(
             continue;
         }
         let code = &codes[local_id * code_size..(local_id + 1) * code_size];
-        let dist = if let Some(centroid) = centroid {
-            sq.distance_to_code_with_offset_with_context(query, code, 
centroid, context)
-        } else {
-            sq.distance_to_code_with_context(query, code, context)
+        let dist = match (centroid, sq_decode_lut) {
+            (Some(centroid), Some(lut)) => sq
+                .distance_to_code_with_lut_offset_with_context(query, code, 
centroid, lut, context),
+            (Some(centroid), None) => {
+                sq.distance_to_code_with_offset_with_context(query, code, 
centroid, context)
+            }
+            (None, Some(lut)) => {
+                sq.distance_to_code_with_lut_with_context(query, code, lut, 
context)
+            }
+            (None, None) => sq.distance_to_code_with_context(query, code, 
context),
         };
         heap.push(dist, row_id);
     }
@@ -1384,6 +1420,47 @@ mod tests {
         assert_eq!(labels, vec![12, -1]);
     }
 
+    #[test]
+    fn test_ivfhnswsq_reader_optimized_filter_search_matches_unoptimized() {
+        let d = 4;
+        let nlist = 4;
+        let n = 128;
+        let data: Vec<f32> = (0..n)
+            .flat_map(|i| {
+                let cluster = (i % nlist) as f32 * 100.0;
+                [cluster + i as f32 * 0.01, 1.0, 2.0, 3.0]
+            })
+            .collect();
+        let ids: Vec<i64> = (0..n as i64).collect();
+
+        let mut index = IVFHNSWSQIndex::new(d, nlist, MetricType::L2, 
HnswBuildParams::default());
+        index.train(&data, n);
+        index.add(&data, &ids, n);
+        index.build_graphs().unwrap();
+
+        let mut buf = Vec::new();
+        write_ivfhnswsq_index(&index, &mut PosWriter::new(&mut buf)).unwrap();
+
+        let mut filter = RoaringTreemap::new();
+        filter.insert(0);
+        filter.insert(64);
+        let mut filter_bytes = Vec::new();
+        filter.serialize_into(&mut filter_bytes).unwrap();
+
+        let mut baseline = 
IVFHNSWSQIndexReader::open(Cursor::new(buf.clone())).unwrap();
+        let expected = baseline
+            .search_with_roaring_filter(&data[0..d], 3, nlist, 64, 
&filter_bytes)
+            .unwrap();
+
+        let mut optimized = 
IVFHNSWSQIndexReader::open(Cursor::new(buf)).unwrap();
+        optimized.optimize_for_search().unwrap();
+        let actual = optimized
+            .search_with_roaring_filter(&data[0..d], 3, nlist, 64, 
&filter_bytes)
+            .unwrap();
+
+        assert_eq!(actual, expected);
+    }
+
     #[test]
     fn test_ivfhnswsq_reader_search_coalesces_contiguous_list_reads() {
         let d = 4;
diff --git a/core/src/sq.rs b/core/src/sq.rs
index 812f94f..fe29509 100644
--- a/core/src/sq.rs
+++ b/core/src/sq.rs
@@ -26,6 +26,12 @@ pub struct ScalarQuantizer {
     pub maxs: Vec<f32>,
 }
 
+#[derive(Debug, Clone, PartialEq)]
+pub struct ScalarQuantizerDecodeLut {
+    d: usize,
+    values: Vec<f32>,
+}
+
 impl ScalarQuantizer {
     pub fn new(d: usize) -> Self {
         Self {
@@ -162,6 +168,42 @@ impl ScalarQuantizer {
         self.distance_to_code_impl(query, code, offset, true, context)
     }
 
+    pub fn distance_to_code_with_lut_with_context(
+        &self,
+        query: &[f32],
+        code: &[u8],
+        lut: &ScalarQuantizerDecodeLut,
+        context: DistanceContext,
+    ) -> f32 {
+        self.distance_to_code_lut_impl(query, code, &[], false, lut, context)
+    }
+
+    pub fn distance_to_code_with_lut_offset_with_context(
+        &self,
+        query: &[f32],
+        code: &[u8],
+        offset: &[f32],
+        lut: &ScalarQuantizerDecodeLut,
+        context: DistanceContext,
+    ) -> f32 {
+        debug_assert!(query.len() >= self.d);
+        debug_assert!(code.len() >= self.d);
+        debug_assert!(offset.len() >= self.d);
+
+        self.distance_to_code_lut_impl(query, code, offset, true, lut, context)
+    }
+
+    pub fn build_decode_lut(&self) -> ScalarQuantizerDecodeLut {
+        let mut values = vec![0.0f32; self.d * 256];
+        for dim in 0..self.d {
+            let base = dim * 256;
+            for code in 0..256 {
+                values[base + code] = self.decode_value(code as u8, dim);
+            }
+        }
+        ScalarQuantizerDecodeLut { d: self.d, values }
+    }
+
     fn distance_to_code_impl(
         &self,
         query: &[f32],
@@ -208,6 +250,55 @@ impl ScalarQuantizer {
         }
     }
 
+    fn distance_to_code_lut_impl(
+        &self,
+        query: &[f32],
+        code: &[u8],
+        offset: &[f32],
+        use_offset: bool,
+        lut: &ScalarQuantizerDecodeLut,
+        context: DistanceContext,
+    ) -> f32 {
+        debug_assert!(query.len() >= self.d);
+        debug_assert!(code.len() >= self.d);
+        debug_assert_eq!(lut.d, self.d);
+
+        match context.metric {
+            MetricType::L2 => {
+                let mut sum = 0.0f32;
+                for i in 0..self.d {
+                    let diff = query[i]
+                        - decoded_lut_value_with_offset(lut, code[i], i, 
offset, use_offset);
+                    sum += diff * diff;
+                }
+                sum
+            }
+            MetricType::InnerProduct => {
+                let mut dot = 0.0f32;
+                for i in 0..self.d {
+                    dot += query[i]
+                        * decoded_lut_value_with_offset(lut, code[i], i, 
offset, use_offset);
+                }
+                -dot
+            }
+            MetricType::Cosine => {
+                let mut dot = 0.0f32;
+                let mut vector_norm = 0.0f32;
+                for i in 0..self.d {
+                    let value = decoded_lut_value_with_offset(lut, code[i], i, 
offset, use_offset);
+                    dot += query[i] * value;
+                    vector_norm += value * value;
+                }
+                let denom = context.query_norm * vector_norm.sqrt();
+                if denom > 0.0 {
+                    1.0 - dot / denom
+                } else {
+                    1.0
+                }
+            }
+        }
+    }
+
     fn decode_value_with_offset(
         &self,
         code: u8,
@@ -253,6 +344,34 @@ impl ScalarQuantizer {
     }
 }
 
+impl ScalarQuantizerDecodeLut {
+    #[inline]
+    pub fn decode_value(&self, code: u8, dim: usize) -> f32 {
+        debug_assert!(dim < self.d);
+        self.values[dim * 256 + code as usize]
+    }
+
+    pub fn dimension(&self) -> usize {
+        self.d
+    }
+}
+
+#[inline]
+fn decoded_lut_value_with_offset(
+    lut: &ScalarQuantizerDecodeLut,
+    code: u8,
+    dim: usize,
+    offset: &[f32],
+    use_offset: bool,
+) -> f32 {
+    let value = lut.decode_value(code, dim);
+    if use_offset {
+        value + offset[dim]
+    } else {
+        value
+    }
+}
+
 #[derive(Debug, Clone, Copy)]
 pub struct DistanceContext {
     metric: MetricType,
diff --git a/cpp/test_vindex.cpp b/cpp/test_vindex.cpp
index 1b95840..c1eeb04 100644
--- a/cpp/test_vindex.cpp
+++ b/cpp/test_vindex.cpp
@@ -99,6 +99,8 @@ static void test_basic_roundtrip() {
     ASSERT_EQ(metadata.metric, PAIMON_VINDEX_METRIC_L2);
     ASSERT_EQ(metadata.total_vectors, 4);
 
+    reader.optimize_for_search();
+
     const float query[] = {0.0f, 0.0f};
     auto result = reader.search(query, 2, 2);
     ASSERT_EQ(result.ids.size(), 2);
diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs
index 5e27ca8..5e58f92 100644
--- a/ffi/src/lib.rs
+++ b/ffi/src/lib.rs
@@ -526,6 +526,19 @@ pub unsafe extern "C" fn paimon_vindex_reader_metadata(
     })
 }
 
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vindex_reader_optimize_for_search(
+    handle: *mut PaimonVindexReaderHandle,
+) -> c_int {
+    ffi_status(|| {
+        let handle = unsafe { reader_mut(handle) }?;
+        handle
+            .inner
+            .optimize_for_search()
+            .map_err(|e| format!("optimize_for_search: {}", e))
+    })
+}
+
 #[no_mangle]
 pub unsafe extern "C" fn paimon_vindex_reader_search(
     handle: *mut PaimonVindexReaderHandle,
diff --git a/include/paimon_vindex.hpp b/include/paimon_vindex.hpp
index ec91d32..b0ab679 100644
--- a/include/paimon_vindex.hpp
+++ b/include/paimon_vindex.hpp
@@ -250,6 +250,10 @@ public:
         return result;
     }
 
+    void optimize_for_search() {
+        check(paimon_vindex_reader_optimize_for_search(handle_));
+    }
+
     SearchResult search(const float* query, size_t top_k, size_t nprobe, 
size_t ef_search = 0) {
         SearchResult result;
         result.ids.resize(top_k);
diff --git 
a/java/src/main/java/org/apache/paimon/index/vector/VectorIndexNative.java 
b/java/src/main/java/org/apache/paimon/index/vector/VectorIndexNative.java
index 5350639..4adf3c9 100644
--- a/java/src/main/java/org/apache/paimon/index/vector/VectorIndexNative.java
+++ b/java/src/main/java/org/apache/paimon/index/vector/VectorIndexNative.java
@@ -37,6 +37,8 @@ final class VectorIndexNative {
 
     static native VectorIndexMetadata metadata(long ptr);
 
+    static native void optimizeForSearch(long ptr);
+
     static native VectorSearchResult search(long ptr, float[] query, int k, 
int nprobe, int efSearch);
 
     static native VectorSearchResult searchWithRoaringFilter(
diff --git 
a/java/src/main/java/org/apache/paimon/index/vector/VectorIndexReader.java 
b/java/src/main/java/org/apache/paimon/index/vector/VectorIndexReader.java
index 34eefc7..2494cfc 100644
--- a/java/src/main/java/org/apache/paimon/index/vector/VectorIndexReader.java
+++ b/java/src/main/java/org/apache/paimon/index/vector/VectorIndexReader.java
@@ -66,6 +66,17 @@ public final class VectorIndexReader implements 
AutoCloseable {
         return metadata().totalVectors();
     }
 
+    public void optimizeForSearch() {
+        synchronized (nativeHandleLock) {
+            enterNativeHandle();
+            try {
+                VectorIndexNative.optimizeForSearch(requireOpen());
+            } finally {
+                exitNativeHandle();
+            }
+        }
+    }
+
     public VectorSearchResult search(float[] query, int topK, int nprobe) {
         return search(query, topK, nprobe, 0);
     }
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 2cf1a05..6fd37a6 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
@@ -128,6 +128,12 @@ public class VectorIndexJavaApiTest {
                 reader.totalVectors();
             }
         });
+        assertThrows(IllegalStateException.class, new ThrowingRunnable() {
+            @Override
+            public void run() {
+                reader.optimizeForSearch();
+            }
+        });
         assertThrows(IllegalStateException.class, new ThrowingRunnable() {
             @Override
             public void run() {
@@ -183,6 +189,7 @@ public class VectorIndexJavaApiTest {
             reader.indexType();
             reader.dimension();
             reader.totalVectors();
+            reader.optimizeForSearch();
             reader.search(new float[] {0.0f, 1.0f}, 10, 4);
             reader.search(new float[] {0.0f, 1.0f}, 10, 4, 32);
             reader.search(new float[] {0.0f, 1.0f}, 10, 4, new byte[] {1, 2});
diff --git a/jni/src/lib.rs b/jni/src/lib.rs
index dd4aa8a..186fa84 100644
--- a/jni/src/lib.rs
+++ b/jni/src/lib.rs
@@ -494,6 +494,23 @@ pub extern "system" fn 
Java_org_apache_paimon_index_vector_VectorIndexNative_met
     })
 }
 
+#[no_mangle]
+pub extern "system" fn 
Java_org_apache_paimon_index_vector_VectorIndexNative_optimizeForSearch(
+    env: JNIEnv,
+    _class: JClass,
+    ptr: jlong,
+) {
+    jni_call_void(env, |env| {
+        let reader = match deref_reader(ptr) {
+            Some(reader) => reader,
+            None => return throw_and_return(env, "null native pointer (reader 
already freed?)"),
+        };
+        if let Err(e) = reader.optimize_for_search() {
+            throw_and_return::<()>(env, &format!("optimize_for_search: {}", 
e));
+        }
+    })
+}
+
 #[no_mangle]
 pub extern "system" fn 
Java_org_apache_paimon_index_vector_VectorIndexNative_search(
     env: JNIEnv,
diff --git a/python/paimon_vindex/__init__.py b/python/paimon_vindex/__init__.py
index 874567b..ddaedfa 100644
--- a/python/paimon_vindex/__init__.py
+++ b/python/paimon_vindex/__init__.py
@@ -298,6 +298,12 @@ class VectorIndexReader:
             _check_error("metadata failed")
         return _metadata_from_ffi(raw)
 
+    def optimize_for_search(self):
+        self._require_open()
+        rc = lib.paimon_vindex_reader_optimize_for_search(self._handle)
+        if rc != 0:
+            _check_error("optimize_for_search failed")
+
     def _filter_args(self, filter_bytes):
         if filter_bytes is None:
             return None, 0, None
diff --git a/python/paimon_vindex/_ffi.py b/python/paimon_vindex/_ffi.py
index 135a966..72cc25c 100644
--- a/python/paimon_vindex/_ffi.py
+++ b/python/paimon_vindex/_ffi.py
@@ -163,6 +163,9 @@ lib.paimon_vindex_reader_metadata.argtypes = [
 ]
 lib.paimon_vindex_reader_metadata.restype = c_int
 
+lib.paimon_vindex_reader_optimize_for_search.argtypes = [c_void_p]
+lib.paimon_vindex_reader_optimize_for_search.restype = c_int
+
 lib.paimon_vindex_reader_search.argtypes = [
     c_void_p,
     POINTER(c_float),
diff --git a/python/tests/test_vindex.py b/python/tests/test_vindex.py
index c7e25c5..8380c84 100644
--- a/python/tests/test_vindex.py
+++ b/python/tests/test_vindex.py
@@ -108,9 +108,15 @@ def test_python_ffi_roundtrips_supported_indexes():
             assert metadata.total_vectors == 512
 
             ids, distances = reader.search(data[0], top_k=5, nprobe=4, 
ef_search=32)
+            reader.optimize_for_search()
+            optimized_ids, optimized_distances = reader.search(
+                data[0], top_k=5, nprobe=4, ef_search=32
+            )
             assert ids.shape == (5,)
             assert distances.shape == (5,)
             assert ids[0] == 0
+            np.testing.assert_array_equal(optimized_ids, ids)
+            np.testing.assert_allclose(optimized_distances, distances, rtol=0, 
atol=1e-4)
 
 
 def test_python_ffi_batch_search():

Reply via email to