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 /// The width of a single head, if this model knows it.
97 ///
98 /// What "width" means is per head, matching the accessor each head
99 /// corresponds to on the single-head traits:
100 ///
101 /// - [`HeadSet::DENSE`] — the dense vector dimensionality
102 /// ([`EmbeddingModel::dimensions`](crate::traits::EmbeddingModel::dimensions))
103 /// - [`HeadSet::SPARSE`] — the term-space size
104 /// ([`SparseEmbeddingModel::vocab_size`](crate::traits::SparseEmbeddingModel::vocab_size))
105 /// - [`HeadSet::MULTI_VECTOR`] — the per-token vector dimensionality
106 /// ([`MultiVectorEmbeddingModel::dimensions`](crate::traits::MultiVectorEmbeddingModel::dimensions))
107 ///
108 /// Returns `None` for a head this model does not expose, for a `head`
109 /// naming more than one flag, and for implementations that simply don't
110 /// track it — the default. Without it, the runtime cannot serve this model
111 /// through the single-head accessors, since those traits must report a
112 /// width; see [`ModelRuntime::sparse_embedder`](crate::runtime::ModelRuntime::sparse_embedder).
113 fn head_width(&self, head: HeadSet) -> Option<u32> {
114 let _ = head;
115 None
116 }
117
118 /// Optional warmup hook (e.g. load weights into memory on first access).
119 /// The default is a no-op.
120 ///
121 /// # Errors
122 /// Returns an error if the underlying model fails to initialize.
123 async fn warmup(&self) -> Result<()> {
124 Ok(())
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use std::sync::Arc;
132
133 // Dyn-safety smoke check: fails to compile if the trait becomes non-object-safe.
134 #[test]
135 fn hybrid_embedding_model_is_dyn_safe() {
136 fn _accept(_: Arc<dyn HybridEmbeddingModel>) {}
137 }
138
139 #[test]
140 fn head_set_membership_and_composition() {
141 let ds = HeadSet::DENSE | HeadSet::SPARSE;
142 assert!(ds.contains(HeadSet::DENSE));
143 assert!(ds.contains(HeadSet::SPARSE));
144 assert!(!ds.contains(HeadSet::MULTI_VECTOR));
145 assert_eq!(
146 HeadSet::DENSE | HeadSet::SPARSE | HeadSet::MULTI_VECTOR,
147 HeadSet::ALL
148 );
149 }
150
151 #[test]
152 fn head_set_intersection_disjoint_and_empty() {
153 // Requested ⊄ available collapses to the available subset.
154 let requested = HeadSet::DENSE | HeadSet::MULTI_VECTOR;
155 let available = HeadSet::DENSE | HeadSet::SPARSE;
156 assert_eq!(requested.intersection(available), HeadSet::DENSE);
157
158 // Fully disjoint → empty; empty request → empty.
159 assert!(HeadSet::SPARSE.intersection(HeadSet::DENSE).is_empty());
160 assert!(HeadSet::empty().intersection(HeadSet::ALL).is_empty());
161 }
162}