Skip to content

Commit 992082e

Browse files
Vova Kolmakovclaude
andcommitted
feat(index): accelerate regex and infix LIKE with the ngram index
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b8055c2 commit 992082e

13 files changed

Lines changed: 1411 additions & 25 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ rand_distr = { version = "0.5.1" }
180180
rand_xoshiro = "0.7.0"
181181
rangemap = { version = "1.0" }
182182
rayon = "1.10"
183+
regex-syntax = "0.8.10"
183184
roaring = "0.11.4"
184185
rstest = "0.26.1"
185186
serde = { version = "^1" }

docs/src/format/index/scalar/ngram.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,10 @@ The N-gram index provides inexact results for the following query types:
2929

3030
| Query Type | Description | Operation | Result Type |
3131
|----------------|--------------------------|-------------------------------------------------------|-------------|
32-
| **contains** | Substring search in text | Finds all trigrams in query, intersects posting lists | AtMost |
32+
| **contains** | Substring search in text | Finds all trigrams in query, intersects posting lists | AtMost |
33+
| **regexp_like** / **regexp_match** | Regular-expression match | Derives a necessary trigram condition from the pattern (AND of intersections, OR of unions), then rechecks the true regex | AtMost |
34+
| **LIKE** (infix) | Wildcard match such as `%foo%bar%` | Uses the literal segments of the pattern as a trigram condition, then rechecks the LIKE | AtMost |
35+
36+
Patterns from which no trigram can be derived - for example `a.b`, `.*`,
37+
case-insensitive matches, or literal runs shorter than three characters - fall
38+
back to rechecking every row. This is always correct, just not accelerated.

python/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/lance-index/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ object_store.workspace = true
5656
prost.workspace = true
5757
prost-types.workspace = true
5858
rand.workspace = true
59+
regex-syntax.workspace = true
5960
roaring.workspace = true
6061
rayon.workspace = true
6162
serde_json.workspace = true

rust/lance-index/src/scalar.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use arrow_array::{BooleanArray, ListArray, RecordBatch, UInt64Array};
88
use arrow_schema::{Field, Schema};
99
use async_trait::async_trait;
1010
use bytes::Bytes;
11+
use datafusion::functions::regex::regexplike::RegexpLikeFunc;
1112
use datafusion::functions::string::contains::ContainsFunc;
1213
use datafusion::functions_nested::array_has;
1314
use datafusion::physical_plan::SendableRecordBatchStream;
@@ -628,9 +629,15 @@ impl AnyQuery for LabelListQuery {
628629
pub enum TextQuery {
629630
/// Retrieve all row ids where the text contains the given string
630631
StringContains(String),
631-
// TODO: In the future we should be able to do string-insensitive contains
632-
// as well as partial matches (e.g. LIKE 'foo%') and potentially even
633-
// some regular expressions
632+
/// Retrieve all row ids whose text matches the given regular expression.
633+
///
634+
/// The pattern is a full regular expression (as accepted by `regexp_like`).
635+
/// The index returns a candidate superset that the scan rechecks, so any
636+
/// pattern is sound; patterns with no usable trigram structure simply fall
637+
/// back to rechecking every row.
638+
Regex(String),
639+
// TODO: In the future we should be able to do case-insensitive contains
640+
// as well as partial matches (e.g. LIKE 'foo%').
634641
}
635642

636643
impl AnyQuery for TextQuery {
@@ -651,6 +658,17 @@ impl AnyQuery for TextQuery {
651658
Expr::Literal(ScalarValue::Utf8(Some(substr.clone())), None),
652659
],
653660
}),
661+
// `regexp_like` returns Boolean directly, so the reconstructed
662+
// expression can be used as-is for the recheck filter (no IsNotNull
663+
// wrapper, unlike `regexp_match`). It is the semantic equivalent of
664+
// the original predicate for the "does it match" question.
665+
Self::Regex(pattern) => Expr::ScalarFunction(ScalarFunction {
666+
func: Arc::new(RegexpLikeFunc::new().into()),
667+
args: vec![
668+
Expr::Column(Column::new_unqualified(col)),
669+
Expr::Literal(ScalarValue::Utf8(Some(pattern.clone())), None),
670+
],
671+
}),
654672
}
655673
}
656674

rust/lance-index/src/scalar/expression.rs

Lines changed: 217 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -781,20 +781,28 @@ impl ScalarQueryParser for LabelListQueryParser {
781781
}
782782
}
783783

