| // 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. |
| |
| //! PaimonTantivyReader: query side of tantivy-fulltext. |
| //! |
| //! Constructs a tantivy Index from a packed-blob produced by writer.rs (via |
| //! PaimonDirectory), registers the same `paimon_jieba` tokenizer, and runs |
| //! one of 5 search types (mirrors `paimon::FullTextSearch::SearchType`): |
| //! |
| //! 1 MATCH_ALL — tokenize query, BooleanQuery (Must) |
| //! 2 MATCH_ANY — tokenize query, BooleanQuery (Should) |
| //! 3 PHRASE — tokenize query, PhraseQuery |
| //! 4 PREFIX — original + case-normalized RegexQuery `<escaped>.*` (no tokenization) |
| //! 5 WILDCARD — original + case-normalized RegexQuery from glob pattern |
| //! (`*` → `.*`, `?` → `.`, others escaped) |
| //! |
| //! For paimon-java compatibility, row_id is stored as an explicit u64 field |
| //! (`fast` for O(1) retrieval). Reader translates tantivy DocAddress → row_id |
| //! via `fast_fields().u64("row_id").first(doc_id)` per segment. |
| //! |
| //! FFI return format (little-endian, **doc identifiers are u64 row_ids**): |
| //! `[u8 has_scores | u64 count | u64 row_id[count] | optional f32 score[count]]` |
| |
| use std::ffi::{c_char, CStr}; |
| use std::path::Path; |
| |
| use croaring::{Portable, Treemap}; |
| use tantivy::collector::{Collector, SegmentCollector}; |
| use tantivy::columnar::Column; |
| use tantivy::query::{ |
| BooleanQuery, DisjunctionMaxQuery, Occur, PhraseQuery, Query, RegexQuery, TermQuery, |
| }; |
| use tantivy::schema::{Field, IndexRecordOption}; |
| use tantivy::{DocAddress, DocId, Index, IndexReader, ReloadPolicy, Score, SegmentOrdinal, |
| SegmentReader, Term}; |
| |
| use crate::buffer::PaimonTantivyBuffer; |
| use crate::callback_directory::{PaimonCallbackDirectory, PaimonStreamCallbacks}; |
| use crate::error::{set_last_error, PaimonTantivyStatus}; |
| use crate::handle::{borrow_handle_mut, free_handle, into_handle}; |
| use crate::tokenizer::{normalize_case, PaimonJiebaTokenizer, TokenizeMode}; |
| use crate::writer::{PAIMON_ROW_ID_FIELD_NAME, PAIMON_TEXT_FIELD_NAME, PAIMON_TOKENIZER_NAME}; |
| |
| /// Numeric encoding of `paimon::FullTextSearch::SearchType`. Kept in sync |
| /// with include/paimon/predicate/full_text_search.h. |
| #[repr(i32)] |
| #[derive(Clone, Copy, Debug)] |
| pub enum SearchType { |
| MatchAll = 1, |
| MatchAny = 2, |
| Phrase = 3, |
| Prefix = 4, |
| Wildcard = 5, |
| } |
| |
| impl SearchType { |
| fn from_i32(v: i32) -> Option<Self> { |
| match v { |
| 1 => Some(Self::MatchAll), |
| 2 => Some(Self::MatchAny), |
| 3 => Some(Self::Phrase), |
| 4 => Some(Self::Prefix), |
| 5 => Some(Self::Wildcard), |
| _ => None, |
| } |
| } |
| } |
| |
| pub struct PaimonTantivyReader { |
| /// Held alive so `IndexReader::searcher()` + `index.tokenizers()` stay |
| /// usable for the reader's lifetime. |
| index: Index, |
| reader: IndexReader, |
| text_field: Field, |
| /// Name of the tokenizer the `text` field is actually bound to in the open |
| /// index's schema (read from `meta.json` at construction time). Query-side |
| /// tokenization looks this up in `index.tokenizers()` every time |
| tokenizer_name: String, |
| } |
| |
| impl PaimonTantivyReader { |
| /// Construct a reader from a pre-built callback-backed Directory. |
| /// Layout (file names + offsets + lengths) must come from the caller |
| /// (C++ side `ArchiveLayout::Parse`); Rust does not re-parse the archive. |
| pub fn new( |
| directory: PaimonCallbackDirectory, |
| mode: TokenizeMode, |
| with_position: bool, |
| dict_dir: &Path, |
| ) -> Result<Self, String> { |
| let index = Index::open(directory) |
| .map_err(|e| format!("tantivy::Index::open: {e}"))?; |
| |
| // Resolve fields by their fixed names (schema is `row_id` + `text`). |
| let schema = index.schema(); |
| let text_field = schema.get_field(PAIMON_TEXT_FIELD_NAME).map_err(|e| { |
| format!("tantivy index missing '{PAIMON_TEXT_FIELD_NAME}' field: {e}") |
| })?; |
| |
| // Read the tokenizer name the `text` field was actually written with |
| // (lives in meta.json's schema). Auto-aligns cpp query-side tokenizer |
| // with whatever the writer side used. |
| let tokenizer_name = match schema.get_field_entry(text_field).field_type() { |
| tantivy::schema::FieldType::Str(text_options) => text_options |
| .get_indexing_options() |
| .map(|io| io.tokenizer().to_string()) |
| .unwrap_or_else(|| "default".to_string()), |
| other => { |
| return Err(format!( |
| "text field has non-TEXT type: {other:?} (schema corrupted?)" |
| )); |
| } |
| }; |
| |
| // Only register paimon_jieba if the index actually uses it. The |
| // tantivy-builtin "default" / "raw" / "en_stem" etc. are pre-registered |
| // by the TokenizerManager — no setup needed for those. |
| if tokenizer_name == PAIMON_TOKENIZER_NAME { |
| // `Path::is_empty` is unstable; check via OsStr. |
| if dict_dir.as_os_str().is_empty() { |
| return Err(format!( |
| "paimon_jieba tokenizer required by archive schema but dict dir \ |
| is empty — set the PAIMON_JIEBA_DICT_DIR env var to a directory \ |
| containing jieba.dict.utf8 / hmm_model.utf8 / user.dict.utf8 / \ |
| idf.utf8 / stop_words.utf8" |
| )); |
| } |
| let jieba = PaimonJiebaTokenizer::new(dict_dir, mode, with_position) |
| .map_err(|e| format!("create paimon_jieba tokenizer: {e}"))?; |
| index.tokenizers().register(PAIMON_TOKENIZER_NAME, jieba); |
| } else { |
| // For other known-safe names we trust tantivy's builtin registry. |
| // `mode` / `dict_dir` are unused in this branch — no-op; we still |
| // require them in the ABI for backward-compat with the jieba case. |
| let _ = (mode, dict_dir); |
| } |
| |
| // Sanity: the tokenizer MUST be resolvable now; otherwise query-time |
| // lookup fails mid-flight. |
| if index.tokenizers().get(&tokenizer_name).is_none() { |
| return Err(format!( |
| "tokenizer {tokenizer_name:?} referenced by text field is not \ |
| registered; add it to TokenizerManager before opening the reader" |
| )); |
| } |
| |
| let reader = index |
| .reader_builder() |
| .reload_policy(ReloadPolicy::Manual) |
| .try_into() |
| .map_err(|e| format!("build IndexReader: {e}"))?; |
| |
| Ok(Self { |
| index, |
| reader, |
| text_field, |
| tokenizer_name, |
| }) |
| } |
| |
| /// Tokenize the query string using the *same* tokenizer the index's text |
| /// field was built with. Looks up `self.tokenizer_name` in the index's |
| /// `TokenizerManager` — which was populated by `new()` with either |
| /// `paimon_jieba` (if cpp wrote the index) or a tantivy builtin like |
| /// `default` (if paimon-java wrote it). |
| fn tokenize_query(&self, query: &str) -> Vec<String> { |
| // `TokenizerManager::get` returns a fresh clone per call — safe to use |
| // across threads / calls. If the tokenizer was missing we'd have |
| // failed in `new()`; we still defend with `unwrap_or_default`. |
| let mut analyzer = match self.index.tokenizers().get(&self.tokenizer_name) { |
| Some(a) => a, |
| None => return Vec::new(), |
| }; |
| let mut stream = analyzer.token_stream(query); |
| let mut out = Vec::new(); |
| while stream.advance() { |
| out.push(stream.token().text.clone()); |
| } |
| out |
| } |
| |
| fn build_match_query(&self, query: &str, occur: Occur) -> Result<Box<dyn Query>, String> { |
| let terms = self.tokenize_query(query); |
| if terms.is_empty() { |
| return Err(format!("query {query:?} produced no tokens after analysis")); |
| } |
| if terms.len() == 1 { |
| let term = Term::from_field_text(self.text_field, &terms[0]); |
| return Ok(Box::new(TermQuery::new(term, IndexRecordOption::WithFreqs))); |
| } |
| let clauses: Vec<(Occur, Box<dyn Query>)> = terms |
| .iter() |
| .map(|t| { |
| let term = Term::from_field_text(self.text_field, t); |
| let q: Box<dyn Query> = |
| Box::new(TermQuery::new(term, IndexRecordOption::WithFreqs)); |
| (occur, q) |
| }) |
| .collect(); |
| Ok(Box::new(BooleanQuery::new(clauses))) |
| } |
| |
| fn build_phrase_query(&self, query: &str) -> Result<Box<dyn Query>, String> { |
| let terms = self.tokenize_query(query); |
| if terms.is_empty() { |
| return Err(format!("phrase query {query:?} produced no tokens")); |
| } |
| if terms.len() == 1 { |
| // PhraseQuery requires >=2 terms in tantivy; degrade to TermQuery. |
| let term = Term::from_field_text(self.text_field, &terms[0]); |
| return Ok(Box::new(TermQuery::new(term, IndexRecordOption::WithFreqsAndPositions))); |
| } |
| let tantivy_terms: Vec<Term> = terms |
| .iter() |
| .map(|t| Term::from_field_text(self.text_field, t)) |
| .collect(); |
| Ok(Box::new(PhraseQuery::new(tantivy_terms))) |
| } |
| |
| fn build_prefix_query(&self, query: &str) -> Result<Box<dyn Query>, String> { |
| if query.is_empty() { |
| return Err("prefix query is empty".into()); |
| } |
| // Mirror lucene-fts: don't tokenize the prefix. Retain the original form for mixed |
| // ASCII/CJK indexed terms, and add the normalized form for pure ASCII terms. |
| let normalized_query = normalize_case(query); |
| let create_query = |value: &str| -> Result<Box<dyn Query>, String> { |
| let pattern = format!("{}.*", regex_escape(value)); |
| RegexQuery::from_pattern(&pattern, self.text_field) |
| .map(|q| Box::new(q) as Box<dyn Query>) |
| .map_err(|e| format!("RegexQuery from prefix {value:?}: {e}")) |
| }; |
| let original = create_query(query)?; |
| if normalized_query == query { |
| return Ok(original); |
| } |
| let normalized = create_query(&normalized_query)?; |
| Ok(Box::new(DisjunctionMaxQuery::new(vec![ |
| original, normalized, |
| ]))) |
| } |
| |
| fn build_wildcard_query(&self, query: &str) -> Result<Box<dyn Query>, String> { |
| if query.is_empty() { |
| return Err("wildcard query is empty".into()); |
| } |
| let normalized_query = normalize_wildcard_query(query); |
| let create_query = |value: &str| -> Result<Box<dyn Query>, String> { |
| let pattern = wildcard_to_regex(value); |
| RegexQuery::from_pattern(&pattern, self.text_field) |
| .map(|q| Box::new(q) as Box<dyn Query>) |
| .map_err(|e| format!("RegexQuery from wildcard {value:?} (pattern {pattern}): {e}")) |
| }; |
| let original = create_query(query)?; |
| if normalized_query == query { |
| return Ok(original); |
| } |
| let normalized = create_query(&normalized_query)?; |
| Ok(Box::new(DisjunctionMaxQuery::new(vec![ |
| original, normalized, |
| ]))) |
| } |
| |
| fn build_query(&self, search_type: SearchType, query: &str) -> Result<Box<dyn Query>, String> { |
| match search_type { |
| SearchType::MatchAll => self.build_match_query(query, Occur::Must), |
| SearchType::MatchAny => self.build_match_query(query, Occur::Should), |
| SearchType::Phrase => self.build_phrase_query(query), |
| SearchType::Prefix => self.build_prefix_query(query), |
| SearchType::Wildcard => self.build_wildcard_query(query), |
| } |
| } |
| |
| /// Return all matching row_ids (no scoring, no limit, no pre_filter). |
| /// row_ids come from the explicit `row_id` u64 fast field, supporting |
| /// multi-segment indexes (e.g. produced by paimon-java without force-merge). |
| pub fn search_all(&self, search_type: SearchType, query: &str) -> Result<Vec<u64>, String> { |
| let q = self.build_query(search_type, query)?; |
| let searcher = self.reader.searcher(); |
| let mut ids: Vec<u64> = searcher |
| .search(&*q, &RowIdCollector) |
| .map_err(|e| format!("tantivy search: {e}"))?; |
| ids.sort_unstable(); |
| ids.dedup(); |
| Ok(ids) |
| } |
| |
| /// Four-path dispatch on `(with_score, limit)`. |
| /// |
| /// | with_score | limit | path | collector | sort | truncate | output score | |
| /// |------------|--------|------|------------------------|----------------|----------|--------------| |
| /// | false | None | A | RowIdCollector | row_id asc | — | ❌ | |
| /// | false | Some(n)| B | AllScoredCollector | score desc | top n | ❌ (dropped) | |
| /// | true | None | C | AllScoredCollector | row_id asc | — | ✅ | |
| /// | true | Some(n)| D | AllScoredCollector | score desc | top n | ✅ | |
| /// |
| /// Pre-filter is a `Treemap` of paimon row_ids (not tantivy doc_ids), applied BEFORE |
| /// truncation so high-score matches outside the filter don't crowd out valid ones. |
| /// |
| /// **v0.2 contract change**: previously `limit.is_some()` implicitly triggered scoring; now |
| /// scoring is gated solely by `with_score`. |
| pub fn search_with_limit_and_filter( |
| &self, |
| search_type: SearchType, |
| query: &str, |
| with_score: bool, |
| limit: Option<usize>, |
| pre_filter: Option<&Treemap>, |
| min_score: Option<f32>, |
| ) -> Result<Vec<(u64, Option<f32>)>, String> { |
| let q = self.build_query(search_type, query)?; |
| let searcher = self.reader.searcher(); |
| match (with_score, limit) { |
| // Path A: all rows, no score. RowIdCollector reads the `row_id` fast |
| // field inline per segment (opened once), avoiding a DocSetCollector |
| // HashSet and per-doc handle — hot path for high-cardinality counts. |
| (false, None) => { |
| let mut row_ids: Vec<u64> = searcher |
| .search(&*q, &RowIdCollector) |
| .map_err(|e| format!("tantivy search: {e}"))?; |
| if let Some(filter) = pre_filter { |
| row_ids.retain(|id| filter.contains(*id)); |
| } |
| row_ids.sort_unstable(); |
| row_ids.dedup(); |
| Ok(row_ids.into_iter().map(|id| (id, None)).collect()) |
| } |
| // Path B: any N matches, unscored. Used by SR's `WHERE MATCH ... LIMIT N` (no |
| // ORDER BY): pushes the limit down so each shard stops collecting once N hits |
| // are gathered per segment instead of materialising the full posting list. |
| // If the caller wants top-N by BM25 they should set `with_score=true` (Path D) |
| // and ignore the score values. |
| (false, Some(n)) => { |
| if n == 0 { |
| return Ok(Vec::new()); |
| } |
| if min_score.is_some() { |
| // min_score requires scoring — fall back to collect_scored path |
| let mut filtered = self.collect_scored(&*q, &searcher, pre_filter)?; |
| if let Some(threshold) = min_score { |
| filtered.retain(|(s, _)| *s > threshold); |
| } |
| let truncated = Self::sort_by_score_desc_truncate(filtered, n); |
| Ok(truncated.into_iter().map(|(_, id)| (id, None)).collect()) |
| } else if let Some(filter) = pre_filter { |
| // pre_filter present: it MUST be applied to the full match set |
| // before truncation. LimitedDocSetCollector stops after the |
| // first N raw matches, which could all be filtered out while |
| // valid matches exist further down the posting list — that |
| // would under-return (fewer than N, or even empty). So collect |
| // every matching row_id (filter-aware), then truncate to N. |
| let mut row_ids: Vec<u64> = searcher |
| .search(&*q, &RowIdCollector) |
| .map_err(|e| format!("tantivy search: {e}"))?; |
| row_ids.retain(|id| filter.contains(*id)); |
| row_ids.sort_unstable(); |
| row_ids.dedup(); |
| row_ids.truncate(n); |
| Ok(row_ids.into_iter().map(|id| (id, None)).collect()) |
| } else { |
| // No pre_filter: fast path — stop collecting once N matches are |
| // gathered per segment instead of materialising the full posting list. |
| let collector = LimitedDocSetCollector::new(n); |
| let mut docset = searcher |
| .search(&*q, &collector) |
| .map_err(|e| format!("tantivy search: {e}"))?; |
| let mut by_segment: std::collections::HashMap<SegmentOrdinal, Vec<DocId>> = |
| std::collections::HashMap::new(); |
| for addr in docset.drain(..) { |
| by_segment.entry(addr.segment_ord).or_default().push(addr.doc_id); |
| } |
| let mut row_ids: Vec<u64> = Vec::new(); |
| for (segment_ord, doc_ids) in by_segment.iter() { |
| let segment_reader = searcher.segment_reader(*segment_ord); |
| let fast = segment_reader |
| .fast_fields() |
| .u64(PAIMON_ROW_ID_FIELD_NAME) |
| .map_err(|e| format!("fast_fields().u64('row_id') on segment {}: {e}", |
| segment_ord))?; |
| for &doc_id in doc_ids { |
| row_ids.push(fast.first(doc_id).unwrap_or(0)); |
| } |
| } |
| row_ids.sort_unstable(); |
| row_ids.dedup(); |
| row_ids.truncate(n); |
| Ok(row_ids.into_iter().map(|id| (id, None)).collect()) |
| } |
| } |
| // Path C: all rows + all scores, sorted by row_id asc to match the |
| // BitmapScoredGlobalIndexResult contract (bitmap iter order == score order). |
| (true, None) => { |
| let mut filtered = self.collect_scored(&*q, &searcher, pre_filter)?; |
| if let Some(threshold) = min_score { |
| filtered.retain(|(s, _)| *s > threshold); |
| } |
| filtered.sort_unstable_by(|a, b| a.1.cmp(&b.1)); |
| Ok(filtered.into_iter().map(|(s, id)| (id, Some(s))).collect()) |
| } |
| // Path D: top-N by BM25 with scores. |
| (true, Some(n)) => { |
| if n == 0 { |
| return Ok(Vec::new()); |
| } |
| let mut filtered = self.collect_scored(&*q, &searcher, pre_filter)?; |
| if let Some(threshold) = min_score { |
| filtered.retain(|(s, _)| *s > threshold); |
| } |
| let truncated = Self::sort_by_score_desc_truncate(filtered, n); |
| Ok(truncated.into_iter().map(|(s, id)| (id, Some(s))).collect()) |
| } |
| } |
| } |
| |
| /// Helper for paths B/C/D: run AllScoredCollector, translate doc_id → row_id, apply pre_filter. |
| /// Groups results by segment so the fast field column handle is opened once per segment |
| /// (same rationale as Path A — avoids per-match Column<u64> allocation). |
| fn collect_scored( |
| &self, |
| q: &dyn Query, |
| searcher: &tantivy::Searcher, |
| pre_filter: Option<&Treemap>, |
| ) -> Result<Vec<(Score, u64)>, String> { |
| let scored = searcher |
| .search(q, &AllScoredCollector) |
| .map_err(|e| format!("tantivy search: {e}"))?; |
| let mut by_segment: std::collections::HashMap<SegmentOrdinal, Vec<(Score, DocId)>> = |
| std::collections::HashMap::new(); |
| for (s, addr) in scored.into_iter() { |
| by_segment.entry(addr.segment_ord).or_default().push((s, addr.doc_id)); |
| } |
| let mut result: Vec<(Score, u64)> = Vec::new(); |
| for (segment_ord, entries) in by_segment.iter() { |
| let segment_reader = searcher.segment_reader(*segment_ord); |
| let fast = segment_reader |
| .fast_fields() |
| .u64(PAIMON_ROW_ID_FIELD_NAME) |
| .map_err(|e| format!("fast_fields().u64('row_id') on segment {}: {e}", |
| segment_ord))?; |
| for &(score, doc_id) in entries { |
| let rid = fast.first(doc_id).unwrap_or(0); |
| if pre_filter.map_or(true, |t| t.contains(rid)) { |
| result.push((score, rid)); |
| } |
| } |
| } |
| Ok(result) |
| } |
| |
| /// Helper for paths B/D: sort (score, row_id) by score desc with row_id asc tie-break, |
| /// then truncate to `n` items. |
| fn sort_by_score_desc_truncate(mut v: Vec<(Score, u64)>, n: usize) -> Vec<(Score, u64)> { |
| v.sort_unstable_by(|a, b| { |
| b.0.partial_cmp(&a.0) |
| .unwrap_or(std::cmp::Ordering::Equal) |
| .then(a.1.cmp(&b.1)) |
| }); |
| v.truncate(n); |
| v |
| } |
| |
| #[cfg(test)] |
| pub(crate) fn tokenizer_name(&self) -> &str { |
| &self.tokenizer_name |
| } |
| |
| #[cfg(test)] |
| pub(crate) fn debug_index(&self) -> &Index { |
| &self.index |
| } |
| } |
| |
| /// Escape regex metacharacters, but leave the input as a verbatim literal. |
| fn regex_escape(input: &str) -> String { |
| let mut out = String::with_capacity(input.len() + 4); |
| for ch in input.chars() { |
| match ch { |
| '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\' => { |
| out.push('\\'); |
| out.push(ch); |
| } |
| _ => out.push(ch), |
| } |
| } |
| out |
| } |
| |
| /// Normalize each literal segment independently while preserving wildcard |
| /// operators. This matches lucene-fts, where `*` and `?` are not analyzed. |
| fn normalize_wildcard_query(input: &str) -> String { |
| let mut out = String::with_capacity(input.len()); |
| let mut segment_start = 0; |
| for (index, ch) in input.char_indices() { |
| if ch != '*' && ch != '?' { |
| continue; |
| } |
| out.push_str(&normalize_case(&input[segment_start..index])); |
| out.push(ch); |
| segment_start = index + ch.len_utf8(); |
| } |
| out.push_str(&normalize_case(&input[segment_start..])); |
| out |
| } |
| |
| /// Translate a glob-style wildcard ('*' = any, '?' = single char) into a |
| /// regex pattern, escaping all other regex metacharacters. |
| fn wildcard_to_regex(input: &str) -> String { |
| let mut out = String::with_capacity(input.len() + 4); |
| for ch in input.chars() { |
| match ch { |
| '*' => out.push_str(".*"), |
| '?' => out.push('.'), |
| '.' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\' => { |
| out.push('\\'); |
| out.push(ch); |
| } |
| _ => out.push(ch), |
| } |
| } |
| out |
| } |
| |
| /// Collector that reads the explicit `row_id` u64 fast field directly into a |
| /// `Vec<u64>`, opening the column once per segment in `for_segment`. Replaces |
| /// the DocSetCollector → HashSet → per-doc translate path for unscored queries. |
| struct RowIdCollector; |
| |
| struct RowIdSegmentCollector { |
| row_id: Column<u64>, |
| ids: Vec<u64>, |
| } |
| |
| impl SegmentCollector for RowIdSegmentCollector { |
| type Fruit = Vec<u64>; |
| |
| fn collect(&mut self, doc: DocId, _score: Score) { |
| self.ids.push(self.row_id.first(doc).unwrap_or(0)); |
| } |
| |
| fn harvest(self) -> Vec<u64> { |
| self.ids |
| } |
| } |
| |
| impl Collector for RowIdCollector { |
| type Fruit = Vec<u64>; |
| type Child = RowIdSegmentCollector; |
| |
| fn for_segment( |
| &self, _ord: SegmentOrdinal, segment: &SegmentReader, |
| ) -> tantivy::Result<RowIdSegmentCollector> { |
| let row_id = segment.fast_fields().u64(PAIMON_ROW_ID_FIELD_NAME)?; |
| Ok(RowIdSegmentCollector { row_id, ids: Vec::new() }) |
| } |
| |
| fn requires_scoring(&self) -> bool { |
| false |
| } |
| |
| fn merge_fruits(&self, segs: Vec<Vec<u64>>) -> tantivy::Result<Vec<u64>> { |
| Ok(segs.into_iter().flatten().collect()) |
| } |
| } |
| |
| /// Collector that returns at most `limit` DocAddresses across all segments, |
| /// no scoring. Shared atomic counter caps the global total so per-shard |
| /// transfer stays bounded for plain `LIMIT N` queries (no ORDER BY). |
| struct LimitedDocSetCollector { |
| limit: usize, |
| counter: std::sync::Arc<std::sync::atomic::AtomicU64>, |
| } |
| |
| impl LimitedDocSetCollector { |
| fn new(limit: usize) -> Self { |
| Self { limit, counter: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)) } |
| } |
| } |
| |
| struct LimitedDocSetSegmentCollector { |
| segment_ord: SegmentOrdinal, |
| docs: Vec<DocId>, |
| counter: std::sync::Arc<std::sync::atomic::AtomicU64>, |
| limit: u64, |
| } |
| |
| impl SegmentCollector for LimitedDocSetSegmentCollector { |
| type Fruit = Vec<DocAddress>; |
| |
| fn collect(&mut self, doc: DocId, _score: Score) { |
| // Best-effort cap: if multiple segments are scanned concurrently the |
| // atomic ensures we never accept more than `limit` rows total. |
| let prev = self.counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
| if prev < self.limit { |
| self.docs.push(doc); |
| } |
| } |
| |
| fn harvest(self) -> Self::Fruit { |
| let segment_ord = self.segment_ord; |
| self.docs.into_iter().map(|d| DocAddress::new(segment_ord, d)).collect() |
| } |
| } |
| |
| impl Collector for LimitedDocSetCollector { |
| type Fruit = Vec<DocAddress>; |
| type Child = LimitedDocSetSegmentCollector; |
| |
| fn for_segment( |
| &self, segment_ord: SegmentOrdinal, _segment: &SegmentReader, |
| ) -> tantivy::Result<Self::Child> { |
| Ok(LimitedDocSetSegmentCollector { |
| segment_ord, |
| docs: Vec::new(), |
| counter: self.counter.clone(), |
| limit: self.limit as u64, |
| }) |
| } |
| |
| fn requires_scoring(&self) -> bool { false } |
| |
| fn merge_fruits( |
| &self, segment_fruits: Vec<Vec<DocAddress>>, |
| ) -> tantivy::Result<Vec<DocAddress>> { |
| let mut result: Vec<DocAddress> = segment_fruits.into_iter().flatten().collect(); |
| result.truncate(self.limit); |
| Ok(result) |
| } |
| } |
| |
| /// Custom Collector that returns ALL matching (score, DocAddress) tuples, |
| /// without truncation. tantivy's stock `TopDocs::with_limit(N)` would force |
| /// us to either pick N upfront (wrong when pre_filter rejects high-score |
| /// docs) or pass `usize::MAX` (which still enforces a binary heap on every |
| /// push). Our collector is just a plain Vec append, then merge. |
| struct AllScoredCollector; |
| |
| struct AllScoredSegmentCollector { |
| segment_ord: SegmentOrdinal, |
| docs: Vec<(Score, DocId)>, |
| } |
| |
| impl SegmentCollector for AllScoredSegmentCollector { |
| type Fruit = Vec<(Score, DocAddress)>; |
| |
| fn collect(&mut self, doc: DocId, score: Score) { |
| self.docs.push((score, doc)); |
| } |
| |
| fn harvest(self) -> Self::Fruit { |
| let segment_ord = self.segment_ord; |
| self.docs |
| .into_iter() |
| .map(|(s, d)| (s, DocAddress::new(segment_ord, d))) |
| .collect() |
| } |
| } |
| |
| impl Collector for AllScoredCollector { |
| type Fruit = Vec<(Score, DocAddress)>; |
| type Child = AllScoredSegmentCollector; |
| |
| fn for_segment( |
| &self, |
| segment_ord: SegmentOrdinal, |
| _segment: &SegmentReader, |
| ) -> tantivy::Result<Self::Child> { |
| Ok(AllScoredSegmentCollector { |
| segment_ord, |
| docs: Vec::new(), |
| }) |
| } |
| |
| fn requires_scoring(&self) -> bool { |
| true |
| } |
| |
| fn merge_fruits( |
| &self, |
| segment_fruits: Vec<Vec<(Score, DocAddress)>>, |
| ) -> tantivy::Result<Vec<(Score, DocAddress)>> { |
| Ok(segment_fruits.into_iter().flatten().collect()) |
| } |
| } |
| |
| // ============================ FFI surface ============================ |
| |
| /// Construct a streaming reader from a layout table + pread callbacks. |
| /// |
| /// The layout arrays (names / offsets / lengths) are produced by C++-side |
| /// `ArchiveLayout::Parse` after reading only the archive header bytes. Payload |
| /// bytes are fetched lazily through `callbacks.read_at` as tantivy reads. |
| /// |
| /// # Arguments |
| /// * `file_names` — array of `file_count` UTF-8 NUL-terminated C strings |
| /// * `file_offsets` / `file_lengths` — u64 arrays (archive-absolute offsets and lengths) |
| /// * `file_count` — number of entries in each of the three arrays |
| /// * `callbacks` — pread + release callbacks; `ctx` ownership transfers to Rust |
| /// * `mode_cstr` — tokenize mode ("mp"/"mix"/"full"/"query"; "hmm" → Unsupported) |
| /// * `with_position` — whether text field was indexed with positions |
| /// * `dict_dir_cstr` — paimon_jieba dictionary directory |
| /// * `out` — receives the reader handle on success |
| /// |
| /// Releases an FFI stream ctx on drop unless disarmed. Gives |
| /// `paimon_tantivy_reader_new_streaming` a single ownership rule: Rust owns ctx |
| /// from entry and releases it on any error before the directory takes over. |
| struct StreamCtxReleaseGuard { |
| release: extern "C" fn(*mut std::os::raw::c_void), |
| ctx: *mut std::os::raw::c_void, |
| armed: bool, |
| } |
| |
| impl Drop for StreamCtxReleaseGuard { |
| fn drop(&mut self) { |
| if self.armed { |
| (self.release)(self.ctx); |
| } |
| } |
| } |
| |
| /// # Safety |
| /// All pointer args must be valid for the duration of the call. Ownership of |
| /// `callbacks.ctx` transfers to Rust on entry: on any error this function |
| /// releases it, and on success it lives until the reader handle is freed. The |
| /// caller must NOT release ctx itself after calling this function. |
| #[no_mangle] |
| pub unsafe extern "C" fn paimon_tantivy_reader_new_streaming( |
| file_names: *const *const c_char, |
| file_offsets: *const u64, |
| file_lengths: *const u64, |
| file_count: usize, |
| callbacks: PaimonStreamCallbacks, |
| mode_cstr: *const c_char, |
| with_position: bool, |
| dict_dir_cstr: *const c_char, |
| out: *mut *mut PaimonTantivyReader, |
| ) -> PaimonTantivyStatus { |
| // Single, uniform ownership rule: Rust owns ctx from entry. This guard |
| // releases it on every error path until ownership moves into the directory |
| // (which then releases on its own drop). Prevents the C++ caller and Rust |
| // from both releasing the same ctx on the post-directory failure path. |
| let mut ctx_guard = StreamCtxReleaseGuard { |
| release: callbacks.release, |
| ctx: callbacks.ctx, |
| armed: true, |
| }; |
| |
| if mode_cstr.is_null() || dict_dir_cstr.is_null() || out.is_null() { |
| set_last_error("paimon_tantivy_reader_new_streaming: null mandatory argument"); |
| return PaimonTantivyStatus::InvalidArgument; |
| } |
| if file_count > 0 |
| && (file_names.is_null() || file_offsets.is_null() || file_lengths.is_null()) |
| { |
| set_last_error("file_names/offsets/lengths must be non-null when file_count > 0"); |
| return PaimonTantivyStatus::InvalidArgument; |
| } |
| |
| let mode_str = match unsafe { CStr::from_ptr(mode_cstr) }.to_str() { |
| Ok(s) => s, |
| Err(e) => { |
| set_last_error(format!("mode not utf-8: {e}")); |
| return PaimonTantivyStatus::InvalidArgument; |
| } |
| }; |
| let dict_dir = match unsafe { CStr::from_ptr(dict_dir_cstr) }.to_str() { |
| Ok(s) => s, |
| Err(e) => { |
| set_last_error(format!("dict_dir not utf-8: {e}")); |
| return PaimonTantivyStatus::InvalidArgument; |
| } |
| }; |
| let mode = match TokenizeMode::parse(mode_str) { |
| Some(m) => m, |
| None => { |
| set_last_error(format!( |
| "unknown tokenize mode {mode_str:?}; expected mp/mix/full/query" |
| )); |
| return PaimonTantivyStatus::InvalidArgument; |
| } |
| }; |
| |
| // Copy the C string array into owned Rust entries so the directory doesn't |
| // depend on caller-supplied lifetime. |
| let mut entries: Vec<(String, u64, u64)> = Vec::with_capacity(file_count); |
| for i in 0..file_count { |
| let name_ptr = unsafe { *file_names.add(i) }; |
| if name_ptr.is_null() { |
| set_last_error(format!("file_names[{i}] is null")); |
| return PaimonTantivyStatus::InvalidArgument; |
| } |
| let name = match unsafe { CStr::from_ptr(name_ptr) }.to_str() { |
| Ok(s) => s.to_owned(), |
| Err(e) => { |
| set_last_error(format!("file_names[{i}] not utf-8: {e}")); |
| return PaimonTantivyStatus::InvalidArgument; |
| } |
| }; |
| let offset = unsafe { *file_offsets.add(i) }; |
| let length = unsafe { *file_lengths.add(i) }; |
| entries.push((name, offset, length)); |
| } |
| |
| // Ownership of ctx transfers to the directory from here on (it releases on |
| // its own drop, whether it fails below or lives inside the returned reader). |
| ctx_guard.armed = false; |
| let directory = PaimonCallbackDirectory::new(entries, callbacks); |
| |
| match PaimonTantivyReader::new(directory, mode, with_position, Path::new(dict_dir)) { |
| Ok(r) => { |
| unsafe { *out = into_handle(r) }; |
| PaimonTantivyStatus::Ok |
| } |
| Err(e) => { |
| let unsupported = e.contains("'hmm' is not supported"); |
| let bad_format = e.contains("tantivy::Index::open") |
| || e.contains("missing 'text' field"); |
| set_last_error(e); |
| if unsupported { |
| PaimonTantivyStatus::Unsupported |
| } else if bad_format { |
| PaimonTantivyStatus::IndexFormatError |
| } else { |
| PaimonTantivyStatus::InternalError |
| } |
| } |
| } |
| } |
| |
| /// Run a query and emit results into `out`. |
| /// |
| /// Output bytes (little-endian): |
| /// `[u8 has_scores | u64 count | u64 row_ids[count] | optional f32 scores[count]]` |
| /// |
| /// `has_scores=1` iff `limit >= 0` (caller asked for scoring + limit). |
| /// |
| /// `limit < 0` ⇒ no limit, no scoring; sorted ascending by row_id. |
| /// `limit >= 0` ⇒ top-N by descending score (pre_filter applied first). |
| /// `pre_filter_bytes`: serialized croaring `Roaring64Map::write` (portable), |
| /// containing paimon **row_ids** (not tantivy doc_ids); null+0 = no filter. |
| /// |
| /// SAFETY: `reader` must be a live handle; `query` and `pre_filter_bytes` |
| /// may be null+0 or readable slices; `out` non-null. |
| #[no_mangle] |
| pub unsafe extern "C" fn paimon_tantivy_reader_search( |
| reader: *mut PaimonTantivyReader, |
| search_type: i32, |
| query: *const c_char, |
| query_len: usize, |
| with_score: bool, |
| limit: i32, |
| pre_filter_bytes: *const c_char, |
| pre_filter_len: usize, |
| min_score: f32, |
| out: *mut PaimonTantivyBuffer, |
| ) -> PaimonTantivyStatus { |
| if out.is_null() { |
| set_last_error("reader_search: out is null"); |
| return PaimonTantivyStatus::InvalidArgument; |
| } |
| let Some(r) = (unsafe { borrow_handle_mut::<PaimonTantivyReader>(reader) }) else { |
| set_last_error("reader_search: null reader handle"); |
| return PaimonTantivyStatus::InvalidArgument; |
| }; |
| let st = match SearchType::from_i32(search_type) { |
| Some(s) => s, |
| None => { |
| set_last_error(format!("unknown search_type {search_type}")); |
| return PaimonTantivyStatus::InvalidArgument; |
| } |
| }; |
| if query.is_null() && query_len != 0 { |
| set_last_error("query is null but len > 0"); |
| return PaimonTantivyStatus::InvalidArgument; |
| } |
| let query_str = if query_len == 0 { |
| "" |
| } else { |
| let slice = unsafe { std::slice::from_raw_parts(query as *const u8, query_len) }; |
| match std::str::from_utf8(slice) { |
| Ok(s) => s, |
| Err(e) => { |
| set_last_error(format!("query not utf-8: {e}")); |
| return PaimonTantivyStatus::InvalidArgument; |
| } |
| } |
| }; |
| |
| let pre_filter: Option<Treemap> = if pre_filter_bytes.is_null() && pre_filter_len == 0 { |
| None |
| } else if pre_filter_bytes.is_null() { |
| set_last_error("pre_filter_bytes is null but len > 0"); |
| return PaimonTantivyStatus::InvalidArgument; |
| } else { |
| let slice = unsafe { |
| std::slice::from_raw_parts(pre_filter_bytes as *const u8, pre_filter_len) |
| }; |
| match Treemap::try_deserialize::<Portable>(slice) { |
| Some(t) => Some(t), |
| None => { |
| set_last_error(format!( |
| "pre_filter not a valid Roaring64Map portable serialization ({} bytes)", |
| pre_filter_len |
| )); |
| return PaimonTantivyStatus::InvalidArgument; |
| } |
| } |
| }; |
| |
| let limit_opt: Option<usize> = if limit < 0 { None } else { Some(limit as usize) }; |
| let min_score_opt: Option<f32> = if min_score > 0.0 { Some(min_score) } else { None }; |
| |
| match r.search_with_limit_and_filter(st, query_str, with_score, limit_opt, pre_filter.as_ref(), min_score_opt) |
| { |
| Ok(rows) => { |
| // v0.2: has_scores is decoupled from limit — it equals with_score directly. |
| let has_scores = with_score; |
| let count = rows.len() as u64; |
| // 1 byte has_scores + 8 bytes count + 8 bytes per row_id + optional 4 bytes per score |
| let mut buf = Vec::with_capacity( |
| 1 + 8 + rows.len() * 8 + if has_scores { rows.len() * 4 } else { 0 }, |
| ); |
| buf.push(if has_scores { 1u8 } else { 0u8 }); |
| buf.extend_from_slice(&count.to_le_bytes()); |
| for (id, _) in &rows { |
| buf.extend_from_slice(&id.to_le_bytes()); // u64 row_id LE |
| } |
| if has_scores { |
| for (_, score) in &rows { |
| let s = score.unwrap_or(0.0); |
| buf.extend_from_slice(&s.to_le_bytes()); |
| } |
| } |
| unsafe { *out = PaimonTantivyBuffer::from_vec(buf) }; |
| PaimonTantivyStatus::Ok |
| } |
| Err(e) => { |
| let parse_err = e.contains("RegexQuery from") |
| || e.contains("phrase query") |
| || e.contains("produced no tokens"); |
| set_last_error(e); |
| if parse_err { |
| PaimonTantivyStatus::QueryParseError |
| } else { |
| PaimonTantivyStatus::InternalError |
| } |
| } |
| } |
| } |
| |
| /// Destroy a reader handle. Safe on null. |
| #[no_mangle] |
| pub unsafe extern "C" fn paimon_tantivy_reader_free(reader: *mut PaimonTantivyReader) { |
| unsafe { free_handle(reader) }; |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use crate::callback_directory::test_support::{build_directory_from_archive, make_mock_callbacks}; |
| use crate::writer::PaimonTantivyWriter; |
| use std::path::PathBuf; |
| |
| // When PaimonTantivyReader::new fails *after* the callback directory (and |
| // thus ctx) is constructed, Rust must release ctx exactly once — never zero |
| // (leak) and never twice (the C++ caller no longer releases on failure, so a |
| // second release would be a double-free). file_count=0 yields an index with |
| // no meta.json, which fails to open after the directory is built. |
| #[test] |
| fn reader_new_streaming_releases_ctx_once_on_failure() { |
| let (cb, backend) = make_mock_callbacks(Vec::new()); |
| let mode = std::ffi::CString::new("mix").unwrap(); |
| let dict = std::ffi::CString::new("").unwrap(); |
| let mut out: *mut PaimonTantivyReader = std::ptr::null_mut(); |
| let st = unsafe { |
| paimon_tantivy_reader_new_streaming( |
| std::ptr::null(), |
| std::ptr::null(), |
| std::ptr::null(), |
| 0, |
| cb, |
| mode.as_ptr(), |
| true, |
| dict.as_ptr(), |
| &mut out, |
| ) |
| }; |
| assert!(!matches!(st, PaimonTantivyStatus::Ok)); |
| assert!(out.is_null()); |
| assert_eq!( |
| backend.release_count.load(std::sync::atomic::Ordering::SeqCst), |
| 1 |
| ); |
| } |
| |
| fn dict_dir() -> PathBuf { |
| std::env::var("PAIMON_JIEBA_DICT_DIR") |
| .map(PathBuf::from) |
| .unwrap_or_else(|_| PathBuf::from("/tmp/nonexistent-dict")) |
| } |
| |
| fn build(docs: &[&str]) -> Vec<u8> { |
| let mut w = PaimonTantivyWriter::new("f0", TokenizeMode::Mix, true, &dict_dir(), "paimon_jieba").unwrap(); |
| for (i, d) in docs.iter().enumerate() { |
| w.add(i as u64, d).unwrap(); |
| } |
| w.finish().unwrap().1 |
| } |
| |
| fn open(packed: &[u8]) -> PaimonTantivyReader { |
| // Simulate production flow: parse archive header → build layout → |
| // back PaimonCallbackDirectory with a mock pread that reads from the |
| // packed Vec. Once C++ `ArchiveLayout::Parse` is in place, prod |
| // uses the same PaimonCallbackDirectory path. |
| let (dir, _backend) = build_directory_from_archive(packed.to_vec()); |
| PaimonTantivyReader::new(dir, TokenizeMode::Mix, true, &dict_dir()).unwrap() |
| } |
| |
| #[test] |
| fn match_all_single_term() { |
| let bytes = build(&["hello world", "hello there", "world peace"]); |
| let r = open(&bytes); |
| let ids = r.search_all(SearchType::MatchAll, "hello").unwrap(); |
| assert_eq!(ids, vec![0u64, 1]); |
| } |
| |
| #[test] |
| fn match_all_two_terms_intersection() { |
| let bytes = build(&["hello world", "hello there", "world peace"]); |
| let r = open(&bytes); |
| let ids = r.search_all(SearchType::MatchAll, "hello world").unwrap(); |
| assert_eq!(ids, vec![0u64]); |
| } |
| |
| #[test] |
| fn match_any_two_terms_union() { |
| let bytes = build(&["hello world", "hello there", "world peace"]); |
| let r = open(&bytes); |
| let ids = r.search_all(SearchType::MatchAny, "hello peace").unwrap(); |
| assert_eq!(ids, vec![0u64, 1, 2]); |
| } |
| |
| #[test] |
| fn phrase_only_consecutive() { |
| let bytes = build(&["hello world there", "world hello there"]); |
| let r = open(&bytes); |
| let ids = r.search_all(SearchType::Phrase, "hello world").unwrap(); |
| assert_eq!(ids, vec![0u64]); |
| } |
| |
| #[test] |
| fn prefix_matches_indexed_terms() { |
| let bytes = build(&["unordered user-defined doc id"]); |
| let r = open(&bytes); |
| let ids = r.search_all(SearchType::Prefix, "unorder").unwrap(); |
| assert_eq!(ids, vec![0u64]); |
| } |
| |
| #[test] |
| fn prefix_normalizes_ascii_alphanumeric_case() { |
| let bytes = build(&["This is a test document", "another document"]); |
| let r = open(&bytes); |
| let ids = r.search_all(SearchType::Prefix, "THIS").unwrap(); |
| assert_eq!(ids, vec![0u64]); |
| } |
| |
| #[test] |
| fn wildcard_with_star() { |
| let bytes = build(&["unordered", "ordered", "border"]); |
| let r = open(&bytes); |
| let ids = r.search_all(SearchType::Wildcard, "*order*").unwrap(); |
| assert_eq!(ids, vec![0u64, 1, 2]); |
| } |
| |
| #[test] |
| fn wildcard_normalizes_ascii_alphanumeric_segments() { |
| let bytes = build(&["This is a test document", "another document"]); |
| let r = open(&bytes); |
| assert_eq!( |
| r.search_all(SearchType::Wildcard, "*THIS*").unwrap(), |
| vec![0u64] |
| ); |
| assert_eq!( |
| r.search_all(SearchType::Wildcard, "*?HIS*").unwrap(), |
| vec![0u64] |
| ); |
| } |
| |
| #[test] |
| fn prefix_and_wildcard_preserve_mixed_ascii_cjk_terms() { |
| let bytes = build(&["B超检查", "T恤"]); |
| let r = open(&bytes); |
| assert_eq!( |
| r.search_all(SearchType::Prefix, "B").unwrap(), |
| vec![0u64] |
| ); |
| assert_eq!( |
| r.search_all(SearchType::Wildcard, "*T*").unwrap(), |
| vec![1u64] |
| ); |
| } |
| |
| #[test] |
| fn empty_query_for_match_returns_query_parse_error() { |
| let bytes = build(&["hello"]); |
| let r = open(&bytes); |
| let err = r.search_all(SearchType::MatchAll, "").unwrap_err(); |
| assert!(err.contains("no tokens"), "got: {err}"); |
| } |
| |
| #[test] |
| fn wildcard_helper_escapes_dots() { |
| assert_eq!(wildcard_to_regex("a*b"), "a.*b"); |
| assert_eq!(wildcard_to_regex("a?b"), "a.b"); |
| assert_eq!(wildcard_to_regex("a.b"), r"a\.b"); |
| assert_eq!(wildcard_to_regex("*a*"), ".*a.*"); |
| } |
| |
| #[test] |
| fn wildcard_normalization_preserves_non_alphanumeric_segments() { |
| assert_eq!(normalize_wildcard_query("*?HIS*"), "*?his*"); |
| assert_eq!(normalize_wildcard_query("*THIS_IS*"), "*THIS_IS*"); |
| assert_eq!(normalize_wildcard_query("*机器*"), "*机器*"); |
| } |
| |
| // ----- limit + pre_filter + scoring (row_id-based) ----- |
| |
| #[test] |
| fn limit_returns_top_n_with_scores() { |
| let bytes = build(&[ |
| "doc", // 0: low score (1 occurrence) |
| "doc doc doc doc doc", // 1: high score (5 occurrences) |
| "doc doc", // 2: medium score |
| ]); |
| let r = open(&bytes); |
| let rows = r |
| .search_with_limit_and_filter(SearchType::MatchAll, "doc", true, Some(2), None, None) |
| .unwrap(); |
| assert_eq!(rows.len(), 2); |
| // doc 1 has highest TF, expect first |
| assert_eq!(rows[0].0, 1u64); |
| assert!(rows[0].1.is_some()); |
| assert!(rows[1].1.is_some()); |
| // Scores monotonically decreasing |
| assert!(rows[0].1.unwrap() >= rows[1].1.unwrap()); |
| } |
| |
| #[test] |
| fn no_limit_returns_all_unscored() { |
| let bytes = build(&["hello world", "world hello", "world peace"]); |
| let r = open(&bytes); |
| let rows = r |
| .search_with_limit_and_filter(SearchType::MatchAll, "world", false, None, None, None) |
| .unwrap(); |
| let ids: Vec<u64> = rows.iter().map(|(id, _)| *id).collect(); |
| assert_eq!(ids, vec![0u64, 1, 2]); |
| assert!(rows.iter().all(|(_, s)| s.is_none())); |
| } |
| |
| #[test] |
| fn pre_filter_no_limit_intersects() { |
| let bytes = build(&["alpha beta", "alpha gamma", "beta gamma"]); |
| let r = open(&bytes); |
| // pre_filter = {0, 2}; query "alpha" matches {0, 1}; expect intersection {0} |
| let mut tm = Treemap::new(); |
| tm.add(0); |
| tm.add(2); |
| let rows = r |
| .search_with_limit_and_filter(SearchType::MatchAll, "alpha", false, None, Some(&tm), None) |
| .unwrap(); |
| let ids: Vec<u64> = rows.iter().map(|(id, _)| *id).collect(); |
| assert_eq!(ids, vec![0u64]); |
| } |
| |
| #[test] |
| fn pre_filter_with_limit_filters_before_topn() { |
| // doc 0 has highest TF for "doc" but is NOT in pre_filter → must NOT |
| // be in result, even with limit=1. |
| let bytes = build(&[ |
| "doc doc doc doc doc", // 0: highest TF, but excluded |
| "doc doc", // 1: medium TF, included |
| "doc", // 2: low TF, excluded |
| ]); |
| let r = open(&bytes); |
| let mut tm = Treemap::new(); |
| tm.add(1); // only doc 1 passes pre_filter |
| let rows = r |
| .search_with_limit_and_filter(SearchType::MatchAll, "doc", true, Some(10), Some(&tm), None) |
| .unwrap(); |
| assert_eq!(rows.len(), 1); |
| assert_eq!(rows[0].0, 1u64); |
| } |
| |
| #[test] |
| fn unscored_limit_with_pre_filter_applies_filter_before_truncate() { |
| // Regression (review finding #1): with_score=false + limit=N + pre_filter |
| // must apply the filter to the FULL match set before truncating to N. |
| // All three docs match "doc" but only row_id 2 (the LAST one) passes the |
| // pre_filter; a truncate-before-filter impl (LimitedDocSetCollector that |
| // stops at N raw matches, then filters) would collect doc 0, filter it |
| // out, and wrongly return empty instead of {2}. |
| let bytes = build(&["doc", "doc", "doc"]); |
| let r = open(&bytes); |
| let mut tm = Treemap::new(); |
| tm.add(2); // only row_id 2 passes the pre_filter |
| let rows = r |
| .search_with_limit_and_filter(SearchType::MatchAll, "doc", false, Some(1), Some(&tm), None) |
| .unwrap(); |
| let ids: Vec<u64> = rows.iter().map(|(id, _)| *id).collect(); |
| assert_eq!(ids, vec![2u64], "pre_filter must be applied before LIMIT truncation"); |
| assert!(rows.iter().all(|(_, s)| s.is_none())); |
| } |
| |
| #[test] |
| fn empty_pre_filter_returns_empty() { |
| let bytes = build(&["alpha", "beta"]); |
| let r = open(&bytes); |
| let tm = Treemap::new(); // empty |
| let rows = r |
| .search_with_limit_and_filter(SearchType::MatchAll, "alpha", false, None, Some(&tm), None) |
| .unwrap(); |
| assert!(rows.is_empty()); |
| } |
| |
| #[test] |
| fn limit_zero_returns_empty_without_running_query() { |
| let bytes = build(&["alpha", "beta"]); |
| let r = open(&bytes); |
| let rows = r |
| .search_with_limit_and_filter(SearchType::MatchAll, "alpha", true, Some(0), None, None) |
| .unwrap(); |
| assert!(rows.is_empty()); |
| } |
| |
| // ----- row_id is independent of doc_id ----- |
| |
| #[test] |
| fn pre_filter_uses_row_id_not_doc_id() { |
| // Build with non-contiguous row_ids so doc_id ≠ row_id. Then verify |
| // pre_filter operates on row_id values, not internal tantivy doc_ids. |
| let mut w = PaimonTantivyWriter::new("f0", TokenizeMode::Mix, true, &dict_dir(), "paimon_jieba").unwrap(); |
| w.add(100, "alpha").unwrap(); |
| w.add(200, "alpha").unwrap(); |
| w.add(300, "alpha").unwrap(); |
| let bytes = w.finish().unwrap().1; |
| let r = open(&bytes); |
| |
| // pre_filter = {200} as row_id (doc_id would be 1) |
| let mut tm = Treemap::new(); |
| tm.add(200); |
| let rows = r |
| .search_with_limit_and_filter(SearchType::MatchAll, "alpha", false, None, Some(&tm), None) |
| .unwrap(); |
| let ids: Vec<u64> = rows.iter().map(|(id, _)| *id).collect(); |
| assert_eq!(ids, vec![200u64], "pre_filter must operate on row_id, not doc_id"); |
| } |
| |
| #[test] |
| fn search_returns_caller_supplied_row_ids() { |
| // Same setup: row_ids 100/200/300, verify search_all returns those values. |
| let mut w = PaimonTantivyWriter::new("f0", TokenizeMode::Mix, true, &dict_dir(), "paimon_jieba").unwrap(); |
| w.add(100, "doc").unwrap(); |
| w.add(200, "doc").unwrap(); |
| w.add(300, "doc").unwrap(); |
| let bytes = w.finish().unwrap().1; |
| let r = open(&bytes); |
| let ids = r.search_all(SearchType::MatchAll, "doc").unwrap(); |
| assert_eq!(ids, vec![100u64, 200, 300]); |
| } |
| |
| #[test] |
| fn tokenizer_name_reflects_paimon_jieba_schema_for_cpp_written_index() { |
| // cpp-written index: PaimonTantivyWriter binds the text field to |
| // `paimon_jieba`. Reader must pick that up from meta.json (not hardcode). |
| let bytes = build(&["hello world"]); |
| let r = open(&bytes); |
| assert_eq!(r.tokenizer_name(), PAIMON_TOKENIZER_NAME); |
| |
| // tokenize sanity: jieba mode="mix" picks `hello` + `world` from ASCII. |
| let q = r.tokenize_query("hello world"); |
| assert_eq!(q, vec!["hello".to_string(), "world".to_string()]); |
| } |
| |
| #[test] |
| fn tokenizer_name_reflects_default_schema_for_externally_written_index() { |
| // Simulate a paimon-java-shaped index: text field bound to the |
| // builtin `default` tokenizer (SimpleTokenizer + LowerCaser), not jieba. |
| // Build it directly via tantivy (bypassing PaimonTantivyWriter's jieba |
| // schema) so we can prove the reader auto-switches to the builtin. |
| use crate::callback_directory::test_support::build_mock_directory; |
| use tantivy::directory::Directory; |
| use tantivy::schema::{IndexRecordOption, NumericOptions, Schema, TextFieldIndexing, TextOptions}; |
| use tantivy::{doc, Index}; |
| |
| // Build a minimal index with field "text" bound to "default". |
| let mut sb = Schema::builder(); |
| let row_id_f = sb.add_u64_field( |
| "row_id", |
| NumericOptions::default().set_stored().set_indexed().set_fast(), |
| ); |
| let text_opts = TextOptions::default().set_indexing_options( |
| TextFieldIndexing::default() |
| .set_tokenizer("default") // ← key: match paimon-java's TEXT default |
| .set_index_option(IndexRecordOption::WithFreqsAndPositions), |
| ); |
| let text_f = sb.add_text_field("text", text_opts); |
| let schema = sb.build(); |
| let tmp = tempfile::Builder::new() |
| .prefix("paimon-tantivy-dyn-tk-") |
| .tempdir() |
| .unwrap(); |
| let index = Index::create_in_dir(tmp.path(), schema).unwrap(); |
| let mut writer = index.writer(15_000_000).unwrap(); |
| writer |
| .add_document(doc!(row_id_f => 0u64, text_f => "Hello World")) |
| .unwrap(); |
| writer |
| .add_document(doc!(row_id_f => 1u64, text_f => "Apple.Banana")) |
| .unwrap(); |
| writer.commit().unwrap(); |
| writer.wait_merging_threads().unwrap(); |
| |
| // Pack the index dir into our archive format so the callback directory |
| // can serve it. Reuse writer.rs's format by streaming entries manually. |
| let mut data = Vec::new(); |
| let mut entries = Vec::<(String, u64, u64)>::new(); |
| let dir_iter = std::fs::read_dir(tmp.path()).unwrap(); |
| let mut files: Vec<_> = dir_iter |
| .filter_map(|e| e.ok()) |
| .filter(|e| e.file_type().ok().map_or(false, |t| t.is_file())) |
| .filter(|e| !e.file_name().to_string_lossy().starts_with('.')) |
| .collect(); |
| files.sort_by_key(|e| e.file_name()); |
| data.extend_from_slice(&(files.len() as i32).to_be_bytes()); |
| for e in &files { |
| let name = e.file_name().to_string_lossy().into_owned(); |
| let bytes = std::fs::read(e.path()).unwrap(); |
| data.extend_from_slice(&(name.len() as i32).to_be_bytes()); |
| data.extend_from_slice(name.as_bytes()); |
| data.extend_from_slice(&(bytes.len() as i64).to_be_bytes()); |
| let off = data.len() as u64; |
| data.extend_from_slice(&bytes); |
| entries.push((name, off, bytes.len() as u64)); |
| } |
| |
| let (dir, _backend) = build_mock_directory(data, entries); |
| let r = PaimonTantivyReader::new(dir, TokenizeMode::Mix, true, &dict_dir()).unwrap(); |
| |
| // Reader must pick up `default` from schema, not hardcode `paimon_jieba`. |
| assert_eq!(r.tokenizer_name(), "default"); |
| |
| // Query tokenization now goes through tantivy's builtin default |
| // (SimpleTokenizer + LowerCaser): |
| // "Apple.Banana" → ["apple", "banana"] (dot is non-alnum, split) |
| // "Hello World" → ["hello", "world"] (space split + lowercase) |
| let q1 = r.tokenize_query("Hello World"); |
| assert_eq!(q1, vec!["hello".to_string(), "world".to_string()]); |
| let q2 = r.tokenize_query("Apple.Banana"); |
| assert_eq!(q2, vec!["apple".to_string(), "banana".to_string()]); |
| |
| // And the search path works across tokenizer: |
| let ids = r.search_all(SearchType::MatchAll, "hello").unwrap(); |
| assert_eq!(ids, vec![0u64]); |
| let ids = r.search_all(SearchType::MatchAll, "apple").unwrap(); |
| assert_eq!(ids, vec![1u64]); |
| } |
| |
| #[test] |
| fn reader_aggregates_row_ids_across_segments() { |
| // Multi-thread default writer + many docs => may produce multiple |
| // segments before force-merge. After finish(), force-merge collapses |
| // to one segment, but this test still validates the row_id retrieval |
| // path works for ≥1 segment. |
| let mut w = PaimonTantivyWriter::new("f0", TokenizeMode::Mix, true, &dict_dir(), "paimon_jieba").unwrap(); |
| for i in 0..200u64 { |
| w.add(i * 7, &format!("docmark_{i} apple")).unwrap(); |
| } |
| let bytes = w.finish().unwrap().1; |
| let r = open(&bytes); |
| let ids = r.search_all(SearchType::MatchAll, "apple").unwrap(); |
| assert_eq!(ids.len(), 200); |
| for i in 0..200u64 { |
| assert!(ids.contains(&(i * 7)), "missing row_id={}", i * 7); |
| } |
| } |
| } |