1#[cfg_attr(not(feature = "native"), allow(dead_code))]
12pub(crate) mod bmp;
13mod config;
14mod dense;
15#[cfg(feature = "diagnostics")]
16mod diagnostics;
17#[cfg_attr(not(feature = "native"), allow(dead_code))]
18pub(crate) mod graph_bisection;
19pub use graph_bisection::BpBudget;
20mod postings;
21mod sparse;
22mod store;
23
24pub use config::{MemoryBreakdown, SegmentBuilderConfig, SegmentBuilderStats};
25
26#[cfg(feature = "native")]
27use std::fs::{File, OpenOptions};
28#[cfg(feature = "native")]
29use std::io::BufWriter;
30use std::io::Write;
31use std::mem::size_of;
32#[cfg(feature = "native")]
33use std::path::PathBuf;
34
35use hashbrown::HashMap;
36use rustc_hash::FxHashMap;
37
38#[cfg(feature = "native")]
40use lasso::{Rodeo, Spur};
41
42#[cfg(not(feature = "native"))]
43pub(crate) mod simple_interner {
44 use hashbrown::HashMap;
45
46 #[derive(Clone, Copy, PartialEq, Eq, Hash)]
47 pub struct Spur(u32);
48
49 pub struct Rodeo {
52 strings: Vec<Box<str>>,
54 map: HashMap<&'static str, u32>,
57 }
58
59 impl Rodeo {
60 pub fn new() -> Self {
61 Self {
62 strings: Vec::new(),
63 map: HashMap::new(),
64 }
65 }
66
67 pub fn get(&self, key: &str) -> Option<Spur> {
68 self.map.get(key).map(|&id| Spur(id))
69 }
70
71 pub fn get_or_intern(&mut self, key: &str) -> Spur {
72 if let Some(&id) = self.map.get(key) {
73 return Spur(id);
74 }
75 let id = self.strings.len() as u32;
76 let boxed: Box<str> = key.into();
77 let static_ref: &'static str = unsafe { &*(boxed.as_ref() as *const str) };
80 self.strings.push(boxed);
81 self.map.insert(static_ref, id);
82 Spur(id)
83 }
84
85 pub fn resolve(&self, spur: &Spur) -> &str {
86 &self.strings[spur.0 as usize]
87 }
88
89 pub fn len(&self) -> usize {
90 self.strings.len()
91 }
92 }
93}
94
95#[cfg(not(feature = "native"))]
96use simple_interner::{Rodeo, Spur};
97
98use super::types::{FieldStats, SegmentFiles, SegmentId, SegmentMeta};
99use std::sync::Arc;
100
101use crate::directories::{Directory, DirectoryWriter};
102use crate::dsl::{Document, Field, FieldType, FieldValue, Schema};
103use crate::tokenizer::BoxedTokenizer;
104use crate::{DocId, Result};
105
106use dense::{BinaryDenseVectorBuilder, DenseVectorBuilder};
107use postings::{CompactPosting, PositionPostingListBuilder, PostingListBuilder, TermKey};
108use sparse::SparseVectorBuilder;
109
110const STORE_BUFFER_SIZE: usize = 16 * 1024 * 1024; const NEW_TERM_OVERHEAD: usize = size_of::<TermKey>() + size_of::<PostingListBuilder>() + 24;
116
117const INTERN_OVERHEAD: usize = size_of::<Spur>() + 2 * size_of::<usize>();
119
120const NEW_POS_TERM_OVERHEAD: usize =
122 size_of::<TermKey>() + size_of::<PositionPostingListBuilder>() + 24;
123
124const MAX_POSITION_ELEMENT_ORDINAL: u32 = (1 << 12) - 1;
130const MAX_TOKEN_POSITION: u32 = (1 << 20) - 1;
131
132const DEFAULT_BMP_SPARSE_DIMS: u32 = 105879;
136
137fn field_type_name(field_type: &FieldType) -> &'static str {
139 match field_type {
140 FieldType::Text => "text",
141 FieldType::U64 => "u64",
142 FieldType::I64 => "i64",
143 FieldType::F64 => "f64",
144 FieldType::Bytes => "bytes",
145 FieldType::SparseVector => "sparse_vector",
146 FieldType::DenseVector => "dense_vector",
147 FieldType::Json => "json",
148 FieldType::BinaryDenseVector => "binary_dense_vector",
149 }
150}
151
152fn field_value_type_name(value: &FieldValue) -> &'static str {
154 match value {
155 FieldValue::Text(_) => "text",
156 FieldValue::U64(_) => "u64",
157 FieldValue::I64(_) => "i64",
158 FieldValue::F64(_) => "f64",
159 FieldValue::Bytes(_) => "bytes",
160 FieldValue::SparseVector(_) => "sparse_vector",
161 FieldValue::DenseVector(_) => "dense_vector",
162 FieldValue::Json(_) => "json",
163 FieldValue::BinaryDenseVector(_) => "binary_dense_vector",
164 }
165}
166
167pub struct SegmentBuilder {
174 schema: Arc<Schema>,
175 config: SegmentBuilderConfig,
176 tokenizers: FxHashMap<Field, BoxedTokenizer>,
177
178 term_interner: Rodeo,
180
181 inverted_index: HashMap<TermKey, PostingListBuilder>,
183
184 #[cfg(feature = "native")]
186 posting_spill_file: Option<BufWriter<File>>,
187 #[cfg(feature = "native")]
188 posting_spill_path: PathBuf,
189 #[cfg(feature = "native")]
191 posting_spill_index: HashMap<TermKey, Vec<(u64, u32)>>,
192 #[cfg(feature = "native")]
193 posting_spill_offset: u64,
194
195 #[cfg(feature = "native")]
197 store_file: BufWriter<File>,
198 #[cfg(feature = "native")]
199 store_path: PathBuf,
200 #[cfg(not(feature = "native"))]
201 store_buffer: Vec<u8>,
202
203 next_doc_id: DocId,
205
206 field_stats: FxHashMap<u32, FieldStats>,
208
209 doc_field_lengths: Vec<u32>,
213 num_indexed_fields: usize,
214 field_to_slot: FxHashMap<u32, usize>,
215
216 local_tf_buffer: FxHashMap<Spur, u32>,
219
220 local_positions: FxHashMap<Spur, Vec<u32>>,
223
224 token_buffer: String,
226
227 numeric_buffer: String,
229
230 dense_vectors: FxHashMap<u32, DenseVectorBuilder>,
233
234 binary_dense_vectors: FxHashMap<u32, BinaryDenseVectorBuilder>,
236
237 sparse_vectors: FxHashMap<u32, SparseVectorBuilder>,
240
241 position_index: HashMap<TermKey, PositionPostingListBuilder>,
244
245 position_enabled_fields: FxHashMap<u32, Option<crate::dsl::PositionMode>>,
247
248 current_element_ordinal: FxHashMap<u32, u32>,
250
251 position_saturation_warned: bool,
254
255 estimated_memory: usize,
257
258 doc_serialize_buffer: Vec<u8>,
260
261 fast_fields: FxHashMap<u32, crate::structures::fast_field::FastFieldWriter>,
263}
264
265impl SegmentBuilder {
266 pub fn new(schema: Arc<Schema>, config: SegmentBuilderConfig) -> Result<Self> {
268 #[cfg(feature = "native")]
269 let (store_file, store_path, spill_path) = {
270 let segment_id = uuid::Uuid::new_v4();
271 let store_path = config
272 .temp_dir
273 .join(format!("hermes_store_{}.tmp", segment_id));
274 let store_file = BufWriter::with_capacity(
275 STORE_BUFFER_SIZE,
276 OpenOptions::new()
277 .create(true)
278 .write(true)
279 .truncate(true)
280 .open(&store_path)?,
281 );
282 let spill_path = config
283 .temp_dir
284 .join(format!("hermes_spill_{}.tmp", segment_id));
285 (store_file, store_path, spill_path)
286 };
287
288 let registry = crate::tokenizer::TokenizerRegistry::new();
290 let mut num_indexed_fields = 0;
291 let mut field_to_slot = FxHashMap::default();
292 let mut position_enabled_fields = FxHashMap::default();
293 let mut tokenizers = FxHashMap::default();
294 for (field, entry) in schema.fields() {
295 if entry.indexed && matches!(entry.field_type, FieldType::Text) {
296 field_to_slot.insert(field.0, num_indexed_fields);
297 num_indexed_fields += 1;
298 if entry.positions.is_some() {
299 position_enabled_fields.insert(field.0, entry.positions);
300 }
301 if let Some(ref tok_name) = entry.tokenizer
302 && let Some(tokenizer) = registry.get(tok_name)
303 {
304 tokenizers.insert(field, tokenizer);
305 }
306 }
307 }
308
309 use crate::structures::fast_field::{FastFieldColumnType, FastFieldWriter};
311 let mut fast_fields = FxHashMap::default();
312 for (field, entry) in schema.fields() {
313 if entry.fast {
314 let writer = if entry.multi {
315 match entry.field_type {
316 FieldType::U64 => {
317 FastFieldWriter::new_numeric_multi(FastFieldColumnType::U64)
318 }
319 FieldType::I64 => {
320 FastFieldWriter::new_numeric_multi(FastFieldColumnType::I64)
321 }
322 FieldType::F64 => {
323 FastFieldWriter::new_numeric_multi(FastFieldColumnType::F64)
324 }
325 FieldType::Text => FastFieldWriter::new_text_multi(),
326 _ => continue,
327 }
328 } else {
329 match entry.field_type {
330 FieldType::U64 => FastFieldWriter::new_numeric(FastFieldColumnType::U64),
331 FieldType::I64 => FastFieldWriter::new_numeric(FastFieldColumnType::I64),
332 FieldType::F64 => FastFieldWriter::new_numeric(FastFieldColumnType::F64),
333 FieldType::Text => FastFieldWriter::new_text(),
334 _ => continue,
335 }
336 };
337 fast_fields.insert(field.0, writer);
338 }
339 }
340
341 Ok(Self {
342 schema,
343 tokenizers,
344 term_interner: Rodeo::new(),
345 inverted_index: HashMap::with_capacity(config.posting_map_capacity),
346 #[cfg(feature = "native")]
347 posting_spill_file: None,
348 #[cfg(feature = "native")]
349 posting_spill_path: spill_path,
350 #[cfg(feature = "native")]
351 posting_spill_index: HashMap::new(),
352 #[cfg(feature = "native")]
353 posting_spill_offset: 0,
354 #[cfg(feature = "native")]
355 store_file,
356 #[cfg(feature = "native")]
357 store_path,
358 #[cfg(not(feature = "native"))]
359 store_buffer: Vec::with_capacity(STORE_BUFFER_SIZE),
360 next_doc_id: 0,
361 field_stats: FxHashMap::default(),
362 doc_field_lengths: Vec::new(),
363 num_indexed_fields,
364 field_to_slot,
365 local_tf_buffer: FxHashMap::default(),
366 local_positions: FxHashMap::default(),
367 token_buffer: String::with_capacity(64),
368 numeric_buffer: String::with_capacity(32),
369 config,
370 dense_vectors: FxHashMap::default(),
371 binary_dense_vectors: FxHashMap::default(),
372 sparse_vectors: FxHashMap::default(),
373 position_index: HashMap::new(),
374 position_enabled_fields,
375 current_element_ordinal: FxHashMap::default(),
376 position_saturation_warned: false,
377 estimated_memory: 0,
378 doc_serialize_buffer: Vec::with_capacity(256),
379 fast_fields,
380 })
381 }
382
383 pub fn set_tokenizer(&mut self, field: Field, tokenizer: BoxedTokenizer) {
384 self.tokenizers.insert(field, tokenizer);
385 }
386
387 fn next_element_ordinal(&mut self, field_id: u32) -> u32 {
390 let ordinal = *self.current_element_ordinal.get(&field_id).unwrap_or(&0);
391 *self.current_element_ordinal.entry(field_id).or_insert(0) += 1;
392 ordinal
393 }
394
395 fn next_vector_ordinal(&mut self, field_id: u32) -> Result<u16> {
396 let ordinal = self.next_element_ordinal(field_id);
397 u16::try_from(ordinal).map_err(|_| {
398 crate::Error::Document(format!(
399 "field {field_id} has more than {} vector values in one document",
400 u16::MAX as usize + 1
401 ))
402 })
403 }
404
405 pub fn num_docs(&self) -> u32 {
406 self.next_doc_id
407 }
408
409 #[inline]
411 pub fn estimated_memory_bytes(&self) -> usize {
412 self.estimated_memory
413 }
414
415 pub fn sparse_dim_count(&self) -> usize {
417 self.sparse_vectors.values().map(|b| b.postings.len()).sum()
418 }
419
420 pub fn stats(&self) -> SegmentBuilderStats {
422 use std::mem::size_of;
423
424 let postings_in_memory: usize =
425 self.inverted_index.values().map(|p| p.postings.len()).sum();
426
427 let compact_posting_size = size_of::<CompactPosting>();
429 let vec_overhead = size_of::<Vec<u8>>(); let term_key_size = size_of::<TermKey>();
431 let posting_builder_size = size_of::<PostingListBuilder>();
432 let spur_size = size_of::<Spur>();
433 let sparse_entry_size = size_of::<(DocId, u16, f32)>();
434
435 let hashmap_entry_base_overhead = 8usize;
438
439 let fxhashmap_entry_overhead = hashmap_entry_base_overhead;
441
442 let postings_bytes: usize = self
444 .inverted_index
445 .values()
446 .map(|p| p.postings.capacity() * compact_posting_size + vec_overhead)
447 .sum();
448
449 let index_overhead_bytes = self.inverted_index.len()
451 * (term_key_size + posting_builder_size + hashmap_entry_base_overhead);
452
453 let interner_arena_overhead = 2 * size_of::<usize>();
456 let avg_term_len = 8; let interner_bytes =
458 self.term_interner.len() * (avg_term_len + spur_size + interner_arena_overhead);
459
460 let field_lengths_bytes =
462 self.doc_field_lengths.capacity() * size_of::<u32>() + vec_overhead;
463
464 let mut dense_vectors_bytes: usize = 0;
466 let mut dense_vector_count: usize = 0;
467 let doc_id_ordinal_size = size_of::<(DocId, u16)>();
468 for b in self.dense_vectors.values() {
469 dense_vectors_bytes += b.vectors.capacity() * size_of::<f32>()
470 + b.doc_ids.capacity() * doc_id_ordinal_size
471 + 2 * vec_overhead; dense_vector_count += b.doc_ids.len();
473 }
474 for b in self.binary_dense_vectors.values() {
476 dense_vectors_bytes += b.vectors.capacity()
477 + b.doc_ids.capacity() * doc_id_ordinal_size
478 + 2 * vec_overhead;
479 dense_vector_count += b.doc_ids.len();
480 }
481
482 let local_tf_entry_size = spur_size + size_of::<u32>() + fxhashmap_entry_overhead;
484 let local_tf_buffer_bytes = self.local_tf_buffer.capacity() * local_tf_entry_size;
485
486 let mut sparse_vectors_bytes: usize = 0;
488 for builder in self.sparse_vectors.values() {
489 for postings in builder.postings.values() {
490 sparse_vectors_bytes += postings.capacity() * sparse_entry_size + vec_overhead;
491 }
492 let inner_entry_size = size_of::<u32>() + vec_overhead + fxhashmap_entry_overhead;
494 sparse_vectors_bytes += builder.postings.len() * inner_entry_size;
495 }
496 let outer_sparse_entry_size =
498 size_of::<u32>() + size_of::<SparseVectorBuilder>() + fxhashmap_entry_overhead;
499 sparse_vectors_bytes += self.sparse_vectors.len() * outer_sparse_entry_size;
500
501 let mut position_index_bytes: usize = 0;
503 for pos_builder in self.position_index.values() {
504 for (_, positions) in &pos_builder.postings {
505 position_index_bytes += positions.capacity() * size_of::<u32>() + vec_overhead;
506 }
507 let pos_entry_size = size_of::<DocId>() + vec_overhead;
509 position_index_bytes += pos_builder.postings.capacity() * pos_entry_size;
510 }
511 let pos_index_entry_size =
513 term_key_size + size_of::<PositionPostingListBuilder>() + hashmap_entry_base_overhead;
514 position_index_bytes += self.position_index.len() * pos_index_entry_size;
515
516 let estimated_memory_bytes = postings_bytes
517 + index_overhead_bytes
518 + interner_bytes
519 + field_lengths_bytes
520 + dense_vectors_bytes
521 + local_tf_buffer_bytes
522 + sparse_vectors_bytes
523 + position_index_bytes;
524
525 let memory_breakdown = MemoryBreakdown {
526 postings_bytes,
527 index_overhead_bytes,
528 interner_bytes,
529 field_lengths_bytes,
530 dense_vectors_bytes,
531 dense_vector_count,
532 sparse_vectors_bytes,
533 position_index_bytes,
534 };
535
536 SegmentBuilderStats {
537 num_docs: self.next_doc_id,
538 unique_terms: self.inverted_index.len(),
539 postings_in_memory,
540 interned_strings: self.term_interner.len(),
541 doc_field_lengths_size: self.doc_field_lengths.len(),
542 estimated_memory_bytes,
543 memory_breakdown,
544 }
545 }
546
547 fn validate_document_against_schema(&self, doc: &Document) -> Result<()> {
562 for (field, value) in doc.field_values() {
563 let Some(entry) = self.schema.get_field_entry(*field) else {
564 continue;
565 };
566
567 if !matches!(
570 &entry.field_type,
571 FieldType::DenseVector | FieldType::BinaryDenseVector
572 ) && !entry.indexed
573 && !entry.fast
574 {
575 continue;
576 }
577
578 match (&entry.field_type, value) {
579 (FieldType::SparseVector, FieldValue::SparseVector(entries)) => {
580 if let Some(config) = entry.sparse_vector_config.as_ref()
581 && config.format == crate::structures::SparseFormat::Bmp
582 {
583 let dims = config.dims.unwrap_or(DEFAULT_BMP_SPARSE_DIMS);
584 if let Some(&(dim_id, _)) =
585 entries.iter().find(|&&(dim_id, _)| dim_id >= dims)
586 {
587 return Err(crate::Error::Schema(format!(
588 "sparse vector for field '{}' contains dim_id {} out of \
589 range for the configured BMP dims={}: dimensions >= dims \
590 are never written to the block-max grid and can never \
591 match a query; raise `dims` in the field's sparse_vector \
592 config or fix the embedding model",
593 entry.name, dim_id, dims
594 )));
595 }
596 }
597 }
598 (FieldType::Text, FieldValue::Text(_))
600 | (FieldType::U64, FieldValue::U64(_))
601 | (FieldType::I64, FieldValue::I64(_))
602 | (FieldType::F64, FieldValue::F64(_))
603 | (FieldType::DenseVector, FieldValue::DenseVector(_))
604 | (FieldType::BinaryDenseVector, FieldValue::BinaryDenseVector(_))
605 | (FieldType::Bytes, FieldValue::Bytes(_))
607 | (FieldType::Json, FieldValue::Json(_)) => {}
608 (expected, got) => {
609 return Err(crate::Error::Schema(format!(
610 "type mismatch for field '{}': schema expects a {} value, got {}; \
611 the value would be stored but never indexed, so queries on this \
612 field could never match the document — fix the document or the \
613 schema",
614 entry.name,
615 field_type_name(expected),
616 field_value_type_name(got),
617 )));
618 }
619 }
620 }
621 Ok(())
622 }
623
624 pub fn add_document(&mut self, doc: Document) -> Result<DocId> {
626 self.validate_document_against_schema(&doc)?;
628
629 let doc_id = self.next_doc_id;
630 self.next_doc_id += 1;
631
632 let base_idx = self.doc_field_lengths.len();
634 self.doc_field_lengths
635 .resize(base_idx + self.num_indexed_fields, 0);
636 self.estimated_memory += self.num_indexed_fields * std::mem::size_of::<u32>();
637
638 self.current_element_ordinal.clear();
640
641 for (field, value) in doc.field_values() {
642 let Some(entry) = self.schema.get_field_entry(*field) else {
643 continue;
644 };
645
646 if !matches!(
649 &entry.field_type,
650 FieldType::DenseVector | FieldType::BinaryDenseVector
651 ) && !entry.indexed
652 && !entry.fast
653 {
654 continue;
655 }
656
657 match (&entry.field_type, value) {
658 (FieldType::Text, FieldValue::Text(text)) => {
659 if entry.indexed {
660 let element_ordinal = self.next_element_ordinal(field.0);
661 let token_count =
662 self.index_text_field(*field, doc_id, text, element_ordinal)?;
663
664 let stats = self.field_stats.entry(field.0).or_default();
665 stats.total_tokens += token_count as u64;
666 if element_ordinal == 0 {
667 stats.doc_count += 1;
668 }
669
670 if let Some(&slot) = self.field_to_slot.get(&field.0) {
671 self.doc_field_lengths[base_idx + slot] = token_count;
672 }
673 }
674
675 if let Some(ff) = self.fast_fields.get_mut(&field.0) {
677 ff.add_text(doc_id, text);
678 }
679 }
680 (FieldType::U64, FieldValue::U64(v)) => {
681 if entry.indexed {
682 self.index_numeric_field(*field, doc_id, *v)?;
683 }
684 if let Some(ff) = self.fast_fields.get_mut(&field.0) {
685 ff.add_u64(doc_id, *v);
686 }
687 }
688 (FieldType::I64, FieldValue::I64(v)) => {
689 if entry.indexed {
690 self.index_numeric_field(*field, doc_id, *v as u64)?;
691 }
692 if let Some(ff) = self.fast_fields.get_mut(&field.0) {
693 ff.add_i64(doc_id, *v);
694 }
695 }
696 (FieldType::F64, FieldValue::F64(v)) => {
697 if entry.indexed {
698 self.index_numeric_field(*field, doc_id, v.to_bits())?;
699 }
700 if let Some(ff) = self.fast_fields.get_mut(&field.0) {
701 ff.add_f64(doc_id, *v);
702 }
703 }
704 (FieldType::DenseVector, FieldValue::DenseVector(vec))
705 if entry.indexed || entry.stored =>
706 {
707 let ordinal = self.next_vector_ordinal(field.0)?;
708 self.index_dense_vector_field(*field, doc_id, ordinal, vec)?;
709 }
710 (FieldType::BinaryDenseVector, FieldValue::BinaryDenseVector(bytes))
711 if entry.indexed || entry.stored =>
712 {
713 let ordinal = self.next_vector_ordinal(field.0)?;
714 self.index_binary_dense_vector_field(*field, doc_id, ordinal, bytes)?;
715 }
716 (FieldType::SparseVector, FieldValue::SparseVector(entries)) => {
717 let ordinal = self.next_vector_ordinal(field.0)?;
718 self.index_sparse_vector_field(*field, doc_id, ordinal, entries)?;
719 }
720 _ => {}
725 }
726 }
727
728 self.write_document_to_store(&doc)?;
730
731 Ok(doc_id)
732 }
733
734 fn index_text_field(
743 &mut self,
744 field: Field,
745 doc_id: DocId,
746 text: &str,
747 element_ordinal: u32,
748 ) -> Result<u32> {
749 use crate::dsl::PositionMode;
750
751 let field_id = field.0;
752 let position_mode = self
753 .position_enabled_fields
754 .get(&field_id)
755 .copied()
756 .flatten();
757
758 let encoded_ordinal = if position_mode.is_some_and(|m| m.tracks_ordinal())
761 && element_ordinal > MAX_POSITION_ELEMENT_ORDINAL
762 {
763 self.warn_position_saturation(
764 "element ordinal",
765 element_ordinal,
766 MAX_POSITION_ELEMENT_ORDINAL,
767 );
768 MAX_POSITION_ELEMENT_ORDINAL
769 } else {
770 element_ordinal
771 };
772
773 self.local_tf_buffer.clear();
777 for v in self.local_positions.values_mut() {
779 v.clear();
780 }
781
782 let mut token_position = 0u32;
783
784 let custom_tokens = self.tokenizers.get(&field).map(|t| t.tokenize(text));
788
789 if let Some(tokens) = custom_tokens {
790 for token in &tokens {
792 let term_spur = if let Some(spur) = self.term_interner.get(&token.text) {
793 spur
794 } else {
795 let spur = self.term_interner.get_or_intern(&token.text);
796 self.estimated_memory += token.text.len() + INTERN_OVERHEAD;
797 spur
798 };
799 *self.local_tf_buffer.entry(term_spur).or_insert(0) += 1;
800
801 if let Some(mode) = position_mode {
802 let encoded_pos = match mode {
803 PositionMode::Ordinal => encoded_ordinal << 20,
804 PositionMode::TokenPosition => token.position,
805 PositionMode::Full => {
806 (encoded_ordinal << 20) | self.saturate_token_position(token.position)
807 }
808 };
809 self.local_positions
810 .entry(term_spur)
811 .or_default()
812 .push(encoded_pos);
813 }
814 }
815 token_position = tokens.len() as u32;
816 } else {
817 for word in text.split_whitespace() {
819 self.token_buffer.clear();
820 for c in word.chars() {
821 if c.is_alphanumeric() {
822 for lc in c.to_lowercase() {
823 self.token_buffer.push(lc);
824 }
825 }
826 }
827
828 if self.token_buffer.is_empty() {
829 continue;
830 }
831
832 let term_spur = if let Some(spur) = self.term_interner.get(&self.token_buffer) {
833 spur
834 } else {
835 let spur = self.term_interner.get_or_intern(&self.token_buffer);
836 self.estimated_memory += self.token_buffer.len() + INTERN_OVERHEAD;
837 spur
838 };
839 *self.local_tf_buffer.entry(term_spur).or_insert(0) += 1;
840
841 if let Some(mode) = position_mode {
842 let encoded_pos = match mode {
843 PositionMode::Ordinal => encoded_ordinal << 20,
844 PositionMode::TokenPosition => token_position,
845 PositionMode::Full => {
846 (encoded_ordinal << 20) | self.saturate_token_position(token_position)
847 }
848 };
849 self.local_positions
850 .entry(term_spur)
851 .or_default()
852 .push(encoded_pos);
853 }
854
855 token_position += 1;
856 }
857 }
858
859 for (&term_spur, &tf) in &self.local_tf_buffer {
862 let term_key = TermKey {
863 field: field_id,
864 term: term_spur,
865 };
866
867 match self.inverted_index.entry(term_key) {
868 hashbrown::hash_map::Entry::Occupied(mut o) => {
869 o.get_mut().add(doc_id, tf);
870 self.estimated_memory += size_of::<CompactPosting>();
871 #[cfg(feature = "native")]
873 if o.get().should_spill() {
874 use byteorder::{LittleEndian, WriteBytesExt};
875
876 let builder = o.get_mut();
877 let count = builder.postings.len() as u32;
878 let offset = self.posting_spill_offset;
879
880 let spill_file = if let Some(ref mut f) = self.posting_spill_file {
882 f
883 } else {
884 self.posting_spill_file = Some(BufWriter::with_capacity(
885 256 * 1024,
886 OpenOptions::new()
887 .create(true)
888 .write(true)
889 .truncate(true)
890 .open(&self.posting_spill_path)?,
891 ));
892 self.posting_spill_file.as_mut().unwrap()
893 };
894 for p in &builder.postings {
895 spill_file.write_u32::<LittleEndian>(p.doc_id)?;
896 spill_file.write_u16::<LittleEndian>(p.term_freq)?;
897 }
898 self.posting_spill_offset += count as u64 * 6;
899 self.posting_spill_index
900 .entry(term_key)
901 .or_default()
902 .push((offset, count));
903
904 let freed = builder.postings.len() * size_of::<CompactPosting>();
905 builder.spilled_count += count;
906 builder.postings.clear();
907 self.estimated_memory -= freed;
908 }
909 }
910 hashbrown::hash_map::Entry::Vacant(v) => {
911 let mut posting = PostingListBuilder::new();
912 posting.add(doc_id, tf);
913 v.insert(posting);
914 self.estimated_memory += size_of::<CompactPosting>() + NEW_TERM_OVERHEAD;
915 }
916 }
917
918 if position_mode.is_some()
919 && let Some(positions) = self.local_positions.get(&term_spur)
920 {
921 match self.position_index.entry(term_key) {
922 hashbrown::hash_map::Entry::Occupied(mut o) => {
923 for &pos in positions {
924 o.get_mut().add_position(doc_id, pos);
925 }
926 self.estimated_memory += positions.len() * size_of::<u32>();
927 }
928 hashbrown::hash_map::Entry::Vacant(v) => {
929 let mut pos_posting = PositionPostingListBuilder::new();
930 for &pos in positions {
931 pos_posting.add_position(doc_id, pos);
932 }
933 self.estimated_memory +=
934 positions.len() * size_of::<u32>() + NEW_POS_TERM_OVERHEAD;
935 v.insert(pos_posting);
936 }
937 }
938 }
939 }
940
941 Ok(token_position)
942 }
943
944 #[inline]
947 fn saturate_token_position(&mut self, token_position: u32) -> u32 {
948 if token_position > MAX_TOKEN_POSITION {
949 self.warn_position_saturation("token position", token_position, MAX_TOKEN_POSITION);
950 MAX_TOKEN_POSITION
951 } else {
952 token_position
953 }
954 }
955
956 #[cold]
958 fn warn_position_saturation(&mut self, what: &str, value: u32, max: u32) {
959 if !self.position_saturation_warned {
960 self.position_saturation_warned = true;
961 log::warn!(
962 "[segment_builder] index={} {what} {value} exceeds the position-encoding limit {max}; \
963 saturating — phrase/ordinal matching degrades for the overflowing \
964 elements/tokens instead of corrupting other documents' matches \
965 (further occurrences in this segment are not logged)",
966 self.schema.index_label()
967 );
968 }
969 }
970
971 fn index_numeric_field(&mut self, field: Field, doc_id: DocId, value: u64) -> Result<()> {
972 use std::fmt::Write;
973
974 self.numeric_buffer.clear();
975 write!(self.numeric_buffer, "__num_{}", value).unwrap();
976 let term_spur = if let Some(spur) = self.term_interner.get(&self.numeric_buffer) {
977 spur
978 } else {
979 let spur = self.term_interner.get_or_intern(&self.numeric_buffer);
980 self.estimated_memory += self.numeric_buffer.len() + INTERN_OVERHEAD;
981 spur
982 };
983
984 let term_key = TermKey {
985 field: field.0,
986 term: term_spur,
987 };
988
989 match self.inverted_index.entry(term_key) {
990 hashbrown::hash_map::Entry::Occupied(mut o) => {
991 o.get_mut().add(doc_id, 1);
992 self.estimated_memory += size_of::<CompactPosting>();
993 }
994 hashbrown::hash_map::Entry::Vacant(v) => {
995 let mut posting = PostingListBuilder::new();
996 posting.add(doc_id, 1);
997 v.insert(posting);
998 self.estimated_memory += size_of::<CompactPosting>() + NEW_TERM_OVERHEAD;
999 }
1000 }
1001
1002 Ok(())
1003 }
1004
1005 fn index_dense_vector_field(
1007 &mut self,
1008 field: Field,
1009 doc_id: DocId,
1010 ordinal: u16,
1011 vector: &[f32],
1012 ) -> Result<()> {
1013 let dim = vector.len();
1014 let expected_dim = self
1015 .schema
1016 .get_field_entry(field)
1017 .and_then(|entry| entry.dense_vector_config.as_ref())
1018 .map(|config| config.dim)
1019 .ok_or_else(|| crate::Error::Schema("DenseVector field missing config".to_string()))?;
1020 if dim != expected_dim {
1021 return Err(crate::Error::Schema(format!(
1022 "Dense vector dimension mismatch: schema expects {}, got {}",
1023 expected_dim, dim
1024 )));
1025 }
1026 if let Some((index, value)) = vector
1027 .iter()
1028 .enumerate()
1029 .find(|(_, value)| !value.is_finite())
1030 {
1031 return Err(crate::Error::Document(format!(
1032 "dense vector contains non-finite value {value} at index {index}"
1033 )));
1034 }
1035
1036 let builder = self
1037 .dense_vectors
1038 .entry(field.0)
1039 .or_insert_with(|| DenseVectorBuilder::new(dim));
1040
1041 if builder.dim != dim && builder.len() > 0 {
1043 return Err(crate::Error::Schema(format!(
1044 "Dense vector dimension mismatch: expected {}, got {}",
1045 builder.dim, dim
1046 )));
1047 }
1048
1049 builder.add(doc_id, ordinal, vector);
1050
1051 self.estimated_memory += std::mem::size_of_val(vector) + size_of::<(DocId, u16)>();
1052
1053 Ok(())
1054 }
1055
1056 fn index_binary_dense_vector_field(
1058 &mut self,
1059 field: Field,
1060 doc_id: DocId,
1061 ordinal: u16,
1062 bytes: &[u8],
1063 ) -> Result<()> {
1064 let dim_bits = self
1065 .schema
1066 .get_field_entry(field)
1067 .and_then(|e| e.binary_dense_vector_config.as_ref())
1068 .map(|c| c.dim)
1069 .ok_or_else(|| {
1070 crate::Error::Schema("BinaryDenseVector field missing config".to_string())
1071 })?;
1072
1073 let expected_byte_len = dim_bits.div_ceil(8);
1074 if dim_bits == 0 || !dim_bits.is_multiple_of(8) {
1075 return Err(crate::Error::Schema(format!(
1076 "Binary vector dimension must be a positive multiple of 8, got {dim_bits}"
1077 )));
1078 }
1079 if bytes.len() != expected_byte_len {
1080 return Err(crate::Error::Schema(format!(
1081 "Binary vector byte length mismatch: expected {} (dim={}), got {}",
1082 expected_byte_len,
1083 dim_bits,
1084 bytes.len()
1085 )));
1086 }
1087
1088 let builder = self
1089 .binary_dense_vectors
1090 .entry(field.0)
1091 .or_insert_with(|| BinaryDenseVectorBuilder::new(dim_bits));
1092
1093 builder.add(doc_id, ordinal, bytes);
1094 self.estimated_memory += bytes.len() + size_of::<(DocId, u16)>();
1095
1096 Ok(())
1097 }
1098
1099 fn index_sparse_vector_field(
1109 &mut self,
1110 field: Field,
1111 doc_id: DocId,
1112 ordinal: u16,
1113 entries: &[(u32, f32)],
1114 ) -> Result<()> {
1115 if let Some((index, (_, weight))) = entries
1116 .iter()
1117 .enumerate()
1118 .find(|(_, (_, weight))| !weight.is_finite())
1119 {
1120 return Err(crate::Error::Document(format!(
1121 "sparse vector contains non-finite weight {weight} at index {index}"
1122 )));
1123 }
1124 let (weight_threshold, doc_mass, min_terms) = self
1125 .schema
1126 .get_field_entry(field)
1127 .and_then(|entry| entry.sparse_vector_config.as_ref())
1128 .map(|config| (config.weight_threshold, config.doc_mass, config.min_terms))
1129 .unwrap_or((0.0, None, 0));
1130
1131 let builder = self
1132 .sparse_vectors
1133 .entry(field.0)
1134 .or_insert_with(SparseVectorBuilder::new);
1135
1136 builder.inc_vector_count();
1137
1138 let mass_cutoff = match doc_mass {
1142 Some(mass) if mass < 1.0 && entries.len() > min_terms => {
1143 let mut weights: Vec<f32> = entries
1144 .iter()
1145 .map(|&(_, w)| w.abs())
1146 .filter(|w| *w >= weight_threshold)
1147 .collect();
1148 weights.sort_unstable_by(|a, b| b.total_cmp(a));
1149 let total: f64 = weights.iter().map(|&w| w as f64).sum();
1150 let target = total * mass as f64;
1151 let mut cumulative = 0.0f64;
1152 let mut cutoff = 0.0f32;
1153 for &w in &weights {
1154 if cumulative >= target {
1155 break;
1156 }
1157 cumulative += w as f64;
1158 cutoff = w;
1159 }
1160 cutoff
1161 }
1162 _ => 0.0,
1163 };
1164
1165 for &(dim_id, weight) in entries {
1166 if weight.abs() < weight_threshold || weight.abs() < mass_cutoff {
1168 continue;
1169 }
1170
1171 let is_new_dim = !builder.postings.contains_key(&dim_id);
1172 builder.add(dim_id, doc_id, ordinal, weight);
1173 self.estimated_memory += size_of::<(DocId, u16, f32)>();
1174 if is_new_dim {
1175 self.estimated_memory += size_of::<u32>() + size_of::<Vec<(DocId, u16, f32)>>() + 8; }
1178 }
1179
1180 Ok(())
1181 }
1182
1183 fn write_document_to_store(&mut self, doc: &Document) -> Result<()> {
1185 use byteorder::{LittleEndian, WriteBytesExt};
1186
1187 super::store::serialize_document_into(doc, &self.schema, &mut self.doc_serialize_buffer)?;
1188
1189 #[cfg(feature = "native")]
1190 {
1191 self.store_file
1192 .write_u32::<LittleEndian>(self.doc_serialize_buffer.len() as u32)?;
1193 self.store_file.write_all(&self.doc_serialize_buffer)?;
1194 }
1195 #[cfg(not(feature = "native"))]
1196 {
1197 self.store_buffer
1198 .write_u32::<LittleEndian>(self.doc_serialize_buffer.len() as u32)?;
1199 self.store_buffer.write_all(&self.doc_serialize_buffer)?;
1200 self.estimated_memory += size_of::<u32>() + self.doc_serialize_buffer.len();
1204 }
1205
1206 Ok(())
1207 }
1208
1209 pub async fn build<D: Directory + DirectoryWriter>(
1215 mut self,
1216 dir: &D,
1217 segment_id: SegmentId,
1218 trained: Option<&super::TrainedVectorStructures>,
1219 ) -> Result<SegmentMeta> {
1220 #[cfg(feature = "native")]
1222 self.store_file.flush()?;
1223
1224 let files = SegmentFiles::new(segment_id.0);
1225
1226 let position_index = std::mem::take(&mut self.position_index);
1228 let position_offsets = if !position_index.is_empty() {
1229 let mut pos_writer = dir.streaming_writer(&files.positions).await?;
1230 let offsets = postings::build_positions_streaming(
1231 position_index,
1232 &self.term_interner,
1233 &mut *pos_writer,
1234 )?;
1235 pos_writer.finish()?;
1236 offsets
1237 } else {
1238 FxHashMap::default()
1239 };
1240
1241 let inverted_index = std::mem::take(&mut self.inverted_index);
1244 let term_interner = std::mem::replace(&mut self.term_interner, Rodeo::new());
1245 #[cfg(feature = "native")]
1246 let store_path = self.store_path.clone();
1247 #[cfg(feature = "native")]
1248 let num_compression_threads = self.config.num_compression_threads;
1249 let compression_level = self.config.compression_level;
1250 let dense_vectors = std::mem::take(&mut self.dense_vectors);
1251 let binary_dense_vectors = std::mem::take(&mut self.binary_dense_vectors);
1252 let mut sparse_vectors = std::mem::take(&mut self.sparse_vectors);
1253 let schema = &self.schema;
1254
1255 let mut term_dict_writer =
1258 super::OffsetWriter::new(dir.streaming_writer(&files.term_dict).await?);
1259 let mut postings_writer =
1260 super::OffsetWriter::new(dir.streaming_writer(&files.postings).await?);
1261 let mut store_writer = super::OffsetWriter::new(dir.streaming_writer(&files.store).await?);
1262 let mut vectors_writer = if !dense_vectors.is_empty() || !binary_dense_vectors.is_empty() {
1263 Some(super::OffsetWriter::new(
1264 dir.streaming_writer(&files.vectors).await?,
1265 ))
1266 } else {
1267 None
1268 };
1269 let mut sparse_writer = if !sparse_vectors.is_empty() {
1270 Some(super::OffsetWriter::new(
1271 dir.streaming_writer(&files.sparse).await?,
1272 ))
1273 } else {
1274 None
1275 };
1276 let mut fast_fields = std::mem::take(&mut self.fast_fields);
1277 let num_docs = self.next_doc_id;
1278 let mut fast_writer = if !fast_fields.is_empty() {
1279 Some(super::OffsetWriter::new(
1280 dir.streaming_writer(&files.fast).await?,
1281 ))
1282 } else {
1283 None
1284 };
1285
1286 #[cfg(feature = "native")]
1287 {
1288 if let Some(ref mut f) = self.posting_spill_file {
1289 f.flush()?;
1290 }
1291 let posting_spill_index = std::mem::take(&mut self.posting_spill_index);
1292 let mut spill_reader_opt = if !posting_spill_index.is_empty() {
1293 let spill_file = std::fs::File::open(&self.posting_spill_path)?;
1294 Some((std::io::BufReader::new(spill_file), posting_spill_index))
1295 } else {
1296 None
1297 };
1298
1299 let ((postings_result, store_result), ((vectors_result, sparse_result), fast_result)) =
1300 rayon::join(
1301 || {
1302 rayon::join(
1303 || {
1304 let spill_arg = spill_reader_opt.as_mut().map(|(r, idx)| {
1305 (
1306 r as &mut std::io::BufReader<std::fs::File>,
1307 idx as &postings::SpillIndex,
1308 )
1309 });
1310 postings::build_postings_streaming(
1311 inverted_index,
1312 term_interner,
1313 &position_offsets,
1314 &mut term_dict_writer,
1315 &mut postings_writer,
1316 spill_arg,
1317 )
1318 },
1319 || {
1320 store::build_store_streaming(
1321 &store_path,
1322 num_compression_threads,
1323 compression_level,
1324 &mut store_writer,
1325 num_docs,
1326 )
1327 },
1328 )
1329 },
1330 || {
1331 rayon::join(
1332 || {
1333 rayon::join(
1334 || -> Result<()> {
1335 if let Some(ref mut w) = vectors_writer {
1336 dense::build_vectors_streaming(
1337 dense_vectors,
1338 binary_dense_vectors,
1339 schema,
1340 trained,
1341 w,
1342 )?;
1343 }
1344 Ok(())
1345 },
1346 || -> Result<()> {
1347 if let Some(ref mut w) = sparse_writer {
1348 sparse::build_sparse_streaming(
1349 &mut sparse_vectors,
1350 schema,
1351 w,
1352 )?;
1353 }
1354 Ok(())
1355 },
1356 )
1357 },
1358 || -> Result<()> {
1359 if let Some(ref mut w) = fast_writer {
1360 build_fast_fields_streaming(&mut fast_fields, num_docs, w)?;
1361 }
1362 Ok(())
1363 },
1364 )
1365 },
1366 );
1367 postings_result?;
1368 store_result?;
1369 vectors_result?;
1370 sparse_result?;
1371 fast_result?;
1372 }
1373
1374 #[cfg(not(feature = "native"))]
1375 {
1376 postings::build_postings_streaming(
1377 inverted_index,
1378 term_interner,
1379 &position_offsets,
1380 &mut term_dict_writer,
1381 &mut postings_writer,
1382 )?;
1383 store::build_store_streaming_from_buffer(
1384 &self.store_buffer,
1385 compression_level,
1386 &mut store_writer,
1387 num_docs,
1388 )?;
1389 if let Some(ref mut w) = vectors_writer {
1390 dense::build_vectors_streaming(
1391 dense_vectors,
1392 binary_dense_vectors,
1393 schema,
1394 trained,
1395 w,
1396 )?;
1397 }
1398 if let Some(ref mut w) = sparse_writer {
1399 sparse::build_sparse_streaming(&mut sparse_vectors, schema, w)?;
1400 }
1401 if let Some(ref mut w) = fast_writer {
1402 build_fast_fields_streaming(&mut fast_fields, num_docs, w)?;
1403 }
1404 }
1405
1406 let term_dict_bytes = term_dict_writer.offset() as usize;
1407 let postings_bytes = postings_writer.offset() as usize;
1408 let store_bytes = store_writer.offset() as usize;
1409 let vectors_bytes = vectors_writer.as_ref().map_or(0, |w| w.offset() as usize);
1410 let sparse_bytes = sparse_writer.as_ref().map_or(0, |w| w.offset() as usize);
1411 let fast_bytes = fast_writer.as_ref().map_or(0, |w| w.offset() as usize);
1412
1413 term_dict_writer.finish()?;
1414 postings_writer.finish()?;
1415 store_writer.finish()?;
1416 if let Some(w) = vectors_writer {
1417 w.finish()?;
1418 }
1419 if let Some(w) = sparse_writer {
1420 w.finish()?;
1421 }
1422 if let Some(w) = fast_writer {
1423 w.finish()?;
1424 }
1425 drop(position_offsets);
1426 drop(sparse_vectors);
1427
1428 log::info!(
1429 "[segment_build] index={} docs={}: term_dict={}, postings={}, store={}, dense_vectors={}, sparse_vectors={}, fast_fields={}",
1430 self.schema.index_label(),
1431 num_docs,
1432 crate::format_bytes(term_dict_bytes as u64),
1433 crate::format_bytes(postings_bytes as u64),
1434 crate::format_bytes(store_bytes as u64),
1435 crate::format_bytes(vectors_bytes as u64),
1436 crate::format_bytes(sparse_bytes as u64),
1437 crate::format_bytes(fast_bytes as u64),
1438 );
1439
1440 let meta = SegmentMeta {
1441 id: segment_id.0,
1442 num_docs: self.next_doc_id,
1443 field_stats: self.field_stats.clone(),
1444 };
1445
1446 dir.write_durable(&files.meta, &meta.serialize()?).await?;
1451
1452 #[cfg(feature = "native")]
1454 {
1455 let _ = std::fs::remove_file(&self.store_path);
1456 }
1457
1458 Ok(meta)
1459 }
1460}
1461
1462fn build_fast_fields_streaming(
1464 fast_fields: &mut FxHashMap<u32, crate::structures::fast_field::FastFieldWriter>,
1465 num_docs: u32,
1466 writer: &mut dyn Write,
1467) -> Result<()> {
1468 use crate::structures::fast_field::{FastFieldTocEntry, write_fast_field_toc_and_footer};
1469
1470 if fast_fields.is_empty() {
1471 return Ok(());
1472 }
1473
1474 let mut field_ids: Vec<u32> = fast_fields.keys().copied().collect();
1476 field_ids.sort_unstable();
1477
1478 let mut toc_entries: Vec<FastFieldTocEntry> = Vec::with_capacity(field_ids.len());
1479 let mut current_offset = 0u64;
1480
1481 for &field_id in &field_ids {
1482 let ff = fast_fields.get_mut(&field_id).unwrap();
1483 ff.pad_to(num_docs);
1484
1485 let (mut toc, bytes_written) = ff.serialize(writer, current_offset)?;
1486 toc.field_id = field_id;
1487 current_offset += bytes_written;
1488 toc_entries.push(toc);
1489 }
1490
1491 let toc_offset = current_offset;
1493 write_fast_field_toc_and_footer(writer, toc_offset, &toc_entries)?;
1494
1495 Ok(())
1496}
1497
1498#[cfg(feature = "native")]
1499impl Drop for SegmentBuilder {
1500 fn drop(&mut self) {
1501 let _ = std::fs::remove_file(&self.store_path);
1502 if self.posting_spill_file.is_some() {
1503 let _ = std::fs::remove_file(&self.posting_spill_path);
1504 }
1505 }
1506}
1507
1508#[cfg(test)]
1509impl SegmentBuilder {
1510 fn positions_for_term(&self, field: Field, term: &str) -> Vec<u32> {
1512 let Some(spur) = self.term_interner.get(term) else {
1513 return Vec::new();
1514 };
1515 let key = TermKey {
1516 field: field.0,
1517 term: spur,
1518 };
1519 self.position_index
1520 .get(&key)
1521 .map(|b| {
1522 b.postings
1523 .iter()
1524 .flat_map(|(_, ps)| ps.iter().copied())
1525 .collect()
1526 })
1527 .unwrap_or_default()
1528 }
1529}
1530
1531#[cfg(test)]
1532mod tests {
1533 use super::*;
1534 use crate::dsl::SchemaBuilder;
1535
1536 fn builder_for(schema: Schema) -> SegmentBuilder {
1537 SegmentBuilder::new(Arc::new(schema), SegmentBuilderConfig::default()).unwrap()
1538 }
1539
1540 #[test]
1546 fn test_add_document_rejects_type_mismatched_field_value() {
1547 let mut sb = SchemaBuilder::default();
1548 let views = sb.add_u64_field("views", true, true);
1549 let mut builder = builder_for(sb.build());
1550
1551 let mut doc = Document::new();
1552 doc.add_text(views, "123");
1553 let err = builder
1554 .add_document(doc)
1555 .expect_err("schema-mismatched value must be rejected loudly, not silently unindexed");
1556 let msg = err.to_string();
1557 assert!(msg.contains("views"), "error must name the field: {msg}");
1558 assert!(
1559 msg.contains("u64"),
1560 "error must name the expected type: {msg}"
1561 );
1562 assert!(msg.contains("text"), "error must name the got type: {msg}");
1563
1564 assert_eq!(builder.num_docs(), 0);
1566
1567 let mut doc = Document::new();
1569 doc.add_u64(views, 123);
1570 builder.add_document(doc).unwrap();
1571 assert_eq!(builder.num_docs(), 1);
1572 }
1573
1574 #[test]
1580 fn test_add_document_rejects_bmp_sparse_dim_out_of_range() {
1581 use crate::structures::{SparseFormat, SparseVectorConfig};
1582
1583 let mut sb = SchemaBuilder::default();
1584 let config = SparseVectorConfig {
1585 format: SparseFormat::Bmp,
1586 dims: Some(100),
1587 ..Default::default()
1588 };
1589 let spv = sb.add_sparse_vector_field_with_config("spv", true, false, config);
1590 let mut builder = builder_for(sb.build());
1591
1592 let mut doc = Document::new();
1594 doc.add_sparse_vector(spv, vec![(50, 1.0)]);
1595 builder.add_document(doc).unwrap();
1596
1597 let mut doc = Document::new();
1599 doc.add_sparse_vector(spv, vec![(50, 1.0), (150, 2.0)]);
1600 let err = builder
1601 .add_document(doc)
1602 .expect_err("out-of-range BMP dim must be rejected, not silently unsearchable");
1603 let msg = err.to_string();
1604 assert!(msg.contains("spv"), "error must name the field: {msg}");
1605 assert!(msg.contains("150"), "error must name the dim_id: {msg}");
1606 assert!(
1607 msg.contains("100"),
1608 "error must name the configured dims: {msg}"
1609 );
1610 assert_eq!(
1611 builder.num_docs(),
1612 1,
1613 "rejected doc must not consume a doc id"
1614 );
1615 }
1616
1617 #[test]
1618 fn test_add_document_maxscore_sparse_dims_unbounded() {
1619 let mut sb = SchemaBuilder::default();
1622 let spv = sb.add_sparse_vector_field("spv", true, false);
1623 let mut builder = builder_for(sb.build());
1624
1625 let mut doc = Document::new();
1626 doc.add_sparse_vector(spv, vec![(3_000_000, 1.0)]);
1627 builder.add_document(doc).unwrap();
1628 }
1629
1630 #[test]
1637 fn test_position_element_ordinal_overflow_saturates_instead_of_wrapping() {
1638 use crate::dsl::PositionMode;
1639
1640 let mut sb = SchemaBuilder::default();
1641 let body = sb.add_text_field("body", true, false);
1642 sb.set_positions(body, PositionMode::Full);
1643 let mut builder = builder_for(sb.build());
1644
1645 let mut doc = Document::new();
1648 doc.add_text(body, "anchor");
1649 for _ in 0..4095 {
1650 doc.add_text(body, "filler");
1651 }
1652 doc.add_text(body, "needle");
1653 builder.add_document(doc).unwrap();
1654
1655 let positions = builder.positions_for_term(body, "needle");
1656 assert_eq!(positions.len(), 1);
1657 let encoded = positions[0];
1658 assert_ne!(
1659 encoded >> 20,
1660 0,
1661 "element ordinal 4096 must not alias element 0"
1662 );
1663 assert_eq!(
1664 encoded >> 20,
1665 4095,
1666 "overflowing element ordinal must saturate at 4095"
1667 );
1668 }
1669
1670 #[test]
1671 fn test_position_token_position_overflow_saturates_instead_of_bleeding() {
1672 use crate::dsl::PositionMode;
1673
1674 let mut sb = SchemaBuilder::default();
1675 let body = sb.add_text_field("body", true, false);
1676 sb.set_positions(body, PositionMode::Full);
1677 let mut builder = builder_for(sb.build());
1678
1679 let mut text = "w ".repeat(1 << 20);
1682 text.push_str("needle");
1683 let mut doc = Document::new();
1684 doc.add_text(body, text);
1685 builder.add_document(doc).unwrap();
1686
1687 let positions = builder.positions_for_term(body, "needle");
1688 assert_eq!(positions.len(), 1);
1689 let encoded = positions[0];
1690 assert_eq!(
1691 encoded >> 20,
1692 0,
1693 "token position overflow must not decode as a different element ordinal"
1694 );
1695 assert_eq!(
1696 encoded & 0xFFFFF,
1697 0xFFFFF,
1698 "overflowing token position must saturate at 2^20 - 1"
1699 );
1700 }
1701
1702 #[cfg(feature = "native")]
1709 #[tokio::test]
1710 async fn test_spill_mid_document_does_not_duplicate_postings() {
1711 use crate::directories::RamDirectory;
1712 use crate::structures::TERMINATED;
1713
1714 let mut sb = SchemaBuilder::default();
1715 let body = sb.add_text_field("body", true, false);
1716 let schema = Arc::new(sb.build());
1717 let mut builder =
1718 SegmentBuilder::new(Arc::clone(&schema), SegmentBuilderConfig::default()).unwrap();
1719
1720 for _ in 0..16383 {
1723 let mut doc = Document::new();
1724 doc.add_text(body, "hot");
1725 builder.add_document(doc).unwrap();
1726 }
1727
1728 let mut doc = Document::new();
1733 doc.add_text(body, "hot");
1734 doc.add_text(body, "hot");
1735 let boundary_doc = builder.add_document(doc).unwrap();
1736 assert_eq!(boundary_doc, 16383);
1737
1738 let dir = RamDirectory::new();
1739 let segment_id = crate::segment::SegmentId::new();
1740 builder.build(&dir, segment_id, None).await.unwrap();
1741
1742 let reader = crate::segment::SegmentReader::open(&dir, segment_id, schema, 16)
1743 .await
1744 .unwrap();
1745 let postings = reader
1746 .get_postings(body, b"hot")
1747 .await
1748 .unwrap()
1749 .expect("postings for 'hot'");
1750 assert_eq!(
1751 postings.doc_count(),
1752 16384,
1753 "each document must appear exactly once per term (spill-boundary duplicate)"
1754 );
1755
1756 let mut it = postings.iterator();
1759 let mut prev: Option<DocId> = None;
1760 let mut boundary_tf = 0u32;
1761 let mut d = it.doc();
1762 while d != TERMINATED {
1763 if let Some(p) = prev {
1764 assert!(p < d, "duplicate/unordered doc id {d} after {p}");
1765 }
1766 if d == boundary_doc {
1767 boundary_tf = it.term_freq();
1768 }
1769 prev = Some(d);
1770 d = it.advance();
1771 }
1772 assert_eq!(prev, Some(boundary_doc));
1773 assert_eq!(
1774 boundary_tf, 2,
1775 "boundary doc's term frequency must combine both values"
1776 );
1777 }
1778}