Skip to content

Evaluate EQ(REGEXP_EXTRACT) once per distinct value with RegexExtractEqQuery - #6819

Merged
Mallets merged 22 commits into
mainfrom
mallets/regex-query-eq
Sep 29, 2026
Merged

Mallets merged 22 commits into
mainfrom
mallets/regex-query-eq

Conversation

@Mallets

@Mallets Mallets commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Calculated-field predicates such as EQ(REGEXP_EXTRACT(field, pattern, capture_index), literal) currently run a JIT regex expression for every document. This PR adds RegexExtractEqQuery, which preserves JIT semantics while evaluating the regex once per distinct string value.

The optimization applies to non-JSON string fast fields when the expression can produce a safe FST prefilter. It accepts either EQ argument order, every explicit capture index, and the two-argument REGEXP_EXTRACT form, which defaults to capture index 0 (the whole match).

Capture isolation

regex-syntax rewrites the pattern before it reaches the existing fast path:

  • non-selected captures become non-capturing (?:...) groups;
  • the requested capture is consequently group 1;
  • for capture index 0, the whole pattern body becomes group 1.

Rust regexes have no backreferences, so anonymizing unrelated captures does not change which values match. The exact matcher then uses captures().get(1) on the rewritten pattern, equivalent to the requested capture in the original pattern.

Patterns without a safe FST prefilter, or with a selected capture nested in an alternation or repetition, remain on the JIT path.

Document selection

The scorer builds matching value ordinals with the FST prefilter and the exact regex check. A document matches only when its first fast-field value matches, like the JIT's load_str_input. A matching later value in a multivalued field does not make the document match.

  • Postings path: on raw-indexed fields, when the term and fast-field dictionaries have equal term counts, only matching terms and their posting lists are visited. Raw tokenization never alters values, and merging removes values without live documents from both dictionaries, so equal counts mean term ordinals can be used as fast-field ordinals.
  • Fallback: otherwise, the scorer walks the fast-field dictionary and checks every document's first value. This covers fast-only or normalized fields, and values at or above the raw tokenizer's 255-byte limit, which are dropped from the inverted index. Such values remain covered by the fallback until a merge removes their deleted-only documents.

The query builder registers the prefilter automaton for the postings path, so term-dictionary blocks and postings are warmed before the synchronous scorer reads them.

Benchmarks

Criterion bench on in-memory single-segment indexes, field message with STRING | FAST, comparing the JIT alone (current behavior), the inexact FST prefilter alone, the FST prefilter ∧ JIT and RegexExtractEqQuery. Hit counts were cross-checked against prefilter ∧ JIT for every corpus.

Corpus (10M docs) Hits JIT only Prefilter only (inexact) Prefilter ∧ JIT RegexExtractEqQuery Speedup vs JIT
status="info"/"warn"/"error", extract = info 70% 1.26 s 24.0 ms 810 ms 24.6 ms ~51×
status="info"/"warn"/"error", extract = error 10% 1.29 s 3.8 ms 113 ms 3.9 ms ~331×
Log lines, ~1k distinct values, anchored 1% 31.9 s 0.93 ms 310 ms 0.94 ms ~33,900×
Log lines, ~1k distinct values, unanchored 1% 31.0 s 0.99 ms 361 ms 0.95 ms ~32,600×
Log lines, all values distinct, anchored 1% 34.7 s 1.88 s 2.23 s 1.89 s ~18×

RegexExtractEqQuery is exact and runs at the cost of the inexact prefilter alone: a single dictionary walk plus the postings of matching terms. The benchmark is not part of this PR.

Test plan

  • JIT equivalence for first-value semantics, missing values, long values, fast-only and normalized fields, deletes, and merges.
  • Capture-isolation equivalence for group 0, omitted capture index, named groups, and selected groups in a five-group pattern.
  • Warmup registers the prefilter automaton only for raw-indexed fields.
  • make fmt and cargo clippy --tests.
  • Exercise an eligible REGEXP_EXTRACT equality through the full root-to-leaf search path.

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.
Honor optional ^/$ by wrapping missing sides with .* and treat EQ as
commutative so literal/extract order does not block the RegexQuery path.
Intersect an over-approximating RegexQuery with the original CalcField
predicate so unanchored log-style extracts stay semantically exact while
the FST path narrows candidates.
When the extract pattern has both ^ and $, the FST RegexQuery alone is
equivalent, so drop the CalcField intersection; keep prefilter∧JIT otherwise.
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.
@fulmicoton
fulmicoton self-requested a review September 25, 2026 07:52
if let Some(regex_query) = calc_field_query.try_prefilter_regex_query(self.schema) {
return Ok(Some(
BoolQuery {
filter: vec![regex_query.into(), QueryAst::CalcField(calc_field_query)],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to keep the Calcfield query at all?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EQ(REGEXP_EXTRACT(field, pattern, 1), "L") is true only when the leftmost match of pattern yields capture group 1 equal to L. That is stricter than the term somehow contains prefix + L + suffix (later occurrences, longer captures, etc. can make containment true while extract+EQ is false).

So, RegexQuery acts as a pre-filter that over-estimates (it could match a non-first value) and then the JIT checks whether it's exactly the first match. JIT checks have been superseded by a dedicated scorer and posting list check in e15e411.

Comment thread quickwit/quickwit-query/src/query_ast/calc_field_query.rs Outdated
Comment thread quickwit/quickwit-query/src/query_ast/calc_field_query.rs Outdated
Comment thread quickwit/quickwit-query/src/query_ast/calc_field_query.rs Outdated
Comment thread quickwit/quickwit-query/src/query_ast/calc_field_query.rs Outdated
Comment thread quickwit/quickwit-query/src/query_ast/calc_field_query.rs Outdated
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.
- 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.
…IT 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.
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.
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.
…RACT)

A document of a full or optional column holds only the value of the term
whose postings contain it, and that value matches.
Main moved tantivy to e229de6, which left two tantivy-common versions and
broke BitSetDocSet::from(BitSet) in RegexExtractEqQuery.
@Mallets Mallets changed the title Rewrite EQ(REGEXP_EXTRACT) predicates to RegexQuery when eligible Evaluate EQ(REGEXP_EXTRACT) once per distinct value with RegexExtractEqQuery Sep 25, 2026
@Mallets Mallets self-assigned this Sep 28, 2026
@Mallets
Mallets marked this pull request as ready for review September 28, 2026 10:18
@Mallets
Mallets requested a review from a team as a code owner September 28, 2026 10:18
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-29T07:53:25.452737Z 4d1c7aa New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mallets
Mallets requested a review from fulmicoton September 28, 2026 10:18

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 98d99cc4d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread quickwit/quickwit-query/src/query_ast/regex_extract_eq.rs
/// contain the same values; callers still warm them for every raw-compatible segment.
///
/// Returns `None` whenever the expression shape or field is not eligible.
pub fn try_prefilter_regex_query(&self, schema: &TantivySchema) -> Option<RegexQuery> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with the following code, I think we end up compiling regex twice per split.
Once for the warmup, to identify what needs to be downloaded.
Once for building the query ast.

Before, it was done only once, because the regex compilation in the calculated field evaluation is done at jitexpr compilation and we have a jitexpr cache.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right.

5643314 adds a fst and value expression cache following the LruCache approach in jitexpr.

@Mallets
Mallets added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 2ba64ed Sep 29, 2026
12 checks passed
@Mallets
Mallets deleted the mallets/regex-query-eq branch September 29, 2026 09:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants