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}