Skip to content

Commit 2ba64ed

Browse files
authored
Evaluate EQ(REGEXP_EXTRACT) once per distinct value with RegexExtractEqQuery (#6819)
* Rewrite EQ(REGEXP_EXTRACT) predicates to RegexQuery when eligible Lower a narrow calculated-field equality over REGEXP_EXTRACT into an inverted-index RegexQuery for fast, indexed, raw string fields, so search can avoid per-document JIT evaluation while preserving warmup. * Allow unanchored EQ(REGEXP_EXTRACT) rewrite with swapped EQ args Honor optional ^/$ by wrapping missing sides with .* and treat EQ as commutative so literal/extract order does not block the RegexQuery path. * Prefilter EQ(REGEXP_EXTRACT) with FST RegexQuery, keep exact JIT Intersect an over-approximating RegexQuery with the original CalcField predicate so unanchored log-style extracts stay semantically exact while the FST path narrows candidates. * Skip JIT for fully anchored EQ(REGEXP_EXTRACT) rewrites When the extract pattern has both ^ and $, the FST RegexQuery alone is equivalent, so drop the CalcField intersection; keep prefilter∧JIT otherwise. * Always prefilter EQ(REGEXP_EXTRACT) with FST RegexQuery Drop the Exact rewrite for fully anchored patterns: leftmost extract semantics are not always equivalent to an FST whole-term match, so keep CalcField as the exact filter and add a Criterion microbench. * Parse REGEXP_EXTRACT pattern with regex-syntax for prefilter rewrite Replace manual parenthesis scanning in substitute_single_capture with a regex-syntax AST walk, and add a prefilter_only path to the bench to separate FST cost from JIT cost. * Fix soundness of EQ(REGEXP_EXTRACT) FST prefilter - Require the capture group to be a direct part of the top-level concatenation. - Wrap unanchored sides with (?s:.*) so multi-line values are not dropped. - Reject fields whose fast field is normalized. - Keep patterns whose rewrite needs look-arounds on the JIT path. * Evaluate EQ(REGEXP_EXTRACT) on the fast-field dictionary with exact JIT semantics Replace the term-dictionary rewrite with RegexExtractEqQuery, which walks the fast-field dictionary with the FST prefilter, checks the capture on each candidate value, and selects documents by the ordinal of their first value. This matches the JIT predicate on multi-valued documents, on values longer than MAX_TOKEN_LEN, and on normalized fast fields. Documents are collected with range scans when the matching ordinals form a few runs, with block decoding on full single-valued columns, and with a per-document first-value loop otherwise. Drop the regexp_extract_eq benchmark. * Simplify RegexExtractEqQuery to a first-value document loop Drop the range-scan and block-decoding strategies: collect matching documents with a single loop over first-value ordinals. Move the RegexExtractEqQuery end-to-end tests next to the query, document that column errors are returned rather than treated as an absent column, and use `regex_extract_eq` naming consistently. * Read postings for EQ(REGEXP_EXTRACT) on raw-indexed fields When the field is also indexed with the raw tokenizer and every matching fast-field value is shorter than the raw tokenizer's length limit, visit only the documents in the postings of the matching terms, still keeping a document only when its first fast-field value matches. Otherwise fall back to the first-value loop over every document. Warm the field's term dictionary and postings with the prefilter regex. On 10M-document low-cardinality corpora this takes 1.2-1.5ms instead of 19ms. When the prefilter accepts most of a high-cardinality dictionary, walking the term dictionary a second time doubles the cost. * Skip the first-value check on single-valued postings of EQ(REGEXP_EXTRACT) A document of a full or optional column holds only the value of the term whose postings contain it, and that value matches. * Keep tantivy-common on the same rev as tantivy Main moved tantivy to e229de6, which left two tantivy-common versions and broke BitSetDocSet::from(BitSet) in RegexExtractEqQuery. * Walk only the term dictionary when it holds every fast-field value * Support any capture index in REGEXP_EXTRACT equality * Clarify regex extract query documentation * Clarify and simplify regex extract planning * Rewrite REGEXP_EXTRACT capture isolation via regex-syntax AST Avoid byte-offset string splicing and keep prefilters correct under multiline and verbose flags. * Simplify verbose-flag wrapping in capture substitution * Cache REGEXP_EXTRACT query regexes * Remove unused search FST dependency
1 parent 7b256dd commit 2ba64ed

10 files changed

Lines changed: 1431 additions & 16 deletions

File tree

‎quickwit/Cargo.lock‎

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎quickwit/Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,8 @@ tantivy = { git = "https://github.com/quickwit-oss/tantivy/", rev = "e229de6db74
386386
"zstd-compression",
387387
"columnar-zstd-compression",
388388
] }
389+
# Must stay on the same rev as `tantivy`.
390+
tantivy-common = { git = "https://github.com/quickwit-oss/tantivy/", rev = "e229de6db748c6b21747a99a18220446e76104e1" }
389391
tantivy-fst = "0.5"
390392

391393
# This is actually not used directly the goal is to fix the version

‎quickwit/quickwit-doc-mapper/src/query_builder.rs‎

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,18 @@ impl<'a, 'b: 'a> QueryAstVisitor<'a> for ExtractPrefixTermRanges<'b> {
380380
);
381381
Ok(())
382382
}
383+
384+
fn visit_calc_field(&mut self, calc_field_query: &'a CalcFieldQuery) -> Result<(), Self::Err> {
385+
let Some(regex_query) = calc_field_query.try_prefilter_regex_query(self.schema) else {
386+
return Ok(());
387+
};
388+
let resolved = regex_query.to_resolved(self.schema, Some(self.tokenizer_manager))?;
389+
self.add_automaton(
390+
resolved.field,
391+
Automaton::Regex(resolved.json_path, resolved.regex),
392+
);
393+
Ok(())
394+
}
383395
}
384396

