1use async_trait::async_trait;
5use futures_util::future::try_join_all;
6use futures_util::StreamExt;
7use futures_util::TryStreamExt;
8use lc_core::language_models::LLMResult;
9use lc_core::{BaseChatModel, Runnable};
10use lc_schema::Message;
11use lc_shared::document::Document;
12use serde_json::Value;
13use std::collections::HashMap;
14
15use crate::base::{BaseChain, ChainError, ChainResult, ChainStream, StreamToken};
16
17pub(crate) const DEFAULT_MAP_PROMPT: &str = "Answer the user's question based on the following document content. Provide a concise answer based on the document content.
19
20Document content:
21{context}
22
23Question: {input}
24
25Answer based on this document:";
26
27pub(crate) const DEFAULT_REDUCE_PROMPT: &str = "Below are answers from multiple documents. Please merge them into a single complete and coherent final answer.
29
30Answers from each document:
31{summaries}
32
33Original question: {input}
34
35Final consolidated answer:";
36
37pub struct MapReduceDocumentsChain<M: BaseChatModel> {
43 llm: M,
44 map_prompt_template: String,
45 reduce_prompt_template: String,
46 document_variable_name: String,
47 input_key: String,
48 output_key: String,
49 name: String,
50 verbose: bool,
51 map_concurrency: Option<usize>,
53}
54
55impl<M: BaseChatModel> MapReduceDocumentsChain<M> {
56 pub fn new(llm: M) -> Self {
57 Self {
58 llm,
59 map_prompt_template: DEFAULT_MAP_PROMPT.to_string(),
60 reduce_prompt_template: DEFAULT_REDUCE_PROMPT.to_string(),
61 document_variable_name: "context".to_string(),
62 input_key: "input".to_string(),
63 output_key: "output".to_string(),
64 name: "map_reduce_documents".to_string(),
65 verbose: false,
66 map_concurrency: None,
67 }
68 }
69
70 pub fn with_map_prompt(mut self, template: impl Into<String>) -> Self {
71 self.map_prompt_template = template.into();
72 self
73 }
74
75 pub fn with_reduce_prompt(mut self, template: impl Into<String>) -> Self {
76 self.reduce_prompt_template = template.into();
77 self
78 }
79
80 pub fn with_document_variable(mut self, name: impl Into<String>) -> Self {
81 self.document_variable_name = name.into();
82 self
83 }
84
85 pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
86 self.input_key = key.into();
87 self
88 }
89
90 pub fn with_output_key(mut self, key: impl Into<String>) -> Self {
91 self.output_key = key.into();
92 self
93 }
94
95 pub fn with_name(mut self, name: impl Into<String>) -> Self {
96 self.name = name.into();
97 self
98 }
99
100 pub fn with_verbose(mut self, verbose: bool) -> Self {
101 self.verbose = verbose;
102 self
103 }
104
105 pub fn with_map_concurrency(mut self, limit: usize) -> Self {
111 self.map_concurrency = Some(limit);
112 self
113 }
114
115 pub fn build_map_prompt(&self, context: &str, input: &str) -> String {
116 self.map_prompt_template
117 .replace(&format!("{{{}}}", self.document_variable_name), context)
118 .replace("{input}", input)
119 }
120
121 pub fn build_reduce_prompt(&self, summaries: &[String], input: &str) -> String {
122 let summaries_text = summaries
123 .iter()
124 .enumerate()
125 .map(|(i, s)| format!("Answer from document {}:\n{}", i + 1, s))
126 .collect::<Vec<_>>()
127 .join("\n\n");
128
129 self.reduce_prompt_template
130 .replace("{summaries}", &summaries_text)
131 .replace("{input}", input)
132 }
133
134 async fn map_document(
136 &self,
137 doc: &Document,
138 input: &str,
139 index: usize,
140 ) -> Result<String, ChainError>
141 where
142 <M as Runnable<Vec<Message>, LLMResult>>::Error: std::fmt::Display,
143 {
144 let prompt = self.build_map_prompt(&doc.content, input);
145
146 if self.verbose {
147 println!("\n--- Map document {} ---", index + 1);
148 }
149
150 let messages = vec![Message::human(&prompt)];
151 let response = self.llm.invoke(messages, None).await.map_err(|e| {
152 ChainError::ExecutionError(format!("Map call failed (document {}): {}", index + 1, e))
153 })?;
154
155 if self.verbose {
156 println!("Document {} answer: {}", index + 1, response.content);
157 }
158
159 Ok(response.content)
160 }
161
162 async fn map_phase(
169 &self,
170 documents: &[Document],
171 input: &str,
172 ) -> Result<Vec<String>, ChainError>
173 where
174 <M as Runnable<Vec<Message>, LLMResult>>::Error: std::fmt::Display,
175 {
176 let mut map_futures = Vec::with_capacity(documents.len());
181 for (i, doc) in documents.iter().enumerate() {
182 map_futures.push(self.map_document(doc, input, i));
183 }
184
185 match self.map_concurrency {
186 Some(limit) => {
187 futures_util::stream::iter(map_futures)
188 .buffer_unordered(limit)
189 .try_collect()
190 .await
191 }
192 None => try_join_all(map_futures).await,
193 }
194 }
195
196 pub async fn invoke_with_documents(
198 &self,
199 documents: Vec<Document>,
200 input: &str,
201 ) -> Result<String, ChainError>
202 where
203 <M as Runnable<Vec<Message>, LLMResult>>::Error: std::fmt::Display,
204 {
205 if documents.is_empty() {
206 return Err(ChainError::ExecutionError(
207 "Document list is empty".to_string(),
208 ));
209 }
210
211 if self.verbose {
212 println!("\n=== MapReduceDocumentsChain ===");
213 println!("Document count: {}", documents.len());
214 println!("Input: {}", input);
215 }
216
217 if self.verbose {
218 println!("\n--- Map phase ---");
219 }
220
221 let summaries = self.map_phase(&documents, input).await?;
222
223 if self.verbose {
224 println!("\n--- Reduce phase ---");
225 }
226
227 let reduce_prompt = self.build_reduce_prompt(&summaries, input);
228
229 if self.verbose {
230 println!("Merging answers from {} documents", summaries.len());
231 }
232
233 let messages = vec![Message::human(&reduce_prompt)];
234 let response = self
235 .llm
236 .invoke(messages, None)
237 .await
238 .map_err(|e| ChainError::ExecutionError(format!("Reduce call failed: {}", e)))?;
239
240 let final_answer = response.content;
241
242 if self.verbose {
243 println!("Final answer: {}", final_answer);
244 println!("=== MapReduceDocumentsChain complete ===\n");
245 }
246
247 Ok(final_answer)
248 }
249}
250
251#[async_trait]
252impl<M: BaseChatModel + Send + Sync + 'static> BaseChain for MapReduceDocumentsChain<M>
253where
254 <M as Runnable<Vec<Message>, LLMResult>>::Error: std::fmt::Display,
255{
256 fn input_keys(&self) -> Vec<&str> {
257 vec![&self.input_key, "documents"]
258 }
259
260 fn output_keys(&self) -> Vec<&str> {
261 vec![&self.output_key]
262 }
263
264 async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
265 self.validate_inputs(&inputs)?;
267
268 let input = inputs
269 .get(&self.input_key)
270 .and_then(|v| v.as_str())
271 .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
272
273 let documents = crate::base::documents_from_input(inputs.get("documents"))?;
274
275 let output = self.invoke_with_documents(documents, input).await?;
276
277 let mut result = HashMap::new();
278 result.insert(self.output_key.clone(), Value::String(output));
279 Ok(result)
280 }
281
282 async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
288 self.validate_inputs(&inputs)?;
289
290 let input = inputs
291 .get(&self.input_key)
292 .and_then(|v| v.as_str())
293 .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
294
295 let documents = crate::base::documents_from_input(inputs.get("documents"))?;
296
297 if documents.is_empty() {
298 return Err(ChainError::ExecutionError(
299 "Document list is empty".to_string(),
300 ));
301 }
302
303 let summaries = self.map_phase(&documents, input).await?;
306
307 let reduce_prompt = self.build_reduce_prompt(&summaries, input);
309 let messages = vec![Message::human(&reduce_prompt)];
310
311 let llm_stream = self
312 .llm
313 .stream_chat(messages, None)
314 .await
315 .map_err(|e| ChainError::StreamError(format!("LLM stream failed: {}", e)))?;
316
317 let stream = llm_stream.map(|result| match result {
318 Ok(token) => Ok(StreamToken {
319 token,
320 is_final: false,
321 }),
322 Err(e) => Err(ChainError::StreamError(format!(
323 "Stream token error: {}",
324 e
325 ))),
326 });
327
328 let final_stream = stream.chain(futures_util::stream::once(async move {
329 Ok(StreamToken {
330 token: String::new(),
331 is_final: true,
332 })
333 }));
334
335 Ok(Box::pin(final_stream))
336 }
337
338 fn name(&self) -> &str {
339 &self.name
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use async_trait::async_trait;
347 use futures_util::Stream;
348 use lc_core::runnables::RunnableConfig;
349 use lc_core::{BaseLanguageModel, Runnable};
350 use std::pin::Pin;
351 use std::sync::atomic::{AtomicUsize, Ordering};
352 use std::sync::Arc;
353
354 #[derive(Debug)]
355 struct MockError(String);
356 impl std::fmt::Display for MockError {
357 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358 write!(f, "{}", self.0)
359 }
360 }
361 impl std::error::Error for MockError {}
362
363 struct TrackingLLM {
368 invokes: Arc<AtomicUsize>,
369 in_flight: Arc<AtomicUsize>,
370 max_in_flight: Arc<AtomicUsize>,
371 }
372
373 impl TrackingLLM {
374 fn counters() -> (Arc<AtomicUsize>, Arc<AtomicUsize>, Arc<AtomicUsize>) {
375 (
376 Arc::new(AtomicUsize::new(0)),
377 Arc::new(AtomicUsize::new(0)),
378 Arc::new(AtomicUsize::new(0)),
379 )
380 }
381 }
382
383 #[async_trait]
384 impl Runnable<Vec<Message>, LLMResult> for TrackingLLM {
385 type Error = MockError;
386 async fn invoke(
387 &self,
388 input: Vec<Message>,
389 _config: Option<RunnableConfig>,
390 ) -> Result<LLMResult, Self::Error> {
391 let cur = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
392 self.max_in_flight.fetch_max(cur, Ordering::SeqCst);
393
394 for _ in 0..32 {
397 tokio::task::yield_now().await;
398 }
399
400 self.in_flight.fetch_sub(1, Ordering::SeqCst);
401 self.invokes.fetch_add(1, Ordering::SeqCst);
402
403 let is_reduce = input
404 .iter()
405 .any(|m| m.content.contains("Below are answers"));
406 let content = if is_reduce {
407 "final merged answer".to_string()
408 } else {
409 "map answer".to_string()
410 };
411 Ok(LLMResult {
412 content,
413 model: "mock".to_string(),
414 token_usage: None,
415 tool_calls: None,
416 thinking_content: None,
417 })
418 }
419 }
420
421 #[async_trait]
422 impl BaseLanguageModel<Vec<Message>, LLMResult> for TrackingLLM {
423 fn model_name(&self) -> &str {
424 "mock"
425 }
426 fn get_num_tokens(&self, t: &str) -> usize {
427 t.len()
428 }
429 fn with_temperature(self, _: f32) -> Self {
430 self
431 }
432 fn with_max_tokens(self, _: usize) -> Self {
433 self
434 }
435 }
436
437 #[async_trait]
438 impl BaseChatModel for TrackingLLM {
439 async fn chat(
440 &self,
441 messages: Vec<Message>,
442 _config: Option<RunnableConfig>,
443 ) -> Result<LLMResult, Self::Error> {
444 self.invoke(messages, None).await
447 }
448 async fn stream_chat(
449 &self,
450 _messages: Vec<Message>,
451 _config: Option<RunnableConfig>,
452 ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error>
453 {
454 let tokens = [Ok("streamed token".to_string())];
455 Ok(Box::pin(futures_util::stream::iter(tokens)))
456 }
457 }
458
459 fn docs(n: usize) -> Vec<Document> {
460 (0..n).map(|i| Document::new(format!("doc {i}"))).collect()
461 }
462
463 #[tokio::test]
467 async fn test_map_reduce_map_phase_is_parallel() {
468 let (invokes, in_flight, max_in_flight) = TrackingLLM::counters();
469 let chain = MapReduceDocumentsChain::new(TrackingLLM {
470 invokes: invokes.clone(),
471 in_flight,
472 max_in_flight: max_in_flight.clone(),
473 });
474
475 let out = chain
476 .invoke_with_documents(docs(6), "question")
477 .await
478 .unwrap();
479 assert_eq!(out, "final merged answer");
480 assert_eq!(invokes.load(Ordering::SeqCst), 7, "6 map + 1 reduce");
481 assert!(
482 max_in_flight.load(Ordering::SeqCst) > 1,
483 "map phase must overlap calls, got max in-flight {}",
484 max_in_flight.load(Ordering::SeqCst)
485 );
486 }
487
488 #[tokio::test]
491 async fn test_map_reduce_concurrency_limit_caps_in_flight() {
492 let (invokes, in_flight, max_in_flight) = TrackingLLM::counters();
493 let chain = MapReduceDocumentsChain::new(TrackingLLM {
494 invokes: invokes.clone(),
495 in_flight,
496 max_in_flight: max_in_flight.clone(),
497 })
498 .with_map_concurrency(2);
499
500 let out = chain
501 .invoke_with_documents(docs(6), "question")
502 .await
503 .unwrap();
504 assert_eq!(out, "final merged answer");
505 assert_eq!(invokes.load(Ordering::SeqCst), 7);
506 assert_eq!(
507 max_in_flight.load(Ordering::SeqCst),
508 2,
509 "concurrency cap 2 must bound in-flight map calls"
510 );
511 }
512
513 #[tokio::test]
515 async fn test_map_reduce_empty_documents() {
516 let (invokes, in_flight, max_in_flight) = TrackingLLM::counters();
517 let chain = MapReduceDocumentsChain::new(TrackingLLM {
518 invokes: invokes.clone(),
519 in_flight,
520 max_in_flight,
521 });
522 let err = match chain.invoke_with_documents(vec![], "q").await {
523 Ok(_) => panic!("expected an execution error"),
524 Err(e) => e,
525 };
526 assert!(matches!(err, ChainError::ExecutionError(_)));
527 assert_eq!(invokes.load(Ordering::SeqCst), 0);
528 }
529}