Skip to content

Add prefix and substring string predicates with index acceleration#358

Open
ragnorc wants to merge 3 commits into
mainfrom
investigate-ngram-prefix-search
Open

Add prefix and substring string predicates with index acceleration#358
ragnorc wants to merge 3 commits into
mainfrom
investigate-ngram-prefix-search

Conversation

@ragnorc

@ragnorc ragnorc commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

What & why

Adds exact string predicates to the query language — starts_with and a String overload of contains (substring) — so prefix/autocomplete and substring lookups are expressible in .gq, and makes them index-accelerated: predicates lower to structured DataFusion exprs that Lance answers from a BTREE (LikePrefix, exact) or NGRAM (StringContains, probe + recheck) when one covers the column, and free-text @index String columns now get an explicitly-named companion BTREE alongside their FTS index so prefix and equality filters stop full-scanning.

Backing issue / RFC

  • Fixes an accepted issue: Closes #
  • Implements / is an accepted RFC: <link to docs/rfcs/NNNN-*.md>
  • Trivial fast-lane (typo / docs / dependency bump / comment / one-line CI) — no issue/RFC required

Maintainer change — internal tracking applies.

Checklist

  • Change is focused (one logical change)
  • Tests added/updated for behavior changes (or N/A)
  • Public docs updated if user-facing surface changed (or N/A)
  • Reviewed against docs/dev/invariants.md — no Hard Invariant weakened, no deny-list item hit (or justified)

Notes for reviewers

  • The contains overload is backward-compatible by construction: a scalar-String left operand was a type error before, so no existing query changes meaning; the overload is resolved at lowering time into a distinct IR op (semantics stay first-class in typed IR, never re-derived at execution).
  • Standalone string-match filters on a scanned variable are hoisted into the NodeScan's filter_expr so they can reach a covering index; the hoist is pinned structurally via new instrumentation probes (results alone cannot distinguish it from a silent full-scan fallback).
  • The sharpest edge is Lance index naming: the default name is shared per column and replace removes by name, so an unnamed second index would silently replace the FTS index — the companion BTREE therefore carries an explicit name, and a new surface guard pins the collision behavior.
  • Both predicates are exact, case-sensitive, and correct without any index (scan fallback); ends_with and glob/regex are deliberately not exposed (no acceleration path / pattern-dependent cost).
  • Follow-up (not in this PR): an opt-in NGRAM index kind via an @index kind argument to accelerate substring contains.

Note

Medium Risk
Changes query semantics (new overload), index materialization on ensure_indices (extra BTREEs on existing FTS columns), and scan pushdown paths; String contains overload is backward-compatible because it was previously a type error.

Overview
Introduces starts_with and a String overload of contains (exact substring) in match filters, with parser/grammar support and mutation grammar still rejecting these ops.

Compiler: Typecheck accepts list contains (membership) or scalar String contains (substring); lowering maps String contains to a distinct StringContains IR op so runtime never re-derives types. New CompOp::StartsWith / StringContains variants.

Execution: In-memory filters implement exact, case-sensitive string matching (NULL → no match). Standalone predicates on a NodeScan variable are hoisted into scan filter_expr alongside FTS search filters; pushdown uses DataFusion starts_with / contains so Lance can use BTREE (LikePrefix) or NGRAM (StringContains) when present. Test probes count pushed vs in-memory filters.

Indexes: Free-text String @index columns now plan FTS plus an explicitly named companion BTREE ({column}_btree_idx) so equality and starts_with are not full scans; IndexBuildSpec::BTree gains optional name to avoid Lance replace clobbering the FTS index on the default name.

Other: Enables DataFusion string_expressions; user docs distinguish exact predicates from token-based search; Lance surface guards pin BTREE/NGRAM routing and dual-index naming.

Reviewed by Cursor Bugbot for commit 549c04b. Bugbot is set up for automated code reviews on this repo. Configure here.

Greptile Summary

This PR adds exact string predicates with Lance index acceleration. The main changes are:

  • New .gq starts_with syntax and scalar-String contains overload.
  • Lowering to DataFusion string expressions for scan pushdown.
  • Companion BTREE indexes for free-text String @index columns.
  • Tests and docs for prefix, substring, and dual-index behavior.

Confidence Score: 4/5

The scan hoist for string predicates needs a fix before merging.

  • Multi-variable string predicates can be evaluated inside the wrong scan.
  • A narrow lowering fallback can keep list semantics for a String contains expression.
  • The dual-index planning path looks consistent for the main cases reviewed.

