Skip to main content

lc_rag/
parent_document.rs

1// lc-rag/src/parent_document.rs
2//! ParentDocumentRetriever — 父子文档检索器
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/// 父子文档检索器。
13///
14/// 文档入库时被切成小块(leaf)索引,查询命中任意小块就返回**整篇父文档**。
15/// 这与 [`ChunkedBM25Retriever`] 的 AutoMerging(按命中比例门控,阈值不足
16/// 返回小块)不同:ParentDocument 是"小块召回、整文喂给 LLM"的经典 RAG
17/// 模式 —— 小块负责精确命中,整篇父文档负责给 LLM 提供完整上下文。
18///
19/// 内部用 `RwLock` 包一层 [`ChunkedBM25Retriever`]:检索走读锁、入库走写锁,
20/// 因此能实现 [`RetrieverTrait`] (其方法全为
21/// `&self`)。配合 [`RetrieverRunnable`](crate::RetrieverRunnable) 可以直接进
22/// LCEL 链:
23///
24/// ```rust,ignore
25/// let retriever = Arc::new(ParentDocumentRetriever::new(store));
26/// let chain = RetrieverRunnable::new(retriever, 4).pipe(prompt).pipe(llm);
27/// ```
28pub struct ParentDocumentRetriever<S: ChunkedDocumentStoreTrait = ChunkedDocumentStore> {
29    inner: RwLock<ChunkedBM25Retriever<S>>,
30}
31
32impl<S: ChunkedDocumentStoreTrait> ParentDocumentRetriever<S> {
33    /// 使用默认配置创建检索器。
34    pub fn new(store: Arc<S>) -> Self {
35        Self {
36            inner: RwLock::new(ChunkedBM25Retriever::new(store)),
37        }
38    }
39
40    /// 使用指定 AutoMerging 配置创建检索器。
41    ///
42    /// 配置中的 `leaf_chunk_size` 决定小块切分粒度;`merge_threshold` 在
43    /// ParentDocument 语义下不参与命中判定(命中任意小块即返回父文档),但
44    /// 仍用于约束底层索引的切分行为。
45    pub fn with_config(store: Arc<S>, config: AutoMergingConfig) -> Self {
46        Self {
47            inner: RwLock::new(ChunkedBM25Retriever::with_config(store, config)),
48        }
49    }
50
51    /// 返回内部检索器引用,需要直接访问底层索引时使用。
52    ///
53    /// 例如只读检索用 `retriever.inner().read().await`,入库用 `.write().await`。
54    pub fn inner(&self) -> &RwLock<ChunkedBM25Retriever<S>> {
55        &self.inner
56    }
57}
58
59#[async_trait]
60impl<S: ChunkedDocumentStoreTrait> RetrieverTrait for ParentDocumentRetriever<S> {
61    async fn retrieve(&self, query: &str, k: usize) -> Result<Vec<Document>, RetrieverError> {
62        let inner = self.inner.read().await;
63        let matched = inner.search_matched_parents(query, k);
64        let docs: Vec<Document> = matched
65            .into_iter()
66            .filter_map(|(parent_id, _)| inner.get_parent_document(&parent_id))
67            .collect();
68        Ok(docs)
69    }
70
71    async fn retrieve_with_scores(
72        &self,
73        query: &str,
74        k: usize,
75    ) -> Result<Vec<SearchResult>, RetrieverError> {
76        let inner = self.inner.read().await;
77        let matched = inner.search_matched_parents(query, k);
78        let results: Vec<SearchResult> = matched
79            .into_iter()
80            .filter_map(|(parent_id, score)| {
81                inner
82                    .get_parent_document(&parent_id)
83                    .map(|document| SearchResult { document, score })
84            })
85            .collect();
86        Ok(results)
87    }
88
89    async fn add_documents(&self, documents: Vec<Document>) -> Result<(), RetrieverError> {
90        let mut inner = self.inner.write().await;
91        inner
92            .add_documents_async(documents)
93            .await
94            .map_err(RetrieverError::StoreError)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::RetrieverRunnable;
102    use lc_core::language_models::{BaseChatModel, BaseLanguageModel, LLMResult};
103    use lc_core::runnables::{Runnable, RunnableConfig, RunnableExt, RunnableLambda};
104    use lc_prompts::ChatPromptTemplate;
105    use lc_schema::Message;
106    use std::collections::HashMap;
107    use std::pin::Pin;
108
109    /// 一段跨多个 leaf 的父文档内容(> leaf_chunk_size 400,含独特词 `zebra`)。
110    fn multi_leaf_parent_content() -> String {
111        let filler =
112            "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor \
113             incididunt ut labore et dolore magna aliqua. ";
114        format!("{filler}{filler}{filler}{filler} zebra")
115    }
116
117    fn test_retriever() -> ParentDocumentRetriever {
118        ParentDocumentRetriever::new(Arc::new(ChunkedDocumentStore::new()))
119    }
120
121    #[tokio::test]
122    async fn parent_document_returns_full_parent_on_chunk_hit() {
123        let retriever = test_retriever();
124        let content = multi_leaf_parent_content();
125        assert!(
126            content.len() > 400,
127            "content must span multiple leaves, got {} chars",
128            content.len()
129        );
130
131        retriever
132            .add_documents(vec![Document::new(content.clone())])
133            .await
134            .unwrap();
135
136        // 查询只出现在某个 leaf 里的词:命中小块 → 返回整篇父文档。
137        let docs = retriever.retrieve("zebra", 1).await.unwrap();
138        assert_eq!(docs.len(), 1);
139        assert_eq!(
140            docs[0].content, content,
141            "a leaf hit must return the FULL parent document, not the leaf chunk"
142        );
143        assert!(docs[0].content.len() > 400);
144    }
145
146    #[tokio::test]
147    async fn parent_document_retrieve_with_scores_reports_score() {
148        let retriever = test_retriever();
149        let content = multi_leaf_parent_content();
150        retriever
151            .add_documents(vec![Document::new(content.clone())])
152            .await
153            .unwrap();
154
155        let results = retriever.retrieve_with_scores("zebra", 1).await.unwrap();
156        assert_eq!(results.len(), 1);
157        assert!(results[0].score > 0.0, "BM25 score should be positive");
158        assert_eq!(results[0].document.content, content);
159    }
160
161    /// E3 验证:ParentDocument → prompt → LLM 整条链。
162    /// 检索器命中小块返回整篇父文档,拼进 prompt,喂给 mock LLM 生成回复。
163    #[tokio::test]
164    async fn parent_document_chains_into_prompt_and_llm() {
165        let retriever = test_retriever();
166        let content = multi_leaf_parent_content();
167        retriever
168            .add_documents(vec![Document::new(content.clone())])
169            .await
170            .unwrap();
171
172        let step = RetrieverRunnable::new(Arc::new(retriever), 1);
173        // 文档 → 模板变量:把检索结果拼成 `context`。
174        let to_context = RunnableLambda::new_sync(|docs: Vec<Document>| {
175            HashMap::from([(
176                "context".to_string(),
177                docs.iter()
178                    .map(|d| d.content.clone())
179                    .collect::<Vec<_>>()
180                    .join("\n\n"),
181            )])
182        });
183        let prompt = ChatPromptTemplate::from_messages([
184            Message::system("你是一个检索问答助手。"),
185            Message::human("请根据以下资料回答问题:\n\n{context}"),
186        ]);
187        let chain = step.pipe(to_context).pipe(prompt).pipe(MockChat);
188
189        let result: LLMResult = chain.invoke("zebra".to_string(), None).await.unwrap();
190        assert!(
191            result.content.contains("context has zebra: true"),
192            "the full parent doc (containing `zebra`) must reach the model, got: {}",
193            result.content
194        );
195    }
196
197    /// 最小 mock 聊天模型:把最后一条人类消息的长度回显,证明上下文流到了模型侧。
198    #[derive(Debug)]
199    struct MockChat;
200
201    #[derive(Debug)]
202    struct MockChatError(String);
203
204    impl std::fmt::Display for MockChatError {
205        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206            write!(f, "MockChatError: {}", self.0)
207        }
208    }
209
210    impl std::error::Error for MockChatError {}
211
212    impl From<MockChatError> for lc_core::runnables::LcelError {
213        fn from(e: MockChatError) -> Self {
214            lc_core::runnables::LcelError::Other(e.0)
215        }
216    }
217
218    #[async_trait]
219    impl Runnable<Vec<Message>, LLMResult> for MockChat {
220        type Error = MockChatError;
221
222        async fn invoke(
223            &self,
224            input: Vec<Message>,
225            config: Option<RunnableConfig>,
226        ) -> Result<LLMResult, Self::Error> {
227            self.chat(input, config).await
228        }
229    }
230
231    #[async_trait]
232    impl BaseLanguageModel<Vec<Message>, LLMResult> for MockChat {
233        fn model_name(&self) -> &str {
234            "mock-chat"
235        }
236
237        fn get_num_tokens(&self, text: &str) -> usize {
238            text.len() / 4
239        }
240
241        fn with_temperature(self, _temp: f32) -> Self
242        where
243            Self: Sized,
244        {
245            self
246        }
247
248        fn with_max_tokens(self, _max: usize) -> Self
249        where
250            Self: Sized,
251        {
252            self
253        }
254    }
255
256    #[async_trait]
257    impl BaseChatModel for MockChat {
258        async fn chat(
259            &self,
260            messages: Vec<Message>,
261            _config: Option<RunnableConfig>,
262        ) -> Result<LLMResult, Self::Error> {
263            let last = messages
264                .last()
265                .map(|m| m.content.clone())
266                .unwrap_or_default();
267            Ok(LLMResult {
268                content: format!(
269                    "context has zebra: {}; received {} chars of context",
270                    last.contains("zebra"),
271                    last.len()
272                ),
273                model: "mock-chat".to_string(),
274                token_usage: None,
275                tool_calls: None,
276                thinking_content: None,
277            })
278        }
279
280        async fn stream_chat(
281            &self,
282            _messages: Vec<Message>,
283            _config: Option<RunnableConfig>,
284        ) -> Result<
285            Pin<Box<dyn futures_util::Stream<Item = Result<String, Self::Error>> + Send>>,
286            Self::Error,
287        > {
288            unreachable!("stream_chat not exercised in parent_document tests")
289        }
290    }
291}