Skip to main content

qql_embed/
embedder.rs

1use async_trait::async_trait;
2use qql_core::error::QqlError;
3
4use crate::sparse::{self, SparseVector};
5
6#[cfg(not(target_arch = "wasm32"))]
7pub trait EmbedderBound: Send + Sync {}
8#[cfg(not(target_arch = "wasm32"))]
9impl<T: Send + Sync> EmbedderBound for T {}
10
11#[cfg(target_arch = "wasm32")]
12pub trait EmbedderBound {}
13#[cfg(target_arch = "wasm32")]
14impl<T> EmbedderBound for T {}
15
16/// Host-agnostic embedding backend.
17///
18/// Dense calls should batch when possible (`embed_dense_batch` → one HTTP
19/// request or one ONNX batch). Sparse defaults to local BM25-style hashing.
20/// Multivector (ColBERT-style) uses [`Self::embed_multi`] → `Vec<Vec<f32>>`.
21#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
22#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
23pub trait Embedder: EmbedderBound {
24    async fn embed_dense(&self, text: &str, model: &str) -> Result<Vec<f32>, QqlError>;
25
26    /// Sparse embedding (BM25-like local hash or model-aware SPLADE / BGE-M3).
27    ///
28    /// Default implementation uses local BM25 hashing when `model` is empty or
29    /// `"default"`. Non-default sparse models are rejected — override this
30    /// method to provide model-aware sparse inference.
31    async fn embed_sparse(&self, text: &str, model: &str) -> Result<SparseVector, QqlError> {
32        if !model.is_empty() && !model.eq_ignore_ascii_case("default") {
33            return Err(sparse_model_unsupported_error(model));
34        }
35        Ok(sparse::build_query_default(text))
36    }
37
38    /// Batch sparse embedding. Default loops [`Self::embed_sparse`].
39    async fn embed_sparse_batch(
40        &self,
41        texts: &[String],
42        model: &str,
43    ) -> Result<Vec<SparseVector>, QqlError> {
44        let mut results = Vec::with_capacity(texts.len());
45        for text in texts {
46            results.push(self.embed_sparse(text, model).await?);
47        }
48        Ok(results)
49    }
50
51    /// Single-pass joint multi-modal / BGE-M3 embedding (dense + sparse + multi-vectors).
52    ///
53    /// Default implementation delegates to separate dense, sparse, and multi calls.
54    /// Override this to make a single inference pass (e.g. `Bgem3Embedding::embed`).
55    /// The default propagates the first error and does **not** suppress failures.
56    async fn embed_joint(&self, text: &str, model: &str) -> Result<JointEmbeddingOutput, QqlError> {
57        let dense = self.embed_dense(text, model).await?;
58        let sparse = self.embed_sparse(text, model).await?;
59        let multi = self.embed_multi(text, model).await?;
60        Ok(JointEmbeddingOutput {
61            dense: Some(dense),
62            sparse: Some(sparse),
63            multi: Some(multi),
64        })
65    }
66
67    /// Batch joint embedding. Default loops [`Self::embed_joint`].
68    async fn embed_joint_batch(
69        &self,
70        texts: &[String],
71        model: &str,
72    ) -> Result<Vec<JointEmbeddingOutput>, QqlError> {
73        let mut results = Vec::with_capacity(texts.len());
74        for text in texts {
75            results.push(self.embed_joint(text, model).await?);
76        }
77        Ok(results)
78    }
79
80    /// Dense output dimension when it is known without running inference.
81    /// Custom and remote embedders may return `None`.
82    fn dimension(&self) -> Option<usize> {
83        None
84    }
85
86    /// Multivector (ColBERT) per-token dimension when known without inference.
87    fn multi_dimension(&self) -> Option<usize> {
88        None
89    }
90
91    /// Whether this embedder can satisfy a requested model identifier.
92    /// Dynamic providers may return `true` for every model.
93    fn accepts_model(&self, _model: &str) -> bool {
94        true
95    }
96
97    /// Embed many texts in one shot. Default loops `embed_dense`; override for
98    /// real batching (OpenAI-compatible `input: [...]`, fastembed batch, etc.).
99    async fn embed_dense_batch(
100        &self,
101        texts: &[String],
102        model: &str,
103    ) -> Result<Vec<Vec<f32>>, QqlError> {
104        let mut results = Vec::with_capacity(texts.len());
105        for text in texts {
106            results.push(self.embed_dense(text, model).await?);
107        }
108        Ok(results)
109    }
110
111    /// Multivector embedding (ColBERT-style late interaction).
112    ///
113    /// Returns one dense vector per token/segment. Default rejects so hosts
114    /// that only support single-vector dense must opt in explicitly.
115    async fn embed_multi(&self, text: &str, model: &str) -> Result<Vec<Vec<f32>>, QqlError> {
116        let _ = text;
117        Err(multi_unsupported_error(model))
118    }
119
120    /// Batch multivector embedding. Default loops [`Self::embed_multi`].
121    async fn embed_multi_batch(
122        &self,
123        texts: &[String],
124        model: &str,
125    ) -> Result<Vec<Vec<Vec<f32>>>, QqlError> {
126        let mut results = Vec::with_capacity(texts.len());
127        for text in texts {
128            results.push(self.embed_multi(text, model).await?);
129        }
130        Ok(results)
131    }
132
133    /// Image / CLIP vision embedding. `source` is a filesystem path or URL.
134    ///
135    /// Returns a single dense vector in the same space as the paired text
136    /// encoder (e.g. CLIP). Default rejects until the host opts in.
137    async fn embed_image(&self, source: &str, model: &str) -> Result<Vec<f32>, QqlError> {
138        let _ = source;
139        Err(image_unsupported_error(model))
140    }
141
142    /// Batch image embedding. Default loops [`Self::embed_image`].
143    async fn embed_image_batch(
144        &self,
145        sources: &[String],
146        model: &str,
147    ) -> Result<Vec<Vec<f32>>, QqlError> {
148        let mut results = Vec::with_capacity(sources.len());
149        for source in sources {
150            results.push(self.embed_image(source, model).await?);
151        }
152        Ok(results)
153    }
154
155    /// Cross-encoder pair scores: `(query, documents[i]) → score`.
156    ///
157    /// Returns one score per document **in the same order** as `documents`
158    /// (not sorted). Hosts that return ranked results must unpermute.
159    /// Default rejects until the host opts in (edge `TextRerank`, HTTP rerank API).
160    async fn rerank_pairs(
161        &self,
162        query: &str,
163        documents: &[String],
164        model: &str,
165    ) -> Result<Vec<f32>, QqlError> {
166        let _ = (query, documents);
167        Err(cross_rerank_unsupported_error(model))
168    }
169}
170
171/// Error when multi-vector embedding is requested but the host has no multi path.
172pub fn multi_unsupported_error(model: &str) -> QqlError {
173    let model_note = if model.is_empty() || model.eq_ignore_ascii_case("default") {
174        "no model specified".to_string()
175    } else {
176        format!("model='{model}'")
177    };
178    QqlError::execution(
179        "QQL-EMBEDDING-MULTI",
180        format!(
181            "multi-vector embedding is not available ({model_note}). \
182             Configure a multi embedder (multi_embedding_endpoint / multi_embedding_model, \
183             or edge multi_model for offline BGE-M3), pass precomputed VECTOR [[...], ...], \
184             or use UPSERT with explicit multivector bags."
185        ),
186        None,
187    )
188}
189
190/// Error when image embedding is requested but the host has no image path.
191pub fn image_unsupported_error(model: &str) -> QqlError {
192    let model_note = if model.is_empty() || model.eq_ignore_ascii_case("default") {
193        "no model specified".to_string()
194    } else {
195        format!("model='{model}'")
196    };
197    QqlError::execution(
198        "QQL-EMBEDDING-IMAGE",
199        format!(
200            "image embedding is not available ({model_note}). \
201             Configure an image/CLIP vision embedder (image_embedding_model / edge image_model, \
202             or image_embedding_endpoint), pass a precomputed VECTOR [...], \
203             or use UPSERT USING IMAGE ON FIELD <path_field>."
204        ),
205        None,
206    )
207}
208
209/// Error when cross-encoder pair rerank is requested without a scorer host.
210pub fn cross_rerank_unsupported_error(model: &str) -> QqlError {
211    let model_note = if model.is_empty() || model.eq_ignore_ascii_case("default") {
212        "no model specified".to_string()
213    } else {
214        format!("model='{model}'")
215    };
216    QqlError::execution(
217        "QQL-RERANK-CROSS",
218        format!(
219            "cross-encoder pair scoring is not available ({model_note}). \
220             Configure a rerank host (rerank_endpoint / rerank_model, or edge \
221             reranker_model for offline TextRerank / bge-reranker)."
222        ),
223        None,
224    )
225}
226
227/// Error when a sparse model is requested that this embedder cannot satisfy.
228pub fn sparse_model_unsupported_error(model: &str) -> QqlError {
229    QqlError::execution(
230        "QQL-EMBEDDING-SPARSE",
231        format!(
232            "sparse model '{model}' is not available on this embedder. \
233             Omit the MODEL clause (or use MODEL 'default') for local BM25-style \
234             hashing. To use model-aware sparse embedding (SPLADE / BGE-M3), \
235             configure a sparse embedding backend."
236        ),
237        None,
238    )
239}
240
241/// Output container for single-pass joint multi-modal / BGE-M3 embedding.
242#[derive(Debug, Clone, Default, PartialEq)]
243pub struct JointEmbeddingOutput {
244    pub dense: Option<Vec<f32>>,
245    pub sparse: Option<SparseVector>,
246    pub multi: Option<Vec<Vec<f32>>>,
247}
248
249/// Local sparse-only helper (no dense model).
250pub struct SparseEmbedder;
251
252impl SparseEmbedder {
253    /// Generate a local BM25-style sparse vector representation for `text`.
254    pub fn embed_sparse(text: &str) -> SparseVector {
255        sparse::build_query_default(text)
256    }
257}