Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
package org.opensearch.sql.calcite.utils;

import static java.util.Objects.requireNonNull;
import static org.opensearch.sql.calcite.utils.UserDefinedFunctionUtils.findRelevanceUsage;
import static org.opensearch.sql.monitor.profile.MetricName.OPTIMIZE;

import com.google.common.collect.ImmutableList;
Expand All @@ -37,6 +38,7 @@
import java.sql.SQLException;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.function.Consumer;
import org.apache.calcite.adapter.enumerable.EnumerableConvention;
Expand Down Expand Up @@ -106,6 +108,7 @@
import org.opensearch.sql.calcite.plan.rule.OpenSearchRules;
import org.opensearch.sql.calcite.plan.rule.PPLSimplifyDedupRule;
import org.opensearch.sql.calcite.profile.PlanProfileBuilder;
import org.opensearch.sql.calcite.utils.UserDefinedFunctionUtils.RelevanceUsage;
import org.opensearch.sql.common.error.ErrorCode;
import org.opensearch.sql.common.error.ErrorReport;
import org.opensearch.sql.expression.function.PPLBuiltinOperators;
Expand Down Expand Up @@ -487,6 +490,51 @@ private static String rootCauseMessage(Throwable e) {
return rc != null ? rc : e.getMessage();
}

/**
* Reports a relevance function that survived push down in terms the user can act on.
*
* <p>Relevance functions have no executable implementation; reaching code generation means the
* planner could not rewrite the call into an OpenSearch query. The generated expression only
* carries positional references by then, so the column is resolved from the pre-compilation
* plan to name it as the user wrote it.
*/
private static void enrichRelevanceNotPushedDown(
ErrorReport.Builder report, SQLException e, RelNode rel) {
String rootCause = rootCauseMessage(e);
if (rootCause == null || !rootCause.contains("Relevance search function")) {
return;
}
RelevanceUsage usage = findRelevanceUsage(rel);
if (usage == null) {
return;
}
String target =
usage.columnName() == null
? "the column it is applied to"
: String.format(Locale.ROOT, "column [%s]", usage.columnName());
report
.details(
String.format(
Locale.ROOT,
"Relevance search function [%s] cannot be applied to %s. It must run against a"
+ " field indexed by OpenSearch, and cannot be evaluated on a value the query"
+ " computes (eval, parse, rex), on aggregated output (stats, top, rare), or"
+ " after a row limit (head). Move the %s filter so it directly follows"
+ " `source=` and targets an indexed field, or use a non-relevance predicate"
+ " such as `like` which can be evaluated per row.",
usage.functionName(),
target,
usage.functionName()))
.code(ErrorCode.UNSUPPORTED_OPERATION)
.context("relevance_function", usage.functionName())
.context("relevance_column", String.valueOf(usage.columnName()))
.suggestion(
String.format(
Locale.ROOT,
"apply %s directly to an indexed field before any command that transforms rows",
usage.functionName()));
}

