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

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


The following commit(s) were added to refs/heads/main by this push:
     new 5c02f0a  Support Java-compatible bitmap global index (#478)
5c02f0a is described below

commit 5c02f0a196a006014d7e764234416d207e3c3198
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jul 8 09:01:25 2026 +0800

    Support Java-compatible bitmap global index (#478)
---
 crates/integrations/datafusion/src/procedures.rs   |  19 +-
 crates/integrations/datafusion/tests/procedures.rs | 114 ++-
 crates/paimon/src/btree/key_serde.rs               | 133 ++-
 crates/paimon/src/btree/meta.rs                    |  57 +-
 crates/paimon/src/btree/mod.rs                     |   2 +-
 crates/paimon/src/btree/query.rs                   |  70 +-
 crates/paimon/src/btree/reader.rs                  |  30 +
 crates/paimon/src/btree/tests.rs                   |  55 ++
 crates/paimon/src/spec/core_options.rs             |  97 +++
 crates/paimon/src/spec/mod.rs                      |   1 +
 crates/paimon/src/spec/predicate.rs                |   2 +-
 .../paimon/src/table/bitmap_global_index_reader.rs | 892 +++++++++++++++++++++
 .../src/table/btree_global_index_build_builder.rs  | 219 ++++-
 .../src/table/btree_global_index_drop_builder.rs   |  34 +-
 crates/paimon/src/table/global_index_scanner.rs    | 866 ++++++++++++++++++--
 crates/paimon/src/table/global_index_types.rs      |  29 +
 crates/paimon/src/table/mod.rs                     |   2 +
 crates/paimon/src/table/table_scan.rs              |   6 +
 .../testdata/bitmap/bitmap_varchar_java.index      | Bin 0 -> 455 bytes
 .../testdata/bitmap/bitmap_varchar_java.index.meta | Bin 0 -> 22 bytes
 docs/src/sql.md                                    |  20 +-
 21 files changed, 2505 insertions(+), 143 deletions(-)

diff --git a/crates/integrations/datafusion/src/procedures.rs 
b/crates/integrations/datafusion/src/procedures.rs
index 0778839..76f3660 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -24,8 +24,10 @@
 //! - `CALL sys.rollback_to_timestamp(table => '...', timestamp => ...)`
 //! - `CALL sys.create_tag_from_timestamp(table => '...', tag => '...', 
timestamp => ...)`
 //! - `CALL sys.create_global_index(table => '...', index_column => '...', 
index_type => 'btree')`
+//! - `CALL sys.create_global_index(table => '...', index_column => '...', 
index_type => 'bitmap')`
 //! - `CALL sys.create_global_index(table => '...', index_column => '...', 
index_type => 'ivf-pq')`
 //! - `CALL sys.drop_global_index(table => '...', index_column => '...', 
index_type => 'btree')`
+//! - `CALL sys.drop_global_index(table => '...', index_column => '...', 
index_type => 'bitmap')`
 //! - `CALL sys.create_lumina_index(table => '...', index_column => '...')`
 
 use std::collections::HashMap;
@@ -545,15 +547,17 @@ async fn proc_create_global_index(
         .get("index_type")
         .map(String::as_str)
         .unwrap_or("btree");
-    if index_type.eq_ignore_ascii_case("btree") {
+    if is_sorted_global_index_type(index_type) {
         if args.contains_key("options") {
             return Err(DataFusionError::NotImplemented(
-                "create_global_index options are not supported for btree 
yet".to_string(),
+                "create_global_index options are not supported for sorted 
global indexes yet"
+                    .to_string(),
             ));
         }
 
         let mut builder = table.new_btree_global_index_build_builder();
         builder.with_index_column(index_column);
+        builder.with_index_type(index_type);
         builder.execute().await.map_err(to_datafusion_error)?;
     } else if is_vindex_index_type(index_type) {
         let mut builder = table.new_vindex_index_build_builder(index_type);
@@ -564,7 +568,7 @@ async fn proc_create_global_index(
         builder.execute().await.map_err(to_datafusion_error)?;
     } else {
         return Err(DataFusionError::NotImplemented(format!(
-            "create_global_index only supports index_type => 'btree' or vindex 
types \
+            "create_global_index only supports index_type => 'btree', 
'bitmap', or vindex types \
              ('ivf-flat', 'ivf-pq', 'ivf-hnsw-flat', 'ivf-hnsw-sq'), got 
'{index_type}'"
         )));
     }
@@ -583,9 +587,9 @@ async fn proc_drop_global_index(
         .get("index_type")
         .map(String::as_str)
         .unwrap_or("btree");
-    if !index_type.eq_ignore_ascii_case("btree") {
+    if !is_sorted_global_index_type(index_type) {
         return Err(DataFusionError::NotImplemented(format!(
-            "drop_global_index only supports index_type => 'btree', got 
'{index_type}'"
+            "drop_global_index only supports index_type => 'btree' or 
'bitmap', got '{index_type}'"
         )));
     }
     if args.contains_key("partitions") {
@@ -601,10 +605,15 @@ async fn proc_drop_global_index(
 
     let mut builder = table.new_btree_global_index_drop_builder();
     builder.with_index_column(index_column);
+    builder.with_index_type(index_type);
     builder.execute().await.map_err(to_datafusion_error)?;
     ok_result(ctx)
 }
 
+fn is_sorted_global_index_type(index_type: &str) -> bool {
+    index_type.eq_ignore_ascii_case("btree") || 
index_type.eq_ignore_ascii_case("bitmap")
+}
+
 fn parse_key_value_options(options: &str) -> DFResult<HashMap<String, String>> 
{
     let mut parsed = HashMap::new();
     for entry in options.split(',').map(str::trim).filter(|s| !s.is_empty()) {
diff --git a/crates/integrations/datafusion/tests/procedures.rs 
b/crates/integrations/datafusion/tests/procedures.rs
index 9e6a256..15ac8d1 100644
--- a/crates/integrations/datafusion/tests/procedures.rs
+++ b/crates/integrations/datafusion/tests/procedures.rs
@@ -153,13 +153,13 @@ async fn test_create_global_index_requires_index_column() 
{
 }
 
 #[tokio::test]
-async fn test_create_global_index_rejects_non_btree() {
-    let (_tmp, sql_context) = 
setup_btree_global_index_table("btree_bad_type").await;
+async fn test_create_global_index_rejects_unsupported_index_type() {
+    let (_tmp, sql_context) = 
setup_btree_global_index_table("global_index_bad_type").await;
 
     assert_sql_error(
         &sql_context,
-        "CALL sys.create_global_index(table => 'test_db.btree_bad_type', 
index_column => 'id', index_type => 'bitmap')",
-        "only supports index_type => 'btree'",
+        "CALL sys.create_global_index(table => 
'test_db.global_index_bad_type', index_column => 'id', index_type => 
'full-text')",
+        "only supports index_type => 'btree', 'bitmap'",
     )
     .await;
 }
@@ -208,6 +208,75 @@ async fn 
test_create_global_index_builds_btree_and_filter_reads() {
     assert_eq!(rows, vec![(2, "bob".to_string())]);
 }
 
+#[tokio::test]
+async fn test_create_global_index_btree_string_fallback_scan_reads() {
+    let (_tmp, sql_context) = 
setup_btree_global_index_table("btree_string_fallback").await;
+    exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.btree_string_fallback (id, name) VALUES \
+         (1, 'alice'), (2, 'alpine'), (3, 'bob'), (4, 'carol'), (5, 'malice')",
+    )
+    .await;
+
+    exec(
+        &sql_context,
+        "CALL sys.create_global_index(table => 
'test_db.btree_string_fallback', index_column => 'name', index_type => 
'btree')",
+    )
+    .await;
+
+    let rows = collect_id_name(
+        &sql_context,
+        "SELECT id, name FROM paimon.test_db.btree_string_fallback WHERE name 
LIKE 'a%c_'",
+    )
+    .await;
+    assert_eq!(rows, vec![(1, "alice".to_string())]);
+}
+
+#[tokio::test]
+async fn test_create_global_index_builds_bitmap_with_java_format() {
+    let (_tmp, sql_context) = 
setup_btree_global_index_table("bitmap_build").await;
+    exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.bitmap_build (id, name) VALUES (1, 
'alice'), (2, 'bob'), (3, 'alice')",
+    )
+    .await;
+
+    exec(
+        &sql_context,
+        "CALL sys.create_global_index(table => 'test_db.bitmap_build', 
index_column => 'name', index_type => 'bitmap')",
+    )
+    .await;
+
+    let index_count = row_count(
+        &sql_context,
+        "SELECT * FROM paimon.test_db.`bitmap_build$table_indexes` \
+         WHERE index_type = 'bitmap' AND row_range_start = 0 AND row_range_end 
= 2 \
+         AND index_field_name = 'name'",
+    )
+    .await;
+    assert_eq!(index_count, 1);
+
+    let rows = collect_id_name(
+        &sql_context,
+        "SELECT id, name FROM paimon.test_db.bitmap_build WHERE name = 
'alice'",
+    )
+    .await;
+    assert_eq!(
+        rows,
+        vec![(1, "alice".to_string()), (3, "alice".to_string())]
+    );
+
+    let contains_rows = collect_id_name(
+        &sql_context,
+        "SELECT id, name FROM paimon.test_db.bitmap_build WHERE name LIKE 
'%lic%'",
+    )
+    .await;
+    assert_eq!(
+        contains_rows,
+        vec![(1, "alice".to_string()), (3, "alice".to_string())]
+    );
+}
+
 #[tokio::test]
 async fn test_drop_global_index_removes_btree_and_reads_fallback() {
     let (_tmp, sql_context) = 
setup_btree_global_index_table("btree_drop").await;
@@ -256,6 +325,35 @@ async fn 
test_drop_global_index_removes_btree_and_reads_fallback() {
     assert_eq!(rows, vec![(2, "bob".to_string()), (3, "carol".to_string())]);
 }
 
+#[tokio::test]
+async fn test_drop_global_index_removes_bitmap() {
+    let (_tmp, sql_context) = 
setup_btree_global_index_table("bitmap_drop").await;
+    exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.bitmap_drop (id, name) VALUES (1, 
'alice'), (2, 'bob')",
+    )
+    .await;
+    exec(
+        &sql_context,
+        "CALL sys.create_global_index(table => 'test_db.bitmap_drop', 
index_column => 'name', index_type => 'bitmap')",
+    )
+    .await;
+
+    exec(
+        &sql_context,
+        "CALL sys.drop_global_index(table => 'test_db.bitmap_drop', 
index_column => 'name', index_type => 'bitmap')",
+    )
+    .await;
+
+    let index_count = row_count(
+        &sql_context,
+        "SELECT * FROM paimon.test_db.`bitmap_drop$table_indexes` \
+         WHERE index_type = 'bitmap' AND index_field_name = 'name'",
+    )
+    .await;
+    assert_eq!(index_count, 0);
+}
+
 #[tokio::test]
 async fn test_drop_global_index_requires_index_column() {
     let (_tmp, sql_context) = 
setup_btree_global_index_table("btree_drop_missing_col").await;
@@ -269,13 +367,13 @@ async fn test_drop_global_index_requires_index_column() {
 }
 
 #[tokio::test]
-async fn test_drop_global_index_rejects_non_btree() {
-    let (_tmp, sql_context) = 
setup_btree_global_index_table("btree_drop_bad_type").await;
+async fn test_drop_global_index_rejects_unsupported_index_type() {
+    let (_tmp, sql_context) = 
setup_btree_global_index_table("global_index_drop_bad_type").await;
 
     assert_sql_error(
         &sql_context,
-        "CALL sys.drop_global_index(table => 'test_db.btree_drop_bad_type', 
index_column => 'id', index_type => 'bitmap')",
-        "only supports index_type => 'btree'",
+        "CALL sys.drop_global_index(table => 
'test_db.global_index_drop_bad_type', index_column => 'id', index_type => 
'full-text')",
+        "only supports index_type => 'btree' or 'bitmap'",
     )
     .await;
 }
diff --git a/crates/paimon/src/btree/key_serde.rs 
b/crates/paimon/src/btree/key_serde.rs
index 22d7254..d1b7834 100644
--- a/crates/paimon/src/btree/key_serde.rs
+++ b/crates/paimon/src/btree/key_serde.rs
@@ -84,15 +84,9 @@ pub fn make_key_comparator(data_type: &DataType) -> 
KeyComparator {
             })
         }
         DataType::Decimal(d) if d.precision() > DECIMAL_COMPACT_PRECISION => {
-            // Non-compact: raw unscaled bytes, compare as big-endian signed
+            // Non-compact Decimal keys use Java BigInteger.toByteArray() 
bytes.
             Box::new(|a: &[u8], b: &[u8]| {
-                let a_neg = !a.is_empty() && (a[0] & 0x80) != 0;
-                let b_neg = !b.is_empty() && (b[0] & 0x80) != 0;
-                match (a_neg, b_neg) {
-                    (true, false) => Ordering::Less,
-                    (false, true) => Ordering::Greater,
-                    _ => a.cmp(b),
-                }
+                
decode_java_big_integer_i128(a).cmp(&decode_java_big_integer_i128(b))
             })
         }
         // Compact Timestamp/LocalZonedTimestamp (precision <= 3): millis as 
i64 LE
@@ -142,10 +136,14 @@ pub fn serialize_datum(datum: &Datum, data_type: 
&DataType) -> Vec<u8> {
             precision,
             ..
         } => {
-            if *precision <= DECIMAL_COMPACT_PRECISION {
+            let key_precision = match data_type {
+                DataType::Decimal(decimal_type) => decimal_type.precision(),
+                _ => *precision,
+            };
+            if key_precision <= DECIMAL_COMPACT_PRECISION {
                 (*unscaled as i64).to_le_bytes().to_vec()
             } else {
-                unscaled.to_be_bytes().to_vec()
+                encode_java_big_integer_i128(*unscaled)
             }
         }
         Datum::Bytes(v) => v.clone(),
@@ -160,3 +158,118 @@ pub fn serialize_datum(datum: &Datum, data_type: 
&DataType) -> Vec<u8> {
         }
     }
 }
+
+fn encode_java_big_integer_i128(value: i128) -> Vec<u8> {
+    let bytes = value.to_be_bytes();
+    let mut start = 0;
+    while start < bytes.len() - 1 {
+        let current = bytes[start];
+        let next = bytes[start + 1];
+        if (current == 0x00 && next & 0x80 == 0) || (current == 0xff && next & 
0x80 != 0) {
+            start += 1;
+        } else {
+            break;
+        }
+    }
+    bytes[start..].to_vec()
+}
+
+fn decode_java_big_integer_i128(bytes: &[u8]) -> i128 {
+    if bytes.is_empty() {
+        return 0;
+    }
+    let negative = bytes[0] & 0x80 != 0;
+    let mut value = if negative { -1 } else { 0 };
+    for &byte in bytes {
+        value = (value << 8) | i128::from(byte);
+    }
+    value
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::{DecimalType, IntType};
+
+    #[test]
+    fn test_serialize_non_compact_decimal_matches_java_big_integer_bytes() {
+        let data_type = DataType::Decimal(DecimalType::new(20, 0).unwrap());
+
+        assert_eq!(
+            serialize_datum(
+                &Datum::Decimal {
+                    unscaled: 127,
+                    precision: 20,
+                    scale: 0,
+                },
+                &data_type,
+            ),
+            vec![0x7f]
+        );
+        assert_eq!(
+            serialize_datum(
+                &Datum::Decimal {
+                    unscaled: 128,
+                    precision: 20,
+                    scale: 0,
+                },
+                &data_type,
+            ),
+            vec![0x00, 0x80]
+        );
+        assert_eq!(
+            serialize_datum(
+                &Datum::Decimal {
+                    unscaled: -129,
+                    precision: 20,
+                    scale: 0,
+                },
+                &data_type,
+            ),
+            vec![0xff, 0x7f]
+        );
+    }
+
+    #[test]
+    fn 
test_decimal_key_serialization_uses_column_precision_not_literal_precision() {
+        let compact_column = DataType::Decimal(DecimalType::new(10, 
0).unwrap());
+        let non_compact_column = DataType::Decimal(DecimalType::new(20, 
0).unwrap());
+
+        let compact_column_key = serialize_datum(
+            &Datum::Decimal {
+                unscaled: 128,
+                precision: 20,
+                scale: 0,
+            },
+            &compact_column,
+        );
+        assert_eq!(compact_column_key, 128i64.to_le_bytes());
+
+        let non_compact_column_key = serialize_datum(
+            &Datum::Decimal {
+                unscaled: 128,
+                precision: 10,
+                scale: 0,
+            },
+            &non_compact_column,
+        );
+        assert_eq!(non_compact_column_key, vec![0x00, 0x80]);
+    }
+
+    #[test]
+    fn test_compare_non_compact_decimal_uses_numeric_order() {
+        let cmp = make_key_comparator(&DataType::Decimal(DecimalType::new(20, 
0).unwrap()));
+        let key_127 = encode_java_big_integer_i128(127);
+        let key_128 = encode_java_big_integer_i128(128);
+        let key_minus_129 = encode_java_big_integer_i128(-129);
+
+        assert_eq!(cmp(&key_127, &key_128), Ordering::Less);
+        assert_eq!(cmp(&key_minus_129, &key_127), Ordering::Less);
+    }
+
+    #[test]
+    fn test_compact_numeric_still_uses_little_endian() {
+        let key = serialize_datum(&Datum::Int(42), 
&DataType::Int(IntType::new()));
+        assert_eq!(key, 42i32.to_le_bytes());
+    }
+}
diff --git a/crates/paimon/src/btree/meta.rs b/crates/paimon/src/btree/meta.rs
index a7ae174..ea511ea 100644
--- a/crates/paimon/src/btree/meta.rs
+++ b/crates/paimon/src/btree/meta.rs
@@ -19,14 +19,19 @@
 //!
 //! Serialization format (little-endian):
 //! ```text
-//! | first_key_length (4) | first_key_bytes | last_key_length (4) | 
last_key_bytes | has_nulls (1) |
+//! | first_key_length (4) | first_key_bytes | last_key_length (4) | 
last_key_bytes |
+//! | has_nulls (1) | format_version (1) | null_key_flags (1) |
 //! ```
-//! If first_key or last_key is null, their length is written as 0.
+//! Null key flags distinguish empty serialized keys from absent keys.
 
 use crate::spec::PredicateOperator;
 use std::cmp::Ordering;
 use std::io;
 
+const FORMAT_VERSION_WITH_NULL_FLAGS: u8 = 1;
+const FIRST_KEY_IS_NULL: u8 = 1;
+const LAST_KEY_IS_NULL: u8 = 1 << 1;
+
 /// Index meta for each BTree index file.
 #[derive(Debug, Clone)]
 pub struct BTreeIndexMeta {
@@ -111,11 +116,12 @@ impl BTreeIndexMeta {
         cmp(first_key, to_key) != Ordering::Greater && cmp(last_key, from_key) 
!= Ordering::Less
     }
 
-    /// Serialize to bytes (compatible with Java BTreeIndexMeta.serialize()).
+    /// Serialize to bytes (compatible with Java 
SortedIndexFileMeta.serialize()).
     pub fn serialize(&self) -> Vec<u8> {
         let fk_len = self.first_key.as_ref().map_or(0, |k| k.len());
         let lk_len = self.last_key.as_ref().map_or(0, |k| k.len());
-        let mut buf = Vec::with_capacity(fk_len + lk_len + 9);
+        let mut buf = Vec::with_capacity(fk_len + lk_len + 11);
+        let mut null_key_flags = 0u8;
 
         // first key
         match &self.first_key {
@@ -125,6 +131,7 @@ impl BTreeIndexMeta {
             }
             None => {
                 buf.extend_from_slice(&0i32.to_le_bytes());
+                null_key_flags |= FIRST_KEY_IS_NULL;
             }
         }
 
@@ -136,16 +143,19 @@ impl BTreeIndexMeta {
             }
             None => {
                 buf.extend_from_slice(&0i32.to_le_bytes());
+                null_key_flags |= LAST_KEY_IS_NULL;
             }
         }
 
         // has_nulls
         buf.push(if self.has_nulls { 1 } else { 0 });
+        buf.push(FORMAT_VERSION_WITH_NULL_FLAGS);
+        buf.push(null_key_flags);
 
         buf
     }
 