crates/omnigraph/src/exec/query.rs and crates/omnigraph-compiler/src/ir/lower.rs

Important Files Changed

Filename Overview
crates/omnigraph-compiler/src/query/typecheck.rs Adds String operand checks for starts_with and the scalar contains overload.
crates/omnigraph-compiler/src/ir/lower.rs Resolves scalar String contains to StringContains, with a narrow fallback gap for String alias-like expressions.
crates/omnigraph/src/exec/query.rs Adds DataFusion string expression lowering and scan hoisting; the hoist can run multi-variable predicates too early.
crates/omnigraph/src/exec/projection.rs Adds in-memory exact string matching for fallback filtering.
crates/omnigraph/src/db/omnigraph/table_ops.rs Plans companion BTREE indexes beside FTS for free-text String index declarations.
crates/omnigraph/src/table_store.rs Threads explicit BTREE names into Lance index creation so dual indexes can coexist.
crates/omnigraph/src/storage_layer.rs Extends the internal BTREE index spec with an optional Lance index name.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Filter: a.name starts_with b.prefix] --> B[Hoist pre-pass checks left variable a]
  B --> C[Filter attached to NodeScan a]
  C --> D[Original Filter skipped]
  D --> E[PropAccess b.prefix becomes column prefix]
  E --> F[Scan reads prefix from a table]
  F --> G[Incorrect rows or planning error]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
  A[Filter: a.name starts_with b.prefix] --> B[Hoist pre-pass checks left variable a]
  B --> C[Filter attached to NodeScan a]
  C --> D[Original Filter skipped]
  D --> E[PropAccess b.prefix becomes column prefix]
  E --> F[Scan reads prefix from a table]
  F --> G[Incorrect rows or planning error]
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "Build a companion BTREE alongside FTS on..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Context used:

  • Context used - AGENTS.md (source)
  • Context used - CLAUDE.md (source)

ragnorc added 3 commits July 15, 2026 00:51
…nguage

starts_with is a new filter keyword; contains is overloaded on the left
operand's type (list = membership as before, scalar String = exact
substring — previously a type error, so no existing query changes
meaning). Both are exact and case-sensitive, with NULL never a match.

The overload is resolved at lowering time into a distinct IR op
(StringContains), so execution dispatches on the IR alone and never
re-derives operand types. Predicates lower to the DataFusion starts_with
/ contains function exprs, whose names Lance's scalar-index expression
parser maps to BTREE LikePrefix (exact) and NGRAM StringContains (probe
+ recheck) probes when a covering index exists; without one they execute
as correct filtered scans. Standalone string-match filters on a scanned
variable are hoisted into the NodeScan's filter_expr so they can reach a
covering index; new instrumentation probes (pushed_filter_exprs /
in_memory_filters) pin the hoist structurally, since results alone
cannot distinguish it from a silent full-scan fallback. Mutation
predicates keep rejecting the new operators at parse time, with
defensive arms behind them.

The datafusion string_expressions feature supplies the two function
builders.
Three new surface guards cover the substrate behaviors the string
predicates and the upcoming index work rely on:

- starts_with on a BTREE'd column plans a scalar-index probe
  (LikePrefix) and treats _ / % in the needle as literal bytes, never
  LIKE wildcards.
- contains on an NGRAM'd column plans a scalar-index probe
  (StringContains) whose recheck keeps results exact, including needles
  below the trigram width.
- Lance's default index name is shared per column and replace removes
  by name, so an unnamed second-index build must replace or refuse —
  never coexist — while an explicitly-named second index of a different
  type coexists. Any second index on a column therefore requires an
  explicit distinct name.
A free-text (non-enum) String @index/@key column previously got only an
FTS inverted index, which Lance never consults for equality or prefix
filters — both fell back to full scans (the documented gap in
docs/user/search/indexes.md). The index planner now additionally plans
a BTREE on such columns, so =, range, and starts_with are
index-accelerated; existing graphs converge on their next
ensure_indices/optimize run.

The companion BTREE carries an explicit name ({column}_btree_idx),
threaded through IndexBuildSpec::BTree: Lance's default index name is
shared per column and replace removes by name, so an unnamed second
build would silently replace the FTS index (pinned by the index-naming
surface guard). Both indexes land in the same staged CreateIndex
transaction, so the table still advances exactly once and the recovery
protocol is unchanged. Index-count expectations in the search and
branching suites move to the new totals.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 549c04b. Configure here.

