Skip to main content

lc_rag/
parent_document.rs

1// lc-rag/src/parent_document.rs
2//! ParentDocumentRetriever — a parent/child document retriever
3
4use async_trait::async_trait;
5use lc_vector_stores::{ChunkedDocumentStore, ChunkedDocumentStoreTrait, Document, SearchResult};
6use std::sync::Arc;
7use tokio::sync::RwLock;
8
9use crate::bm25::{AutoMergingConfig, ChunkedBM25Retriever};
10use crate::retriever::{RetrieverError, RetrieverTrait};
11
12/// A parent/child document retriever.
13///
14/// On ingestion, documents are split into small chunks (leaves) for indexing; any leaf hit
15/// returns the **entire parent document**. This differs from [`ChunkedBM25Retriever`]'s
16/// AutoMerging (gated by hit ratio; returns leaf chunks when the threshold is not met):
17/// ParentDocument is the classic RAG pattern of "small-chunk recall, full document fed to
18/// the LLM" — the leaf chunks provide precise hits, while the whole parent document gives
19/// the LLM complete context.
20///
21/// Internally it wraps a [`ChunkedBM25Retriever`] in an `RwLock`: retrieval takes the read
22/// lock, ingestion takes the write lock, so it can implement [`RetrieverTrait`] (whose
23/// methods all take `&self`). Combined with
24/// [`RetrieverRunnable`](crate::RetrieverRunnable) it can plug directly into an LCEL chain:
25///
26/// ```rust,ignore
27/// let retriever = Arc::new(ParentDocumentRetriever::new(store));
28/// let chain = RetrieverRunnable::new(retriever, 4).pipe(prompt).pipe(llm);
29/// ```
30pub struct ParentDocumentRetriever<S: ChunkedDocumentStoreTrait = ChunkedDocumentStore> {
31    inner: RwLock<ChunkedBM25Retriever<S>>,
32}
33
34impl<S: ChunkedDocumentStoreTrait> ParentDocumentRetriever<S> {
35    /// Creates a retriever with the default configuration.
36    pub fn new(store: Arc<S>) -> Self {
37        Self {
38            inner: RwLock::new(ChunkedBM25Retriever::new(store)),
39        }
40    }
41
42    /// Creates a retriever with the specified AutoMerging configuration.
43    ///
44    /// `leaf_chunk_size` in the config determines the leaf-chunk granularity; under the
45    /// ParentDocument semantics `merge_threshold` does not take part in hit decisions
46    /// (any leaf hit returns the parent document), but it still constrains how the
47    /// underlying index is chunked.
48    pub fn with_config(store: Arc<S>, config: AutoMergingConfig) -> Self {
49        Self {
50            inner: RwLock::new(ChunkedBM25Retriever::with_config(store, config)),
51        }
52    }
53
54    /// Returns the inner retriever reference, for direct access to the underlying index.
55    ///
56    /// For example, read-only retrieval uses `retriever.inner().read().await`; ingestion
57    /// uses `.write().await`.
58    pub fn inner(&self) -> &RwLock<ChunkedBM25Retriever<S>> {
59        &self.inner
60    }
61}
62
63#[async_trait]
64impl<S: ChunkedDocumentStoreTrait> RetrieverTrait for ParentDocumentRetriever<S> {
65    async fn retrieve(&self, query: &str, k: usize) -> Result<Vec<Document>, RetrieverError> {
66        let inner = self.inner.read().await;
67        let matched = inner.search_matched_parents(query, k);
68        let docs: Vec<Document> = matched
69            .into_iter()
70            .filter_map(|(parent_id, _)| inner.get_parent_document(&parent_id))
71            .collect();
72        Ok(docs)
73    }
74
75    async fn retrieve_with_scores(
76        &self,
77        query: &str,
78        k: usize,
79    ) -> Result<Vec<SearchResult>, RetrieverError> {
80        let inner = self.inner.read().await;
81        let matched = inner.search_matched_parents(query, k);
82        let results: Vec<SearchResult> = matched
83            .into_iter()
84            .filter_map(|(parent_id, score)| {
85                inner
86                    .get_parent_document(&parent_id)
87                    .map(|document| SearchResult { document, score })
88            })
89            .collect();
90        Ok(results)
91    }
92
93    async fn add_documents(&self, documents: Vec<Document>) -> Result<(), RetrieverError> {
94        let mut inner = self.inner.write().await;
95        inner
96            .add_documents_async(documents)
97            .await
98            .map_err(RetrieverError::StoreError)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::RetrieverRunnable;
106    use lc_core::language_models::{BaseChatModel, BaseLanguageModel, LLMResult, StreamChunk};
107    use lc_core::runnables::{Runnable, RunnableConfig, RunnableExt, RunnableLambda};
108    use lc_prompts::ChatPromptTemplate;
109    use lc_schema::Message;
110    use std::collections::HashMap;
111    use std::pin::Pin;
112
113    /// A parent-document content spanning multiple leaves (> leaf_chunk_size 400, with the distinctive word `zebra`).
114    fn multi_leaf_parent_content() -> String {
115        let filler =
116            "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor \
117             incididunt ut labore et dolore magna aliqua. ";
118        format!("{filler}{filler}{filler}{filler} zebra")
119    }
120
121    fn test_retriever() -> ParentDocumentRetriever {
122        ParentDocumentRetriever::new(Arc::new(ChunkedDocumentStore::new()))
123    }
124
125    #[tokio::test]
126    async fn parent_document_returns_full_parent_on_chunk_hit() {
127        let retriever = test_retriever();
128        let content = multi_leaf_parent_content();
129        assert!(
130            content.len() > 400,
131            "content must span multiple leaves, got {} chars",
132            content.len()
133        );
134
135        retriever
136            .add_documents(vec![Document::new(content.clone())])
137            .await
138            .unwrap();
139
140        // Query a word that appears in only one leaf: a leaf hit returns the entire parent document.
141        let docs = retriever.retrieve("zebra", 1).await.unwrap();
142        assert_eq!(docs.len(), 1);
143        assert_eq!(
144            docs[0].content, content,
145            "a leaf hit must return the FULL parent document, not the leaf chunk"
146        );
147        assert!(docs[0].content.len() > 400);
148    }
149
150    #[tokio::test]
151    async fn parent_document_retrieve_with_scores_reports_score() {
152        let retriever = test_retriever();
153        let content = multi_leaf_parent_content();
154        retriever
155            .add_documents(vec![Document::new(content.clone())])
156            .await
157            .unwrap();
158
159        let results = retriever.retrieve_with_scores("zebra", 1).await.unwrap();
160        assert_eq!(results.len(), 1);
161        assert!(results[0].score > 0.0, "BM25 score should be positive");
162        assert_eq!(results[0].document.content, content);
163    }
164
165    /// E3 verification: the full ParentDocument → prompt → LLM chain.
166    /// The retriever returns the whole parent document on a leaf hit, which is composed
167    /// into the prompt and fed to the mock LLM to generate a reply.
168    #[tokio::test]
169    async fn parent_document_chains_into_prompt_and_llm() {
170        let retriever = test_retriever();
171        let content = multi_leaf_parent_content();
172        retriever
173            .add_documents(vec![Document::new(content.clone())])
174            .await
175            .unwrap();
176
177        let step = RetrieverRunnable::new(Arc::new(retriever), 1);
178        // Documents → template variables: join the retrieved results into `context`.
179        let to_context = RunnableLambda::new_sync(|docs: Vec<Document>| {
180            HashMap::from([(
181                "context".to_string(),
182                docs.iter()
183                    .map(|d| d.content.clone())
184                    .collect::<Vec<_>>()
185                    .join("\n\n"),
186            )])
187        });
188        let prompt = ChatPromptTemplate::from_messages([
189            Message::system("你是一个检索问答助手。"),
190            Message::human("请根据以下资料回答问题:\n\n{context}"),
191        ]);
192        let chain = step.pipe(to_context).pipe(prompt).pipe(MockChat);
193
194        let result: LLMResult = chain.invoke("zebra".to_string(), None).await.unwrap();
195        assert!(
196            result.content.contains("context has zebra: true"),
197            "the full parent doc (containing `zebra`) must reach the model, got: {}",
198            result.content
199        );
200    }
201
202    /// Minimal mock chat model: echoes the length of the last human message, proving context reached the model.
203    #[derive(Debug)]
204    struct MockChat;
205
206    #[derive(Debug)]
207    struct MockChatError(String);
208
209    impl std::fmt::Display for MockChatError {
210        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211            write!(f, "MockChatError: {}", self.0)
212        }
213    }
214
215    impl std::error::Error for MockChatError {}
216
217    impl From<MockChatError> for lc_core::runnables::LcelError {
218        fn from(e: MockChatError) -> Self {
219            lc_core::runnables::LcelError::Other(e.0)
220        }
221    }
222
223    #[async_trait]
224    impl Runnable<Vec<Message>, LLMResult> for MockChat {
225        type Error = MockChatError;
226
227        async fn invoke(
228            &self,
229            input: Vec<Message>,
230            config: Option<RunnableConfig>,
231        ) -> Result<LLMResult, Self::Error> {
232            self.chat(input, config).await
233        }
234    }
235
236    #[async_trait]
237    impl BaseLanguageModel<Vec<Message>, LLMResult> for MockChat {
238        fn model_name(&self) -> &str {
239            "mock-chat"
240        }
241
242        fn get_num_tokens(&self, text: &str) -> usize {
243            text.len() / 4
244        }
245
246        fn with_temperature(self, _temp: f32) -> Self
247        where
248            Self: Sized,
249        {
250            self
251        }
252
253        fn with_max_tokens(self, _max: usize) -> Self
254        where
255            Self: Sized,
256        {
257            self
258        }
259    }
260
261    #[async_trait]
262    impl BaseChatModel for MockChat {
263        async fn chat(
264            &self,
265            messages: Vec<Message>,
266            _config: Option<RunnableConfig>,
267        ) -> Result<LLMResult, Self::Error> {
268            let last = messages
269                .last()
270                .map(|m| m.content.clone())
271                .unwrap_or_default();
272            Ok(LLMResult {
273                content: format!(
274                    "context has zebra: {}; received {} chars of context",
275                    last.contains("zebra"),
276                    last.len()
277                ),
278                model: "mock-chat".to_string(),
279                token_usage: None,
280                tool_calls: None,
281                thinking_content: None,
282            })
283        }
284
285        async fn stream_chat(
286            &self,
287            _messages: Vec<Message>,
288            _config: Option<RunnableConfig>,
289        ) -> Result<
290            Pin<Box<dyn futures_util::Stream<Item = Result<StreamChunk, Self::Error>> + Send>>,
291            Self::Error,
292        > {
293            unreachable!("stream_chat not exercised in parent_document tests")
294        }
295    }
296}