385397
type TermRangeWarmupInfo = HashMap<Field, HashMap<TermRange, PositionNeeded>>;
@@ -400,7 +412,7 @@ fn extract_prefix_term_ranges_and_automaton(
400412

401413
#[cfg(test)]
402414
mod test {
403-
use std::collections::HashSet;
415+
use std::collections::{HashMap, HashSet};
404416
use std::ops::Bound;
405417

406418
use quickwit_common::shared_consts::FIELD_PRESENCE_FIELD_NAME;
@@ -413,11 +425,14 @@ mod test {
413425
};
414426
use tantivy::Term;
415427
use tantivy::jitexpr::ast::deserialize;
416-
use tantivy::schema::{DateOptions, DateTimePrecision, FAST, INDEXED, STORED, Schema, TEXT};
428+
use tantivy::schema::{
429+
DateOptions, DateTimePrecision, FAST, Field, INDEXED, STORED, STRING, Schema, TEXT,
430+
};
417431

418432
use super::{ExtractPrefixTermRanges, build_query};
419433
use crate::{
420-
DYNAMIC_FIELD_NAME, FastFieldWarmupInfo, SOURCE_FIELD_NAME, TermRange, WarmupInfo,
434+
Automaton, DYNAMIC_FIELD_NAME, FastFieldWarmupInfo, SOURCE_FIELD_NAME, TermRange,
435+
WarmupInfo,
421436
};
422437

423438
fn calc_field(expression: &str) -> QueryAst {
@@ -488,6 +503,64 @@ mod test {
488503
);
489504
}
490505

506+
#[test]
507+
fn test_calc_field_regex_extract_eq_warmup() {
508+
let mut schema_builder = Schema::builder();
509+
let service = schema_builder.add_text_field("service", STRING | FAST);
510+
schema_builder.add_text_field("tokenized", TEXT | FAST);
511+
schema_builder.add_text_field("fast_only", FAST);
512+
let schema = schema_builder.build();
513+
let context = BuildTantivyAstContext::for_test(&schema);
514+
515+
// The predicate always reads the fast field. It may also read postings of terms accepted
516+
// by the prefilter when the field uses the raw tokenizer for both indexing and fast fields.
517+
for (expression, fast_field, expected_prefilter) in [
518+
(
519+
r#"(EQ (REGEXP_EXTRACT service "^svc-([a-z]+)-prod$" 1u64) "api")"#,
520+
"service",
521+
Some("svc-api-prod"),
522+
),
523+
(
524+
r#"(EQ (REGEXP_EXTRACT service "svc-([a-z]+)-prod" 1u64) "api")"#,
525+
"service",
526+
Some("(?s:.*)svc-api-prod(?s:.*)"),
527+
),
528+
(
529+
r#"(EQ (REGEXP_EXTRACT tokenized "^svc-([a-z]+)-prod$" 1u64) "api")"#,
530+
"tokenized",
531+
None,
532+
),
533+
(
534+
r#"(EQ (REGEXP_EXTRACT fast_only "^svc-([a-z]+)-prod$" 1u64) "api")"#,
535+
"fast_only",
536+
None,
537+
),
538+
(
539+
r#"(EQ (REGEXP_EXTRACT service "(?m)^svc-([a-z]+)" 1u64) "api")"#,
540+
"service",
541+
None,
542+
),
543+
] {
544+
let (_, warmup) = build_query(calc_field(expression), &context, None).unwrap();
545+
assert_eq!(
546+
warmup.fast_fields,
547+
expected_fast_fields(&[fast_field]),
548+
"{expression}"
549+
);
550+
let expected_automatons: HashMap<Field, HashSet<Automaton>> = expected_prefilter
551+
.map(|regex| {
552+
let automaton = Automaton::Regex(None, regex.to_string());
553+
(service, HashSet::from([automaton]))
554+
})
555+
.into_iter()
556+
.collect();
557+
assert_eq!(
558+
warmup.automatons_grouped_by_field, expected_automatons,
559+
"{expression}"
560+
);
561+
}
562+
}
563+
491564
fn full_text_query_for_warmup(field: &str, tokenizer: Option<&str>) -> QueryAst {
492565
quickwit_query::query_ast::FullTextQuery {
493566
field: field.to_string(),

‎quickwit/quickwit-query/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@ anyhow = { workspace = true }
1515
base64 = { workspace = true }
1616
bitpacking = { workspace = true }
1717
hex = { workspace = true }
18+
lru = { workspace = true }
1819
regex = { workspace = true }
20+
regex-syntax = { workspace = true }
1921
serde = { workspace = true }
2022
serde_json = { workspace = true }
2123
serde_with = { workspace = true }
2224
tantivy = { workspace = true }
25+
tantivy-common = { workspace = true }
2326
unicode-segmentation = { workspace = true }
2427
tantivy-fst = { workspace = true }
2528
tracing = { workspace = true }

‎quickwit/quickwit-query/benches/tokenizers_bench.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
use criterion::{Criterion, Throughput, black_box, criterion_group, criterion_main};
15+
use std::hint::black_box;
16+
17+
use criterion::{Criterion, Throughput, criterion_group, criterion_main};
1618
use quickwit_query::CodeTokenizer;
1719
use tantivy::tokenizer::{RegexTokenizer, TextAnalyzer, Token, TokenStream};
1820

0 commit comments

Comments
 (0)