@@ -27,6 +27,8 @@ use crate::control::server::exchange::gather::{
2727} ;
2828use crate :: control:: server:: result_stream:: ResultStream ;
2929
30+ use crate :: control:: server:: response_translate:: vector:: resolve_surrogate_pk;
31+
3032use super :: capture:: DistributedReadCapture ;
3133use super :: join_input:: { gather_join_build_side, resolve_join_input} ;
3234use 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+ }
0 commit comments