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