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

JingsongLi pushed a commit to branch codex/roaring-filter-search
in repository https://gitbox.apache.org/repos/asf/paimon-full-text.git

commit dcd3ed4f49ba5c156ceacdc36f7c4877f7267a14
Author: JingsongLi <[email protected]>
AuthorDate: Sat Jul 4 22:52:22 2026 +0800

    Add Roaring row-id filters to full-text search
---
 Cargo.lock                                         |  18 ++
 Cargo.toml                                         |   1 +
 README.md                                          |  17 +-
 core/Cargo.toml                                    |   1 +
 core/src/index.rs                                  |  39 ++++-
 core/tests/core_roundtrip.rs                       |  84 +++++++++
 docs/designs/2026-07-04-paimon-full-text-index.md  |  21 ++-
 docs/java-api.md                                   |   6 +
 docs/paimon-integration.md                         |   2 +
 docs/python-api.md                                 |  11 +-
 ffi/Cargo.toml                                     |   1 +
 ffi/src/lib.rs                                     | 192 +++++++++++++++++----
 include/paimon_ftindex.h                           |  11 ++
 .../paimon/index/fulltext/FullTextIndexReader.java |  14 ++
 .../paimon/index/fulltext/FullTextNative.java      |   3 +
 jni/src/lib.rs                                     |  48 +++++-
 python/paimon_ftindex/_ffi.py                      |  17 ++
 python/paimon_ftindex/reader.py                    |  24 ++-
 python/tests/test_roundtrip.py                     |  57 ++++++
 19 files changed, 517 insertions(+), 50 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 35b4f2b..ccb6369 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -76,6 +76,12 @@ version = "3.20.3"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
 
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+
 [[package]]
 name = "byteorder"
 version = "1.5.0"
@@ -577,6 +583,7 @@ name = "paimon-ftindex-core"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "roaring",
  "serde",
  "serde_json",
  "tantivy",
@@ -590,6 +597,7 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "paimon-ftindex-core",
+ "roaring",
  "serde_json",
 ]
 
@@ -749,6 +757,16 @@ version = "0.8.11"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
 
+[[package]]
+name = "roaring"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9"
+dependencies = [
+ "bytemuck",
+ "byteorder",
+]
+
 [[package]]
 name = "rust-stemmers"
 version = "1.2.0"
diff --git a/Cargo.toml b/Cargo.toml
index 1e0c7ec..8a2e210 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,7 @@ anyhow = "1"
 jni = "0.21"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
+roaring = "0.11"
 tantivy = "0.22"
 tempfile = "3"
 thiserror = "1"
diff --git a/README.md b/README.md
index 6c0f2a8..89e97a7 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,8 @@ and do not depend on Paimon manifest metadata.
 Implemented:
 
 - Rust writer, reader, v1 envelope, and search.
-- C FFI writer/reader/search JSON.
+- C FFI writer/reader/search JSON, including serialized 64-bit Roaring row-id
+  filters.
 - Java API and JNI bridge.
 - Python ctypes package.
 - Cross-boundary round-trip tests for Rust core, FFI, and Python.
@@ -67,6 +68,17 @@ let mut reader = 
FullTextIndexReader::open(SliceReader::new(bytes))?;
 let result = reader.search(FullTextQuery::match_query("paimon", "text"), 10)?;
 ```
 
+To restrict search to an upstream candidate set, pass a serialized
+`RoaringTreemap` of allowed row ids:
+
+```rust
+let filtered = reader.search_with_roaring_filter(
+    FullTextQuery::match_query("paimon", "text"),
+    10,
+    roaring_filter_bytes,
+)?;
+```
+
 ## Python Example
 
 ```python
@@ -86,4 +98,7 @@ class Input:
 
 with FullTextIndexReader(Input(out.getvalue())) as reader:
     ids, scores = reader.search(MatchQuery("paimon"), limit=10)
+    filtered_ids, filtered_scores = reader.search(
+        MatchQuery("paimon"), limit=10, filter_bytes=roaring_filter_bytes
+    )
 ```
diff --git a/core/Cargo.toml b/core/Cargo.toml
index a1124c0..1f07c33 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -7,6 +7,7 @@ repository.workspace = true
 rust-version.workspace = true
 
 [dependencies]
+roaring.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 tantivy.workspace = true
diff --git a/core/src/index.rs b/core/src/index.rs
index 85bc987..30e7f1f 100644
--- a/core/src/index.rs
+++ b/core/src/index.rs
@@ -4,10 +4,11 @@ use crate::io::{SeekRead, SeekWrite};
 use crate::query::{BooleanOccur, FullTextQuery, MatchOperator};
 use crate::storage::{read_exact_at, read_header, write_envelope, 
ArchiveFileEntry, IndexHeader};
 use crate::tokenizer::{TokenizerConfig, TokenizerKind};
+use roaring::RoaringTreemap;
 use std::fmt;
 use std::fs;
 use std::path::Path;
-use tantivy::collector::TopDocs;
+use tantivy::collector::{FilterCollector, TopDocs};
 use tantivy::directory::{Directory, RamDirectory};
 use tantivy::query::{
     BooleanQuery, BoostQuery, EnableScoring, Explanation, Occur, Query, 
QueryParser, Scorer, Weight,
@@ -131,6 +132,25 @@ impl<R: SeekRead> FullTextIndexReader<R> {
     }
 
     pub fn search(&mut self, query: FullTextQuery, limit: usize) -> 
Result<FullTextSearchResult> {
+        self.search_with_filter(query, limit, None)
+    }
+
+    pub fn search_with_roaring_filter(
+        &mut self,
+        query: FullTextQuery,
+        limit: usize,
+        roaring_filter_bytes: &[u8],
+    ) -> Result<FullTextSearchResult> {
+        let filter = decode_roaring_filter(roaring_filter_bytes)?;
+        self.search_with_filter(query, limit, Some(filter))
+    }
+
+    fn search_with_filter(
+        &mut self,
+        query: FullTextQuery,
+        limit: usize,
+        filter: Option<RoaringTreemap>,
+    ) -> Result<FullTextSearchResult> {
         if limit == 0 {
             return Err(FtIndexError::InvalidQuery(
                 "search limit must be positive".to_string(),
@@ -139,7 +159,17 @@ impl<R: SeekRead> FullTextIndexReader<R> {
         let reader = self.index.reader()?;
         let searcher = reader.searcher();
         let tantivy_query = build_query(&self.index, &self.metadata.config, 
&query)?;
-        let top_docs = searcher.search(&tantivy_query, 
&TopDocs::with_limit(limit))?;
+        let top_docs = if let Some(filter) = filter {
+            let row_id_field = self.metadata.config.row_id_field.clone();
+            let collector = FilterCollector::new(
+                row_id_field,
+                move |row_id: u64| filter.contains(row_id),
+                TopDocs::with_limit(limit),
+            );
+            searcher.search(&tantivy_query, &collector)?
+        } else {
+            searcher.search(&tantivy_query, &TopDocs::with_limit(limit))?
+        };
         let mut row_ids = Vec::with_capacity(top_docs.len());
         let mut scores = Vec::with_capacity(top_docs.len());
         for (score, doc_address) in top_docs {
@@ -155,6 +185,11 @@ impl<R: SeekRead> FullTextIndexReader<R> {
     }
 }
 
+fn decode_roaring_filter(bytes: &[u8]) -> Result<RoaringTreemap> {
+    RoaringTreemap::deserialize_from(bytes)
+        .map_err(|e| FtIndexError::InvalidQuery(format!("invalid 
RoaringTreemap filter: {e}")))
+}
+
 fn build_schema(config: &FullTextIndexConfig) -> Schema {
     let mut builder = Schema::builder();
     builder.add_u64_field(
diff --git a/core/tests/core_roundtrip.rs b/core/tests/core_roundtrip.rs
index 3f1b009..00d66ec 100644
--- a/core/tests/core_roundtrip.rs
+++ b/core/tests/core_roundtrip.rs
@@ -2,6 +2,7 @@ use paimon_ftindex_core::io::{PosWriter, SliceReader};
 use paimon_ftindex_core::{
     FullTextIndexConfig, FullTextIndexReader, FullTextIndexWriter, 
FullTextQuery, MatchOperator,
 };
+use roaring::RoaringTreemap;
 
 fn build_index() -> anyhow::Result<Vec<u8>> {
     let mut writer = FullTextIndexWriter::new(FullTextIndexConfig::new())?;
@@ -47,6 +48,89 @@ fn match_query_and_operator_filters_terms() -> 
anyhow::Result<()> {
     Ok(())
 }
 
+#[test]
+fn search_with_roaring_filter_limits_allowed_row_ids_before_top_docs() -> 
anyhow::Result<()> {
+    let bytes = build_index()?;
+    let query = FullTextQuery::match_query("paimon", "text");
+
+    let mut reader = 
FullTextIndexReader::open(SliceReader::new(bytes.clone()))?;
+    let unfiltered_top = reader.search(query.clone(), 1)?.row_ids[0];
+    let allowed_id = if unfiltered_top == 10 { 12 } else { 10 };
+
+    let mut allowed = RoaringTreemap::new();
+    allowed.insert(allowed_id as u64);
+    let mut filter_bytes = Vec::new();
+    allowed.serialize_into(&mut filter_bytes)?;
+
+    let mut reader = FullTextIndexReader::open(SliceReader::new(bytes))?;
+    let result = reader.search_with_roaring_filter(query, 1, &filter_bytes)?;
+
+    assert_eq!(result.row_ids, vec![allowed_id]);
+    assert_eq!(result.scores.len(), 1);
+    Ok(())
+}
+
+#[test]
+fn search_with_empty_roaring_filter_returns_empty_results() -> 
anyhow::Result<()> {
+    let bytes = build_index()?;
+    let empty = RoaringTreemap::new();
+    let mut filter_bytes = Vec::new();
+    empty.serialize_into(&mut filter_bytes)?;
+
+    let mut reader = FullTextIndexReader::open(SliceReader::new(bytes))?;
+    let result = reader.search_with_roaring_filter(
+        FullTextQuery::match_query("paimon", "text"),
+        10,
+        &filter_bytes,
+    )?;
+
+    assert!(result.row_ids.is_empty());
+    assert!(result.scores.is_empty());
+    Ok(())
+}
+
+#[test]
+fn search_with_roaring_filter_supports_64_bit_row_ids() -> anyhow::Result<()> {
+    let allowed_id = (1i64 << 33) + 17;
+    let mut writer = FullTextIndexWriter::new(FullTextIndexConfig::new())?;
+    writer.add_document(1, "apache paimon")?;
+    writer.add_document(allowed_id, "paimon filtered row")?;
+
+    let mut bytes = Vec::new();
+    writer.write(&mut PosWriter::new(&mut bytes))?;
+
+    let mut allowed = RoaringTreemap::new();
+    allowed.insert(allowed_id as u64);
+    let mut filter_bytes = Vec::new();
+    allowed.serialize_into(&mut filter_bytes)?;
+
+    let mut reader = FullTextIndexReader::open(SliceReader::new(bytes))?;
+    let result = reader.search_with_roaring_filter(
+        FullTextQuery::match_query("paimon", "text"),
+        10,
+        &filter_bytes,
+    )?;
+
+    assert_eq!(result.row_ids, vec![allowed_id]);
+    Ok(())
+}
+
+#[test]
+fn search_rejects_invalid_roaring_filter_bytes() -> anyhow::Result<()> {
+    let bytes = build_index()?;
+    let mut reader = FullTextIndexReader::open(SliceReader::new(bytes))?;
+    let err = reader
+        .search_with_roaring_filter(
+            FullTextQuery::match_query("paimon", "text"),
+            10,
+            b"not roaring",
+        )
+        .expect_err("invalid filter bytes should fail");
+
+    assert!(err.to_string().contains("invalid RoaringTreemap filter"));
+    Ok(())
+}
+
 #[test]
 fn phrase_query_uses_positions() -> anyhow::Result<()> {
     let bytes = build_index()?;
diff --git a/docs/designs/2026-07-04-paimon-full-text-index.md 
b/docs/designs/2026-07-04-paimon-full-text-index.md
index 78bb0e5..02e174f 100644
--- a/docs/designs/2026-07-04-paimon-full-text-index.md
+++ b/docs/designs/2026-07-04-paimon-full-text-index.md
@@ -250,6 +250,17 @@ int paimon_ftindex_reader_search_json(
     float *scores,
     size_t capacity,
     size_t *result_len);
+
+int paimon_ftindex_reader_search_json_with_roaring_filter(
+    PaimonFtindexReaderHandle *reader,
+    const char *query_json,
+    size_t limit,
+    const uint8_t *roaring_filter,
+    size_t roaring_filter_len,
+    int64_t *row_ids,
+    float *scores,
+    size_t capacity,
+    size_t *result_len);
 ```
 
 All functions return `0` on success and `-1` on error. A thread-local
