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}