1use async_trait::async_trait;
5use futures_util::future::try_join_all;
6use futures_util::StreamExt;
7use lc_core::language_models::LLMResult;
8use lc_core::{BaseChatModel, Runnable};
9use lc_schema::Message;
10use lc_shared::document::Document;
11use regex::Regex;
12use serde_json::Value;
13use std::collections::HashMap;
14use std::sync::LazyLock;
15
16use crate::base::{BaseChain, ChainError, ChainResult, ChainStream, StreamToken};
17
18pub(crate) const DEFAULT_MAP_RERANK_PROMPT: &str = "Answer the question based on the following document, and provide a relevance score (0-100, higher is more relevant).
20
21Document content:
22{context}
23
24Question: {input}
25
26Please output in the following format:
27Relevance score: <score>
28Answer: <your answer>";
29
30pub struct MapRerankDocumentsChain<M: BaseChatModel> {
35 llm: M,
36 map_prompt_template: String,
37 document_variable_name: String,
38 input_key: String,
39 output_key: String,
40 name: String,
41 verbose: bool,
42 top_k: usize,
44 default_score: Option<u32>,
47}
48
49static SCORE_RE: LazyLock<Regex> =
51 LazyLock::new(|| Regex::new(r"(?i)(?:relevance\s*score|相关性评分)\s*[::]\s*(\d+)").unwrap());
52static SCORE_RE2: LazyLock<Regex> =
53 LazyLock::new(|| Regex::new(r"(?i)score\s*[::]\s*(\d+)").unwrap());
54
55fn truncate_str(s: &str, max_len: usize) -> &str {
57 if s.chars().count() <= max_len {
58 s
59 } else {
60 let end = s
61 .char_indices()
62 .nth(max_len)
63 .map(|(i, _)| i)
64 .unwrap_or(s.len());
65 &s[..end]
66 }
67}
68
69pub fn extract_score(text: &str) -> Option<(u32, String)> {
75 for re in [&*SCORE_RE, &*SCORE_RE2] {
76 if let Some(caps) = re.captures(text) {
77 if let Ok(score) = caps[1].parse::<u32>() {
78 let cleaned = re.replace(text, "").trim().to_string();
79 let cleaned = cleaned
80 .trim_start_matches("Answer")
81 .trim_start_matches("答案")
82 .trim_start_matches(&[':', ':'][..])
83 .trim()
84 .to_string();
85 return Some((
86 std::cmp::min(score, 100),
87 if cleaned.is_empty() {
88 text.to_string()
89 } else {
90 cleaned
91 },
92 ));
93 }
94 }
95 }
96 None
97}
98
99impl<M: BaseChatModel> MapRerankDocumentsChain<M> {
100 pub fn new(llm: M) -> Self {
101 Self {
102 llm,
103 map_prompt_template: DEFAULT_MAP_RERANK_PROMPT.to_string(),
104 document_variable_name: "context".to_string(),
105 input_key: "input".to_string(),
106 output_key: "output".to_string(),
107 name: "map_rerank_documents".to_string(),
108 verbose: false,
109 top_k: 1,
110 default_score: None,
111 }
112 }
113
114 pub fn with_map_prompt(mut self, template: impl Into<String>) -> Self {
115 self.map_prompt_template = template.into();
116 self
117 }
118
119 pub fn with_document_variable(mut self, name: impl Into<String>) -> Self {
120 self.document_variable_name = name.into();
121 self
122 }
123
124 pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
125 self.input_key = key.into();
126 self
127 }
128
129 pub fn with_output_key(mut self, key: impl Into<String>) -> Self {
130 self.output_key = key.into();
131 self
132 }
133
134 pub fn with_name(mut self, name: impl Into<String>) -> Self {
135 self.name = name.into();
136 self
137 }
138
139 pub fn with_verbose(mut self, verbose: bool) -> Self {
140 self.verbose = verbose;
141 self
142 }
143
144 pub fn with_top_k(mut self, k: usize) -> Self {
146 self.top_k = k;
147 self
148 }
149
150 pub fn with_default_score(mut self, score: u32) -> Self {
155 self.default_score = Some(score);
156 self
157 }
158
159 pub fn build_map_prompt(&self, context: &str, input: &str) -> String {
161 self.map_prompt_template
162 .replace(&format!("{{{}}}", self.document_variable_name), context)
163 .replace("{input}", input)
164 }
165
166 async fn map_document(
167 &self,
168 doc: &Document,
169 input: &str,
170 index: usize,
171 ) -> Result<Option<(u32, String)>, ChainError>
172 where
173 <M as Runnable<Vec<Message>, LLMResult>>::Error: std::fmt::Display,
174 {
175 let prompt = self.build_map_prompt(&doc.content, input);
176 if self.verbose {
177 println!("\n--- Map document {} ---", index + 1);
178 }
179 let messages = vec![Message::human(&prompt)];
180 let response = self.llm.invoke(messages, None).await.map_err(|e| {
181 ChainError::ExecutionError(format!("Map call failed (document {}): {}", index + 1, e))
182 })?;
183
184 self.rank_output(&response.content, index)
185 }
186
187 fn rank_output(&self, output: &str, index: usize) -> Result<Option<(u32, String)>, ChainError> {
191 let scored = match extract_score(output) {
194 Some(pair) => Some(pair),
195 None => match self.default_score {
196 Some(n) => Some((n, output.trim().to_string())),
197 None => {
198 log::warn!(
199 "MapRerank: document {} output has no parseable score; excluded from ranking",
200 index + 1
201 );
202 None
203 }
204 },
205 };
206
207 if self.verbose {
208 if let Some((score, answer)) = &scored {
209 println!(
210 "Document {} score: {}, answer: {}",
211 index + 1,
212 score,
213 truncate_str(answer, 80)
214 );
215 } else {
216 println!("Document {} excluded (no score)", index + 1);
217 }
218 }
219 Ok(scored)
220 }
221
222 async fn map_document_stream(
226 &self,
227 doc: &Document,
228 input: &str,
229 index: usize,
230 ) -> Result<Option<(u32, String)>, ChainError>
231 where
232 <M as Runnable<Vec<Message>, LLMResult>>::Error: std::fmt::Display,
233 {
234 let prompt = self.build_map_prompt(&doc.content, input);
235 if self.verbose {
236 println!("\n--- Map document {} (stream) ---", index + 1);
237 }
238 let messages = vec![Message::human(&prompt)];
239 let mut llm_stream = self.llm.stream_chat(messages, None).await.map_err(|e| {
240 ChainError::StreamError(format!("Map stream failed (document {}): {}", index + 1, e))
241 })?;
242
243 let mut text = String::new();
244 while let Some(chunk) = llm_stream.next().await {
245 match chunk {
246 Ok(token) => text.push_str(&token),
247 Err(e) => {
248 return Err(ChainError::StreamError(format!(
249 "Map stream token error (document {}): {}",
250 index + 1,
251 e
252 )));
253 }
254 }
255 }
256 self.rank_output(&text, index)
257 }
258
259 pub async fn invoke_with_documents(
261 &self,
262 documents: Vec<Document>,
263 input: &str,
264 ) -> Result<Vec<(u32, String)>, ChainError>
265 where
266 <M as Runnable<Vec<Message>, LLMResult>>::Error: std::fmt::Display,
267 {
268 if documents.is_empty() {
269 return Err(ChainError::ExecutionError(
270 "Document list is empty".to_string(),
271 ));
272 }
273
274 if self.verbose {
275 println!("\n=== MapRerankDocumentsChain ===");
276 println!("Document count: {}, Input: {}", documents.len(), input);
277 println!("\n--- Map phase ---");
278 }
279
280 let mut map_futures = Vec::new();
281 for (i, doc) in documents.iter().enumerate() {
282 map_futures.push(self.map_document(doc, input, i));
283 }
284 let mut results: Vec<(u32, String)> = try_join_all(map_futures)
286 .await?
287 .into_iter()
288 .flatten()
289 .collect();
290
291 if results.is_empty() {
292 return Err(ChainError::ExecutionError(
293 "All documents were excluded: no document produced a parseable score".to_string(),
294 ));
295 }
296
297 results.sort_by(|a, b| b.0.cmp(&a.0));
298
299 if self.verbose {
300 println!("\n--- Rerank phase ---");
301 for (i, (score, answer)) in results.iter().enumerate() {
302 println!(
303 "Rank {}: score={}, answer={}",
304 i + 1,
305 score,
306 truncate_str(answer, 100)
307 );
308 }
309 }
310
311 let top_results: Vec<(u32, String)> = results.into_iter().take(self.top_k).collect();
312 if self.verbose {
313 println!("Selected {} best results", top_results.len());
314 println!("=== MapRerankDocumentsChain complete ===\n");
315 }
316 Ok(top_results)
317 }
318}
319
320#[async_trait]
321impl<M: BaseChatModel + Send + Sync + 'static> BaseChain for MapRerankDocumentsChain<M>
322where
323 <M as Runnable<Vec<Message>, LLMResult>>::Error: std::fmt::Display,
324{
325 fn input_keys(&self) -> Vec<&str> {
326 vec![&self.input_key, "documents"]
327 }
328 fn output_keys(&self) -> Vec<&str> {
329 vec![&self.output_key]
330 }
331
332 async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
333 self.validate_inputs(&inputs)?;
335
336 let input = inputs
337 .get(&self.input_key)
338 .and_then(|v| v.as_str())
339 .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
340
341 let documents = crate::base::documents_from_input(inputs.get("documents"))?;
342
343 let results = self.invoke_with_documents(documents, input).await?;
344 let output_json: Vec<serde_json::Value> = results
345 .iter()
346 .map(|(score, answer)| serde_json::json!({"score": score, "answer": answer}))
347 .collect();
348
349 let mut result = HashMap::new();
350 result.insert(self.output_key.clone(), Value::Array(output_json));
351 Ok(result)
352 }
353
354 async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
362 self.validate_inputs(&inputs)?;
365
366 let input = inputs
367 .get(&self.input_key)
368 .and_then(|v| v.as_str())
369 .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
370
371 let documents = crate::base::documents_from_input(inputs.get("documents"))?;
372 if documents.is_empty() {
373 return Err(ChainError::ExecutionError(
374 "Document list is empty".to_string(),
375 ));
376 }
377
378 let mut map_futures = Vec::new();
379 for (i, doc) in documents.iter().enumerate() {
380 map_futures.push(self.map_document_stream(doc, input, i));
381 }
382 let mut results: Vec<(u32, String)> = try_join_all(map_futures)
383 .await?
384 .into_iter()
385 .flatten()
386 .collect();
387
388 if results.is_empty() {
389 return Err(ChainError::ExecutionError(
390 "All documents were excluded: no document produced a parseable score".to_string(),
391 ));
392 }
393
394 results.sort_by(|a, b| b.0.cmp(&a.0));
395 let top_results: Vec<(u32, String)> = results.into_iter().take(self.top_k).collect();
396
397 let stream = futures_util::stream::once(async move {
398 let text = top_results
399 .iter()
400 .map(|(_, answer)| answer.as_str())
401 .collect::<Vec<_>>()
402 .join("\n\n");
403 Ok(StreamToken {
404 token: text,
405 is_final: true,
406 })
407 });
408
409 Ok(Box::pin(stream))
410 }
411
412 fn name(&self) -> &str {
413 &self.name
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use async_trait::async_trait;
421 use futures_util::Stream;
422 use lc_core::language_models::LLMResult;
423 use lc_core::runnables::RunnableConfig;
424 use lc_core::{BaseLanguageModel, Runnable};
425 use std::pin::Pin;
426
427 #[derive(Debug)]
429 struct MockError(String);
430 impl std::fmt::Display for MockError {
431 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432 write!(f, "{}", self.0)
433 }
434 }
435 impl std::error::Error for MockError {}
436
437 struct MockLLM;
438
439 #[async_trait]
440 impl Runnable<Vec<Message>, LLMResult> for MockLLM {
441 type Error = MockError;
442 async fn invoke(
443 &self,
444 _input: Vec<Message>,
445 _config: Option<RunnableConfig>,
446 ) -> Result<LLMResult, Self::Error> {
447 Ok(LLMResult {
448 content: "Relevance score: 90\nAnswer: best answer".to_string(),
449 model: "mock".to_string(),
450 token_usage: None,
451 tool_calls: None,
452 thinking_content: None,
453 })
454 }
455 }
456
457 #[async_trait]
458 impl BaseLanguageModel<Vec<Message>, LLMResult> for MockLLM {
459 fn model_name(&self) -> &str {
460 "mock"
461 }
462 fn get_num_tokens(&self, t: &str) -> usize {
463 t.len()
464 }
465 fn with_temperature(self, _: f32) -> Self {
466 self
467 }
468 fn with_max_tokens(self, _: usize) -> Self {
469 self
470 }
471 }
472
473 #[async_trait]
474 impl BaseChatModel for MockLLM {
475 async fn chat(
476 &self,
477 _messages: Vec<Message>,
478 _config: Option<RunnableConfig>,
479 ) -> Result<LLMResult, Self::Error> {
480 Ok(LLMResult {
481 content: "Relevance score: 90\nAnswer: best answer".to_string(),
482 model: "mock".to_string(),
483 token_usage: None,
484 tool_calls: None,
485 thinking_content: None,
486 })
487 }
488 async fn stream_chat(
489 &self,
490 _messages: Vec<Message>,
491 _config: Option<RunnableConfig>,
492 ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error>
493 {
494 let tokens = [Ok("Relevance score: 90\nAnswer: best answer".to_string())];
495 Ok(Box::pin(futures_util::stream::iter(tokens)))
496 }
497 }
498
499 #[tokio::test]
500 async fn test_map_rerank_stream_emits_top_answer() {
501 let chain = MapRerankDocumentsChain::new(MockLLM);
502 let docs = vec![Document::new("doc one"), Document::new("doc two")];
503 let docs_value = serde_json::to_value(docs).unwrap();
504 let mut inputs = HashMap::new();
505 inputs.insert("input".to_string(), Value::String("question".to_string()));
506 inputs.insert("documents".to_string(), docs_value);
507
508 let mut stream = chain.stream(inputs).await.unwrap();
509 let mut tokens = Vec::new();
510 while let Some(item) = stream.next().await {
511 tokens.push(item.unwrap());
512 }
513 assert_eq!(tokens.len(), 1);
514 assert!(tokens[0].is_final);
515 assert!(
516 tokens[0].token.contains("best answer"),
517 "top answer should be streamed, got {:?}",
518 tokens[0].token
519 );
520 }
521
522 #[tokio::test]
523 async fn test_map_rerank_stream_empty_documents() {
524 let chain = MapRerankDocumentsChain::new(MockLLM);
525 let mut inputs = HashMap::new();
526 inputs.insert("input".to_string(), Value::String("q".to_string()));
527 inputs.insert("documents".to_string(), serde_json::json!([]));
528 let err = match chain.stream(inputs).await {
529 Ok(_) => panic!("expected an execution error"),
530 Err(e) => e,
531 };
532 assert!(matches!(err, ChainError::ExecutionError(_)));
533 }
534
535 #[test]
536 fn test_rank_output_uses_default_score_for_unscored() {
537 let chain = MapRerankDocumentsChain::new(MockLLM).with_default_score(40);
538 let scored = chain.rank_output("plain answer without score", 0).unwrap();
539 assert_eq!(scored, Some((40, "plain answer without score".to_string())));
540 }
541
542 #[test]
543 fn test_rank_output_skips_unscored_without_default() {
544 let chain = MapRerankDocumentsChain::new(MockLLM);
545 assert_eq!(chain.rank_output("no score here", 0).unwrap(), None);
546 }
547}