Skip to main content

qql_embed/
sparse.rs

1//! Client-side BM25 sparse embeddings, wire-compatible with Qdrant's
2//! `qdrant/bm25` model defaults.
3//!
4//! Token IDs are murmur3-32 (seed 0, `|i32|` made positive) — identical to the
5//! Qdrant server, Qdrant Edge, and FastEmbed's `Qdrant/bm25`. The text pipeline
6//! mirrors the server defaults: word tokenizer (split on non-alphanumeric),
7//! Unicode lowercasing, English stopword removal, and English snowball
8//! stemming. Queries embed with unit term weights; documents with BM25
9//! term-frequency saturation (k1=1.2, b=0.75, avg_len=256, or explicit
10//! [`Bm25Params`]). IDF is applied server-side via the sparse vector
11//! `modifier: idf`.
12//!
13//! Because token IDs and formulas match the server, vectors produced here can
14//! be mixed with server-side `qdrant/bm25` inference on the same collection.
15//!
16//! ### Tuning BM25 (`k1`, `b`, `avg_len`)
17//!
18//! [`Bm25Params`] is a **client-side, write-path-only** setting: it shapes how
19//! *documents* are encoded (tf saturation via `k1`, length normalization via
20//! `b`, and the expected average document length via `avg_len`). It is not a
21//! collection or wire setting, does not affect query-side weights (always unit
22//! weights), and does not change server-side `qdrant/bm25` inference. A wrong
23//! `avg_len` silently misjudges every document, so tune it to the corpus being
24//! written; documents embedded before the change keep their vectors — re-ingest
25//! to apply.
26//!
27//! ### FastEmbed Query Weighting Parity Note
28//! FastEmbed's Python `Qdrant/bm25` emits a uniform scaling factor (~1.665) on query
29//! term weights, whereas Qdrant's server-side inference and QQL use unit weights (1.0).
30//! Because this factor is uniform across all terms in a query, ranking order is
31//! mathematically identical, but raw score magnitudes will scale by ~1.665x.
32
33use murmur3_32::Murmur3;
34use qql_core::error::QqlError;
35
36use super::bm25_text::default_pipeline;
37
38/// Sparse embedding (indices + values). Transport-neutral — not a protobuf type.
39#[derive(Debug, Clone, PartialEq, Default)]
40pub struct SparseVector {
41    /// Sorted token IDs (murmur3-32, wire-compatible with Qdrant `qdrant/bm25`).
42    pub indices: Vec<u32>,
43    /// Per-token weights aligned with `indices` (unit for queries, tf for docs).
44    pub values: Vec<f32>,
45}
46
47/// BM25 term-frequency saturation, matching Qdrant's `qdrant/bm25` default.
48pub const DEFAULT_K1: f64 = 1.2;
49/// BM25 document-length normalization, matching Qdrant's `qdrant/bm25` default.
50pub const DEFAULT_B: f64 = 0.75;
51/// BM25 expected average document length in tokens, matching Qdrant's
52/// `qdrant/bm25` default.
53pub const DEFAULT_AVGDL: f64 = 256.0;
54
55/// Validated BM25 hyperparameters for **document-side** local encoding.
56///
57/// Only documents are affected: query text always embeds with unit term
58/// weights, and IDF is applied by the backend from the sparse vector's
59/// `modifier: idf`. This is a client-side, write-path-only knob — it is not a
60/// collection/wire setting, and it does not change server-side `qdrant/bm25`
61/// inference. Vectors written before a change stay as written; re-ingest to
62/// apply new parameters.
63///
64/// Construct via [`Bm25Params::new`] (or [`Bm25Params::resolve`] for optional
65/// overrides); invalid values fail closed with `QQL-VALIDATION-CONFIG`.
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct Bm25Params {
68    k1: f64,
69    b: f64,
70    avg_len: f64,
71}
72
73impl Default for Bm25Params {
74    /// Qdrant's `qdrant/bm25` defaults: `k1 = 1.2`, `b = 0.75`,
75    /// `avg_len = 256`. Unset configuration keeps this exact behavior.
76    fn default() -> Self {
77        Self {
78            k1: DEFAULT_K1,
79            b: DEFAULT_B,
80            avg_len: DEFAULT_AVGDL,
81        }
82    }
83}
84
85impl Bm25Params {
86    /// Validate and build explicit BM25 parameters.
87    ///
88    /// `k1` must be finite and `>= 0` (like Qdrant's validator; `0` gives
89    /// binary weighting), `b` finite and within `[0, 1]`, and `avg_len`
90    /// finite and `> 0`. NaN and ±Inf are rejected for all three.
91    pub fn new(k1: f64, b: f64, avg_len: f64) -> Result<Self, QqlError> {
92        if !k1.is_finite() || k1 < 0.0 {
93            return Err(config_error(
94                "bm25 k1 must be a finite number greater than or equal to zero".to_string(),
95            ));
96        }
97        if !b.is_finite() || !(0.0..=1.0).contains(&b) {
98            return Err(config_error(
99                "bm25 b must be a finite number in [0, 1]".to_string(),
100            ));
101        }
102        if !avg_len.is_finite() || avg_len <= 0.0 {
103            return Err(config_error(
104                "bm25 avg_len must be a finite number greater than zero".to_string(),
105            ));
106        }
107        Ok(Self { k1, b, avg_len })
108    }
109
110    /// Resolve optional overrides on top of [`Bm25Params::default`]; `None`
111    /// keeps the corresponding Qdrant `qdrant/bm25` default.
112    pub fn resolve(
113        k1: Option<f64>,
114        b: Option<f64>,
115        avg_len: Option<f64>,
116    ) -> Result<Self, QqlError> {
117        let defaults = Self::default();
118        Self::new(
119            k1.unwrap_or(defaults.k1),
120            b.unwrap_or(defaults.b),
121            avg_len.unwrap_or(defaults.avg_len),
122        )
123    }
124
125    /// Term-frequency saturation (`k1`).
126    pub fn k1(&self) -> f64 {
127        self.k1
128    }
129
130    /// Document-length normalization factor (`b`), `0` = none, `1` = full.
131    pub fn b(&self) -> f64 {
132        self.b
133    }
134
135    /// Expected average document length in tokens (`avg_len`).
136    pub fn avg_len(&self) -> f64 {
137        self.avg_len
138    }
139}
140
141fn config_error(message: String) -> QqlError {
142    QqlError::validation("QQL-VALIDATION-CONFIG", message, None)
143}
144
145/// Token → `u32` ID. Wire-compatible with Qdrant's BM25 sparse vectors:
146/// murmur3 32-bit (seed 0), then `|i32|` to make it positive.
147///
148/// Hashes bytes **as given**: the embedding pipeline lowercases (and stems)
149/// before calling, so callers must pass already-normalized text — hashing
150/// `"Hello"` and `"hello"` yields different IDs by design (like the server's
151/// own `token_id` layer).
152pub fn token_id(token: &str) -> u32 {
153    (Murmur3::hash(0, token.as_bytes()) as i32).unsigned_abs()
154}
155
156/// Tokenize and iterate over processed tokens (default English pipeline: word
157/// tokenizer, lowercase, English stopwords, English stemming).
158///
159/// Compatibility shim over [`crate::bm25_text::Bm25Pipeline`]: allocates one
160/// `Vec` per call (the old stack-buffered zero-alloc form is gone — hot
161/// paths should use the pipeline directly). For other languages and options
162/// see [`crate::bm25_text::Bm25Pipeline`].
163#[inline]
164pub fn for_each_token<F>(text: &str, mut f: F)
165where
166    F: FnMut(&str),
167{
168    // The default pipeline only runs the word tokenizer: infallible.
169    if let Ok(tokens) = default_pipeline().doc_tokens(text) {
170        for token in &tokens {
171            f(token);
172        }
173    }
174}
175
176/// Tokenize and iterate directly over `u32` token IDs without intermediate allocations.
177#[inline]
178pub fn for_each_token_id<F>(text: &str, mut f: F)
179where
180    F: FnMut(u32),
181{
182    for_each_token(text, |token| {
183        f(token_id(token));
184    });
185}
186
187/// Server-default text pipeline: word tokenizer (split on non-alphanumeric),
188/// Unicode lowercase, English stopword removal, English snowball stemming.
189///
190/// Matches `WordTokenizer` + default `TokensProcessor` on the Qdrant server —
191/// the same pipeline Qdrant Edge's `EdgeBm25` runs. For other languages and
192/// options see [`crate::bm25_text::Bm25Pipeline`].
193pub fn tokenize(text: &str) -> Vec<String> {
194    default_pipeline().doc_tokens(text).unwrap_or_default()
195}
196
197/// Embed query text: unique token IDs (sorted) with unit weights — identical
198/// to Qdrant's `qdrant/bm25` query embedding.
199pub fn embed_query(text: &str) -> SparseVector {
200    default_pipeline().embed_query(text).unwrap_or_default()
201}
202
203/// Embed document text with BM25 term-frequency saturation using Qdrant's
204/// default parameters (`k1=1.2`, `b=0.75`, `avg_len=256`).
205pub fn embed_document(text: &str) -> SparseVector {
206    embed_document_with(text, DEFAULT_K1, DEFAULT_B, DEFAULT_AVGDL)
207}
208
209/// Embed document text with validated [`Bm25Params`].
210///
211/// Prefer this over [`embed_document_with`] on configurable paths: the
212/// parameters are validated once at construction instead of sanitized per call.
213pub fn embed_document_with_params(text: &str, params: &Bm25Params) -> SparseVector {
214    super::bm25_text::Bm25Pipeline::with_params(params)
215        .embed_document(text)
216        .unwrap_or_default()
217}
218
219/// Embed document text with explicit BM25 parameters.
220///
221/// `avgdl <= 0` or non-finite falls back to [`DEFAULT_AVGDL`] (it is a
222/// divisor). `k1` and `b` are used as given — including non-finite values,
223/// which propagate as `NaN` weights — so prefer
224/// [`embed_document_with_params`] for fail-closed validation. Frequencies are
225/// counted per token ID: on the rare murmur3 collision two terms merge into
226/// one dimension with summed counts, which keeps output deterministic across
227/// runs (the server's own per-string counting is randomized there, so collided
228/// IDs carry no cross-implementation contract).
229pub fn embed_document_with(text: &str, k1: f64, b: f64, avgdl: f64) -> SparseVector {
230    let safe_avgdl = if avgdl.is_finite() && avgdl > 0.0 {
231        avgdl
232    } else {
233        DEFAULT_AVGDL
234    };
235    default_pipeline()
236        .embed_document_with(text, k1, b, safe_avgdl)
237        .unwrap_or_default()
238}