tantivy/query/phrase_query/regex_phrase_query.rs
1use super::regex_phrase_weight::RegexPhraseWeight;
2use crate::query::bm25::Bm25Weight;
3use crate::query::{EnableScoring, Query, Weight};
4use crate::schema::{Field, IndexRecordOption, Term, Type};
5
6/// `RegexPhraseQuery` matches a specific sequence of regex queries.
7///
8/// For instance, the phrase query for `"pa.* time"` will match
9/// the sentence:
10///
11/// **Alan just got a part time job.**
12///
13/// On the other hand it will not match the sentence.
14///
15/// **This is my favorite part of the job.**
16///
17/// [Slop](RegexPhraseQuery::set_slop) allows leniency in term proximity
18/// for some performance trade-off.
19///
20/// Using a `RegexPhraseQuery` on a field requires positions
21/// to be indexed for this field.
22#[derive(Clone, Debug)]
23pub struct RegexPhraseQuery {
24 field: Field,
25 phrase_terms: Vec<(usize, String)>,
26 slop: u32,
27 max_expansions: u32,
28}
29
30/// Transform a wildcard query to a regex string.
31///
32/// `AB*CD` for example is converted to `AB.*CD`
33///
34/// All other chars are regex escaped.
35pub fn wildcard_query_to_regex_str(term: &str) -> String {
36 regex::escape(term).replace(r"\*", ".*")
37}
38
39impl RegexPhraseQuery {
40 /// Creates a new `RegexPhraseQuery` given a list of terms.
41 ///
42 /// There must be at least two terms, and all terms
43 /// must belong to the same field.
44 ///
45 /// Offset for each term will be same as index in the Vector
46 pub fn new(field: Field, terms: Vec<String>) -> RegexPhraseQuery {
47 let terms_with_offset = terms.into_iter().enumerate().collect();
48 RegexPhraseQuery::new_with_offset(field, terms_with_offset)
49 }
50
51 /// Creates a new `RegexPhraseQuery` given a list of terms and their offsets.
52 ///
53 /// Can be used to provide custom offset for each term.
54 pub fn new_with_offset(field: Field, terms: Vec<(usize, String)>) -> RegexPhraseQuery {
55 RegexPhraseQuery::new_with_offset_and_slop(field, terms, 0)
56 }
57
58 /// Creates a new `RegexPhraseQuery` given a list of terms, their offsets and a slop
59 pub fn new_with_offset_and_slop(
60 field: Field,
61 mut terms: Vec<(usize, String)>,
62 slop: u32,
63 ) -> RegexPhraseQuery {
64 assert!(
65 terms.len() > 1,
66 "A phrase query is required to have strictly more than one term."
67 );
68 terms.sort_by_key(|&(offset, _)| offset);
69 RegexPhraseQuery {
70 field,
71 phrase_terms: terms,
72 slop,
73 max_expansions: 1 << 14,
74 }
75 }
76
77 /// Slop allowed for the phrase.
78 ///
79 /// The query will match if its terms are separated by `slop` terms at most.
80 /// The slop can be considered a budget between all terms.
81 /// E.g. "A B C" with slop 1 allows "A X B C", "A B X C", but not "A X B X C".
82 ///
83 /// Transposition costs 2, e.g. "A B" with slop 1 will not match "B A" but it would with slop 2
84 /// Transposition is not a special case, in the example above A is moved 1 position and B is
85 /// moved 1 position, so the slop is 2.
86 ///
87 /// As a result slop works in both directions, so the order of the terms may changed as long as
88 /// they respect the slop.
89 ///
90 /// By default the slop is 0 meaning query terms need to be adjacent.
91 pub fn set_slop(&mut self, value: u32) {
92 self.slop = value;
93 }
94
95 /// Sets the max expansions a regex term can match. The limit will be over all terms.
96 /// After the limit is hit an error will be returned.
97 pub fn set_max_expansions(&mut self, value: u32) {
98 self.max_expansions = value;
99 }
100
101 /// The [`Field`] this `RegexPhraseQuery` is targeting.
102 pub fn field(&self) -> Field {
103 self.field
104 }
105
106 /// `Term`s in the phrase without the associated offsets.
107 pub fn phrase_terms(&self) -> Vec<Term> {
108 self.phrase_terms
109 .iter()
110 .map(|(_, term)| Term::from_field_text(self.field, term))
111 .collect::<Vec<Term>>()
112 }
113
114 /// Returns the [`RegexPhraseWeight`] for the given phrase query given a specific `searcher`.
115 ///
116 /// This function is the same as [`Query::weight()`] except it returns
117 /// a specialized type [`RegexPhraseWeight`] instead of a Boxed trait.
118 pub(crate) fn regex_phrase_weight(
119 &self,
120 enable_scoring: EnableScoring<'_>,
121 ) -> crate::Result<RegexPhraseWeight> {
122 let schema = enable_scoring.schema();
123 let field_type = schema.get_field_entry(self.field).field_type().value_type();
124 if field_type != Type::Str {
125 return Err(crate::TantivyError::SchemaError(format!(
126 "RegexPhraseQuery can only be used with a field of type text currently, but got \
127 {field_type:?}"
128 )));
129 }
130
131 let field_entry = schema.get_field_entry(self.field);
132 let has_positions = field_entry
133 .field_type()
134 .get_index_record_option()
135 .map(IndexRecordOption::has_positions)
136 .unwrap_or(false);
137 if !has_positions {
138 let field_name = field_entry.name();
139 return Err(crate::TantivyError::SchemaError(format!(
140 "Applied phrase query on field {field_name:?}, which does not have positions \
141 indexed"
142 )));
143 }
144 let terms = self.phrase_terms();
145 let bm25_weight_opt = match enable_scoring {
146 EnableScoring::Enabled {
147 statistics_provider,
148 ..
149 } => Some(Bm25Weight::for_terms(statistics_provider, &terms)?),
150 EnableScoring::Disabled { .. } => None,
151 };
152 let weight = RegexPhraseWeight::new(
153 self.field,
154 self.phrase_terms.clone(),
155 bm25_weight_opt,
156 self.max_expansions,
157 self.slop,
158 );
159 Ok(weight)
160 }
161}
162
163impl Query for RegexPhraseQuery {
164 /// Create the weight associated with a query.
165 ///
166 /// See [`Weight`].
167 fn weight(&self, enable_scoring: EnableScoring<'_>) -> crate::Result<Box<dyn Weight>> {
168 let phrase_weight = self.regex_phrase_weight(enable_scoring)?;
169 Ok(Box::new(phrase_weight))
170 }
171}