Skip to content

Commit 969c9d2

Browse files
committed
fix(sql): resolve surrogate ids to user PKs for body-less subquery hits
Sparse, multi-vector, and hybrid (RRF) search hits carry no document body, so `id`/`doc_id` in a gathered row was left as the raw internal surrogate instead of the user primary key. Outer ORDER BY / DISTINCT / projection over such subqueries therefore sorted and returned the wrong values. Classify the PostProcess child's row shape (vector-family vs. hybrid vs. flat) and resolve surrogates against the catalog during flatten, mirroring the existing Control-Plane response translator.
1 parent 75e1edc commit 969c9d2

5 files changed

Lines changed: 337 additions & 62 deletions

File tree

nodedb/src/control/server/exchange/resolve/exchange.rs

Lines changed: 109 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ use crate::control::server::exchange::gather::{
2727
};
2828
use crate::control::server::result_stream::ResultStream;
2929

30+
use crate::control::server::response_translate::vector::resolve_surrogate_pk;
31+
3032
use super::capture::DistributedReadCapture;
3133
use super::join_input::{gather_join_build_side, resolve_join_input};
3234
use super::materialize::materialize_providers;
@@ -483,23 +485,29 @@ async fn resolve_exchange(
483485
Resolved::Stream(s) => return Ok(Resolved::Stream(s)),
484486
};
485487

486-
// A vector search emits hit rows (`{id, distance, doc_id, body}`)
487-
// rather than flat storage rows; they need the hit-specific flatten
488-
// (merge `body` columns, surface `distance` / `_surrogate`) so the
489-
// tail can sort / distinct / project by any document column.
490-
let is_vector_hits = matches!(
491-
&child,
492-
PhysicalPlan::Vector(
493-
nodedb_physical::physical_plan::VectorOp::Search { .. }
494-
| nodedb_physical::physical_plan::VectorOp::MultiSearch { .. }
495-
)
496-
);
488+
// Classify the body's row shape so the gathered payload is
489+
// flattened correctly:
490+
// - `Vector` → vector/sparse/multivec hits (`{id, distance,
491+
// doc_id, body}`): merge the document `body` to top-level and
492+
// resolve the surrogate to the user PK.
493+
// - `Hybrid` → RRF fusion hits (`{doc_id: hex, <score alias>}`,
494+
// no body): resolve `doc_id` to the user PK as `id`.
495+
// - `None` → flat storage rows (document / text `{id, data}`,
496+
// columnar, spatial) or computed rows: the ordinary storage
497+
// flatten already exposes every column.
498+
// `collection` and `hit_kind` are captured before the gather
499+
// consumes `child`.
500+
let hit_kind = classify_hit_shape(&child);
501+
// Extract the collection from the hit op directly: `collection()`
502+
// has no arm for sparse / multi-vector search, so it would yield
503+
// `None` and the PK resolver would be handed an empty collection.
504+
let hit_collection = hit_collection_name(&child);
497505

498506
// Record the child's single base collection in the in-transaction
499507
// read-set at its own observed read-version (mirrors the root
500508
// Gather arm). Autocommit reads skip the catalog lookup.
501509
let probe_collection: Option<String> = if txn_id.is_some() {
502-
child.collection().map(str::to_owned)
510+
hit_collection.clone()
503511
} else {
504512
None
505513
};
@@ -523,15 +531,42 @@ async fn resolve_exchange(
523531
outcome.merged_array
524532
};
525533

