Skip to content
Merged
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
11 changes: 11 additions & 0 deletions changelog.d/nearest-alias-order-key.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
- A return alias can follow the leading `nearest(...)` order key as a
secondary sort, as in `order { nearest($d.embedding, $v), name desc }`, the
way it already could after `bm25(...)` and `rrf(...)`. The compile refusal
`T18` ("alias-based ordering is not supported together with nearest") is
withdrawn from the diagnostic catalog; its number is not reused. See
[return, order, and limit][nearest-alias-order-guide].
- A returned expression over `nearest(...)`, `bm25(...)`, or
`rrf(...)` can also be repeated directly as a secondary order key, without
failing with "the planned sort key is not a property or an alias".

[nearest-alias-order-guide]: ../docs/user/queries/index.md
25 changes: 21 additions & 4 deletions crates/omnigraph-compiler/src/ir/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ pub fn lower_query(catalog: &Catalog, query: &CheckedReadQuery) -> Result<QueryI
let order_by: Vec<IROrdering> = query
.orders
.iter()
.map(|order| {
.enumerate()
.map(|(index, order)| {
let expr = order
.expr
.meta_field_result_key()
Expand All @@ -172,7 +173,23 @@ pub fn lower_query(catalog: &Catalog, query: &CheckedReadQuery) -> Result<QueryI
.clone();
IRExpr::AliasRef(name, ty)
})
.map_or_else(|| lower_expr(&order.expr, &ctx), Ok)?;
.map_or_else(
|| {
if index == 0
&& matches!(
&order.expr,
TypedExpr::Nearest { .. }
| TypedExpr::Bm25 { .. }
| TypedExpr::Rrf { .. }
)
{
lower_expr(&order.expr, &ctx)
} else {
lower_projection(&order.expr, &ctx)
}
},
Ok,
)?;
Ok(IROrdering {
expr,
descending: order.descending,
Expand Down Expand Up @@ -869,8 +886,8 @@ fn outer_var_in_expr(expr: &TypedExpr, outer_bound: &HashSet<String>) -> Option<
}
}

/// A projected rank expression, at the root or inside the Boolean structure
/// (`bm25($p.name, "x") > 0.0 as hit`), lowers to the score column the
/// A rank expression in a projection or secondary order key, at the root or
/// inside Boolean structure (`bm25($p.name, "x") > 0.0 as hit`), lowers to the score column the
/// retrieval appends (`TypedExpr::score_column`); T33 required `order` to execute it.
fn lower_projection(expr: &TypedExpr, ctx: &LowerCtx<'_>) -> Result<IRExpr> {
let mut lowered = match expr {
Expand Down
1 change: 0 additions & 1 deletion crates/omnigraph-compiler/src/query/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ codes! {
T15 = "traversal hop bounds or nearest operands are invalid";
T16 = "update is not supported for edge types";
T17 = "nearest ordering requires a limit";
T18 = "alias ordering cannot be combined with nearest";
T19 = "search or fuzzy operands are invalid";
T20 = "match_text or bm25 operands are invalid";
T21 = "rrf operands are invalid or its ordering lacks a limit";
Expand Down
14 changes: 1 addition & 13 deletions crates/omnigraph-compiler/src/query/typecheck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -886,17 +886,6 @@ fn typecheck_read_query(catalog: &Catalog, query: &QueryDecl) -> Result<CheckedR
"nearest ordering requires a limit clause".to_string(),
));
}
if has_standalone_nearest
&& query
.order_clause
.iter()
.any(|ord| matches!(ord.expr, Expr::AliasRef(_)))
{
return Err(CompilerError::typed(
T18,
"alias-based ordering is not supported together with nearest in phase 1".to_string(),
));
}

// T9: If any return expression is an aggregate, non-aggregate expressions
// must be valid group-by keys (PropAccess or Variable).
Expand Down Expand Up @@ -3986,7 +3975,7 @@ fn expr_references_any(expr: &Expr, vars: &[String]) -> bool {

/// T33 admits a projected rank expression only as a repeat of the leading
/// `order` key, so an alias never leads the order and the walk stops at
/// `AliasRef` (T18 refuses an alias key beside `nearest`).
/// `AliasRef`; a `nearest` nested under `rrf` is T21's.
fn expr_contains_standalone_nearest(expr: &Expr) -> bool {
match expr {
Expr::Nearest { .. } => true,
Expand All @@ -4007,7 +3996,6 @@ fn expr_contains_standalone_nearest(expr: &Expr) -> bool {
.as_deref()
.is_some_and(expr_contains_standalone_nearest)
}
// nearest() nested under rrf() is handled by T21 and should not trigger T17/T18 checks.
Expr::Rrf { .. } => false,
Expr::Binary { left, right, .. }
| Expr::In {
Expand Down
23 changes: 0 additions & 23 deletions crates/omnigraph-compiler/src/query/typecheck_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -914,29 +914,6 @@ limit 5
assert!(ctx.scope.bindings.contains_key("d"));
}

#[test]
fn test_standalone_nearest_with_alias_ordering_still_rejected() {
let catalog = setup_vector();
let qf = parse_query(
r#"
query q($vq: Vector(3)) {
match { $d: Doc }
return {
$d.id_str as score
}
order {
nearest($d.embedding, $vq),
score desc
}
limit 5
}
"#,
)
.unwrap();
let err = typecheck_query(&catalog, qf.single_decl()).unwrap_err();
assert!(err.to_string().contains("T18"));
}

#[test]
fn test_rrf_rejects_non_rank_expression_argument() {
let parse = parse_query(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# red_on: 2026-09-07, score-projection working tree based on upstream ad425c68: steps 1-4 refused with T26 (on that upstream commit: `unsupported projection expression: Nearest { … }` at execution); the refusal steps carried T26 instead of their own codes; the control step green throughout; the no-match step then stayed red at execution (`column '<var>._distance' not found`) until `execute_node_scan`'s zero-row fallback carried the score columns.
# notes: a projected `nearest`/`bm25` reads the score column the retrieval appended (`{var}._distance` /
# notes: `{var}._score`) and must repeat the leading `order` key (T33); `_distance` is the squared L2 distance.
# notes: Repeated raw rank secondary keys use the same projected score, with aliased and default names.
# notes: the bm25 values pin Lance 11's BM25 (k1 1.2, b 0.75, default tokenizer) on this corpus; a Lance bump that changes scoring reds that step by design.

--- runner
Expand Down Expand Up @@ -30,7 +31,7 @@ edge Knows: Person -> Person {
query nearest_projected($v: Vector(2)) {
match { $p: Person }
return { $p.name, nearest($p.embedding, $v) as score }
order { nearest($p.embedding, $v) }
order { nearest($p.embedding, $v), nearest($p.embedding, $v) }
limit 2
}

Expand All @@ -49,7 +50,7 @@ score: F32
query nearest_projected_default_name($v: Vector(2)) {
match { $p: Person }
return { $p.name, nearest($p.embedding, $v) }
order { nearest($p.embedding, $v) }
order { nearest($p.embedding, $v), nearest($p.embedding, $v) }
limit 1
}

Expand All @@ -67,7 +68,7 @@ p._distance: F32
query bm25_projected($q: String) {
match { $p: Person }
return { $p.name, bm25($p.bio, $q) }
order { bm25($p.bio, $q) desc }
order { bm25($p.bio, $q) desc, bm25($p.bio, $q) desc }
limit 3
}

Expand Down Expand Up @@ -157,7 +158,7 @@ query nearest_projected_under_aggregate($v: Vector(2)) {
query rrf_projected($q: String, $v: Vector(2)) {
match { $p: Person }
return { $p.name, rrf(nearest($p.embedding, $v), bm25($p.bio, $q)) as fused }
order { rrf(nearest($p.embedding, $v), bm25($p.bio, $q)) }
order { rrf(nearest($p.embedding, $v), bm25($p.bio, $q)), rrf(nearest($p.embedding, $v), bm25($p.bio, $q)) }
limit 3
}

Expand Down
47 changes: 47 additions & 0 deletions crates/omnigraph-gqt/cases/nearest_alias_secondary_key.gqt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# issue: none
# red_on: d6604a19
# notes: A return alias is a secondary order key after the leading `nearest`,
# notes: as it already was after `bm25` and `rrf`. Two docs at distance 1 are
# notes: ordered by `name desc`, the reverse of their id tie-break order, so a
# notes: sort that ignores the alias cannot pass. Replaces the Rust test
# notes: test_standalone_nearest_with_alias_ordering_still_rejected (T18, withdrawn).

--- runner
timeout_ms: 10000
environments:
- target: omnigraph-engine
storage: local-filesystem

--- schema
node Doc {
slug: String @key
name: String @index
embedding: Vector(4) @index
}

--- seed
{"type":"Doc","data":{"slug":"d000","name":"zed","embedding":[0.0,0.0,0.0,0.0]}}
{"type":"Doc","data":{"slug":"d001","name":"amy","embedding":[1.0,0.0,0.0,0.0]}}
{"type":"Doc","data":{"slug":"d002","name":"bob","embedding":[-1.0,0.0,0.0,0.0]}}
{"type":"Doc","data":{"slug":"d003","name":"cat","embedding":[2.0,0.0,0.0,0.0]}}

--- query
query nearest_then_alias($q: Vector(4)) {
match { $d: Doc }
return { $d.slug, $d.name as name }
order { nearest($d.embedding, $q), name desc }
limit 10
}

--- params
{"q": [0.0, 0.0, 0.0, 0.0]}

--- expect ordered
{"d.slug":"d000","name":"zed"}
{"d.slug":"d002","name":"bob"}
{"d.slug":"d001","name":"amy"}
{"d.slug":"d003","name":"cat"}

--- expect shape
d.slug: String
name: String
Loading
Loading