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 std::sync::LazyLock;
34
35use murmur3_32::Murmur3;
36use phf::phf_set;
37use qql_core::error::QqlError;
38use rust_stemmers::{Algorithm, Stemmer};
39
40/// Sparse embedding (indices + values). Transport-neutral — not a protobuf type.
41#[derive(Debug, Clone, PartialEq, Default)]
42pub struct SparseVector {
43    /// Sorted token IDs (murmur3-32, wire-compatible with Qdrant `qdrant/bm25`).
44    pub indices: Vec<u32>,
45    /// Per-token weights aligned with `indices` (unit for queries, tf for docs).
46    pub values: Vec<f32>,
47}
48
49/// BM25 term-frequency saturation, matching Qdrant's `qdrant/bm25` default.
50pub const DEFAULT_K1: f64 = 1.2;
51/// BM25 document-length normalization, matching Qdrant's `qdrant/bm25` default.
52pub const DEFAULT_B: f64 = 0.75;
53/// BM25 expected average document length in tokens, matching Qdrant's
54/// `qdrant/bm25` default.
55pub const DEFAULT_AVGDL: f64 = 256.0;
56
57/// Validated BM25 hyperparameters for **document-side** local encoding.
58///
59/// Only documents are affected: query text always embeds with unit term
60/// weights, and IDF is applied by the backend from the sparse vector's
61/// `modifier: idf`. This is a client-side, write-path-only knob — it is not a
62/// collection/wire setting, and it does not change server-side `qdrant/bm25`
63/// inference. Vectors written before a change stay as written; re-ingest to
64/// apply new parameters.
65///
66/// Construct via [`Bm25Params::new`] (or [`Bm25Params::resolve`] for optional
67/// overrides); invalid values fail closed with `QQL-VALIDATION-CONFIG`.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct Bm25Params {
70    k1: f64,
71    b: f64,
72    avg_len: f64,
73}
74
75impl Default for Bm25Params {
76    /// Qdrant's `qdrant/bm25` defaults: `k1 = 1.2`, `b = 0.75`,
77    /// `avg_len = 256`. Unset configuration keeps this exact behavior.
78    fn default() -> Self {
79        Self {
80            k1: DEFAULT_K1,
81            b: DEFAULT_B,
82            avg_len: DEFAULT_AVGDL,
83        }
84    }
85}
86
87impl Bm25Params {
88    /// Validate and build explicit BM25 parameters.
89    ///
90    /// `k1` must be finite and `> 0`, `b` finite and within `[0, 1]`, and
91    /// `avg_len` finite and `> 0`. NaN and ±Inf are rejected for all three.
92    pub fn new(k1: f64, b: f64, avg_len: f64) -> Result<Self, QqlError> {
93        if !k1.is_finite() || k1 <= 0.0 {
94            return Err(config_error(
95                "bm25 k1 must be a finite number greater than zero".to_string(),
96            ));
97        }
98        if !b.is_finite() || !(0.0..=1.0).contains(&b) {
99            return Err(config_error(
100                "bm25 b must be a finite number in [0, 1]".to_string(),
101            ));
102        }
103        if !avg_len.is_finite() || avg_len <= 0.0 {
104            return Err(config_error(
105                "bm25 avg_len must be a finite number greater than zero".to_string(),
106            ));
107        }
108        Ok(Self { k1, b, avg_len })
109    }
110
111    /// Resolve optional overrides on top of [`Bm25Params::default`]; `None`
112    /// keeps the corresponding Qdrant `qdrant/bm25` default.
113    pub fn resolve(
114        k1: Option<f64>,
115        b: Option<f64>,
116        avg_len: Option<f64>,
117    ) -> Result<Self, QqlError> {
118        let defaults = Self::default();
119        Self::new(
120            k1.unwrap_or(defaults.k1),
121            b.unwrap_or(defaults.b),
122            avg_len.unwrap_or(defaults.avg_len),
123        )
124    }
125
126    /// Term-frequency saturation (`k1`).
127    pub fn k1(&self) -> f64 {
128        self.k1
129    }
130
131    /// Document-length normalization factor (`b`), `0` = none, `1` = full.
132    pub fn b(&self) -> f64 {
133        self.b
134    }
135
136    /// Expected average document length in tokens (`avg_len`).
137    pub fn avg_len(&self) -> f64 {
138        self.avg_len
139    }
140}
141
142fn config_error(message: String) -> QqlError {
143    QqlError::validation("QQL-VALIDATION-CONFIG", message, None)
144}
145
146/// Token → `u32` ID. Wire-compatible with Qdrant's BM25 sparse vectors:
147/// murmur3 32-bit (seed 0), then `|i32|` to make it positive.
148///
149/// Hashes bytes **as given**: the embedding pipeline lowercases (and stems)
150/// before calling, so callers must pass already-normalized text — hashing
151/// `"Hello"` and `"hello"` yields different IDs by design (like the server's
152/// own `token_id` layer).
153pub fn token_id(token: &str) -> u32 {
154    (Murmur3::hash(0, token.as_bytes()) as i32).unsigned_abs()
155}
156
157/// English stopwords, identical to the Qdrant server set
158/// (`lib/segment/src/index/field_index/full_text_index/stop_words/english.rs`).
159static STOPWORDS: phf::Set<&'static str> = phf_set! {
160    "i",
161    "me",
162    "my",
163    "myself",
164    "we",
165    "our",
166    "ours",
167    "ourselves",
168    "you",
169    "you're",
170    "you've",
171    "you'll",
172    "you'd",
173    "your",
174    "yours",
175    "yourself",
176    "yourselves",
177    "he",
178    "him",
179    "his",
180    "himself",
181    "she",
182    "she's",
183    "her",
184    "hers",
185    "herself",
186    "it",
187    "it's",
188    "its",
189    "itself",
190    "they",
191    "them",
192    "their",
193    "theirs",
194    "themselves",
195    "what",
196    "which",
197    "who",
198    "whom",
199    "this",
200    "that",
201    "that'll",
202    "these",
203    "those",
204    "am",
205    "is",
206    "are",
207    "was",
208    "were",
209    "be",
210    "been",
211    "being",
212    "have",
213    "has",
214    "had",
215    "having",
216    "do",
217    "does",
218    "did",
219    "doing",
220    "a",
221    "an",
222    "the",
223    "and",
224    "but",
225    "if",
226    "or",
227    "because",
228    "as",
229    "until",
230    "while",
231    "of",
232    "at",
233    "by",
234    "for",
235    "with",
236    "about",
237    "against",
238    "between",
239    "into",
240    "through",
241    "during",
242    "before",
243    "after",
244    "above",
245    "below",
246    "to",
247    "from",
248    "up",
249    "down",
250    "in",
251    "out",
252    "on",
253    "off",
254    "over",
255    "under",
256    "again",
257    "further",
258    "then",
259    "once",
260    "here",
261    "there",
262    "when",
263    "where",
264    "why",
265    "how",
266    "all",
267    "any",
268    "both",
269    "each",
270    "few",
271    "more",
272    "most",
273    "other",
274    "some",
275    "such",
276    "no",
277    "nor",
278    "not",
279    "only",
280    "own",
281    "same",
282    "so",
283    "than",
284    "too",
285    "very",
286    "s",
287    "t",
288    "can",
289    "will",
290    "just",
291    "don",
292    "don't",
293    "should",
294    "should've",
295    "now",
296    "d",
297    "ll",
298    "m",
299    "o",
300    "re",
301    "ve",
302    "y",
303    "ain",
304    "aren",
305    "aren't",
306    "couldn",
307    "couldn't",
308    "didn",
309    "didn't",
310    "doesn",
311    "doesn't",
312    "hadn",
313    "hadn't",
314    "hasn",
315    "hasn't",
316    "haven",
317    "haven't",
318    "isn",
319    "isn't",
320    "ma",
321    "mightn",
322    "mightn't",
323    "mustn",
324    "mustn't",
325    "needn",
326    "needn't",
327    "shan",
328    "shan't",
329    "shouldn",
330    "shouldn't",
331    "wasn",
332    "wasn't",
333    "weren",
334    "weren't",
335    "won",
336    "won't",
337    "wouldn",
338    "wouldn't",
339};
340
341static STEMMER: LazyLock<Stemmer> = LazyLock::new(|| Stemmer::create(Algorithm::English));
342
343#[inline]
344fn process_token<F>(raw: &str, buf: &mut [u8; 64], f: &mut F)
345where
346    F: FnMut(&str),
347{
348    let bytes = raw.as_bytes();
349    let len = bytes.len();
350    if len <= buf.len() && raw.is_ascii() {
351        for (j, &b) in bytes.iter().enumerate() {
352            buf[j] = b.to_ascii_lowercase();
353        }
354        // Safe: `raw.is_ascii()` guarantees `bytes` is ASCII, and
355        // `to_ascii_lowercase()` maps ASCII to ASCII, so `buf[..len]`
356        // is valid UTF-8. Use the checked conversion so a logic error
357        // fails loudly instead of invoking undefined behavior.
358        let lower =
359            std::str::from_utf8(&buf[..len]).expect("ascii lowercasing preserves valid UTF-8");
360        if !STOPWORDS.contains(lower) {
361            let stemmed = STEMMER.stem(lower);
362            f(&stemmed);
363        }
364    } else {
365        let lower = raw.to_lowercase();
366        if !STOPWORDS.contains(lower.as_str()) {
367            let stemmed = STEMMER.stem(&lower);
368            f(&stemmed);
369        }
370    }
371}
372
373/// Tokenize and iterate over stemmed tokens without intermediate heap allocations.
374#[inline]
375pub fn for_each_token<F>(text: &str, mut f: F)
376where
377    F: FnMut(&str),
378{
379    let mut buf = [0u8; 64];
380
381    if text.is_ascii() {
382        let bytes = text.as_bytes();
383        let mut start = None;
384        for (i, &b) in bytes.iter().enumerate() {
385            if b.is_ascii_alphanumeric() {
386                if start.is_none() {
387                    start = Some(i);
388                }
389            } else if let Some(s) = start {
390                process_token(&text[s..i], &mut buf, &mut f);
391                start = None;
392            }
393        }
394        if let Some(s) = start {
395            process_token(&text[s..], &mut buf, &mut f);
396        }
397    } else {
398        let mut start = None;
399        for (i, c) in text.char_indices() {
400            if c.is_alphanumeric() {
401                if start.is_none() {
402                    start = Some(i);
403                }
404            } else if let Some(s) = start {
405                process_token(&text[s..i], &mut buf, &mut f);
406                start = None;
407            }
408        }
409        if let Some(s) = start {
410            process_token(&text[s..], &mut buf, &mut f);
411        }
412    }
413}
414
415/// Tokenize and iterate directly over `u32` token IDs without intermediate allocations.
416#[inline]
417pub fn for_each_token_id<F>(text: &str, mut f: F)
418where
419    F: FnMut(u32),
420{
421    for_each_token(text, |token| {
422        f(token_id(token));
423    });
424}
425
426/// Server-default text pipeline: word tokenizer (split on non-alphanumeric),
427/// Unicode lowercase, English stopword removal, English snowball stemming.
428///
429/// Matches `WordTokenizer` + default `TokensProcessor` on the Qdrant server —
430/// the same pipeline Qdrant Edge's `EdgeBm25` runs.
431pub fn tokenize(text: &str) -> Vec<String> {
432    let mut tokens = Vec::new();
433    for_each_token(text, |token| {
434        tokens.push(token.to_string());
435    });
436    tokens
437}
438
439/// Embed query text: unique token IDs (sorted) with unit weights — identical
440/// to Qdrant's `qdrant/bm25` query embedding.
441pub fn embed_query(text: &str) -> SparseVector {
442    let mut indices: Vec<u32> = Vec::with_capacity(text.len() / 6 + 1);
443    for_each_token_id(text, |id| {
444        indices.push(id);
445    });
446
447    if indices.is_empty() {
448        return SparseVector::default();
449    }
450
451    indices.sort_unstable();
452    indices.dedup();
453
454    let values = vec![1.0; indices.len()];
455    SparseVector { indices, values }
456}
457
458/// Embed document text with BM25 term-frequency saturation using Qdrant's
459/// default parameters (`k1=1.2`, `b=0.75`, `avg_len=256`).
460pub fn embed_document(text: &str) -> SparseVector {
461    embed_document_with(text, DEFAULT_K1, DEFAULT_B, DEFAULT_AVGDL)
462}
463
464/// Embed document text with validated [`Bm25Params`].
465///
466/// Prefer this over [`embed_document_with`] on configurable paths: the
467/// parameters are validated once at construction instead of sanitized per call.
468pub fn embed_document_with_params(text: &str, params: &Bm25Params) -> SparseVector {
469    embed_document_impl(text, params.k1, params.b, params.avg_len)
470}
471
472/// Embed document text with explicit BM25 parameters.
473///
474/// `avgdl <= 0` or non-finite falls back to [`DEFAULT_AVGDL`] (it is a
475/// divisor). `k1` and `b` are used as given — prefer
476/// [`embed_document_with_params`] for fail-closed validation. Frequencies are
477/// counted per token ID: on the rare murmur3 collision two terms merge into
478/// one dimension with summed counts, which keeps output deterministic across
479/// runs (the server's own per-string counting is randomized there, so collided
480/// IDs carry no cross-implementation contract).
481pub fn embed_document_with(text: &str, k1: f64, b: f64, avgdl: f64) -> SparseVector {
482    let safe_avgdl = if avgdl.is_finite() && avgdl > 0.0 {
483        avgdl
484    } else {
485        DEFAULT_AVGDL
486    };
487    embed_document_impl(text, k1, b, safe_avgdl)
488}
489
490fn embed_document_impl(text: &str, k1: f64, b: f64, avgdl: f64) -> SparseVector {
491    let mut token_ids: Vec<u32> = Vec::with_capacity(text.len() / 6 + 1);
492    for_each_token_id(text, |id| {
493        token_ids.push(id);
494    });
495
496    if token_ids.is_empty() {
497        return SparseVector::default();
498    }
499
500    let doc_len = token_ids.len() as f64;
501    let denom_scale = k1 * (1.0 - b + b * doc_len / avgdl);
502    let k1p1 = k1 + 1.0;
503
504    token_ids.sort_unstable();
505
506    let mut indices = Vec::with_capacity(token_ids.len());
507    let mut values = Vec::with_capacity(token_ids.len());
508
509    let mut i = 0;
510    while i < token_ids.len() {
511        let id = token_ids[i];
512        let mut count = 1u32;
513        while i + 1 < token_ids.len() && token_ids[i + 1] == id {
514            count += 1;
515            i += 1;
516        }
517        indices.push(id);
518        let n = count as f64;
519        values.push((n * k1p1 / (denom_scale + n)) as f32);
520        i += 1;
521    }
522
523    SparseVector { indices, values }
524}