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
338 .store
339 .add_parent_document_blocking(document, self.index.config.leaf_chunk_size)?;
340
341 let chunks = self
342 .index
343 .store
344 .blocking_get_chunks_for_parent(&parent_id)?;
345
346 for chunk in chunks {
347 self.add_chunk_index(
348 chunk.chunk_id.clone(),
349 chunk.parent_id.clone(),
350 &chunk.content,
351 );
352 }
353
354 Ok(())
355 }
356
357 pub async fn add_document_async(&mut self, document: Document) -> Result<(), VectorStoreError> {
358 let parent_id = document
359 .id
360 .clone()
361 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
362
363 self.index
364 .store
365 .add_parent_document(document, self.index.config.leaf_chunk_size)
366 .await?;
367
368 let chunks = self.index.store.get_chunks_for_parent(&parent_id).await?;
369
370 for chunk in chunks {
371 self.add_chunk_index(
372 chunk.chunk_id.clone(),
373 chunk.parent_id.clone(),
374 &chunk.content,
375 );
376 }
377
378 Ok(())
379 }
380
381 pub fn add_documents(&mut self, documents: Vec<Document>) -> Result<(), VectorStoreError> {
382 for doc in documents {
383 self.add_document(doc)?;
384 }
385 Ok(())
386 }
387
388 pub async fn add_documents_async(
389 &mut self,
390 documents: Vec<Document>,
391 ) -> Result<(), VectorStoreError> {
392 for doc in documents {
393 self.add_document_async(doc).await?;
394 }
395 Ok(())
396 }
397
398 pub fn search(&mut self, query: &str, k: usize) -> Vec<ChunkedSearchResult> {
399 if self.index.n_docs == 0 {
400 return Vec::new();
401 }
402
403 let query_terms = self.index.tokenizer.tokenize(query);
404 if query_terms.is_empty() {
405 return Vec::new();
406 }
407
408 let idf_values: HashMap<String, f64> = query_terms
409 .iter()
410 .map(|t| (t.clone(), self.index.compute_idf_for_term(t)))
411 .collect();
412
413 let scored_chunks = self.score_chunks(&query_terms, &idf_values);
414
415 if scored_chunks.is_empty() {
416 return Vec::new();
417 }
418
419 let top_chunks: Vec<(usize, f64)> = scored_chunks.into_iter().take(k * 2).collect();
420
421 self.auto_merge_sync(top_chunks, k)
422 }
423
424 pub async fn search_async(&mut self, query: &str, k: usize) -> Vec<ChunkedSearchResult> {
425 if self.index.n_docs == 0 {
426 return Vec::new();
427 }
428
429 let query_terms = self.index.tokenizer.tokenize(query);
430 if query_terms.is_empty() {
431 return Vec::new();
432 }
433
434 let idf_values: HashMap<String, f64> = query_terms
435 .iter()
436 .map(|t| (t.clone(), self.index.compute_idf_for_term(t)))
437 .collect();
438
439 let scored_chunks = self.score_chunks(&query_terms, &idf_values);
440
441 if scored_chunks.is_empty() {
442 return Vec::new();
443 }
444
445 let top_chunks: Vec<(usize, f64)> = scored_chunks.into_iter().take(k * 2).collect();
446
447 self.auto_merge_async(top_chunks, k).await
448 }
449
450 fn auto_merge_sync(
451 &self,
452 scored_chunks: Vec<(usize, f64)>,
453 k: usize,
454 ) -> Vec<ChunkedSearchResult> {
455 let threshold = self.index.config.merge_threshold;
456 let leaves_per_parent = self.index.config.leaves_per_parent;
457
458 let parent_stats = self.collect_parent_stats(&scored_chunks);
459
460 let mut results: Vec<ChunkedSearchResult> = Vec::new();
461
462 for (parent_id, matched_leaves) in parent_stats {
463 let ratio = matched_leaves.len() as f32 / leaves_per_parent as f32;
464
465 let avg_score =
466 matched_leaves.iter().map(|(_, s)| s).sum::<f64>() / matched_leaves.len() as f64;
467
468 let matched_terms = matched_leaves
469 .iter()
470 .filter_map(|(idx, _)| self.index.chunk_term_freqs.get(*idx))
471 .flat_map(|tf| tf.keys().cloned())
472 .collect::<Vec<_>>();
473
474 if ratio >= threshold {
475 let parent_doc = self
476 .index
477 .store()
478 .get_parent_document_blocking(&parent_id)
479 .ok()
480 .flatten();
481
482 results.push(ChunkedSearchResult {
483 merged_parent: parent_doc,
484 leaf_chunks: Vec::new(),
485 score: avg_score as f32,
486 matched_terms,
487 parent_id,
488 });
489 } else {
490 let leaf_chunks: Vec<ChunkDocument> = matched_leaves
491 .iter()
492 .filter_map(|(idx, _)| {
493 let chunk_id = self.index.get_chunk_id(*idx)?;
494 let chunk = self
495 .index
496 .store()
497 .get_chunk_blocking(chunk_id)
498 .ok()
499 .flatten()?;
500 Some(chunk)
501 })
502 .collect();
503
504 results.push(ChunkedSearchResult {
505 merged_parent: None,
506 leaf_chunks,
507 score: avg_score as f32,
508 matched_terms,
509 parent_id,
510 });
511 }
512 }
513
514 results.sort_by(|a, b| {
515 b.score
516 .partial_cmp(&a.score)
517 .unwrap_or(std::cmp::Ordering::Equal)
518 });
519 results.into_iter().take(k).collect()
520 }
521
522 async fn auto_merge_async(
523 &self,
524 scored_chunks: Vec<(usize, f64)>,
525 k: usize,
526 ) -> Vec<ChunkedSearchResult> {
527 let threshold = self.index.config.merge_threshold;
528 let leaves_per_parent = self.index.config.leaves_per_parent;
529
530 let parent_stats = self.collect_parent_stats(&scored_chunks);
531
532 let mut results: Vec<ChunkedSearchResult> = Vec::new();
533
534 for (parent_id, matched_leaves) in parent_stats {
535 let ratio = matched_leaves.len() as f32 / leaves_per_parent as f32;
536
537 let avg_score =
538 matched_leaves.iter().map(|(_, s)| s).sum::<f64>() / matched_leaves.len() as f64;
539
540 let matched_terms = matched_leaves
541 .iter()
542 .filter_map(|(idx, _)| self.index.chunk_term_freqs.get(*idx))
543 .flat_map(|tf| tf.keys().cloned())
544 .collect::<Vec<_>>();
545
546 if ratio >= threshold {
547 let parent_doc = self
548 .index
549 .store()
550 .get_parent_document(&parent_id)
551 .await
552 .ok()
553 .flatten();
554
555 results.push(ChunkedSearchResult {
556 merged_parent: parent_doc,
557 leaf_chunks: Vec::new(),
558 score: avg_score as f32,
559 matched_terms,
560 parent_id,
561 });
562 } else {
563 let mut leaf_chunks = Vec::new();
564 for (idx, _) in matched_leaves {
565 if let Some(chunk_id) = self.index.get_chunk_id(idx) {
566 if let Some(chunk) =
567 self.index.store().get_chunk(chunk_id).await.ok().flatten()
568 {
569 leaf_chunks.push(chunk);
570 }
571 }
572 }
573
574 results.push(ChunkedSearchResult {
575 merged_parent: None,
576 leaf_chunks,
577 score: avg_score as f32,
578 matched_terms,
579 parent_id,
580 });
581 }
582 }
583
584 results.sort_by(|a, b| {
585 b.score
586 .partial_cmp(&a.score)
587 .unwrap_or(std::cmp::Ordering::Equal)
588 });
589 results.into_iter().take(k).collect()
590 }
591
592 fn score_chunks(
593 &self,
594 query_terms: &[String],
595 idf_values: &HashMap<String, f64>,
596 ) -> Vec<(usize, f64)> {
597 let mut scored = Vec::new();
598
599 for chunk_idx in 0..self.index.n_docs {
600 if let Some(term_freqs) = self.index.chunk_term_freqs.get(chunk_idx) {
601 let doc_length = *self.index.doc_lengths.get(chunk_idx).unwrap_or(&0);
602
603 let score = bm25_score(
604 query_terms,
605 term_freqs,
606 doc_length,
607 self.index.avgdl,
608 idf_values,
609 &self.index.params,
610 );
611
612 if score > 0.0 {
613 scored.push((chunk_idx, score));
614 }
615 }
616 }
617
618 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
619 scored
620 }
621
622 fn collect_parent_stats(
623 &self,
624 scored_chunks: &[(usize, f64)],
625 ) -> HashMap<String, Vec<(usize, f64)>> {
626 let mut stats: HashMap<String, Vec<(usize, f64)>> = HashMap::new();
627
628 for (chunk_idx, score) in scored_chunks {
629 if let Some(chunk_id) = self.index.chunk_id_list.get(*chunk_idx) {
630 let parent_id = chunk_id.split("::").next().unwrap_or_default().to_string();
631 stats
632 .entry(parent_id)
633 .or_default()
634 .push((*chunk_idx, *score));
635 }
636 }
637
638 stats
639 }
640
641 pub fn get_parent_document(&self, parent_id: &str) -> Option<Document> {
642 self.index
643 .store()
644 .get_parent_document_blocking(parent_id)
645 .ok()
646 .flatten()
647 }
648
649 pub fn len(&self) -> usize {
650 self.index.n_docs()
651 }
652
653 pub fn is_empty(&self) -> bool {
654 self.index.n_docs() == 0
655 }
656
657 pub fn clear(&mut self) {
658 self.index.clear();
659 }
660
661 pub fn config(&self) -> &AutoMergingConfig {
662 self.index.config()
663 }
664
665 pub fn save(&self, path: impl AsRef<Path>) -> Result<(), Box<dyn std::error::Error>> {
667 let data = ChunkedIndexData {
668 chunk_id_list: self.index.chunk_id_list.clone(),
669 chunk_term_freqs: self.index.chunk_term_freqs.clone(),
670 term_index: self.index.term_index.clone(),
671 parent_to_leaves: self.index.parent_to_leaves.clone(),
672 doc_lengths: self.index.doc_lengths.clone(),
673 avgdl: self.index.avgdl,
674 n_docs: self.index.n_docs,
675 params: BM25ParamsData::from(self.index.params.clone()),
676 config: self.index.config.clone(),
677 };
678 let encoded = bincode::serialize(&data)?;
679 std::fs::write(path.as_ref(), encoded)?;
680 Ok(())
681 }
682}
683
684impl ChunkedBM25Retriever<lc_vector_stores::ChunkedDocumentStore> {
685 pub fn load(
686 store: Arc<lc_vector_stores::ChunkedDocumentStore>,
687 path: impl AsRef<Path>,
688 ) -> Result<Self, Box<dyn std::error::Error>> {
689 let bytes = std::fs::read(path.as_ref())?;
690 let data: ChunkedIndexData = bincode::deserialize(&bytes)?;
691 let params: BM25Params = data.params.into();
692
693 Ok(Self {
694 index: ChunkedBM25Index {
695 store,
696 chunk_id_list: data.chunk_id_list,
697 chunk_term_freqs: data.chunk_term_freqs,
698 term_index: data.term_index,
699 parent_to_leaves: data.parent_to_leaves,
700 doc_lengths: data.doc_lengths,
701 avgdl: data.avgdl,
702 n_docs: data.n_docs,
703 idf_cache: HashMap::new(),
704 params,
705 tokenizer: Tokenizer::new(),
706 config: data.config,
707 },
708 })
709 }
710}
711
712impl Default for ChunkedBM25Retriever<lc_vector_stores::ChunkedDocumentStore> {
713 fn default() -> Self {
714 Self::new(Arc::new(lc_vector_stores::ChunkedDocumentStore::new()))
715 }
716}