Skip to main content

qql_embed/
embedder.rs

1use async_trait::async_trait;
2use qql_core::error::QqlError;
3
4use crate::sparse::{self, Bm25Params, 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"`, honoring [`Self::bm25_params`]. Non-default sparse
51    /// models are rejected — override this method to provide model-aware sparse
52    /// inference.
53    async fn embed_sparse_document(
54        &self,
55        text: &str,
56        model: &str,
57    ) -> Result<SparseVector, QqlError> {
58        if !model.is_empty() && !model.eq_ignore_ascii_case("default") {
59            return Err(sparse_model_unsupported_error(model));
60        }
61        Ok(sparse::embed_document_with_params(
62            text,
63            &self.bm25_params(),
64        ))
65    }
66
67    /// Batch document-side sparse embedding. Default loops
68    /// [`Self::embed_sparse_document`]; override for real batching.
69    async fn embed_sparse_document_batch(
70        &self,
71        texts: &[String],
72        model: &str,
73    ) -> Result<Vec<SparseVector>, QqlError> {
74        let mut results = Vec::with_capacity(texts.len());
75        for text in texts {
76            results.push(self.embed_sparse_document(text, model).await?);
77        }
78        Ok(results)
79    }
80
81    /// Batch query-side sparse embedding. Default loops
82    /// [`Self::embed_sparse_query`]; override for real batching
83    /// (model-backed SPLADE / BGE-M3 sparse inference).
84    async fn embed_sparse_query_batch(
85        &self,
86        texts: &[String],
87        model: &str,
88    ) -> Result<Vec<SparseVector>, QqlError> {
89        let mut results = Vec::with_capacity(texts.len());
90        for text in texts {
91            results.push(self.embed_sparse_query(text, model).await?);
92        }
93        Ok(results)
94    }
95
96    /// Single-pass joint multi-modal / BGE-M3 embedding (dense + sparse + multi-vectors).
97    ///
98    /// Default implementation delegates to separate dense, sparse, and multi calls.
99    /// Override this to make a single inference pass (e.g. `Bgem3Embedding::embed`).
100    /// The default propagates the first error and does **not** suppress failures.
101    async fn embed_joint(&self, text: &str, model: &str) -> Result<JointEmbeddingOutput, QqlError> {
102        let dense = self.embed_dense(text, model).await?;
103        let sparse = self.embed_sparse_document(text, model).await?;
104        let multi = self.embed_multi(text, model).await?;
105        Ok(JointEmbeddingOutput {
106            dense: Some(dense),
107            sparse: Some(sparse),
108            multi: Some(multi),
109        })
110    }
111
112    /// Batch joint embedding. Default loops [`Self::embed_joint`].
113    async fn embed_joint_batch(
114        &self,
115        texts: &[String],
116        model: &str,
117    ) -> Result<Vec<JointEmbeddingOutput>, QqlError> {
118        let mut results = Vec::with_capacity(texts.len());
119        for text in texts {
120            results.push(self.embed_joint(text, model).await?);
121        }
122        Ok(results)
123    }
124
125    /// Dense output dimension when it is known without running inference.
126    /// Custom and remote embedders may return `None`.
127    fn dimension(&self) -> Option<usize> {
128        None
129    }
130
131    /// Local BM25 hyperparameters used by the default
132    /// [`Self::embed_sparse_document`] / [`Self::embed_sparse_document_batch`]
133    /// implementations.
134    ///
135    /// Document-side only: it does not affect [`Self::embed_sparse_query`]
136    /// (always unit term weights) or model-backed sparse inference (SPLADE /
137    /// BGE-M3), and it is not a collection/wire setting. Defaults to
138    /// [`Bm25Params::default`], so hosts that never override it keep Qdrant's
139    /// `qdrant/bm25` defaults. Changing it affects documents embedded *after*
140    /// the change — re-ingest to apply.
141    fn bm25_params(&self) -> Bm25Params {
142        Bm25Params::default()
143    }
144
145    /// Multivector (ColBERT) per-token dimension when known without inference.
146    fn multi_dimension(&self) -> Option<usize> {
147        None
148    }
149
150    /// Whether this embedder can satisfy a requested model identifier.
151    /// Dynamic providers may return `true` for every model.
152    fn accepts_model(&self, _model: &str) -> bool {
153        true
154    }
155
156    /// Embed many texts in one shot. Default loops `embed_dense`; override for
157    /// real batching (OpenAI-compatible `input: [...]`, fastembed batch, etc.).
158    async fn embed_dense_batch(
159        &self,
160        texts: &[String],
161        model: &str,
162    ) -> Result<Vec<Vec<f32>>, QqlError> {
163        let mut results = Vec::with_capacity(texts.len());
164        for text in texts {
165            results.push(self.embed_dense(text, model).await?);
166        }
167        Ok(results)
168    }
169
170    /// Multivector embedding (ColBERT-style late interaction).
171    ///
172    /// Returns one dense vector per token/segment. Default rejects so hosts
173    /// that only support single-vector dense must opt in explicitly.
174    async fn embed_multi(&self, text: &str, model: &str) -> Result<Vec<Vec<f32>>, QqlError> {
175        let _ = text;
176        Err(multi_unsupported_error(model))
177    }
178
179    /// Batch multivector embedding. Default loops [`Self::embed_multi`].
180    async fn embed_multi_batch(
181        &self,
182        texts: &[String],
183        model: &str,
184    ) -> Result<Vec<Vec<Vec<f32>>>, QqlError> {
185        let mut results = Vec::with_capacity(texts.len());
186        for text in texts {
187            results.push(self.embed_multi(text, model).await?);
188        }
189        Ok(results)
190    }
191
192    /// Image / CLIP vision embedding. `source` is a filesystem path or URL.
193    ///
194    /// Returns a single dense vector in the same space as the paired text
195    /// encoder (e.g. CLIP). Default rejects until the host opts in.
196    async fn embed_image(&self, source: &str, model: &str) -> Result<Vec<f32>, QqlError> {
197        let _ = source;
198        Err(image_unsupported_error(model))
199    }
200
201    /// Batch image embedding. Default loops [`Self::embed_image`].
202    async fn embed_image_batch(
203        &self,
204        sources: &[String],
205        model: &str,
206    ) -> Result<Vec<Vec<f32>>, QqlError> {
207        let mut results = Vec::with_capacity(sources.len());
208        for source in sources {
209            results.push(self.embed_image(source, model).await?);
210        }
211        Ok(results)
212    }
213
214    /// Cross-encoder pair scores: `(query, documents[i]) → score`.
215    ///
216    /// Returns one score per document **in the same order** as `documents`
217    /// (not sorted). Hosts that return ranked results must unpermute.
218    /// Default rejects until the host opts in (edge `TextRerank`, HTTP rerank API).
219    async fn rerank_pairs(
220        &self,
221        query: &str,
222        documents: &[String],
223        model: &str,
224    ) -> Result<Vec<f32>, QqlError> {
225        let _ = (query, documents);
226        Err(cross_rerank_unsupported_error(model))
227    }
228}
229
230/// Error when multi-vector embedding is requested but the host has no multi path.
231pub fn multi_unsupported_error(model: &str) -> QqlError {
232    let model_note = if model.is_empty() || model.eq_ignore_ascii_case("default") {
233        "no model specified".to_string()
234    } else {
235        format!("model='{model}'")
236    };
237    QqlError::execution(
238        "QQL-EMBEDDING-MULTI",
239        format!(
240            "multi-vector embedding is not available ({model_note}). \
241             Configure a multi embedder (multi_embedding_endpoint / multi_embedding_model, \
242             or edge multi_model for offline BGE-M3), pass precomputed VECTOR [[...], ...], \
243             or use UPSERT with explicit multivector bags."
244        ),
245        None,
246    )
247}
248
249/// Error when image embedding is requested but the host has no image path.
250pub fn image_unsupported_error(model: &str) -> QqlError {
251    let model_note = if model.is_empty() || model.eq_ignore_ascii_case("default") {
252        "no model specified".to_string()
253    } else {
254        format!("model='{model}'")
255    };
256    QqlError::execution(
257        "QQL-EMBEDDING-IMAGE",
258        format!(
259            "image embedding is not available ({model_note}). \
260             Configure an image/CLIP vision embedder (image_embedding_model / edge image_model, \
261             or image_embedding_endpoint), pass a precomputed VECTOR [...], \
262             or use UPSERT USING IMAGE ON FIELD <path_field>."
263        ),
264        None,
265    )
266}
267
268/// Error when cross-encoder pair rerank is requested without a scorer host.
269pub fn cross_rerank_unsupported_error(model: &str) -> QqlError {
270    let model_note = if model.is_empty() || model.eq_ignore_ascii_case("default") {
271        "no model specified".to_string()
272    } else {
273        format!("model='{model}'")
274    };
275    QqlError::execution(
276        "QQL-RERANK-CROSS",
277        format!(
278            "cross-encoder pair scoring is not available ({model_note}). \
279             Configure a rerank host (rerank_endpoint / rerank_model, or edge \
280             reranker_model for offline TextRerank / bge-reranker)."
281        ),
282        None,
283    )
284}
285
286/// Error when a sparse model is requested that this embedder cannot satisfy.
287pub fn sparse_model_unsupported_error(model: &str) -> QqlError {
288    QqlError::execution(
289        "QQL-EMBEDDING-SPARSE",
290        format!(
291            "sparse model '{model}' is not available on this embedder. \
292             Omit the MODEL clause (or use MODEL 'default') for local \
293             wire-compatible BM25. To use model-aware sparse embedding \
294             (SPLADE / BGE-M3), configure a sparse embedding backend."
295        ),
296        None,
297    )
298}
299
300/// Error when a dense model is requested that this embedder cannot satisfy.
301///
302/// Mirrors [`sparse_model_unsupported_error`]: single-model hosts (WASM client
303/// embedder, fixed local models) reject non-default `MODEL` clauses instead
304/// of silently returning vectors from the wrong model.
305pub fn dense_model_unsupported_error(model: &str) -> QqlError {
306    QqlError::execution(
307        "QQL-EMBEDDING",
308        format!(
309            "dense model '{model}' is not available on this embedder. \
310             Omit the MODEL clause (or use MODEL 'default') to use the \
311             configured dense model. To serve multiple dense models, \
312             configure a model-routing dense embedding backend."
313        ),
314        None,
315    )
316}
317
318/// Output container for single-pass joint multi-modal / BGE-M3 embedding.
319#[derive(Debug, Clone, Default, PartialEq)]
320pub struct JointEmbeddingOutput {
321    /// Dense vector, when the model provides one.
322    pub dense: Option<Vec<f32>>,
323    /// Sparse (BM25 / SPLADE) vector, when the model provides one.
324    pub sparse: Option<SparseVector>,
325    /// Multivector token vectors (ColBERT), when the model provides them.
326    pub multi: Option<Vec<Vec<f32>>>,
327}
328
329/// Local sparse-only helper (no dense model).
330pub struct SparseEmbedder;
331
332impl SparseEmbedder {
333    /// Embed query text with local wire-compatible BM25 (unit term weights).
334    pub fn embed_query(text: &str) -> SparseVector {
335        sparse::embed_query(text)
336    }
337
338    /// Embed document text with local wire-compatible BM25 (tf saturation)
339    /// using Qdrant's `qdrant/bm25` defaults.
340    pub fn embed_document(text: &str) -> SparseVector {
341        sparse::embed_document(text)
342    }
343
344    /// Embed document text with explicit validated [`Bm25Params`].
345    pub fn embed_document_with(text: &str, params: &Bm25Params) -> SparseVector {
346        sparse::embed_document_with_params(text, params)
347    }
348}