784-
/// A parser for indices that handle string contains queries
784+
/// A parser for indices that handle string `contains` queries, and -- when
785+
/// `supports_regex` is set -- `regexp_like` / `regexp_match` queries.
785786
#[derive(Debug, Clone)]
786787
pub struct TextQueryParser {
787788
index_name: String,
788789
index_type: String,
789790
needs_recheck: bool,
791+
supports_regex: bool,
790792
}
791793

792794
impl TextQueryParser {
793-
pub fn new(index_name: String, index_type: String, needs_recheck: bool) -> Self {
795+
pub fn new(
796+
index_name: String,
797+
index_type: String,
798+
needs_recheck: bool,
799+
supports_regex: bool,
800+
) -> Self {
794801
Self {
795802
index_name,
796803
index_type,
797804
needs_recheck,
805+
supports_regex,
798806
}
799807
}
800808
}
@@ -837,31 +845,156 @@ impl ScalarQueryParser for TextQueryParser {
837845
func: &ScalarUDF,
838846
args: &[Expr],
839847
) -> Option<IndexedExpression> {
840-
if args.len() != 2 {
848+
// The first argument is the indexed column; the second is the substring
849+
// / pattern. `contains` takes exactly two arguments; the regex functions
850+
// optionally take a third flags argument.
851+
if args.len() < 2 {
841852
return None;
842853
}
843-
let scalar = maybe_scalar(&args[1], data_type)?;
844-
match scalar {
845-
ScalarValue::Utf8(Some(scalar_str)) | ScalarValue::LargeUtf8(Some(scalar_str)) => {
846-
if func.name() == "contains" {
847-
let query = TextQuery::StringContains(scalar_str);
848-
Some(IndexedExpression::index_query_with_recheck(
849-
column.to_string(),
850-
self.index_name.clone(),
851-
self.index_type.clone(),
852-
Arc::new(query),
853-
self.needs_recheck,
854-
))
855-
} else {
854+
// A non-string pattern cannot be handled.
855+
let (ScalarValue::Utf8(Some(pattern)) | ScalarValue::LargeUtf8(Some(pattern))) =
856+
maybe_scalar(&args[1], data_type)?
857+
else {
858+
return None;
859+
};
860+
861+
let query = match func.name() {
862+
"contains" if args.len() == 2 => TextQuery::StringContains(pattern),
863+
"regexp_like" | "regexp_match" if self.supports_regex => {
864+
let pattern = match args.get(2) {
865+
Some(flags_expr) => apply_regex_flags(&pattern, flags_expr)?,
866+
None => pattern,
867+
};
868+
// If the pattern yields no usable trigram (e.g. `a.b`), leave it
869+
// to a full scan instead of routing it to the index, which could
870+
// only answer with an unsupported "recheck everything" result.
871+
if !crate::scalar::ngram::regex_can_use_index(&pattern) {
872+
return None;
873+
}
874+
TextQuery::Regex(pattern)
875+
}
876+
_ => return None,
877+
};
878+
879+
Some(IndexedExpression::index_query_with_recheck(
880+
column.to_string(),
881+
self.index_name.clone(),
882+
self.index_type.clone(),
883+
Arc::new(query),
884+
self.needs_recheck,
885+
))
886+
}
887+
888+
fn visit_like(
889+
&self,
890+
column: &str,
891+
like: &Like,
892+
pattern: &ScalarValue,
893+
) -> Option<IndexedExpression> {
894+
// Infix LIKE is accelerated only by the ngram index (via its regex
895+
// machinery). A plain-literal `regexp_like(col, 'foo')` is rewritten to
896+
// `col LIKE '%foo%'` before it reaches the index, so this is the path
897+
// that accelerates those. ILIKE is skipped because its case folding does
898+
// not match the index's normalization.
899+
if !self.supports_regex || like.case_insensitive {
900+
return None;
901+
}
902+
let pattern_str = match pattern {
903+
ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => s.as_str(),
904+
_ => return None,
905+
};
906+
// Translate the LIKE pattern into a loose regex used only for candidate
907+
// generation; the original LIKE stays as the recheck filter, so the
908+
// regex only needs to be a sound superset.
909+
let regex = like_to_regex(pattern_str, like.escape_char)?;
910+
if !crate::scalar::ngram::regex_can_use_index(&regex) {
911+
return None;
912+
}
913+
Some(IndexedExpression {
914+
scalar_query: Some(ScalarIndexExpr::Query(ScalarIndexSearch {
915+
column: column.to_string(),
916+
index_name: self.index_name.clone(),
917+
index_type: self.index_type.clone(),
918+
query: Arc::new(TextQuery::Regex(regex)),
919+
needs_recheck: self.needs_recheck,
920+
fragment_bitmap: None,
921+
})),
922+
refine_expr: Some(Expr::Like(like.clone())),
923+
})
924+
}
925+
}
926+
927+
/// Translate a LIKE pattern into a regular expression used purely for ngram
928+
/// candidate generation: `%` becomes `.*`, `_` becomes `.`, and literal
929+
/// characters are regex-escaped. Returns `None` when no literal run is long
930+
/// enough to yield a trigram (the index could not help, so a full scan is left
931+
/// to handle it).
932+
fn like_to_regex(pattern: &str, escape: Option<char>) -> Option<String> {
933+
let mut regex = String::new();
934+
let mut run = 0usize;
935+
let mut longest_run = 0usize;
936+
let mut chars = pattern.chars();
937+
while let Some(c) = chars.next() {
938+
let literal = if Some(c) == escape {
939+
// The next character is escaped, i.e. a literal.
940+
chars.next()
941+
} else {
942+
match c {
943+
'%' => {
944+
regex.push_str(".*");
945+
run = 0;
856946
None
857947
}
948+
'_' => {
949+
regex.push('.');
950+
run = 0;
951+
None
952+
}
953+
other => Some(other),
858954
}
859-
_ => {
860-
// If the scalar is not a string, we cannot handle it
861-
None
955+
};
956+
if let Some(lit) = literal {
957+
if regex_syntax::is_meta_character(lit) {
958+
regex.push('\\');
959+
}
960+
regex.push(lit);
961+
// Only runs of alphanumeric characters can produce a trigram.
962+
if lit.is_alphanumeric() {
963+
run += 1;
964+
longest_run = longest_run.max(run);
965+
} else {
966+
run = 0;
862967
}
863968
}
864969
}
970+
(longest_run >= 3).then_some(regex)
971+
}
972+
973+
/// Fold the supported `regexp_like` / `regexp_match` flags into an inline prefix
974+
/// on the pattern (e.g. flags `"i"` -> `"(?i)pattern"`). Returns `None` for a
975+
/// non-literal flags argument or an unrecognized flag, so the caller leaves the
976+
/// predicate to a full recheck rather than risk changing its semantics.
977+
fn apply_regex_flags(pattern: &str, flags_expr: &Expr) -> Option<String> {
978+
let (Expr::Literal(ScalarValue::Utf8(Some(flags)), _)
979+
| Expr::Literal(ScalarValue::LargeUtf8(Some(flags)), _)) = flags_expr
980+
else {
981+
return None;
982+
};
983+
let mut inline = String::new();
984+
for flag in flags.chars() {
985+
// Only flags expressible as an inline `(?...)` group in the regex crate
986+
// (which the recheck uses) are safe to fold.
987+
if ['i', 's', 'm', 'x'].contains(&flag) {
988+
inline.push(flag);
989+
} else {
990+
return None;
991+
}
992+
}
993+
if inline.is_empty() {
994+
Some(pattern.to_string())
995+
} else {
996+
Some(format!("(?{inline}){pattern}"))
997+
}
865998
}
866999

8671000
/// A parser for indices that handle queries with the contains_tokens function
@@ -1813,7 +1946,18 @@ fn visit_node(
18131946
Expr::IsFalse(expr) => Ok(visit_is_bool(expr.as_ref(), index_info, false)),
18141947
Expr::IsTrue(expr) => Ok(visit_is_bool(expr.as_ref(), index_info, true)),
18151948
Expr::IsNull(expr) => Ok(visit_is_null(expr.as_ref(), index_info, false)),
1816-
Expr::IsNotNull(expr) => Ok(visit_is_null(expr.as_ref(), index_info, true)),
1949+
Expr::IsNotNull(expr) => {
1950+
// `regexp_match(col, pat)` returns a list and is coerced to
1951+
// `IsNotNull(regexp_match(...))` before it reaches here. Unwrap that
1952+
// so the regex acceleration applies; everything else is a genuine
1953+
// IS NOT NULL check.
1954+
if let Expr::ScalarFunction(scalar_fn) = expr.as_ref()
1955+
&& scalar_fn.func.name() == "regexp_match"
1956+
{
1957+
return Ok(visit_scalar_fn(scalar_fn, index_info));
1958+
}
1959+
Ok(visit_is_null(expr.as_ref(), index_info, true))
1960+
}
18171961
Expr::Not(expr) => visit_not(expr.as_ref(), index_info, depth),
18181962
Expr::BinaryExpr(binary_expr) => visit_binary_expr(binary_expr, index_info, depth),
18191963
Expr::ScalarFunction(scalar_fn) => Ok(visit_scalar_fn(scalar_fn, index_info)),
@@ -2690,6 +2834,59 @@ mod tests {
26902834
assert!(matches!(negated.upper, NullableRowAddrMask::BlockList(_)));
26912835
}
26922836

2837+
#[test]
2838+
fn test_like_to_regex() {
2839+
// `%` -> `.*`, `_` -> `.`, with a literal run of at least three chars.
2840+
assert_eq!(like_to_regex("%foo%", None).as_deref(), Some(".*foo.*"));
2841+
assert_eq!(like_to_regex("foo%bar", None).as_deref(), Some("foo.*bar"));
2842+
assert_eq!(like_to_regex("foo_bar", None).as_deref(), Some("foo.bar"));
2843+
assert_eq!(like_to_regex("foobar", None).as_deref(), Some("foobar"));
2844+
2845+
// Regex metacharacters in the literal portion are escaped.
2846+
assert_eq!(
2847+
like_to_regex("%a.bcd%", None).as_deref(),
2848+
Some(".*a\\.bcd.*")
2849+
);
2850+
2851+
// No literal run of three alphanumeric characters -> no index help.
2852+
assert_eq!(like_to_regex("%ab%", None), None);
2853+
assert_eq!(like_to_regex("%a%b%c%", None), None);
2854+
assert_eq!(like_to_regex("%", None), None);
2855+
2856+
// The escape character makes the following character a literal.
2857+
assert_eq!(
2858+
like_to_regex(r"%foo\%bar%", Some('\\')).as_deref(),
2859+
Some(".*foo%bar.*")
2860+
);
2861+
}
2862+
2863+
#[test]
2864+
fn test_apply_regex_flags() {
2865+
fn flags(s: &str) -> Expr {
2866+
Expr::Literal(ScalarValue::Utf8(Some(s.to_string())), None)
2867+
}
2868+
2869+
// Empty flags leave the pattern untouched (no inline group emitted).
2870+
assert_eq!(apply_regex_flags("foo", &flags("")).as_deref(), Some("foo"));
2871+
// Supported flags are folded into an inline `(?...)` prefix.
2872+
assert_eq!(
2873+
apply_regex_flags("foo", &flags("i")).as_deref(),
2874+
Some("(?i)foo")
2875+
);
2876+
assert_eq!(
2877+
apply_regex_flags("foo", &flags("is")).as_deref(),
2878+
Some("(?is)foo")
2879+
);
2880+
// An unrecognized flag bails out so the caller leaves the predicate to a
2881+
// full recheck rather than risk changing its semantics.
2882+
assert_eq!(apply_regex_flags("foo", &flags("g")), None);
2883+
// A non-string (hence non-literal-flags) argument cannot be folded.
2884+
assert_eq!(
2885+
apply_regex_flags("foo", &Expr::Literal(ScalarValue::Int32(Some(1)), None)),
2886+
None
2887+
);
2888+
}
2889+
26932890
#[test]
26942891
fn test_extract_like_leading_prefix() {
26952892
// Simple prefix patterns (no recheck needed)

rust/lance-index/src/scalar/fmindex.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,6 +1311,12 @@ impl ScalarIndex for FMIndexScalarIndex {
13111311
Default::default(),
13121312
)))
13131313
}
1314+
// Regex queries are routed only to the ngram index (the FM-index's
1315+
// query parser advertises `supports_regex = false`), so this is
1316+
// unreachable in practice; reject it explicitly rather than silently.
1317+
TextQuery::Regex(_) => Err(Error::invalid_input(
1318+
"FMIndex does not support regular expression queries",
1319+
)),
13141320
}
13151321
}
13161322
fn can_remap(&self) -> bool {
@@ -1599,6 +1605,9 @@ impl ScalarIndexPlugin for FMIndexPlugin {
15991605
Some(Box::new(TextQueryParser::new(
16001606
index_name,
16011607
self.name().to_string(),
1608+
// needs_recheck: the FM-index returns exact substring matches.
1609+
false,
1610+
// supports_regex: regex acceleration is only implemented for ngram.
16021611
false,
16031612
)))
16041613
}

0 commit comments

Comments
 (0)