-    /// Deserialize from bytes (compatible with Java 
BTreeIndexMeta.deserialize()).
+    /// Deserialize from bytes (compatible with Java 
SortedIndexFileMeta.deserialize()).
     pub fn deserialize(data: &[u8]) -> io::Result<Self> {
         if data.len() < 9 {
             return Err(io::Error::new(
@@ -158,9 +168,7 @@ impl BTreeIndexMeta {
 
         let fk_len = i32::from_le_bytes(data[pos..pos + 
4].try_into().unwrap()) as usize;
         pos += 4;
-        let first_key = if fk_len == 0 {
-            None
-        } else {
+        let mut first_key = {
             let key = data[pos..pos + fk_len].to_vec();
             pos += fk_len;
             Some(key)
@@ -168,15 +176,31 @@ impl BTreeIndexMeta {
 
         let lk_len = i32::from_le_bytes(data[pos..pos + 
4].try_into().unwrap()) as usize;
         pos += 4;
-        let last_key = if lk_len == 0 {
-            None
-        } else {
+        let mut last_key = {
             let key = data[pos..pos + lk_len].to_vec();
             pos += lk_len;
             Some(key)
         };
 
         let has_nulls = data[pos] == 1;
+        pos += 1;
+
+        if data.len().saturating_sub(pos) >= 2 {
+            let format_version = data[pos];
+            pos += 1;
+            if format_version == FORMAT_VERSION_WITH_NULL_FLAGS {
+                let null_key_flags = data[pos];
+                if null_key_flags & FIRST_KEY_IS_NULL != 0 {
+                    first_key = None;
+                }
+                if null_key_flags & LAST_KEY_IS_NULL != 0 {
+                    last_key = None;
+                }
+            }
+        } else if fk_len == 0 && lk_len == 0 && has_nulls {
+            first_key = None;
+            last_key = None;
+        }
 
         Ok(Self {
             first_key,
@@ -210,6 +234,17 @@ mod tests {
         assert!(decoded.has_nulls);
     }
 
+    #[test]
+    fn test_meta_empty_string_keys_are_not_null() {
+        let meta = BTreeIndexMeta::new(Some(Vec::new()), Some(Vec::new()), 
false);
+        let encoded = meta.serialize();
+        let decoded = BTreeIndexMeta::deserialize(&encoded).unwrap();
+        assert_eq!(decoded.first_key, Some(Vec::new()));
+        assert_eq!(decoded.last_key, Some(Vec::new()));
+        assert!(!decoded.only_nulls());
+        assert!(!decoded.has_nulls);
+    }
+
     #[test]
     fn test_meta_no_nulls() {
         let meta = BTreeIndexMeta::new(Some(b"key1".to_vec()), 
Some(b"key2".to_vec()), false);
diff --git a/crates/paimon/src/btree/mod.rs b/crates/paimon/src/btree/mod.rs
index ca68faa..0371013 100644
--- a/crates/paimon/src/btree/mod.rs
+++ b/crates/paimon/src/btree/mod.rs
@@ -43,7 +43,7 @@ mod meta;
 pub(crate) mod query;
 mod reader;
 mod sst_file;
-mod var_len;
+pub(crate) mod var_len;
 mod writer;
 
 pub use block::BlockCompressionType;
diff --git a/crates/paimon/src/btree/query.rs b/crates/paimon/src/btree/query.rs
index 0aed9d4..df4c275 100644
--- a/crates/paimon/src/btree/query.rs
+++ b/crates/paimon/src/btree/query.rs
@@ -22,7 +22,7 @@
 
 use crate::btree::key_serde::serialize_datum;
 use crate::btree::reader::BTreeIndexReader;
-use crate::spec::{DataType, Datum, PredicateOperator};
+use crate::spec::{like_match, DataType, Datum, PredicateOperator};
 use roaring::RoaringTreemap;
 use std::cmp::Ordering;
 use std::io;
@@ -109,17 +109,71 @@ where
                 all_non_null -= inside;
                 Ok(all_non_null)
             }
-            PredicateOperator::StartsWith
-            | PredicateOperator::EndsWith
-            | PredicateOperator::Contains
-            | PredicateOperator::Like => Err(io::Error::new(
-                io::ErrorKind::Unsupported,
-                format!("BTree index does not support op: {op}"),
-            )),
+            PredicateOperator::StartsWith => {
+                let key = serialize_datum(&literals[0], data_type);
+                self.query_prefix(&key).await
+            }
+            PredicateOperator::EndsWith => {
+                ensure_character_string(data_type, op)?;
+                let suffix = serialize_datum(&literals[0], data_type);
+                if suffix.is_empty() {
+                    return self.all_non_null_rows().await;
+                }
+                self.scan_entries(move |key| key.ends_with(&suffix)).await
+            }
+            PredicateOperator::Contains => {
+                ensure_character_string(data_type, op)?;
+                let needle = serialize_datum(&literals[0], data_type);
+                if needle.is_empty() {
+                    return self.all_non_null_rows().await;
+                }
+                self.scan_entries(move |key| contains_bytes(key, &needle))
+                    .await
+            }
+            PredicateOperator::Like => {
+                ensure_character_string(data_type, op)?;
+                let pattern = string_literal(literals, op)?.to_string();
+                self.scan_entries(move |key| {
+                    std::str::from_utf8(key).is_ok_and(|value| 
like_match(value, &pattern))
+                })
+                .await
+            }
         }
     }
 }
 
+fn ensure_character_string(data_type: &DataType, op: PredicateOperator) -> 
io::Result<()> {
+    if matches!(data_type, DataType::Char(_) | DataType::VarChar(_)) {
+        Ok(())
+    } else {
+        Err(io::Error::new(
+            io::ErrorKind::Unsupported,
+            format!("BTree index {op} only supports string columns"),
+        ))
+    }
+}
+
+fn string_literal(literals: &[Datum], op: PredicateOperator) -> 
io::Result<&str> {
+    match literals.first() {
+        Some(Datum::String(value)) => Ok(value),
+        Some(other) => Err(io::Error::new(
+            io::ErrorKind::InvalidInput,
+            format!("BTree index {op} requires a string literal, got {other}"),
+        )),
+        None => Err(io::Error::new(
+            io::ErrorKind::InvalidInput,
+            format!("BTree index {op} requires one literal"),
+        )),
+    }
+}
+
+fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
+    !needle.is_empty()
+        && haystack
+            .windows(needle.len())
+            .any(|window| window == needle)
+}
+
 /// Detected between pattern from predicate pairs.
 pub(crate) struct BetweenInfo<'a> {
     pub from: &'a Datum,
diff --git a/crates/paimon/src/btree/reader.rs 
b/crates/paimon/src/btree/reader.rs
index 6de0c3a..f983633 100644
--- a/crates/paimon/src/btree/reader.rs
+++ b/crates/paimon/src/btree/reader.rs
@@ -113,6 +113,36 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> {
         .await
     }
 
+    /// Scan all non-null entries and collect row ids for keys matching 
`predicate`.
+    pub async fn scan_entries(
+        &self,
+        predicate: impl Fn(&[u8]) -> bool + Send + Sync,
+    ) -> io::Result<RoaringTreemap> {
+        let Some(min_key) = self.min_key.as_deref() else {
+            return Ok(RoaringTreemap::new());
+        };
+
+        let cmp = &self.key_comparator;
+        let index_block = self.sst_reader.index_block();
+        let (_, mut index_iter) = index_block.seek_and_iter(min_key, cmp);
+        let mut result = RoaringTreemap::new();
+
+        while let Some((_key, handle_bytes)) = index_iter.next() {
+            let handle = BlockHandle::decode(handle_bytes)?;
+            let block = self.read_data_block(&handle).await?;
+            let mut offset = 0;
+            while offset < block.data.len() {
+                let (key, value, next_offset) = block.read_entry_at(offset);
+                offset = next_offset;
+                if predicate(key) {
+                    insert_row_ids_into(value, &mut result)?;
+                }
+            }
+        }
+
+        Ok(result)
+    }
+
     /// Range query: returns a bitmap of all row ids whose keys fall in [from, 
to]
     /// with configurable inclusivity. Reads data blocks on demand.
     pub async fn range_query(
diff --git a/crates/paimon/src/btree/tests.rs b/crates/paimon/src/btree/tests.rs
index 0ef9687..9641508 100644
--- a/crates/paimon/src/btree/tests.rs
+++ b/crates/paimon/src/btree/tests.rs
@@ -17,9 +17,11 @@
 
 use crate::btree::block::BlockCompressionType;
 use crate::btree::meta::BTreeIndexMeta;
+use crate::btree::query::IndexQuery;
 use crate::btree::reader::BTreeIndexReader;
 use crate::btree::test_util::{BytesFileRead, VecFileWrite};
 use crate::btree::writer::BTreeIndexWriter;
+use crate::spec::{DataType, Datum, PredicateOperator, VarCharType};
 use bytes::Bytes;
 
 fn int_key(v: i32) -> Vec<u8> {
@@ -217,6 +219,59 @@ async fn test_prefix_query() {
     assert_eq!(bm.len(), keys.len() as u64);
 }
 
+#[tokio::test]
+async fn test_string_fallback_scan_query() {
+    let buf = VecFileWrite::new();
+    let mut writer = BTreeIndexWriter::new(Box::new(buf.clone()), 32, 
BlockCompressionType::None);
+
+    writer.write(None, 99).await.unwrap();
+    let keys = [
+        ("alice", 0),
+        ("alpine", 1),
+        ("bob", 2),
+        ("carol", 3),
+        ("malice", 4),
+        ("office", 5),
+    ];
+    for (key, row_id) in keys {
+        writer.write(Some(key.as_bytes()), row_id).await.unwrap();
+    }
+
+    let result = writer.finish().await.unwrap();
+    let reader = write_and_open(&buf, &result, |a: &[u8], b: &[u8]| 
a.cmp(b)).await;
+    let data_type = DataType::VarChar(VarCharType::string_type());
+
+    let ends_with = reader
+        .query(
+            PredicateOperator::EndsWith,
+            &[Datum::String("ice".to_string())],
+            &data_type,
+        )
+        .await
+        .unwrap();
+    assert_eq!(ends_with.iter().collect::<Vec<_>>(), vec![0, 4, 5]);
+
+    let contains = reader
+        .query(
+            PredicateOperator::Contains,
+            &[Datum::String("o".to_string())],
+            &data_type,
+        )
+        .await
+        .unwrap();
+    assert_eq!(contains.iter().collect::<Vec<_>>(), vec![2, 3, 5]);
+
+    let like = reader
+        .query(
+            PredicateOperator::Like,
+            &[Datum::String("a%c_".to_string())],
+            &data_type,
+        )
+        .await
+        .unwrap();
+    assert_eq!(like.iter().collect::<Vec<_>>(), vec![0]);
+}
+
 #[tokio::test]
 async fn test_not_equal_query() {
     let buf = VecFileWrite::new();
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index e7d0fa6..3d5d79b 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -25,6 +25,8 @@ const GLOBAL_INDEX_SEARCH_MODE_OPTION: &str = 
"global-index.search-mode";
 const GLOBAL_INDEX_ROW_COUNT_PER_SHARD_OPTION: &str = 
"global-index.row-count-per-shard";
 const GLOBAL_INDEX_COLUMN_UPDATE_ACTION_OPTION: &str = 
"global-index.column-update-action";
 const SORTED_INDEX_RECORDS_PER_RANGE_OPTION: &str = 
"sorted-index.records-per-range";
+const BTREE_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION: &str = 
"btree-index.fallback-scan-max-size";
+const BITMAP_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION: &str = 
"bitmap-index.fallback-scan-max-size";
 const SOURCE_SPLIT_TARGET_SIZE_OPTION: &str = "source.split.target-size";
 const SOURCE_SPLIT_OPEN_FILE_COST_OPTION: &str = "source.split.open-file-cost";
 const PARTITION_DEFAULT_NAME_OPTION: &str = "partition.default-name";
@@ -87,6 +89,7 @@ const DEFAULT_WRITE_PARQUET_BUFFER_SIZE: i64 = 256 * 1024 * 
1024;
 const DYNAMIC_BUCKET_TARGET_ROW_NUM_OPTION: &str = 
"dynamic-bucket.target-row-num";
 const DEFAULT_DYNAMIC_BUCKET_TARGET_ROW_NUM: i64 = 200_000;
 const DEFAULT_GLOBAL_INDEX_ROW_COUNT_PER_SHARD: i64 = 100_000;
+const DEFAULT_GLOBAL_INDEX_FALLBACK_SCAN_MAX_SIZE: i64 = 256 * 1024 * 1024;
 const BLOB_AS_DESCRIPTOR_OPTION: &str = "blob-as-descriptor";
 const BLOB_DESCRIPTOR_FIELD_OPTION: &str = "blob-descriptor-field";
 
@@ -434,6 +437,37 @@ impl<'a> CoreOptions<'a> {
         Ok(value)
     }
 
+    pub fn btree_index_fallback_scan_max_size(&self) -> crate::Result<i64> {
+        self.fallback_scan_max_size(BTREE_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION)
+    }
+
+    pub fn bitmap_index_fallback_scan_max_size(&self) -> crate::Result<i64> {
+        self.fallback_scan_max_size(BITMAP_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION)
+    }
+
+    fn fallback_scan_max_size(&self, option_name: &'static str) -> 
crate::Result<i64> {
+        let value = match self.options.get(option_name) {
+            Some(raw) => parse_memory_size(raw).ok_or_else(|| 
crate::Error::DataInvalid {
+                message: format!(
+                    "Option '{}' must be a valid memory size, got: {}",
+                    option_name, raw
+                ),
+                source: None,
+            })?,
+            None => DEFAULT_GLOBAL_INDEX_FALLBACK_SCAN_MAX_SIZE,
+        };
+        if value < 0 {
+            return Err(crate::Error::DataInvalid {
+                message: format!(
+                    "Option '{}' must be greater than or equal to 0, got: {}",
+                    option_name, value
+                ),
+                source: None,
+            });
+        }
+        Ok(value)
+    }
+
     pub fn global_index_column_update_action(
         &self,
     ) -> crate::Result<GlobalIndexColumnUpdateAction> {
@@ -836,6 +870,14 @@ mod tests {
             core_options.sorted_index_records_per_range().unwrap(),
             100_000
         );
+        assert_eq!(
+            core_options.btree_index_fallback_scan_max_size().unwrap(),
+            256 * 1024 * 1024
+        );
+        assert_eq!(
+            core_options.bitmap_index_fallback_scan_max_size().unwrap(),
+            256 * 1024 * 1024
+        );
         assert_eq!(
             core_options.global_index_column_update_action().unwrap(),
             GlobalIndexColumnUpdateAction::ThrowError
@@ -865,6 +907,14 @@ mod tests {
                 SORTED_INDEX_RECORDS_PER_RANGE_OPTION.to_string(),
                 "4096".to_string(),
             ),
+            (
+                BTREE_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION.to_string(),
+                "4 mb".to_string(),
+            ),
+            (
+                BITMAP_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION.to_string(),
+                "8 mb".to_string(),
+            ),
             (
                 GLOBAL_INDEX_COLUMN_UPDATE_ACTION_OPTION.to_string(),
                 "DROP_PARTITION_INDEX".to_string(),
@@ -883,6 +933,14 @@ mod tests {
             2048
         );
         assert_eq!(core_options.sorted_index_records_per_range().unwrap(), 
4096);
+        assert_eq!(
+            core_options.btree_index_fallback_scan_max_size().unwrap(),
+            4 * 1024 * 1024
+        );
+        assert_eq!(
+            core_options.bitmap_index_fallback_scan_max_size().unwrap(),
+            8 * 1024 * 1024
+        );
         assert_eq!(
             core_options.global_index_column_update_action().unwrap(),
             GlobalIndexColumnUpdateAction::DropPartitionIndex
@@ -955,6 +1013,45 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_global_index_fallback_scan_max_size_values() {
+        for option_name in [
+            BTREE_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION,
+            BITMAP_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION,
+        ] {
+            let options = HashMap::from([(option_name.to_string(), 
"0".to_string())]);
+            let core = CoreOptions::new(&options);
+            let value = match option_name {
+                BTREE_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION => {
+                    core.btree_index_fallback_scan_max_size()
+                }
+                BITMAP_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION => {
+                    core.bitmap_index_fallback_scan_max_size()
+                }
+                _ => unreachable!(),
+            };
+            assert_eq!(value.unwrap(), 0);
+
+            for value in ["-1", "abc"] {
+                let options = HashMap::from([(option_name.to_string(), 
value.to_string())]);
+                let core = CoreOptions::new(&options);
+
+                let err = match option_name {
+                    BTREE_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION => {
+                        core.btree_index_fallback_scan_max_size()
+                    }
+                    BITMAP_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION => {
+                        core.bitmap_index_fallback_scan_max_size()
+                    }
+                    _ => unreachable!(),
+                }
+                .expect_err("invalid fallback scan max size should fail");
+                assert!(matches!(err, crate::Error::DataInvalid { message, .. }
+                    if message.contains(option_name)));
+            }
+        }
+    }
+
     #[test]
     fn test_parse_memory_size() {
         assert_eq!(parse_memory_size("1024"), Some(1024));
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 12a0cc6..eb4dc54 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -94,6 +94,7 @@ pub(crate) use predicate::datum_cmp;
 pub(crate) use predicate::eval_row;
 pub(crate) use predicate::extract_datum;
 pub(crate) use predicate::java_bytes_cmp;
+pub(crate) use predicate::like_match;
 pub use predicate::{
     field_idx_to_partition_idx, Datum, Predicate, PredicateBuilder, 
PredicateOperator,
 };
diff --git a/crates/paimon/src/spec/predicate.rs 
b/crates/paimon/src/spec/predicate.rs
index d44463e..badabb8 100644
--- a/crates/paimon/src/spec/predicate.rs
+++ b/crates/paimon/src/spec/predicate.rs
@@ -1165,7 +1165,7 @@ fn optimize_like_pattern(pattern: &str) -> LikeShape {
 /// * `_` matches exactly one character,
 /// * `\X` matches the literal `X` for any character `X` (the backslash is
 ///   consumed); a trailing `\` matches a literal backslash.
-fn like_match(value: &str, pattern: &str) -> bool {
+pub(crate) fn like_match(value: &str, pattern: &str) -> bool {
     let value: Vec<char> = value.chars().collect();
     let pattern: Vec<char> = pattern.chars().collect();
     like_match_chars(&value, 0, &pattern, 0)
diff --git a/crates/paimon/src/table/bitmap_global_index_reader.rs 
b/crates/paimon/src/table/bitmap_global_index_reader.rs
new file mode 100644
index 0000000..0c6172b
--- /dev/null
+++ b/crates/paimon/src/table/bitmap_global_index_reader.rs
@@ -0,0 +1,892 @@
+// 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.
+
+//! Reader for Java Paimon's bitmap global index file format.
+//!
+//! Reference: `org.apache.paimon.globalindex.bitmap.BitmapGlobalIndexFormat`.
+
+use crate::btree::var_len::{decode_var_int, decode_var_long, encode_var_int, 
encode_var_long};
+use crate::btree::BTreeIndexMeta;
+use crate::btree::{make_key_comparator, serialize_datum, BlockCompressionType};
+use crate::io::{FileRead, FileWrite};
+use crate::spec::{like_match, DataType, Datum, PredicateOperator};
+use bytes::Bytes;
+use roaring::RoaringTreemap;
+use std::cmp::Ordering;
+use std::collections::BTreeMap;
+use std::io::Write;
+use std::io::{self, Cursor, Read};
+
+const MAGIC: i32 = 0x4247_4958;
+const VERSION: i32 = 1;
+const FOOTER_LENGTH: usize = 48;
+const BLOCK_TRAILER_LENGTH: usize = 5;
+
+#[derive(Clone, Copy)]
+struct BlockInfo {
+    offset: u64,
+    length: usize,
+}
+
+#[derive(Clone)]
+struct DictionaryBlockMeta {
+    first_key: Vec<u8>,
+    block: BlockInfo,
+}
+
+struct DictionaryEntry {
+    key: Vec<u8>,
+    bitmap_block: BlockInfo,
+}
+
+struct Footer {
+    null_rows_block: BlockInfo,
+    non_null_rows_block: BlockInfo,
+    index_block: BlockInfo,
+}
+
+/// Result of finishing a Java-compatible bitmap global index write.
+pub(crate) struct BitmapWriteResult {
+    pub(crate) meta: BTreeIndexMeta,
+    pub(crate) row_count: u64,
+}
+
+/// Writer for Java Paimon's `BitmapGlobalIndexFormat`.
+pub(crate) struct BitmapGlobalIndexWriter<F: Fn(&[u8], &[u8]) -> Ordering> {
+    writer: Box<dyn FileWrite>,
+    dictionary_block_size: usize,
+    compression_type: BlockCompressionType,
+    key_comparator: F,
+    bitmaps: BTreeMap<Vec<u8>, RoaringTreemap>,
+    null_rows: RoaringTreemap,
+    non_null_rows: RoaringTreemap,
+    first_key: Option<Vec<u8>>,
+    last_key: Option<Vec<u8>>,
+    row_count: u64,
+}
+
+impl<F: Fn(&[u8], &[u8]) -> Ordering> BitmapGlobalIndexWriter<F> {
+    pub(crate) fn new(
+        writer: Box<dyn FileWrite>,
+        dictionary_block_size: usize,
+        compression_type: BlockCompressionType,
+        key_comparator: F,
+    ) -> Self {
+        Self {
+            writer,
+            dictionary_block_size,
+            compression_type,
+            key_comparator,
+            bitmaps: BTreeMap::new(),
+            null_rows: RoaringTreemap::new(),
+            non_null_rows: RoaringTreemap::new(),
+            first_key: None,
+            last_key: None,
+            row_count: 0,
+        }
+    }
+
+    pub(crate) fn write(&mut self, key: Option<&[u8]>, relative_row_id: i64) 
-> io::Result<()> {
+        if relative_row_id < 0 {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidInput,
+                format!("Bitmap global index row id must be non-negative: 
{relative_row_id}"),
+            ));
+        }
+
+        self.row_count += 1;
+        match key {
+            Some(key) => {
+                let row_id = relative_row_id as u64;
+                self.non_null_rows.insert(row_id);
+                self.bitmaps.entry(key.to_vec()).or_default().insert(row_id);
+                self.update_min_max(key);
+            }
+            None => {
+                self.null_rows.insert(relative_row_id as u64);
+            }
+        }
+        Ok(())
+    }
+
+    pub(crate) async fn finish(mut self) -> io::Result<BitmapWriteResult> {
+        let mut bytes = Vec::new();
+        write_bitmap_index_bytes(
+            &mut bytes,
+            &self.null_rows,
+            &self.non_null_rows,
+            &self.bitmaps,
+            self.dictionary_block_size,
+            self.compression_type,
+        )?;
+        self.writer
+            .write(Bytes::from(bytes))
+            .await
+            .map_err(|e| io::Error::other(e.to_string()))?;
+        self.writer
+            .close()
+            .await
+            .map_err(|e| io::Error::other(e.to_string()))?;
+
+        Ok(BitmapWriteResult {
+            meta: BTreeIndexMeta::new(self.first_key, self.last_key, 
!self.null_rows.is_empty()),
+            row_count: self.row_count,
+        })
+    }
+
+    fn update_min_max(&mut self, key: &[u8]) {
+        if self
+            .first_key
+            .as_ref()
+            .is_none_or(|existing| (self.key_comparator)(key, 
existing).is_lt())
+        {
+            self.first_key = Some(key.to_vec());
+        }
+        if self
+            .last_key
+            .as_ref()
+            .is_none_or(|existing| (self.key_comparator)(key, 
existing).is_gt())
+        {
+            self.last_key = Some(key.to_vec());
+        }
+    }
+}
+
+pub(crate) struct BitmapGlobalIndexReader {
+    reader: Box<dyn FileRead>,
+    footer: Footer,
+    dictionary_blocks: Vec<DictionaryBlockMeta>,
+}
+
+impl BitmapGlobalIndexReader {
+    pub(crate) async fn open(reader: Box<dyn FileRead>, file_size: u64) -> 
io::Result<Self> {
+        let footer = read_footer(reader.as_ref(), file_size).await?;
+        let index_block = read_compressible_block(reader.as_ref(), 
footer.index_block).await?;
+        let dictionary_blocks = read_index_block(&index_block)?;
+        Ok(Self {
+            reader,
+            footer,
+            dictionary_blocks,
+        })
+    }
+
+    pub(crate) async fn query(
+        &self,
+        op: PredicateOperator,
+        literals: &[Datum],
+        data_type: &DataType,
+    ) -> io::Result<RoaringTreemap> {
+        match op {
+            PredicateOperator::Eq => {
+                let key = serialize_datum(&literals[0], data_type);
+                self.equal(&key).await
+            }
+            PredicateOperator::NotEq => {
+                let mut result = self.is_not_null().await?;
+                let key = serialize_datum(&literals[0], data_type);
+                result -= self.equal(&key).await?;
+                Ok(result)
+            }
+            PredicateOperator::In => {
+                let keys = literals
+                    .iter()
+                    .map(|literal| serialize_datum(literal, data_type))
+                    .collect::<Vec<_>>();
+                self.in_keys(&keys).await
+            }
+            PredicateOperator::NotIn => {
+                let mut result = self.is_not_null().await?;
+                let keys = literals
+                    .iter()
+                    .map(|literal| serialize_datum(literal, data_type))
+                    .collect::<Vec<_>>();
+                result -= self.in_keys(&keys).await?;
+                Ok(result)
+            }
+            PredicateOperator::IsNull => self.is_null().await,
+            PredicateOperator::IsNotNull => self.is_not_null().await,
+            PredicateOperator::Lt => {
+                let key = serialize_datum(&literals[0], data_type);
+                self.scan_dictionary(data_type, |candidate, cmp| 
cmp(candidate, &key).is_lt())
+                    .await
+            }
+            PredicateOperator::LtEq => {
+                let key = serialize_datum(&literals[0], data_type);
+                self.scan_dictionary(data_type, |candidate, cmp| 
!cmp(candidate, &key).is_gt())
+                    .await
+            }
+            PredicateOperator::Gt => {
+                let key = serialize_datum(&literals[0], data_type);
+                self.scan_dictionary(data_type, |candidate, cmp| 
cmp(candidate, &key).is_gt())
+                    .await
+            }
+            PredicateOperator::GtEq => {
+                let key = serialize_datum(&literals[0], data_type);
+                self.scan_dictionary(data_type, |candidate, cmp| 
!cmp(candidate, &key).is_lt())
+                    .await
+            }
+            PredicateOperator::Between => {
+                let from = serialize_datum(&literals[0], data_type);
+                let to = serialize_datum(&literals[1], data_type);
+                self.scan_dictionary(data_type, |candidate, cmp| {
+                    !cmp(candidate, &from).is_lt() && !cmp(candidate, 
&to).is_gt()
+                })
+                .await
+            }
+            PredicateOperator::NotBetween => {
+                let mut result = self.is_not_null().await?;
+                let from = serialize_datum(&literals[0], data_type);
+                let to = serialize_datum(&literals[1], data_type);
+                let inside = self
+                    .scan_dictionary(data_type, |candidate, cmp| {
+                        !cmp(candidate, &from).is_lt() && !cmp(candidate, 
&to).is_gt()
+                    })
+                    .await?;
+                result -= inside;
+                Ok(result)
+            }
+            PredicateOperator::StartsWith => {
+                if !is_character_string(data_type) {
+                    return Err(io::Error::new(
+                        io::ErrorKind::Unsupported,
+                        "Bitmap global index starts_with only supports string 
columns",
+                    ));
+                }
+                let prefix = serialize_datum(&literals[0], data_type);
+                if prefix.is_empty() {
+                    return self.is_not_null().await;
+                }
+                self.scan_serialized_dictionary(|candidate| 
candidate.starts_with(&prefix))
+                    .await
+            }
+            PredicateOperator::EndsWith => {
+                if !is_character_string(data_type) {
+                    return Err(io::Error::new(
+                        io::ErrorKind::Unsupported,
+                        "Bitmap global index ends_with only supports string 
columns",
+                    ));
+                }
+                let suffix = serialize_datum(&literals[0], data_type);
+                if suffix.is_empty() {
+                    return self.is_not_null().await;
+                }
+                self.scan_serialized_dictionary(|candidate| 
candidate.ends_with(&suffix))
+                    .await
+            }
+            PredicateOperator::Contains => {
+                if !is_character_string(data_type) {
+                    return Err(io::Error::new(
+                        io::ErrorKind::Unsupported,
+                        "Bitmap global index contains only supports string 
columns",
+                    ));
+                }
+                let needle = serialize_datum(&literals[0], data_type);
+                if needle.is_empty() {
+                    return self.is_not_null().await;
+                }
+                self.scan_serialized_dictionary(|candidate| 
contains_bytes(candidate, &needle))
+                    .await
+            }
+            PredicateOperator::Like => {
+                if !is_character_string(data_type) {
+                    return Err(io::Error::new(
+                        io::ErrorKind::Unsupported,
+                        "Bitmap global index like only supports string 
columns",
+                    ));
+                }
+                let pattern = string_literal(literals, op)?.to_string();
+                self.scan_serialized_dictionary(|candidate| {
+                    std::str::from_utf8(candidate).is_ok_and(|value| 
like_match(value, &pattern))
+                })
+                .await
+            }
+        }
+    }
+
+    pub(crate) async fn range_query(
+        &self,
+        from: &[u8],
+        to: &[u8],
+        data_type: &DataType,
+        from_inclusive: bool,
+        to_inclusive: bool,
+    ) -> io::Result<RoaringTreemap> {
+        self.scan_dictionary(data_type, |candidate, cmp| {
+            let from_cmp = cmp(candidate, from);
+            let to_cmp = cmp(candidate, to);
+            (from_cmp.is_gt() || (from_inclusive && from_cmp.is_eq()))
+                && (to_cmp.is_lt() || (to_inclusive && to_cmp.is_eq()))
+        })
+        .await
+    }
+
+    async fn is_null(&self) -> io::Result<RoaringTreemap> {
+        self.read_bitmap(self.footer.null_rows_block).await
+    }
+
+    async fn is_not_null(&self) -> io::Result<RoaringTreemap> {
+        self.read_bitmap(self.footer.non_null_rows_block).await
+    }
+
+    async fn equal(&self, key: &[u8]) -> io::Result<RoaringTreemap> {
+        match self.find_bitmap_block(key).await? {
+            Some(block) => self.read_bitmap(block).await,
+            None => Ok(RoaringTreemap::new()),
+        }
+    }
+
+    async fn in_keys(&self, keys: &[Vec<u8>]) -> io::Result<RoaringTreemap> {
+        let mut sorted_keys = keys.to_vec();
+        sorted_keys.sort();
+        sorted_keys.dedup();
+
+        let mut result = RoaringTreemap::new();
+        for key in sorted_keys {
+            if let Some(block) = self.find_bitmap_block(&key).await? {
+                result |= self.read_bitmap(block).await?;
+            }
+        }
+        Ok(result)
+    }
+
+    async fn scan_dictionary(
+        &self,
+        data_type: &DataType,
+        predicate: impl Fn(&[u8], &dyn Fn(&[u8], &[u8]) -> Ordering) -> bool,
+    ) -> io::Result<RoaringTreemap> {
+        let cmp = make_key_comparator(data_type);
+        self.scan_serialized_dictionary(|candidate| predicate(candidate, 
cmp.as_ref()))
+            .await
+    }
+
+    async fn scan_serialized_dictionary(
+        &self,
+        predicate: impl Fn(&[u8]) -> bool,
+    ) -> io::Result<RoaringTreemap> {
+        let mut result = RoaringTreemap::new();
+        for block_meta in &self.dictionary_blocks {
+            for entry in self.read_dictionary_block(block_meta.block).await? {
+                if predicate(&entry.key) {
+                    result |= self.read_bitmap(entry.bitmap_block).await?;
+                }
+            }
+        }
+        Ok(result)
+    }
+
+    async fn find_bitmap_block(&self, key: &[u8]) -> 
io::Result<Option<BlockInfo>> {
+        let Some(block_meta) = self.find_dictionary_block_meta(key) else {
+            return Ok(None);
+        };
+
+        for entry in self.read_dictionary_block(block_meta.block).await? {
+            match compare_unsigned(&entry.key, key) {
+                Ordering::Equal => return Ok(Some(entry.bitmap_block)),
+                Ordering::Greater => return Ok(None),
+                Ordering::Less => {}
+            }
+        }
+        Ok(None)
+    }
+
+    fn find_dictionary_block_meta(&self, key: &[u8]) -> 
Option<&DictionaryBlockMeta> {
+        if self.dictionary_blocks.is_empty() {
+            return None;
+        }
+        let mut low = 0usize;
+        let mut high = self.dictionary_blocks.len();
+        while low < high {
+            let mid = (low + high) / 2;
+            if compare_unsigned(&self.dictionary_blocks[mid].first_key, key) 
!= Ordering::Greater {
+                low = mid + 1;
+            } else {
+                high = mid;
+            }
+        }
+        low.checked_sub(1)
+            .and_then(|index| self.dictionary_blocks.get(index))
+    }
+
+    async fn read_dictionary_block(&self, block: BlockInfo) -> 
io::Result<Vec<DictionaryEntry>> {
+        let bytes = read_compressible_block(self.reader.as_ref(), 
block).await?;
+        let mut cursor = Cursor::new(bytes.as_slice());
+        let entry_count = decode_var_int(&mut cursor)?;
+        if entry_count < 0 {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidData,
+                format!("Invalid bitmap dictionary entry count: 
{entry_count}"),
+            ));
+        }
+        let mut entries = Vec::with_capacity(entry_count as usize);
+        for _ in 0..entry_count {
+            let key = read_key(&mut cursor)?;
+            let offset = decode_var_long(&mut cursor)?;
+            let length = decode_var_int(&mut cursor)?;
+            entries.push(DictionaryEntry {
+                key,
+                bitmap_block: block_info(offset, length)?,
+            });
+        }
+        Ok(entries)
+    }
+
+    async fn read_bitmap(&self, block: BlockInfo) -> 
io::Result<RoaringTreemap> {
+        let bytes = self
+            .reader
+            .read(block.offset..block.offset + block.length as u64)
+            .await
+            .map_err(|e| io::Error::other(e.to_string()))?;
+        RoaringTreemap::deserialize_from(bytes.as_ref())
+            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
+    }
+}
+
+async fn read_footer(reader: &dyn FileRead, file_size: u64) -> 
io::Result<Footer> {
+    if file_size < FOOTER_LENGTH as u64 {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidData,
+            "Invalid bitmap global index file size",
+        ));
+    }
+    let bytes = reader
+        .read(file_size - FOOTER_LENGTH as u64..file_size)
+        .await
+        .map_err(|e| io::Error::other(e.to_string()))?;
+
+    let null_rows_block = block_info(read_i64_be(&bytes, 0)?, 
read_i32_be(&bytes, 8)?)?;
+    let non_null_rows_block = block_info(read_i64_be(&bytes, 12)?, 
read_i32_be(&bytes, 20)?)?;
+    let index_block = block_info(read_i64_be(&bytes, 24)?, read_i32_be(&bytes, 
32)?)?;
+    let value_count = read_i32_be(&bytes, 36)?;
+    let version = read_i32_be(&bytes, 40)?;
+    let magic = read_i32_be(&bytes, 44)?;
+
+    if magic != MAGIC {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidData,
+            "File is not a bitmap global index file",
+        ));
+    }
+    if version != VERSION {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidData,
+            format!("Unsupported bitmap global index file version: {version}"),
+        ));
+    }
+    if value_count < 0 {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidData,
+            "Invalid bitmap value count",
+        ));
+    }
+
+    Ok(Footer {
+        null_rows_block,
+        non_null_rows_block,
+        index_block,
+    })
+}
+
+fn read_index_block(bytes: &[u8]) -> io::Result<Vec<DictionaryBlockMeta>> {
+    let mut cursor = Cursor::new(bytes);
+    let block_count = decode_var_int(&mut cursor)?;
+    if block_count < 0 {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidData,
+            format!("Invalid bitmap dictionary block count: {block_count}"),
+        ));
+    }
+    let mut blocks = Vec::with_capacity(block_count as usize);
+    for _ in 0..block_count {
+        let first_key = read_key(&mut cursor)?;
+        let offset = decode_var_long(&mut cursor)?;
+        let length = decode_var_int(&mut cursor)?;
+        blocks.push(DictionaryBlockMeta {
+            first_key,
+            block: block_info(offset, length)?,
+        });
+    }
+    blocks.sort_by(|left, right| compare_unsigned(&left.first_key, 
&right.first_key));
+    Ok(blocks)
+}
+
+async fn read_compressible_block(reader: &dyn FileRead, block: BlockInfo) -> 
io::Result<Vec<u8>> {
+    if block.length > usize::MAX - BLOCK_TRAILER_LENGTH {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidData,
+            "Bitmap block is too large",
+        ));
+    }
+    let bytes = reader
+        .read(block.offset..block.offset + (block.length + 
BLOCK_TRAILER_LENGTH) as u64)
+        .await
+        .map_err(|e| io::Error::other(e.to_string()))?;
+    let block_bytes = &bytes[..block.length];
+    let trailer = &bytes[block.length..block.length + BLOCK_TRAILER_LENGTH];
+    let compression_type = 
BlockCompressionType::from_persistent_id(trailer[0])?;
+    let expected_crc = u32::from_le_bytes([trailer[1], trailer[2], trailer[3], 
trailer[4]]);
+    let actual_crc = compute_crc32(block_bytes, compression_type);
+    if expected_crc != actual_crc {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidData,
+            format!(
+                "Bitmap block CRC mismatch: expected 0x{expected_crc:08X}, got 
0x{actual_crc:08X}"
+            ),
+        ));
+    }
+
+    match compression_type {
+        BlockCompressionType::None => Ok(block_bytes.to_vec()),
+        BlockCompressionType::Zstd => {
+            let mut cursor = Cursor::new(block_bytes);
+            let uncompressed_size = decode_var_int(&mut cursor)? as usize;
+            let compressed_start = cursor.position() as usize;
+            let compressed_data = &block_bytes[compressed_start..];
+            let mut decompressed = vec![0u8; uncompressed_size];
+            let actual = zstd::bulk::decompress_to_buffer(compressed_data, 
&mut decompressed)
+                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
+            if actual != uncompressed_size {
+                return Err(io::Error::new(
+                    io::ErrorKind::InvalidData,
+                    format!(
+                        "Bitmap block decompressed size mismatch: expected 
{uncompressed_size}, got {actual}"
+                    ),
+                ));
+            }
+            Ok(decompressed)
+        }
+        _ => Err(io::Error::new(
+            io::ErrorKind::Unsupported,
+            format!(
+                "Bitmap global index compression type {:?} is not supported",
+                compression_type
+            ),
+        )),
+    }
+}
+
+fn compute_crc32(data: &[u8], compression_type: BlockCompressionType) -> u32 {
+    let mut hasher = crc32fast::Hasher::new();
+    hasher.update(data);
+    hasher.update(&[compression_type as u8]);
+    hasher.finalize()
+}
+
+fn read_key(input: &mut impl Read) -> io::Result<Vec<u8>> {
+    let key_length = decode_var_int(input)?;
+    if key_length < 0 {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidData,
+            format!("Invalid bitmap key length: {key_length}"),
+        ));
+    }
+    let mut key = vec![0; key_length as usize];
+    input.read_exact(&mut key)?;
+    Ok(key)
+}
+
+fn block_info(offset: i64, length: i32) -> io::Result<BlockInfo> {
+    if offset < 0 || length < 0 {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidData,
+            format!("Invalid bitmap block info: offset={offset}, 
length={length}"),
+        ));
+    }
+    Ok(BlockInfo {
+        offset: offset as u64,
+        length: length as usize,
+    })
+}
+
+fn read_i64_be(bytes: &[u8], offset: usize) -> io::Result<i64> {
+    let end = offset + 8;
+    let value = bytes
+        .get(offset..end)
+        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Bitmap 
footer is truncated"))?;
+    Ok(i64::from_be_bytes(value.try_into().unwrap()))
+}
+
+fn read_i32_be(bytes: &[u8], offset: usize) -> io::Result<i32> {
+    let end = offset + 4;
+    let value = bytes
+        .get(offset..end)
+        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Bitmap 
footer is truncated"))?;
+    Ok(i32::from_be_bytes(value.try_into().unwrap()))
+}
+
+fn compare_unsigned(left: &[u8], right: &[u8]) -> Ordering {
+    for (left, right) in left.iter().zip(right.iter()) {
+        match left.cmp(right) {
+            Ordering::Equal => {}
+            non_eq => return non_eq,
+        }
+    }
+    left.len().cmp(&right.len())
+}
+
+fn is_character_string(data_type: &DataType) -> bool {
+    matches!(data_type, DataType::Char(_) | DataType::VarChar(_))
+}
+
+fn string_literal(literals: &[Datum], op: PredicateOperator) -> 
io::Result<&str> {
+    match literals.first() {
+        Some(Datum::String(value)) => Ok(value),
+        Some(other) => Err(io::Error::new(
+            io::ErrorKind::InvalidInput,
+            format!("Bitmap global index {op} requires a string literal, got 
{other}"),
+        )),
+        None => Err(io::Error::new(
+            io::ErrorKind::InvalidInput,
+            format!("Bitmap global index {op} requires one literal"),
+        )),
+    }
+}
+
+fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
+    !needle.is_empty()
+        && haystack
+            .windows(needle.len())
+            .any(|window| window == needle)
+}
+
+fn write_bitmap_index_bytes(
+    out: &mut Vec<u8>,
+    null_rows: &RoaringTreemap,
+    non_null_rows: &RoaringTreemap,
+    bitmaps: &BTreeMap<Vec<u8>, RoaringTreemap>,
+    dictionary_block_size: usize,
+    compression_type: BlockCompressionType,
+) -> io::Result<()> {
+    if dictionary_block_size == 0 {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidInput,
+            "Bitmap dictionary block size must be greater than 0",
+        ));
+    }
+
+    let null_rows_block = write_bitmap_block(out, null_rows)?;
+    let non_null_rows_block = write_bitmap_block(out, non_null_rows)?;
+    let (dictionary_blocks, value_count) =
+        write_dictionary_and_bitmap_blocks(out, bitmaps, 
dictionary_block_size, compression_type)?;
+    let index_block = write_index_block(out, &dictionary_blocks, 
compression_type)?;
+
+    out.extend_from_slice(&u64_to_i64(null_rows_block.offset)?.to_be_bytes());
+    
out.extend_from_slice(&usize_to_i32(null_rows_block.length)?.to_be_bytes());
+    
out.extend_from_slice(&u64_to_i64(non_null_rows_block.offset)?.to_be_bytes());
+    
out.extend_from_slice(&usize_to_i32(non_null_rows_block.length)?.to_be_bytes());
+    out.extend_from_slice(&u64_to_i64(index_block.offset)?.to_be_bytes());
+    out.extend_from_slice(&usize_to_i32(index_block.length)?.to_be_bytes());
+    out.extend_from_slice(&usize_to_i32(value_count)?.to_be_bytes());
+    out.extend_from_slice(&VERSION.to_be_bytes());
+    out.extend_from_slice(&MAGIC.to_be_bytes());
+    Ok(())
+}
+
+fn write_bitmap_block(out: &mut Vec<u8>, bitmap: &RoaringTreemap) -> 
io::Result<BlockInfo> {
+    let offset = out.len() as u64;
+    bitmap.serialize_into(&mut *out)?;
+    Ok(BlockInfo {
+        offset,
+        length: out.len() - offset as usize,
+    })
+}
+
+fn write_dictionary_and_bitmap_blocks(
+    out: &mut Vec<u8>,
+    bitmaps: &BTreeMap<Vec<u8>, RoaringTreemap>,
+    dictionary_block_size: usize,
+    compression_type: BlockCompressionType,
+) -> io::Result<(Vec<DictionaryBlockMeta>, usize)> {
+    let mut block_metas = Vec::new();
+    let mut current = DictionaryBlockBuilder::default();
+    let mut value_count = 0usize;
+
+    for (key, bitmap) in bitmaps {
+        let bitmap_block = write_bitmap_block(out, bitmap)?;
+        let entry = DictionaryEntry {
+            key: key.clone(),
+            bitmap_block,
+        };
+        if current.has_entries() && current.estimated_size_after(&entry) > 
dictionary_block_size {
+            block_metas.push(write_dictionary_block(
+                out,
+                &current.entries,
+                compression_type,
+            )?);
+            current = DictionaryBlockBuilder::default();
+        }
+        current.add(entry);
+        value_count += 1;
+    }
+
+    if current.has_entries() {
+        block_metas.push(write_dictionary_block(
+            out,
+            &current.entries,
+            compression_type,
+        )?);
+    }
+    Ok((block_metas, value_count))
+}
+
+fn write_dictionary_block(
+    out: &mut Vec<u8>,
+    entries: &[DictionaryEntry],
+    compression_type: BlockCompressionType,
+) -> io::Result<DictionaryBlockMeta> {
+    let mut bytes = Vec::new();
+    encode_var_int(&mut bytes, usize_to_i32(entries.len())?)?;
+    for entry in entries {
+        encode_var_int(&mut bytes, usize_to_i32(entry.key.len())?)?;
+        bytes.extend_from_slice(&entry.key);
+        encode_var_long(&mut bytes, u64_to_i64(entry.bitmap_block.offset)?)?;
+        encode_var_int(&mut bytes, usize_to_i32(entry.bitmap_block.length)?)?;
+    }
+    let block = write_compressible_block(out, &bytes, compression_type)?;
+    Ok(DictionaryBlockMeta {
+        first_key: entries[0].key.clone(),
+        block,
+    })
+}
+
+fn write_index_block(
+    out: &mut Vec<u8>,
+    blocks: &[DictionaryBlockMeta],
+    compression_type: BlockCompressionType,
+) -> io::Result<BlockInfo> {
+    let mut bytes = Vec::new();
+    encode_var_int(&mut bytes, usize_to_i32(blocks.len())?)?;
+    for block in blocks {
+        encode_var_int(&mut bytes, usize_to_i32(block.first_key.len())?)?;
+        bytes.extend_from_slice(&block.first_key);
+        encode_var_long(&mut bytes, u64_to_i64(block.block.offset)?)?;
+        encode_var_int(&mut bytes, usize_to_i32(block.block.length)?)?;
+    }
+    write_compressible_block(out, &bytes, compression_type)
+}
+
+fn write_compressible_block(
+    out: &mut Vec<u8>,
+    bytes: &[u8],
+    compression_type: BlockCompressionType,
+) -> io::Result<BlockInfo> {
+    let (block_bytes, actual_compression_type) = encode_block(bytes, 
compression_type)?;
+    let offset = out.len() as u64;
+    out.write_all(&block_bytes)?;
+    let crc = compute_crc32(&block_bytes, actual_compression_type);
+    out.write_all(&[actual_compression_type as u8])?;
+    out.write_all(&crc.to_le_bytes())?;
+    Ok(BlockInfo {
+        offset,
+        length: block_bytes.len(),
+    })
+}
+
+fn encode_block(
+    bytes: &[u8],
+    compression_type: BlockCompressionType,
+) -> io::Result<(Vec<u8>, BlockCompressionType)> {
+    match compression_type {
+        BlockCompressionType::None => Ok((bytes.to_vec(), 
BlockCompressionType::None)),
+        BlockCompressionType::Zstd => {
+            let compressed = zstd::bulk::compress(bytes, 3)
+                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
+            let mut encoded = Vec::with_capacity(5 + compressed.len());
+            encode_var_int(&mut encoded, usize_to_i32(bytes.len())?)?;
+            encoded.extend_from_slice(&compressed);
+            if encoded.len() < bytes.len() - (bytes.len() / 8) {
+                Ok((encoded, BlockCompressionType::Zstd))
+            } else {
+                Ok((bytes.to_vec(), BlockCompressionType::None))
+            }
+        }
+        _ => Err(io::Error::new(
+            io::ErrorKind::Unsupported,
+            format!(
+                "Bitmap global index compression type {:?} is not supported",
+                compression_type
+            ),
+        )),
+    }
+}
+
+#[derive(Default)]
+struct DictionaryBlockBuilder {
+    entries: Vec<DictionaryEntry>,
+    entries_size: usize,
+}
+
+impl DictionaryBlockBuilder {
+    fn has_entries(&self) -> bool {
+        !self.entries.is_empty()
+    }
+
+    fn estimated_size_after(&self, entry: &DictionaryEntry) -> usize {
+        estimated_var_len_int_size(self.entries.len() + 1)
+            + self.entries_size
+            + entry.estimated_size()
+    }
+
+    fn add(&mut self, entry: DictionaryEntry) {
+        self.entries_size += entry.estimated_size();
+        self.entries.push(entry);
+    }
+}
+
+impl DictionaryEntry {
+    fn estimated_size(&self) -> usize {
+        estimated_var_len_int_size(self.key.len())
+            + self.key.len()
+            + estimated_var_len_long_size(self.bitmap_block.offset)
+            + estimated_var_len_int_size(self.bitmap_block.length)
+    }
+}
+
+fn estimated_var_len_int_size(mut value: usize) -> usize {
+    let mut size = 1;
+    while (value & !0x7f) != 0 {
+        value >>= 7;
+        size += 1;
+    }
+    size
+}
+
+fn estimated_var_len_long_size(mut value: u64) -> usize {
+    let mut size = 1;
+    while (value & !0x7f) != 0 {
+        value >>= 7;
+        size += 1;
+    }
+    size
+}
+
+fn usize_to_i32(value: usize) -> io::Result<i32> {
+    i32::try_from(value).map_err(|_| {
+        io::Error::new(
+            io::ErrorKind::InvalidData,
+            format!("Bitmap global index value is too large: {value}"),
+        )
+    })
+}
+
+fn u64_to_i64(value: u64) -> io::Result<i64> {
+    i64::try_from(value).map_err(|_| {
+        io::Error::new(
+            io::ErrorKind::InvalidData,
+            format!("Bitmap global index offset is too large: {value}"),
+        )
+    })
+}
diff --git a/crates/paimon/src/table/btree_global_index_build_builder.rs 
b/crates/paimon/src/table/btree_global_index_build_builder.rs
index 57b2ff2..2f242ca 100644
--- a/crates/paimon/src/table/btree_global_index_build_builder.rs
+++ b/crates/paimon/src/table/btree_global_index_build_builder.rs
@@ -15,6 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use super::bitmap_global_index_reader::{BitmapGlobalIndexWriter, 
BitmapWriteResult};
+use super::global_index_types::{
+    normalize_sorted_global_index_type, BITMAP_GLOBAL_INDEX_TYPE, 
BTREE_GLOBAL_INDEX_TYPE,
+};
 use crate::btree::{make_key_comparator, serialize_datum, BTreeIndexWriter, 
BlockCompressionType};
 use crate::spec::{
     bucket_dir_name, extract_datum_from_arrow, BinaryRow, CoreOptions, 
DataField, DataFileMeta,
@@ -31,15 +35,16 @@ use futures::TryStreamExt;
 use std::cmp::Ordering;
 use std::collections::HashMap;
 
-const BTREE_INDEX_TYPE: &str = "btree";
 const INDEX_DIR: &str = "index";
 const BTREE_BLOCK_SIZE: usize = 4 * 1024;
+const BITMAP_DICTIONARY_BLOCK_SIZE: usize = 16 * 1024;
 
 type BTreeKeyRow = (Option<Vec<u8>>, i64);
 
 pub struct BTreeGlobalIndexBuildBuilder<'a> {
     table: &'a Table,
     index_column: Option<String>,
+    index_type: String,
 }
 
 impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
