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.
8///
9/// Kept as a twin of `QdrantOpsBound` in `qql-runtime` (not shared): sharing
10/// would couple the crates backwards (`qql-embed` must stay dependency-free
11/// of the runtime) or mistype the bound (`QdrantOps: EmbedderBound` reads as
12/// an is-a relationship that does not exist). One shim per trait, each
13/// documenting its own target split.
14pub trait EmbedderBound: Send + Sync {}
15#[cfg(not(target_arch = "wasm32"))]
16impl<T: Send + Sync> EmbedderBound for T {}
17
18#[cfg(target_arch = "wasm32")]
19/// Single-threaded bound helper for `Embedder` implementations on wasm32.
20pub trait EmbedderBound {}
21#[cfg(target_arch = "wasm32")]
22impl<T> EmbedderBound for T {}
23
24/// Host-agnostic embedding backend.
25///
26/// Calls should batch when possible (`*_batch` → one HTTP request or one ONNX
27/// batch): `resolve_embeddings` batches dense, sparse-query, multi, and image
28/// jobs alike (grouped by model, one `*_batch` call per model), so remote
29/// backends must override the batch variants for real batching instead of
30/// relying on the sequential single-call defaults. Sparse is role-split:
31/// [`Self::embed_sparse_query`] (unit weights) for search text and
32/// [`Self::embed_sparse_document`] (BM25 tf saturation) for ingestion text,
33/// both defaulting to local wire-compatible BM25. Multivector (ColBERT-style)
34/// uses [`Self::embed_multi`] → `Vec<Vec<f32>>`.
35#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
36#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
37pub trait Embedder: EmbedderBound {
38 /// Embed one text into a dense vector; `model` may be empty or `"default"`.
39 async fn embed_dense(&self, text: &str, model: &str) -> Result<Vec<f32>, QqlError>;
40
41 /// Sparse embedding for **query** text: unique terms with unit weights,
42 /// matching Qdrant's `qdrant/bm25` query embedding.
43 ///
44 /// Default implementation uses the local pipeline from
45 /// [`Self::bm25_text_config`] when `model` is empty or `"default"`.
46 /// Non-default sparse models are rejected — override this method to
47 /// provide model-aware sparse inference.
48 async fn embed_sparse_query(&self, text: &str, model: &str) -> Result<SparseVector, QqlError> {
49 if !model.is_empty() && !model.eq_ignore_ascii_case("default") {
50 return Err(sparse_model_unsupported_error(model));
51 }
52 self.bm25_text_config().pipeline().embed_query(text)
53 }
54
55 /// Sparse embedding for **document** text at ingestion: BM25
56 /// term-frequency saturation, matching Qdrant's `qdrant/bm25` document
57 /// embedding.
58 ///
59 /// Default implementation uses the local pipeline from
60 /// [`Self::bm25_text_config`] when `model` is empty or `"default"`,
61 /// honoring its [`Bm25Params`]. Non-default sparse models are rejected —
62 /// override this method to provide model-aware sparse inference.
63 async fn embed_sparse_document(
64 &self,
65 text: &str,
66 model: &str,
67 ) -> Result<SparseVector, QqlError> {
68 if !model.is_empty() && !model.eq_ignore_ascii_case("default") {
69 return Err(sparse_model_unsupported_error(model));
70 }
71 self.bm25_text_config().pipeline().embed_document(text)
72 }
73
74 /// Batch document-side sparse embedding. Default loops
75 /// [`Self::embed_sparse_document`]; override for real batching.
76 async fn embed_sparse_document_batch(
77 &self,
78 texts: &[String],
79 model: &str,
80 ) -> Result<Vec<SparseVector>, QqlError> {
81 let mut results = Vec::with_capacity(texts.len());
82 for text in texts {
83 results.push(self.embed_sparse_document(text, model).await?);
84 }
85 Ok(results)
86 }
87
88 /// Batch query-side sparse embedding. Default loops
89 /// [`Self::embed_sparse_query`]; override for real batching
90 /// (model-backed SPLADE / BGE-M3 sparse inference).
91 async fn embed_sparse_query_batch(
92 &self,
93 texts: &[String],
94 model: &str,
95 ) -> Result<Vec<SparseVector>, QqlError> {
96 let mut results = Vec::with_capacity(texts.len());
97 for text in texts {
98 results.push(self.embed_sparse_query(text, model).await?);
99 }
100 Ok(results)
101 }
102
103 /// Single-pass joint multi-modal / BGE-M3 embedding (dense + sparse + multi-vectors).
104 ///
105 /// Default implementation delegates to separate dense, sparse, and multi calls.
106 /// Override this to make a single inference pass (e.g. `Bgem3Embedding::embed`).
107 /// The default propagates the first error and does **not** suppress failures.
108 async fn embed_joint(&self, text: &str, model: &str) -> Result<JointEmbeddingOutput, QqlError> {
109 let dense = self.embed_dense(text, model).await?;
110 let sparse = self.embed_sparse_document(text, model).await?;
111 let multi = self.embed_multi(text, model).await?;
112 Ok(JointEmbeddingOutput {
113 dense: Some(dense),
114 sparse: Some(sparse),
115 multi: Some(multi),
116 })
117 }
118
119 /// Batch joint embedding. Default loops [`Self::embed_joint`].
120 async fn embed_joint_batch(
121 &self,
122 texts: &[String],
123 model: &str,
124 ) -> Result<Vec<JointEmbeddingOutput>, QqlError> {
125 let mut results = Vec::with_capacity(texts.len());
126 for text in texts {
127 results.push(self.embed_joint(text, model).await?);
128 }
129 Ok(results)
130 }
131
132 /// Dense output dimension when it is known without running inference.
133 /// Custom and remote embedders may return `None`.
134 fn dimension(&self) -> Option<usize> {
135 None
136 }
137
138 /// Local BM25 hyperparameters used by the default
139 /// [`Self::embed_sparse_document`] / [`Self::embed_sparse_document_batch`]
140 /// implementations.
141 ///
142 /// Document-side only: it does not affect [`Self::embed_sparse_query`]
143 /// (always unit term weights) or model-backed sparse inference (SPLADE /
144 /// BGE-M3), and it is not a collection/wire setting. Defaults to
145 /// [`Bm25Params::default`], so hosts that never override it keep Qdrant's
146 /// `qdrant/bm25` defaults. Changing it affects documents embedded *after*
147 /// the change — re-ingest to apply.
148 fn bm25_params(&self) -> Bm25Params {
149 Bm25Params::default()
150 }
151
152 /// Full local BM25 text configuration (pipeline + hyperparameters) used
153 /// by the default [`Self::embed_sparse_document`] /
154 /// [`Self::embed_sparse_query`] implementations.
155 ///
156 /// Default wraps [`Self::bm25_params`] with Qdrant's text defaults (word
157 /// tokenizer, English, lowercase on, folding off, language
158 /// stopwords/stemmer), so overriding only `bm25_params` keeps working
159 /// unchanged. Override this instead to change tokenization, language,
160 /// folding, stopwords, stemming, or token length limits.
161 fn bm25_text_config(&self) -> crate::Bm25TextConfig {
162 crate::Bm25TextConfig {
163 params: self.bm25_params(),
164 ..crate::Bm25TextConfig::default()
165 }
166 }
167
168 /// Multivector (ColBERT) per-token dimension when known without inference.
169 fn multi_dimension(&self) -> Option<usize> {
170 None
171 }
172
173 /// Whether this embedder can satisfy a requested model identifier.
174 /// Dynamic providers may return `true` for every model.
175 fn accepts_model(&self, _model: &str) -> bool {
176 true
177 }
178
179 /// Embed many texts in one shot. Default loops `embed_dense`; override for
180 /// real batching (OpenAI-compatible `input: [...]`, fastembed batch, etc.).
181 async fn embed_dense_batch(
182 &self,
183 texts: &[String],
184 model: &str,
185 ) -> Result<Vec<Vec<f32>>, QqlError> {
186 let mut results = Vec::with_capacity(texts.len());
187 for text in texts {
188 results.push(self.embed_dense(text, model).await?);
189 }
190 Ok(results)
191 }
192
193 /// Multivector embedding (ColBERT-style late interaction).
194 ///
195 /// Returns one dense vector per token/segment. Default rejects so hosts
196 /// that only support single-vector dense must opt in explicitly.
197 async fn embed_multi(&self, text: &str, model: &str) -> Result<Vec<Vec<f32>>, QqlError> {
198 let _ = text;
199 Err(multi_unsupported_error(model))
200 }
201
202 /// Batch multivector embedding. Default loops [`Self::embed_multi`].
203 async fn embed_multi_batch(
204 &self,
205 texts: &[String],
206 model: &str,
207 ) -> Result<Vec<Vec<Vec<f32>>>, QqlError> {
208 let mut results = Vec::with_capacity(texts.len());
209 for text in texts {
210 results.push(self.embed_multi(text, model).await?);
211 }
212 Ok(results)
213 }
214
215 /// Image / CLIP vision embedding. `source` is a filesystem path or URL.
216 ///
217 /// Returns a single dense vector in the same space as the paired text
218 /// encoder (e.g. CLIP). Default rejects until the host opts in.
219 async fn embed_image(&self, source: &str, model: &str) -> Result<Vec<f32>, QqlError> {
220 let _ = source;
221 Err(image_unsupported_error(model))
222 }
223
224 /// Batch image embedding. Default loops [`Self::embed_image`].
225 async fn embed_image_batch(
226 &self,
227 sources: &[String],
228 model: &str,
229 ) -> Result<Vec<Vec<f32>>, QqlError> {
230 let mut results = Vec::with_capacity(sources.len());
231 for source in sources {
232 results.push(self.embed_image(source, model).await?);
233 }
234 Ok(results)
235 }
236
237 /// Cross-encoder pair scores: `(query, documents[i]) → score`.
238 ///
239 /// Returns one score per document **in the same order** as `documents`
240 /// (not sorted). Hosts that return ranked results must unpermute.
241 /// Default rejects until the host opts in (edge `TextRerank`, HTTP rerank API).
242 async fn rerank_pairs(
243 &self,
244 query: &str,
245 documents: &[String],
246 model: &str,
247 ) -> Result<Vec<f32>, QqlError> {
248 let _ = (query, documents);
249 Err(cross_rerank_unsupported_error(model))
250 }
251}
252
253/// Error when multi-vector embedding is requested but the host has no multi path.
254pub fn multi_unsupported_error(model: &str) -> QqlError {
255 let model_note = if model.is_empty() || model.eq_ignore_ascii_case("default") {
256 "no model specified".to_string()
257 } else {
258 format!("model='{model}'")
259 };
260 QqlError::execution(
261 "QQL-EMBEDDING-MULTI",
262 format!(
263 "multi-vector embedding is not available ({model_note}). \
264 Configure a multi embedder (multi_embedding_endpoint / multi_embedding_model, \
265 or edge multi_model for offline BGE-M3), pass precomputed VECTOR [[...], ...], \
266 or use UPSERT with explicit multivector bags."
267 ),
268 None,
269 )
270}
271
272/// Error when image embedding is requested but the host has no image path.
273pub fn image_unsupported_error(model: &str) -> QqlError {
274 let model_note = if model.is_empty() || model.eq_ignore_ascii_case("default") {
275 "no model specified".to_string()
276 } else {
277 format!("model='{model}'")
278 };
279 QqlError::execution(
280 "QQL-EMBEDDING-IMAGE",
281 format!(
282 "image embedding is not available ({model_note}). \
283 Configure an image/CLIP vision embedder (image_embedding_model / edge image_model, \
284 or image_embedding_endpoint), pass a precomputed VECTOR [...], \
285 or use UPSERT USING IMAGE ON FIELD <path_field>."
286 ),
287 None,
288 )
289}
290
291/// Error when cross-encoder pair rerank is requested without a scorer host.
292pub fn cross_rerank_unsupported_error(model: &str) -> QqlError {
293 let model_note = if model.is_empty() || model.eq_ignore_ascii_case("default") {
294 "no model specified".to_string()
295 } else {
296 format!("model='{model}'")
297 };
298 QqlError::execution(
299 "QQL-RERANK-CROSS",
300 format!(
301 "cross-encoder pair scoring is not available ({model_note}). \
302 Configure a rerank host (rerank_endpoint / rerank_model, or edge \
303 reranker_model for offline TextRerank / bge-reranker)."
304 ),
305 None,
306 )
307}
308
309/// Error when a sparse model is requested that this embedder cannot satisfy.
310pub fn sparse_model_unsupported_error(model: &str) -> QqlError {
311 QqlError::execution(
312 "QQL-EMBEDDING-SPARSE",
313 format!(
314 "sparse model '{model}' is not available on this embedder. \
315 Omit the MODEL clause (or use MODEL 'default') for local \
316 wire-compatible BM25. To use model-aware sparse embedding \
317 (SPLADE / BGE-M3), configure a sparse embedding backend."
318 ),
319 None,
320 )
321}
322
323/// Error when a dense model is requested that this embedder cannot satisfy.
324///
325/// Mirrors [`sparse_model_unsupported_error`]: single-model hosts (WASM client
326/// embedder, fixed local models) reject non-default `MODEL` clauses instead
327/// of silently returning vectors from the wrong model.
328pub fn dense_model_unsupported_error(model: &str) -> QqlError {
329 QqlError::execution(
330 "QQL-EMBEDDING",
331 format!(
332 "dense model '{model}' is not available on this embedder. \
333 Omit the MODEL clause (or use MODEL 'default') to use the \
334 configured dense model. To serve multiple dense models, \
335 configure a model-routing dense embedding backend."
336 ),
337 None,
338 )
339}
340
341/// Output container for single-pass joint multi-modal / BGE-M3 embedding.
342#[derive(Debug, Clone, Default, PartialEq)]
343pub struct JointEmbeddingOutput {
344 /// Dense vector, when the model provides one.
345 pub dense: Option<Vec<f32>>,
346 /// Sparse (BM25 / SPLADE) vector, when the model provides one.
347 pub sparse: Option<SparseVector>,
348 /// Multivector token vectors (ColBERT), when the model provides them.
349 pub multi: Option<Vec<Vec<f32>>>,
350}
351
352/// Local sparse-only helper (no dense model). Default English pipeline
353/// only — hosts needing other languages use [`Bm25TextConfig`](crate::Bm25TextConfig)
354/// / [`Bm25Pipeline`](crate::Bm25Pipeline) directly.
355pub struct SparseEmbedder;
356
357impl SparseEmbedder {
358 /// Embed query text with local wire-compatible BM25 (unit term weights).
359 pub fn embed_query(text: &str) -> SparseVector {
360 sparse::embed_query(text)
361 }
362
363 /// Embed document text with local wire-compatible BM25 (tf saturation)
364 /// using Qdrant's `qdrant/bm25` defaults.
365 pub fn embed_document(text: &str) -> SparseVector {
366 sparse::embed_document(text)
367 }
368
369 /// Embed document text with explicit validated [`Bm25Params`].
370 pub fn embed_document_with(text: &str, params: &Bm25Params) -> SparseVector {
371 sparse::embed_document_with_params(text, params)
372 }
373}