@@ -291,6 +302,8 @@ try (FullTextIndexWriter writer = 
FullTextIndexWriter.create(options)) {
 try (FullTextIndexReader reader = new FullTextIndexReader(input)) {
     FullTextSearchResult result =
             reader.search(FullTextQuery.match("paimon", "text"), 10);
+    FullTextSearchResult filtered =
+            reader.search(FullTextQuery.match("paimon", "text"), 10, 
roaringFilterBytes);
 }
 ```
 
@@ -320,6 +333,11 @@ writer.write(output)
 
 reader = FullTextIndexReader(input)
 ids, scores = reader.search(MatchQuery("paimon", column="text"), limit=10)
+filtered_ids, filtered_scores = reader.search(
+    MatchQuery("paimon", column="text"),
+    limit=10,
+    filter_bytes=roaring_filter_bytes,
+)
 ```
 
 Python I/O protocol:
@@ -441,6 +459,3 @@ matching the caution already present in upstream 
`paimon-tantivy-jni`.
 - Should Java native loading copy prebuilt libraries from resources, or should
   this repository initially require `PAIMON_FTINDEX_LIB_PATH` like the Python
   package?
-- Should v1 expose row-id prefiltering with serialized 64-bit Roaring bitmaps,
-  matching vector-index, or defer it until Paimon needs hybrid scalar plus
-  full-text search?
diff --git a/docs/java-api.md b/docs/java-api.md
index 73aa5d4..3a9535a 100644
--- a/docs/java-api.md
+++ b/docs/java-api.md
@@ -25,9 +25,15 @@ try (FullTextIndexWriter writer = 
FullTextIndexWriter.create(Collections.emptyMa
 
 try (FullTextIndexReader reader = new FullTextIndexReader(input)) {
     FullTextSearchResult result = reader.search(FullTextQuery.match("paimon", 
"text"), 10);
+    FullTextSearchResult filtered =
+            reader.search(FullTextQuery.match("paimon", "text"), 10, 
roaringFilterBytes);
 }
 ```
 
+`roaringFilterBytes` must be a serialized 64-bit Roaring bitmap
+(`RoaringTreemap`) containing the allowed row ids. The filter is applied during
+Tantivy collection, before the top results are selected.
+
 Native loading:
 
 - Set `PAIMON_FTINDEX_JNI_LIB_PATH` to the full path of
diff --git a/docs/paimon-integration.md b/docs/paimon-integration.md
index 26f82b3..e5da028 100644
--- a/docs/paimon-integration.md
+++ b/docs/paimon-integration.md
@@ -7,6 +7,8 @@ integration should be a thin adapter:
 - Serialize queries to the JSON accepted by this library.
 - Implement a Paimon `GlobalIndexerFactory` that delegates to Java
   `FullTextIndexWriter` and `FullTextIndexReader`.
+- Pass serialized 64-bit Roaring row-id filters to reader search when another
+  index or predicate pushdown has already produced an allowed candidate set.
 - Store produced files as global index files.
 
 Suggested index identifier:
diff --git a/docs/python-api.md b/docs/python-api.md
index 5411d83..a5e0b65 100644
--- a/docs/python-api.md
+++ b/docs/python-api.md
@@ -17,8 +17,15 @@ with FullTextIndexWriter({"tokenizer": "ngram"}) as writer:
 
 with FullTextIndexReader(input_) as reader:
     ids, scores = reader.search(MatchQuery("paimon"), limit=10)
+    filtered_ids, filtered_scores = reader.search(
+        MatchQuery("paimon"), limit=10, filter_bytes=roaring_filter_bytes
+    )
 ```
 
+`filter_bytes` must be a serialized 64-bit Roaring bitmap (`RoaringTreemap`)
+containing the allowed row ids. The filter is applied during Tantivy
+collection, before the top results are selected.
+
 The output object must provide:
 
 - `write(bytes)`
@@ -31,5 +38,5 @@ The input object must provide:
 Native loading:
 
 - Set `PAIMON_FTINDEX_LIB_PATH` to a library file or directory, or
-- build `paimon-ftindex-ffi` so the package can discover `target/debug` or
-  `target/release`.
+- build `paimon-ftindex-ffi` so the package can discover `target/debug`,
+  `target/debug/deps`, `target/release`, or `target/release/deps`.
diff --git a/ffi/Cargo.toml b/ffi/Cargo.toml
index f552b3e..b8f479d 100644
--- a/ffi/Cargo.toml
+++ b/ffi/Cargo.toml
@@ -16,3 +16,4 @@ serde_json.workspace = true
 
 [dev-dependencies]
 anyhow.workspace = true
+roaring.workspace = true
diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs
index 4840eff..49ce058 100644
--- a/ffi/src/lib.rs
+++ b/ffi/src/lib.rs
@@ -237,38 +237,37 @@ pub unsafe extern "C" fn 
paimon_ftindex_reader_search_json(
 ) -> c_int {
     ffi_status(|| {
         let reader = require_mut(reader, "reader")?;
-        if result_len.is_null() {
-            return Err("result_len is null".to_string());
-        }
-        let query_json = cstr_to_string(query_json, "query_json")?;
-        let query = FullTextQuery::from_json(&query_json).map_err(|e| 
e.to_string())?;
-        let result = reader
-            .inner
-            .search(query, limit)
-            .map_err(|e| e.to_string())?;
-        unsafe {
-            *result_len = result.row_ids.len();
-        }
-        if result.row_ids.len() > capacity {
-            return Err(format!(
-                "result capacity {} is smaller than result length {}",
-                capacity,
-                result.row_ids.len()
-            ));
-        }
-        if !result.row_ids.is_empty() {
-            if row_ids.is_null() {
-                return Err("row_ids is null".to_string());
-            }
-            if scores.is_null() {
-                return Err("scores is null".to_string());
-            }
-            unsafe {
-                ptr::copy_nonoverlapping(result.row_ids.as_ptr(), row_ids, 
result.row_ids.len());
-                ptr::copy_nonoverlapping(result.scores.as_ptr(), scores, 
result.scores.len());
-            }
-        }
-        Ok(())
+        search_json_impl(
+            reader, query_json, limit, None, row_ids, scores, capacity, 
result_len,
+        )
+    })
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn paimon_ftindex_reader_search_json_with_roaring_filter(
+    reader: *mut PaimonFtindexReaderHandle,
+    query_json: *const c_char,
+    limit: usize,
+    roaring_filter: *const u8,
+    roaring_filter_len: usize,
+    row_ids: *mut i64,
+    scores: *mut f32,
+    capacity: usize,
+    result_len: *mut usize,
+) -> c_int {
+    ffi_status(|| {
+        let reader = require_mut(reader, "reader")?;
+        let roaring_filter = const_slice(roaring_filter, roaring_filter_len, 
"roaring_filter")?;
+        search_json_impl(
+            reader,
+            query_json,
+            limit,
+            Some(roaring_filter),
+            row_ids,
+            scores,
+            capacity,
+            result_len,
+        )
     })
 }
 
@@ -308,6 +307,16 @@ unsafe fn require_mut<'a, T>(ptr: *mut T, name: &str) -> 
Result<&'a mut T, Strin
     ptr.as_mut().ok_or_else(|| format!("{name} is null"))
 }
 
+unsafe fn const_slice<'a, T>(ptr: *const T, len: usize, name: &str) -> 
Result<&'a [T], String> {
+    if len == 0 {
+        Ok(&[])
+    } else if ptr.is_null() {
+        Err(format!("{name} is null"))
+    } else {
+        Ok(slice::from_raw_parts(ptr, len))
+    }
+}
+
 unsafe fn cstr_to_string(ptr: *const c_char, name: &str) -> Result<String, 
String> {
     if ptr.is_null() {
         return Err(format!("{name} is null"));
@@ -318,9 +327,61 @@ unsafe fn cstr_to_string(ptr: *const c_char, name: &str) 
-> Result<String, Strin
         .map_err(|e| format!("{name} is not valid UTF-8: {e}"))
 }
 
+fn search_json_impl(
+    reader: &mut PaimonFtindexReaderHandle,
+    query_json: *const c_char,
+    limit: usize,
+    roaring_filter: Option<&[u8]>,
+    row_ids: *mut i64,
+    scores: *mut f32,
+    capacity: usize,
+    result_len: *mut usize,
+) -> Result<(), String> {
+    if result_len.is_null() {
+        return Err("result_len is null".to_string());
+    }
+    let query_json = unsafe { cstr_to_string(query_json, "query_json") }?;
+    let query = FullTextQuery::from_json(&query_json).map_err(|e| 
e.to_string())?;
+    let result = if let Some(roaring_filter) = roaring_filter {
+        reader
+            .inner
+            .search_with_roaring_filter(query, limit, roaring_filter)
+            .map_err(|e| e.to_string())?
+    } else {
+        reader
+            .inner
+            .search(query, limit)
+            .map_err(|e| e.to_string())?
+    };
+    unsafe {
+        *result_len = result.row_ids.len();
+    }
+    if result.row_ids.len() > capacity {
+        return Err(format!(
+            "result capacity {} is smaller than result length {}",
+            capacity,
+            result.row_ids.len()
+        ));
+    }
+    if !result.row_ids.is_empty() {
+        if row_ids.is_null() {
+            return Err("row_ids is null".to_string());
+        }
+        if scores.is_null() {
+            return Err("scores is null".to_string());
+        }
+        unsafe {
+            ptr::copy_nonoverlapping(result.row_ids.as_ptr(), row_ids, 
result.row_ids.len());
+            ptr::copy_nonoverlapping(result.scores.as_ptr(), scores, 
result.scores.len());
+        }
+    }
+    Ok(())
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
+    use roaring::RoaringTreemap;
     use std::ffi::CString;
 
     unsafe extern "C" fn write_vec(ctx: *mut c_void, buf: *const u8, len: 
usize) -> c_int {
@@ -395,4 +456,71 @@ mod tests {
             paimon_ftindex_reader_free(reader);
         }
     }
+
+    #[test]
+    fn ffi_round_trip_search_json_with_roaring_filter() {
+        unsafe {
+            let writer = paimon_ftindex_writer_open(ptr::null(), ptr::null(), 
0);
+            assert!(!writer.is_null());
+
+            let text = CString::new("Apache Paimon full text").unwrap();
+            assert_eq!(
+                paimon_ftindex_writer_add_document(writer, 7, text.as_ptr()),
+                0
+            );
+            let text = CString::new("Paimon filtered row").unwrap();
+            assert_eq!(
+                paimon_ftindex_writer_add_document(writer, 9, text.as_ptr()),
+                0
+            );
+
+            let mut bytes = Vec::new();
+            let output = PaimonFtindexOutputFile {
+                ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
+                write_fn: Some(write_vec),
+                flush_fn: None,
+            };
+            assert_eq!(paimon_ftindex_writer_write_index(writer, output), 0);
+            paimon_ftindex_writer_free(writer);
+
+            let input = PaimonFtindexInputFile {
+                ctx: &bytes as *const Vec<u8> as *mut c_void,
+                read_at_fn: Some(read_vec),
+            };
+            let reader = paimon_ftindex_reader_open(input);
+            assert!(!reader.is_null());
+
+            let query = CString::new(
+                paimon_ftindex_core::FullTextQuery::match_query("paimon", 
"text")
+                    .to_json()
+                    .unwrap(),
+            )
+            .unwrap();
+            let mut allowed = RoaringTreemap::new();
+            allowed.insert(9);
+            let mut filter_bytes = Vec::new();
+            allowed.serialize_into(&mut filter_bytes).unwrap();
+            let mut row_ids = [0i64; 4];
+            let mut scores = [0f32; 4];
+            let mut result_len = 0usize;
+            assert_eq!(
+                paimon_ftindex_reader_search_json_with_roaring_filter(
+                    reader,
+                    query.as_ptr(),
+                    4,
+                    filter_bytes.as_ptr(),
+                    filter_bytes.len(),
+                    row_ids.as_mut_ptr(),
+                    scores.as_mut_ptr(),
+                    row_ids.len(),
+                    &mut result_len,
+                ),
+                0
+            );
+            assert_eq!(result_len, 1);
+            assert_eq!(row_ids[0], 9);
+            assert!(scores[0] > 0.0);
+            paimon_ftindex_reader_free(reader);
+        }
+    }
 }
diff --git a/include/paimon_ftindex.h b/include/paimon_ftindex.h
index 08c9237..b4d2cea 100644
--- a/include/paimon_ftindex.h
+++ b/include/paimon_ftindex.h
@@ -51,6 +51,17 @@ int paimon_ftindex_reader_search_json(
     size_t capacity,
     size_t *result_len);
 
+int paimon_ftindex_reader_search_json_with_roaring_filter(
+    PaimonFtindexReaderHandle *reader,
+    const char *query_json,
+    size_t limit,
+    const uint8_t *roaring_filter,
+    size_t roaring_filter_len,
+    int64_t *row_ids,
+    float *scores,
+    size_t capacity,
+    size_t *result_len);
+
 void paimon_ftindex_reader_free(PaimonFtindexReaderHandle *reader);
 
 #ifdef __cplusplus
diff --git 
a/java/src/main/java/org/apache/paimon/index/fulltext/FullTextIndexReader.java 
b/java/src/main/java/org/apache/paimon/index/fulltext/FullTextIndexReader.java
index 717af65..1520532 100644
--- 
a/java/src/main/java/org/apache/paimon/index/fulltext/FullTextIndexReader.java
+++ 
b/java/src/main/java/org/apache/paimon/index/fulltext/FullTextIndexReader.java
@@ -21,6 +21,20 @@ public final class FullTextIndexReader implements 
AutoCloseable {
         return FullTextNative.searchJson(requireOpen(), query.toJson(), limit);
     }
 
+    public FullTextSearchResult search(FullTextQuery query, int limit, byte[] 
roaringFilter) {
+        if (query == null) {
+            throw new NullPointerException("query");
+        }
+        if (limit <= 0) {
+            throw new IllegalArgumentException("search limit must be 
positive");
+        }
+        if (roaringFilter == null) {
+            throw new NullPointerException("roaringFilter");
+        }
+        return FullTextNative.searchJsonWithRoaringFilter(
+                requireOpen(), query.toJson(), limit, roaringFilter);
+    }
+
     @Override
     public void close() {
         long ptr = nativePtr;
diff --git 
a/java/src/main/java/org/apache/paimon/index/fulltext/FullTextNative.java 
b/java/src/main/java/org/apache/paimon/index/fulltext/FullTextNative.java
index d573941..5d2a07e 100644
--- a/java/src/main/java/org/apache/paimon/index/fulltext/FullTextNative.java
+++ b/java/src/main/java/org/apache/paimon/index/fulltext/FullTextNative.java
@@ -25,5 +25,8 @@ final class FullTextNative {
 
     static native FullTextSearchResult searchJson(long readerPtr, String 
queryJson, int limit);
 
+    static native FullTextSearchResult searchJsonWithRoaringFilter(
+            long readerPtr, String queryJson, int limit, byte[] roaringFilter);
+
     static native void freeReader(long readerPtr);
 }
diff --git a/jni/src/lib.rs b/jni/src/lib.rs
index e78af3d..953beaa 100644
--- a/jni/src/lib.rs
+++ b/jni/src/lib.rs
@@ -175,7 +175,28 @@ pub extern "system" fn 
Java_org_apache_paimon_index_fulltext_FullTextNative_sear
     query_json: JString,
     limit: jint,
 ) -> jobject {
-    match search_json(&mut env, reader_ptr, query_json, limit) {
+    match search_json(&mut env, reader_ptr, query_json, limit, None) {
+        Ok(obj) => obj,
+        Err(e) => throw_and_return(&mut env, &e, ptr::null_mut()),
+    }
+}
+
+#[no_mangle]
+pub extern "system" fn 
Java_org_apache_paimon_index_fulltext_FullTextNative_searchJsonWithRoaringFilter(
+    mut env: JNIEnv,
+    _class: JClass,
+    reader_ptr: jlong,
+    query_json: JString,
+    limit: jint,
+    roaring_filter: JByteArray,
+) -> jobject {
+    match search_json(
+        &mut env,
+        reader_ptr,
+        query_json,
+        limit,
+        Some(roaring_filter),
+    ) {
         Ok(obj) => obj,
         Err(e) => throw_and_return(&mut env, &e, ptr::null_mut()),
     }
@@ -244,6 +265,7 @@ fn search_json(
     reader_ptr: jlong,
     query_json: JString,
     limit: jint,
+    roaring_filter: Option<JByteArray>,
 ) -> Result<jobject, String> {
     let reader = handle_mut::<ReaderHandle>(reader_ptr, "reader")?;
     let query_json: String = env
@@ -252,10 +274,18 @@ fn search_json(
         .into();
     let query = FullTextQuery::from_json(&query_json).map_err(|e| 
e.to_string())?;
     let limit = validate_search_limit(limit)?;
-    let result = reader
-        .inner
-        .search(query, limit)
-        .map_err(|e| e.to_string())?;
+    let result = if let Some(roaring_filter) = roaring_filter {
+        let roaring_filter = read_byte_array(env, roaring_filter)?;
+        reader
+            .inner
+            .search_with_roaring_filter(query, limit, &roaring_filter)
+            .map_err(|e| e.to_string())?
+    } else {
+        reader
+            .inner
+            .search(query, limit)
+            .map_err(|e| e.to_string())?
+    };
 
     let row_ids = env
         .new_long_array(result.row_ids.len() as i32)
@@ -287,6 +317,14 @@ fn validate_search_limit(limit: jint) -> Result<usize, 
String> {
     Ok(limit as usize)
 }
 
+fn read_byte_array(env: &mut JNIEnv, array: JByteArray) -> Result<Vec<u8>, 
String> {
+    if array.as_raw().is_null() {
+        return Err("roaringFilter is null".to_string());
+    }
+    env.convert_byte_array(array)
+        .map_err(|e| format!("failed to read roaringFilter: {e}"))
+}
+
 fn options_from_arrays(
     env: &mut JNIEnv,
     keys: JObjectArray,
diff --git a/python/paimon_ftindex/_ffi.py b/python/paimon_ftindex/_ffi.py
index 1bb025d..af04a6c 100644
--- a/python/paimon_ftindex/_ffi.py
+++ b/python/paimon_ftindex/_ffi.py
@@ -39,9 +39,13 @@ def _load_library():
     candidates = [
         os.path.join(pkg_dir, lib_name),
         os.path.join(pkg_dir, "..", "..", "target", "debug", lib_name),
+        os.path.join(pkg_dir, "..", "..", "target", "debug", "deps", lib_name),
         os.path.join(pkg_dir, "..", "..", "target", "release", lib_name),
+        os.path.join(pkg_dir, "..", "..", "target", "release", "deps", 
lib_name),
         os.path.join(pkg_dir, "..", "..", "..", "target", "debug", lib_name),
+        os.path.join(pkg_dir, "..", "..", "..", "target", "debug", "deps", 
lib_name),
         os.path.join(pkg_dir, "..", "..", "..", "target", "release", lib_name),
+        os.path.join(pkg_dir, "..", "..", "..", "target", "release", "deps", 
lib_name),
     ]
     for candidate in candidates:
         candidate = os.path.abspath(candidate)
@@ -101,6 +105,19 @@ lib.paimon_ftindex_reader_search_json.argtypes = [
 ]
 lib.paimon_ftindex_reader_search_json.restype = c_int
 
+lib.paimon_ftindex_reader_search_json_with_roaring_filter.argtypes = [
+    c_void_p,
+    c_char_p,
+    c_size_t,
+    POINTER(c_uint8),
+    c_size_t,
+    POINTER(c_int64),
+    POINTER(c_float),
+    c_size_t,
+    POINTER(c_size_t),
+]
+lib.paimon_ftindex_reader_search_json_with_roaring_filter.restype = c_int
+
 lib.paimon_ftindex_reader_free.argtypes = [c_void_p]
 lib.paimon_ftindex_reader_free.restype = None
 
diff --git a/python/paimon_ftindex/reader.py b/python/paimon_ftindex/reader.py
index 5cc102b..645d583 100644
--- a/python/paimon_ftindex/reader.py
+++ b/python/paimon_ftindex/reader.py
@@ -1,5 +1,5 @@
 import ctypes
-from ctypes import c_float, c_int64, c_size_t, c_void_p
+from ctypes import c_float, c_int64, c_size_t, c_uint8, c_void_p
 
 from ._ffi import (
     READ_AT_FN,
@@ -30,7 +30,7 @@ class FullTextIndexReader:
         native_input = PaimonFtindexInputFile(c_void_p(0), read_at_fn)
         self._ptr = check_ptr(lib.paimon_ftindex_reader_open(native_input))
 
-    def search(self, query, limit=10):
+    def search(self, query, limit=10, filter_bytes=None):
         if self._closed:
             raise RuntimeError("FullTextIndexReader is closed")
         query_json = query.to_json() if hasattr(query, "to_json") else 
str(query)
@@ -38,8 +38,8 @@ class FullTextIndexReader:
         row_ids = (c_int64 * capacity)()
         scores = (c_float * capacity)()
         result_len = c_size_t()
-        check_status(
-            lib.paimon_ftindex_reader_search_json(
+        if filter_bytes is None:
+            status = lib.paimon_ftindex_reader_search_json(
                 self._ptr,
                 query_json.encode("utf-8"),
                 capacity,
@@ -48,7 +48,21 @@ class FullTextIndexReader:
                 capacity,
                 ctypes.byref(result_len),
             )
-        )
+        else:
+            filter_bytes = bytes(filter_bytes)
+            filter_buf = (c_uint8 * 
len(filter_bytes)).from_buffer_copy(filter_bytes)
+            status = lib.paimon_ftindex_reader_search_json_with_roaring_filter(
+                self._ptr,
+                query_json.encode("utf-8"),
+                capacity,
+                filter_buf,
+                len(filter_bytes),
+                row_ids,
+                scores,
+                capacity,
+                ctypes.byref(result_len),
+            )
+        check_status(status)
         size = result_len.value
         return list(row_ids[:size]), list(scores[:size])
 
diff --git a/python/tests/test_roundtrip.py b/python/tests/test_roundtrip.py
index 2d3e667..b6a98ca 100644
--- a/python/tests/test_roundtrip.py
+++ b/python/tests/test_roundtrip.py
@@ -1,4 +1,5 @@
 from io import BytesIO
+import struct
 
 from paimon_ftindex import FullTextIndexReader, FullTextIndexWriter, MatchQuery
 
@@ -23,3 +24,59 @@ def test_python_round_trip():
 
     assert row_ids == [1]
     assert scores[0] > 0
+
+
+def test_python_search_with_roaring_filter():
+    output = BytesIO()
+    allowed_id = (1 << 33) + 2
+    with FullTextIndexWriter() as writer:
+        writer.add_document(1, "Apache Paimon full text")
+        writer.add_document(allowed_id, "Paimon filtered row")
+        writer.write(output)
+
+    filter_bytes = _roaring_treemap_bytes([allowed_id])
+    with FullTextIndexReader(BytesInput(output.getvalue())) as reader:
+        row_ids, scores = reader.search(
+            MatchQuery("paimon"), limit=10, filter_bytes=filter_bytes
+        )
+
+    assert row_ids == [allowed_id]
+    assert scores[0] > 0
+
+
+def _roaring_treemap_bytes(ids):
+    bitmaps = {}
+    for value in sorted(set(ids)):
+        high = value >> 32
+        low = value & 0xFFFFFFFF
+        bitmaps.setdefault(high, []).append(low)
+
+    out = bytearray(struct.pack("<Q", len(bitmaps)))
+    for high, values in bitmaps.items():
+        out.extend(struct.pack("<I", high))
+        out.extend(_roaring_bitmap_bytes(values))
+    return bytes(out)
+
+
+def _roaring_bitmap_bytes(values):
+    containers = {}
+    for value in values:
+        key = value >> 16
+        low = value & 0xFFFF
+        containers.setdefault(key, []).append(low)
+
+    out = bytearray(struct.pack("<II", 12346, len(containers)))
+    descriptions = bytearray()
+    offsets = bytearray()
+    payload = bytearray()
+    offset = 8 + len(containers) * 8
+    for key, lows in containers.items():
+        lows = sorted(set(lows))
+        if len(lows) > 4096:
+            raise ValueError("test helper only supports array containers")
+        descriptions.extend(struct.pack("<HH", key, len(lows) - 1))
+        offsets.extend(struct.pack("<I", offset))
+        values_bytes = struct.pack("<" + "H" * len(lows), *lows)
+        payload.extend(values_bytes)
+        offset += len(values_bytes)
+    return bytes(out + descriptions + offsets + payload)


Reply via email to