526-
// Flatten to the flat relational row shape the `ProviderScan` tail
527-
// consumes: vector hits merge their document `body`; storage
528-
// `{id, data}` rows unwrap their `data`; computed rows pass through.
529-
let rows = if is_vector_hits {
530-
crate::data::executor::response_codec::flatten_vector_hits_to_relational_rows(
531-
&merged,
532-
)
533-
} else {
534-
crate::data::executor::response_codec::flatten_to_relational_rows(&merged)
534+
// Flatten to the bare relational row shape the `ProviderScan` tail
535+
// consumes, resolving surrogate→PK for hit-shaped bodies via the
536+
// catalog so `SELECT id` returns the user PK, not the surrogate.
537+
use crate::data::executor::response_codec::{
538+
flatten_hybrid_hits_to_relational_rows, flatten_to_relational_rows,
539+
flatten_vector_hits_to_relational_rows,
540+
};
541+
let coll = hit_collection.unwrap_or_default();
542+
let rows = match hit_kind {
543+
HitShape::Vector => flatten_vector_hits_to_relational_rows(&merged, |surrogate| {
544+
resolve_surrogate_pk(
545+
state,
546+
database_id,
547+
tenant_id,
548+
&coll,
549+
nodedb_types::Surrogate::new(surrogate),
550+
)
551+
}),
552+
HitShape::Hybrid => {
553+
flatten_hybrid_hits_to_relational_rows(&merged, |hex| {
554+
// `__local_<id>` is the headless-vector-leg sentinel; it
555+
// is not a real surrogate and must not be parsed as hex.
556+
if hex.starts_with("__local_") {
557+
return None;
558+
}
559+
let surrogate = u32::from_str_radix(hex, 16).ok()?;
560+
resolve_surrogate_pk(
561+
state,
562+
database_id,
563+
tenant_id,
564+
&coll,
565+
nodedb_types::Surrogate::new(surrogate),
566+
)
567+
})
568+
}
569+
HitShape::None => flatten_to_relational_rows(&merged),
535570
};
536571
Ok(Resolved::Plan(PhysicalPlan::Query(QueryOp::ProviderScan {
537572
provider: None,
@@ -549,3 +584,56 @@ async fn resolve_exchange(
549584
other => Ok(Resolved::Plan(other)),
550585
}
551586
}
587+
588+
/// The row shape a `PostProcess` body produces, driving how its gathered
589+
/// payload is flattened into bare relational rows.
590+
enum HitShape {
591+
/// Vector / sparse / multi-vector hits: `{id: <surrogate>, distance,
592+
/// doc_id?, body?}`. Merge the document `body` to top-level and resolve
593+
/// the surrogate to the user PK.
594+
Vector,
595+
/// Hybrid (RRF) fusion hits: `{doc_id: <surrogate hex>, <score alias>,
596+
/// ...}` with no body. Resolve `doc_id` to the user PK as `id`.
597+
Hybrid,
598+
/// Flat storage rows (`{id, data}` document / text, columnar, spatial) or
599+
/// computed rows — already fully columned after the storage flatten.
600+
None,
601+
}
602+
603+
/// Classify a resolved `PostProcess` child by the row shape its engine emits.
604+
fn classify_hit_shape(plan: &PhysicalPlan) -> HitShape {
605+
use nodedb_physical::physical_plan::{TextOp, VectorOp};
606+
match plan {
607+
PhysicalPlan::Vector(
608+
VectorOp::Search { .. }
609+
| VectorOp::MultiSearch { .. }
610+
| VectorOp::SparseSearch { .. }
611+
| VectorOp::MultiVectorScoreSearch { .. },
612+
) => HitShape::Vector,
613+
PhysicalPlan::Text(TextOp::HybridSearch { .. } | TextOp::HybridSearchTriple { .. }) => {
614+
HitShape::Hybrid
615+
}
616+
_ => HitShape::None,
617+
}
618+
}
619+
620+
/// Collection a `PostProcess` child reads, for the surrogate→PK resolver.
621+
///
622+
/// The search ops that emit surrogate-keyed hits carry their collection in a
623+
/// field `PhysicalPlan::collection` does not surface (sparse / multi-vector),
624+
/// so match them explicitly; every other body defers to `collection()`.
625+
fn hit_collection_name(plan: &PhysicalPlan) -> Option<String> {
626+
use nodedb_physical::physical_plan::{TextOp, VectorOp};
627+
match plan {
628+
PhysicalPlan::Vector(
629+
VectorOp::Search { collection, .. }
630+
| VectorOp::MultiSearch { collection, .. }
631+
| VectorOp::SparseSearch { collection, .. }
632+
| VectorOp::MultiVectorScoreSearch { collection, .. },
633+
)
634+
| PhysicalPlan::Text(
635+
TextOp::HybridSearch { collection, .. } | TextOp::HybridSearchTriple { collection, .. },
636+
) => Some(collection.clone()),
637+
other => other.collection().map(str::to_owned),
638+
}
639+
}

nodedb/src/control/server/response_translate/vector.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ fn apply_rls_filter(hits: &mut Vec<Hit>, rls_filters: &[u8], top_k: usize) {
7676
/// catalog has no PK mapping for the surrogate (headless row, or a document
7777
/// that was never written) — callers must leave the row's identifier
7878
/// untouched in that case rather than fabricate a value.
79-
pub(super) fn resolve_surrogate_pk(
79+
pub(crate) fn resolve_surrogate_pk(
8080
state: &SharedState,
8181
database_id: DatabaseId,
8282
tenant_id: TenantId,

nodedb/src/data/executor/response_codec/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,6 @@ pub(in crate::data::executor) use hits::{
4242
};
4343
pub(crate) use raw::{decode_raw_scan_to_docs, encode_raw_document_rows};
4444
pub use raw::{
45-
encode_binary_rows, flatten_to_relational_rows, flatten_vector_hits_to_relational_rows,
45+
encode_binary_rows, flatten_hybrid_hits_to_relational_rows, flatten_to_relational_rows,
46+
flatten_vector_hits_to_relational_rows,
4647
};

0 commit comments

Comments
 (0)