1use 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
45pub const MAX_BATCH_ITEMS: usize = 4096;
47
48#[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 pub title_weight: Option<u32>,
96}
97
98#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum EncodeMode {
104 #[default]
106 Eager,
107 Deferred,
112 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase", default)]
142pub struct IngestOptions {
143 pub encode: EncodeMode,
144 pub unstemmed: bool,
148}
149
150#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub(crate) enum VectorState {
156 #[default]
157 Encoded,
158 Pending,
161 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 pub fusion: Option<String>,
198 pub bm25_k1: Option<f64>,
201 pub bm25_b: Option<f64>,
203}
204
205#[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 #[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 #[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#[derive(Clone, Debug, PartialEq)]
296pub(crate) struct Embedding(Arc<[f32]>);
297
298impl Embedding {
299 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 pub(crate) fn to_f64(&self) -> Vec<f64> {
310 self.0.iter().map(|&value| f64::from(value)).collect()
311 }
312
313 #[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 #[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 pub text: Option<String>,
358 #[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 #[serde(default)]
370 pub stemmed: bool,
371 #[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
497pub(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 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
563pub(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
590pub(crate) fn terms(text: &str, stem: bool) -> BTreeMap<String, u32> {
593 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 lower.push_str(&word.to_lowercase());
616 }
617 if stem {
618 add(&stemmer().stem(&lower));
619 } else {
620 add(&lower);
621 }
622 }
623 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
630const STEM_MEMO_CAPACITY: usize = 1 << 18;
633
634type SeededMap<K, V> = std::collections::HashMap<K, V, foldhash::fast::RandomState>;
636
637#[derive(Default)]
642struct Interner {
643 words: WordMap,
645 ids: SeededMap<Box<str>, u32>,
647 terms: Vec<Box<str>>,
650 keys: Vec<u64>,
651 counts: Vec<u32>,
654 counted: Vec<u32>,
655 lower: String,
656 unstemmed: bool,
658}
659
660#[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 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 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 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#[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#[derive(Default)]
861pub(crate) struct StemMemoPool {
862 kept: parking_lot::Mutex<Option<StemMemo>>,
863 release: std::sync::atomic::AtomicBool,
864}
865
866impl StemMemoPool {
867 pub(crate) fn take(&self) -> PooledStemMemo<'_> {
869 let memo = self.kept.lock().take().unwrap_or_default();
870 PooledStemMemo { pool: self, memo }
871 }
872
873 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 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
911fn 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
936pub(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
973pub(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
1092pub(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 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 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 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}