blob: 0738c57f3a27198157ce8eab8fbf9cf474ba3c80 [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.
use crate::archive_directory::ArchiveDirectory;
use crate::config::{FullTextIndexConfig, FullTextIndexMetadata};
use crate::error::{FtIndexError, Result};
use crate::io::{FullTextReadMetrics, ReadMetrics, ReadRequest, SeekRead, SeekWrite};
use crate::query::{BooleanOccur, MatchOperator, QuerySpec};
use crate::storage::{read_header, write_envelope, ArchiveFileEntry, IndexHeader};
use crate::tokenizer::{TokenizerConfig, TokenizerKind};
use levenshtein_automata::{Distance, LevenshteinAutomatonBuilder, DFA, SINK_STATE};
use roaring::RoaringTreemap;
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tantivy::collector::{FilterCollector, TopDocs};
use tantivy::query::{
BooleanQuery, BoostQuery, ConstScorer, EmptyQuery, EnableScoring, Explanation, Occur, Query,
QueryParser, Scorer, TermQuery, Weight,
};
use tantivy::schema::{IndexRecordOption, NumericOptions, Schema, TextFieldIndexing, TextOptions};
use tantivy::tokenizer::{
AsciiFoldingFilter, Language, LowerCaser, NgramTokenizer, RawTokenizer, RemoveLongFilter,
SimpleTokenizer, Stemmer, StopWordFilter, TextAnalyzer, TokenStream, WhitespaceTokenizer,
};
use tantivy::{
DocId, DocSet, Index, Score, SegmentReader, SingleSegmentIndexWriter, TantivyDocument, Term,
TERMINATED,
};
use tantivy_fst::Automaton;
use tantivy_jieba::JiebaTokenizer;
use tempfile::TempDir;
#[derive(Clone, Debug, PartialEq)]
pub struct FullTextSearchResult {
pub row_ids: Vec<i64>,
pub scores: Vec<f32>,
}
pub struct FullTextIndexWriter {
config: FullTextIndexConfig,
documents: Vec<FullTextDocument>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FullTextDocument {
pub row_id: i64,
pub fields: Vec<(String, String)>,
}
impl FullTextIndexWriter {
pub fn new(config: FullTextIndexConfig) -> Result<Self> {
config.validate()?;
Ok(Self {
config,
documents: Vec::new(),
})
}
pub fn add_document(&mut self, row_id: i64, text: impl Into<String>) -> Result<()> {
let text_field = self.config.default_text_field().to_string();
self.add_document_fields(row_id, [(text_field, text.into())])
}
pub fn add_document_fields<I, K, V>(&mut self, row_id: i64, fields: I) -> Result<()>
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
if row_id < 0 {
return Err(FtIndexError::InvalidStorage(format!(
"row id must be non-negative, got {row_id}"
)));
}
let fields = fields
.into_iter()
.map(|(name, text)| (name.into(), text.into()))
.collect::<Vec<_>>();
if fields.is_empty() {
return Err(FtIndexError::InvalidStorage(
"document must contain at least one text field".to_string(),
));
}
for (name, _) in &fields {
validate_indexed_field(&self.config, name)?;
}
self.documents.push(FullTextDocument { row_id, fields });
Ok(())
}
pub fn write<W: SeekWrite>(&mut self, output: &mut W) -> Result<()> {
let temp_dir = TempDir::new()?;
let schema = build_schema(&self.config);
let mut index = Index::create_in_dir(temp_dir.path(), schema.clone())?;
register_tokenizer(&mut index, &self.config.tokenizer)?;
let row_id_field = schema
.get_field(&self.config.row_id_field)
.map_err(|_| FtIndexError::InvalidStorage("missing row_id field".to_string()))?;
let text_fields = text_field_map(&schema, &self.config)?;
{
let mut index_writer =
SingleSegmentIndexWriter::<TantivyDocument>::new(index, 50_000_000)?;
for document in &self.documents {
let mut doc = TantivyDocument::new();
doc.add_u64(row_id_field, document.row_id as u64);
for (name, text) in &document.fields {
let text_field = text_fields.get(name).ok_or_else(|| {
FtIndexError::InvalidStorage(format!(
"document field '{name}' is not configured for this index"
))
})?;
doc.add_text(*text_field, text);
}
index_writer.add_document(doc)?;
}
index_writer.finalize()?;
}
let files = collect_index_files(temp_dir.path())?;
let mut offset = 0u64;
let mut entries = Vec::with_capacity(files.len());
for (name, data) in &files {
entries.push(ArchiveFileEntry {
name: name.clone(),
offset,
length: data.len() as u64,
});
offset += data.len() as u64;
}
let header = IndexHeader {
metadata: FullTextIndexMetadata {
config: self.config.clone(),
document_count: self.documents.len() as u64,
tantivy_version: tantivy::version().to_string(),
},
files: entries,
};
write_envelope(output, &header, &files)
}
}
pub struct FullTextIndexReader<R> {
_input: Arc<MeteredSeekRead<R>>,
index: Index,
reader: Mutex<Option<tantivy::IndexReader>>,
read_metrics: Arc<ReadMetrics>,
metadata: FullTextIndexMetadata,
}
impl<R: SeekRead + 'static> FullTextIndexReader<R> {
pub fn open(input: R) -> Result<Self> {
let read_metrics = Arc::new(ReadMetrics::default());
let input = MeteredSeekRead {
inner: input,
metrics: Arc::clone(&read_metrics),
};
let (header, body_start) = read_header(&input)?;
validate_tantivy_version(&header.metadata)?;
let input = Arc::new(input);
let directory = ArchiveDirectory::new_with_metrics(
Arc::clone(&input),
body_start,
&header.files,
Arc::clone(&read_metrics),
)?;
let mut index = Index::open(directory)?;
register_tokenizer(&mut index, &header.metadata.config.tokenizer)?;
Ok(Self {
_input: input,
index,
reader: Mutex::new(None),
read_metrics,
metadata: header.metadata,
})
}
pub fn metadata(&self) -> &FullTextIndexMetadata {
&self.metadata
}
pub fn read_metrics(&self) -> FullTextReadMetrics {
self.read_metrics.snapshot()
}
pub fn prewarm(&self) -> Result<()> {
let _ = self.searcher()?;
Ok(())
}
pub fn search<Q: AsRef<str>>(&self, query: Q, limit: usize) -> Result<FullTextSearchResult> {
self.search_with_filter(query, limit, None)
}
pub fn search_with_roaring_filter<Q: AsRef<str>>(
&self,
query: Q,
limit: usize,
roaring_filter_bytes: &[u8],
) -> Result<FullTextSearchResult> {
let filter = decode_roaring_filter(roaring_filter_bytes)?;
self.search_with_filter(query, limit, Some(filter))
}
fn search_with_filter<Q: AsRef<str>>(
&self,
query: Q,
limit: usize,
filter: Option<RoaringTreemap>,
) -> Result<FullTextSearchResult> {
if limit == 0 {
return Err(FtIndexError::InvalidQuery(
"search limit must be positive".to_string(),
));
}
let searcher = self.searcher()?;
let query = QuerySpec::from_json(query.as_ref())?;
let tantivy_query = build_query(&self.index, &self.metadata.config, &query)?;
let top_docs = if let Some(filter) = filter {
let row_id_field = self.metadata.config.row_id_field.clone();
let collector = FilterCollector::new(
row_id_field,
move |row_id: u64| filter.contains(row_id),
TopDocs::with_limit(limit).order_by_score(),
);
searcher.search(&tantivy_query, &collector)?
} else {
searcher.search(&tantivy_query, &TopDocs::with_limit(limit).order_by_score())?
};
let mut row_ids = Vec::with_capacity(top_docs.len());
let mut scores = Vec::with_capacity(top_docs.len());
for (score, doc_address) in top_docs {
let segment_reader = searcher.segment_reader(doc_address.segment_ord);
let row_id_column = segment_reader
.fast_fields()
.u64(&self.metadata.config.row_id_field)?
.first_or_default_col(0);
row_ids.push(row_id_column.get_val(doc_address.doc_id) as i64);
scores.push(score);
}
Ok(FullTextSearchResult { row_ids, scores })
}
fn searcher(&self) -> Result<tantivy::Searcher> {
let mut reader = self.reader.lock().map_err(|_| {
FtIndexError::InvalidStorage("Tantivy reader lock poisoned".to_string())
})?;
if reader.is_none() {
*reader = Some(self.index.reader()?);
}
Ok(reader.as_ref().expect("reader initialized").searcher())
}
}
struct MeteredSeekRead<R> {
inner: R,
metrics: Arc<ReadMetrics>,
}
impl<R: SeekRead> SeekRead for MeteredSeekRead<R> {
fn pread(&self, ranges: &mut [ReadRequest<'_>]) -> std::io::Result<()> {
self.metrics.record_pread(ranges);
self.inner.pread(ranges)
}
}
fn validate_tantivy_version(metadata: &FullTextIndexMetadata) -> Result<()> {
let runtime_version = tantivy::version().to_string();
if metadata.tantivy_version != runtime_version {
return Err(FtIndexError::InvalidStorage(format!(
"unsupported Tantivy index version {}, runtime uses {}",
metadata.tantivy_version, runtime_version
)));
}
Ok(())
}
fn decode_roaring_filter(bytes: &[u8]) -> Result<RoaringTreemap> {
RoaringTreemap::deserialize_from(bytes)
.map_err(|e| FtIndexError::InvalidQuery(format!("invalid RoaringTreemap filter: {e}")))
}
fn build_schema(config: &FullTextIndexConfig) -> Schema {
let mut builder = Schema::builder();
builder.add_u64_field(
&config.row_id_field,
NumericOptions::default()
.set_fast()
.set_stored()
.set_indexed(),
);
let index_option = if config.tokenizer.with_position {
IndexRecordOption::WithFreqsAndPositions
} else {
IndexRecordOption::WithFreqs
};
let tokenizer_name = tokenizer_name(&config.tokenizer);
let indexing = TextFieldIndexing::default()
.set_tokenizer(tokenizer_name)
.set_index_option(index_option);
for field in config.indexed_text_fields() {
builder.add_text_field(
field,
TextOptions::default().set_indexing_options(indexing.clone()),
);
}
builder.build()
}
fn tokenizer_name(config: &TokenizerConfig) -> &'static str {
match config.tokenizer {
TokenizerKind::Default | TokenizerKind::Simple => "paimon_custom",
TokenizerKind::Whitespace => "paimon_custom",
TokenizerKind::Raw => "paimon_custom",
TokenizerKind::Ngram => "paimon_ngram",
TokenizerKind::Jieba => "paimon_jieba",
}
}
fn register_tokenizer(index: &mut Index, config: &TokenizerConfig) -> Result<()> {
let analyzer = build_text_analyzer(config)?;
index
.tokenizers()
.register(tokenizer_name(config), analyzer);
Ok(())
}
fn build_text_analyzer(config: &TokenizerConfig) -> Result<TextAnalyzer> {
let mut builder = match config.tokenizer {
TokenizerKind::Default | TokenizerKind::Simple => {
TextAnalyzer::builder(SimpleTokenizer::default()).dynamic()
}
TokenizerKind::Whitespace => {
TextAnalyzer::builder(WhitespaceTokenizer::default()).dynamic()
}
TokenizerKind::Raw => TextAnalyzer::builder(RawTokenizer::default()).dynamic(),
TokenizerKind::Ngram => {
let tokenizer = NgramTokenizer::new(
config.ngram_min_gram,
config.ngram_max_gram,
config.ngram_prefix_only,
)
.map_err(|e| FtIndexError::InvalidOption {
key: "ngram".to_string(),
message: e.to_string(),
})?;
TextAnalyzer::builder(tokenizer).dynamic()
}
TokenizerKind::Jieba => {
let mut tokenizer = JiebaTokenizer::with_search_mode(config.jieba_search_mode);
tokenizer.set_ordinal_position_mode(config.jieba_ordinal_position);
TextAnalyzer::builder(tokenizer).dynamic()
}
};
builder = builder.filter_dynamic(RemoveLongFilter::limit(config.max_token_length));
if config.lower_case {
builder = builder.filter_dynamic(LowerCaser);
}
if config.stem {
builder = builder.filter_dynamic(Stemmer::new(parse_language(&config.language)?));
}
if config.remove_stop_words {
let language = parse_language(&config.language)?;
if let Some(filter) = StopWordFilter::new(language) {
builder = builder.filter_dynamic(filter);
} else if config.stop_words.is_empty() {
return Err(FtIndexError::InvalidOption {
key: "language".to_string(),
message: format!(
"removing stop words for language '{}' is not supported",
config.language
),
});
}
if !config.stop_words.is_empty() {
builder = builder.filter_dynamic(StopWordFilter::remove(config.stop_words.clone()));
}
}
if config.ascii_folding {
builder = builder.filter_dynamic(AsciiFoldingFilter);
}
Ok(builder.build())
}
fn parse_language(language: &str) -> Result<Language> {
match language.trim().to_lowercase().as_str() {
"arabic" => Ok(Language::Arabic),
"danish" => Ok(Language::Danish),
"dutch" => Ok(Language::Dutch),
"english" | "en" => Ok(Language::English),
"finnish" => Ok(Language::Finnish),
"french" | "fr" => Ok(Language::French),
"german" | "de" => Ok(Language::German),
"greek" => Ok(Language::Greek),
"hungarian" => Ok(Language::Hungarian),
"italian" | "it" => Ok(Language::Italian),
"norwegian" => Ok(Language::Norwegian),
"portuguese" | "pt" => Ok(Language::Portuguese),
"romanian" => Ok(Language::Romanian),
"russian" | "ru" => Ok(Language::Russian),
"spanish" | "es" => Ok(Language::Spanish),
"swedish" => Ok(Language::Swedish),
"tamil" => Ok(Language::Tamil),
"turkish" => Ok(Language::Turkish),
other => Err(FtIndexError::InvalidOption {
key: "language".to_string(),
message: format!("unsupported tokenizer language '{other}'"),
}),
}
}
fn text_field_map(
schema: &Schema,
config: &FullTextIndexConfig,
) -> Result<HashMap<String, tantivy::schema::Field>> {
let mut fields = HashMap::new();
for name in config.indexed_text_fields() {
let field = schema
.get_field(name)
.map_err(|_| FtIndexError::InvalidStorage(format!("missing text field '{name}'")))?;
fields.insert(name.to_string(), field);
}
Ok(fields)
}
fn validate_indexed_field(config: &FullTextIndexConfig, field: &str) -> Result<()> {
if field.trim().is_empty() {
return Err(FtIndexError::InvalidStorage(
"document field name must not be empty".to_string(),
));
}
if config.indexed_text_fields().contains(&field) {
Ok(())
} else {
Err(FtIndexError::InvalidStorage(format!(
"document field '{field}' is not configured for this index"
)))
}
}
fn collect_index_files(path: &Path) -> Result<Vec<(String, Vec<u8>)>> {
let mut paths = Vec::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
if entry.file_type()?.is_file() {
paths.push(entry.path());
}
}
paths.sort();
let mut files = Vec::with_capacity(paths.len());
for path in paths {
let name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| FtIndexError::InvalidStorage("non-utf8 file name".to_string()))?
.to_string();
if name.ends_with(".lock") {
continue;
}
files.push((name, fs::read(path)?));
}
Ok(files)
}
fn build_query(
index: &Index,
config: &FullTextIndexConfig,
query: &QuerySpec,
) -> Result<Box<dyn Query>> {
match query {
QuerySpec::Match {
column,
terms,
operator,
boost,
fuzziness,
max_expansions,
prefix_length,
} => {
validate_match_options(*fuzziness, *max_expansions, *prefix_length)?;
let fields = resolve_match_fields(index, config, column.as_deref())?;
let mut children = Vec::with_capacity(fields.len());
let options = FieldMatchOptions {
operator: *operator,
boost: *boost,
fuzziness: *fuzziness,
max_expansions: *max_expansions,
prefix_length: *prefix_length,
};
for field in fields {
children.push((
Occur::Should,
build_field_match_query(index, config, field, terms, options)?,
));
}
if children.len() == 1 {
Ok(children.remove(0).1)
} else {
Ok(Box::new(BooleanQuery::new(children)))
}
}
QuerySpec::MultiMatch {
terms,
columns,
boosts,
operator,
fuzziness,
max_expansions,
prefix_length,
} => {
validate_multi_match_options(
columns,
boosts,
*fuzziness,
*max_expansions,
*prefix_length,
)?;
let mut children = Vec::with_capacity(columns.len());
for (idx, column) in columns.iter().enumerate() {
let boost = boosts.get(idx).copied().unwrap_or(1.0);
let field = resolve_text_field(index, config, Some(column))?;
let options = FieldMatchOptions {
operator: *operator,
boost,
fuzziness: *fuzziness,
max_expansions: *max_expansions,
prefix_length: *prefix_length,
};
children.push((
Occur::Should,
build_field_match_query(index, config, field, terms, options)?,
));
}
Ok(Box::new(BooleanQuery::new(children)))
}
QuerySpec::MatchPhrase {
column,
terms,
slop,
} => {
if !config.tokenizer.with_position {
return Err(FtIndexError::InvalidQuery(
"phrase query requires positions".to_string(),
));
}
let text_field = resolve_text_field(index, config, column.as_deref())?;
let parser = QueryParser::for_index(index, vec![text_field]);
let escaped = terms.replace('\\', "\\\\").replace('"', "\\\"");
let query_text = if *slop == 0 {
format!("\"{escaped}\"")
} else {
format!("\"{escaped}\"~{slop}")
};
parser
.parse_query(&query_text)
.map_err(|e| FtIndexError::InvalidQuery(e.to_string()))
}
QuerySpec::Boolean {
should,
must,
must_not,
queries,
} => {
if should.is_empty() && must.is_empty() && must_not.is_empty() && queries.is_empty() {
return Err(FtIndexError::InvalidQuery(
"boolean query must contain at least one clause".to_string(),
));
}
let has_positive_clause = !should.is_empty()
|| !must.is_empty()
|| queries
.iter()
.any(|(occur, _)| matches!(occur, BooleanOccur::Should | BooleanOccur::Must));
if !has_positive_clause {
return Err(FtIndexError::InvalidQuery(
"boolean query must contain at least one should or must clause".to_string(),
));
}
let mut children =
Vec::with_capacity(should.len() + must.len() + must_not.len() + queries.len());
for child in should {
children.push((Occur::Should, build_query(index, config, child)?));
}
for child in must {
children.push((Occur::Must, build_query(index, config, child)?));
}
for child in must_not {
children.push((Occur::MustNot, build_query(index, config, child)?));
}
for (occur, child) in queries {
let occur = match occur {
BooleanOccur::Should => Occur::Should,
BooleanOccur::Must => Occur::Must,
BooleanOccur::MustNot => Occur::MustNot,
};
children.push((occur, build_query(index, config, child)?));
}
Ok(Box::new(BooleanQuery::new(children)))
}
QuerySpec::Boost {
positive,
negative,
negative_boost,
} => {
validate_negative_boost(*negative_boost)?;
Ok(Box::new(DemoteQuery::new(
build_query(index, config, positive)?,
build_query(index, config, negative)?,
*negative_boost,
)))
}
}
}
fn validate_match_options(
fuzziness: Option<u8>,
max_expansions: usize,
prefix_length: u32,
) -> Result<()> {
if fuzziness.unwrap_or(0) > 2 {
return Err(FtIndexError::InvalidQuery(
"match query fuzziness must be auto/null or a value in [0, 2]".to_string(),
));
}
if max_expansions == 0 {
return Err(FtIndexError::InvalidQuery(
"match query max_expansions must be positive".to_string(),
));
}
let _ = prefix_length;
Ok(())
}
fn validate_multi_match_options(
columns: &[String],
boosts: &[f32],
fuzziness: Option<u8>,
max_expansions: usize,
prefix_length: u32,
) -> Result<()> {
if columns.is_empty() {
return Err(FtIndexError::InvalidQuery(
"multi_match query must contain at least one column".to_string(),
));
}
if !boosts.is_empty() && boosts.len() != columns.len() {
return Err(FtIndexError::InvalidQuery(format!(
"multi_match boosts length {} does not match columns length {}",
boosts.len(),
columns.len()
)));
}
for boost in boosts {
validate_boost(*boost)?;
}
validate_match_options(fuzziness, max_expansions, prefix_length)
}
fn validate_boost(boost: f32) -> Result<()> {
if !boost.is_finite() || boost <= 0.0 {
return Err(FtIndexError::InvalidQuery(format!(
"boost must be a finite positive value, got {boost}"
)));
}
Ok(())
}
fn resolve_text_field(
index: &Index,
config: &FullTextIndexConfig,
column: Option<&str>,
) -> Result<tantivy::schema::Field> {
let column = match column.map(str::trim).filter(|column| !column.is_empty()) {
Some(column) => column,
None => {
let fields = config.indexed_text_fields();
if fields.len() == 1 {
fields[0]
} else {
return Err(FtIndexError::InvalidQuery(
"full-text query column must be set for multi-field indexes".to_string(),
));
}
}
};
if !config.indexed_text_fields().contains(&column) {
return Err(FtIndexError::InvalidQuery(format!(
"full-text query column '{column}' is not configured for this index"
)));
}
index
.schema()
.get_field(column)
.map_err(|_| FtIndexError::InvalidQuery(format!("missing text field '{column}'")))
}
fn resolve_match_fields(
index: &Index,
config: &FullTextIndexConfig,
column: Option<&str>,
) -> Result<Vec<tantivy::schema::Field>> {
if column
.map(str::trim)
.filter(|column| !column.is_empty())
.is_some()
{
return Ok(vec![resolve_text_field(index, config, column)?]);
}
config
.indexed_text_fields()
.into_iter()
.map(|field| resolve_text_field(index, config, Some(field)))
.collect()
}
#[derive(Clone, Copy)]
struct FieldMatchOptions {
operator: MatchOperator,
boost: f32,
fuzziness: Option<u8>,
max_expansions: usize,
prefix_length: u32,
}
fn build_field_match_query(
index: &Index,
config: &FullTextIndexConfig,
field: tantivy::schema::Field,
terms: &str,
options: FieldMatchOptions,
) -> Result<Box<dyn Query>> {
validate_boost(options.boost)?;
let tokens = analyze_terms(index, config, terms)?;
let mut query = if tokens.is_empty() {
Box::new(EmptyQuery) as Box<dyn Query>
} else if tokens.len() == 1 {
build_token_query(
field,
&tokens[0],
options.fuzziness,
options.max_expansions,
options.prefix_length,
)?
} else {
let occur = match options.operator {
MatchOperator::Or => Occur::Should,
MatchOperator::And => Occur::Must,
};
let mut children = Vec::with_capacity(tokens.len());
for token in tokens {
children.push((
occur,
build_token_query(
field,
&token,
options.fuzziness,
options.max_expansions,
options.prefix_length,
)?,
));
}
Box::new(BooleanQuery::new(children)) as Box<dyn Query>
};
if (options.boost - 1.0).abs() > f32::EPSILON {
query = Box::new(BoostQuery::new(query, options.boost));
}
Ok(query)
}
fn analyze_terms(index: &Index, config: &FullTextIndexConfig, terms: &str) -> Result<Vec<String>> {
let tokenizer_name = tokenizer_name(&config.tokenizer);
let mut analyzer = index.tokenizers().get(tokenizer_name).ok_or_else(|| {
FtIndexError::InvalidQuery(format!("tokenizer '{tokenizer_name}' is not registered"))
})?;
let mut tokens = Vec::new();
let mut token_stream = analyzer.token_stream(terms);
token_stream.process(&mut |token| tokens.push(token.text.clone()));
Ok(tokens)
}
fn build_token_query(
field: tantivy::schema::Field,
token: &str,
fuzziness: Option<u8>,
max_expansions: usize,
prefix_length: u32,
) -> Result<Box<dyn Query>> {
let fuzziness = fuzziness.unwrap_or_else(|| auto_fuzziness(token));
if fuzziness == 0 {
return Ok(Box::new(TermQuery::new(
Term::from_field_text(field, token),
IndexRecordOption::WithFreqs,
)));
}
if fuzziness > 2 {
return Err(FtIndexError::InvalidQuery(
"match query fuzziness must be auto/null or a value in [0, 2]".to_string(),
));
}
let term = Term::from_field_text(field, token);
let prefix = token
.chars()
.take(prefix_length as usize)
.collect::<String>();
Ok(Box::new(CappedFuzzyTermQuery::new(
term,
fuzziness,
prefix,
max_expansions,
)))
}
fn auto_fuzziness(token: &str) -> u8 {
match token.chars().count() {
0..=2 => 0,
3..=5 => 1,
_ => 2,
}
}
fn validate_negative_boost(negative_boost: f32) -> Result<()> {
if !negative_boost.is_finite() || !(0.0..=1.0).contains(&negative_boost) {
return Err(FtIndexError::InvalidQuery(format!(
"negative_boost must be a finite value in [0.0, 1.0], got {negative_boost}"
)));
}
Ok(())
}
struct DemoteQuery {
positive: Box<dyn Query>,
negative: Box<dyn Query>,
negative_boost: Score,
}
impl DemoteQuery {
fn new(positive: Box<dyn Query>, negative: Box<dyn Query>, negative_boost: Score) -> Self {
Self {
positive,
negative,
negative_boost,
}
}
}
impl Clone for DemoteQuery {
fn clone(&self) -> Self {
Self {
positive: self.positive.box_clone(),
negative: self.negative.box_clone(),
negative_boost: self.negative_boost,
}
}
}
impl fmt::Debug for DemoteQuery {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Demote(positive={:?}, negative={:?}, negative_boost={})",
self.positive, self.negative, self.negative_boost
)
}
}
impl Query for DemoteQuery {
fn weight(&self, enable_scoring: EnableScoring<'_>) -> tantivy::Result<Box<dyn Weight>> {
let positive_weight = self.positive.weight(enable_scoring)?;
if !enable_scoring.is_scoring_enabled() {
return Ok(positive_weight);
}
let negative_weight = self.negative.weight(EnableScoring::Disabled {
schema: enable_scoring.schema(),
searcher_opt: enable_scoring.searcher(),
})?;
Ok(Box::new(DemoteWeight {
positive: positive_weight,
negative: negative_weight,
negative_boost: self.negative_boost,
}))
}
fn query_terms<'a>(&'a self, visitor: &mut dyn FnMut(&'a tantivy::Term, bool)) {
self.positive.query_terms(visitor);
self.negative.query_terms(visitor);
}
}
struct DemoteWeight {
positive: Box<dyn Weight>,
negative: Box<dyn Weight>,
negative_boost: Score,
}
impl Weight for DemoteWeight {
fn scorer(&self, reader: &SegmentReader, boost: Score) -> tantivy::Result<Box<dyn Scorer>> {
Ok(Box::new(DemoteScorer {
positive: self.positive.scorer(reader, boost)?,
negative: self.negative.scorer(reader, 1.0)?,
negative_boost: self.negative_boost,
}))
}
fn explain(&self, reader: &SegmentReader, doc: DocId) -> tantivy::Result<Explanation> {
let positive_explanation = self.positive.explain(reader, doc)?;
let mut negative_scorer = self.negative.scorer(reader, 1.0)?;
let matched_negative = matches_doc(negative_scorer.as_mut(), doc);
let factor = if matched_negative {
self.negative_boost
} else {
1.0
};
let score = positive_explanation.value() * factor;
let mut explanation =
Explanation::new_with_string(format!("Demote by negative query x{factor}"), score);
explanation.add_detail(positive_explanation);
if matched_negative {
explanation.add_const("negative_boost", self.negative_boost);
}
Ok(explanation)
}
fn count(&self, reader: &SegmentReader) -> tantivy::Result<u32> {
self.positive.count(reader)
}
}
struct DemoteScorer {
positive: Box<dyn Scorer>,
negative: Box<dyn Scorer>,
negative_boost: Score,
}
impl DocSet for DemoteScorer {
fn advance(&mut self) -> DocId {
self.positive.advance()
}
fn seek(&mut self, target: DocId) -> DocId {
self.positive.seek(target)
}
fn doc(&self) -> DocId {
self.positive.doc()
}
fn size_hint(&self) -> u32 {
self.positive.size_hint()
}
}
impl Scorer for DemoteScorer {
fn score(&mut self) -> Score {
let positive_score = self.positive.score();
if matches_doc(self.negative.as_mut(), self.positive.doc()) {
positive_score * self.negative_boost
} else {
positive_score
}
}
}
fn matches_doc(docset: &mut dyn DocSet, doc: DocId) -> bool {
if doc == TERMINATED {
return false;
}
let current = docset.doc();
if current < doc {
docset.seek(doc) == doc
} else {
current == doc
}
}
#[derive(Clone)]
struct CappedFuzzyTermQuery {
term: Term,
distance: u8,
prefix: String,
max_expansions: usize,
}
impl CappedFuzzyTermQuery {
fn new(term: Term, distance: u8, prefix: String, max_expansions: usize) -> Self {
Self {
term,
distance,
prefix,
max_expansions,
}
}
}
impl fmt::Debug for CappedFuzzyTermQuery {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"CappedFuzzyTermQuery(term={:?}, distance={}, prefix={:?}, max_expansions={})",
self.term, self.distance, self.prefix, self.max_expansions
)
}
}
impl Query for CappedFuzzyTermQuery {
fn weight(&self, _enable_scoring: EnableScoring<'_>) -> tantivy::Result<Box<dyn Weight>> {
let term_value = self.term.value();
let term_text = term_value.as_str().ok_or_else(|| {
tantivy::TantivyError::InvalidArgument("fuzzy query requires a string term".to_string())
})?;
let builder = LevenshteinAutomatonBuilder::new(self.distance, true);
let automaton = PrefixedDfaAutomaton::new(
builder.build_dfa(term_text),
self.prefix.as_bytes().to_vec(),
);
Ok(Box::new(CappedAutomatonWeight {
field: self.term.field(),
automaton,
max_expansions: self.max_expansions,
}))
}
fn query_terms<'a>(&'a self, visitor: &mut dyn FnMut(&'a Term, bool)) {
visitor(&self.term, false);
}
}
struct CappedAutomatonWeight<A> {
field: tantivy::schema::Field,
automaton: A,
max_expansions: usize,
}
impl<A> Weight for CappedAutomatonWeight<A>
where
A: Automaton + Send + Sync + 'static,
A::State: Clone,
{
fn scorer(&self, reader: &SegmentReader, boost: Score) -> tantivy::Result<Box<dyn Scorer>> {
let inverted_index = reader.inverted_index(self.field)?;
let term_dict = inverted_index.terms();
let mut term_stream = term_dict.search(&self.automaton).into_stream()?;
let mut docs = Vec::new();
let mut expansions = 0usize;
while expansions < self.max_expansions && term_stream.advance() {
expansions += 1;
let term_info = term_stream.value();
let mut block_segment_postings = inverted_index
.read_block_postings_from_terminfo(term_info, IndexRecordOption::Basic)?;
loop {
let block_docs = block_segment_postings.docs();
if block_docs.is_empty() {
break;
}
docs.extend_from_slice(block_docs);
block_segment_postings.advance();
}
}
docs.sort_unstable();
docs.dedup();
Ok(Box::new(ConstScorer::new(SortedDocSet::new(docs), boost)))
}
fn explain(&self, reader: &SegmentReader, doc: DocId) -> tantivy::Result<Explanation> {
let mut scorer = self.scorer(reader, 1.0)?;
if scorer.seek(doc) == doc {
Ok(Explanation::new("CappedAutomatonScorer", 1.0))
} else {
Err(tantivy::TantivyError::InvalidArgument(
"Document does not match fuzzy query".to_string(),
))
}
}
}
struct SortedDocSet {
docs: Vec<DocId>,
cursor: usize,
doc: DocId,
}
impl SortedDocSet {
fn new(docs: Vec<DocId>) -> Self {
let doc = docs.first().copied().unwrap_or(TERMINATED);
Self {
docs,
cursor: 0,
doc,
}
}
}
impl DocSet for SortedDocSet {
fn advance(&mut self) -> DocId {
if self.doc == TERMINATED {
return TERMINATED;
}
self.cursor += 1;
self.doc = self.docs.get(self.cursor).copied().unwrap_or(TERMINATED);
self.doc
}
fn seek(&mut self, target: DocId) -> DocId {
if self.doc >= target {
return self.doc;
}
let relative_cursor = self.docs[self.cursor..].partition_point(|doc| *doc < target);
self.cursor += relative_cursor;
self.doc = self.docs.get(self.cursor).copied().unwrap_or(TERMINATED);
self.doc
}
fn doc(&self) -> DocId {
self.doc
}
fn size_hint(&self) -> u32 {
if self.doc == TERMINATED {
0
} else {
(self.docs.len() - self.cursor) as u32
}
}
}
struct PrefixedDfaAutomaton {
dfa: DFA,
prefix: Vec<u8>,
}
impl PrefixedDfaAutomaton {
fn new(dfa: DFA, prefix: Vec<u8>) -> Self {
Self { dfa, prefix }
}
}
impl Automaton for PrefixedDfaAutomaton {
type State = (u32, Option<usize>);
fn start(&self) -> Self::State {
(self.dfa.initial_state(), Some(0))
}
fn is_match(&self, state: &Self::State) -> bool {
matches!(self.dfa.distance(state.0), Distance::Exact(_))
&& matches!(state.1, Some(pos) if pos >= self.prefix.len())
}
fn can_match(&self, state: &Self::State) -> bool {
state.0 != SINK_STATE && state.1.is_some()
}
fn accept(&self, state: &Self::State, byte: u8) -> Self::State {
let dfa_state = self.dfa.transition(state.0, byte);
let prefix_state = match state.1 {
None => None,
Some(pos) if pos >= self.prefix.len() => Some(pos),
Some(pos) if self.prefix[pos] == byte => Some(pos + 1),
Some(_) => None,
};
(dfa_state, prefix_state)
}
}