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