Skip to main content

holographic_memory/core/
documents.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::{bail, ensure, Result};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8use std::ops::Deref;
9use std::sync::{Arc, OnceLock};
10
11use super::{encoding::TextEncoder, entangled::EntangledHVec};
12
13pub(crate) mod binary;
14#[cfg(test)]
15mod compact_term_tests;
16mod packed_terms;
17use packed_terms::StoredTerms;
18
19mod dense;
20mod index;
21mod listing;
22mod memory;
23mod postings;
24mod search;
25mod text;
26mod vector_index;
27pub(crate) use dense::{DenseIndexSnapshot, TrainedDenseIndex};
28pub(crate) use index::DocumentWithText;
29pub(crate) use index::{canonical_staging_path, opaque_counter_start, DocumentStore};
30pub use listing::DocumentRecord;
31pub(crate) use listing::{count, get_record, list_records};
32pub use memory::{MemoryDocumentStore, MemoryStoreStats};
33pub(crate) use search::{CapturedSearch, PreparedSearch};
34pub(crate) use text::TextSegment;
35pub(crate) use vector_index::{
36    build_vector_snapshot, capture_vector_rows, search_vector_snapshot, vector_index_status,
37    VectorIndexSlot,
38};
39pub use vector_index::{
40    GraphUnavailable, VectorIndex, VectorIndexKind, VectorIndexMode, VectorIndexStatus,
41    VectorQueryOptions, DEFAULT_EF, DEFAULT_GRAPH_MIN_ROWS, MAX_EF, MAX_K, REBUILD_AFTER_MUTATIONS,
42    SNAPSHOT_MIN_ROWS,
43};
44
45/// Documents, and chunks, one `memorize_documents_with` call accepts.
46pub const MAX_BATCH_ITEMS: usize = 4096;
47
48/// A document batch over one of the engine's batch limits (items, chunks, source or metadata
49/// bytes, embedding values, transaction bytes). Nothing was written; a smaller batch can succeed.
50#[derive(Debug, Clone, Copy)]
51pub struct BatchTooLarge(pub &'static str);
52
53impl std::fmt::Display for BatchTooLarge {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.write_str(self.0)
56    }
57}
58
59impl std::error::Error for BatchTooLarge {}
60pub(crate) const MAX_DOCUMENT_BYTES: usize = 8 * 1024 * 1024;
61pub(crate) const MAX_CHUNK_BYTES: usize = 64 * 1024;
62pub(crate) const CHUNK_PREFIX: &str = "hms:chunk:";
63
64pub(crate) fn capture_dense_index(
65    store: &DocumentStore,
66    dimensions: usize,
67) -> Result<Option<DenseIndexSnapshot>> {
68    dense::capture_dense_index(store, dimensions)
69}
70
71pub(crate) fn build_dense_index(snapshot: DenseIndexSnapshot) -> Result<Option<TrainedDenseIndex>> {
72    dense::build_dense_index(snapshot)
73}
74
75pub(crate) fn publish_dense_index(store: &mut DocumentStore, trained: &TrainedDenseIndex) -> bool {
76    dense::publish_dense_index(store, trained)
77}
78
79#[cfg_attr(feature = "node-api", napi_derive::napi(object))]
80#[derive(Clone, Debug, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct DocumentInput {
83    pub id: String,
84    pub text: String,
85    pub source_uri: Option<String>,
86    pub version: Option<String>,
87    pub metadata: Option<Value>,
88    pub chunk_words: Option<u32>,
89    pub overlap_words: Option<u32>,
90    pub store_text: Option<bool>,
91    pub embeddings: Option<Vec<Vec<f64>>>,
92    /// Counts the terms of the text's first line this many times in the first chunk's term
93    /// frequencies and word count, as if the title were repeated; 1 or absent leaves the
94    /// document exactly as before this option existed.
95    pub title_weight: Option<u32>,
96}
97
98/// When a document's chunk hypervectors are computed. Terms, postings and text are stored at
99/// ingest in every mode, so lexical search sees the document at once; only the vector side
100/// (`HmsCore::query` over chunk vectors) differs.
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum EncodeMode {
104    /// Encode at ingest: the behavior before this option existed.
105    #[default]
106    Eager,
107    /// Commit the document lexically searchable now; its vectors are encoded from the stored
108    /// text on the first vector query, by `maintain_indices`, `train_document_index` or
109    /// `encode_pending_documents`, and persisted then. Needs a text encoder and the chunk text
110    /// (`storeText` not false).
111    Deferred,
112    /// Never encode: the document stays outside vector search until memorized again.
113    Lexical,
114}
115
116impl EncodeMode {
117    pub fn name(self) -> &'static str {
118        match self {
119            Self::Eager => "eager",
120            Self::Deferred => "deferred",
121            Self::Lexical => "lexical",
122        }
123    }
124}
125
126impl std::str::FromStr for EncodeMode {
127    type Err = anyhow::Error;
128
129    fn from_str(name: &str) -> Result<Self> {
130        match name {
131            "eager" => Ok(Self::Eager),
132            "deferred" => Ok(Self::Deferred),
133            "lexical" => Ok(Self::Lexical),
134            _ => bail!("encode mode must be \"eager\", \"deferred\" or \"lexical\", not {name:?}"),
135        }
136    }
137}
138
139/// Options of one `memorize_documents_with` call; the default is exactly `memorize_documents`.
140#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase", default)]
142pub struct IngestOptions {
143    pub encode: EncodeMode,
144    /// Index each word lowercased but not stemmed, so a document matches only the query words
145    /// it contains as written. The default stems every word with the English Snowball stemmer.
146    /// Query terms are matched in both forms, so one store may hold documents of either kind.
147    pub unstemmed: bool,
148}
149
150/// Persisted state of a chunk's hypervector. Written only when not `Encoded`, so a store
151/// without deferred or lexical documents serializes byte for byte as before the flag existed,
152/// and a chunk without the field reads as `Encoded`.
153#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub(crate) enum VectorState {
156    #[default]
157    Encoded,
158    /// Memorized with [`EncodeMode::Deferred`]; `vector` is empty and the chunk is not in the
159    /// vector index until `HmsCore::encode_pending_documents` installs its encoding.
160    Pending,
161    /// Memorized with [`EncodeMode::Lexical`]; `vector` is empty for good.
162    Lexical,
163}
164
165impl VectorState {
166    pub(crate) fn is_encoded(&self) -> bool {
167        *self == Self::Encoded
168    }
169}
170
171#[cfg_attr(feature = "node-api", napi_derive::napi(object))]
172#[derive(Clone, Debug, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct DocumentChunk {
175    pub id: String,
176    pub text: String,
177    pub start_byte: u32,
178    pub end_byte: u32,
179}
180
181#[cfg_attr(feature = "node-api", napi_derive::napi(object))]
182#[derive(Clone, Debug, Default, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase", default)]
184pub struct SearchOptions {
185    pub k: Option<u32>,
186    pub candidate_limit: Option<u32>,
187    pub filter: Option<Value>,
188    pub source_uri: Option<String>,
189    pub document_ids: Option<Vec<String>>,
190    pub embedding: Option<Vec<f64>>,
191    pub lexical_weight: Option<f64>,
192    pub semantic_weight: Option<f64>,
193    pub min_semantic_score: Option<f64>,
194    /// How the lexical and semantic lists combine: `"blend"` (default) adds each list's
195    /// min-max normalized scores over its top `candidateLimit`, weighted by `lexicalWeight`
196    /// and `semanticWeight`; `"rrf"` is reciprocal rank fusion with k = 60.
197    pub fusion: Option<String>,
198    /// BM25 term-frequency saturation `k1`; the default scores exactly as before this option
199    /// existed (1.2).
200    pub bm25_k1: Option<f64>,
201    /// BM25 length normalization `b` in `[0, 1]`; the default scores exactly as before (0.75).
202    pub bm25_b: Option<f64>,
203}
204
205/// BM25 constants as the scorer multiplies them: `k1 + 1`, `k1`, `1 - b` and `b`. The default
206/// keeps the literal constants the scorer always used, so default scores stay bit-identical.
207#[derive(Clone, Copy, Debug, PartialEq)]
208pub(crate) struct Bm25 {
209    pub(crate) k1_plus_one: f64,
210    pub(crate) k1: f64,
211    pub(crate) one_minus_b: f64,
212    pub(crate) b: f64,
213}
214
215impl Bm25 {
216    pub(crate) const DEFAULT: Self = Self {
217        k1_plus_one: 2.2,
218        k1: 1.2,
219        one_minus_b: 0.25,
220        b: 0.75,
221    };
222
223    pub(crate) fn from_options(options: &SearchOptions) -> Result<Self> {
224        let (Some(k1), Some(b)) = (
225            options.bm25_k1.or(Some(Self::DEFAULT.k1)),
226            options.bm25_b.or(Some(Self::DEFAULT.b)),
227        ) else {
228            unreachable!("defaults fill both parameters");
229        };
230        if options.bm25_k1.is_none() && options.bm25_b.is_none() {
231            return Ok(Self::DEFAULT);
232        }
233        ensure!(
234            k1.is_finite() && k1 >= 0.0 && (0.0..=1.0).contains(&b),
235            "bm25K1 must be finite and nonnegative and bm25B in [0, 1]"
236        );
237        Ok(Self {
238            k1_plus_one: k1 + 1.0,
239            k1,
240            one_minus_b: 1.0 - b,
241            b,
242        })
243    }
244
245    /// The length normalization of a chunk of `word_count` words against `average`.
246    #[inline]
247    pub(crate) fn norm(self, word_count: f64, average: f64) -> f64 {
248        self.k1 * (self.one_minus_b + self.b * word_count / average)
249    }
250
251    /// The weight of a term with inverse document frequency `idf` seen `tf` times in a chunk
252    /// whose length normalization is `norm`.
253    #[inline]
254    pub(crate) fn weight(self, idf: f64, tf: f64, norm: f64) -> f64 {
255        idf * tf * self.k1_plus_one / (tf + norm)
256    }
257}
258
259#[cfg_attr(feature = "node-api", napi_derive::napi(object))]
260#[derive(Clone, Debug, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct DocumentResult {
263    pub id: String,
264    pub document_id: String,
265    pub source_uri: Option<String>,
266    pub version: String,
267    pub metadata: Value,
268    pub text: Option<String>,
269    pub start_byte: u32,
270    pub end_byte: u32,
271    pub score: f64,
272    pub lexical_score: f64,
273    pub semantic_score: Option<f64>,
274}
275
276/// A chunk's dense embedding as stored: the l2-normalised values rounded to `f32`, in one
277/// shared allocation that the store and every vector snapshot covering the chunk hold
278/// together, so a snapshot adds no copy of the matrix (4 bytes per value in memory, against
279/// the 8 of the `f64` form plus a 4-byte snapshot copy before this type).
280///
281/// Scoring: every path (the generic search, the dense index, the vector snapshot's exact
282/// re-score) calls [`Embedding::cosine`], which widens each stored value to `f64` and
283/// accumulates in `f64`, so one chunk has one semantic score however it was reached. The
284/// score is the exact cosine of the stored (`f32`) vector; it differs from the cosine of the
285/// client's `f64` vector by at most the rounding of the stored values, `2^-24` relative per
286/// coordinate (about `6e-8` worst case over an l2-normalised pair, `2e-9` typical at 384
287/// dimensions).
288///
289/// Serialized form: a JSON array of the `f32` values (shortest round-trip decimals, about
290/// half the bytes of the `f64` form). A record written before this type held an array of
291/// `f64` values under the same key; both read as an array of numbers rounded to the nearest
292/// `f32`, so a store from either version opens, and a store written now still opens with
293/// the previous release (which reads the array as `f64`). Chunks without an embedding
294/// serialize byte for byte as before.
295#[derive(Clone, Debug, PartialEq)]
296pub(crate) struct Embedding(Arc<[f32]>);
297
298impl Embedding {
299    /// Rounds l2-normalised `f64` values to the stored `f32` form.
300    pub(crate) fn from_f64(values: &[f64]) -> Self {
301        Self(values.iter().map(|&value| value as f32).collect())
302    }
303
304    pub(crate) fn from_f32(values: Vec<f32>) -> Self {
305        Self(values.into())
306    }
307
308    /// The stored values widened to `f64` (exact).
309    pub(crate) fn to_f64(&self) -> Vec<f64> {
310        self.0.iter().map(|&value| f64::from(value)).collect()
311    }
312
313    /// The semantic score of this chunk against an l2-normalised `f64` query: the sequential
314    /// `f64` dot product over the widened stored values, clamped to the cosine range.
315    #[inline]
316    pub(crate) fn cosine(&self, query: &[f64]) -> f64 {
317        query
318            .iter()
319            .zip(self.0.iter())
320            .map(|(q, &v)| q * f64::from(v))
321            .sum::<f64>()
322            .clamp(-1.0, 1.0)
323    }
324
325    /// Whether both handles share one allocation (a snapshot row and the store's chunk).
326    #[cfg(test)]
327    pub(crate) fn shares_allocation(&self, other: &Self) -> bool {
328        Arc::ptr_eq(&self.0, &other.0)
329    }
330}
331
332impl Deref for Embedding {
333    type Target = [f32];
334
335    fn deref(&self) -> &[f32] {
336        &self.0
337    }
338}
339
340impl Serialize for Embedding {
341    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
342        serializer.collect_seq(self.0.iter())
343    }
344}
345
346impl<'de> Deserialize<'de> for Embedding {
347    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
348        Vec::<f32>::deserialize(deserializer).map(Self::from_f32)
349    }
350}
351
352#[derive(Clone, Serialize, Deserialize)]
353pub(crate) struct StoredChunk {
354    pub id: String,
355    /// Inline text, or none when the store moved it to its text segment (`text_at`) or the
356    /// document was stored without text.
357    pub text: Option<String>,
358    /// Location of the text in the store's text segment, for the process that installed it.
359    #[serde(skip)]
360    pub text_at: Option<(u64, u32)>,
361    pub start_byte: u32,
362    pub end_byte: u32,
363    pub vector: EntangledHVec,
364    pub embedding: Option<Embedding>,
365    pub terms: StoredTerms,
366    pub word_count: usize,
367    /// Whether `terms` were stemmed at ingest; chunks stored before stemming existed match
368    /// the unstemmed query terms.
369    #[serde(default)]
370    pub stemmed: bool,
371    /// Whether `vector` is the chunk's encoding, awaits deferred encoding, or is deliberately
372    /// absent; `Pending` and `Lexical` chunks carry an empty vector and are not in the vector
373    /// index. Absent from the serialized form when `Encoded`.
374    #[serde(default, skip_serializing_if = "VectorState::is_encoded")]
375    pub vector_state: VectorState,
376}
377
378#[derive(Clone, Serialize, Deserialize)]
379pub(crate) struct StoredDocument {
380    #[serde(deserialize_with = "deserialize_compact_string")]
381    pub id: String,
382    #[serde(default, deserialize_with = "deserialize_compact_uri")]
383    pub source_uri: Option<String>,
384    #[serde(deserialize_with = "deserialize_compact_string")]
385    pub version: String,
386    #[serde(deserialize_with = "deserialize_compact_metadata")]
387    pub metadata: Value,
388    #[serde(deserialize_with = "deserialize_compact_chunks")]
389    pub chunks: Vec<StoredChunk>,
390}
391
392fn deserialize_compact_chunks<'de, D>(deserializer: D) -> Result<Vec<StoredChunk>, D::Error>
393where
394    D: serde::Deserializer<'de>,
395{
396    let mut chunks = Vec::<StoredChunk>::deserialize(deserializer)?;
397    compact_chunks(&mut chunks);
398    Ok(chunks)
399}
400
401fn deserialize_compact_string<'de, D>(deserializer: D) -> Result<String, D::Error>
402where
403    D: serde::Deserializer<'de>,
404{
405    let mut value = String::deserialize(deserializer)?;
406    compact_string(&mut value);
407    Ok(value)
408}
409
410fn deserialize_compact_uri<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
411where
412    D: serde::Deserializer<'de>,
413{
414    let mut value = Option::<String>::deserialize(deserializer)?;
415    if let Some(value) = &mut value {
416        compact_string(value);
417    }
418    Ok(value)
419}
420
421fn deserialize_compact_metadata<'de, D>(deserializer: D) -> Result<Value, D::Error>
422where
423    D: serde::Deserializer<'de>,
424{
425    let mut value = Value::deserialize(deserializer)?;
426    compact_metadata(&mut value);
427    Ok(value)
428}
429
430fn compact_vec<T>(values: &mut Vec<T>) {
431    if values.capacity() != values.len() {
432        *values = std::mem::take(values).into_boxed_slice().into_vec();
433    }
434}
435
436fn compact_string(value: &mut String) {
437    if value.capacity() != value.len() {
438        *value = std::mem::take(value).into_boxed_str().into_string();
439    }
440}
441
442fn compact_metadata(value: &mut Value) {
443    match value {
444        Value::Array(values) => {
445            for value in values.iter_mut() {
446                compact_metadata(value);
447            }
448            compact_vec(values);
449        }
450        Value::Object(values) => {
451            if values.keys().any(|key| key.capacity() != key.len()) {
452                *values = std::mem::take(values)
453                    .into_iter()
454                    .map(|(mut key, value)| {
455                        compact_string(&mut key);
456                        (key, value)
457                    })
458                    .collect();
459            }
460            for value in values.values_mut() {
461                compact_metadata(value);
462            }
463        }
464        Value::String(value) => compact_string(value),
465        _ => {}
466    }
467}
468
469fn compact_chunks(chunks: &mut Vec<StoredChunk>) {
470    for chunk in chunks.iter_mut() {
471        compact_string(&mut chunk.id);
472        if let Some(text) = &mut chunk.text {
473            compact_string(text);
474        }
475        compact_vec(&mut chunk.vector.indices);
476    }
477    compact_vec(chunks);
478}
479
480pub fn chunk_document(input: &DocumentInput) -> Result<Vec<DocumentChunk>> {
481    Ok(chunk_spans(input)?
482        .into_iter()
483        .enumerate()
484        .map(|(index, (start_byte, end_byte))| DocumentChunk {
485            id: chunk_id(&input.id, index),
486            text: input.text[start_byte as usize..end_byte as usize].into(),
487            start_byte,
488            end_byte,
489        })
490        .collect())
491}
492
493fn chunk_id(document: &str, index: usize) -> String {
494    format!("{CHUNK_PREFIX}{}:{document}:{index}", document.len())
495}
496
497/// The byte span of every chunk [`chunk_document`] cuts `input` into, with the same checks and
498/// errors, without copying any text.
499pub(crate) fn chunk_spans(input: &DocumentInput) -> Result<Vec<(u32, u32)>> {
500    ensure!(
501        !input.id.is_empty() && input.id.len() <= 4096 && !input.id.contains('\0'),
502        "document id must contain 1..=4096 bytes"
503    );
504    ensure!(
505        input.text.len() <= MAX_DOCUMENT_BYTES,
506        "document exceeds 8 MiB"
507    );
508    let size = input.chunk_words.unwrap_or(256) as usize;
509    let overlap = input
510        .overlap_words
511        .unwrap_or(32.min(size.saturating_sub(1)) as u32) as usize;
512    ensure!(
513        (1..=4096).contains(&size) && overlap < size,
514        "chunkWords must be 1..=4096 and overlapWords smaller than chunkWords"
515    );
516    let mut words = Vec::with_capacity(input.text.len() / 6);
517    let mut start = None;
518    let mut boundary = |i: usize, whitespace: bool| {
519        if whitespace {
520            if let Some(s) = start.take() {
521                words.push((s, i));
522            }
523        } else if start.is_none() {
524            start = Some(i);
525        }
526    };
527    if input.text.is_ascii() {
528        // `char::is_whitespace` on ASCII is exactly 0x09..=0x0D and the space.
529        for (i, b) in input.text.bytes().enumerate() {
530            boundary(i, matches!(b, b' ' | b'\t'..=b'\r'));
531        }
532    } else {
533        for (i, c) in input.text.char_indices() {
534            boundary(i, c.is_whitespace());
535        }
536    }
537    if let Some(s) = start {
538        words.push((s, input.text.len()));
539    }
540    ensure!(!words.is_empty(), "document must contain text");
541    let mut chunks = Vec::new();
542    let mut word = 0;
543    while word < words.len() {
544        ensure!(
545            chunks.len() < 4096,
546            "document exceeds 4096 chunks; increase chunkWords"
547        );
548        let end_word = (word + size).min(words.len());
549        let (start_byte, end_byte) = (words[word].0, words[end_word - 1].1);
550        ensure!(
551            end_byte - start_byte <= MAX_CHUNK_BYTES,
552            "chunk exceeds 64 KiB; reduce chunkWords or split long tokens"
553        );
554        chunks.push((start_byte as u32, end_byte as u32));
555        if end_word == words.len() {
556            break;
557        }
558        word += size - overlap;
559    }
560    Ok(chunks)
561}
562
563/// Validates `values` (the store's dimensions, finite, not all zero) and returns them
564/// l2-normalised in `f64`. Accepts the client's `f64` values and a stored [`Embedding`]'s
565/// `f32` values, which widen exactly.
566pub(crate) fn normalize_embedding<T: Copy + Into<f64>>(
567    values: &[T],
568    dimensions: usize,
569) -> Result<Vec<f64>> {
570    let values: Vec<f64> = values.iter().map(|&x| x.into()).collect();
571    ensure!(
572        values.len() == dimensions && values.iter().all(|x| x.is_finite()),
573        "embedding dimensions must match the store and values must be finite"
574    );
575    let scale = values.iter().map(|x| x.abs()).fold(0.0_f64, f64::max);
576    ensure!(scale > 0.0, "zero embeddings are not supported");
577    let norm = values
578        .iter()
579        .map(|x| (x / scale).powi(2))
580        .sum::<f64>()
581        .sqrt();
582    Ok(values.iter().map(|x| (x / scale) / norm).collect())
583}
584
585fn stemmer() -> &'static rust_stemmers::Stemmer {
586    static STEMMER: OnceLock<rust_stemmers::Stemmer> = OnceLock::new();
587    STEMMER.get_or_init(|| rust_stemmers::Stemmer::create(rust_stemmers::Algorithm::English))
588}
589
590/// Lowercased alphanumeric tokens and their counts; with `stem`, each token is reduced by
591/// the English Snowball (Porter 2) stemmer, which is what the published BM25 baselines do.
592pub(crate) fn terms(text: &str, stem: bool) -> BTreeMap<String, u32> {
593    // Counted in a hash map keyed by borrowed text (one allocation per distinct term, not per
594    // occurrence), then sorted once into the ordered map the postings expect.
595    // Seeded per process: the keys are caller text.
596    let mut counts: std::collections::HashMap<String, u32, foldhash::fast::RandomState> =
597        std::collections::HashMap::with_capacity_and_hasher(text.len() / 16, Default::default());
598    let mut lower = String::new();
599    let mut add = |key: &str| match counts.get_mut(key) {
600        Some(count) => *count += 1,
601        None => {
602            counts.insert(key.to_owned(), 1);
603        }
604    };
605    for word in text
606        .split(|c: char| !c.is_alphanumeric())
607        .filter(|s| !s.is_empty())
608    {
609        lower.clear();
610        if word.is_ascii() {
611            lower.push_str(word);
612            lower.make_ascii_lowercase();
613        } else {
614            // `str::to_lowercase`, not per-char mapping: it applies the final-sigma rule.
615            lower.push_str(&word.to_lowercase());
616        }
617        if stem {
618            add(&stemmer().stem(&lower));
619        } else {
620            add(&lower);
621        }
622    }
623    // Keys are unique, so an unstable sort is exact; the map's own stable sort then sees one
624    // ascending run and finishes in a single pass.
625    let mut sorted: Vec<(String, u32)> = counts.into_iter().collect();
626    sorted.sort_unstable_by(|a, b| a.0.cmp(&b.0));
627    sorted.into_iter().collect()
628}
629
630/// Words a batch interner maps before it starts over; bounds memory on corpora with
631/// unbounded vocabularies while keeping the common-word hit rate.
632const STEM_MEMO_CAPACITY: usize = 1 << 18;
633
634/// Hash map keyed by caller text, so seeded per process.
635type SeededMap<K, V> = std::collections::HashMap<K, V, foldhash::fast::RandomState>;
636
637/// The lexical state of ingest on one thread, carried across a store's batches
638/// ([`StemMemoPool`]): every distinct word seen maps to the id of its term (lowercased,
639/// Snowball-stemmed), so a repeated word costs one hash lookup and no copy, stemming runs
640/// once per distinct word, and a chunk's counts accumulate in an array by term id.
641#[derive(Default)]
642struct Interner {
643    /// Word exactly as it appears in the text -> term id.
644    words: WordMap,
645    /// Term -> id, so the words of one stem count as one term.
646    ids: SeededMap<Box<str>, u32>,
647    /// Term by id, and its first eight bytes big-endian (zero-padded): a term never contains
648    /// a NUL byte, so comparing keys orders terms as their bytes do except on equal keys.
649    terms: Vec<Box<str>>,
650    keys: Vec<u64>,
651    /// Count per term id for the chunk being counted, and the ids counted so far. Zero and
652    /// empty between chunks.
653    counts: Vec<u32>,
654    counted: Vec<u32>,
655    lower: String,
656    /// Terms are the lowercased words themselves rather than their stems.
657    unstemmed: bool,
658}
659
660/// Word -> term id. A word of at most 16 bytes (nearly every word) is keyed by its bytes
661/// zero-padded into a `u128`, unambiguous because a word holds no NUL byte, so a lookup
662/// compares integers rather than following a pointer to compare strings.
663#[derive(Default)]
664struct WordMap {
665    short: SeededMap<u128, u32>,
666    long: SeededMap<Box<str>, u32>,
667}
668
669impl WordMap {
670    #[inline]
671    fn short_key(word: &str) -> Option<u128> {
672        debug_assert!(!word.contains('\0'));
673        let bytes = word.as_bytes();
674        (bytes.len() <= 16).then(|| {
675            let mut key = [0u8; 16];
676            key[..bytes.len()].copy_from_slice(bytes);
677            u128::from_le_bytes(key)
678        })
679    }
680
681    #[inline]
682    fn get(&self, word: &str) -> Option<u32> {
683        match Self::short_key(word) {
684            Some(key) => self.short.get(&key).copied(),
685            None => self.long.get(word).copied(),
686        }
687    }
688
689    fn insert(&mut self, word: &str, id: u32) {
690        match Self::short_key(word) {
691            Some(key) => self.short.insert(key, id),
692            None => self.long.insert(word.into(), id),
693        };
694    }
695
696    fn len(&self) -> usize {
697        self.short.len() + self.long.len()
698    }
699}
700
701impl Interner {
702    fn term_id(&mut self, word: &str, ascii: bool) -> u32 {
703        if let Some(id) = self.words.get(word) {
704            return id;
705        }
706        self.lower.clear();
707        if ascii {
708            self.lower.push_str(word);
709            self.lower.make_ascii_lowercase();
710        } else {
711            // `str::to_lowercase`, not per-char mapping: it applies the final-sigma rule.
712            self.lower.push_str(&word.to_lowercase());
713        }
714        let stem = if self.unstemmed {
715            std::borrow::Cow::Borrowed(self.lower.as_str())
716        } else {
717            stemmer().stem(&self.lower)
718        };
719        let id = match self.ids.get(stem.as_ref()) {
720            Some(&id) => id,
721            None => {
722                let id = u32::try_from(self.terms.len()).expect("batch vocabulary over u32");
723                let term: Box<str> = stem.into();
724                let mut key = [0u8; 8];
725                let prefix = term.len().min(8);
726                key[..prefix].copy_from_slice(&term.as_bytes()[..prefix]);
727                self.keys.push(u64::from_be_bytes(key));
728                self.ids.insert(term.clone(), id);
729                self.terms.push(term);
730                self.counts.push(0);
731                id
732            }
733        };
734        self.words.insert(word, id);
735        id
736    }
737
738    /// Counts the words of `text` `weight` times each, as `terms(text, true)` splits them
739    /// (maximal runs of alphanumeric characters); returns how many were counted, weighted.
740    fn count(&mut self, text: &str, weight: u32) -> usize {
741        let bytes = text.as_bytes();
742        let mut counted = 0;
743        let mut at = 0;
744        let char_at = |at: usize| text[at..].chars().next().expect("char boundary");
745        while at < bytes.len() {
746            if bytes[at] < 0x80 {
747                if !bytes[at].is_ascii_alphanumeric() {
748                    at += 1;
749                    continue;
750                }
751            } else {
752                let c = char_at(at);
753                if !c.is_alphanumeric() {
754                    at += c.len_utf8();
755                    continue;
756                }
757            }
758            let start = at;
759            let mut ascii = true;
760            while at < bytes.len() {
761                if bytes[at] < 0x80 {
762                    if !bytes[at].is_ascii_alphanumeric() {
763                        break;
764                    }
765                    at += 1;
766                } else {
767                    let c = char_at(at);
768                    if !c.is_alphanumeric() {
769                        break;
770                    }
771                    ascii = false;
772                    at += c.len_utf8();
773                }
774            }
775            let id = self.term_id(&text[start..at], ascii);
776            let count = &mut self.counts[id as usize];
777            if *count == 0 {
778                self.counted.push(id);
779            }
780            *count += weight;
781            counted += weight as usize;
782        }
783        counted
784    }
785
786    /// The term table of `text` with its first line's terms counted `title_weight` times in
787    /// total, and the weighted word count: what `terms(text, true)` plus the title weighting
788    /// produce.
789    fn chunk_terms(&mut self, text: &str, title_weight: u32, stem: bool) -> (StoredTerms, usize) {
790        if self.words.len() >= STEM_MEMO_CAPACITY || self.unstemmed == stem {
791            *self = Self {
792                unstemmed: !stem,
793                ..Self::default()
794            };
795        }
796        let mut words = self.count(text, 1);
797        if title_weight > 1 {
798            words += self.count(text.lines().next().unwrap_or_default(), title_weight - 1);
799        }
800        let (terms, keys, counts) = (&self.terms, &self.keys, &mut self.counts);
801        self.counted.sort_unstable_by(|&a, &b| {
802            let (a, b) = (a as usize, b as usize);
803            keys[a]
804                .cmp(&keys[b])
805                .then_with(|| terms[a].as_bytes().cmp(terms[b].as_bytes()))
806        });
807        let bytes = self
808            .counted
809            .iter()
810            .map(|&id| terms[id as usize].len())
811            .sum();
812        let table = StoredTerms::from_sorted_refs(
813            self.counted.len(),
814            bytes,
815            self.counted
816                .iter()
817                .map(|&id| (&*terms[id as usize], counts[id as usize])),
818        );
819        for &id in &self.counted {
820            counts[id as usize] = 0;
821        }
822        self.counted.clear();
823        (table, words)
824    }
825}
826
827thread_local! {
828    static ACTIVE_STEM_MEMO: std::cell::RefCell<Option<Interner>> =
829        const { std::cell::RefCell::new(None) };
830}
831
832/// The word interner of an ingest batch. It is installed on a thread only while that thread
833/// prepares documents for the batch and is kept by the store between batches
834/// ([`StemMemoPool`]), so no thread keeps it and queries never use it.
835#[derive(Default)]
836pub(crate) struct StemMemo(Interner);
837
838pub(crate) struct StemMemoGuard<'a>(&'a mut StemMemo);
839
840impl StemMemo {
841    pub(crate) fn install(&mut self) -> StemMemoGuard<'_> {
842        let table = std::mem::take(&mut self.0);
843        ACTIVE_STEM_MEMO.with(|active| *active.borrow_mut() = Some(table));
844        StemMemoGuard(self)
845    }
846}
847
848impl Drop for StemMemoGuard<'_> {
849    fn drop(&mut self) {
850        if let Some(table) = ACTIVE_STEM_MEMO.with(|active| active.borrow_mut().take()) {
851            self.0 .0 = table;
852        }
853    }
854}
855
856/// The batch interner a store keeps between its document batches, so a corpus loaded in
857/// consecutive batches stems each distinct word once rather than once per batch and rayon
858/// split. One is kept, bounded by [`STEM_MEMO_CAPACITY`] words, unless keeping is turned off
859/// ([`StemMemoPool::set_keep`]), when each batch's interner is freed with the batch.
860#[derive(Default)]
861pub(crate) struct StemMemoPool {
862    kept: parking_lot::Mutex<Option<StemMemo>>,
863    release: std::sync::atomic::AtomicBool,
864}
865
866impl StemMemoPool {
867    /// The kept interner, or a new one; it goes back to the pool when the result drops.
868    pub(crate) fn take(&self) -> PooledStemMemo<'_> {
869        let memo = self.kept.lock().take().unwrap_or_default();
870        PooledStemMemo { pool: self, memo }
871    }
872
873    /// Whether interners outlive their batch; turning it off frees the kept one now.
874    pub(crate) fn set_keep(&self, keep: bool) {
875        self.release
876            .store(!keep, std::sync::atomic::Ordering::Relaxed);
877        if !keep {
878            self.kept.lock().take();
879        }
880    }
881}
882
883pub(crate) struct PooledStemMemo<'a> {
884    pool: &'a StemMemoPool,
885    memo: StemMemo,
886}
887
888impl PooledStemMemo<'_> {
889    pub(crate) fn install(&mut self) -> StemMemoGuard<'_> {
890        self.memo.install()
891    }
892}
893
894impl Drop for PooledStemMemo<'_> {
895    fn drop(&mut self) {
896        let memo = std::mem::take(&mut self.memo);
897        if self.pool.release.load(std::sync::atomic::Ordering::Relaxed) {
898            return;
899        }
900        let mut kept = self.pool.kept.lock();
901        // Of the interners of concurrent splits, keep the one that has seen the most words.
902        if kept
903            .as_ref()
904            .is_none_or(|k| k.0.words.len() < memo.0.words.len())
905        {
906            *kept = Some(memo);
907        }
908    }
909}
910
911/// [`Interner::chunk_terms`] through the batch interner installed on this thread, or a
912/// one-off one.
913fn chunk_terms(text: &str, title_weight: u32, stem: bool) -> (StoredTerms, usize) {
914    ACTIVE_STEM_MEMO.with(|active| match active.borrow_mut().as_mut() {
915        Some(interner) => interner.chunk_terms(text, title_weight, stem),
916        None => Interner::default().chunk_terms(text, title_weight, stem),
917    })
918}
919
920pub(crate) fn prepare(
921    input: DocumentInput,
922    dimensions: usize,
923    embedding_dimensions: Option<usize>,
924    encoder: TextEncoder,
925) -> Result<StoredDocument> {
926    prepare_with_options(
927        input,
928        dimensions,
929        embedding_dimensions,
930        None,
931        encoder,
932        &IngestOptions::default(),
933    )
934}
935
936/// [`prepare`] with an optional lexical key per chunk (for example the chunk prefixed with
937/// facts extracted from it; stored text and byte offsets stay the chunk's own) and the chunk vectors encoded now, later or never (`mode`). The
938/// lexical side of the document is the same in every mode.
939pub(crate) fn prepare_with_options(
940    input: DocumentInput,
941    dimensions: usize,
942    embedding_dimensions: Option<usize>,
943    keys: Option<&[String]>,
944    encoder: TextEncoder,
945    options: &IngestOptions,
946) -> Result<StoredDocument> {
947    check_mode(&input, encoder, options.encode)?;
948    let spans = chunk_spans(&input)?;
949    prepare_spans(
950        input,
951        spans,
952        dimensions,
953        embedding_dimensions,
954        keys,
955        encoder,
956        options,
957    )
958}
959
960fn check_mode(input: &DocumentInput, encoder: TextEncoder, mode: EncodeMode) -> Result<()> {
961    ensure!(
962        mode != EncodeMode::Deferred || !encoder.is_embedding(),
963        "deferred encoding applies to text encoders; {} vectors come from embeddings",
964        encoder.name()
965    );
966    ensure!(
967        mode != EncodeMode::Deferred || input.store_text.unwrap_or(true),
968        "deferred encoding needs the chunk text; set storeText or encode eagerly"
969    );
970    Ok(())
971}
972
973/// [`prepare_with_options`] of an input whose chunk spans ([`chunk_spans`]) are known.
974pub(crate) fn prepare_spans(
975    input: DocumentInput,
976    chunks: Vec<(u32, u32)>,
977    dimensions: usize,
978    embedding_dimensions: Option<usize>,
979    keys: Option<&[String]>,
980    encoder: TextEncoder,
981    options: &IngestOptions,
982) -> Result<StoredDocument> {
983    let mode = options.encode;
984    check_mode(&input, encoder, mode)?;
985    ensure!(
986        keys.is_none_or(|k| k.len() == chunks.len()),
987        "provide one key per chunk"
988    );
989    ensure!(
990        input.metadata.as_ref().is_none_or(Value::is_object),
991        "metadata must be a JSON object"
992    );
993    ensure!(
994        serde_json::to_vec(&input.metadata)?.len() <= 64 * 1024,
995        "metadata exceeds 64 KiB"
996    );
997    ensure!(
998        input.source_uri.as_ref().is_none_or(|s| s.len() <= 8192),
999        "sourceUri exceeds 8192 bytes"
1000    );
1001    ensure!(
1002        input
1003            .version
1004            .as_ref()
1005            .is_none_or(|s| !s.is_empty() && s.len() <= 1024),
1006        "version requires 1..=1024 bytes"
1007    );
1008    if let Some(embeddings) = &input.embeddings {
1009        ensure!(
1010            embedding_dimensions.is_some() && embeddings.len() == chunks.len(),
1011            "provide one embedding per chunk and configure the store embedding space"
1012        );
1013        ensure!(
1014            embeddings.iter().map(Vec::len).sum::<usize>() <= 1_048_576,
1015            "document exceeds 1048576 embedding values; split the document"
1016        );
1017    }
1018    let mut stored = Vec::with_capacity(chunks.len());
1019    let title_weight = input.title_weight.unwrap_or(1);
1020    ensure!(
1021        (1..=16).contains(&title_weight),
1022        "titleWeight must be between 1 and 16"
1023    );
1024    for (i, &(start_byte, end_byte)) in chunks.iter().enumerate() {
1025        let text = &input.text[start_byte as usize..end_byte as usize];
1026        let source = keys.map_or(text, |k| k[i].as_str());
1027        let (terms, word_count) = chunk_terms(
1028            source,
1029            if i == 0 { title_weight } else { 1 },
1030            !options.unstemmed,
1031        );
1032        let embedding = input
1033            .embeddings
1034            .as_ref()
1035            .map(|all| {
1036                normalize_embedding(&all[i], embedding_dimensions.unwrap_or(0))
1037                    .map(|values| Embedding::from_f64(&values))
1038            })
1039            .transpose()?;
1040        let (vector, vector_state) = match (mode, encoder.is_embedding(), &embedding) {
1041            (EncodeMode::Lexical, _, _) => (
1042                EntangledHVec::from_indices(Vec::new(), dimensions),
1043                VectorState::Lexical,
1044            ),
1045            (EncodeMode::Deferred, _, _) => (
1046                EntangledHVec::from_indices(Vec::new(), dimensions),
1047                VectorState::Pending,
1048            ),
1049            (EncodeMode::Eager, false, _) => {
1050                (encoder.encode(text, dimensions), VectorState::Encoded)
1051            }
1052            (EncodeMode::Eager, true, Some(e)) => (
1053                EntangledHVec::from_dense(e, dimensions),
1054                VectorState::Encoded,
1055            ),
1056            (EncodeMode::Eager, true, None) => bail!(
1057                "the {} text encoder needs a dense embedding for every chunk; install a text embedder",
1058                encoder.name()
1059            ),
1060        };
1061        stored.push(StoredChunk {
1062            vector,
1063            vector_state,
1064            text: input.store_text.unwrap_or(true).then(|| text.to_owned()),
1065            text_at: None,
1066            id: chunk_id(&input.id, i),
1067            start_byte,
1068            end_byte,
1069            embedding,
1070            terms,
1071            word_count,
1072            stemmed: !options.unstemmed,
1073        });
1074    }
1075    let mut document = StoredDocument {
1076        id: input.id,
1077        source_uri: input.source_uri,
1078        version: input.version.unwrap_or_else(|| "1".into()),
1079        metadata: input.metadata.unwrap_or_else(|| serde_json::json!({})),
1080        chunks: stored,
1081    };
1082    compact_string(&mut document.id);
1083    compact_string(&mut document.version);
1084    if let Some(uri) = &mut document.source_uri {
1085        compact_string(uri);
1086    }
1087    compact_metadata(&mut document.metadata);
1088    compact_chunks(&mut document.chunks);
1089    Ok(document)
1090}
1091
1092/// Identity of the content deferred encoding reads: the version, byte spans and text of every
1093/// chunk, mixed with the crate's splitmix64 chain. `None` when a chunk's text is unavailable.
1094/// `Mutation::EncodeDocument` carries it so vectors computed for one revision of a document
1095/// are never installed on another that reused its id, version and chunk count.
1096pub(crate) fn document_fingerprint(
1097    store: &DocumentStore,
1098    document: &StoredDocument,
1099) -> Option<u64> {
1100    let mut h = super::encoding::hash_str_seed(&document.version, 0x5EED_D0C5_F1A6_0001);
1101    for chunk in &document.chunks {
1102        let text = store.chunk_text(chunk)?;
1103        h = super::entangled::hash_u64(h, u64::from(chunk.start_byte));
1104        h = super::entangled::hash_u64(h, u64::from(chunk.end_byte));
1105        h = super::encoding::hash_str_seed(&text, h);
1106    }
1107    Some(h)
1108}
1109
1110pub(crate) fn prepare_search(
1111    text: &str,
1112    options: &SearchOptions,
1113    embedding_dimensions: Option<usize>,
1114) -> Result<PreparedSearch> {
1115    search::prepare_search(text, options, embedding_dimensions)
1116}
1117
1118pub(crate) fn capture_search(
1119    store: &DocumentStore,
1120    prepared: Vec<PreparedSearch>,
1121) -> CapturedSearch {
1122    search::capture_search(store, prepared)
1123}
1124
1125pub(crate) fn count_matches(store: &DocumentStore, text: &str) -> Result<usize> {
1126    search::count_matches(store, text)
1127}
1128
1129pub(crate) fn execute_search(captured: &CapturedSearch, query: usize) -> Vec<DocumentResult> {
1130    search::execute_search(captured, query)
1131}
1132
1133#[cfg(test)]
1134pub(crate) fn search(
1135    store: &DocumentStore,
1136    text: &str,
1137    options: &SearchOptions,
1138    embedding_dimensions: Option<usize>,
1139) -> Result<Vec<DocumentResult>> {
1140    let prepared: PreparedSearch = prepare_search(text, options, embedding_dimensions)?;
1141    let captured: CapturedSearch = capture_search(store, vec![prepared]);
1142    Ok(execute_search(&captured, 0))
1143}
1144
1145#[cfg(test)]
1146fn eligible_chunks<'a>(
1147    documents: impl Iterator<Item = &'a StoredDocument>,
1148    options: &SearchOptions,
1149) -> Vec<(&'a StoredDocument, &'a StoredChunk)> {
1150    documents
1151        .filter(|document| matches_document(document, options))
1152        .flat_map(|d| d.chunks.iter().map(move |chunk| (d, chunk)))
1153        .collect()
1154}
1155
1156fn matches_document(document: &StoredDocument, options: &SearchOptions) -> bool {
1157    options
1158        .source_uri
1159        .as_ref()
1160        .is_none_or(|uri| document.source_uri.as_ref() == Some(uri))
1161        && options
1162            .document_ids
1163            .as_ref()
1164            .is_none_or(|ids| ids.contains(&document.id))
1165        && options
1166            .filter
1167            .as_ref()
1168            .and_then(Value::as_object)
1169            .is_none_or(|filter| {
1170                filter
1171                    .iter()
1172                    .all(|(key, value)| document.metadata.get(key) == Some(value))
1173            })
1174}
1175
1176fn top_ranked<T>(
1177    values: &mut Vec<T>,
1178    limit: usize,
1179    mut compare: impl FnMut(&T, &T) -> std::cmp::Ordering,
1180) {
1181    if values.len() > limit {
1182        values.select_nth_unstable_by(limit - 1, &mut compare);
1183        values.truncate(limit);
1184    }
1185    values.sort_unstable_by(compare);
1186}
1187
1188#[cfg(test)]
1189fn search_impl(
1190    store: &DocumentStore,
1191    text: &str,
1192    options: &SearchOptions,
1193    embedding_dimensions: Option<usize>,
1194    indexed: bool,
1195) -> Result<Vec<DocumentResult>> {
1196    if indexed {
1197        return search(store, text, options, embedding_dimensions);
1198    }
1199    ensure!(text.len() <= MAX_DOCUMENT_BYTES, "query exceeds 8 MiB");
1200    let k = options.k.unwrap_or(10) as usize;
1201    let limit = options.candidate_limit.unwrap_or(100.max(k as u32)) as usize;
1202    ensure!(
1203        (1..=1000).contains(&k) && (k..=4096).contains(&limit),
1204        "require 1 <= k <= 1000 and k <= candidateLimit <= 4096"
1205    );
1206    ensure!(
1207        options.filter.as_ref().is_none_or(Value::is_object),
1208        "filter must be a JSON object of exact metadata matches"
1209    );
1210    let lexical_weight = options.lexical_weight.unwrap_or(1.0);
1211    let semantic_weight = options.semantic_weight.unwrap_or(1.0);
1212    ensure!(
1213        [lexical_weight, semantic_weight]
1214            .iter()
1215            .all(|v| v.is_finite() && *v >= 0.0)
1216            && lexical_weight + semantic_weight > 0.0,
1217        "search weights must be finite, nonnegative and not both zero"
1218    );
1219    let bm25 = Bm25::from_options(options)?;
1220    let minimum = options.min_semantic_score.unwrap_or(0.0);
1221    ensure!(
1222        minimum.is_finite() && (-1.0..=1.0).contains(&minimum),
1223        "minSemanticScore must be in [-1,1]"
1224    );
1225    let rrf = match options.fusion.as_deref() {
1226        None | Some("blend") => false,
1227        Some("rrf") => true,
1228        Some(other) => anyhow::bail!("fusion must be \"blend\" or \"rrf\", not {other:?}"),
1229    };
1230    let query_embedding = options
1231        .embedding
1232        .as_ref()
1233        .map(|v| {
1234            ensure!(
1235                embedding_dimensions.is_some(),
1236                "configure an embedding space before semantic search"
1237            );
1238            normalize_embedding(v, embedding_dimensions.unwrap_or(0))
1239        })
1240        .transpose()?;
1241    // Unstemmed and stemmed query forms: a chunk is scored with the form it was stored in.
1242    let query_terms = [terms(text, false), terms(text, true)];
1243    let candidates = if indexed {
1244        store.candidate_ids(options)
1245    } else {
1246        None
1247    };
1248    let eligible = match candidates {
1249        Some(ids) => eligible_chunks(
1250            ids.into_iter().map(|id| {
1251                store
1252                    .documents
1253                    .get(id)
1254                    .expect("document candidate is absent from store")
1255                    .as_ref()
1256            }),
1257            options,
1258        ),
1259        None => eligible_chunks(store.values(), options),
1260    };
1261    if eligible.is_empty() {
1262        return Ok(Vec::new());
1263    }
1264    let n = eligible.len() as f64;
1265    let average = eligible.iter().map(|(_, c)| c.word_count).sum::<usize>() as f64 / n;
1266    let posting_work: usize = query_terms
1267        .iter()
1268        .enumerate()
1269        .map(|(form, terms)| {
1270            terms
1271                .keys()
1272                .map(|word| store.postings[form].get(word).map_or(0, |p| p.len()))
1273                .sum::<usize>()
1274        })
1275        .sum();
1276    let scan_work = eligible
1277        .len()
1278        .saturating_mul(query_terms.iter().map(BTreeMap::len).sum());
1279    let use_postings = indexed && posting_work.saturating_add(store.slot_count) < scan_work;
1280    let mut positions = Vec::new();
1281    if use_postings {
1282        positions.resize(store.slot_count, usize::MAX);
1283        let mut previous = "";
1284        let mut chunk = 0;
1285        for (i, (document, _)) in eligible.iter().enumerate() {
1286            if document.id != previous {
1287                previous = &document.id;
1288                chunk = 0;
1289            }
1290            positions[store.slots[&document.id][chunk]] = i;
1291            chunk += 1;
1292        }
1293    }
1294    let idf: [BTreeMap<&String, f64>; 2] = std::array::from_fn(|form| {
1295        let stemmed = form == 1;
1296        query_terms[form]
1297            .keys()
1298            .map(|word| {
1299                let df = if use_postings {
1300                    store.postings[form].get(word).map_or(0, |posting| {
1301                        posting
1302                            .iter()
1303                            .filter(|&(slot, _)| positions[slot] != usize::MAX)
1304                            .count()
1305                    })
1306                } else {
1307                    eligible
1308                        .iter()
1309                        .filter(|(_, c)| c.stemmed == stemmed && c.terms.contains_key(word))
1310                        .count()
1311                } as f64;
1312                (word, (1.0 + (n - df + 0.5) / (df + 0.5)).ln())
1313            })
1314            .collect()
1315    });
1316    let mut scores: Vec<(f64, Option<f64>)> = eligible
1317        .iter()
1318        .map(|(_, c)| {
1319            let lexical = if use_postings {
1320                0.0
1321            } else {
1322                idf[usize::from(c.stemmed)]
1323                    .iter()
1324                    .map(|(word, idf)| {
1325                        let tf = c.terms.get(word).copied().unwrap_or(0) as f64;
1326                        bm25.weight(*idf, tf, bm25.norm(c.word_count as f64, average.max(1.0)))
1327                    })
1328                    .sum()
1329            };
1330            let semantic = query_embedding
1331                .as_ref()
1332                .zip(c.embedding.as_ref())
1333                .map(|(q, v)| v.cosine(q));
1334            (lexical, semantic)
1335        })
1336        .collect();
1337    if use_postings {
1338        for (form, weights) in idf.iter().enumerate() {
1339            for (word, idf) in weights {
1340                if let Some(posting) = store.postings[form].get(word) {
1341                    for (slot, frequency) in posting.iter() {
1342                        let i = positions[slot];
1343                        if i == usize::MAX {
1344                            continue;
1345                        }
1346                        let c = eligible[i].1;
1347                        let tf = f64::from(frequency);
1348                        scores[i].0 +=
1349                            bm25.weight(*idf, tf, bm25.norm(c.word_count as f64, average.max(1.0)));
1350                    }
1351                }
1352            }
1353        }
1354    }
1355    let mut lexical: Vec<usize> = (0..scores.len())
1356        .filter(|&i| scores[i].0 > 0.0 && lexical_weight > 0.0)
1357        .collect();
1358    let mut semantic: Vec<usize> = (0..scores.len())
1359        .filter(|&i| semantic_weight > 0.0 && scores[i].1.is_some_and(|s| s >= minimum))
1360        .collect();
1361    top_ranked(&mut lexical, limit, |&a, &b| {
1362        scores[b].0.total_cmp(&scores[a].0).then(a.cmp(&b))
1363    });
1364    top_ranked(&mut semantic, limit, |&a, &b| {
1365        scores[b]
1366            .1
1367            .unwrap_or(-1.0)
1368            .total_cmp(&scores[a].1.unwrap_or(-1.0))
1369            .then(a.cmp(&b))
1370    });
1371    let mut fused = BTreeMap::<usize, f64>::new();
1372    for (list, (indices, weight)) in [(&lexical, lexical_weight), (&semantic, semantic_weight)]
1373        .into_iter()
1374        .enumerate()
1375    {
1376        let top = &indices[..indices.len().min(limit)];
1377        if rrf {
1378            for (rank, &i) in top.iter().enumerate() {
1379                *fused.entry(i).or_default() += weight / (60.0 + rank as f64 + 1.0);
1380            }
1381            continue;
1382        }
1383        // Min-max over the list's own top candidates; a flat list contributes its weight.
1384        let value = |i: usize| {
1385            if list == 0 {
1386                scores[i].0
1387            } else {
1388                scores[i].1.unwrap_or(-1.0)
1389            }
1390        };
1391        let (hi, lo) = match (top.first(), top.last()) {
1392            (Some(&f), Some(&l)) => (value(f), value(l)),
1393            _ => continue,
1394        };
1395        for &i in top {
1396            let norm = if hi > lo {
1397                (value(i) - lo) / (hi - lo)
1398            } else {
1399                1.0
1400            };
1401            *fused.entry(i).or_default() += weight * norm;
1402        }
1403    }
1404    let mut ranked: Vec<_> = fused.into_iter().collect();
1405    ranked.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
1406    Ok(ranked
1407        .into_iter()
1408        .take(k)
1409        .map(|(i, score)| {
1410            let (d, c) = eligible[i];
1411            DocumentResult {
1412                id: c.id.clone(),
1413                document_id: d.id.clone(),
1414                source_uri: d.source_uri.clone(),
1415                version: d.version.clone(),
1416                metadata: d.metadata.clone(),
1417                text: c.text.clone(),
1418                start_byte: c.start_byte,
1419                end_byte: c.end_byte,
1420                score,
1421                lexical_score: scores[i].0,
1422                semantic_score: scores[i].1,
1423            }
1424        })
1425        .collect())
1426}
1427
1428#[cfg(test)]
1429mod tests {
1430    use super::*;
1431    use serde_json::json;
1432
1433    fn document(i: usize, revision: usize) -> StoredDocument {
1434        let input: DocumentInput = serde_json::from_value(json!({
1435            "id":format!("d{i:03}"), "version":revision.to_string(),
1436            "text":format!("running common topic{} rare{i} shared birds fly distant place revision{revision}",i%7),
1437            "sourceUri":format!("source{}",i%3), "metadata":{"group":i%5,"empty":null,"flag":i.is_multiple_of(2),"text":"n","fraction":-0.0,"nested":{"key":i%5}},
1438            "chunkWords":5,"overlapWords":1,
1439            "embeddings":[[1.0,0.1,0.0],[0.0,1.0,0.1],[0.1,0.0,1.0]]
1440        })).unwrap();
1441        let mut doc = prepare(input, 1024, Some(3), TextEncoder::default()).unwrap();
1442        if i.is_multiple_of(2) {
1443            for chunk in &mut doc.chunks {
1444                chunk.terms = terms(chunk.text.as_deref().unwrap(), false).into();
1445                chunk.stemmed = false;
1446            }
1447        }
1448        doc
1449    }
1450
1451    #[test]
1452    fn bm25_options_change_scores_only_when_set() {
1453        let mut store = DocumentStore::default();
1454        for i in 0..12 {
1455            let doc = document(i, 1);
1456            store.insert(doc.id.clone(), doc);
1457        }
1458        let lexical = |k1: Option<f64>, b: Option<f64>| {
1459            let options = SearchOptions {
1460                k: Some(5),
1461                lexical_weight: Some(1.0),
1462                semantic_weight: Some(0.0),
1463                bm25_k1: k1,
1464                bm25_b: b,
1465                ..Default::default()
1466            };
1467            search_impl(&store, "rare3 shared", &options, Some(3), true).map(|hits| {
1468                hits.into_iter()
1469                    .map(|h| (h.id, h.lexical_score))
1470                    .collect::<Vec<_>>()
1471            })
1472        };
1473        let default = lexical(None, None).unwrap();
1474        assert_eq!(default, lexical(Some(1.2), Some(0.75)).unwrap());
1475        // k1 = 0 saturates every term at its idf, so the top hit scores the sum of its idfs.
1476        let flat = lexical(Some(0.0), Some(0.5)).unwrap();
1477        assert_ne!(flat, default);
1478        let n = store.chunk_count as f64;
1479        let idf = |word: &str| {
1480            let df = store.postings[1].get(word).map_or(0, |list| list.len()) as f64;
1481            (1.0 + (n - df + 0.5) / (df + 0.5)).ln()
1482        };
1483        let stem = |word: &str| terms(word, true).into_keys().next().unwrap();
1484        assert!((flat[0].1 - (idf(&stem("rare3")) + idf(&stem("shared")))).abs() < 1e-12);
1485        assert!(lexical(Some(-1.0), None).is_err());
1486        assert!(lexical(None, Some(1.5)).is_err());
1487        assert!(lexical(Some(f64::NAN), None).is_err());
1488        assert_scan_parity(&store);
1489    }
1490
1491    fn assert_scan_parity(store: &DocumentStore) {
1492        for query in [
1493            "running rare3 missing absent unknown extra terms for selective postings",
1494            "running rare3",
1495            "birds distant",
1496            "common",
1497            "missing token",
1498            "",
1499        ] {
1500            for fusion in ["blend", "rrf"] {
1501                for filter in [
1502                    None,
1503                    Some(json!({"group":3})),
1504                    Some(json!({"group":99})),
1505                    Some(json!({"empty":null,"flag":true,"text":"n"})),
1506                    Some(json!({"fraction":0.0,"nested":{"key":3}})),
1507                ] {
1508                    for limit in [9, 10, 11, 100] {
1509                        for embedding in [None, Some(vec![1., 0., 0.])] {
1510                            let opts = SearchOptions {
1511                                k: Some(9),
1512                                candidate_limit: Some(limit),
1513                                filter: filter.clone(),
1514                                fusion: Some(fusion.into()),
1515                                embedding,
1516                                min_semantic_score: Some(-1.),
1517                                ..Default::default()
1518                            };
1519                            let indexed = search_impl(store, query, &opts, Some(3), true).unwrap();
1520                            let scanned = search_impl(store, query, &opts, Some(3), false).unwrap();
1521                            assert_eq!(
1522                                serde_json::to_value(indexed).unwrap(),
1523                                serde_json::to_value(scanned).unwrap()
1524                            );
1525                        }
1526                    }
1527                }
1528            }
1529        }
1530        let opts = SearchOptions {
1531            source_uri: Some("source1".into()),
1532            document_ids: Some(vec![
1533                "d010".into(),
1534                "d003".into(),
1535                "d010".into(),
1536                "absent".into(),
1537            ]),
1538            ..Default::default()
1539        };
1540        assert_eq!(
1541            serde_json::to_value(search_impl(store, "running", &opts, Some(3), true).unwrap())
1542                .unwrap(),
1543            serde_json::to_value(search_impl(store, "running", &opts, Some(3), false).unwrap())
1544                .unwrap()
1545        );
1546    }
1547
1548    #[test]
1549    fn postings_preserve_filtered_mixed_tokenization_scores_after_slot_reuse() {
1550        let mut store = DocumentStore::default();
1551        for i in 0..40 {
1552            let doc = document(i, 1);
1553            store.insert(doc.id.clone(), doc);
1554        }
1555        let query = "running rare3 missing absent unknown extra terms for selective postings";
1556        let forms = [terms(query, false), terms(query, true)];
1557        let posting_work: usize = forms
1558            .iter()
1559            .enumerate()
1560            .map(|(form, terms)| {
1561                terms
1562                    .keys()
1563                    .map(|word| store.postings[form].get(word).map_or(0, |p| p.len()))
1564                    .sum::<usize>()
1565            })
1566            .sum();
1567        let eligible = store
1568            .values()
1569            .filter(|d| d.metadata["group"] == 3)
1570            .map(|d| d.chunks.len())
1571            .sum::<usize>();
1572        assert!(
1573            posting_work + store.slot_count
1574                < eligible * forms.iter().map(BTreeMap::len).sum::<usize>()
1575        );
1576        assert_scan_parity(&store);
1577        for i in (0..40).step_by(2) {
1578            store.remove(&format!("d{i:03}"));
1579        }
1580        for i in 0..20 {
1581            let mut doc = document(i, 2);
1582            if i == 3 {
1583                doc.metadata["group"] = json!(22);
1584                doc.source_uri = Some("replacement-source".into());
1585            }
1586            store.insert(doc.id.clone(), doc);
1587        }
1588        assert_scan_parity(&store);
1589        let ids: Vec<_> = store.values().map(|doc| doc.id.clone()).collect();
1590        for id in ids {
1591            store.remove(&id);
1592        }
1593        assert_eq!(store.slot_count, 0);
1594        assert!(store.postings.iter().all(|p| p.term_count() == 0));
1595        let doc = document(0, 3);
1596        store.insert(doc.id.clone(), doc);
1597        assert_scan_parity(&store);
1598    }
1599
1600    #[test]
1601    fn deleting_low_slots_reclaims_postings_with_high_slot_survivors() {
1602        for n in [1023, 1024, 1025] {
1603            let mut store = DocumentStore::default();
1604            for i in 0..n {
1605                let mut doc = document(i, 1);
1606                doc.chunks.truncate(1);
1607                store.insert(doc.id.clone(), doc);
1608            }
1609            for i in 0..n - 1 {
1610                store.remove(&format!("d{i:03}"));
1611            }
1612            assert!(store.slot_count <= 1024);
1613            assert_eq!(store.values().count(), 1);
1614            for form in &store.postings {
1615                form.assert_valid();
1616            }
1617            assert_scan_parity(&store);
1618        }
1619    }
1620
1621    #[test]
1622    fn restored_zero_frequency_terms_do_not_become_lexical_candidates() {
1623        let mut store = DocumentStore::default();
1624        let mut document = document(0, 1);
1625        for chunk in &mut document.chunks {
1626            chunk.terms.insert("ghost".into(), 0);
1627        }
1628        store.insert(document.id.clone(), document);
1629        for fusion in ["blend", "rrf"] {
1630            let options = SearchOptions {
1631                fusion: Some(fusion.into()),
1632                ..Default::default()
1633            };
1634            assert!(search_impl(&store, "ghost", &options, None, true)
1635                .unwrap()
1636                .is_empty());
1637            for query in ["ghost common", "ghost rare0"] {
1638                assert_eq!(
1639                    serde_json::to_value(search_impl(&store, query, &options, None, true).unwrap())
1640                        .unwrap(),
1641                    serde_json::to_value(
1642                        search_impl(&store, query, &options, None, false).unwrap()
1643                    )
1644                    .unwrap()
1645                );
1646            }
1647        }
1648    }
1649
1650    #[test]
1651    fn embeddings_round_to_f32_and_read_legacy_f64_records() {
1652        let legacy = "[0.1, 0.2, 0.30000000000000004, -0.7071067811865476]";
1653        let read: Embedding = serde_json::from_str(legacy).unwrap();
1654        assert_eq!(
1655            read,
1656            Embedding::from_f32(vec![0.1, 0.2, 0.3, -std::f32::consts::FRAC_1_SQRT_2])
1657        );
1658        let written = serde_json::to_string(&read).unwrap();
1659        assert_eq!(written, "[0.1,0.2,0.3,-0.70710677]");
1660        let again: Embedding = serde_json::from_str(&written).unwrap();
1661        assert_eq!(again, read);
1662        assert_eq!(
1663            read.to_f64(),
1664            read.iter().map(|&v| f64::from(v)).collect::<Vec<_>>()
1665        );
1666
1667        let stored = document(5, 1);
1668        let embedded = stored.chunks[0].embedding.as_ref().unwrap();
1669        let reference = normalize_embedding(&[1.0, 0.1, 0.0], 3).unwrap();
1670        assert_eq!(
1671            &embedded[..],
1672            &reference.iter().map(|&v| v as f32).collect::<Vec<_>>()[..]
1673        );
1674        let query = normalize_embedding(&[0.3, -0.2, 0.9], 3).unwrap();
1675        let exact: f64 = query.iter().zip(&reference).map(|(q, v)| q * v).sum();
1676        assert!((embedded.cosine(&query) - exact).abs() < 1e-6);
1677        let widened: f64 = query
1678            .iter()
1679            .zip(embedded.iter())
1680            .map(|(q, &v)| q * f64::from(v))
1681            .sum();
1682        assert_eq!(embedded.cosine(&query), widened.clamp(-1.0, 1.0));
1683
1684        let json = serde_json::to_value(&stored).unwrap();
1685        assert!(json["chunks"][0]["embedding"].is_array());
1686        let parsed: StoredDocument = serde_json::from_value(json).unwrap();
1687        assert_eq!(parsed.chunks[0].embedding, stored.chunks[0].embedding);
1688        let without: StoredChunk = {
1689            let mut chunk = stored.chunks[0].clone();
1690            chunk.embedding = None;
1691            chunk
1692        };
1693        assert_eq!(
1694            serde_json::to_value(&without).unwrap()["embedding"],
1695            Value::Null
1696        );
1697    }
1698
1699    #[test]
1700    fn replacements_preserve_frequency_filters_embeddings_and_retained_views() {
1701        for chunks in [1, 2, 3] {
1702            for change_tokenization in [false, true] {
1703                let mut store = DocumentStore::default();
1704                for i in 0..20 {
1705                    let mut doc = document(i, 1);
1706                    if i == 3 {
1707                        doc.chunks.truncate(2);
1708                    }
1709                    store.insert(doc.id.clone(), doc);
1710                }
1711                store.remove("d000");
1712                let options = SearchOptions {
1713                    k: Some(100),
1714                    candidate_limit: Some(100),
1715                    embedding: Some(vec![1.0, 0.0, 0.0]),
1716                    ..Default::default()
1717                };
1718                let prepared = prepare_search("running common ghost", &options, Some(3)).unwrap();
1719                let retained = capture_search(&store, vec![prepared]);
1720                let old = serde_json::to_value(execute_search(&retained, 0)).unwrap();
1721                let mut replacement = document(3, 2);
1722                replacement.chunks.truncate(chunks);
1723                replacement.metadata = json!({"group":99, "flag":false});
1724                replacement.source_uri = Some("replacement-source".into());
1725                for chunk in &mut replacement.chunks {
1726                    chunk.terms.insert("common".into(), (chunks - 1) as u32);
1727                    chunk.terms.insert("ghost".into(), 2);
1728                    chunk.terms.remove("run");
1729                    chunk.word_count += 1;
1730                    chunk.embedding = None;
1731                    if change_tokenization {
1732                        chunk.stemmed = !chunk.stemmed;
1733                    }
1734                }
1735                store.insert(replacement.id.clone(), replacement);
1736                assert_scan_parity(&store);
1737                let captured = capture_search(
1738                    &store,
1739                    vec![prepare_search("running common ghost", &options, Some(3)).unwrap()],
1740                );
1741                assert_eq!(
1742                    serde_json::to_value(execute_search(&captured, 0)).unwrap(),
1743                    serde_json::to_value(
1744                        search_impl(&store, "running common ghost", &options, Some(3), false)
1745                            .unwrap()
1746                    )
1747                    .unwrap()
1748                );
1749                assert_eq!(
1750                    serde_json::to_value(execute_search(&retained, 0)).unwrap(),
1751                    old
1752                );
1753                assert_eq!(
1754                    store.embedded_count(),
1755                    store
1756                        .values()
1757                        .flat_map(|d| &d.chunks)
1758                        .filter(|c| c.embedding.is_some())
1759                        .count()
1760                );
1761                assert_eq!(
1762                    store.word_count,
1763                    store
1764                        .values()
1765                        .flat_map(|d| &d.chunks)
1766                        .map(|c| c.word_count)
1767                        .sum::<usize>()
1768                );
1769                assert_eq!(
1770                    store.chunk_count,
1771                    store.values().map(|d| d.chunks.len()).sum::<usize>()
1772                );
1773                for form in &store.postings {
1774                    form.assert_valid();
1775                }
1776                let selected = search_impl(
1777                    &store,
1778                    "ghost",
1779                    &SearchOptions {
1780                        source_uri: Some("replacement-source".into()),
1781                        filter: Some(json!({"group":99})),
1782                        k: Some(100),
1783                        candidate_limit: Some(100),
1784                        ..Default::default()
1785                    },
1786                    Some(3),
1787                    true,
1788                )
1789                .unwrap();
1790                assert_eq!(selected.len(), chunks);
1791                assert!(selected.iter().all(|r| r.document_id == "d003"
1792                    && r.version == "2"
1793                    && r.semantic_score.is_none()));
1794                let mut embedded = document(3, 3);
1795                embedded.chunks.truncate(chunks);
1796                store.insert(embedded.id.clone(), embedded);
1797                assert_scan_parity(&store);
1798                assert!(store.embedded_documents.contains("d003"));
1799                assert_eq!(
1800                    store.embedded_count(),
1801                    store
1802                        .values()
1803                        .flat_map(|d| &d.chunks)
1804                        .filter(|c| c.embedding.is_some())
1805                        .count()
1806                );
1807                store.remove("d003");
1808                assert_scan_parity(&store);
1809                assert_eq!(
1810                    serde_json::to_value(execute_search(&retained, 0)).unwrap(),
1811                    old
1812                );
1813            }
1814        }
1815    }
1816}