Skip to main content

qql_embed/
bm25_text.rs

1//! Full BM25 text-processing pipeline, option-compatible with Qdrant.
2//!
3//! Qdrant's `Bm25Config` (REST) / `EdgeBm25Config` (edge) exposes, per sparse
4//! vector: `k`, `b`, `avg_len`, `tokenizer`, `language`, `lowercase`,
5//! `ascii_folding`, `stopwords`, `stemmer`, `min_token_len`, `max_token_len`.
6//! [`Bm25TextConfig`] mirrors that surface with the same defaults (word
7//! tokenizer, English, lowercase on, folding off, language stopwords/stemmer,
8//! no length limits), and [`Bm25Pipeline`] executes it in Qdrant's exact
9//! stage order: fold → lowercase → stopwords → stem → length check.
10//!
11//! Wire compatibility notes (all verified against Qdrant's implementation):
12//! - Token IDs are Qdrant's `token_id` (murmur3-32 seed 0, `unsigned_abs`).
13//! - The TF formula uses the same fused operation order as `lib/bm25`.
14//! - `k1 = 0` is accepted (binary weighting), like Qdrant's validator.
15//! - Stopword lists are ported verbatim from Qdrant's segment crate.
16//! - Folding uses Qdrant's Lucene-derived mapping.
17//! - `Multilingual` needs the `charabia` tokenizer and fails closed without
18//!   it; Japanese falls back to generic segmentation (Qdrant uses a
19//!   `vaporetto` model file we do not ship) — both are explicit errors, never
20//!   silent degradation.
21
22use std::borrow::Cow;
23use std::collections::HashSet;
24use std::sync::LazyLock;
25
26use qql_core::error::QqlError;
27use rust_stemmers::Algorithm;
28use rust_stemmers::Stemmer as SnowballStemmer;
29
30use super::bm25_fold::fold_to_ascii_cow;
31use super::bm25_lang::Language;
32use super::bm25_stopwords::stopwords_for;
33use super::sparse::{Bm25Params, SparseVector, token_id};
34
35fn config_error(message: String) -> QqlError {
36    QqlError::validation("QQL-VALIDATION-CONFIG", message, None)
37}
38
39/// Tokenizer, mirroring Qdrant's `TokenizerType` names.
40///
41/// `Multilingual` parses but fails closed at build time: it needs the
42/// `charabia`/`vaporetto` segmentation stack, which `qql-embed` deliberately
43/// does not depend on (lean core for WASM/edge; no model files to ship).
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum Tokenizer {
46    /// Split on non-alphanumeric boundaries (Qdrant default).
47    #[default]
48    Word,
49    /// Split on Unicode whitespace only.
50    Whitespace,
51    /// Document side expands all n-grams; query side keeps the longest only.
52    Prefix,
53    /// Script-aware segmentation (requires tokenizer support not compiled in).
54    Multilingual,
55}
56
57impl Tokenizer {
58    /// Parse a Qdrant `tokenizer` name (`"word"`, `"whitespace"`, `"prefix"`,
59    /// `"multilingual"`), ASCII-case-insensitively (superset of Qdrant's
60    /// case-sensitive spelling, same accepted set). Anything else fails closed.
61    pub fn parse(name: &str) -> Result<Self, QqlError> {
62        match name.to_ascii_lowercase().as_str() {
63            "word" => Ok(Self::Word),
64            "whitespace" => Ok(Self::Whitespace),
65            "prefix" => Ok(Self::Prefix),
66            "multilingual" => Ok(Self::Multilingual),
67            _ => Err(config_error(format!(
68                "unsupported bm25 tokenizer: {name:?}"
69            ))),
70        }
71    }
72
73    /// Canonical Qdrant spelling.
74    pub fn name(self) -> &'static str {
75        match self {
76            Self::Word => "word",
77            Self::Whitespace => "whitespace",
78            Self::Prefix => "prefix",
79            Self::Multilingual => "multilingual",
80        }
81    }
82}
83
84/// Stemmer selection, mirroring Qdrant's `Option<StemmingAlgorithm>`.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Stemmer {
87    /// Snowball stemmer for an explicit language (Qdrant's `Snowball`).
88    /// Covers Qdrant's `SnowballLanguage` set, including Armenian and Tamil
89    /// (which have no `Language` variant and are only reachable this way —
90    /// Qdrant has no stopword lists for them either).
91    Snowball(Language),
92    /// Armenian Snowball stemmer (explicit-only, like Qdrant).
93    Armenian,
94    /// Tamil Snowball stemmer (explicit-only, like Qdrant).
95    Tamil,
96    /// Explicitly no stemming (Qdrant's `{"type": "none"}`). Differs from
97    /// leaving `stemmer` unset, which falls back to the language default.
98    Disabled,
99}
100
101impl Stemmer {
102    /// Parse `"none"` (disable), a language name/alias (its Snowball
103    /// stemmer), or `"armenian"`/`"hy"`/`"tamil"`/`"ta"`. Anything else —
104    /// including languages without any Snowball stemmer — fails closed.
105    pub fn parse(name: &str) -> Result<Self, QqlError> {
106        if name.eq_ignore_ascii_case("none") {
107            return Ok(Self::Disabled);
108        }
109        let lower = name.to_ascii_lowercase();
110        if lower == "armenian" || lower == "hy" {
111            return Ok(Self::Armenian);
112        }
113        if lower == "tamil" || lower == "ta" {
114            return Ok(Self::Tamil);
115        }
116        let language = Language::parse(name)?;
117        if language.stem_algorithm().is_none() {
118            return Err(config_error(format!(
119                "bm25 stemmer unavailable for language {:?}: no Snowball stemmer (use \"none\" to disable)",
120                language.name()
121            )));
122        }
123        Ok(Self::Snowball(language))
124    }
125
126    /// The Snowball algorithm, if this selection stems at all.
127    pub fn algorithm(self) -> Option<Algorithm> {
128        match self {
129            Self::Snowball(language) => language.stem_algorithm(),
130            Self::Armenian => Some(Algorithm::Armenian),
131            Self::Tamil => Some(Algorithm::Tamil),
132            Self::Disabled => None,
133        }
134    }
135}
136
137/// Stopword selection, mirroring Qdrant's `StopwordsInterface`.
138///
139/// `None` (the `stopwords` field left unset) keeps the processing
140/// language's list. `Some` **replaces** it: an empty [`Stopwords`] disables
141/// filtering entirely (Qdrant's `Set` default), otherwise the listed
142/// languages plus custom words are merged.
143#[derive(Debug, Clone, PartialEq, Eq, Default)]
144pub struct Stopwords {
145    /// Additional language lists to merge.
146    pub languages: Vec<Language>,
147    /// Custom words to merge (compared post-normalization, like Qdrant,
148    /// which lowercases entries when `lowercase` is on).
149    pub custom: Vec<String>,
150}
151
152/// Full BM25 text-processing configuration — mirrors Qdrant's `Bm25Config`.
153///
154/// Construct via [`Bm25TextConfig::resolve`] (flat stringly options, the
155/// single choke point every config surface calls) or struct-literal with
156/// `..Default::default()`. Numeric parameters validate through
157/// [`Bm25Params`]; names parse through [`Language`]/[`Tokenizer`]/[`Stemmer`].
158#[derive(Debug, Clone, PartialEq)]
159pub struct Bm25TextConfig {
160    /// Validated `k1`/`b`/`avg_len`.
161    pub params: Bm25Params,
162    /// Tokenizer (default [`Tokenizer::Word`]).
163    pub tokenizer: Tokenizer,
164    /// Language for default stopwords/stemmer (default English, like Qdrant).
165    pub language: Language,
166    /// Lowercase before matching (default true, like Qdrant).
167    pub lowercase: bool,
168    /// Lucene ASCII folding before lowercasing (default false, like Qdrant).
169    pub ascii_folding: bool,
170    /// Stopword override (`None` = language default).
171    pub stopwords: Option<Stopwords>,
172    /// Stemmer override (`None` = language default).
173    pub stemmer: Option<Stemmer>,
174    /// Drop tokens shorter than this (chars, always enforced, like Qdrant).
175    pub min_token_len: Option<usize>,
176    /// Drop tokens longer than this (chars; on the document path, like
177    /// Qdrant — the prefix query path truncates instead).
178    pub max_token_len: Option<usize>,
179}
180
181impl Default for Bm25TextConfig {
182    /// Qdrant server defaults: `k1 = 1.2`, `b = 0.75`, `avg_len = 256`,
183    /// word tokenizer, English, lowercase on, folding off, language
184    /// stopwords/stemmer, no length limits.
185    fn default() -> Self {
186        Self {
187            params: Bm25Params::default(),
188            tokenizer: Tokenizer::Word,
189            language: Language::English,
190            lowercase: true,
191            ascii_folding: false,
192            stopwords: None,
193            stemmer: None,
194            min_token_len: None,
195            max_token_len: None,
196        }
197    }
198}
199
200impl Bm25TextConfig {
201    /// Resolve flat options into a validated config. `None` keeps the
202    /// corresponding default; invalid names or numbers fail closed with
203    /// `QQL-VALIDATION-CONFIG`.
204    ///
205    /// - `stopwords`: `None` = language default; `Some(list)` **replaces**
206    ///   it with exactly `list` (empty disables filtering). Use
207    ///   `stopwords_languages` to merge additional language lists instead.
208    /// - `stopwords_languages`: additional language lists merged with
209    ///   `stopwords` (Qdrant `Set.languages`). An explicit selection
210    ///   replaces the default, so include the processing language to keep
211    ///   its words. Invalid names fail closed.
212    /// - `stemmer`: `None` = language default; `Some("none")` disables;
213    ///   `Some("<language>")` overrides.
214    /// - `tokenizer`: `"multilingual"` parses here but fails at embed time
215    ///   (see [`Tokenizer::Multilingual`]).
216    #[allow(clippy::too_many_arguments)]
217    pub fn resolve(
218        k1: Option<f64>,
219        b: Option<f64>,
220        avg_len: Option<f64>,
221        language: Option<&str>,
222        tokenizer: Option<&str>,
223        lowercase: Option<bool>,
224        ascii_folding: Option<bool>,
225        stopwords: Option<Vec<String>>,
226        stemmer: Option<&str>,
227        min_token_len: Option<usize>,
228        max_token_len: Option<usize>,
229        stopwords_languages: Option<Vec<String>>,
230    ) -> Result<Self, QqlError> {
231        let mut languages = Vec::new();
232        if let Some(names) = stopwords_languages {
233            for name in &names {
234                languages.push(Language::parse(name)?);
235            }
236        }
237        Ok(Self {
238            params: Bm25Params::resolve(k1, b, avg_len)?,
239            tokenizer: match tokenizer {
240                None => Tokenizer::Word,
241                Some(name) => Tokenizer::parse(name)?,
242            },
243            language: match language {
244                None => Language::English,
245                Some(name) => Language::parse(name)?,
246            },
247            lowercase: lowercase.unwrap_or(true),
248            ascii_folding: ascii_folding.unwrap_or(false),
249            stopwords: match (stopwords, languages.is_empty()) {
250                // No override at all: the processing language's list.
251                (None, true) => None,
252                // Otherwise exactly the merged selection (Qdrant `Set`
253                // semantics: an explicit list replaces the default, so
254                // include the processing language to keep its words).
255                (custom, _) => Some(Stopwords {
256                    languages,
257                    custom: custom.unwrap_or_default(),
258                }),
259            },
260            stemmer: match stemmer {
261                None => None,
262                Some(name) => Some(Stemmer::parse(name)?),
263            },
264            min_token_len,
265            max_token_len,
266        })
267    }
268
269    /// Update text knobs over the current config: `None` (or empty names)
270    /// keeps `self`'s value, explicit values replace it. Unlike
271    /// [`Bm25TextConfig::resolve`], where `None` means "Qdrant default",
272    /// this is the incremental-update path (WASM `setBm25Text`, REPL-style
273    /// tuning): previously configured values survive untouched knobs.
274    /// Names validate exactly like [`Bm25TextConfig::resolve`]. There is no
275    /// reset-to-default signal: to restore defaults, resolve a fresh
276    /// [`Bm25TextConfig::default`] instead of updating.
277    #[allow(clippy::too_many_arguments)]
278    pub fn with_text_options(
279        &self,
280        language: Option<&str>,
281        tokenizer: Option<&str>,
282        lowercase: Option<bool>,
283        ascii_folding: Option<bool>,
284        stopwords: Option<Vec<String>>,
285        stemmer: Option<&str>,
286        min_token_len: Option<usize>,
287        max_token_len: Option<usize>,
288        stopwords_languages: Option<Vec<String>>,
289    ) -> Result<Self, QqlError> {
290        let mut next = self.clone();
291        if let Some(name) = language.filter(|s| !s.is_empty()) {
292            next.language = Language::parse(name)?;
293        }
294        if let Some(name) = tokenizer.filter(|s| !s.is_empty()) {
295            next.tokenizer = Tokenizer::parse(name)?;
296        }
297        if let Some(lowercase) = lowercase {
298            next.lowercase = lowercase;
299        }
300        if let Some(ascii_folding) = ascii_folding {
301            next.ascii_folding = ascii_folding;
302        }
303        if stopwords.is_some() || stopwords_languages.is_some() {
304            let mut languages = Vec::new();
305            if let Some(names) = stopwords_languages {
306                for name in &names {
307                    languages.push(Language::parse(name)?);
308                }
309            }
310            next.stopwords = Some(Stopwords {
311                languages,
312                custom: stopwords.unwrap_or_default(),
313            });
314        }
315        if let Some(name) = stemmer.filter(|s| !s.is_empty()) {
316            next.stemmer = Some(Stemmer::parse(name)?);
317        }
318        if min_token_len.is_some() {
319            next.min_token_len = min_token_len;
320        }
321        if max_token_len.is_some() {
322            next.max_token_len = max_token_len;
323        }
324        Ok(next)
325    }
326
327    /// Compile into an executable pipeline. Infallible: names already
328    /// validated at parse/resolve time; remaining choices are total.
329    pub fn pipeline(&self) -> Bm25Pipeline {
330        let stemmer = match self.stemmer {
331            Some(Stemmer::Disabled) => None,
332            Some(stemmer) => stemmer.algorithm().map(SnowballStemmer::create),
333            None => self.language.stem_algorithm().map(SnowballStemmer::create),
334        };
335        // Mirror Qdrant's `StopwordsFilter`: entries are lowercased at build
336        // time when `lowercase` is on, and matching runs post-normalization.
337        let mut stopwords = HashSet::new();
338        let mut insert = |word: &str| {
339            if self.lowercase {
340                stopwords.insert(word.to_lowercase());
341            } else {
342                stopwords.insert(word.to_string());
343            }
344        };
345        match &self.stopwords {
346            None => {
347                for word in stopwords_for(self.language) {
348                    insert(word);
349                }
350            }
351            Some(selection) => {
352                for language in &selection.languages {
353                    for word in stopwords_for(*language) {
354                        insert(word);
355                    }
356                }
357                for word in &selection.custom {
358                    insert(word.as_str());
359                }
360            }
361        }
362        Bm25Pipeline {
363            params: self.params,
364            tokenizer: self.tokenizer,
365            lowercase: self.lowercase,
366            ascii_folding: self.ascii_folding,
367            stopwords,
368            stemmer,
369            min_token_len: self.min_token_len,
370            max_token_len: self.max_token_len,
371        }
372    }
373}
374
375/// Compiled BM25 pipeline: build once per config, embed many texts.
376///
377/// Construct via [`Bm25TextConfig::pipeline`]. The default English pipeline
378/// backing the [`crate::sparse`] free functions is shared process-wide.
379pub struct Bm25Pipeline {
380    params: Bm25Params,
381    tokenizer: Tokenizer,
382    lowercase: bool,
383    ascii_folding: bool,
384    stopwords: HashSet<String>,
385    stemmer: Option<SnowballStemmer>,
386    min_token_len: Option<usize>,
387    max_token_len: Option<usize>,
388}
389
390impl Bm25Pipeline {
391    /// Default text knobs with explicit numeric parameters.
392    pub fn with_params(params: &Bm25Params) -> Self {
393        Bm25TextConfig {
394            params: *params,
395            ..Bm25TextConfig::default()
396        }
397        .pipeline()
398    }
399
400    /// Qdrant's stage order: fold → lowercase → stopwords → stem → length.
401    /// `check_max_len` is Qdrant's per-call flag: word/whitespace pass true
402    /// on both paths; the prefix document path passes false (the n-gram loop
403    /// bounds length instead); the prefix query path truncates afterwards.
404    fn process_token<'a>(
405        &self,
406        raw: &'a str,
407        is_query: bool,
408        check_max_len: bool,
409    ) -> Option<Cow<'a, str>> {
410        if raw.is_empty() {
411            return None;
412        }
413        let mut token: Cow<'a, str> = Cow::Borrowed(raw);
414        if self.ascii_folding {
415            token = fold_to_ascii_cow(token);
416        }
417        if self.lowercase {
418            token = Cow::Owned(token.to_lowercase());
419        }
420        let prefix_query = is_query && self.tokenizer == Tokenizer::Prefix;
421        if !prefix_query && self.stopwords.contains(token.as_ref()) {
422            return None;
423        }
424        if let Some(stemmer) = self.stemmer.as_ref() {
425            token = Cow::Owned(stemmer.stem(token.as_ref()).into_owned());
426        }
427        if self
428            .min_token_len
429            .is_some_and(|min| token.chars().count() < min)
430        {
431            return None;
432        }
433        if check_max_len
434            && self
435                .max_token_len
436                .is_some_and(|max| token.chars().count() > max)
437        {
438            return None;
439        }
440        Some(token)
441    }
442
443    /// Iterate processed tokens (`is_query` selects the query path).
444    fn for_each<F>(&self, text: &str, is_query: bool, mut f: F) -> Result<(), QqlError>
445    where
446        F: FnMut(&str),
447    {
448        match self.tokenizer {
449            Tokenizer::Word => {
450                for raw in text.split(|c: char| !c.is_alphanumeric()) {
451                    if let Some(token) = self.process_token(raw, is_query, true) {
452                        f(token.as_ref());
453                    }
454                }
455            }
456            Tokenizer::Whitespace => {
457                for raw in text.split_whitespace() {
458                    if let Some(token) = self.process_token(raw, is_query, true) {
459                        f(token.as_ref());
460                    }
461                }
462            }
463            Tokenizer::Prefix => {
464                if is_query {
465                    self.for_each_prefix_query(text, &mut f);
466                } else {
467                    self.for_each_prefix_doc(text, &mut f);
468                }
469            }
470            Tokenizer::Multilingual => {
471                return Err(config_error(
472                    "bm25 tokenizer \"multilingual\" needs script-aware segmentation (charabia/vaporetto), which is not compiled in; use \"word\" or \"whitespace\"".to_string(),
473                ));
474            }
475        }
476        Ok(())
477    }
478
479    /// Document path: expand every n-gram in `min..=max` (Qdrant's
480    /// `PrefixTokenizer::tokenize`; `max` unbounded emits up to the full word
481    /// and always emits the full word last). Note: `min_token_len = 0` emits
482    /// a phantom empty token (`nth(0)`), exactly like Qdrant's own
483    /// implementation — shared quirk kept for parity, not fixed.
484    fn for_each_prefix_doc<F>(&self, text: &str, mut f: F)
485    where
486        F: FnMut(&str),
487    {
488        let min_ngram = self.min_token_len.unwrap_or(1);
489        let max_ngram = self.max_token_len.unwrap_or(usize::MAX);
490        for raw in text.split(|c: char| !c.is_alphanumeric()) {
491            let Some(word) = self.process_token(raw, false, false) else {
492                continue;
493            };
494            for n in min_ngram..=max_ngram {
495                match word.char_indices().map(|(i, _)| i).nth(n) {
496                    Some(end) => f(&word[..end]),
497                    None => {
498                        f(word.as_ref());
499                        break;
500                    }
501                }
502            }
503        }
504    }
505
506    /// Query path: longest n-gram only, no stopwords (Qdrant's
507    /// `PrefixTokenizer::tokenize_query`).
508    fn for_each_prefix_query<F>(&self, text: &str, mut f: F)
509    where
510        F: FnMut(&str),
511    {
512        let max_ngram = self.max_token_len.unwrap_or(usize::MAX);
513        for raw in text.split(|c: char| !c.is_alphanumeric()) {
514            if raw.is_empty() {
515                continue;
516            }
517            // No stopwords and no max-as-filter here: over-long words
518            // truncate to `max_ngram` below instead of dropping.
519            let Some(word) = self.process_token(raw, true, false) else {
520                continue;
521            };
522            match word.char_indices().map(|(i, _)| i).nth(max_ngram) {
523                Some(end) => f(&word[..end]),
524                None => f(word.as_ref()),
525            }
526        }
527    }
528
529    /// Processed document tokens (post-pipeline, in order, duplicates kept).
530    /// Used by the `avg_len` estimator so the estimate measures exactly the
531    /// `doc_len` the TF formula consumes.
532    pub fn doc_tokens(&self, text: &str) -> Result<Vec<String>, QqlError> {
533        let mut tokens = Vec::new();
534        self.for_each(text, false, |token| {
535            tokens.push(token.to_string());
536        })?;
537        Ok(tokens)
538    }
539
540    /// Processed query tokens (query path: prefix keeps the longest n-gram
541    /// only and skips stopwords; other tokenizers match the document path).
542    pub(crate) fn for_each_query<F>(&self, text: &str, f: F) -> Result<(), QqlError>
543    where
544        F: FnMut(&str),
545    {
546        self.for_each(text, true, f)
547    }
548
549    /// Post-pipeline token count of one document (the formula's `doc_len`).
550    pub fn token_count(&self, text: &str) -> Result<usize, QqlError> {
551        let mut count = 0;
552        self.for_each(text, false, |_| {
553            count += 1;
554        })?;
555        Ok(count)
556    }
557
558    /// Embed query text: unique token IDs (sorted) with unit weights —
559    /// identical to Qdrant's `qdrant/bm25` query embedding.
560    pub fn embed_query(&self, text: &str) -> Result<SparseVector, QqlError> {
561        let mut indices = Vec::with_capacity(text.len() / 6 + 1);
562        self.for_each_query(text, |token| {
563            indices.push(token_id(token));
564        })?;
565        if indices.is_empty() {
566            return Ok(SparseVector::default());
567        }
568        indices.sort_unstable();
569        indices.dedup();
570        let values = vec![1.0; indices.len()];
571        Ok(SparseVector { indices, values })
572    }
573
574    /// Embed document text with this pipeline's validated [`Bm25Params`].
575    ///
576    /// Frequencies count per token ID: on the rare murmur3 collision two
577    /// terms merge into one dimension with summed counts, keeping output
578    /// deterministic (the server counts per string, so collided IDs carry no
579    /// cross-implementation contract — same caveat as before).
580    pub fn embed_document(&self, text: &str) -> Result<SparseVector, QqlError> {
581        self.embed_document_with(
582            text,
583            self.params.k1(),
584            self.params.b(),
585            self.params.avg_len(),
586        )
587    }
588
589    /// Embed with explicit parameters, used as given (no validation — the
590    /// caller sanitizes, mirroring [`crate::sparse::embed_document_with`]).
591    /// The formula is the same fused op order as [`Bm25Pipeline::embed_document`].
592    pub(crate) fn embed_document_with(
593        &self,
594        text: &str,
595        k1: f64,
596        b: f64,
597        avgdl: f64,
598    ) -> Result<SparseVector, QqlError> {
599        let mut token_ids: Vec<u32> = Vec::with_capacity(text.len() / 6 + 1);
600        self.for_each(text, false, |token| {
601            token_ids.push(token_id(token));
602        })?;
603        if token_ids.is_empty() {
604            return Ok(SparseVector::default());
605        }
606        let doc_len = token_ids.len() as f64;
607        // Same fused operation order as Qdrant `lib/bm25`, so weights agree
608        // bit-for-bit absent murmur3 collisions — not just algebraically:
609        // `n * (k1 + 1)` over `k1.mul_add(1 - b + b * doc_len / avgdl, n)`.
610        // (On a collision Qdrant counts per string and overwrites while we
611        // count per ID and sum, so collided IDs carry no cross-impl contract
612        // either way — same caveat as the server documents.)
613        let k1p1 = k1 + 1.0;
614        let norm = 1.0 - b + b * doc_len / avgdl;
615        token_ids.sort_unstable();
616        let mut indices = Vec::with_capacity(token_ids.len());
617        let mut values = Vec::with_capacity(token_ids.len());
618        let mut i = 0;
619        while i < token_ids.len() {
620            let id = token_ids[i];
621            let mut count = 1u32;
622            while i + 1 < token_ids.len() && token_ids[i + 1] == id {
623                count += 1;
624                i += 1;
625            }
626            indices.push(id);
627            let n = count as f64;
628            values.push((n * k1p1 / k1.mul_add(norm, n)) as f32);
629            i += 1;
630        }
631        Ok(SparseVector { indices, values })
632    }
633}
634
635static DEFAULT_PIPELINE: LazyLock<Bm25Pipeline> =
636    LazyLock::new(|| Bm25TextConfig::default().pipeline());
637
638/// Default English pipeline backing the [`crate::sparse`] free functions.
639pub fn default_pipeline() -> &'static Bm25Pipeline {
640    &DEFAULT_PIPELINE
641}
642
643/// Mean post-pipeline token count over sampled document texts — the
644/// estimator for a corpus-true `avg_len`.
645///
646/// "Real data" in one function: pass the actual field texts (e.g. from a
647/// `SCROLL` sample) and get the average `doc_len` the TF formula consumes,
648/// measured with the same pipeline that will embed the writes. Returns
649/// `None` when the sample holds no documents or no tokens at all (an empty
650/// corpus has no meaningful average — keep the default instead of
651/// dividing by zero or storing `avg_len = 0`, which validation rejects).
652#[derive(Debug, Clone, Copy, PartialEq)]
653pub struct AvgLenEstimate {
654    /// Mean post-pipeline tokens per document (`> 0` when returned).
655    pub mean: f64,
656    /// Documents measured.
657    pub docs: usize,
658}
659
660/// Estimate a corpus-true `avg_len` from sampled document texts.
661///
662/// Measures each text with `pipeline.token_count` (the same `doc_len` the
663/// TF formula consumes) and returns the mean. Returns `None` when the
664/// sample holds no documents or no tokens at all — an empty corpus has no
665/// meaningful average, so callers should keep the default instead of
666/// storing `avg_len = 0` (which [`Bm25Params`] validation rejects).
667pub fn estimate_avg_len<'a, I>(
668    texts: I,
669    pipeline: &Bm25Pipeline,
670) -> Result<Option<AvgLenEstimate>, QqlError>
671where
672    I: IntoIterator<Item = &'a str>,
673{
674    let mut docs = 0usize;
675    let mut total = 0usize;
676    for text in texts {
677        docs += 1;
678        total += pipeline.token_count(text)?;
679    }
680    if docs == 0 || total == 0 {
681        return Ok(None);
682    }
683    Ok(Some(AvgLenEstimate {
684        mean: total as f64 / docs as f64,
685        docs,
686    }))
687}