CompOp::StringContains
} else {
CompOp::Contains
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reversed contains uses wrong semantics

Medium Severity

The String overload of contains is keyed off any String-typed left operand (literal, query param, or property), but execution always applies left.contains(right). Queries such as $q contains $p.name or "needle" contains $p.name now typecheck and lower to StringContains, yet they test whether the constant or param contains each row’s property—not substring search on the column as documented.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 549c04b. Configure here.

Comment on lines 702 to +716
.push(filter.clone());
hoisted_indices.insert(i);
}
} else if is_string_match_filter(filter)
&& ir_filter_to_expr(filter, params, None).is_some()
{
if let IRExpr::PropAccess { variable, .. } = &filter.left {
if scan_vars.contains(variable.as_str()) {
hoisted_search_filters
.entry(variable.clone())
.or_default()
.push(filter.clone());
hoisted_indices.insert(i);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Cross-Variable Filter Hoisted Early

When a string predicate compares the scanned variable to another variable, such as a.name starts_with b.prefix, this branch hoists it into a's NodeScan and skips the original filter. The pushed expression drops the b variable and resolves prefix inside a's table, so callers can get incorrect rows or a scan-planning error instead of evaluating the predicate after both variables are bound.

Context Used: AGENTS.md (source)

Fix in Claude Code

Comment on lines +441 to +443
.get(v)
.is_some_and(|t| !t.list && matches!(t.scalar, ScalarType::String)),
_ => false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 String Alias Keeps List Semantics

contains is accepted by typecheck for any scalar String left expression, but lowering only recognizes properties, literals, and params as the String overload. If a String alias expression reaches this fallback, it stays as CompOp::Contains, so execution uses list-membership lowering on a scalar String and the query errors instead of doing a substring match.

Context Used: AGENTS.md (source)

Fix in Claude Code

@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: 549c04b003

ℹ️ 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 on lines +708 to +714
if let IRExpr::PropAccess { variable, .. } = &filter.left {
if scan_vars.contains(variable.as_str()) {
hoisted_search_filters
.entry(variable.clone())
.or_default()
.push(filter.clone());
hoisted_indices.insert(i);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid hoisting cross-variable string predicates

When a starts_with/String contains filter compares one scanned variable with another (for example, match { $a: Metric $b: Metric $b.label starts_with $a.label }), this branch hoists it into $b's NodeScan and removes the original Filter. The scan-level lowering drops variable names and uses bare column identifiers, so the predicate is evaluated against $b's table only (or errors if the other property is absent) instead of after the cross join, producing incorrect results for cross-variable filters. Only hoist predicates whose property operands all belong to the scanned variable or are literals/params.

Useful? React with 👍 / 👎.

Comment on lines +433 to +438
Expr::PropAccess { variable, property } => type_ctx
.bindings
.get(variable)
.and_then(|bv| catalog.node_types.get(&bv.type_name))
.and_then(|nt| nt.properties.get(property))
.is_some_and(|p| !p.list && matches!(p.scalar, ScalarType::String)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve contains using negation-local bindings

For contains filters inside not { ... } that reference a variable introduced only inside the negation, this lookup uses the outer TypeContext, which intentionally does not retain negation-local bindings from typecheck. A query such as not { $m tagged $t $t.tname contains "sal" } typechecks as String substring matching, but lowering leaves it as list Contains; execution then tries to evaluate a String column as a list and fails at runtime instead of applying the substring predicate. The overload resolution needs the current inner binding context when lowering nested clauses.

Useful? React with 👍 / 👎.

Comment on lines +692 to +695
work.push_spec(crate::storage_layer::IndexBuildSpec::BTree {
column: prop_name.clone(),
name: Some(format!("{prop_name}_btree_idx")),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Choose collision-proof companion BTREE names

The generated {prop_name}_btree_idx can collide with Lance's default {column}_idx for another indexed column, e.g. a schema with text: String @index and text_btree: I32 @index gives both BTREEs the name text_btree_idx. In one pass stage_create_indices rejects duplicate new names; across passes, Lance removes/replaces indexes by name, so the two indexes can keep deleting each other and ensure_indices never converges. Use a reserved/collision-checked naming scheme or include disambiguation that cannot be produced by a user column's default index name.

Useful? React with 👍 / 👎.

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.

1 participant