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