1use super::algorithm::{bm25_score, compute_idf, BM25Params};
11use super::tokenizer::Tokenizer;
12use lc_vector_stores::document_store::{ChunkDocument, ChunkedDocumentStoreTrait};
13use lc_vector_stores::{Document, VectorStoreError};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::Path;
17use std::sync::Arc;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct AutoMergingConfig {
28 pub merge_threshold: f32,
29 pub leaf_chunk_size: usize,
30 pub parent_chunk_size: usize,
31 pub leaves_per_parent: usize,
32}
33
34impl Default for AutoMergingConfig {
35 fn default() -> Self {
36 Self {
37 merge_threshold: 0.5,
38 leaf_chunk_size: 400,
39 parent_chunk_size: 2000,
40 leaves_per_parent: 5,
41 }
42 }
43}
44
45impl AutoMergingConfig {
46 pub fn new() -> Self {
47 Self::default()
48 }
49
50 pub fn with_threshold(mut self, threshold: f32) -> Self {
51 self.merge_threshold = threshold;
52 self
53 }
54
55 pub fn with_leaf_size(mut self, size: usize) -> Self {
56 self.leaf_chunk_size = size;
57 self
58 }
59
60 pub fn with_parent_size(mut self, size: usize) -> Self {
61 self.parent_chunk_size = size;
62 self
63 }
64}
65
66#[derive(Debug, Clone)]
68pub struct ChunkedSearchResult {
69 pub merged_parent: Option<Document>,
70 pub leaf_chunks: Vec<ChunkDocument>,
71 pub score: f32,
72 pub matched_terms: Vec<String>,
73 pub parent_id: String,
74}
75
76impl ChunkedSearchResult {
77 pub fn content(&self) -> String {
78 if let Some(parent) = &self.merged_parent {
79 parent.content.clone()
80 } else {
81 self.leaf_chunks
82 .iter()
83 .map(|c| c.content.as_str())
84 .collect::<Vec<_>>()
85 .join("\n")
86 }
87 }
88
89 pub fn is_merged(&self) -> bool {
90 self.merged_parent.is_some()
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct BM25ParamsData {
97 pub k1: f64,
98 pub b: f64,
99}
100
101impl From<BM25Params> for BM25ParamsData {
102 fn from(params: BM25Params) -> Self {
103 Self {
104 k1: params.k1,
105 b: params.b,
106 }
107 }
108}
109
110impl From<BM25ParamsData> for BM25Params {
111 fn from(data: BM25ParamsData) -> Self {
112 BM25Params::with_values(data.k1, data.b)
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ChunkedIndexData {
119 pub chunk_id_list: Vec<String>,
120 pub chunk_term_freqs: Vec<HashMap<String, usize>>,
121 pub term_index: HashMap<String, Vec<(usize, usize)>>,
122 pub parent_to_leaves: HashMap<String, Vec<usize>>,
123 pub doc_lengths: Vec<usize>,
124 pub avgdl: f64,
125 pub n_docs: usize,
126 pub params: BM25ParamsData,
127 pub config: AutoMergingConfig,
128}
129
130pub struct ChunkedBM25Index<S: ChunkedDocumentStoreTrait = lc_vector_stores::ChunkedDocumentStore> {
135 store: Arc<S>,
136 chunk_id_list: Vec<String>,
137 chunk_term_freqs: Vec<HashMap<String, usize>>,
138 term_index: HashMap<String, Vec<(usize, usize)>>,
139 parent_to_leaves: HashMap<String, Vec<usize>>,
140 doc_lengths: Vec<usize>,
141 avgdl: f64,
142 n_docs: usize,
143 idf_cache: HashMap<String, f64>,
144 params: BM25Params,
145 tokenizer: Tokenizer,
146 config: AutoMergingConfig,
147}
148
149impl<S: ChunkedDocumentStoreTrait> ChunkedBM25Index<S> {
150 pub fn new(store: Arc<S>) -> Self {
151 Self::with_config(store, AutoMergingConfig::default())
152 }
153
154 pub fn with_config(store: Arc<S>, config: AutoMergingConfig) -> Self {
155 Self {
156 store,
157 chunk_id_list: Vec::new(),
158 chunk_term_freqs: Vec::new(),
159 term_index: HashMap::new(),
160 parent_to_leaves: HashMap::new(),
161 doc_lengths: Vec::new(),
162 avgdl: 0.0,
163 n_docs: 0,
164 idf_cache: HashMap::new(),
165 params: BM25Params::default(),
166 tokenizer: Tokenizer::new(),
167 config,
168 }
169 }
170
171 pub fn with_params(store: Arc<S>, params: BM25Params) -> Self {
172 let mut index = Self::new(store);
173 index.params = params;
174 index
175 }
176
177 pub fn add_chunk_index(&mut self, chunk_id: String, parent_id: String, content: &str) {
179 let chunk_idx = self.n_docs;
180
181 let terms = self.tokenizer.tokenize(content);
182 let term_freq = self.compute_term_freq(&terms);
183
184 for (term, freq) in &term_freq {
186 self.term_index
187 .entry(term.clone())
188 .or_default()
189 .push((chunk_idx, *freq));
190 }
191
192 self.parent_to_leaves
194 .entry(parent_id)
195 .or_default()
196 .push(chunk_idx);
197
198 self.chunk_id_list.push(chunk_id);
200 self.chunk_term_freqs.push(term_freq.clone());
201
202 let doc_length: usize = term_freq.values().sum();
203 self.doc_lengths.push(doc_length);
204 self.n_docs += 1;
205 self.update_avgdl();
206 self.idf_cache.clear();
207 }
208
209 pub fn add_chunk_indexes(&mut self, chunks: Vec<(String, String, String)>) {
211 for (chunk_id, parent_id, content) in chunks {
212 self.add_chunk_index(chunk_id, parent_id, &content);
213 }
214 }
215
216 fn compute_term_freq(&self, terms: &[String]) -> HashMap<String, usize> {
217 let mut freq = HashMap::new();
218 for term in terms {
219 *freq.entry(term.clone()).or_insert(0) += 1;
220 }
221 freq
222 }
223
224 fn update_avgdl(&mut self) {
225 if self.n_docs == 0 {
226 self.avgdl = 0.0;
227 } else {
228 let total: usize = self.doc_lengths.iter().sum();
229 self.avgdl = total as f64 / self.n_docs as f64;
230 }
231 }
232
233 fn compute_idf_for_term(&mut self, term: &str) -> f64 {
234 if let Some(idf) = self.idf_cache.get(term) {
235 return *idf;
236 }
237
238 let n = self.term_index.get(term).map(|v| v.len()).unwrap_or(0);
239 let idf = compute_idf(n, self.n_docs);
240 self.idf_cache.insert(term.to_string(), idf);
241 idf
242 }
243
244 pub fn get_chunk_id(&self, chunk_idx: usize) -> Option<&String> {
245 self.chunk_id_list.get(chunk_idx)
246 }
247
248 pub fn get_chunk_ids_for_parent(&self, parent_id: &str) -> Vec<&String> {
249 self.parent_to_leaves
250 .get(parent_id)
251 .map(|indices| {
252 indices
253 .iter()
254 .filter_map(|idx| self.chunk_id_list.get(*idx))
255 .collect()
256 })
257 .unwrap_or_default()
258 }
259
260 pub fn config(&self) -> &AutoMergingConfig {
261 &self.config
262 }
263
264 pub fn n_docs(&self) -> usize {
265 self.n_docs
266 }
267
268 pub fn store(&self) -> &Arc<S> {
269 &self.store
270 }
271
272 pub fn clear(&mut self) {
273 self.chunk_id_list.clear();
274 self.chunk_term_freqs.clear();
275 self.term_index.clear();
276 self.parent_to_leaves.clear();
277 self.doc_lengths.clear();
278 self.avgdl = 0.0;
279 self.n_docs = 0;
280 self.idf_cache.clear();
281 }
282}
283
284impl Default for ChunkedBM25Index<lc_vector_stores::ChunkedDocumentStore> {
285 fn default() -> Self {
286 Self::new(Arc::new(lc_vector_stores::ChunkedDocumentStore::new()))
287 }
288}
289
290pub struct ChunkedBM25Retriever<
295 S: ChunkedDocumentStoreTrait = lc_vector_stores::ChunkedDocumentStore,
296> {
297 index: ChunkedBM25Index<S>,
298}
299
300impl<S: ChunkedDocumentStoreTrait> ChunkedBM25Retriever<S> {
301 pub fn new(store: Arc<S>) -> Self {
302 Self {
303 index: ChunkedBM25Index::new(store),
304 }
305 }
306
307 pub fn with_config(store: Arc<S>, config: AutoMergingConfig) -> Self {
308 Self {
309 index: ChunkedBM25Index::with_config(store, config),
310 }
311 }
312
313 pub fn with_params(store: Arc<S>, k1: f64, b: f64) -> Self {
314 Self {
315 index: ChunkedBM25Index::with_params(store, BM25Params::with_values(k1, b)),
316 }
317 }
318
319 pub fn store(&self) -> &Arc<S> {
320 self.index.store()
321 }
322
323 pub fn add_chunk_index(&mut self, chunk_id: String, parent_id: String, content: &str) {
324 self.index.add_chunk_index(chunk_id, parent_id, content);
325 }
326
327 pub fn add_chunk_indexes(&mut self, chunks: Vec<(String, String, String)>) {
328 self.index.add_chunk_indexes(chunks);
329 }
330
331 pub fn add_document(&mut self, document: Document) -> Result<(), VectorStoreError> {
332 let parent_id = document
333 .id
334 .clone()
335 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
336
337 self.index.store.add_parent_document_blocking(
340 document.clone().with_id(parent_id.clone()),
341 self.index.config.leaf_chunk_size,
342 )?;
343
344 let chunks = self
345 .index
346 .store
347 .blocking_get_chunks_for_parent(&parent_id)?;
348
349 for chunk in chunks {
350 self.add_chunk_index(
351 chunk.chunk_id.clone(),
352 chunk.parent_id.clone(),
353 &chunk.content,
354 );
355 }
356
357 Ok(())
358 }
359
360 pub async fn add_document_async(&mut self, document: Document) -> Result<(), VectorStoreError> {
361 let parent_id = document
362 .id
363 .clone()
364 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
365
366 self.index
367 .store
368 .add_parent_document(
369 document.clone().with_id(parent_id.clone()),
370 self.index.config.leaf_chunk_size,
371 )
372 .await?;
373
374 let chunks = self.index.store.get_chunks_for_parent(&parent_id).await?;
375
376 for chunk in chunks {
377 self.add_chunk_index(
378 chunk.chunk_id.clone(),
379 chunk.parent_id.clone(),
380 &chunk.content,
381 );
382 }
383
384 Ok(())
385 }
386
387 pub fn add_documents(&mut self, documents: Vec<Document>) -> Result<(), VectorStoreError> {
388 for doc in documents {
389 self.add_document(doc)?;
390 }
391 Ok(())
392 }
393
394 pub async fn add_documents_async(
395 &mut self,
396 documents: Vec<Document>,
397 ) -> Result<(), VectorStoreError> {
398 for doc in documents {
399 self.add_document_async(doc).await?;
400 }
401 Ok(())
402 }
403
404 pub fn search(&mut self, query: &str, k: usize) -> Vec<ChunkedSearchResult> {
405 if self.index.n_docs == 0 {
406 return Vec::new();
407 }
408
409 let query_terms = self.index.tokenizer.tokenize(query);
410 if query_terms.is_empty() {
411 return Vec::new();
412 }
413
414 let idf_values: HashMap<String, f64> = query_terms
415 .iter()
416 .map(|t| (t.clone(), self.index.compute_idf_for_term(t)))
417 .collect();
418
419 let scored_chunks = self.score_chunks(&query_terms, &idf_values);
420
421 if scored_chunks.is_empty() {
422 return Vec::new();
423 }
424
425 let top_chunks: Vec<(usize, f64)> = scored_chunks.into_iter().take(k * 2).collect();
426
427 self.auto_merge_sync(top_chunks, k)
428 }
429
430 pub async fn search_async(&mut self, query: &str, k: usize) -> Vec<ChunkedSearchResult> {
431 if self.index.n_docs == 0 {
432 return Vec::new();
433 }
434
435 let query_terms = self.index.tokenizer.tokenize(query);
436 if query_terms.is_empty() {
437 return Vec::new();
438 }
439
440 let idf_values: HashMap<String, f64> = query_terms
441 .iter()
442 .map(|t| (t.clone(), self.index.compute_idf_for_term(t)))
443 .collect();
444
445 let scored_chunks = self.score_chunks(&query_terms, &idf_values);
446
447 if scored_chunks.is_empty() {
448 return Vec::new();
449 }
450
451 let top_chunks: Vec<(usize, f64)> = scored_chunks.into_iter().take(k * 2).collect();
452
453 self.auto_merge_async(top_chunks, k).await
454 }
455
456 fn auto_merge_sync(
457 &self,
458 scored_chunks: Vec<(usize, f64)>,
459 k: usize,
460 ) -> Vec<ChunkedSearchResult> {
461 let threshold = self.index.config.merge_threshold;
462 let leaves_per_parent = self.index.config.leaves_per_parent;
463
464 let parent_stats = self.collect_parent_stats(&scored_chunks);
465
466 let mut results: Vec<ChunkedSearchResult> = Vec::new();
467
468 for (parent_id, matched_leaves) in parent_stats {
469 let ratio = matched_leaves.len() as f32 / leaves_per_parent as f32;
470
471 let avg_score =
472 matched_leaves.iter().map(|(_, s)| s).sum::<f64>() / matched_leaves.len() as f64;
473
474 let matched_terms = matched_leaves
475 .iter()
476 .filter_map(|(idx, _)| self.index.chunk_term_freqs.get(*idx))
477 .flat_map(|tf| tf.keys().cloned())
478 .collect::<Vec<_>>();
479
480 if ratio >= threshold {
481 let parent_doc = self
482 .index
483 .store()
484 .get_parent_document_blocking(&parent_id)
485 .ok()
486 .flatten();
487
488 results.push(ChunkedSearchResult {
489 merged_parent: parent_doc,
490 leaf_chunks: Vec::new(),
491 score: avg_score as f32,
492 matched_terms,
493 parent_id,
494 });
495 } else {
496 let leaf_chunks: Vec<ChunkDocument> = matched_leaves
497 .iter()
498 .filter_map(|(idx, _)| {
499 let chunk_id = self.index.get_chunk_id(*idx)?;
500 let chunk = self
501 .index
502 .store()
503 .get_chunk_blocking(chunk_id)
504 .ok()
505 .flatten()?;
506 Some(chunk)
507 })
508 .collect();
509
510 results.push(ChunkedSearchResult {
511 merged_parent: None,
512 leaf_chunks,
513 score: avg_score as f32,
514 matched_terms,
515 parent_id,
516 });
517 }
518 }
519
520 results.sort_by(|a, b| {
521 b.score
522 .partial_cmp(&a.score)
523 .unwrap_or(std::cmp::Ordering::Equal)
524 });
525 results.into_iter().take(k).collect()
526 }
527
528 async fn auto_merge_async(
529 &self,
530 scored_chunks: Vec<(usize, f64)>,
531 k: usize,
532 ) -> Vec<ChunkedSearchResult> {
533 let threshold = self.index.config.merge_threshold;
534 let leaves_per_parent = self.index.config.leaves_per_parent;
535
536 let parent_stats = self.collect_parent_stats(&scored_chunks);
537
538 let mut results: Vec<ChunkedSearchResult> = Vec::new();
539
540 for (parent_id, matched_leaves) in parent_stats {
541 let ratio = matched_leaves.len() as f32 / leaves_per_parent as f32;
542
543 let avg_score =
544 matched_leaves.iter().map(|(_, s)| s).sum::<f64>() / matched_leaves.len() as f64;
545
546 let matched_terms = matched_leaves
547 .iter()
548 .filter_map(|(idx, _)| self.index.chunk_term_freqs.get(*idx))
549 .flat_map(|tf| tf.keys().cloned())
550 .collect::<Vec<_>>();
551
552 if ratio >= threshold {
553 let parent_doc = self
554 .index
555 .store()
556 .get_parent_document(&parent_id)
557 .await
558 .ok()
559 .flatten();
560
561 results.push(ChunkedSearchResult {
562 merged_parent: parent_doc,
563 leaf_chunks: Vec::new(),
564 score: avg_score as f32,
565 matched_terms,
566 parent_id,
567 });
568 } else {
569 let mut leaf_chunks = Vec::new();
570 for (idx, _) in matched_leaves {
571 if let Some(chunk_id) = self.index.get_chunk_id(idx) {
572 if let Some(chunk) =
573 self.index.store().get_chunk(chunk_id).await.ok().flatten()
574 {
575 leaf_chunks.push(chunk);
576 }
577 }
578 }
579
580 results.push(ChunkedSearchResult {
581 merged_parent: None,
582 leaf_chunks,
583 score: avg_score as f32,
584 matched_terms,
585 parent_id,
586 });
587 }
588 }
589
590 results.sort_by(|a, b| {
591 b.score
592 .partial_cmp(&a.score)
593 .unwrap_or(std::cmp::Ordering::Equal)
594 });
595 results.into_iter().take(k).collect()
596 }
597
598 fn score_chunks(
599 &self,
600 query_terms: &[String],
601 idf_values: &HashMap<String, f64>,
602 ) -> Vec<(usize, f64)> {
603 let mut scored = Vec::new();
604
605 for chunk_idx in 0..self.index.n_docs {
606 if let Some(term_freqs) = self.index.chunk_term_freqs.get(chunk_idx) {
607 let doc_length = *self.index.doc_lengths.get(chunk_idx).unwrap_or(&0);
608
609 let score = bm25_score(
610 query_terms,
611 term_freqs,
612 doc_length,
613 self.index.avgdl,
614 idf_values,
615 &self.index.params,
616 );
617
618 if score > 0.0 {
619 scored.push((chunk_idx, score));
620 }
621 }
622 }
623
624 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
625 scored
626 }
627
628 fn collect_parent_stats(
629 &self,
630 scored_chunks: &[(usize, f64)],
631 ) -> HashMap<String, Vec<(usize, f64)>> {
632 let mut stats: HashMap<String, Vec<(usize, f64)>> = HashMap::new();
633
634 for (chunk_idx, score) in scored_chunks {
635 if let Some(chunk_id) = self.index.chunk_id_list.get(*chunk_idx) {
636 let parent_id = chunk_id.split("::").next().unwrap_or_default().to_string();
637 stats
638 .entry(parent_id)
639 .or_default()
640 .push((*chunk_idx, *score));
641 }
642 }
643
644 stats
645 }
646
647 pub fn get_parent_document(&self, parent_id: &str) -> Option<Document> {
648 self.index
649 .store()
650 .get_parent_document_blocking(parent_id)
651 .ok()
652 .flatten()
653 }
654
655 pub fn len(&self) -> usize {
656 self.index.n_docs()
657 }
658
659 pub fn is_empty(&self) -> bool {
660 self.index.n_docs() == 0
661 }
662
663 pub fn clear(&mut self) {
664 self.index.clear();
665 }
666
667 pub fn config(&self) -> &AutoMergingConfig {
668 self.index.config()
669 }
670
671 pub fn save(&self, path: impl AsRef<Path>) -> Result<(), Box<dyn std::error::Error>> {
673 let data = ChunkedIndexData {
674 chunk_id_list: self.index.chunk_id_list.clone(),
675 chunk_term_freqs: self.index.chunk_term_freqs.clone(),
676 term_index: self.index.term_index.clone(),
677 parent_to_leaves: self.index.parent_to_leaves.clone(),
678 doc_lengths: self.index.doc_lengths.clone(),
679 avgdl: self.index.avgdl,
680 n_docs: self.index.n_docs,
681 params: BM25ParamsData::from(self.index.params.clone()),
682 config: self.index.config.clone(),
683 };
684 let encoded = bincode::serialize(&data)?;
685 std::fs::write(path.as_ref(), encoded)?;
686 Ok(())
687 }
688}
689
690impl ChunkedBM25Retriever<lc_vector_stores::ChunkedDocumentStore> {
691 pub fn load(
692 store: Arc<lc_vector_stores::ChunkedDocumentStore>,
693 path: impl AsRef<Path>,
694 ) -> Result<Self, Box<dyn std::error::Error>> {
695 let bytes = std::fs::read(path.as_ref())?;
696 let data: ChunkedIndexData = bincode::deserialize(&bytes)?;
697 let params: BM25Params = data.params.into();
698
699 Ok(Self {
700 index: ChunkedBM25Index {
701 store,
702 chunk_id_list: data.chunk_id_list,
703 chunk_term_freqs: data.chunk_term_freqs,
704 term_index: data.term_index,
705 parent_to_leaves: data.parent_to_leaves,
706 doc_lengths: data.doc_lengths,
707 avgdl: data.avgdl,
708 n_docs: data.n_docs,
709 idf_cache: HashMap::new(),
710 params,
711 tokenizer: Tokenizer::new(),
712 config: data.config,
713 },
714 })
715 }
716}
717
718impl Default for ChunkedBM25Retriever<lc_vector_stores::ChunkedDocumentStore> {
719 fn default() -> Self {
720 Self::new(Arc::new(lc_vector_stores::ChunkedDocumentStore::new()))
721 }
722}