Skip to main content

lc_rag/bm25/
chunked.rs

1// src/retrieval/bm25/chunked.rs
2//! BM25 Chunked Retriever - 支持 Parent-Child 文档结构的 BM25 检索器
3//!
4//! 基于 LlamaIndex AutoMerging 模式实现:
5//! - 文档拆分为 Parent + Leaf 两层
6//! - BM25 在 Leaf 层搜索
7//! - AutoMerging 合并同一 Parent 的多个 Leaf
8//! - 支持 Bincode 持久化
9
10use 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// ============================================================================
20// 数据结构定义
21// ============================================================================
22
23// ChunkDocument 现在在 document_store.rs 中定义,BM25 直接使用
24
25/// AutoMerging 配置
26#[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/// AutoMerging 搜索结果
67#[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/// BM25 参数的可序列化版本
95#[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/// 可序列化的索引数据(不含内容,内容在ChunkedDocumentStore中)
117#[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
130// ============================================================================
131// ChunkedBM25Index 索引结构
132// ============================================================================
133
134pub 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    /// 添加chunk索引(内容已在store中)
178    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        // 更新倒排索引
185        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        // 更新parent到chunk的映射
193        self.parent_to_leaves
194            .entry(parent_id)
195            .or_default()
196            .push(chunk_idx);
197
198        // 存储chunk_id和词频(BM25计算需要)
199        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    /// 批量添加chunk索引
210    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
290// ============================================================================
291// ChunkedBM25Retriever 检索器
292// ============================================================================
293
294pub 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        // P0-1: 无 id 的文档先把预分配的 parent_id 挂到文档上再入库,
338        // 否则 store 内部会再生成一个新 uuid,导致 get_chunks_for_parent 用错 key 查空。
339        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    // 持久化方法
672    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}