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