uni_xervo/traits/hybrid.rs
1//! Single-pass hybrid embedding: the [`HybridEmbeddingModel`] trait and its
2//! [`HeadSet`] / [`HybridEmbedResult`] types.
3//!
4//! Some exports — notably BGE-M3 (`aapot/bge-m3-onnx`) — fuse a dense head, a
5//! learned-sparse head, and a multi-vector (ColBERT) head into one graph. The
6//! per-task [`EmbeddingModel`](crate::traits::EmbeddingModel),
7//! [`SparseEmbeddingModel`](crate::traits::SparseEmbeddingModel), and
8//! [`MultiVectorEmbeddingModel`](crate::traits::MultiVectorEmbeddingModel)
9//! handles each load their own session and run their own forward pass, so
10//! serving all three means loading the weights three times and running the graph
11//! three times.
12//!
13//! This trait collapses that to one: a single shared encoder forward pass whose
14//! outputs are post-processed into every requested head. It exists only for
15//! graphs that actually expose multiple heads (declared by a hybrid preset);
16//! single-head models keep using the per-task handles. The caller picks which
17//! heads to materialize with a [`HeadSet`], mirroring how
18//! [`NlpTasks`](crate::traits::NlpTasks) selects NLP heads on a single pass.
19
20use crate::error::Result;
21use crate::traits::{ModelInfo, SparseVector, TokenUsage};
22use async_trait::async_trait;
23use bitflags::bitflags;
24
25bitflags! {
26 /// Selects which embedding heads a [`HybridEmbeddingModel`] should populate.
27 ///
28 /// A head is only post-processed when its flag is set *and* the model
29 /// exposes it (see [`HybridEmbeddingModel::available_heads`]); the
30 /// intersection drives which fields of [`HybridEmbedResult`] are `Some`.
31 /// Skipping a head skips its post-processing, not the (shared) forward pass.
32 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33 pub struct HeadSet: u32 {
34 /// Pooled dense vector (one per input).
35 const DENSE = 1 << 0;
36 /// Learned-sparse term weights (one [`SparseVector`] per input).
37 const SPARSE = 1 << 1;
38 /// Per-token multi-vector / ColBERT embeddings (ragged, per input).
39 const MULTI_VECTOR = 1 << 2;
40 /// Convenience: all currently-defined heads.
41 const ALL = Self::DENSE.bits() | Self::SPARSE.bits() | Self::MULTI_VECTOR.bits();
42 }
43}
44
45/// Dense, sparse, and multi-vector embeddings from a single forward pass.
46///
47/// Each field is `Some` iff its head was both **requested** (set in the `heads`
48/// argument to [`HybridEmbeddingModel::embed`]) and **available** (set in
49/// [`HybridEmbeddingModel::available_heads`]); otherwise it is `None`. When a
50/// requested-and-available head is computed over an empty input batch the field
51/// is `Some` of an empty `Vec`, never `None`.
52///
53/// `usage` follows the [`EmbedResult`](crate::traits::EmbedResult) convention:
54/// `Some` for remote providers that report token counts, `None` for local ones.
55#[derive(Debug, Clone, Default)]
56#[non_exhaustive]
57pub struct HybridEmbedResult {
58 /// Pooled dense vectors, one per input. `Some` iff the dense head was
59 /// requested and available.
60 pub dense: Option<Vec<Vec<f32>>>,
61 /// Learned-sparse vectors, one per input. `Some` iff the sparse head was
62 /// requested and available.
63 pub sparse: Option<Vec<SparseVector>>,
64 /// Per-token (multi-vector / ColBERT) embeddings, one ragged list per input.
65 /// `Some` iff the multi-vector head was requested and available.
66 pub multi_vector: Option<Vec<Vec<Vec<f32>>>>,
67 /// Token usage reported by the provider, if any.
68 pub usage: Option<TokenUsage>,
69}
70
71/// A model that produces several embedding heads from one shared forward pass.
72///
73/// Backed by multi-output exports such as BGE-M3 (`aapot/bge-m3-onnx`): one
74/// encoder run feeds the dense, sparse, and ColBERT post-processors, so a hybrid
75/// retrieval pipeline pays for one weight load and one forward pass instead of
76/// three. Select the heads to materialize with a [`HeadSet`].
77#[async_trait]
78pub trait HybridEmbeddingModel: ModelInfo {
79 /// Embed a batch of text strings, populating the heads in
80 /// `heads ∩ available_heads()` from a single forward pass.
81 ///
82 /// Heads outside that intersection are left `None` on the result; the others
83 /// are computed and returned in input order.
84 ///
85 /// # Errors
86 /// Returns an error if tokenization fails, the model session errors, or a
87 /// requested head's output cannot be read from the graph.
88 async fn embed(&self, texts: &[&str], heads: HeadSet) -> Result<HybridEmbedResult>;
89
90 /// The heads this model's graph actually exposes.
91 ///
92 /// Parallels [`NlpModel::supported_tasks`](crate::traits::NlpModel::supported_tasks):
93 /// requesting a head outside this set yields `None` for it rather than an error.
94 fn available_heads(&self) -> HeadSet;
95
96 /// Optional warmup hook (e.g. load weights into memory on first access).
97 /// The default is a no-op.
98 ///
99 /// # Errors
100 /// Returns an error if the underlying model fails to initialize.
101 async fn warmup(&self) -> Result<()> {
102 Ok(())
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use std::sync::Arc;
110
111 // Dyn-safety smoke check: fails to compile if the trait becomes non-object-safe.
112 #[test]
113 fn hybrid_embedding_model_is_dyn_safe() {
114 fn _accept(_: Arc<dyn HybridEmbeddingModel>) {}
115 }
116
117 #[test]
118 fn head_set_membership_and_composition() {
119 let ds = HeadSet::DENSE | HeadSet::SPARSE;
120 assert!(ds.contains(HeadSet::DENSE));
121 assert!(ds.contains(HeadSet::SPARSE));
122 assert!(!ds.contains(HeadSet::MULTI_VECTOR));
123 assert_eq!(
124 HeadSet::DENSE | HeadSet::SPARSE | HeadSet::MULTI_VECTOR,
125 HeadSet::ALL
126 );
127 }
128
129 #[test]
130 fn head_set_intersection_disjoint_and_empty() {
131 // Requested ⊄ available collapses to the available subset.
132 let requested = HeadSet::DENSE | HeadSet::MULTI_VECTOR;
133 let available = HeadSet::DENSE | HeadSet::SPARSE;
134 assert_eq!(requested.intersection(available), HeadSet::DENSE);
135
136 // Fully disjoint → empty; empty request → empty.
137 assert!(HeadSet::SPARSE.intersection(HeadSet::DENSE).is_empty());
138 assert!(HeadSet::empty().intersection(HeadSet::ALL).is_empty());
139 }
140}