use std::{
borrow::Cow,
cmp::{Ordering, Reverse},
collections::BinaryHeap,
slice,
sync::{
Arc, Mutex,
atomic::{self, AtomicU32},
},
time::Instant,
};
use arrow::record_batch::RecordBatch;
use arrow_array::{Array, LargeStringArray};
use roaring::RoaringBitmap;
use tracing::debug;
use uuid::Uuid;
pub use crate::superfile::fts::reader::BoolMode;
use crate::{
InfinoError,
superfile::{
SuperfileReader,
error::{FtsError, ReadError},
fts::{
bm25,
reader::{Bm25Stats, ClauseLists, GlobalTermIdf},
},
},
supertable::{
error::QueryError,
handle::{Supertable, SupertableReader},
manifest::{ManifestSnapshot, SuperfileEntry},
query::{
SuperfileHit, dispatch,
exec::common::{resolve_hits_named, take_rows_byte_source},
prune::{PruneLeaf, select_superfiles},
},
reader_cache::disk::ForegroundQueryGuard,
tombstones::SidecarCache,
},
};
struct UnrankedMatchSet {
terms: Vec<String>,
phrases: Vec<Vec<String>>,
mode: BoolMode,
}
impl Default for UnrankedMatchSet {
fn default() -> Self {
Self {
terms: Vec::new(),
phrases: Vec::new(),
mode: BoolMode::Or,
}
}
}
impl UnrankedMatchSet {
fn has_phrases(&self) -> bool {
!self.phrases.is_empty()
}
}
#[derive(Default)]
struct UnrankedNegatives {
terms: Vec<String>,
phrases: Vec<Vec<String>>,
}
impl UnrankedNegatives {
fn is_empty(&self) -> bool {
self.terms.is_empty() && self.phrases.is_empty()
}
}
const NEGATION_ONLY_QUERY_MSG: &str = "only negated terms; at least one positive term is required";
struct SharedTopK {
k: usize,
heap: Mutex<BinaryHeap<Reverse<OrdScore>>>,
floor_bits: AtomicU32,
}
#[derive(PartialEq)]
struct OrdScore(f32);
impl Eq for OrdScore {}
impl PartialOrd for OrdScore {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for OrdScore {
fn cmp(&self, other: &Self) -> Ordering {
self.0.total_cmp(&other.0)
}
}
impl SharedTopK {
fn new(k: usize) -> Arc<Self> {
Arc::new(Self {
k,
heap: Mutex::new(BinaryHeap::new()),
floor_bits: AtomicU32::new(f32::NEG_INFINITY.to_bits()),
})
}
fn floor(&self) -> f32 {
f32::from_bits(self.floor_bits.load(atomic::Ordering::Acquire))
}
fn merge(&self, scores: impl IntoIterator<Item = f32>) {
let mut heap = self.heap.lock().expect("SharedTopK mutex poisoned");
for s in scores {
if heap.len() < self.k {
heap.push(Reverse(OrdScore(s)));
} else if let Some(Reverse(OrdScore(min))) = heap.peek()
&& s > *min
{
heap.pop();
heap.push(Reverse(OrdScore(s)));
}
}
if heap.len() == self.k
&& let Some(Reverse(OrdScore(min))) = heap.peek()
{
self.floor_bits
.store(min.to_bits(), atomic::Ordering::Release);
}
}
}
impl SupertableReader {
#[cfg_attr(
feature = "detailed-tracing",
tracing::instrument(skip_all, fields(column = column, k = k, mode = ?mode))
)]
pub(crate) async fn bm25_search_async(
&self,
column: &str,
query: &str,
k: usize,
mode: BoolMode,
stats: Bm25Stats,
) -> Result<Vec<SuperfileHit>, QueryError> {
if k == 0 {
return Ok(Vec::new());
}
let manifest = self.manifest();
let pool_threads = manifest.options.reader_pool.current_num_threads();
let column_owned = column.to_owned();
let clauses = manifest
.options
.fts_tokenizer_for(column)
.parse(query)
.into_clauses(mode);
let musts: Vec<String> = clauses.musts.into_iter().map(Cow::into_owned).collect();
let shoulds: Vec<String> = clauses.shoulds.into_iter().map(Cow::into_owned).collect();
let negatives: Vec<String> = clauses.negatives.into_iter().map(Cow::into_owned).collect();
let own_phrases = |phrases: Vec<Vec<Cow<'_, str>>>| -> Vec<Vec<String>> {
phrases
.into_iter()
.map(|p| p.into_iter().map(Cow::into_owned).collect())
.collect()
};
let must_phrases = own_phrases(clauses.must_phrases);
let should_phrases = own_phrases(clauses.should_phrases);
let negative_phrases = own_phrases(clauses.negative_phrases);
let has_musts = !musts.is_empty() || !must_phrases.is_empty();
let has_phrases =
!must_phrases.is_empty() || !should_phrases.is_empty() || !negative_phrases.is_empty();
if !has_musts && shoulds.is_empty() && should_phrases.is_empty() {
if negatives.is_empty() && negative_phrases.is_empty() {
return Ok(Vec::new());
}
return Err(QueryError::InvalidQuery(NEGATION_ONLY_QUERY_MSG.to_owned()));
}
let (mut prune_terms, prune_mode) = if !has_musts {
(shoulds.clone(), mode)
} else {
(musts.clone(), BoolMode::And)
};
match has_musts {
true => {
for p in &must_phrases {
prune_terms.extend(p.iter().cloned());
}
}
false => {
for p in &should_phrases {
prune_terms.extend(p.iter().cloned());
}
}
}
let prune_leaf = PruneLeaf::TermPresence {
column: column_owned.clone(),
terms: prune_terms,
mode: prune_mode,
};
let kept = select_superfiles(manifest.as_ref(), slice::from_ref(&prune_leaf)).await?;
if kept.is_empty() {
return Ok(Vec::new());
}
let global_idf: Option<Arc<GlobalTermIdf>> = match stats {
Bm25Stats::PerSuperfile => None,
Bm25Stats::Global => {
let mut scored: Vec<String> = Vec::new();
let mut add = |t: &String| {
if !scored.contains(t) {
scored.push(t.clone());
}
};
for t in musts.iter().chain(shoulds.iter()) {
add(t);
}
for phrase in must_phrases.iter().chain(should_phrases.iter()) {
for member in phrase {
add(member);
}
}
match scored.is_empty() {
true => None,
false => Some(Arc::new(
self.gather_global_term_idf(manifest.as_ref(), column, &scored)
.await?,
)),
}
}
};
let kept_refs: Vec<&Arc<SuperfileEntry>> = kept.iter().collect();
let fanout = match has_phrases {
true => FanOut::PerSuperfile,
false => fanout_for(musts.len(), shoulds.len(), !negatives.is_empty()),
};
let work_units = build_work_units(&kept_refs, fanout, pool_threads);
let units: Vec<(Arc<SuperfileEntry>, (Option<(u32, u32)>, Uuid))> = work_units
.into_iter()
.map(|u| {
let suid = u.entry.superfile_id;
(u.entry, (u.range, suid))
})
.collect();
let must_arc: Arc<Vec<String>> = Arc::new(musts);
let should_arc: Arc<Vec<String>> = Arc::new(shoulds);
let neg_arc: Arc<Vec<String>> = Arc::new(negatives);
let must_ph_arc: Arc<Vec<Vec<String>>> = Arc::new(must_phrases);
let should_ph_arc: Arc<Vec<Vec<String>>> = Arc::new(should_phrases);
let neg_ph_arc: Arc<Vec<Vec<String>>> = Arc::new(negative_phrases);
let column_arc = Arc::new(column_owned);
let shared = SharedTopK::new(k);
let tombstones = self.tombstone_cache.clone();
let now = Instant::now();
let kernel = move |r: Arc<SuperfileReader>, (range, suid): (Option<(u32, u32)>, Uuid)| {
let column_arc = Arc::clone(&column_arc);
let must_arc = Arc::clone(&must_arc);
let should_arc = Arc::clone(&should_arc);
let neg_arc = Arc::clone(&neg_arc);
let must_ph_arc = Arc::clone(&must_ph_arc);
let should_ph_arc = Arc::clone(&should_ph_arc);
let neg_ph_arc = Arc::clone(&neg_ph_arc);
let shared = Arc::clone(&shared);
let tombstones = tombstones.clone();
let global_idf = global_idf.clone();
async move {
let floor = shared.floor();
let hits = match range {
Some((start, end)) => {
let should_refs: Vec<&str> =
should_arc.iter().map(|s| s.as_str()).collect();
r.bm25_search_or_range_pretokenized_with_floor(
&column_arc,
&should_refs,
k,
start,
end,
floor,
global_idf.as_deref(),
)
.await
.map_err(fts_read_error)?
}
None => {
let must_refs: Vec<&str> = must_arc.iter().map(|s| s.as_str()).collect();
let should_refs: Vec<&str> =
should_arc.iter().map(|s| s.as_str()).collect();
let neg_refs: Vec<&str> = neg_arc.iter().map(|s| s.as_str()).collect();
r.bm25_search_clauses(
&column_arc,
ClauseLists {
musts: &must_refs,
shoulds: &should_refs,
negatives: &neg_refs,
must_phrases: &must_ph_arc,
should_phrases: &should_ph_arc,
negative_phrases: &neg_ph_arc,
global_idf: global_idf.as_deref(),
},
k,
floor,
)
.await
.map_err(fts_read_error)?
}
};
match tombstones.as_ref().map(|c| c.bitmap_for(suid, now)) {
Some(Ok(bitmap)) if !bitmap.is_empty() => shared.merge(
hits.iter()
.filter(|(d, _)| !bitmap.contains(*d))
.map(|(_, s)| *s),
),
Some(Err(_)) => {}
_ => shared.merge(hits.iter().map(|(_, s)| *s)),
}
Ok(hits)
}
};
let per_unit = dispatch::fanout_local_hits(self, units, kernel).await?;
let hits = select_top_k_stable(self, per_unit, k).await?;
Ok(hits)
}
async fn gather_global_term_idf(
&self,
manifest: &ManifestSnapshot,
column: &str,
terms: &[String],
) -> Result<GlobalTermIdf, QueryError> {
let mut map = GlobalTermIdf::with_capacity(terms.len());
let global_n = manifest.n_docs_total();
if terms.is_empty() || global_n == 0 {
return Ok(map);
}
let prune = PruneLeaf::TermPresence {
column: column.to_owned(),
terms: terms.to_vec(),
mode: BoolMode::Or,
};
let kept = select_superfiles(manifest, slice::from_ref(&prune)).await?;
let column_arc = Arc::new(column.to_owned());
let terms_arc: Arc<Vec<String>> = Arc::new(terms.to_vec());
let units: Vec<(Arc<SuperfileEntry>, ())> = kept.into_iter().map(|e| (e, ())).collect();
let per_sf: Vec<Vec<u64>> = dispatch::fanout_with(
self,
units,
false,
true,
move |r, _entry, _sidecars, _now, _params: ()| {
let column_arc = Arc::clone(&column_arc);
let terms_arc = Arc::clone(&terms_arc);
async move {
let refs: Vec<&str> = terms_arc.iter().map(String::as_str).collect();
let dfs = r
.term_dfs(&column_arc, &refs)
.await
.map_err(fts_read_error)?;
Ok::<Vec<u64>, QueryError>(dfs)
}
},
)
.await?;
let mut global_df = vec![0u64; terms.len()];
for sf in per_sf {
for (i, d) in sf.into_iter().enumerate() {
global_df[i] += d;
}
}
for (i, t) in terms.iter().enumerate() {
let df = global_df[i].min(global_n);
map.insert(t.clone(), bm25::idf(global_n, df));
}
Ok(map)
}
pub(crate) async fn bm25_search_prefix_async(
&self,
column: &str,
prefix: &str,
k: usize,
) -> Result<Vec<SuperfileHit>, QueryError> {
if k == 0 {
return Ok(Vec::new());
}
let manifest = self.manifest();
let pool_threads = manifest.options.reader_pool.current_num_threads();
let column_owned = column.to_owned();
let prefix_owned = prefix.to_owned();
let prefix_lower = prefix_owned.to_ascii_lowercase();
let kept = select_superfiles(
manifest.as_ref(),
&[PruneLeaf::Prefix {
column: column_owned.clone(),
prefix: prefix_lower.as_bytes().to_vec(),
}],
)
.await?;
if kept.is_empty() {
return Ok(Vec::new());
}
let kept_refs: Vec<&Arc<SuperfileEntry>> = kept.iter().collect();
let work_units = build_work_units(&kept_refs, FanOut::SubRanges, pool_threads);
let units: Vec<(Arc<SuperfileEntry>, Option<(u32, u32)>)> =
work_units.into_iter().map(|u| (u.entry, u.range)).collect();
let column_arc = Arc::new(column_owned);
let prefix_arc = Arc::new(prefix_owned);
let kernel = move |r: Arc<SuperfileReader>, range: Option<(u32, u32)>| {
let column_arc = Arc::clone(&column_arc);
let prefix_arc = Arc::clone(&prefix_arc);
async move {
match range {
Some((start, end)) => r
.bm25_search_prefix_range(&column_arc, &prefix_arc, k, start, end)
.await
.map_err(fts_read_error),
None => r
.bm25_search_prefix(&column_arc, &prefix_arc, k)
.await
.map_err(fts_read_error),
}
}
};
let per_unit = dispatch::fanout_local_hits(self, units, kernel).await?;
let hits = select_top_k_stable(self, per_unit, k).await?;
Ok(hits)
}
async fn parse_and_prune(
&self,
column: &str,
query: &str,
mode: BoolMode,
) -> Result<
(
UnrankedMatchSet,
UnrankedNegatives,
Vec<Arc<SuperfileEntry>>,
),
QueryError,
> {
let clauses = self
.manifest()
.options
.fts_tokenizer_for(column)
.parse(query)
.into_clauses(mode);
let musts: Vec<String> = clauses.musts.into_iter().map(Cow::into_owned).collect();
let shoulds: Vec<String> = clauses.shoulds.into_iter().map(Cow::into_owned).collect();
let negatives: Vec<String> = clauses.negatives.into_iter().map(Cow::into_owned).collect();
let own_phrases = |phrases: Vec<Vec<Cow<'_, str>>>| -> Vec<Vec<String>> {
phrases
.into_iter()
.map(|p| p.into_iter().map(Cow::into_owned).collect())
.collect()
};
let must_phrases = own_phrases(clauses.must_phrases);
let should_phrases = own_phrases(clauses.should_phrases);
let negative_phrases = own_phrases(clauses.negative_phrases);
let negs = UnrankedNegatives {
terms: negatives,
phrases: negative_phrases,
};
let has_musts = !musts.is_empty() || !must_phrases.is_empty();
if !has_musts && shoulds.is_empty() && should_phrases.is_empty() {
if negs.terms.is_empty() && negs.phrases.is_empty() {
return Ok((UnrankedMatchSet::default(), negs, Vec::new()));
}
return Err(QueryError::InvalidQuery(NEGATION_ONLY_QUERY_MSG.to_owned()));
}
let match_set = match has_musts {
true => UnrankedMatchSet {
terms: musts,
phrases: must_phrases,
mode: BoolMode::And,
},
false => UnrankedMatchSet {
terms: shoulds,
phrases: should_phrases,
mode,
},
};
let mut prune_terms = match_set.terms.clone();
for p in &match_set.phrases {
prune_terms.extend(p.iter().cloned());
}
let prune_leaf = PruneLeaf::TermPresence {
column: column.to_owned(),
terms: prune_terms,
mode: match_set.mode,
};
let kept =
select_superfiles(self.manifest().as_ref(), slice::from_ref(&prune_leaf)).await?;
Ok((match_set, negs, kept))
}
#[cfg_attr(
feature = "detailed-tracing",
tracing::instrument(skip_all, fields(column = column, mode = ?mode))
)]
pub(crate) async fn token_match_async(
&self,
column: &str,
query: &str,
mode: BoolMode,
) -> Result<Vec<SuperfileHit>, QueryError> {
let (match_set, negatives, kept) = self.parse_and_prune(column, query, mode).await?;
if kept.is_empty() {
return Ok(Vec::new());
}
let match_mode = match_set.mode;
let has_negatives = !negatives.is_empty();
let phrase_involved = match_set.has_phrases() || !negatives.phrases.is_empty();
let units: Vec<(Arc<SuperfileEntry>, ())> = kept.into_iter().map(|e| (e, ())).collect();
let column_arc = Arc::new(column.to_owned());
let term_arc: Arc<Vec<String>> = Arc::new(match_set.terms);
let phrase_arc: Arc<Vec<Vec<String>>> = Arc::new(match_set.phrases);
let neg_arc: Arc<Vec<String>> = Arc::new(negatives.terms);
let neg_ph_arc: Arc<Vec<Vec<String>>> = Arc::new(negatives.phrases);
let kernel = move |r: Arc<SuperfileReader>, _: ()| {
let column_arc = Arc::clone(&column_arc);
let term_arc = Arc::clone(&term_arc);
let phrase_arc = Arc::clone(&phrase_arc);
let neg_arc = Arc::clone(&neg_arc);
let neg_ph_arc = Arc::clone(&neg_ph_arc);
async move {
let refs: Vec<&str> = term_arc.iter().map(|s| s.as_str()).collect();
let docs = match phrase_involved {
true => r
.atoms_match_ids(&column_arc, &refs, &phrase_arc, match_mode)
.await
.map_err(fts_read_error)?,
false => r
.token_match(&column_arc, &refs, match_mode)
.await
.map_err(fts_read_error)?,
};
let docs = if has_negatives {
let neg_refs: Vec<&str> = neg_arc.iter().map(|s| s.as_str()).collect();
let excluded: RoaringBitmap = match neg_ph_arc.is_empty() {
true => r
.token_match(&column_arc, &neg_refs, BoolMode::Or)
.await
.map_err(fts_read_error)?,
false => r
.atoms_match_ids(&column_arc, &neg_refs, &neg_ph_arc, BoolMode::Or)
.await
.map_err(fts_read_error)?,
}
.into_iter()
.collect();
docs.into_iter()
.filter(|d| !excluded.contains(*d))
.collect::<Vec<_>>()
} else {
docs
};
Ok(docs.into_iter().map(|d| (d, 0.0f32)).collect::<Vec<_>>())
}
};
let per_unit = dispatch::fanout_local_hits(self, units, kernel).await?;
let total: usize = per_unit.iter().map(Vec::len).sum();
let mut hits: Vec<SuperfileHit> = Vec::with_capacity(total);
for unit in per_unit {
hits.extend(unit);
}
dispatch::attach_stable_ids_to_hits(self, &mut hits).await?;
Ok(hits)
}
pub(crate) async fn token_match_count_async(
&self,
column: &str,
query: &str,
mode: BoolMode,
) -> Result<u64, QueryError> {
let (match_set, negatives, kept) = self.parse_and_prune(column, query, mode).await?;
if kept.is_empty() {
return Ok(0);
}
let match_mode = match_set.mode;
let single_term = match_set.terms.len() == 1 && !match_set.has_phrases();
let has_negatives = !negatives.is_empty();
let phrase_involved = match_set.has_phrases() || !negatives.phrases.is_empty();
let column_arc = Arc::new(column.to_owned());
let term_arc: Arc<Vec<String>> = Arc::new(match_set.terms);
let phrase_arc: Arc<Vec<Vec<String>>> = Arc::new(match_set.phrases);
let neg_arc: Arc<Vec<String>> = Arc::new(negatives.terms);
let neg_ph_arc: Arc<Vec<Vec<String>>> = Arc::new(negatives.phrases);
let units: Vec<(Arc<SuperfileEntry>, ())> = kept.into_iter().map(|e| (e, ())).collect();
let per_superfile = dispatch::fanout_with(
self,
units,
true,
true,
move |r, entry, tombstone_cache, now, _params: ()| {
let column_arc = Arc::clone(&column_arc);
let term_arc = Arc::clone(&term_arc);
let phrase_arc = Arc::clone(&phrase_arc);
let neg_arc = Arc::clone(&neg_arc);
let neg_ph_arc = Arc::clone(&neg_ph_arc);
async move {
let tomb = match tombstone_cache.as_ref() {
Some(c) => {
let b = c
.bitmap_for(entry.superfile_id, now)
.map_err(|e| QueryError::Store(format!("tombstone cache: {e}")))?;
if b.is_empty() { None } else { Some(b) }
}
None => None,
};
let refs: Vec<&str> = term_arc.iter().map(|s| s.as_str()).collect();
if has_negatives || tomb.is_some() {
let docs = match phrase_involved {
true => r
.atoms_match_ids(&column_arc, &refs, &phrase_arc, match_mode)
.await
.map_err(fts_read_error)?,
false => r
.token_match(&column_arc, &refs, match_mode)
.await
.map_err(fts_read_error)?,
};
let excluded: RoaringBitmap = if has_negatives {
let neg_refs: Vec<&str> = neg_arc.iter().map(|s| s.as_str()).collect();
match neg_ph_arc.is_empty() {
true => r
.token_match(&column_arc, &neg_refs, BoolMode::Or)
.await
.map_err(fts_read_error)?,
false => r
.atoms_match_ids(
&column_arc,
&neg_refs,
&neg_ph_arc,
BoolMode::Or,
)
.await
.map_err(fts_read_error)?,
}
.into_iter()
.collect()
} else {
RoaringBitmap::new()
};
let n = docs
.iter()
.filter(|d| {
!excluded.contains(**d)
&& tomb.as_ref().is_none_or(|b| !b.contains(**d))
})
.count() as u64;
return Ok::<u64, QueryError>(n);
}
let n = if single_term {
r.term_df(&column_arc, &term_arc[0])
.await
.map_err(fts_read_error)?
} else if phrase_involved {
r.atoms_match_count(&column_arc, &refs, &phrase_arc, match_mode)
.await
.map_err(fts_read_error)?
} else {
r.token_match_count(&column_arc, &refs, match_mode)
.await
.map_err(fts_read_error)?
};
Ok(n)
}
},
)
.await?;
Ok(per_superfile.into_iter().sum())
}
pub(crate) async fn exact_match_async(
&self,
column: &str,
value: &str,
) -> Result<Vec<SuperfileHit>, QueryError> {
let manifest = self.manifest();
let term_strings: Vec<String> = manifest
.options
.fts_tokenizer_for(column)
.tokenize(value)
.collect();
let leaves = if term_strings.is_empty() {
Vec::new()
} else {
vec![PruneLeaf::TermPresence {
column: column.to_owned(),
terms: term_strings.clone(),
mode: BoolMode::And,
}]
};
let kept = select_superfiles(manifest.as_ref(), &leaves).await?;
if kept.is_empty() {
return Ok(Vec::new());
}
let units: Vec<(Arc<SuperfileEntry>, ())> = kept.into_iter().map(|e| (e, ())).collect();
let column_arc = Arc::new(column.to_owned());
let value_arc = Arc::new(value.to_owned());
let tokens_arc = Arc::new(term_strings);
let body = move |r: Arc<SuperfileReader>,
entry: Arc<SuperfileEntry>,
tombstone_cache: Option<Arc<SidecarCache>>,
now: Instant,
_: ()| {
let column_arc = Arc::clone(&column_arc);
let value_arc = Arc::clone(&value_arc);
let tokens_arc = Arc::clone(&tokens_arc);
async move {
let candidates: Vec<u32> = if tokens_arc.is_empty() {
(0..r.n_docs() as u32).collect()
} else {
let refs: Vec<&str> = tokens_arc.iter().map(String::as_str).collect();
r.token_match(&column_arc, &refs, BoolMode::And)
.await
.map_err(|e| QueryError::Parquet(e.to_string()))?
};
if candidates.is_empty() {
return Ok(Vec::new());
}
let batch = if r.can_take_by_local_doc_ids() {
r.take_by_local_doc_ids(&candidates, &[column_arc.as_str()])
.map_err(|e| QueryError::Parquet(e.to_string()))?
} else {
take_rows_byte_source(&r, &candidates, &[column_arc.as_str()])
.await
.map_err(|e| QueryError::Execute(e.to_string()))?
};
let values = batch
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.ok_or_else(|| {
QueryError::Execute(format!(
"exact_match column '{}' is not LargeUtf8",
column_arc
))
})?;
let mut hits: Vec<SuperfileHit> = candidates
.iter()
.enumerate()
.filter(|(index, _)| {
!values.is_null(*index) && values.value(*index) == value_arc.as_str()
})
.map(|(_, &local_doc_id)| SuperfileHit {
superfile: entry.uri,
local_doc_id,
score: 0.0,
stable_id: None,
})
.collect();
dispatch::apply_tombstone_filter(tombstone_cache.as_ref(), &entry, &mut hits, now)?;
Ok(hits)
}
};
let per_unit = dispatch::fanout_with(self, units, true, true, body).await?;
let mut hits: Vec<SuperfileHit> = per_unit.into_iter().flatten().collect();
dispatch::attach_stable_ids_to_hits(self, &mut hits).await?;
Ok(hits)
}
}
impl SupertableReader {
pub fn bm25_search(
&self,
column: &str,
query: &str,
k: usize,
mode: BoolMode,
stats: Bm25Stats,
projection: Option<&[&str]>,
) -> Result<Vec<RecordBatch>, QueryError> {
let _foreground = ForegroundQueryGuard::enter();
self.block_on(async {
let hits = self
.bm25_search_async(column, query, k, mode, stats)
.await?;
let batch = resolve_hits_named(self, &hits, projection, "bm25_search")
.await
.map_err(|e| QueryError::Execute(e.to_string()))?;
Ok(vec![batch])
})
}
pub fn bm25_hits(
&self,
column: &str,
query: &str,
k: usize,
mode: BoolMode,
) -> Result<Vec<SuperfileHit>, QueryError> {
let _foreground = ForegroundQueryGuard::enter();
self.block_on(self.bm25_search_async(column, query, k, mode, Bm25Stats::PerSuperfile))
}
pub fn bm25_search_prefix(
&self,
column: &str,
prefix: &str,
k: usize,
) -> Result<Vec<SuperfileHit>, QueryError> {
let _foreground = ForegroundQueryGuard::enter();
self.block_on(self.bm25_search_prefix_async(column, prefix, k))
}
pub fn token_match(
&self,
column: &str,
query: &str,
mode: BoolMode,
) -> Result<Vec<SuperfileHit>, QueryError> {
let _foreground = ForegroundQueryGuard::enter();
self.block_on(self.token_match_async(column, query, mode))
}
pub fn count(&self, column: &str, query: &str, mode: BoolMode) -> Result<u64, QueryError> {
let _foreground = ForegroundQueryGuard::enter();
self.block_on(self.token_match_count_async(column, query, mode))
}
pub fn exact_match(&self, column: &str, value: &str) -> Result<Vec<SuperfileHit>, QueryError> {
let _foreground = ForegroundQueryGuard::enter();
self.block_on(self.exact_match_async(column, value))
}
}
struct WorkUnit {
entry: Arc<SuperfileEntry>,
range: Option<(u32, u32)>,
}
const SUBRANGE_MIN_DOCS: u32 = 50_000;
fn fts_read_error(e: ReadError) -> QueryError {
match &e {
ReadError::Fts(fts)
if matches!(
fts.as_ref(),
FtsError::PositionsUnavailable { .. } | FtsError::NegationOnly
) =>
{
QueryError::InvalidQuery(e.to_string())
}
_ => QueryError::Parquet(e.to_string()),
}
}
const OR_FANOUT_MIN_TERMS: usize = 2;
enum FanOut {
PerSuperfile,
SubRanges,
}
fn fanout_for(n_musts: usize, n_shoulds: usize, has_negatives: bool) -> FanOut {
if n_musts == 0 && n_shoulds >= OR_FANOUT_MIN_TERMS && !has_negatives {
FanOut::SubRanges
} else {
FanOut::PerSuperfile
}
}
fn build_work_units(
kept: &[&Arc<SuperfileEntry>],
fanout: FanOut,
pool_threads: usize,
) -> Vec<WorkUnit> {
let want_subranges = pool_threads.div_ceil(kept.len().max(1)).max(1);
if matches!(fanout, FanOut::PerSuperfile) || want_subranges <= 1 {
return kept
.iter()
.map(|e| WorkUnit {
entry: Arc::clone(e),
range: None,
})
.collect();
}
let mut units: Vec<WorkUnit> = Vec::with_capacity(kept.len() * want_subranges);
for entry in kept {
let n_docs = entry.n_docs as u32;
if n_docs == 0 {
continue;
}
let cap_by_floor = (n_docs / SUBRANGE_MIN_DOCS).max(1) as usize;
let n_sub = want_subranges.min(cap_by_floor);
if n_sub <= 1 {
units.push(WorkUnit {
entry: Arc::clone(entry),
range: None,
});
continue;
}
let stride = n_docs.div_ceil(n_sub as u32);
let mut start: u32 = 0;
while start < n_docs {
let end = start.saturating_add(stride).min(n_docs);
units.push(WorkUnit {
entry: Arc::clone(entry),
range: Some((start, end)),
});
start = end;
}
}
units
}
async fn select_top_k_stable(
tr: &SupertableReader,
per_unit: Vec<Vec<SuperfileHit>>,
k: usize,
) -> Result<Vec<SuperfileHit>, QueryError> {
let mut cands: Vec<SuperfileHit> = per_unit.into_iter().flatten().collect();
if cands.len() > k {
cands.select_nth_unstable_by(k - 1, |a, b| {
b.score.partial_cmp(&a.score).unwrap_or(Ordering::Equal)
});
let kth_score = cands[k - 1].score;
cands.retain(|c| c.score >= kth_score);
}
dispatch::attach_stable_ids_to_hits(tr, &mut cands).await?;
cands.sort_unstable_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(Ordering::Equal)
.then(a.stable_id.cmp(&b.stable_id))
});
cands.truncate(k);
Ok(cands)
}
impl Supertable {
#[cfg_attr(
feature = "detailed-tracing",
tracing::instrument(skip_all, fields(column = column, k = k, mode = ?mode))
)]
pub fn bm25_search(
&self,
column: &str,
query: &str,
k: usize,
mode: BoolMode,
stats: Bm25Stats,
projection: Option<&[&str]>,
) -> Result<Vec<RecordBatch>, InfinoError> {
debug!(column, k, mode = ?mode, "bm25_search");
self.reader()?
.bm25_search(column, query, k, mode, stats, projection)
.map_err(InfinoError::from)
.map_err(|e| e.with_context("bm25_search", None))
}
#[cfg_attr(
feature = "detailed-tracing",
tracing::instrument(skip_all, fields(column = column, mode = ?mode))
)]
pub fn token_match(
&self,
column: &str,
query: &str,
mode: BoolMode,
projection: Option<&[&str]>,
) -> Result<Vec<RecordBatch>, InfinoError> {
debug!(column, mode = ?mode, "token_match");
let reader = self.reader()?;
let hits = reader
.token_match(column, query, mode)
.map_err(|e| InfinoError::from(e).with_context("token_match", None))?;
let batch = self
.block_on_query(resolve_hits_named(
&reader,
&hits,
projection,
"token_match",
))
.map_err(|e| InfinoError::Query(e.to_string()).with_context("token_match", None))?;
Ok(vec![batch])
}
#[cfg_attr(
feature = "detailed-tracing",
tracing::instrument(skip_all, fields(column = column))
)]
pub fn exact_match(
&self,
column: &str,
value: &str,
projection: Option<&[&str]>,
) -> Result<Vec<RecordBatch>, InfinoError> {
debug!(column, "exact_match");
let reader = self.reader()?;
let hits = reader
.exact_match(column, value)
.map_err(|e| InfinoError::from(e).with_context("exact_match", None))?;
let batch = self
.block_on_query(resolve_hits_named(
&reader,
&hits,
projection,
"exact_match",
))
.map_err(|e| InfinoError::Query(e.to_string()).with_context("exact_match", None))?;
Ok(vec![batch])
}
pub fn count(&self, column: &str, query: &str, mode: BoolMode) -> Result<u64, InfinoError> {
self.reader()?
.count(column, query, mode)
.map_err(InfinoError::from)
.map_err(|e| e.with_context("count", None))
}
}
#[cfg(test)]
mod tests {
use std::{collections::HashSet, future::Future, sync::Arc};
use arrow_array::{Decimal128Array, LargeStringArray, RecordBatch};
use arrow_schema::{DataType, Field, Schema};
use bytes::Bytes;
use datafusion::prelude::{col, lit};
use tokio::runtime::Builder;
use super::{Bm25Stats, BoolMode, FanOut, build_work_units, fanout_for};
use crate::{
storage::{LocalFsStorageProvider, StorageProvider},
superfile::{
SuperfileReader,
builder::{BuilderOptions, FtsConfig, SuperfileBuilder},
vector::layout::VectorLayout,
},
supertable::{
Supertable, SupertableOptions,
error::QueryError,
options::{DECIMAL128_PRECISION, DECIMAL128_SCALE},
},
test_helpers::default_tokenizer as tok,
};
fn block_on<F: Future>(fut: F) -> F::Output {
Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime")
.block_on(fut)
}
fn schema_id_title() -> Arc<Schema> {
Arc::new(Schema::new(vec![Field::new(
"title",
DataType::LargeUtf8,
false,
)]))
}
fn options_one_superfile_per_commit() -> SupertableOptions {
let pool = Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.expect("pool"),
);
SupertableOptions::new(
schema_id_title(),
vec![FtsConfig {
column: "title".into(),
positions: false,
}],
vec![],
Some(tok()),
)
.expect("valid options")
.with_writer_pool(pool)
}
fn build_batch(_start: u64, titles: &[&str]) -> RecordBatch {
let titles_arr = LargeStringArray::from(titles.to_vec());
RecordBatch::try_new(schema_id_title(), vec![Arc::new(titles_arr)]).expect("batch")
}
fn all_scored(st: &Supertable, query: &str, stats: Bm25Stats) -> Vec<(String, f32)> {
const K_ALL: usize = 1000;
top_k_scored(st, query, stats, K_ALL)
}
fn top_k_scored(
st: &Supertable,
query: &str,
stats: Bm25Stats,
k: usize,
) -> Vec<(String, f32)> {
use arrow_array::{Float32Array, LargeStringArray};
let batches = st
.reader()
.expect("reader")
.bm25_search(
"title",
query,
k,
BoolMode::Or,
stats,
Some(&["title", "score"]),
)
.expect("bm25_search");
let mut out = Vec::new();
for b in &batches {
let titles = b
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("title utf8");
let scores = b
.column(1)
.as_any()
.downcast_ref::<Float32Array>()
.expect("score f32");
for i in 0..b.num_rows() {
out.push((titles.value(i).to_string(), scores.value(i)));
}
}
out
}
#[test]
fn global_stats_multi_superfile_matches_single_superfile() {
let titles: Vec<String> = (0..24)
.map(|i| {
let topic = ["alpha", "beta", "gamma"][i % 3];
let band = ["red", "green"][(i / 3) % 2];
format!("{topic} shared {band} d{i:02}")
})
.collect();
let refs: Vec<&str> = titles.iter().map(|s| s.as_str()).collect();
let single = Supertable::create(options_one_superfile_per_commit()).expect("create");
{
let mut w = single.writer().expect("writer");
w.append(&build_batch(0, &refs)).expect("append");
w.commit().expect("commit");
}
assert_eq!(
single
.reader()
.expect("reader")
.manifest()
.get_all_superfiles()
.len(),
1,
"single table must be one superfile"
);
let multi = Supertable::create(options_one_superfile_per_commit()).expect("create");
{
let mut w = multi.writer().expect("writer");
for chunk in refs.chunks(6) {
w.append(&build_batch(0, chunk)).expect("append");
w.commit().expect("commit");
}
}
assert!(
multi
.reader()
.expect("reader")
.manifest()
.get_all_superfiles()
.len()
> 1,
"multi table must be fragmented across superfiles"
);
let score_map = |hits: Vec<(String, f32)>| -> std::collections::HashMap<String, f32> {
hits.into_iter().collect()
};
for q in ["alpha shared", "beta red", "gamma green d05", "shared red"] {
let single_ref = score_map(all_scored(&single, q, Bm25Stats::PerSuperfile));
let multi_global = score_map(all_scored(&multi, q, Bm25Stats::Global));
let multi_local = score_map(all_scored(&multi, q, Bm25Stats::PerSuperfile));
assert_eq!(
single_ref.len(),
multi_global.len(),
"hit count mismatch for {q:?}"
);
for (title, s_score) in &single_ref {
let g_score = multi_global
.get(title)
.unwrap_or_else(|| panic!("global result missing {title:?} for {q:?}"));
assert!(
(s_score - g_score).abs() <= 1e-5 * s_score.abs().max(1.0),
"global score {g_score} != single score {s_score} for {title:?} / {q:?}"
);
}
if q == "alpha shared" {
let local_diverges = single_ref.len() != multi_local.len()
|| single_ref.iter().any(|(title, s)| {
multi_local
.get(title)
.is_none_or(|l| (s - l).abs() > 1e-4 * s.abs().max(1.0))
});
assert!(
local_diverges,
"per-superfile stats unexpectedly matched single-superfile for {q:?}; \
the oracle would not be exercising Global"
);
}
}
}
fn options_positions_one_superfile_per_commit() -> SupertableOptions {
let pool = Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.expect("pool"),
);
SupertableOptions::new(
schema_id_title(),
vec![FtsConfig {
column: "title".into(),
positions: true,
}],
vec![],
Some(tok()),
)
.expect("valid options")
.with_writer_pool(pool)
}
#[test]
fn global_stats_phrase_query_matches_single_superfile() {
let titles: Vec<String> = (0..24)
.map(|i| {
let topic = ["alpha", "beta", "gamma"][i % 3];
let w2 = if i % 2 == 0 { "brown" } else { "red" };
format!("{topic} quick {w2} d{i:02}")
})
.collect();
let refs: Vec<&str> = titles.iter().map(|s| s.as_str()).collect();
let single =
Supertable::create(options_positions_one_superfile_per_commit()).expect("create");
{
let mut w = single.writer().expect("writer");
w.append(&build_batch(0, &refs)).expect("append");
w.commit().expect("commit");
}
assert_eq!(
single
.reader()
.expect("reader")
.manifest()
.get_all_superfiles()
.len(),
1,
"single table must be one superfile"
);
let multi =
Supertable::create(options_positions_one_superfile_per_commit()).expect("create");
{
let mut w = multi.writer().expect("writer");
for chunk in refs.chunks(6) {
w.append(&build_batch(0, chunk)).expect("append");
w.commit().expect("commit");
}
}
assert!(
multi
.reader()
.expect("reader")
.manifest()
.get_all_superfiles()
.len()
> 1,
"multi table must be fragmented across superfiles"
);
let score_map = |hits: Vec<(String, f32)>| -> std::collections::HashMap<String, f32> {
hits.into_iter().collect()
};
for q in ["alpha \"quick brown\"", "\"quick brown\""] {
let single_ref = score_map(all_scored(&single, q, Bm25Stats::PerSuperfile));
let multi_global = score_map(all_scored(&multi, q, Bm25Stats::Global));
let multi_local = score_map(all_scored(&multi, q, Bm25Stats::PerSuperfile));
assert!(!single_ref.is_empty(), "query {q:?} matched nothing");
assert_eq!(
single_ref.len(),
multi_global.len(),
"hit count mismatch for {q:?}"
);
for (title, s_score) in &single_ref {
let g_score = multi_global
.get(title)
.unwrap_or_else(|| panic!("global result missing {title:?} for {q:?}"));
assert!(
(s_score - g_score).abs() <= 1e-5 * s_score.abs().max(1.0),
"global score {g_score} != single score {s_score} for {title:?} / {q:?}"
);
}
if q == "\"quick brown\"" {
let local_diverges = single_ref.len() != multi_local.len()
|| single_ref.iter().any(|(title, s)| {
multi_local
.get(title)
.is_none_or(|l| (s - l).abs() > 1e-4 * s.abs().max(1.0))
});
assert!(
local_diverges,
"per-superfile phrase stats unexpectedly matched single-superfile for {q:?}"
);
}
}
}
#[test]
fn global_stats_small_k_pruning_matches_single_superfile() {
const N: usize = 160;
const L: usize = 8; const K: usize = 3;
let boosts = [(10usize, 3u32), (90, 2), (150, 1)];
let titles: Vec<String> = (0..N)
.map(|i| {
let bt = boosts
.iter()
.find(|(idx, _)| *idx == i)
.map(|(_, tf)| *tf as usize)
.unwrap_or(0);
let mut toks: Vec<String> = vec!["common".to_string()];
for _ in 0..bt {
toks.push("boost".to_string());
}
while toks.len() < L {
toks.push("pad".to_string());
}
toks[L - 1] = format!("d{i:03}");
toks.join(" ")
})
.collect();
let refs: Vec<&str> = titles.iter().map(String::as_str).collect();
let single = Supertable::create(options_one_superfile_per_commit()).expect("create");
{
let mut w = single.writer().expect("writer");
w.append(&build_batch(0, &refs)).expect("append");
w.commit().expect("commit");
}
assert_eq!(
single
.reader()
.expect("reader")
.manifest()
.get_all_superfiles()
.len(),
1
);
let multi = Supertable::create(options_one_superfile_per_commit()).expect("create");
{
let mut w = multi.writer().expect("writer");
for chunk in refs.chunks(20) {
w.append(&build_batch(0, chunk)).expect("append");
w.commit().expect("commit");
}
}
assert!(
multi
.reader()
.expect("reader")
.manifest()
.get_all_superfiles()
.len()
> 1
);
let q = "+common boost";
let single_ref = top_k_scored(&single, q, Bm25Stats::PerSuperfile, K);
let multi_global = top_k_scored(&multi, q, Bm25Stats::Global, K);
assert_eq!(
single_ref.len(),
K,
"top-k should be truncated to k (heap full)"
);
assert_eq!(multi_global.len(), K, "global top-k should also be k");
for ((s_title, s_score), (g_title, g_score)) in single_ref.iter().zip(&multi_global) {
assert_eq!(s_title, g_title, "top-{K} doc/order mismatch under pruning");
assert!(
(s_score - g_score).abs() <= 1e-5 * s_score.abs().max(1.0),
"top-{K} score mismatch: single {s_score} vs global {g_score}"
);
}
assert!(
multi_global.iter().all(|(t, _)| t.contains("boost")),
"top-{K} must be the boost docs, got {multi_global:?}"
);
}
fn build_oracle_superfile(titles: &[&str]) -> Arc<SuperfileReader> {
let schema = Arc::new(Schema::new(vec![
Field::new(
"_id",
DataType::Decimal128(DECIMAL128_PRECISION, DECIMAL128_SCALE),
false,
),
Field::new("title", DataType::LargeUtf8, false),
]));
let opts = BuilderOptions::new(
schema.clone(),
"_id",
vec![FtsConfig {
column: "title".into(),
positions: false,
}],
vec![],
Some(tok()),
);
let mut b = SuperfileBuilder::new(opts).expect("builder");
let n = titles.len();
let ids = Decimal128Array::from((0..n as i128).collect::<Vec<_>>())
.with_precision_and_scale(DECIMAL128_PRECISION, DECIMAL128_SCALE)
.expect("decimal128");
let titles_arr = LargeStringArray::from(titles.to_vec());
let batch =
RecordBatch::try_new(schema, vec![Arc::new(ids), Arc::new(titles_arr)]).expect("batch");
b.add_batch(&batch, &[]).expect("add_batch");
let bytes = Bytes::from(b.finish().expect("finish"));
Arc::new(SuperfileReader::open(bytes).expect("open"))
}
#[test]
fn negation_excludes_across_superfiles() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["alpha beta", "alpha gamma"]))
.expect("append");
w.commit().expect("commit");
w.append(&build_batch(2, &["alpha delta"])).expect("append");
w.commit().expect("commit");
w.append(&build_batch(3, &["beta gamma"])).expect("append");
w.commit().expect("commit");
let r = st.reader().expect("reader");
let hits = r
.bm25_hits("title", "alpha -beta", 10, BoolMode::Or)
.expect("negation search");
assert_eq!(hits.len(), 2, "alpha minus beta: {hits:?}");
let hits = r
.bm25_hits("title", "alpha", 10, BoolMode::Or)
.expect("positive search");
assert_eq!(hits.len(), 3);
}
#[test]
fn negated_term_does_not_prune_superfiles() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["alpha one", "alpha two"]))
.expect("append");
w.commit().expect("commit");
w.append(&build_batch(2, &["alpha delta"])).expect("append");
w.commit().expect("commit");
w.append(&build_batch(3, &["gamma three"])).expect("append");
w.commit().expect("commit");
let r = st.reader().expect("reader");
let hits = r
.bm25_hits("title", "alpha -delta", 10, BoolMode::And)
.expect("negation search");
assert_eq!(hits.len(), 2, "alpha minus delta: {hits:?}");
}
#[test]
fn negation_only_query_errors() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["alpha beta"])).expect("append");
w.commit().expect("commit");
let r = st.reader().expect("reader");
let res = r.bm25_hits("title", "-alpha", 10, BoolMode::Or);
assert!(res.is_err(), "negation-only must error; got {res:?}");
}
#[test]
fn count_and_token_match_negation_only_query_errors() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["alpha beta"])).expect("append");
w.commit().expect("commit");
let r = st.reader().expect("reader");
for mode in [BoolMode::Or, BoolMode::And] {
assert!(
r.count("title", "-alpha", mode).is_err(),
"negation-only count must error ({mode:?})"
);
assert!(
r.token_match("title", "-alpha", mode).is_err(),
"negation-only token_match must error ({mode:?})"
);
}
assert!(r.count("title", "-alpha -beta", BoolMode::Or).is_err());
assert_eq!(r.count("title", "", BoolMode::Or).expect("empty"), 0);
assert!(
r.token_match("title", " ", BoolMode::Or)
.expect("blank")
.is_empty()
);
}
#[test]
fn bm25_search_empty_supertable_returns_empty_without_store_calls() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let r = st.reader().expect("reader");
let hits = r
.bm25_hits("title", "rust", 5, BoolMode::Or)
.expect("query");
assert!(hits.is_empty());
}
#[test]
fn bm25_search_k_zero_short_circuits() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["rust async"])).expect("append");
w.commit().expect("commit");
let r = st.reader().expect("reader");
let hits = r
.bm25_hits("title", "rust", 0, BoolMode::Or)
.expect("query");
assert!(hits.is_empty());
}
#[test]
fn bm25_search_returns_descending_score_order() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(
0,
&[
"rust rust rust async",
"rust async runtime",
"rust embedded",
"python data",
],
))
.expect("append");
w.commit().expect("commit");
let r = st.reader().expect("reader");
let hits = r
.bm25_hits("title", "rust", 4, BoolMode::Or)
.expect("query");
assert_eq!(hits.len(), 3);
for w in hits.windows(2) {
assert!(w[0].score >= w[1].score);
}
}
#[test]
fn bm25_search_carries_superfile_uri_for_each_hit() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["rust rust async"])).expect("a1");
w.commit().expect("c1");
w.append(&build_batch(10, &["rust runtime"])).expect("a2");
w.commit().expect("c2");
let r = st.reader().expect("reader");
assert_eq!(r.n_superfiles(), 2);
let hits = r
.bm25_hits("title", "rust", 5, BoolMode::Or)
.expect("query");
assert_eq!(hits.len(), 2);
let mut uris: Vec<_> = hits.iter().map(|h| h.superfile).collect();
uris.sort();
let expected: Vec<_> = {
let mut v: Vec<_> = r.manifest().superfiles.iter().map(|e| e.uri).collect();
v.sort();
v
};
assert_eq!(uris, expected);
}
#[test]
fn bm25_search_oracle_top_k_set_matches_single_superfile() {
let titles = vec![
"lookup nimblefox special token", "ordinary common everyday text", "more usual filler corpus copy", "something boring without it", "mid corpus another nimblefox row", "generic page that adds nothing", "another stuffer no rare terms", "more padding here for filler", "tail nimblefox final superfile", "another tail row", "yet another normal title", "wrapping up the corpus today", ];
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
for chunk_start in (0..titles.len()).step_by(4) {
let end = (chunk_start + 4).min(titles.len());
let chunk = &titles[chunk_start..end];
w.append(&build_batch(chunk_start as u64, chunk))
.expect("append");
w.commit().expect("commit");
}
assert_eq!(st.reader().expect("reader").n_superfiles(), 3);
let oracle = build_oracle_superfile(&titles);
let oracle_hits = block_on(oracle.bm25_hits_async("title", "nimblefox", 5, BoolMode::Or))
.expect("oracle");
assert_eq!(oracle_hits.len(), 3);
let oracle_set: HashSet<u32> = oracle_hits.iter().map(|(d, _)| *d).collect();
assert_eq!(oracle_set, [0u32, 4, 8].iter().copied().collect());
let st_reader = st.reader().expect("reader");
let st_hits = st_reader
.bm25_hits("title", "nimblefox", 5, BoolMode::Or)
.expect("supertable query");
assert_eq!(st_hits.len(), 3);
let manifest = st_reader.manifest();
let st_globals: HashSet<u32> = st_hits
.iter()
.map(|h| {
let seg_idx = manifest
.superfiles
.iter()
.position(|e| e.uri == h.superfile)
.expect("superfile in manifest");
(seg_idx as u32) * 4 + h.local_doc_id
})
.collect();
assert_eq!(st_globals, oracle_set);
}
#[test]
fn bm25_search_prefix_oracle_top_k_set_matches_single_superfile() {
let titles = vec![
"rust async runtime",
"rust embedded systems",
"ruby gemfile config",
"rustacean conference",
"python machine learning",
"python web framework",
"rusty pipe rebuild",
"go concurrency model",
];
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
for chunk_start in (0..titles.len()).step_by(2) {
let end = (chunk_start + 2).min(titles.len());
let chunk = &titles[chunk_start..end];
w.append(&build_batch(chunk_start as u64, chunk))
.expect("append");
w.commit().expect("commit");
}
let oracle = build_oracle_superfile(&titles);
let oracle_hits = block_on(oracle.bm25_search_prefix("title", "rust", 5)).expect("oracle");
let oracle_globals: HashSet<u32> = oracle_hits.iter().map(|(d, _)| *d).collect();
let st_reader = st.reader().expect("reader");
let st_hits = st_reader
.bm25_search_prefix("title", "rust", 5)
.expect("supertable query");
let manifest = st_reader.manifest();
let st_globals: HashSet<u32> = st_hits
.iter()
.map(|h| {
let seg_idx = manifest
.superfiles
.iter()
.position(|e| e.uri == h.superfile)
.expect("superfile in manifest");
(seg_idx as u32) * 2 + h.local_doc_id
})
.collect();
assert_eq!(st_hits.len(), oracle_hits.len());
assert_eq!(st_globals, oracle_globals);
assert!(st_hits.len() >= 4);
}
#[test]
fn bm25_search_prefix_unmatched_prefix_returns_empty() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["rust async"])).expect("append");
w.commit().expect("commit");
let r = st.reader().expect("reader");
let hits = r.bm25_search_prefix("title", "zzzz", 10).expect("query");
assert!(hits.is_empty());
}
#[test]
fn bm25_search_prefix_lowercases_input() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["Rust async runtime"]))
.expect("append");
w.commit().expect("commit");
let r = st.reader().expect("reader");
let hits = r.bm25_search_prefix("title", "RUST", 5).expect("query");
assert_eq!(hits.len(), 1);
}
#[test]
fn bm25_search_unknown_column_errors() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["rust"])).expect("append");
w.commit().expect("commit");
let r = st.reader().expect("reader");
let err = r
.bm25_hits("missing_column", "rust", 5, BoolMode::Or)
.expect_err("expected error");
assert!(matches!(err, QueryError::Parquet(_)), "got {err:?}");
}
#[test]
fn bm25_search_results_global_top_k_caps_at_k() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
for i in 0..4 {
w.append(&build_batch(i * 10, &["rust async runtime"]))
.expect("a");
w.commit().expect("c");
}
let r = st.reader().expect("reader");
let hits = r
.bm25_hits("title", "rust", 2, BoolMode::Or)
.expect("query");
assert_eq!(hits.len(), 2);
}
fn seeded_three_doc_supertable() -> Supertable {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(
0,
&["the quick brown fox", "a lazy dog", "quick thinking"],
))
.expect("append");
w.commit().expect("commit");
st
}
#[test]
fn supertable_bm25_search_rows_default_and_projected() {
let st = seeded_three_doc_supertable();
let bare = st
.bm25_search(
"title",
"fox",
10,
BoolMode::Or,
Bm25Stats::PerSuperfile,
None,
)
.expect("bm25 rows");
assert_eq!(bare.iter().map(|b| b.num_rows()).sum::<usize>(), 1);
assert_eq!(bare[0].num_columns(), 2, "_id + score");
let rows = st
.bm25_search(
"title",
"fox",
10,
BoolMode::Or,
Bm25Stats::PerSuperfile,
Some(&["_id", "title", "score"]),
)
.expect("bm25 projected rows");
assert_eq!(rows[0].num_columns(), 3);
}
#[test]
fn supertable_token_match_and_exact_match_rows() {
let st = seeded_three_doc_supertable();
let tm = st
.token_match("title", "quick", BoolMode::Or, None)
.expect("token_match");
assert_eq!(tm.iter().map(|b| b.num_rows()).sum::<usize>(), 2);
let em = st
.exact_match("title", "a lazy dog", Some(&["_id", "title"]))
.expect("exact_match");
assert_eq!(em.iter().map(|b| b.num_rows()).sum::<usize>(), 1);
assert_eq!(em[0].num_columns(), 2);
}
#[test]
fn reader_token_match_and_exact_match_hits() {
let st = seeded_three_doc_supertable();
let r = st.reader().expect("reader");
let any = r.token_match("title", "quick", BoolMode::And).expect("tm");
assert_eq!(any.len(), 2);
let none = r.exact_match("title", "!!!").expect("em punctuation");
assert!(none.is_empty());
let one = r.exact_match("title", "quick thinking").expect("em");
assert_eq!(one.len(), 1);
}
#[test]
fn token_match_empty_query_short_circuits() {
let st = seeded_three_doc_supertable();
let r = st.reader().expect("reader");
let hits = r
.token_match("title", " ", BoolMode::Or)
.expect("tm empty");
assert!(hits.is_empty());
}
fn seeded_clause_supertable() -> Supertable {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(
0,
&["climate change policy", "climate science report"],
))
.expect("append");
w.commit().expect("commit");
w.append(&build_batch(
10,
&["policy analysis quarterly", "climate policy summit"],
))
.expect("append");
w.commit().expect("commit");
st
}
fn options_positional_one_superfile_per_commit() -> SupertableOptions {
let pool = Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.expect("pool"),
);
SupertableOptions::new(
schema_id_title(),
vec![FtsConfig {
column: "title".into(),
positions: true,
}],
vec![],
Some(tok()),
)
.expect("valid options")
.with_writer_pool(pool)
}
fn seeded_phrase_supertable() -> Supertable {
let st = Supertable::create(options_positional_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["new york city", "the new york times"]))
.expect("append");
w.commit().expect("commit");
w.append(&build_batch(10, &["york loves new haven", "big new york"]))
.expect("append");
w.commit().expect("commit");
st
}
#[test]
fn phrase_query_end_to_end() {
let st = seeded_phrase_supertable();
let r = st.reader().expect("reader");
let hits = r
.bm25_hits("title", r#""new york""#, 10, BoolMode::Or)
.expect("phrase hits");
assert_eq!(hits.len(), 3, "three docs contain the phrase");
let n = r
.count("title", r#""new york""#, BoolMode::Or)
.expect("phrase count");
assert_eq!(n, 3);
let and_count = r
.count("title", "+new +york", BoolMode::Or)
.expect("token and count");
assert_eq!(and_count, 4);
let hits = r
.bm25_hits("title", r#"+"new york" +the"#, 10, BoolMode::Or)
.expect("phrase + term");
assert_eq!(hits.len(), 1);
let n = r
.count("title", r#"york -"new york""#, BoolMode::Or)
.expect("negated phrase count");
assert_eq!(n, 1);
}
#[test]
fn phrase_on_positionless_table_errors() {
let st = seeded_clause_supertable();
let r = st.reader().expect("reader");
let err = r
.bm25_hits("title", r#""climate change""#, 10, BoolMode::Or)
.expect_err("typed error expected");
assert!(
matches!(err, QueryError::InvalidQuery(_)),
"phrase on positionless column should be InvalidQuery, got {err:?}"
);
assert!(
err.to_string().contains("positions"),
"error should say positions are missing: {err}"
);
let err = r
.count("title", r#""climate change""#, BoolMode::Or)
.expect_err("count errors too");
assert!(
matches!(err, QueryError::InvalidQuery(_)),
"count phrase on positionless column should be InvalidQuery, got {err:?}"
);
assert!(err.to_string().contains("positions"));
}
#[test]
fn must_should_match_set_and_count_across_superfiles() {
let st = seeded_clause_supertable();
let r = st.reader().expect("reader");
let hits = r
.bm25_hits("title", "+climate policy", 10, BoolMode::Or)
.expect("bm25 +climate policy");
assert_eq!(hits.len(), 3, "match set is the must set");
let n = r
.count("title", "+climate policy", BoolMode::Or)
.expect("count +climate policy");
assert_eq!(n, 3);
let union = r
.count("title", "climate policy", BoolMode::Or)
.expect("count union");
assert_eq!(union, 4);
let top2: Vec<f32> = hits.iter().take(2).map(|h| h.score).collect();
let third = hits[2].score;
assert!(
top2.iter().all(|s| *s > third),
"climate∧policy docs must outrank climate-only: {hits:?}"
);
}
#[test]
fn must_should_token_match_matches_musts_only() {
let st = seeded_clause_supertable();
let r = st.reader().expect("reader");
let tm = r
.token_match("title", "+climate policy", BoolMode::Or)
.expect("tm +climate policy");
assert_eq!(tm.len(), 3);
}
#[test]
fn must_should_with_negation_across_superfiles() {
let st = seeded_clause_supertable();
let r = st.reader().expect("reader");
let hits = r
.bm25_hits("title", "+climate policy -summit", 10, BoolMode::Or)
.expect("bm25 with negation");
assert_eq!(hits.len(), 2);
let n = r
.count("title", "+climate policy -summit", BoolMode::Or)
.expect("count with negation");
assert_eq!(n, 2);
}
#[test]
fn absent_must_prunes_every_superfile() {
let st = seeded_clause_supertable();
let r = st.reader().expect("reader");
let hits = r
.bm25_hits("title", "+zzzabsent policy", 10, BoolMode::Or)
.expect("bm25 absent must");
assert!(hits.is_empty());
let n = r
.count("title", "+zzzabsent policy", BoolMode::Or)
.expect("count absent must");
assert_eq!(n, 0);
}
#[test]
fn token_match_no_match_returns_empty() {
let st = seeded_three_doc_supertable();
let r = st.reader().expect("reader");
let hits = r
.token_match("title", "nonexistentterm", BoolMode::Or)
.expect("tm");
assert!(hits.is_empty());
}
#[test]
fn fanout_for_only_multi_term_or_without_negation_subranges() {
assert!(matches!(fanout_for(0, 2, false), FanOut::SubRanges));
assert!(matches!(fanout_for(0, 1, false), FanOut::PerSuperfile));
assert!(matches!(fanout_for(0, 2, true), FanOut::PerSuperfile));
assert!(matches!(fanout_for(2, 0, false), FanOut::PerSuperfile));
assert!(matches!(fanout_for(1, 1, false), FanOut::PerSuperfile));
}
#[test]
fn build_work_units_per_superfile_is_one_unranged_unit_each() {
use std::collections::HashMap;
use uuid::Uuid;
use crate::supertable::manifest::{SuperfileEntry, SuperfileUri};
fn entry(n_docs: u64) -> Arc<SuperfileEntry> {
let id = Uuid::new_v4();
Arc::new(SuperfileEntry {
birth_version: 0,
superfile_id: id,
uri: SuperfileUri(id),
n_docs,
id_min: 0,
id_max: n_docs.saturating_sub(1) as i128,
scalar_stats: HashMap::new(),
fts_summary: HashMap::new(),
vector_summary: HashMap::new(),
partition_key: Vec::new(),
partition_hint: None,
vector_layout: VectorLayout::Ivf,
subsection_offsets: None,
})
}
let e0 = entry(100);
let e1 = entry(200);
let kept = vec![&e0, &e1];
let units = build_work_units(&kept, FanOut::PerSuperfile, 8);
assert_eq!(units.len(), 2);
assert!(units.iter().all(|u| u.range.is_none()));
let units = build_work_units(&kept, FanOut::SubRanges, 1);
assert_eq!(units.len(), 2);
assert!(units.iter().all(|u| u.range.is_none()));
let units = build_work_units(&kept, FanOut::SubRanges, 16);
assert_eq!(units.len(), 2);
assert!(units.iter().all(|u| u.range.is_none()));
}
#[test]
fn build_work_units_slices_large_superfiles_when_threads_spare() {
use std::collections::HashMap;
use uuid::Uuid;
use crate::supertable::manifest::{SuperfileEntry, SuperfileUri};
let id = Uuid::new_v4();
let big = Arc::new(SuperfileEntry {
birth_version: 0,
superfile_id: id,
uri: SuperfileUri(id),
n_docs: 200_000,
id_min: 0,
id_max: 199_999,
scalar_stats: HashMap::new(),
fts_summary: HashMap::new(),
vector_summary: HashMap::new(),
partition_key: Vec::new(),
partition_hint: None,
vector_layout: VectorLayout::Ivf,
subsection_offsets: None,
});
let kept = vec![&big];
let units = build_work_units(&kept, FanOut::SubRanges, 4);
assert!(units.len() > 1, "large superfile sliced into sub-ranges");
let mut cursor = 0u32;
for u in &units {
let (start, end) = u.range.expect("ranged unit");
assert_eq!(start, cursor);
cursor = end;
}
assert_eq!(cursor, 200_000, "sub-ranges tile the whole superfile");
}
#[test]
fn count_single_term_sums_df_across_superfiles() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["alpha beta", "alpha gamma"]))
.expect("append");
w.commit().expect("commit");
w.append(&build_batch(2, &["alpha delta"])).expect("append");
w.commit().expect("commit");
w.append(&build_batch(3, &["beta gamma"])).expect("append");
w.commit().expect("commit");
assert_eq!(st.count("title", "alpha", BoolMode::Or).expect("count"), 3);
assert_eq!(st.count("title", "beta", BoolMode::Or).expect("count"), 2);
assert_eq!(st.count("title", "gamma", BoolMode::Or).expect("count"), 2);
assert_eq!(st.count("title", "absent", BoolMode::Or).expect("count"), 0);
}
#[test]
fn count_multi_term_sums_across_superfiles() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["alpha beta", "alpha gamma"]))
.expect("append");
w.commit().expect("commit");
w.append(&build_batch(2, &["beta gamma", "delta"]))
.expect("append");
w.commit().expect("commit");
w.append(&build_batch(4, &["alpha delta", "beta"]))
.expect("append");
w.commit().expect("commit");
assert_eq!(st.count("title", "alpha beta", BoolMode::Or).expect("c"), 5);
assert_eq!(
st.count("title", "gamma delta", BoolMode::Or).expect("c"),
4
);
assert_eq!(
st.count("title", "alpha beta", BoolMode::And).expect("c"),
1
);
assert_eq!(
st.count("title", "alpha delta", BoolMode::And).expect("c"),
1
);
let r = st.reader().expect("reader");
for (q, mode) in [
("alpha beta", BoolMode::Or),
("gamma delta", BoolMode::Or),
("alpha beta", BoolMode::And),
("alpha delta", BoolMode::And),
] {
let c = r.count("title", q, mode).expect("count");
let n = r.token_match("title", q, mode).expect("token_match").len() as u64;
assert_eq!(c, n, "count vs token_match for {q:?} {mode:?}");
}
}
#[test]
fn count_honors_or_and_modes() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(
0,
&["alpha beta", "alpha gamma", "beta delta"],
))
.expect("append");
w.commit().expect("commit");
assert_eq!(
st.count("title", "alpha delta", BoolMode::Or).expect("c"),
3
);
assert_eq!(
st.count("title", "alpha beta", BoolMode::And).expect("c"),
1
);
assert_eq!(
st.count("title", "gamma delta", BoolMode::And).expect("c"),
0
);
}
#[test]
fn count_agrees_with_token_match_len() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(
0,
&["alpha beta", "alpha gamma", "beta delta"],
))
.expect("append");
w.commit().expect("commit");
let r = st.reader().expect("reader");
for (q, mode) in [
("alpha", BoolMode::Or),
("alpha delta", BoolMode::Or),
("alpha beta", BoolMode::And),
] {
let c = r.count("title", q, mode).expect("count");
let n = r.token_match("title", q, mode).expect("token_match").len() as u64;
assert_eq!(c, n, "count vs token_match for {q:?} {mode:?}");
}
}
#[test]
fn count_empty_query_and_empty_supertable_are_zero() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
assert_eq!(st.count("title", "alpha", BoolMode::Or).expect("c"), 0);
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["alpha beta"])).expect("append");
w.commit().expect("commit");
assert_eq!(st.count("title", "", BoolMode::Or).expect("c"), 0);
assert_eq!(st.count("title", " ", BoolMode::Or).expect("c"), 0);
}
#[test]
fn count_excludes_tombstoned_docs() {
let dir = tempfile::TempDir::new().expect("tempdir");
let storage: Arc<dyn StorageProvider> =
Arc::new(LocalFsStorageProvider::new(dir.path()).expect("provider"));
let st = Supertable::create(options_one_superfile_per_commit().with_storage(storage))
.expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["alpha one", "alpha two", "alpha three"]))
.expect("append");
w.commit().expect("commit");
drop(w);
assert_eq!(st.count("title", "alpha", BoolMode::Or).expect("count"), 3);
let stats = st
.delete(col("title").eq(lit("alpha two")))
.expect("delete");
assert_eq!(stats.matched(), 1);
assert_eq!(
st.count("title", "alpha", BoolMode::Or)
.expect("count after delete"),
2
);
}
#[test]
fn count_excludes_negated_terms() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(0, &["alpha beta", "alpha gamma"]))
.expect("append");
w.commit().expect("commit");
w.append(&build_batch(2, &["alpha delta"])).expect("append");
w.commit().expect("commit");
w.append(&build_batch(3, &["beta gamma"])).expect("append");
w.commit().expect("commit");
assert_eq!(
st.count("title", "alpha -beta", BoolMode::Or)
.expect("count"),
2
);
assert_eq!(st.count("title", "alpha", BoolMode::Or).expect("count"), 3);
assert_eq!(
st.count("title", "alpha -absent", BoolMode::Or)
.expect("count"),
3
);
}
#[test]
fn count_with_negation_agrees_with_token_match() {
let st = Supertable::create(options_one_superfile_per_commit()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(
0,
&["alpha beta", "alpha gamma", "beta delta", "gamma delta"],
))
.expect("append");
w.commit().expect("commit");
let r = st.reader().expect("reader");
for (q, mode) in [
("alpha -beta", BoolMode::Or),
("alpha gamma -delta", BoolMode::Or),
("alpha -gamma", BoolMode::And),
("beta -alpha", BoolMode::Or),
] {
let c = r.count("title", q, mode).expect("count");
let n = r.token_match("title", q, mode).expect("token_match").len() as u64;
assert_eq!(c, n, "count vs token_match for {q:?} {mode:?}");
}
}
#[test]
fn count_excludes_negated_terms_and_tombstones() {
let dir = tempfile::TempDir::new().expect("tempdir");
let storage: Arc<dyn StorageProvider> =
Arc::new(LocalFsStorageProvider::new(dir.path()).expect("provider"));
let st = Supertable::create(options_one_superfile_per_commit().with_storage(storage))
.expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_batch(
0,
&["alpha one", "alpha two", "alpha beta", "alpha three"],
))
.expect("append");
w.commit().expect("commit");
drop(w);
assert_eq!(
st.count("title", "alpha -beta", BoolMode::Or)
.expect("count"),
3
);
let stats = st
.delete(col("title").eq(lit("alpha two")))
.expect("delete");
assert_eq!(stats.matched(), 1);
assert_eq!(
st.count("title", "alpha -beta", BoolMode::Or)
.expect("count after delete"),
2
);
}
}