diff --git a/changelog.d/nearest-alias-order-key.changed.md b/changelog.d/nearest-alias-order-key.changed.md new file mode 100644 index 000000000..ee8943fc1 --- /dev/null +++ b/changelog.d/nearest-alias-order-key.changed.md @@ -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 diff --git a/crates/omnigraph-compiler/src/ir/lower.rs b/crates/omnigraph-compiler/src/ir/lower.rs index 79cb181fa..a3d966b74 100644 --- a/crates/omnigraph-compiler/src/ir/lower.rs +++ b/crates/omnigraph-compiler/src/ir/lower.rs @@ -157,7 +157,8 @@ pub fn lower_query(catalog: &Catalog, query: &CheckedReadQuery) -> Result = query .orders .iter() - .map(|order| { + .enumerate() + .map(|(index, order)| { let expr = order .expr .meta_field_result_key() @@ -172,7 +173,23 @@ pub fn lower_query(catalog: &Catalog, query: &CheckedReadQuery) -> Result) -> 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 { let mut lowered = match expr { diff --git a/crates/omnigraph-compiler/src/query/codes.rs b/crates/omnigraph-compiler/src/query/codes.rs index 4e2096c0c..34d573fd0 100644 --- a/crates/omnigraph-compiler/src/query/codes.rs +++ b/crates/omnigraph-compiler/src/query/codes.rs @@ -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"; diff --git a/crates/omnigraph-compiler/src/query/typecheck.rs b/crates/omnigraph-compiler/src/query/typecheck.rs index 5e7934014..308f19280 100644 --- a/crates/omnigraph-compiler/src/query/typecheck.rs +++ b/crates/omnigraph-compiler/src/query/typecheck.rs @@ -886,17 +886,6 @@ fn typecheck_read_query(catalog: &Catalog, query: &QueryDecl) -> Result 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, @@ -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 { diff --git a/crates/omnigraph-compiler/src/query/typecheck_tests.rs b/crates/omnigraph-compiler/src/query/typecheck_tests.rs index 64f0dfb62..1e551cd0d 100644 --- a/crates/omnigraph-compiler/src/query/typecheck_tests.rs +++ b/crates/omnigraph-compiler/src/query/typecheck_tests.rs @@ -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( diff --git a/crates/omnigraph-gqt/cases/issue_640_search_score_projection.gqt b/crates/omnigraph-gqt/cases/issue_640_search_score_projection.gqt index 17cc590b5..677badbfa 100644 --- a/crates/omnigraph-gqt/cases/issue_640_search_score_projection.gqt +++ b/crates/omnigraph-gqt/cases/issue_640_search_score_projection.gqt @@ -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 '._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 @@ -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 } @@ -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 } @@ -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 } @@ -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 } diff --git a/crates/omnigraph-gqt/cases/nearest_alias_secondary_key.gqt b/crates/omnigraph-gqt/cases/nearest_alias_secondary_key.gqt new file mode 100644 index 000000000..d8406d646 --- /dev/null +++ b/crates/omnigraph-gqt/cases/nearest_alias_secondary_key.gqt @@ -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 diff --git a/crates/omnigraph-gqt/cases/rank_call_nested_in_a_boolean_projection.gqt b/crates/omnigraph-gqt/cases/rank_call_nested_in_a_boolean_projection.gqt index d9ad31348..0b261243e 100644 --- a/crates/omnigraph-gqt/cases/rank_call_nested_in_a_boolean_projection.gqt +++ b/crates/omnigraph-gqt/cases/rank_call_nested_in_a_boolean_projection.gqt @@ -1,6 +1,6 @@ # issue: none -# notes: A `bm25` or `nearest` call nested inside a Boolean projection reads the score column -# notes: the leading `order` retrieval computed, as the bare projected call does (T33). +# notes: Projected Boolean expressions over nearest, bm25 and rrf read the retrieval score column. +# notes: Expression and alias secondary keys agree and preserve the leading retrieval order. --- runner timeout_ms: 10000 @@ -13,11 +13,12 @@ node Doc { slug: String @key text: String @index embedding: Vector(2) + n: I64? } --- seed {"type":"Doc","data":{"slug":"a","text":"needle","embedding":[1.0,0.0]}} -{"type":"Doc","data":{"slug":"b","text":"needle filler","embedding":[0.0,1.0]}} +{"type":"Doc","data":{"slug":"b","text":"needle filler","embedding":[0.0,1.0],"n":10}} --- query set engine = v2; @@ -58,3 +59,158 @@ query nearest_threshold($v: Vector(2)) { --- expect shape d.slug: String close: Bool + +--- query +set engine = v2; +query nearest_threshold_expression($v: Vector(2)) { + match { $d: Doc } + return { $d.slug, nearest($d.embedding, $v) < 1.0 as close } + order { nearest($d.embedding, $v), (nearest($d.embedding, $v) < 1.0) asc } + limit 2 +} + +--- params +{"v":[1.0,0.0]} + +--- expect ordered +{"d.slug":"a","close":true} +{"d.slug":"b","close":false} + +--- expect shape +d.slug: String +close: Bool + +--- query +set engine = v2; +query nearest_threshold_alias($v: Vector(2)) { + match { $d: Doc } + return { $d.slug, nearest($d.embedding, $v) < 1.0 as close } + order { nearest($d.embedding, $v), close asc } + limit 2 +} + +--- params +{"v":[1.0,0.0]} + +--- expect ordered +{"d.slug":"a","close":true} +{"d.slug":"b","close":false} + +--- expect shape +d.slug: String +close: Bool + +--- query +set engine = v2; +query bm25_threshold_expression($q: String) { + match { $d: Doc } + return { $d.slug, bm25($d.text, $q) > 0.18 as hit } + order { bm25($d.text, $q) desc, (bm25($d.text, $q) > 0.18) asc } + limit 2 +} + +--- params +{"q":"needle"} + +--- expect ordered +{"d.slug":"a","hit":true} +{"d.slug":"b","hit":false} + +--- expect shape +d.slug: String +hit: Bool + +--- query +set engine = v2; +query bm25_threshold_alias($q: String) { + match { $d: Doc } + return { $d.slug, bm25($d.text, $q) > 0.18 as hit } + order { bm25($d.text, $q) desc, hit asc } + limit 2 +} + +--- params +{"q":"needle"} + +--- expect ordered +{"d.slug":"a","hit":true} +{"d.slug":"b","hit":false} + +--- expect shape +d.slug: String +hit: Bool + +--- query +set engine = v2; +query rrf_threshold_expression($v: Vector(2), $q: String) { + match { $d: Doc } + return { $d.slug, rrf(nearest($d.embedding, $v), bm25($d.text, $q)) > 0.0325 as high } + order { rrf(nearest($d.embedding, $v), bm25($d.text, $q)), (rrf(nearest($d.embedding, $v), bm25($d.text, $q)) > 0.0325) asc } + limit 2 +} + +--- params +{"v":[1.0,0.0],"q":"needle"} + +--- expect ordered +{"d.slug":"a","high":true} +{"d.slug":"b","high":false} + +--- expect shape +d.slug: String +high: Bool + +--- query +set engine = v2; +query rrf_threshold_alias($v: Vector(2), $q: String) { + match { $d: Doc } + return { $d.slug, rrf(nearest($d.embedding, $v), bm25($d.text, $q)) > 0.0325 as high } + order { rrf(nearest($d.embedding, $v), bm25($d.text, $q)), high asc } + limit 2 +} + +--- params +{"v":[1.0,0.0],"q":"needle"} + +--- expect ordered +{"d.slug":"a","high":true} +{"d.slug":"b","high":false} + +--- expect shape +d.slug: String +high: Bool + +--- query +set engine = v2; +query recursive_rank_keys($v: Vector(2)) { + match { $d: Doc } + return { + $d.slug, + not (nearest($d.embedding, $v) < 1.0) as far, + nearest($d.embedding, $v) is null as missing, + nearest($d.embedding, $v) in [0.0] as exact, + nearest($d.embedding, $v) < 1.0 and $d.n > 5 as eligible + } + order { + nearest($d.embedding, $v), + (not (nearest($d.embedding, $v) < 1.0)) desc, + (nearest($d.embedding, $v) is null) desc, + (nearest($d.embedding, $v) in [0.0]) asc, + (nearest($d.embedding, $v) < 1.0 and $d.n > 5) asc + } + limit 2 +} + +--- params +{"v":[1.0,0.0]} + +--- expect ordered +{"d.slug":"a","far":false,"missing":false,"exact":true} +{"d.slug":"b","far":true,"missing":false,"exact":false,"eligible":false} + +--- expect shape +d.slug: String +far: Bool +missing: Bool +exact: Bool +eligible: Bool?