private static void enrichErrorsForSpecialCases(ErrorReport.Builder report, SQLException e) {
if (e.getMessage().contains("Error while preparing plan [") && e.getCause() != null) {
// Generic 'something went wrong' planning error, try to get the cause
Expand Down Expand Up @@ -546,6 +594,7 @@ public RelNode visit(TableScan scan) {
.location("while compiling the optimized query plan for physical execution")
.code(ErrorCode.PLANNING_ERROR);
enrichErrorsForSpecialCases(report, e);
enrichRelevanceNotPushedDown(report, e, rel);
throw report.build();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import javax.annotation.Nullable;
Expand All @@ -26,9 +27,13 @@
import org.apache.calcite.adapter.enumerable.RexToLixTranslator;
import org.apache.calcite.linq4j.tree.Expression;
import org.apache.calcite.linq4j.tree.Expressions;
import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.type.RelDataType;
import org.apache.calcite.rex.RexCall;
import org.apache.calcite.rex.RexInputRef;
import org.apache.calcite.rex.RexNode;
import org.apache.calcite.rex.RexShuttle;
import org.apache.calcite.rex.RexVisitorImpl;
import org.apache.calcite.schema.impl.AggregateFunctionImpl;
import org.apache.calcite.sql.SqlAggFunction;
import org.apache.calcite.sql.SqlIdentifier;
Expand Down Expand Up @@ -81,6 +86,162 @@ public class UserDefinedFunctionUtils {
ImmutableSet.of("simple_query_string", "query_string", "multi_match");
public static String IP_FUNCTION_NAME = "IP";

/** Returns true if the given operator name is a relevance search query function. */
public static boolean isRelevanceFunction(String operatorName) {
String lowerCased = operatorName.toLowerCase(Locale.ROOT);
return SINGLE_FIELD_RELEVANCE_FUNCTION_SET.contains(lowerCased)
|| MULTI_FIELDS_RELEVANCE_FUNCTION_SET.contains(lowerCased);
}

/**
* Checks whether a {@link RexNode} tree contains a relevance search query function such as {@code
* match} or {@code query_string}.
*
* <p>Relevance functions have no executable implementation: they exist only as markers to be
* rewritten into an OpenSearch query during push down (see {@code RelevanceQueryFunction}).
* Callers must use this to avoid handing such a node to any execution path that would try to
* generate code for it -- notably script push down, where the failure would surface as a shard
* side "Failed to compile inline script" error instead of a local planning error.
*
* @param node the node to inspect
* @return true if the tree contains a relevance function
*/
public static boolean containsRelevanceFunction(RexNode node) {
RelevanceFunctionFinder finder = new RelevanceFunctionFinder();
node.accept(finder);
return finder.found;
}

/** Visitor that detects relevance functions anywhere in a {@link RexNode} tree. */
private static class RelevanceFunctionFinder extends RexVisitorImpl<Void> {
private boolean found = false;

RelevanceFunctionFinder() {
super(true);
}

@Override
public Void visitCall(RexCall call) {
if (isRelevanceFunction(call.getOperator().getName())) {
found = true;
return null; // stop descending once found
}
return super.visitCall(call);
}
}

/**
* A relevance function found in a plan, together with the column it targets as the user wrote it.
*/
public record RelevanceUsage(String functionName, String columnName) {}

/**
* Finds the first relevance function left in a plan and resolves the column it targets back to
* its user-facing name.
*
* <p>Used to report a relevance function that survived push down. By the time code generation
* fails, the expression only carries positional references such as {@code $t4}; resolving them
* against the owning operator's input row type recovers the name the user actually wrote (for
* example {@code b2} for {@code eval b2 = upper(body)}).
*
* @param root the plan to inspect
* @return the first usage found, or null if the plan contains no relevance function
*/
public static RelevanceUsage findRelevanceUsage(RelNode root) {
if (root == null) {
return null;
}
for (RelNode input : root.getInputs()) {
RelevanceUsage fromInput = findRelevanceUsage(input);
if (fromInput != null) {
return fromInput;
}
}
List<String> inputFieldNames = new ArrayList<>();
for (RelNode input : root.getInputs()) {
inputFieldNames.addAll(input.getRowType().getFieldNames());
}
RelevanceUsageFinder finder = new RelevanceUsageFinder(inputFieldNames);
root.accept(finder);
return finder.usage;
}

/**
* Locates a relevance call and resolves the field operand against the supplied input field names.
* Relevance calls are shaped as {@code match(MAP('field', <expr>), MAP('query', <literal>),
* ...)}, so the column lives inside the first map argument.
*/
private static class RelevanceUsageFinder extends RexShuttle {
private final List<String> inputFieldNames;
private RelevanceUsage usage;

RelevanceUsageFinder(List<String> inputFieldNames) {
this.inputFieldNames = inputFieldNames;
}

@Override
public RexNode visitCall(RexCall call) {
if (usage == null && isRelevanceFunction(call.getOperator().getName())) {
usage =
new RelevanceUsage(
call.getOperator().getName().toLowerCase(Locale.ROOT), resolveColumnName(call));
return call;
}
return super.visitCall(call);
}

private String resolveColumnName(RexCall relevanceCall) {
if (relevanceCall.getOperands().isEmpty()) {
return null;
}
ColumnRefCollector collector = new ColumnRefCollector();
relevanceCall.getOperands().get(0).accept(collector);
if (collector.index == null) {
return null;
}
return collector.index < inputFieldNames.size() ? inputFieldNames.get(collector.index) : null;
}
}

/** Grabs the first input reference index inside an expression. */
private static class ColumnRefCollector extends RexShuttle {
private Integer index;

@Override
public RexNode visitInputRef(RexInputRef inputRef) {
if (index == null) {
index = inputRef.getIndex();
}
return inputRef;
}
}

/**
* Returns the name of the first relevance function found in the tree, or null if there is none.
*/
public static String findRelevanceFunctionName(RexNode node) {
RelevanceFunctionNameFinder finder = new RelevanceFunctionNameFinder();
node.accept(finder);
return finder.name;
}

private static class RelevanceFunctionNameFinder extends RexVisitorImpl<Void> {
private String name = null;

RelevanceFunctionNameFinder() {
super(true);
}

@Override
public Void visitCall(RexCall call) {
if (name == null && isRelevanceFunction(call.getOperator().getName())) {
name = call.getOperator().getName().toLowerCase(Locale.ROOT);
return null;
}
return super.visitCall(call);
}
}

/**
* Creates a SqlUserDefinedAggFunction that wraps a Java class implementing an aggregate function.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import com.google.common.collect.ImmutableList;
import java.util.List;
import java.util.Locale;
import org.apache.calcite.adapter.enumerable.NotNullImplementor;
import org.apache.calcite.adapter.enumerable.NullPolicy;
import org.apache.calcite.adapter.enumerable.RexToLixTranslator;
Expand Down Expand Up @@ -94,8 +95,17 @@ public static class RelevanceQueryImplementor implements NotNullImplementor {
@Override
public Expression implement(
RexToLixTranslator translator, RexCall call, List<Expression> translatedOperands) {
// Reaching code generation means the call was not rewritten into an OpenSearch query. There
// is no row-by-row implementation to fall back to, so report why rather than just that.
throw new UnsupportedOperationException(
"Relevance search query functions are only supported when they are pushed down");
String.format(
Locale.ROOT,
"Relevance search function [%s] could not be pushed down to OpenSearch, and it has no"
+ " other execution path. Apply it directly to an indexed field before any"
+ " command that transforms rows (eval, parse, rex, stats, top, rare, sort, head,"
+ " lookup), and not to a column computed by the query itself. Expression: %s",
call.getOperator().getName().toLowerCase(Locale.ROOT),
call));
}
}
}
85 changes: 85 additions & 0 deletions docs/user/ppl/functions/relevance.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,91 @@ Relevance-based functions enable users to search an index for documents based on

You can use these functions for global query filtering, such as in condition expressions within `WHERE` or `HAVING` clauses. For more details about relevance-based search, see [Relevance Based Search With SQL/PPL Query Engine](https://github.com/opensearch-project/sql/issues/182).

## Usage constraints

Relevance functions are executed by the OpenSearch search engine, not by the plugin. A relevance function matches against the inverted index that OpenSearch builds when a document is written, so it can only be applied to a field that exists in the index mapping, and only at a point in the pipeline where the query can still be handed to the search engine.

In practice this means a relevance function must be applied **directly to an indexed field, before any command that transforms rows**. Place it immediately after `source=`:

```
source=logs | where match(body, 'ERROR') | eval level = upper(body) | fields level
```

The following are not supported, because there is no indexed field left to search against:

| Pattern | Why it cannot work |
|---|---|
| `... \| eval b = upper(body) \| where match(b, 'ERROR')` | `b` is computed while the query runs; it was never indexed. The same applies to columns produced by `parse` and `rex`. |
| `... \| top 1 body \| where match(body, 'ERROR')`<br>`... \| rare 1 body \| where match(body, 'ERROR')` | These commands produce summary rows after the search phase completes; there are no documents left to search. |
| `... \| sort body \| head 1 \| where match(body, 'ERROR')` | Once a sort and a row limit have been applied, the filter can no longer be handed to the search engine. |
| `... \| eval m = match(body, 'ERROR')` | A relevance function is a search condition, not a per-row value, so it cannot be assigned to a column. |

A query in this category fails at planning time, before any data is read, with an error naming the function and the column:

```
Relevance search function [match] cannot be applied to column [b]. It must run against a
field indexed by OpenSearch, and cannot be evaluated on a value the query computes (eval,
parse, rex), on aggregated output (stats, top, rare), or after a row limit (head).
```

A relevance filter placed after a `stats` aggregation on the grouping key is supported, because the filter can be applied to the underlying documents before grouping:

```
source=logs | stats count() by body | where match(body, 'ERROR')
```

If you need to filter on a computed column, use a predicate that can be evaluated per row, such as `like`:

```
source=logs | eval b = upper(body) | where like(b, '%ERROR%')
```

Note that `like` performs substring matching rather than analyzed text matching, so it does not apply tokenization, stemming, or relevance scoring.

## Matching behavior by field type

A relevance function matches against the terms OpenSearch produced when the document was indexed, so the field's mapping type determines what it matches. The same query can behave very differently on `text` and `keyword` fields.

Given a document whose `body` value is `ERROR something bad happened`:

| `body` mapping | `match(body, 'ERROR')` | Why |
|---|---|---|
| `text` | matches | The value is analyzed into terms (`error`, `something`, `bad`, `happened`), and `ERROR` matches one of them. |
| `text` with a `keyword` subfield | matches | `match` uses the analyzed `text` form. |
| `keyword` | **does not match** | A `keyword` field is indexed as one whole term. The single term is the full string `ERROR something bad happened`, which is not equal to `ERROR`. |

This applies to `match_phrase` as well, and is the most common reason a relevance query returns zero results without an error. It is correct Lucene behavior, not a failure.

On a `keyword` field, a relevance function only matches when the query value equals the entire field value:

```
source=logs | where match(body, 'ERROR something bad happened')
```

To search within a `keyword` field, use a predicate that does not depend on analysis:

| Goal | Use |
|---|---|
| Exact whole-value match | `where body = 'ERROR something bad happened'` |
| Substring match | `where like(body, '%ERROR%')` |
| Word or phrase matching | Map the field as `text`, or add a `text` subfield, then use `match` |

Note that a `keyword` subfield cannot be referenced directly in PPL — `match(body.keyword, 'ERROR')` fails with `Field [body.keyword] not found`. For a `text` field with a `keyword` subfield, the engine selects the appropriate form automatically: `match` uses the analyzed `text` form, and `like` uses the `keyword` subfield.

### Known issue: relevance filters after `head`

Avoid placing a relevance filter directly after `head`:

```
source=logs | head 100 | where match(body, 'ERROR')
```

A search request applies its query before limiting results, so this is evaluated as "search the whole index, then take 100 matches" rather than "take 100 rows, then search them". The query returns results without an error, but they are drawn from the entire index rather than from the intended sample. Apply the relevance filter before `head` instead:

```
source=logs | where match(body, 'ERROR') | head 100
```

## MATCH

**Usage**: `MATCH(<field_expression>, <query_expression>[, <option>=<option_value>]*)`
Expand Down
Loading
Loading