blob: 033b7f2594c964fa8a21e5648666603116743402 [file]
// 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);
}
}
}