@@ -47,6 +52,7 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
         Self {
             table,
             index_column: None,
+            index_type: BTREE_GLOBAL_INDEX_TYPE.to_string(),
         }
     }
 
@@ -55,12 +61,25 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
         self
     }
 
+    pub fn with_index_type(&mut self, index_type: &str) -> &mut Self {
+        self.index_type = index_type.to_string();
+        self
+    }
+
     pub async fn execute(&self) -> Result<usize> {
+        let index_type = 
normalize_sorted_global_index_type(&self.index_type).ok_or_else(|| {
+            Error::Unsupported {
+                message: format!(
+                    "Sorted global index build only supports index_type => 
'btree' or 'bitmap', got '{}'",
+                    self.index_type
+                ),
+            }
+        })?;
         let index_column = self
             .index_column
             .as_deref()
             .ok_or_else(|| Error::DataInvalid {
-                message: "BTree global index column is required".to_string(),
+                message: "Sorted global index column is required".to_string(),
                 source: None,
             })?;
 
@@ -79,7 +98,7 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
             .get_latest_snapshot()
             .await?
             .ok_or_else(|| Error::DataInvalid {
-                message: "Cannot build BTree global index without a 
snapshot".to_string(),
+                message: "Cannot build sorted global index without a 
snapshot".to_string(),
                 source: None,
             })?;
 
@@ -127,7 +146,7 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
             self.table.clone(),
             format!(
                 "global-index-{}-create-{}",
-                BTREE_INDEX_TYPE,
+                index_type,
                 uuid::Uuid::new_v4()
             ),
         )
