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