hermes_core/segment/merger/
mod.rs1mod dense;
4#[cfg(feature = "diagnostics")]
5mod diagnostics;
6mod fast_fields;
7mod postings;
8mod sparse;
9mod store;
10
11use std::sync::Arc;
12
13use rustc_hash::FxHashMap;
14
15use super::reader::SegmentReader;
16use super::types::{FieldStats, SegmentFiles, SegmentId, SegmentMeta};
17use super::{OffsetWriter, format_bytes};
18use crate::Result;
19use crate::directories::{Directory, DirectoryWriter};
20use crate::dsl::Schema;
21
22fn doc_offsets(segments: &[SegmentReader]) -> Result<Vec<u32>> {
26 let mut offsets = Vec::with_capacity(segments.len());
27 let mut acc = 0u32;
28 for seg in segments {
29 offsets.push(acc);
30 acc = acc.checked_add(seg.num_docs()).ok_or_else(|| {
31 crate::Error::Internal(format!(
32 "Total document count across segments exceeds u32::MAX ({})",
33 u32::MAX
34 ))
35 })?;
36 }
37 Ok(offsets)
38}
39
40#[derive(Debug, Clone, Default)]
42pub struct MergeStats {
43 pub terms_processed: usize,
45 pub term_dict_bytes: usize,
47 pub postings_bytes: usize,
49 pub store_bytes: usize,
51 pub vectors_bytes: usize,
53 pub sparse_bytes: usize,
55 pub fast_bytes: usize,
57}
58
59impl std::fmt::Display for MergeStats {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 write!(
62 f,
63 "terms={}, term_dict={}, postings={}, store={}, vectors={}, sparse={}, fast={}",
64 self.terms_processed,
65 format_bytes(self.term_dict_bytes),
66 format_bytes(self.postings_bytes),
67 format_bytes(self.store_bytes),
68 format_bytes(self.vectors_bytes),
69 format_bytes(self.sparse_bytes),
70 format_bytes(self.fast_bytes),
71 )
72 }
73}
74
75pub use super::types::TrainedVectorStructures;
77
78pub(crate) fn block_in_place_if_multithread<R>(f: impl FnOnce() -> R) -> R {
82 if tokio::runtime::Handle::try_current()
83 .map(|h| h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
84 .unwrap_or(false)
85 {
86 tokio::task::block_in_place(f)
87 } else {
88 f()
89 }
90}
91
92pub struct SegmentMerger {
94 schema: Arc<Schema>,
95 reorder_bmp: bool,
99 background_pool: Option<Arc<rayon::ThreadPool>>,
103 granularity: crate::segment::reorder::BpGranularity,
108}
109
110impl SegmentMerger {
111 pub fn new(schema: Arc<Schema>) -> Self {
112 Self {
113 schema,
114 reorder_bmp: false,
115 background_pool: None,
116 granularity: crate::segment::reorder::BpGranularity::Auto,
117 }
118 }
119
120 pub fn with_bmp_reorder(mut self, reorder: bool) -> Self {
122 self.reorder_bmp = reorder;
123 self
124 }
125
126 pub fn with_background_pool(mut self, pool: Option<Arc<rayon::ThreadPool>>) -> Self {
128 self.background_pool = pool;
129 self
130 }
131
132 pub fn with_granularity(mut self, granularity: crate::segment::reorder::BpGranularity) -> Self {
134 self.granularity = granularity;
135 self
136 }
137
138 pub async fn merge<D: Directory + DirectoryWriter>(
148 &self,
149 dir: &D,
150 segments: &[SegmentReader],
151 new_segment_id: SegmentId,
152 trained: Option<&TrainedVectorStructures>,
153 ) -> Result<(SegmentMeta, MergeStats)> {
154 let mut stats = MergeStats::default();
155 let files = SegmentFiles::new(new_segment_id.0);
156
157 let merge_start = std::time::Instant::now();
170
171 let postings_fut = async {
173 let mut postings_writer =
174 OffsetWriter::new(dir.streaming_writer_cold(&files.postings).await?);
175 let mut positions_writer =
176 OffsetWriter::new(dir.streaming_writer_cold(&files.positions).await?);
177 let mut term_dict_writer =
178 OffsetWriter::new(dir.streaming_writer_cold(&files.term_dict).await?);
179
180 let terms_processed = self
181 .merge_postings(
182 segments,
183 &mut term_dict_writer,
184 &mut postings_writer,
185 &mut positions_writer,
186 )
187 .await?;
188
189 let postings_bytes = postings_writer.offset() as usize;
190 let term_dict_bytes = term_dict_writer.offset() as usize;
191 let positions_bytes = positions_writer.offset();
192
193 postings_writer.finish()?;
194 term_dict_writer.finish()?;
195 if positions_bytes > 0 {
196 positions_writer.finish()?;
197 } else {
198 drop(positions_writer);
199 let _ = dir.delete(&files.positions).await;
200 }
201 log::info!(
202 "[merge] postings done: {} terms, term_dict={}, postings={}, positions={}",
203 terms_processed,
204 format_bytes(term_dict_bytes),
205 format_bytes(postings_bytes),
206 format_bytes(positions_bytes as usize),
207 );
208 Ok::<(usize, usize, usize), crate::Error>((
209 terms_processed,
210 term_dict_bytes,
211 postings_bytes,
212 ))
213 };
214
215 let store_fut = async {
216 let mut store_writer =
217 OffsetWriter::new(dir.streaming_writer_cold(&files.store).await?);
218 let store_num_docs = self.merge_store(segments, &mut store_writer).await?;
219 let bytes = store_writer.offset() as usize;
220 store_writer.finish()?;
221 Ok::<(usize, u32), crate::Error>((bytes, store_num_docs))
222 };
223
224 let fast_fut = async { self.merge_fast_fields(dir, segments, &files).await };
225
226 let (postings_result, store_result, fast_bytes) =
227 tokio::try_join!(postings_fut, store_fut, fast_fut)?;
228
229 log::info!(
230 "[merge] stage 1 done in {:.1}s (postings + store + fast)",
231 merge_start.elapsed().as_secs_f64()
232 );
233
234 let sparse_fut = async { self.merge_sparse_vectors(dir, segments, &files).await };
238
239 let dense_fut = async {
240 self.merge_dense_vectors(dir, segments, &files, trained)
241 .await
242 };
243
244 let (sparse_bytes, vectors_bytes) = tokio::try_join!(sparse_fut, dense_fut)?;
245 let (store_bytes, store_num_docs) = store_result;
246 stats.terms_processed = postings_result.0;
247 stats.term_dict_bytes = postings_result.1;
248 stats.postings_bytes = postings_result.2;
249 stats.store_bytes = store_bytes;
250 stats.vectors_bytes = vectors_bytes;
251 stats.sparse_bytes = sparse_bytes;
252 stats.fast_bytes = fast_bytes;
253 log::info!(
254 "[merge] all phases done in {:.1}s: {}",
255 merge_start.elapsed().as_secs_f64(),
256 stats
257 );
258
259 let mut merged_field_stats: FxHashMap<u32, FieldStats> = FxHashMap::default();
261 for segment in segments {
262 for (&field_id, field_stats) in &segment.meta().field_stats {
263 let entry = merged_field_stats.entry(field_id).or_default();
264 entry.total_tokens += field_stats.total_tokens;
265 entry.doc_count += field_stats.doc_count;
266 }
267 }
268
269 let total_docs: u32 = segments
270 .iter()
271 .try_fold(0u32, |acc, s| acc.checked_add(s.num_docs()))
272 .ok_or_else(|| {
273 crate::Error::Internal(format!(
274 "Total document count exceeds u32::MAX ({})",
275 u32::MAX
276 ))
277 })?;
278
279 if store_num_docs != total_docs {
283 log::error!(
284 "[merge] STORE/META MISMATCH: store has {} docs but metadata expects {}. \
285 Per-segment: {:?}",
286 store_num_docs,
287 total_docs,
288 segments
289 .iter()
290 .map(|s| (
291 format!("{:016x}", s.meta().id),
292 s.num_docs(),
293 s.store().num_docs()
294 ))
295 .collect::<Vec<_>>()
296 );
297 return Err(crate::Error::Io(std::io::Error::new(
298 std::io::ErrorKind::InvalidData,
299 format!(
300 "Store/meta doc count mismatch: store={}, meta={}",
301 store_num_docs, total_docs
302 ),
303 )));
304 }
305
306 let meta = SegmentMeta {
307 id: new_segment_id.0,
308 num_docs: total_docs,
309 field_stats: merged_field_stats,
310 };
311
312 dir.write(&files.meta, &meta.serialize()?).await?;
313
314 let label = if trained.is_some() {
315 "ANN merge"
316 } else {
317 "Merge"
318 };
319 log::info!("{} complete: {} docs, {}", label, total_docs, stats);
320
321 Ok((meta, stats))
322 }
323}
324
325pub async fn delete_segment<D: Directory + DirectoryWriter>(
327 dir: &D,
328 segment_id: SegmentId,
329) -> Result<()> {
330 let files = SegmentFiles::new(segment_id.0);
331 let _ = tokio::join!(
332 dir.delete(&files.term_dict),
333 dir.delete(&files.postings),
334 dir.delete(&files.store),
335 dir.delete(&files.meta),
336 dir.delete(&files.vectors),
337 dir.delete(&files.sparse),
338 dir.delete(&files.positions),
339 dir.delete(&files.fast),
340 );
341 Ok(())
342}