@@ -143,6 +162,14 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
         index_field: &DataField,
         index_column: &str,
     ) -> Result<IndexFileMeta> {
+        let index_type = 
normalize_sorted_global_index_type(&self.index_type).ok_or_else(|| {
+            Error::Unsupported {
+                message: format!(
+                    "Sorted global index build only supports index_type => 
'btree' or 'bitmap', got '{}'",
+                    self.index_type
+                ),
+            }
+        })?;
         let row_count = checked_row_count(shard.row_range_start, 
shard.row_range_end)?;
         let mut rows = extract_index_rows(self.table, shard, index_column, 
index_field).await?;
         let cmp = make_key_comparator(index_field.data_type());
@@ -155,7 +182,7 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
                 self.table.location().trim_end_matches('/')
             ))
             .await?;
-        let file_name = format!("btree-global-index-{}.index", 
uuid::Uuid::new_v4());
+        let file_name = format!("{index_type}-global-index-{}.index", 
uuid::Uuid::new_v4());
         let index_path = format!(
             "{}/{INDEX_DIR}/{}",
             self.table.location().trim_end_matches('/'),
@@ -163,31 +190,64 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
         );
         let output = self.table.file_io().new_output(&index_path)?;
         let writer = output.writer().await?;
-        let mut writer = BTreeIndexWriter::with_comparator(
-            writer,
-            BTREE_BLOCK_SIZE,
-            BlockCompressionType::None,
-            cmp,
-        );
-        for (key, local_row_id) in &rows {
-            writer
-                .write(key.as_deref(), *local_row_id)
-                .await
-                .map_err(|e| Error::DataInvalid {
-                    message: format!("Failed to write BTree global index file 
'{file_name}'"),
+        let (written_row_count, index_meta) = match index_type {
+            BTREE_GLOBAL_INDEX_TYPE => {
+                let mut writer = BTreeIndexWriter::with_comparator(
+                    writer,
+                    BTREE_BLOCK_SIZE,
+                    BlockCompressionType::None,
+                    cmp,
+                );
+                for (key, local_row_id) in &rows {
+                    writer
+                        .write(key.as_deref(), *local_row_id)
+                        .await
+                        .map_err(|e| Error::DataInvalid {
+                            message: format!(
+                                "Failed to write BTree global index file 
'{file_name}'"
+                            ),
+                            source: Some(Box::new(e)),
+                        })?;
+                }
+                let write_result = writer.finish().await.map_err(|e| 
Error::DataInvalid {
+                    message: format!("Failed to finish BTree global index file 
'{file_name}'"),
                     source: Some(Box::new(e)),
                 })?;
-        }
-        let write_result = writer.finish().await.map_err(|e| 
Error::DataInvalid {
-            message: format!("Failed to finish BTree global index file 
'{file_name}'"),
-            source: Some(Box::new(e)),
-        })?;
+                (write_result.row_count, write_result.meta)
+            }
+            BITMAP_GLOBAL_INDEX_TYPE => {
+                let cmp = make_key_comparator(index_field.data_type());
+                let mut writer = BitmapGlobalIndexWriter::new(
+                    writer,
+                    BITMAP_DICTIONARY_BLOCK_SIZE,
+                    BlockCompressionType::None,
+                    cmp,
+                );
+                for (key, local_row_id) in &rows {
+                    writer.write(key.as_deref(), *local_row_id).map_err(|e| {
+                        Error::DataInvalid {
+                            message: format!(
+                                "Failed to write bitmap global index file 
'{file_name}'"
+                            ),
+                            source: Some(Box::new(e)),
+                        }
+                    })?;
+                }
+                let BitmapWriteResult { row_count, meta } =
+                    writer.finish().await.map_err(|e| Error::DataInvalid {
+                        message: format!("Failed to finish bitmap global index 
file '{file_name}'"),
+                        source: Some(Box::new(e)),
+                    })?;
+                (row_count, meta)
+            }
+            _ => unreachable!("normalized sorted global index type"),
+        };
 
-        if write_result.row_count != u64::try_from(row_count).unwrap() {
+        if written_row_count != u64::try_from(row_count).unwrap() {
             return Err(Error::DataInvalid {
                 message: format!(
-                    "BTree global index expected {} rows, wrote {}",
-                    row_count, write_result.row_count
+                    "Sorted global index expected {} rows, wrote {}",
+                    row_count, written_row_count
                 ),
                 source: None,
             });
@@ -195,7 +255,7 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
 
         let status = self.table.file_io().get_status(&index_path).await?;
         Ok(IndexFileMeta {
-            index_type: BTREE_INDEX_TYPE.to_string(),
+            index_type: index_type.to_string(),
             file_name,
             file_size: checked_i32(
                 status.size,
@@ -208,7 +268,7 @@ impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
                 row_range_end: shard.row_range_end,
                 index_field_id: index_field.id(),
                 extra_field_ids: None,
-                index_meta: Some(write_result.meta.serialize()),
+                index_meta: Some(index_meta.serialize()),
             }),
         })
     }
@@ -1159,7 +1219,7 @@ mod tests {
         assert_eq!(index_entries.len(), 1);
 
         let index_file = &index_entries[0].index_file;
-        assert_eq!(index_file.index_type, BTREE_INDEX_TYPE);
+        assert_eq!(index_file.index_type, BTREE_GLOBAL_INDEX_TYPE);
         assert!(index_file.file_name.starts_with("btree-global-index-"));
         assert_eq!(index_file.row_count, 3);
         assert!(index_file.file_size > 0);
@@ -1188,6 +1248,109 @@ mod tests {
             predicates: &[predicate],
             schema_fields: table.schema().fields(),
             search_mode: GlobalIndexSearchMode::Fast,
+            btree_fallback_scan_max_size: i64::MAX,
+            bitmap_fallback_scan_max_size: i64::MAX,
+            next_row_id: snapshot.next_row_id(),
+            data_ranges: &[],
+        })
+        .await
+        .unwrap()
+        .unwrap();
+        assert_eq!(row_ranges, vec![RowRange::new(0, 0), RowRange::new(2, 2)]);
+    }
+
+    #[tokio::test]
+    async fn test_execute_writes_bitmap_index_manifest_and_java_file() {
+        let table_path = "memory:/test_bitmap_global_index_builder_e2e";
+        let table = test_table_with_path(table_path, table_options("10"));
+        setup_dirs(&table).await;
+
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+        table_write
+            .write_arrow_batch(&data_batch(vec![1, 2, 3], vec!["alice", "bob", 
"alice"]))
+            .await
+            .unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
+            .await
+            .unwrap();
+
+        let shard_count = table
+            .new_btree_global_index_build_builder()
+            .with_index_column("name")
+            .with_index_type(BITMAP_GLOBAL_INDEX_TYPE)
+            .execute()
+            .await
+            .unwrap();
+        assert_eq!(shard_count, 1);
+
+        let snapshot_manager =
+            SnapshotManager::new(table.file_io().clone(), 
table.location().to_string());
+        let snapshot = snapshot_manager
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        let index_manifest = snapshot.index_manifest().expect("index 
manifest");
+        let index_entries = IndexManifest::read(
+            table.file_io(),
+            &format!("{table_path}/manifest/{index_manifest}"),
+        )
+        .await
+        .unwrap();
+        assert_eq!(index_entries.len(), 1);
+
+        let index_file = &index_entries[0].index_file;
+        assert_eq!(index_file.index_type, BITMAP_GLOBAL_INDEX_TYPE);
+        assert!(index_file.file_name.starts_with("bitmap-global-index-"));
+        assert_eq!(index_file.row_count, 3);
+        assert!(index_file.file_size > 0);
+
+        let global_meta = index_file
+            .global_index_meta
+            .as_ref()
+            .expect("global index meta");
+        let bitmap_meta =
+            
crate::btree::BTreeIndexMeta::deserialize(global_meta.index_meta.as_ref().unwrap())
+                .unwrap();
+        assert_eq!(bitmap_meta.first_key, Some(b"alice".to_vec()));
+        assert_eq!(bitmap_meta.last_key, Some(b"bob".to_vec()));
+        assert!(!bitmap_meta.has_nulls);
+
+        let index_path = format!("{table_path}/index/{}", 
index_file.file_name);
+        let input = table.file_io().new_input(&index_path).unwrap();
+        let file_size = input.metadata().await.unwrap().size;
+        let reader = input.reader().await.unwrap();
+        let bitmap_reader =
+            
crate::table::bitmap_global_index_reader::BitmapGlobalIndexReader::open(
+                Box::new(reader),
+                file_size,
+            )
+            .await
+            .unwrap();
+        let bitmap = bitmap_reader
+            .query(
+                crate::spec::PredicateOperator::Eq,
+                &[crate::spec::Datum::String("alice".to_string())],
+                table.schema().fields()[1].data_type(),
+            )
+            .await
+            .unwrap();
+        assert_eq!(bitmap.iter().collect::<Vec<_>>(), vec![0, 2]);
+
+        let predicate = PredicateBuilder::new(table.schema().fields())
+            .equal("name", crate::spec::Datum::String("alice".to_string()))
+            .unwrap();
+        let row_ranges = evaluate_global_index(GlobalIndexEvaluation {
+            file_io: table.file_io(),
+            table_path: table.location(),
+            index_entries: &index_entries,
+            predicates: &[predicate],
+            schema_fields: table.schema().fields(),
+            search_mode: GlobalIndexSearchMode::Fast,
+            btree_fallback_scan_max_size: i64::MAX,
+            bitmap_fallback_scan_max_size: i64::MAX,
             next_row_id: snapshot.next_row_id(),
             data_ranges: &[],
         })
diff --git a/crates/paimon/src/table/btree_global_index_drop_builder.rs 
b/crates/paimon/src/table/btree_global_index_drop_builder.rs
index 50f0e30..a1c497e 100644
--- a/crates/paimon/src/table/btree_global_index_drop_builder.rs
+++ b/crates/paimon/src/table/btree_global_index_drop_builder.rs
@@ -15,16 +15,16 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use super::global_index_types::{normalize_sorted_global_index_type, 
BTREE_GLOBAL_INDEX_TYPE};
 use crate::spec::{DataField, FileKind, IndexFileMeta, IndexManifest};
 use crate::table::{CommitMessage, SnapshotManager, Table, TableCommit};
 use crate::{Error, Result};
 use std::collections::HashMap;
 
-const BTREE_INDEX_TYPE: &str = "btree";
-
 pub struct BTreeGlobalIndexDropBuilder<'a> {
     table: &'a Table,
     index_column: Option<String>,
+    index_type: String,
 }
 
 impl<'a> BTreeGlobalIndexDropBuilder<'a> {
@@ -32,6 +32,7 @@ impl<'a> BTreeGlobalIndexDropBuilder<'a> {
         Self {
             table,
             index_column: None,
+            index_type: BTREE_GLOBAL_INDEX_TYPE.to_string(),
         }
     }
 
@@ -40,12 +41,25 @@ impl<'a> BTreeGlobalIndexDropBuilder<'a> {
         self
     }
 
+    pub fn with_index_type(&mut self, index_type: &str) -> &mut Self {
+        self.index_type = index_type.to_string();
+        self
+    }
+
     pub async fn execute(&self) -> Result<usize> {
+        let index_type = 
normalize_sorted_global_index_type(&self.index_type).ok_or_else(|| {
+            Error::Unsupported {
+                message: format!(
+                    "Sorted global index drop only supports index_type => 
'btree' or 'bitmap', got '{}'",
+                    self.index_type
+                ),
+            }
+        })?;
         let index_column = self
             .index_column
             .as_deref()
             .ok_or_else(|| Error::DataInvalid {
-                message: "BTree global index column is required".to_string(),
+                message: "Sorted global index column is required".to_string(),
                 source: None,
             })?;
         let index_field = find_index_field(self.table, index_column)?;
@@ -70,7 +84,7 @@ impl<'a> BTreeGlobalIndexDropBuilder<'a> {
             HashMap::new();
         let mut dropped = 0;
         for entry in index_entries {
-            if entry.kind != FileKind::Add || entry.index_file.index_type != 
BTREE_INDEX_TYPE {
+            if entry.kind != FileKind::Add || entry.index_file.index_type != 
index_type {
                 continue;
             }
             let Some(global_meta) = 
entry.index_file.global_index_meta.as_ref() else {
@@ -110,11 +124,7 @@ impl<'a> BTreeGlobalIndexDropBuilder<'a> {
 
         TableCommit::new(
             self.table.clone(),
-            format!(
-                "global-index-{}-drop-{}",
-                BTREE_INDEX_TYPE,
-                uuid::Uuid::new_v4()
-            ),
+            format!("global-index-{}-drop-{}", index_type, 
uuid::Uuid::new_v4()),
         )
         .commit_if_latest_snapshot(messages, snapshot.id())
         .await?;
@@ -288,8 +298,8 @@ mod tests {
             vec![data_file("data-0.parquet")],
         );
         message.new_index_files = vec![
-            global_index_file(BTREE_INDEX_TYPE, "btree-id.index", 0, 0, 9),
-            global_index_file(BTREE_INDEX_TYPE, "btree-name.index", 1, 0, 9),
+            global_index_file(BTREE_GLOBAL_INDEX_TYPE, "btree-id.index", 0, 0, 
9),
+            global_index_file(BTREE_GLOBAL_INDEX_TYPE, "btree-name.index", 1, 
0, 9),
             global_index_file("full-text", "fulltext-id.index", 0, 100, 109),
             hash_index_file("hash.index"),
             deletion_vector_index_file("dv.index"),
@@ -331,7 +341,7 @@ mod tests {
 
         let mut message = CommitMessage::new(vec![], 0, 
vec![data_file("data-0.parquet")]);
         message.new_index_files = vec![global_index_file(
-            BTREE_INDEX_TYPE,
+            BTREE_GLOBAL_INDEX_TYPE,
             "btree-name.index",
             1,
             0,
diff --git a/crates/paimon/src/table/global_index_scanner.rs 
b/crates/paimon/src/table/global_index_scanner.rs
index 752ef81..07b4858 100644
--- a/crates/paimon/src/table/global_index_scanner.rs
+++ b/crates/paimon/src/table/global_index_scanner.rs
@@ -15,11 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Global index scanner: evaluates predicates against BTree global indexes
+//! Global index scanner: evaluates predicates against sorted global indexes
 //! to produce row ID ranges for data evolution tables.
 //!
 //! Reference: 
[org.apache.paimon.index.GlobalIndexScanner](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/index/GlobalIndexScanner.java)
 
+use super::bitmap_global_index_reader::BitmapGlobalIndexReader;
+use super::global_index_types::{
+    normalize_sorted_global_index_type, BITMAP_GLOBAL_INDEX_TYPE, 
BTREE_GLOBAL_INDEX_TYPE,
+};
 use crate::btree::query::{extract_between, IndexQuery};
 use crate::btree::{make_key_comparator, serialize_datum, BTreeIndexMeta, 
BTreeIndexReader};
 use crate::deletion_vector::DeletionVectorFactory;
@@ -43,7 +47,6 @@ type EvaluateFuture<'a> = std::pin::Pin<
 
 type PredicateTuple<'a> = (PredicateOperator, &'a [Datum], &'a DataType);
 
-const BTREE_INDEX_TYPE: &str = "btree";
 const DELETION_VECTORS_INDEX_TYPE: &str = "DELETION_VECTORS";
 const INDEX_DIR: &str = "index";
 
@@ -56,11 +59,13 @@ struct GlobalIndexScanResult {
 ///
 /// The scanner filters index manifest entries for global index files,
 /// uses BTreeIndexMeta for file-level pruning, then reads matching
-/// BTree files to evaluate predicates and collect row IDs.
+/// BTree or bitmap files to evaluate predicates and collect row IDs.
 /// Opened BTreeIndexReaders are cached for reuse across evaluations.
 pub(crate) struct GlobalIndexScanner {
     file_io: FileIO,
     table_path: String,
+    btree_fallback_scan_max_size: i64,
+    bitmap_fallback_scan_max_size: i64,
     /// Global index entries grouped by field_id.
     entries_by_field: Vec<(i32, Vec<GlobalIndexEntry>)>,
     /// Indexed row-id coverage grouped by field_id.
@@ -74,16 +79,84 @@ pub(crate) struct GlobalIndexScanner {
 /// A resolved global index entry with parsed metadata.
 struct GlobalIndexEntry {
     file_name: String,
+    index_type: GlobalIndexFileKind,
+    file_size: i64,
     row_range_start: i64,
     meta: BTreeIndexMeta,
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum GlobalIndexFileKind {
+    BTree,
+    Bitmap,
+}
+
+enum OpenedGlobalIndexReader {
+    BTree(BTreeIndexReader<BoxedCmp>),
+    Bitmap(BitmapGlobalIndexReader),
+}
+
+#[derive(Clone, Copy, Default)]
+struct FallbackScanPlan {
+    selected_btree: usize,
+    selected_bitmap: usize,
+    allow_btree: bool,
+    allow_bitmap: bool,
+}
+
+impl FallbackScanPlan {
+    fn allowed(self, kind: GlobalIndexFileKind) -> bool {
+        match kind {
+            GlobalIndexFileKind::BTree => self.allow_btree,
+            GlobalIndexFileKind::Bitmap => self.allow_bitmap,
+        }
+    }
+}
+
+impl OpenedGlobalIndexReader {
+    async fn query(
+        &self,
+        op: PredicateOperator,
+        literals: &[Datum],
+        data_type: &DataType,
+    ) -> std::io::Result<RoaringTreemap> {
+        match self {
+            Self::BTree(reader) => reader.query(op, literals, data_type).await,
+            Self::Bitmap(reader) => reader.query(op, literals, 
data_type).await,
+        }
+    }
+
+    async fn range_query(
+        &self,
+        from: &[u8],
+        to: &[u8],
+        data_type: &DataType,
+        from_inclusive: bool,
+        to_inclusive: bool,
+    ) -> std::io::Result<RoaringTreemap> {
+        match self {
+            Self::BTree(reader) => {
+                reader
+                    .range_query(from, to, from_inclusive, to_inclusive)
+                    .await
+            }
+            Self::Bitmap(reader) => {
+                reader
+                    .range_query(from, to, data_type, from_inclusive, 
to_inclusive)
+                    .await
+            }
+        }
+    }
+}
+
 impl GlobalIndexScanner {
     /// Create a scanner from index manifest entries.
     /// Returns `None` if there are no global index entries.
     pub(crate) fn create(
         file_io: &FileIO,
         table_path: &str,
+        btree_fallback_scan_max_size: i64,
+        bitmap_fallback_scan_max_size: i64,
         index_entries: &[IndexManifestEntry],
         schema_fields: &[DataField],
     ) -> Option<Self> {
@@ -95,15 +168,16 @@ impl GlobalIndexScanner {
             if entry.kind != FileKind::Add {
                 continue;
             }
-            if entry.index_file.index_type != BTREE_INDEX_TYPE {
+            let Some(index_type) = 
normalize_sorted_global_index_type(&entry.index_file.index_type)
+            else {
                 continue;
-            }
+            };
             let global_meta = match &entry.index_file.global_index_meta {
                 Some(m) => m,
                 None => continue,
             };
 
-            let btree_meta = global_meta
+            let sorted_meta = global_meta
                 .index_meta
                 .as_ref()
                 .and_then(|bytes| BTreeIndexMeta::deserialize(bytes).ok())
@@ -111,8 +185,14 @@ impl GlobalIndexScanner {
 
             let resolved = GlobalIndexEntry {
                 file_name: entry.index_file.file_name.clone(),
+                index_type: match index_type {
+                    BTREE_GLOBAL_INDEX_TYPE => GlobalIndexFileKind::BTree,
+                    BITMAP_GLOBAL_INDEX_TYPE => GlobalIndexFileKind::Bitmap,
+                    _ => unreachable!("normalized sorted global index type"),
+                },
+                file_size: i64::from(entry.index_file.file_size),
                 row_range_start: global_meta.row_range_start,
-                meta: btree_meta,
+                meta: sorted_meta,
             };
 
             let row_range = RowRange::new(global_meta.row_range_start, 
global_meta.row_range_end);
@@ -142,6 +222,8 @@ impl GlobalIndexScanner {
         Some(Self {
             file_io: file_io.clone(),
             table_path: table_path.trim_end_matches('/').to_string(),
+            btree_fallback_scan_max_size,
+            bitmap_fallback_scan_max_size,
             entries_by_field: entries_by_field.into_iter().collect(),
             coverage_by_field,
             schema_fields: schema_fields.to_vec(),
@@ -161,7 +243,7 @@ impl GlobalIndexScanner {
                     data_type,
                     ..
                 } => {
-                    if !is_btree_supported_op(*op) {
+                    if !is_sorted_global_index_supported_op(*op) {
                         return Ok(None);
                     }
                     let field_id = self.find_field_id_by_name(column)?;
@@ -197,7 +279,7 @@ impl GlobalIndexScanner {
                             ..
                         } = child
                         {
-                            if is_btree_supported_op(*op) {
+                            if is_sorted_global_index_supported_op(*op) {
                                 if let Some(field_id) = 
self.find_field_id_by_name(column)? {
                                     if 
self.entries_for_field(field_id).is_some() {
                                         
leaf_groups.entry(field_id).or_default().push((
@@ -306,22 +388,47 @@ impl GlobalIndexScanner {
             })
             .collect();
 
-        for entry in entries {
-            // Check if any predicate may match this file (use 
effective_predicates for pruning)
-            let matching_predicates: Vec<usize> = 
(0..effective_predicates.len())
-                .filter(|&i| {
-                    entry
-                        .meta
-                        .may_match(pruning_info[i].0, &pruning_info[i].2, 
&pruning_info[i].1)
-                })
-                .collect();
+        let predicate_matches: Vec<Vec<bool>> = pruning_info
+            .iter()
+            .map(|(op, cmp, serialized)| {
+                entries
+                    .iter()
+                    .map(|entry| entry.meta.may_match(*op, serialized, cmp))
+                    .collect()
+            })
+            .collect();
+        let predicate_fallback_plans: Vec<Option<FallbackScanPlan>> = 
effective_predicates
+            .iter()
+            .enumerate()
+            .map(|(i, (op, _, _))| {
+                requires_fallback_scan(*op)
+                    .then(|| self.fallback_scan_plan(entries, 
&predicate_matches[i]))
+            })
+            .collect();
 
-            // Also check if between range may match
-            let between_matches = between.as_ref().is_some_and(|b| {
+        let between_matches_by_entry: Vec<bool> = match between.as_ref() {
+            Some(b) => {
                 let cmp = make_key_comparator(b.data_type);
                 let from_key = serialize_datum(b.from, b.data_type);
                 let to_key = serialize_datum(b.to, b.data_type);
-                entry.meta.may_match_between(&from_key, &to_key, &cmp)
+                entries
+                    .iter()
+                    .map(|entry| entry.meta.may_match_between(&from_key, 
&to_key, &cmp))
+                    .collect()
+            }
+            None => Vec::new(),
+        };
+        let between_fallback_plan = between
+            .as_ref()
+            .map(|_| self.fallback_scan_plan(entries, 
&between_matches_by_entry));
+
+        for (entry_idx, entry) in entries.iter().enumerate() {
+            // Also check if between range may match
+            let between_matches = between
+                .as_ref()
+                .is_some_and(|_| between_matches_by_entry[entry_idx]);
+            let between_evaluated_for_entry = 
between_fallback_plan.is_some_and(|plan| {
+                fallback_plan_evaluates_entry(plan, entry.index_type, 
between_matches)
             });
 
             // When a Between conjunct exists but the file does not overlap its
@@ -331,11 +438,44 @@ impl GlobalIndexScanner {
             // (e.g. `BETWEEN 10 AND 20 AND id >= 0` on a file [30, 40]) would
             // be retained because `file_result` is initialized from the
             // remaining bitmap, silently dropping the Between conjunct.
-            if between.is_some() && !between_matches {
+            if between_evaluated_for_entry && !between_matches {
                 continue;
             }
 
-            if matching_predicates.is_empty() && !between_matches {
+            let mut file_evaluated = between_evaluated_for_entry;
+            let mut file_cannot_match = false;
+            let mut file_has_unsupported_match =
+                between_matches && !between_evaluated_for_entry && 
between_fallback_plan.is_some();
+            let matching_predicates: Vec<usize> = 
(0..effective_predicates.len())
+                .filter(|&i| {
+                    let predicate_matches_entry = 
predicate_matches[i][entry_idx];
+                    let predicate_evaluated_for_entry =
+                        predicate_fallback_plans[i].is_none_or(|plan| {
+                            fallback_plan_evaluates_entry(
+                                plan,
+                                entry.index_type,
+                                predicate_matches_entry,
+                            )
+                        });
+                    if !predicate_evaluated_for_entry {
+                        file_has_unsupported_match |= predicate_matches_entry;
+                        return false;
+                    }
+                    file_evaluated = true;
+                    if !predicate_matches_entry {
+                        file_cannot_match = true;
+                        return false;
+                    }
+                    true
+                })
+                .collect();
+            if file_cannot_match {
+                continue;
+            }
+            if !file_evaluated {
+                if file_has_unsupported_match {
+                    return Ok(None);
+                }
                 continue;
             }
 
@@ -344,22 +484,34 @@ impl GlobalIndexScanner {
                 .map(|b| b.data_type)
                 .or_else(|| effective_predicates.first().map(|p| p.2))
                 .unwrap_or(predicates[0].2);
-            let reader = self
-                .get_or_open_reader(&entry.file_name, &entry.meta, data_type)
-                .await?;
+            let mut reader = if (between_matches && 
between_evaluated_for_entry)
+                || !matching_predicates.is_empty()
+            {
+                Some(self.open_reader_for_entry(entry, data_type).await?)
+            } else {
+                None
+            };
 
-            let mut file_result: Option<RoaringTreemap> = None;
+            let mut file_result = None;
 
             // Execute between query first if applicable
-            if between_matches {
+            if between_matches && between_evaluated_for_entry {
                 if let Some(b) = &between {
                     let from_key = serialize_datum(b.from, b.data_type);
                     let to_key = serialize_datum(b.to, b.data_type);
                     let bitmap = reader
-                        .range_query(&from_key, &to_key, b.from_inclusive, 
b.to_inclusive)
+                        .as_ref()
+                        .expect("reader is opened when between matches")
+                        .range_query(
+                            &from_key,
+                            &to_key,
+                            b.data_type,
+                            b.from_inclusive,
+                            b.to_inclusive,
+                        )
                         .await
                         .map_err(|e| crate::Error::DataInvalid {
-                            message: "BTree query failed".to_string(),
+                            message: "Global index query failed".to_string(),
                             source: Some(Box::new(e)),
                         })?;
                     file_result = Some(bitmap);
@@ -369,12 +521,15 @@ impl GlobalIndexScanner {
             // Evaluate remaining predicates
             for &idx in &matching_predicates {
                 let (op, literals, dt) = &effective_predicates[idx];
-                let bitmap = reader.query(*op, literals, dt).await.map_err(|e| 
{
-                    crate::Error::DataInvalid {
-                        message: "BTree query failed".to_string(),
+                let bitmap = reader
+                    .as_ref()
+                    .expect("reader is opened when predicates match")
+                    .query(*op, literals, dt)
+                    .await
+                    .map_err(|e| crate::Error::DataInvalid {
+                        message: "Global index query failed".to_string(),
                         source: Some(Box::new(e)),
-                    }
-                })?;
+                    })?;
                 file_result = Some(match file_result {
                     None => bitmap,
                     Some(mut existing) => {
@@ -384,8 +539,11 @@ impl GlobalIndexScanner {
                 });
             }
 
-            // Return reader to cache
-            self.return_reader(entry.file_name.clone(), reader);
+            // Return BTree readers to cache. Bitmap readers are cheap wrappers
+            // around one opened file and are not cached yet.
+            if let Some(OpenedGlobalIndexReader::BTree(reader)) = 
reader.take() {
+                self.return_reader(entry.file_name.clone(), reader);
+            }
 
             if let Some(bitmap) = file_result {
                 for rid in bitmap.iter() {
@@ -403,12 +561,12 @@ impl GlobalIndexScanner {
         file_name: &str,
         meta: &BTreeIndexMeta,
         data_type: &DataType,
-    ) -> Result<BTreeIndexReader<BoxedCmp>> {
+    ) -> Result<OpenedGlobalIndexReader> {
         // Try to take from cache
         {
             let mut cache = self.reader_cache.lock().unwrap();
             if let Some(reader) = cache.remove(file_name) {
-                return Ok(reader);
+                return Ok(OpenedGlobalIndexReader::BTree(reader));
             }
         }
 
@@ -421,12 +579,96 @@ impl GlobalIndexScanner {
         let cmp = make_key_comparator(data_type);
         BTreeIndexReader::open(Box::new(file_reader), file_size, meta, cmp)
             .await
+            .map(OpenedGlobalIndexReader::BTree)
             .map_err(|e| crate::Error::DataInvalid {
                 message: format!("Failed to open BTree index file: 
{file_name}"),
                 source: Some(Box::new(e)),
             })
     }
 
+    async fn open_reader_for_entry(
+        &self,
+        entry: &GlobalIndexEntry,
+        data_type: &DataType,
+    ) -> Result<OpenedGlobalIndexReader> {
+        match entry.index_type {
+            GlobalIndexFileKind::BTree => {
+                self.get_or_open_reader(&entry.file_name, &entry.meta, 
data_type)
+                    .await
+            }
+            GlobalIndexFileKind::Bitmap => self
+                .open_bitmap_reader(&entry.file_name)
+                .await
+                .map(OpenedGlobalIndexReader::Bitmap)
+                .map_err(|e| crate::Error::DataInvalid {
+                    message: format!(
+                        "Failed to open bitmap global index file: {}",
+                        entry.file_name
+                    ),
+                    source: Some(Box::new(e)),
+                }),
+        }
+    }
+
+    async fn open_bitmap_reader(
+        &self,
+        file_name: &str,
+    ) -> std::io::Result<BitmapGlobalIndexReader> {
+        let path = format!("{}/{INDEX_DIR}/{}", self.table_path, file_name);
+        let input = self
+            .file_io
+            .new_input(&path)
+            .map_err(|e| std::io::Error::other(e.to_string()))?;
+        let file_size = input
+            .metadata()
+            .await
+            .map_err(|e| std::io::Error::other(e.to_string()))?
+            .size;
+        let file_reader = input
+            .reader()
+            .await
+            .map_err(|e| std::io::Error::other(e.to_string()))?;
+        BitmapGlobalIndexReader::open(Box::new(file_reader), file_size).await
+    }
+
+    fn fallback_scan_plan(
+        &self,
+        entries: &[GlobalIndexEntry],
+        selected: &[bool],
+    ) -> FallbackScanPlan {
+        let mut plan = FallbackScanPlan::default();
+        let mut btree_total = 0i64;
+        let mut bitmap_total = 0i64;
+        let mut btree_valid = true;
+        let mut bitmap_valid = true;
+
+        for (entry, selected) in entries.iter().zip(selected) {
+            if !selected {
+                continue;
+            }
+            match entry.index_type {
+                GlobalIndexFileKind::BTree => {
+                    plan.selected_btree += 1;
+                    btree_valid &= add_file_size(&mut btree_total, 
entry.file_size);
+                }
+                GlobalIndexFileKind::Bitmap => {
+                    plan.selected_bitmap += 1;
+                    bitmap_valid &= add_file_size(&mut bitmap_total, 
entry.file_size);
+                }
+            }
+        }
+
+        plan.allow_btree = plan.selected_btree > 0
+            && btree_valid
+            && self.btree_fallback_scan_max_size > 0
+            && btree_total <= self.btree_fallback_scan_max_size;
+        plan.allow_bitmap = plan.selected_bitmap > 0
+            && bitmap_valid
+            && self.bitmap_fallback_scan_max_size > 0
+            && bitmap_total <= self.bitmap_fallback_scan_max_size;
+        plan
+    }
+
     /// Return a reader to the cache for future reuse.
     fn return_reader(&self, file_name: String, reader: 
BTreeIndexReader<BoxedCmp>) {
         let mut cache = self.reader_cache.lock().unwrap();
@@ -512,10 +754,10 @@ impl GlobalIndexScanner {
     }
 }
 
-/// Whether the b-tree global index can evaluate this operator directly.
+/// Whether the sorted global index can evaluate this operator directly.
 /// Operators that fall outside this set bypass the index and are evaluated
 /// later in the read pipeline (stats prune + parquet row filter).
-fn is_btree_supported_op(op: PredicateOperator) -> bool {
+fn is_sorted_global_index_supported_op(op: PredicateOperator) -> bool {
     matches!(
         op,
         PredicateOperator::Eq
@@ -530,9 +772,49 @@ fn is_btree_supported_op(op: PredicateOperator) -> bool {
             | PredicateOperator::IsNotNull
             | PredicateOperator::Between
             | PredicateOperator::NotBetween
+            | PredicateOperator::StartsWith
+            | PredicateOperator::EndsWith
+            | PredicateOperator::Contains
+            | PredicateOperator::Like
     )
 }
 
+fn requires_fallback_scan(op: PredicateOperator) -> bool {
+    matches!(
+        op,
+        PredicateOperator::Lt
+            | PredicateOperator::LtEq
+            | PredicateOperator::Gt
+            | PredicateOperator::GtEq
+            | PredicateOperator::Between
+            | PredicateOperator::NotBetween
+            | PredicateOperator::EndsWith
+            | PredicateOperator::Contains
+            | PredicateOperator::Like
+    )
+}
+
+fn fallback_plan_evaluates_entry(
+    plan: FallbackScanPlan,
+    kind: GlobalIndexFileKind,
+    selected: bool,
+) -> bool {
+    !selected || plan.allowed(kind)
+}
+
+fn add_file_size(total: &mut i64, file_size: i64) -> bool {
+    if file_size < 0 {
+        return false;
+    }
+    match total.checked_add(file_size) {
+        Some(next) => {
+            *total = next;
+            true
+        }
+        None => false,
+    }
+}
+
 /// Convert a RoaringTreemap to merged RowRanges (already sorted and 
deduplicated).
 fn bitmap_to_ranges(bitmap: &RoaringTreemap) -> Vec<RowRange> {
     if bitmap.is_empty() {
@@ -938,6 +1220,8 @@ pub(crate) struct GlobalIndexEvaluation<'a> {
     pub(crate) predicates: &'a [Predicate],
     pub(crate) schema_fields: &'a [DataField],
     pub(crate) search_mode: GlobalIndexSearchMode,
+    pub(crate) btree_fallback_scan_max_size: i64,
+    pub(crate) bitmap_fallback_scan_max_size: i64,
     pub(crate) next_row_id: Option<i64>,
     pub(crate) data_ranges: &'a [RowRange],
 }
@@ -948,6 +1232,8 @@ pub(crate) async fn evaluate_global_index(
     let scanner = match GlobalIndexScanner::create(
         evaluation.file_io,
         evaluation.table_path,
+        evaluation.btree_fallback_scan_max_size,
+        evaluation.bitmap_fallback_scan_max_size,
         evaluation.index_entries,
         evaluation.schema_fields,
     ) {
@@ -1128,12 +1414,50 @@ mod tests {
         (file_io, table_path, testdata_name.to_string(), tmp)
     }
 
+    type JavaBitmapTestdataTable = (FileIO, String, String, BTreeIndexMeta, 
tempfile::TempDir);
+
+    fn setup_java_bitmap_testdata_table() -> JavaBitmapTestdataTable {
+        const FILE_NAME: &str = "bitmap_varchar_java.index";
+        let src = format!("{}/testdata/bitmap/{FILE_NAME}", 
env!("CARGO_MANIFEST_DIR"));
+        let meta_src = format!(
+            "{}/testdata/bitmap/{FILE_NAME}.meta",
+            env!("CARGO_MANIFEST_DIR")
+        );
+        let tmp = tempfile::tempdir().unwrap();
+        let index_dir = tmp.path().join("index");
+        std::fs::create_dir_all(&index_dir).unwrap();
+        std::fs::copy(&src, index_dir.join(FILE_NAME)).unwrap();
+        let meta = 
BTreeIndexMeta::deserialize(&std::fs::read(meta_src).unwrap()).unwrap();
+
+        let table_path = format!("file://{}", tmp.path().display());
+        let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
+        (file_io, table_path, FILE_NAME.to_string(), meta, tmp)
+    }
+
     fn make_global_index_entry(
         file_name: &str,
         field_id: i32,
         row_range_start: i64,
         row_range_end: i64,
         meta: &BTreeIndexMeta,
+    ) -> crate::spec::IndexManifestEntry {
+        make_global_index_entry_with_type(
+            BTREE_GLOBAL_INDEX_TYPE,
+            file_name,
+            field_id,
+            row_range_start,
+            row_range_end,
+            meta,
+        )
+    }
+
+    fn make_global_index_entry_with_type(
+        index_type: &str,
+        file_name: &str,
+        field_id: i32,
+        row_range_start: i64,
+        row_range_end: i64,
+        meta: &BTreeIndexMeta,
     ) -> crate::spec::IndexManifestEntry {
         use crate::spec::{GlobalIndexMeta, IndexFileMeta};
         IndexManifestEntry {
@@ -1142,7 +1466,7 @@ mod tests {
             partition: vec![],
             bucket: 0,
             index_file: IndexFileMeta {
-                index_type: BTREE_INDEX_TYPE.to_string(),
+                index_type: index_type.to_string(),
                 file_name: file_name.to_string(),
                 file_size: 0,
                 row_count: 0,
@@ -1166,12 +1490,41 @@ mod tests {
         )]
     }
 
+    fn string_schema_fields() -> Vec<DataField> {
+        vec![DataField::new(
+            1,
+            "name".to_string(),
+            DataType::VarChar(crate::spec::VarCharType::string_type()),
+        )]
+    }
+
     async fn evaluate_global_index_fast(
         file_io: &FileIO,
         table_path: &str,
         entries: &[IndexManifestEntry],
         predicates: &[Predicate],
         fields: &[DataField],
+    ) -> Result<Option<Vec<RowRange>>> {
+        evaluate_global_index_fast_with_fallback_size(
+            file_io,
+            table_path,
+            entries,
+            predicates,
+            fields,
+            i64::MAX,
+            i64::MAX,
+        )
+        .await
+    }
+
+    async fn evaluate_global_index_fast_with_fallback_size(
+        file_io: &FileIO,
+        table_path: &str,
+        entries: &[IndexManifestEntry],
+        predicates: &[Predicate],
+        fields: &[DataField],
+        btree_fallback_scan_max_size: i64,
+        bitmap_fallback_scan_max_size: i64,
     ) -> Result<Option<Vec<RowRange>>> {
         super::evaluate_global_index(super::GlobalIndexEvaluation {
             file_io,
@@ -1180,6 +1533,8 @@ mod tests {
             predicates,
             schema_fields: fields,
             search_mode: GlobalIndexSearchMode::Fast,
+            btree_fallback_scan_max_size,
+            bitmap_fallback_scan_max_size,
             next_row_id: None,
             data_ranges: &[],
         })
@@ -1217,8 +1572,15 @@ mod tests {
         let meta = BTreeIndexMeta::new(None, None, false);
         let entries = vec![make_global_index_entry("idx", 1, 0, 49, &meta)];
         let fields = int_schema_fields();
-        let scanner =
-            GlobalIndexScanner::create(&file_io, "memory:/t", &entries, 
&fields).expect("scanner");
+        let scanner = GlobalIndexScanner::create(
+            &file_io,
+            "memory:/t",
+            i64::MAX,
+            i64::MAX,
+            &entries,
+            &fields,
+        )
+        .expect("scanner");
 
         let ranges = scanner
             .unindexed_ranges(
@@ -1237,8 +1599,15 @@ mod tests {
         let meta = BTreeIndexMeta::new(None, None, false);
         let entries = vec![make_global_index_entry("idx", 1, 0, 49, &meta)];
         let fields = int_schema_fields();
-        let scanner =
-            GlobalIndexScanner::create(&file_io, "memory:/t", &entries, 
&fields).expect("scanner");
+        let scanner = GlobalIndexScanner::create(
+            &file_io,
+            "memory:/t",
+            i64::MAX,
+            i64::MAX,
+            &entries,
+            &fields,
+        )
+        .expect("scanner");
 
         let ranges = scanner
             .unindexed_ranges(
@@ -1257,8 +1626,15 @@ mod tests {
         let meta = BTreeIndexMeta::new(None, None, false);
         let entries = vec![make_global_index_entry("idx", 1, 0, 49, &meta)];
         let fields = int_schema_fields();
-        let scanner =
-            GlobalIndexScanner::create(&file_io, "memory:/t", &entries, 
&fields).expect("scanner");
+        let scanner = GlobalIndexScanner::create(
+            &file_io,
+            "memory:/t",
+            i64::MAX,
+            i64::MAX,
+            &entries,
+            &fields,
+        )
+        .expect("scanner");
 
         let ranges = scanner
             .unindexed_ranges(
@@ -1284,8 +1660,15 @@ mod tests {
             make_global_index_entry("idx_value", 2, 0, 99, &meta),
         ];
         let fields = two_field_schema_fields();
-        let scanner =
-            GlobalIndexScanner::create(&file_io, "memory:/t", &entries, 
&fields).expect("scanner");
+        let scanner = GlobalIndexScanner::create(
+            &file_io,
+            "memory:/t",
+            i64::MAX,
+            i64::MAX,
+            &entries,
+            &fields,
+        )
+        .expect("scanner");
         let predicate = Predicate::and(vec![int_eq("id", 0, 7), 
int_eq("value", 1, 8)]);
 
         let ranges = scanner
@@ -1300,8 +1683,15 @@ mod tests {
         let meta = BTreeIndexMeta::new(None, None, false);
         let entries = vec![make_global_index_entry("idx_id", 1, 0, 49, &meta)];
         let fields = two_field_schema_fields();
-        let scanner =
-            GlobalIndexScanner::create(&file_io, "memory:/t", &entries, 
&fields).expect("scanner");
+        let scanner = GlobalIndexScanner::create(
+            &file_io,
+            "memory:/t",
+            i64::MAX,
+            i64::MAX,
+            &entries,
+            &fields,
+        )
+        .expect("scanner");
         let predicate = Predicate::and(vec![int_eq("id", 0, 7), 
int_eq("value", 1, 8)]);
 
         let ranges = scanner
@@ -1322,8 +1712,15 @@ mod tests {
             .unwrap()
             .extra_field_ids = Some(vec![2]);
         let fields = two_field_schema_fields();
-        let scanner =
-            GlobalIndexScanner::create(&file_io, "memory:/t", &[entry], 
&fields).expect("scanner");
+        let scanner = GlobalIndexScanner::create(
+            &file_io,
+            "memory:/t",
+            i64::MAX,
+            i64::MAX,
+            &[entry],
+            &fields,
+        )
+        .expect("scanner");
 
         let ranges = scanner
             .unindexed_ranges(
@@ -1386,6 +1783,343 @@ mod tests {
         assert_eq!(ranges, vec![RowRange::new(25, 25)]);
     }
 
+    #[tokio::test]
+    async fn test_evaluate_java_bitmap_golden_index_eq_and_null() {
+        let data_type = 
DataType::VarChar(crate::spec::VarCharType::string_type());
+        let (file_io, table_path, file_name, meta, _tmp) = 
setup_java_bitmap_testdata_table();
+        let entries = vec![make_global_index_entry_with_type(
+            BITMAP_GLOBAL_INDEX_TYPE,
+            &file_name,
+            1,
+            100,
+            109,
+            &meta,
+        )];
+        let fields = string_schema_fields();
+        assert_eq!(meta.first_key, Some(b"alpha".to_vec()));
+        assert_eq!(meta.last_key, Some(b"office".to_vec()));
+        assert!(meta.has_nulls);
+
+        let eq_predicates = vec![Predicate::Leaf {
+            column: "name".to_string(),
+            index: 0,
+            data_type: data_type.clone(),
+            op: PredicateOperator::Eq,
+            literals: vec![Datum::String("k2".to_string())],
+        }];
+        let eq_result =
+            evaluate_global_index_fast(&file_io, &table_path, &entries, 
&eq_predicates, &fields)
+                .await
+                .unwrap();
+        assert_eq!(eq_result.unwrap(), vec![RowRange::new(105, 106)]);
+
+        let null_predicates = vec![Predicate::Leaf {
+            column: "name".to_string(),
+            index: 0,
+            data_type,
+            op: PredicateOperator::IsNull,
+            literals: vec![],
+        }];
+        let null_result =
+            evaluate_global_index_fast(&file_io, &table_path, &entries, 
&null_predicates, &fields)
+                .await
+                .unwrap();
+        assert_eq!(null_result.unwrap(), vec![RowRange::new(104, 104)]);
+    }
+
+    #[tokio::test]
+    async fn test_evaluate_java_bitmap_golden_index_string_fallback_scan() {
+        let data_type = 
DataType::VarChar(crate::spec::VarCharType::string_type());
+        let (file_io, table_path, file_name, meta, _tmp) = 
setup_java_bitmap_testdata_table();
+        let entries = vec![make_global_index_entry_with_type(
+            BITMAP_GLOBAL_INDEX_TYPE,
+            &file_name,
+            1,
+            100,
+            109,
+            &meta,
+        )];
+        let fields = string_schema_fields();
+
+        let ends_with_predicates = vec![Predicate::Leaf {
+            column: "name".to_string(),
+            index: 0,
+            data_type: data_type.clone(),
+            op: PredicateOperator::EndsWith,
+            literals: vec![Datum::String("ta".to_string())],
+        }];
+        let ends_with_result = evaluate_global_index_fast(
+            &file_io,
+            &table_path,
+            &entries,
+            &ends_with_predicates,
+            &fields,
+        )
+        .await
+        .unwrap();
+        assert_eq!(
+            ends_with_result.unwrap(),
+            vec![RowRange::new(101, 101), RowRange::new(103, 103)]
+        );
+
+        let contains_predicates = vec![Predicate::Leaf {
+            column: "name".to_string(),
+            index: 0,
+            data_type: data_type.clone(),
+            op: PredicateOperator::Contains,
+            literals: vec![Datum::String("ph".to_string())],
+        }];
+        let contains_result = evaluate_global_index_fast(
+            &file_io,
+            &table_path,
+            &entries,
+            &contains_predicates,
+            &fields,
+        )
+        .await
+        .unwrap();
+        assert_eq!(
+            contains_result.unwrap(),
+            vec![RowRange::new(100, 100), RowRange::new(102, 102)]
+        );
+
+        let like_predicates = vec![Predicate::Leaf {
+            column: "name".to_string(),
+            index: 0,
+            data_type: data_type.clone(),
+            op: PredicateOperator::Like,
+            literals: vec![Datum::String("%ha%".to_string())],
+        }];
+        let like_result =
+            evaluate_global_index_fast(&file_io, &table_path, &entries, 
&like_predicates, &fields)
+                .await
+                .unwrap();
+        assert_eq!(
+            like_result.unwrap(),
+            vec![RowRange::new(100, 100), RowRange::new(102, 102)]
+        );
+
+        let less_than_predicates = vec![Predicate::Leaf {
+            column: "name".to_string(),
+            index: 0,
+            data_type: data_type.clone(),
+            op: PredicateOperator::Lt,
+            literals: vec![Datum::String("delta".to_string())],
+        }];
+        let less_than_result = evaluate_global_index_fast(
+            &file_io,
+            &table_path,
+            &entries,
+            &less_than_predicates,
+            &fields,
+        )
+        .await
+        .unwrap();
+        assert_eq!(less_than_result.unwrap(), vec![RowRange::new(100, 102)]);
+
+        let mut over_limit_entries = vec![make_global_index_entry_with_type(
+            BITMAP_GLOBAL_INDEX_TYPE,
+            &file_name,
+            1,
+            100,
+            109,
+            &meta,
+        )];
+        over_limit_entries[0].index_file.file_size = 2;
+        let over_limit_less_than = 
evaluate_global_index_fast_with_fallback_size(
+            &file_io,
+            &table_path,
+            &over_limit_entries,
+            &less_than_predicates,
+            &fields,
+            i64::MAX,
+            1,
+        )
+        .await
+        .unwrap();
+        assert!(
+            over_limit_less_than.is_none(),
+            "range predicates require fallback dictionary scans and should be 
unsupported over budget"
+        );
+
+        let no_match_contains = vec![Predicate::Leaf {
+            column: "name".to_string(),
+            index: 0,
+            data_type: data_type.clone(),
+            op: PredicateOperator::Contains,
+            literals: vec![Datum::String("zz".to_string())],
+        }];
+        let over_limit_result = evaluate_global_index_fast_with_fallback_size(
+            &file_io,
+            &table_path,
+            &over_limit_entries,
+            &no_match_contains,
+            &fields,
+            i64::MAX,
+            1,
+        )
+        .await
+        .unwrap();
+        assert!(
+            over_limit_result.is_none(),
+            "fallback scans over budget should be unsupported instead of 
returning full coverage"
+        );
+
+        let direct_with_over_limit_fallback = vec![Predicate::and(vec![
+            Predicate::Leaf {
+                column: "name".to_string(),
+                index: 0,
+                data_type: data_type.clone(),
+                op: PredicateOperator::Eq,
+                literals: vec![Datum::String("k2".to_string())],
+            },
+            Predicate::Leaf {
+                column: "name".to_string(),
+                index: 0,
+                data_type,
+                op: PredicateOperator::Contains,
+                literals: vec![Datum::String("zz".to_string())],
+            },
+        ])];
+        let direct_result = evaluate_global_index_fast_with_fallback_size(
+            &file_io,
+            &table_path,
+            &over_limit_entries,
+            &direct_with_over_limit_fallback,
+            &fields,
+            i64::MAX,
+            1,
+        )
+        .await
+        .unwrap();
+        assert_eq!(direct_result.unwrap(), vec![RowRange::new(105, 106)]);
+    }
+
+    #[tokio::test]
+    async fn test_btree_fallback_scan_over_limit_is_unsupported() {
+        let (file_io, table_path, file_name, tmp) =
+            setup_testdata_table("btree_varchar_100_no_compress.bin");
+        let meta = BTreeIndexMeta::new(Some(b"a".to_vec()), 
Some(b"yyyy".to_vec()), false);
+        let fields = string_schema_fields();
+        let data_type = 
DataType::VarChar(crate::spec::VarCharType::string_type());
+        let predicates = vec![Predicate::Leaf {
+            column: "name".to_string(),
+            index: 0,
+            data_type,
+            op: PredicateOperator::Contains,
+            literals: vec![Datum::String("not-present".to_string())],
+        }];
+
+        let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, 
&meta)];
+        let exact_result = evaluate_global_index_fast_with_fallback_size(
+            &file_io,
+            &table_path,
+            &entries,
+            &predicates,
+            &fields,
+            i64::MAX,
+            i64::MAX,
+        )
+        .await
+        .unwrap();
+        assert_eq!(exact_result.unwrap(), Vec::<RowRange>::new());
+
+        let mut over_limit_entries = vec![make_global_index_entry(&file_name, 
1, 0, 99, &meta)];
+        over_limit_entries[0].index_file.file_size = 2;
+        let over_limit_result = evaluate_global_index_fast_with_fallback_size(
+            &file_io,
+            &table_path,
+            &over_limit_entries,
+            &predicates,
+            &fields,
+            1,
+            i64::MAX,
+        )
+        .await
+        .unwrap();
+        assert!(
+            over_limit_result.is_none(),
+            "fallback scans over budget should be unsupported instead of 
returning full coverage"
+        );
+
+        let second_file_name = "btree_varchar_100_no_compress_2.bin";
+        std::fs::copy(
+            tmp.path().join("index").join(&file_name),
+            tmp.path().join("index").join(second_file_name),
+        )
+        .unwrap();
+        let mut first = make_global_index_entry(&file_name, 1, 0, 99, &meta);
+        first.index_file.file_size = 1;
+        let mut second = make_global_index_entry(second_file_name, 1, 100, 
199, &meta);
+        second.index_file.file_size = 1;
+        let total_over_limit_result = 
evaluate_global_index_fast_with_fallback_size(
+            &file_io,
+            &table_path,
+            &[first, second],
+            &predicates,
+            &fields,
+            1,
+            i64::MAX,
+        )
+        .await
+        .unwrap();
+        assert!(
+            total_over_limit_result.is_none(),
+            "fallback budget should use selected files' total size, not 
per-file size"
+        );
+    }
+
+    #[tokio::test]
+    async fn 
test_fallback_scan_over_limit_with_mixed_index_kinds_is_unsupported() {
+        let (file_io, table_path, file_name, _tmp) =
+            setup_testdata_table("btree_varchar_100_no_compress.bin");
+        let btree_meta = BTreeIndexMeta::new(Some(b"a".to_vec()), 
Some(b"yyyy".to_vec()), false);
+        let bitmap_meta = BTreeIndexMeta::new(Some(b"m".to_vec()), 
Some(b"z".to_vec()), false);
+        let fields = string_schema_fields();
+        let predicates = vec![Predicate::Leaf {
+            column: "name".to_string(),
+            index: 0,
+            data_type: 
DataType::VarChar(crate::spec::VarCharType::string_type()),
+            op: PredicateOperator::Lt,
+            literals: vec![Datum::String("delta".to_string())],
+        }];
+
+        let mut btree = make_global_index_entry_with_type(
+            BTREE_GLOBAL_INDEX_TYPE,
+            &file_name,
+            1,
+            0,
+            99,
+            &btree_meta,
+        );
+        btree.index_file.file_size = 2;
+        let mut bitmap = make_global_index_entry_with_type(
+            BITMAP_GLOBAL_INDEX_TYPE,
+            "bitmap-no-match.index",
+            1,
+            100,
+            199,
+            &bitmap_meta,
+        );
+        bitmap.index_file.file_size = 1;
+
+        let result = evaluate_global_index_fast_with_fallback_size(
+            &file_io,
+            &table_path,
+            &[btree, bitmap],
+            &predicates,
+            &fields,
+            1,
+            i64::MAX,
+        )
+        .await
+        .unwrap();
+        assert!(
+            result.is_none(),
+            "an over-budget selected BTree file must stay unsupported even if 
bitmap files are pruned by metadata"
+        );
+    }
+
     #[tokio::test]
     async fn test_evaluate_global_index_full_mode_includes_unindexed_tail() {
         let (file_io, table_path, file_name, _tmp) =
@@ -1402,6 +2136,8 @@ mod tests {
             predicates: &predicates,
             schema_fields: &fields,
             search_mode: GlobalIndexSearchMode::Full,
+            btree_fallback_scan_max_size: i64::MAX,
+            bitmap_fallback_scan_max_size: i64::MAX,
             next_row_id: Some(150),
             data_ranges: &[],
         })
@@ -1452,6 +2188,8 @@ mod tests {
             predicates: &predicates,
             schema_fields: &fields,
             search_mode: GlobalIndexSearchMode::Full,
+            btree_fallback_scan_max_size: i64::MAX,
+            bitmap_fallback_scan_max_size: i64::MAX,
             next_row_id: Some(100),
             data_ranges: &[],
         })
@@ -1483,6 +2221,8 @@ mod tests {
             predicates: &predicates,
             schema_fields: &fields,
             search_mode: GlobalIndexSearchMode::Detail,
+            btree_fallback_scan_max_size: i64::MAX,
+            bitmap_fallback_scan_max_size: i64::MAX,
             next_row_id: Some(150),
             data_ranges: &data_ranges,
         })
@@ -1531,6 +2271,24 @@ mod tests {
                 .unwrap();
         let ranges = result.unwrap();
         assert_eq!(ranges, vec![RowRange::new(5, 10)]);
+
+        let mut over_limit_entries = vec![make_global_index_entry(&file_name, 
1, 0, 99, &meta)];
+        over_limit_entries[0].index_file.file_size = 2;
+        let over_limit_result = evaluate_global_index_fast_with_fallback_size(
+            &file_io,
+            &table_path,
+            &over_limit_entries,
+            &predicates,
+            &fields,
+            1,
+            i64::MAX,
+        )
+        .await
+        .unwrap();
+        assert!(
+            over_limit_result.is_none(),
+            "between/range predicates require fallback scans and should be 
unsupported over budget"
+        );
     }
 
     #[tokio::test]
diff --git a/crates/paimon/src/table/global_index_types.rs 
b/crates/paimon/src/table/global_index_types.rs
new file mode 100644
index 0000000..bfa4430
--- /dev/null
+++ b/crates/paimon/src/table/global_index_types.rs
@@ -0,0 +1,29 @@
+// 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.
+
+pub(crate) const BTREE_GLOBAL_INDEX_TYPE: &str = "btree";
+pub(crate) const BITMAP_GLOBAL_INDEX_TYPE: &str = "bitmap";
+
+pub(crate) fn normalize_sorted_global_index_type(index_type: &str) -> 
Option<&'static str> {
+    if index_type.eq_ignore_ascii_case(BTREE_GLOBAL_INDEX_TYPE) {
+        Some(BTREE_GLOBAL_INDEX_TYPE)
+    } else if index_type.eq_ignore_ascii_case(BITMAP_GLOBAL_INDEX_TYPE) {
+        Some(BITMAP_GLOBAL_INDEX_TYPE)
+    } else {
+        None
+    }
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 9e6b623..373efa5 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -19,6 +19,7 @@
 
 pub(crate) mod aggregator;
 pub(crate) mod bin_pack;
+mod bitmap_global_index_reader;
 mod blob_file_writer;
 mod branch_manager;
 mod btree_global_index_build_builder;
@@ -39,6 +40,7 @@ mod data_file_writer;
 #[cfg(feature = "fulltext")]
 mod full_text_search_builder;
 pub(crate) mod global_index_scanner;
+mod global_index_types;
 mod hybrid_search_builder;
 mod kv_file_reader;
 mod kv_file_writer;
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 115fb01..67184a2 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -1065,6 +1065,10 @@ impl<'a> TableScan<'a> {
         } else {
             Vec::new()
         };
+        let btree_index_fallback_scan_max_size =
+            core_options.btree_index_fallback_scan_max_size()?;
+        let bitmap_index_fallback_scan_max_size =
+            core_options.bitmap_index_fallback_scan_max_size()?;
 
         let snapshot_id = snapshot.id();
         let base_path = table_path.trim_end_matches('/');
@@ -1119,6 +1123,8 @@ impl<'a> TableScan<'a> {
                             predicates: &self.data_predicates,
                             schema_fields: self.table.schema().fields(),
                             search_mode,
+                            btree_fallback_scan_max_size: 
btree_index_fallback_scan_max_size,
+                            bitmap_fallback_scan_max_size: 
bitmap_index_fallback_scan_max_size,
                             next_row_id: snapshot.next_row_id(),
                             data_ranges: &global_index_detail_data_ranges,
                         },
diff --git a/crates/paimon/testdata/bitmap/bitmap_varchar_java.index 
b/crates/paimon/testdata/bitmap/bitmap_varchar_java.index
new file mode 100644
index 0000000..028fc46
Binary files /dev/null and 
b/crates/paimon/testdata/bitmap/bitmap_varchar_java.index differ
diff --git a/crates/paimon/testdata/bitmap/bitmap_varchar_java.index.meta 
b/crates/paimon/testdata/bitmap/bitmap_varchar_java.index.meta
new file mode 100644
index 0000000..5571240
Binary files /dev/null and 
b/crates/paimon/testdata/bitmap/bitmap_varchar_java.index.meta differ
diff --git a/docs/src/sql.md b/docs/src/sql.md
index b97a654..934eb27 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -648,10 +648,18 @@ CALL sys.create_global_index(
   index_column => 'id',
   index_type => 'btree'
 );
+
+CALL sys.create_global_index(
+  table => 'paimon.my_db.my_table',
+  index_column => 'tag',
+  index_type => 'bitmap'
+);
 ```
 
-`index_type` defaults to `btree`. BTree indexes support scalar columns and do
-not accept the `options` argument yet.
+`index_type` defaults to `btree`. BTree and bitmap global indexes support
+scalar columns and do not accept the `options` argument yet. Bitmap global
+indexes use the same on-disk file format as Java Paimon's
+`BitmapGlobalIndexFormat`.
 
 The current global-index builders require a row-tracking data-evolution table
 with global indexes enabled. They do not support primary-key tables or tables
@@ -726,7 +734,7 @@ FROM paimon.my_db.items$table_indexes;
 
 ### drop_global_index
 
-Drop a committed BTree global index:
+Drop a committed sorted global index:
 
 ```sql
 CALL sys.drop_global_index(
@@ -736,7 +744,7 @@ CALL sys.drop_global_index(
 );
 ```
 
-Only BTree indexes can be dropped through this procedure currently.
+BTree and bitmap global indexes can be dropped through this procedure 
currently.
 
 ### create_lumina_index
 
@@ -1384,7 +1392,7 @@ Columns:
 |---|---|---|
 | `partition` | STRING | Partition spec for the indexed data, or `NULL` for 
unpartitioned tables |
 | `bucket` | INT | Bucket id covered by the index file |
-| `index_type` | STRING | Index type, such as `btree`, `ivf-flat`, `lumina`, 
or `DELETION_VECTORS` |
+| `index_type` | STRING | Index type, such as `btree`, `bitmap`, `ivf-flat`, 
`lumina`, or `DELETION_VECTORS` |
 | `file_name` | STRING | Index file name under the table index directory |
 | `file_size` | BIGINT | Index file size in bytes |
 | `row_count` | BIGINT | Number of rows covered by the index file |
@@ -1516,6 +1524,8 @@ deletion vectors enabled.
 | `global-index.enabled` | `false` | Enables global index metadata and 
global-index-aware reads. |
 | `global-index.row-count-per-shard` | `100000` | Maximum row count per vector 
global-index shard. |
 | `sorted-index.records-per-range` | `100000` | Maximum row count per BTree 
range. |
+| `btree-index.fallback-scan-max-size` | `256mb` | Maximum total size of 
selected BTree global-index files for fallback scans used by range/between and 
suffix/contains/complex LIKE predicates; `0` disables BTree fallback index 
scans. |
+| `bitmap-index.fallback-scan-max-size` | `256mb` | Maximum total size of 
selected bitmap global-index files for fallback scans used by range/between and 
suffix/contains/complex LIKE predicates; `0` disables bitmap fallback index 
scans. |
 | `global-index.search-mode` | `fast` | Global index coverage mode for reads: 
`fast`, `full`, or `detail`. |
 
 ### Variant Shredding Options


Reply via email to