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
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
65pub const MAX_BATCH_ITEMS: usize = 4096;
67
68#[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 pub title_weight: Option<u32>,
101}
102
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "lowercase")]
108pub enum EncodeMode {
109 #[default]
111 Eager,
112 Deferred,
117 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase", default)]
147pub struct IngestOptions {
148 pub encode: EncodeMode,
149 pub unstemmed: bool,
153 pub positions: bool,
155 pub whole_document: bool,
158}
159
160#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "lowercase")]
165pub(crate) enum VectorState {
166 #[default]
167 Encoded,
168 Pending,
171 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 pub fusion: Option<String>,
208 pub bm25_k1: Option<f64>,
211 pub bm25_b: Option<f64>,
213}
214
215#[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 #[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 #[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 #[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#[derive(Clone, Debug, PartialEq)]
312pub(crate) struct Embedding(Arc<[f32]>);
313
314impl Embedding {
315 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 pub(crate) fn to_f64(&self) -> Vec<f64> {
326 self.0.iter().map(|&value| f64::from(value)).collect()
327 }
328
329 #[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 #[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 pub text: Option<String>,
374 #[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 #[serde(default)]
386 pub stemmed: bool,
387 #[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 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
526fn 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
544fn 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
558struct 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
588pub(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
595pub(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 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
689pub(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
716pub(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 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 lower.push_str(&word.to_lowercase());
747 }
748 if stem {
749 add(&stemmer().stem(&lower));
750 } else {
751 add(&lower);
752 }
753 }
754 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
761const STEM_MEMO_CAPACITY: usize = 1 << 18;
764
765type SeededMap<K, V> = std::collections::HashMap<K, V, foldhash::fast::RandomState>;
767
768#[derive(Default)]
773struct Interner {
774 words: WordMap,
776 ids: SeededMap<Box<str>, u32>,
778 terms: Vec<Box<str>>,
781 keys: Vec<u64>,
782 counts: Vec<u32>,
785 counted: Vec<u32>,
786 sequence: Vec<u32>,
788 record: bool,
789 lower: String,
790 unstemmed: bool,
792}
793
794#[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 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 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 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 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#[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#[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 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 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 drop(freed);
1067 }
1068
1069 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#[cfg(test)]
1104fn chunk_terms(text: &str, title_weight: u32, stem: bool) -> (StoredTerms, usize) {
1105 analyze_chunk(text, title_weight, stem, None)
1106}
1107
1108fn 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
1138pub(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
1175pub(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
1299pub(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
1317pub(crate) fn overlay_serves(store: &DocumentStore, prepared: &[PreparedSearch]) -> bool {
1320 prepared
1321 .iter()
1322 .all(|query| search::overlay_serves(query, store))
1323}
1324
1325pub(crate) fn capture_overlaid_search(
1328 store: &DocumentStore,
1329 prepared: Vec<PreparedSearch>,
1330) -> Result<CapturedSearch> {
1331 search::capture_overlaid_search(store, prepared)
1332}
1333
1334pub(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 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 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 #[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 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 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 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}