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). IDF is applied
10//! server-side via the sparse vector `modifier: idf`.
11//!
12//! Because token IDs and formulas match the server, vectors produced here can
13//! be mixed with server-side `qdrant/bm25` inference on the same collection.
14
15use std::sync::LazyLock;
16
17use murmur3_32::Murmur3;
18use phf::phf_set;
19use rust_stemmers::{Algorithm, Stemmer};
20
21/// Sparse embedding (indices + values). Transport-neutral — not a protobuf type.
22#[derive(Debug, Clone, PartialEq, Default)]
23pub struct SparseVector {
24    /// Sorted token IDs (murmur3-32, wire-compatible with Qdrant `qdrant/bm25`).
25    pub indices: Vec<u32>,
26    /// Per-token weights aligned with `indices` (unit for queries, tf for docs).
27    pub values: Vec<f32>,
28}
29
30/// BM25 term-frequency saturation, matching Qdrant's `qdrant/bm25` default.
31pub const DEFAULT_K1: f64 = 1.2;
32/// BM25 document-length normalization, matching Qdrant's `qdrant/bm25` default.
33pub const DEFAULT_B: f64 = 0.75;
34/// BM25 expected average document length in tokens, matching Qdrant's
35/// `qdrant/bm25` default.
36pub const DEFAULT_AVGDL: f64 = 256.0;
37
38/// Token → `u32` ID. Wire-compatible with Qdrant's BM25 sparse vectors:
39/// murmur3 32-bit (seed 0), then `|i32|` to make it positive.
40pub fn token_id(token: &str) -> u32 {
41    (Murmur3::hash(0, token.as_bytes()) as i32).unsigned_abs()
42}
43
44/// English stopwords, identical to the Qdrant server set
45/// (`lib/segment/src/index/field_index/full_text_index/stop_words/english.rs`).
46static STOPWORDS: phf::Set<&'static str> = phf_set! {
47    "i",
48    "me",
49    "my",
50    "myself",
51    "we",
52    "our",
53    "ours",
54    "ourselves",
55    "you",
56    "you're",
57    "you've",
58    "you'll",
59    "you'd",
60    "your",
61    "yours",
62    "yourself",
63    "yourselves",
64    "he",
65    "him",
66    "his",
67    "himself",
68    "she",
69    "she's",
70    "her",
71    "hers",
72    "herself",
73    "it",
74    "it's",
75    "its",
76    "itself",
77    "they",
78    "them",
79    "their",
80    "theirs",
81    "themselves",
82    "what",
83    "which",
84    "who",
85    "whom",
86    "this",
87    "that",
88    "that'll",
89    "these",
90    "those",
91    "am",
92    "is",
93    "are",
94    "was",
95    "were",
96    "be",
97    "been",
98    "being",
99    "have",
100    "has",
101    "had",
102    "having",
103    "do",
104    "does",
105    "did",
106    "doing",
107    "a",
108    "an",
109    "the",
110    "and",
111    "but",
112    "if",
113    "or",
114    "because",
115    "as",
116    "until",
117    "while",
118    "of",
119    "at",
120    "by",
121    "for",
122    "with",
123    "about",
124    "against",
125    "between",
126    "into",
127    "through",
128    "during",
129    "before",
130    "after",
131    "above",
132    "below",
133    "to",
134    "from",
135    "up",
136    "down",
137    "in",
138    "out",
139    "on",
140    "off",
141    "over",
142    "under",
143    "again",
144    "further",
145    "then",
146    "once",
147    "here",
148    "there",
149    "when",
150    "where",
151    "why",
152    "how",
153    "all",
154    "any",
155    "both",
156    "each",
157    "few",
158    "more",
159    "most",
160    "other",
161    "some",
162    "such",
163    "no",
164    "nor",
165    "not",
166    "only",
167    "own",
168    "same",
169    "so",
170    "than",
171    "too",
172    "very",
173    "s",
174    "t",
175    "can",
176    "will",
177    "just",
178    "don",
179    "don't",
180    "should",
181    "should've",
182    "now",
183    "d",
184    "ll",
185    "m",
186    "o",
187    "re",
188    "ve",
189    "y",
190    "ain",
191    "aren",
192    "aren't",
193    "couldn",
194    "couldn't",
195    "didn",
196    "didn't",
197    "doesn",
198    "doesn't",
199    "hadn",
200    "hadn't",
201    "hasn",
202    "hasn't",
203    "haven",
204    "haven't",
205    "isn",
206    "isn't",
207    "ma",
208    "mightn",
209    "mightn't",
210    "mustn",
211    "mustn't",
212    "needn",
213    "needn't",
214    "shan",
215    "shan't",
216    "shouldn",
217    "shouldn't",
218    "wasn",
219    "wasn't",
220    "weren",
221    "weren't",
222    "won",
223    "won't",
224    "wouldn",
225    "wouldn't",
226};
227
228static STEMMER: LazyLock<Stemmer> = LazyLock::new(|| Stemmer::create(Algorithm::English));
229
230#[inline]
231fn process_token<F>(raw: &str, buf: &mut [u8; 64], f: &mut F)
232where
233    F: FnMut(&str),
234{
235    let bytes = raw.as_bytes();
236    let len = bytes.len();
237    if len <= buf.len() && raw.is_ascii() {
238        for (j, &b) in bytes.iter().enumerate() {
239            buf[j] = b.to_ascii_lowercase();
240        }
241        // Safe: `raw.is_ascii()` guarantees `bytes` is ASCII, and
242        // `to_ascii_lowercase()` maps ASCII to ASCII, so `buf[..len]`
243        // is valid UTF-8. Use the checked conversion so a logic error
244        // fails loudly instead of invoking undefined behavior.
245        let lower =
246            std::str::from_utf8(&buf[..len]).expect("ascii lowercasing preserves valid UTF-8");
247        if !STOPWORDS.contains(lower) {
248            let stemmed = STEMMER.stem(lower);
249            f(&stemmed);
250        }
251    } else {
252        let lower = raw.to_lowercase();
253        if !STOPWORDS.contains(lower.as_str()) {
254            let stemmed = STEMMER.stem(&lower);
255            f(&stemmed);
256        }
257    }
258}
259
260/// Tokenize and iterate over stemmed tokens without intermediate heap allocations.
261#[inline]
262pub fn for_each_token<F>(text: &str, mut f: F)
263where
264    F: FnMut(&str),
265{
266    let mut buf = [0u8; 64];
267
268    if text.is_ascii() {
269        let bytes = text.as_bytes();
270        let mut start = None;
271        for (i, &b) in bytes.iter().enumerate() {
272            if b.is_ascii_alphanumeric() {
273                if start.is_none() {
274                    start = Some(i);
275                }
276            } else if let Some(s) = start {
277                process_token(&text[s..i], &mut buf, &mut f);
278                start = None;
279            }
280        }
281        if let Some(s) = start {
282            process_token(&text[s..], &mut buf, &mut f);
283        }
284    } else {
285        let mut start = None;
286        for (i, c) in text.char_indices() {
287            if c.is_alphanumeric() {
288                if start.is_none() {
289                    start = Some(i);
290                }
291            } else if let Some(s) = start {
292                process_token(&text[s..i], &mut buf, &mut f);
293                start = None;
294            }
295        }
296        if let Some(s) = start {
297            process_token(&text[s..], &mut buf, &mut f);
298        }
299    }
300}
301
302/// Tokenize and iterate directly over `u32` token IDs without intermediate allocations.
303#[inline]
304pub fn for_each_token_id<F>(text: &str, mut f: F)
305where
306    F: FnMut(u32),
307{
308    for_each_token(text, |token| {
309        f(token_id(token));
310    });
311}
312
313/// Server-default text pipeline: word tokenizer (split on non-alphanumeric),
314/// Unicode lowercase, English stopword removal, English snowball stemming.
315///
316/// Matches `WordTokenizer` + default `TokensProcessor` on the Qdrant server —
317/// the same pipeline Qdrant Edge's `EdgeBm25` runs.
318pub fn tokenize(text: &str) -> Vec<String> {
319    let mut tokens = Vec::new();
320    for_each_token(text, |token| {
321        tokens.push(token.to_string());
322    });
323    tokens
324}
325
326/// Embed query text: unique token IDs (sorted) with unit weights — identical
327/// to Qdrant's `qdrant/bm25` query embedding.
328pub fn embed_query(text: &str) -> SparseVector {
329    let mut indices: Vec<u32> = Vec::with_capacity(text.len() / 6 + 1);
330    for_each_token_id(text, |id| {
331        indices.push(id);
332    });
333
334    if indices.is_empty() {
335        return SparseVector::default();
336    }
337
338    indices.sort_unstable();
339    indices.dedup();
340
341    let values = vec![1.0; indices.len()];
342    SparseVector { indices, values }
343}
344
345/// Embed document text with BM25 term-frequency saturation using Qdrant's
346/// default parameters (`k1=1.2`, `b=0.75`, `avg_len=256`).
347pub fn embed_document(text: &str) -> SparseVector {
348    embed_document_with(text, DEFAULT_K1, DEFAULT_B, DEFAULT_AVGDL)
349}
350
351/// Embed document text with explicit BM25 parameters.
352///
353/// `avgdl <= 0` or non-finite falls back to [`DEFAULT_AVGDL`] (it is a
354/// divisor). Frequencies are counted per token ID: on the rare murmur3
355/// collision two terms merge into one dimension with summed counts, which
356/// keeps output deterministic across runs (the server's own per-string
357/// counting is randomized there, so collided IDs carry no cross-implementation
358/// contract).
359pub fn embed_document_with(text: &str, k1: f64, b: f64, avgdl: f64) -> SparseVector {
360    let mut token_ids: Vec<u32> = Vec::with_capacity(text.len() / 6 + 1);
361    for_each_token_id(text, |id| {
362        token_ids.push(id);
363    });
364
365    if token_ids.is_empty() {
366        return SparseVector::default();
367    }
368
369    let doc_len = token_ids.len() as f64;
370    let safe_avgdl = if avgdl.is_finite() && avgdl > 0.0 {
371        avgdl
372    } else {
373        DEFAULT_AVGDL
374    };
375    let denom_scale = k1 * (1.0 - b + b * doc_len / safe_avgdl);
376    let k1p1 = k1 + 1.0;
377
378    token_ids.sort_unstable();
379
380    let mut indices = Vec::with_capacity(token_ids.len());
381    let mut values = Vec::with_capacity(token_ids.len());
382
383    let mut i = 0;
384    while i < token_ids.len() {
385        let id = token_ids[i];
386        let mut count = 1u32;
387        while i + 1 < token_ids.len() && token_ids[i + 1] == id {
388            count += 1;
389            i += 1;
390        }
391        indices.push(id);
392        let n = count as f64;
393        values.push((n * k1p1 / (denom_scale + n)) as f32);
394        i += 1;
395    }
396
397    SparseVector { indices, values }
398}