1use async_trait::async_trait;
5use futures_util::future::try_join_all;
6use futures_util::StreamExt;
7use lc_core::BaseChatModel;
8use lc_providers::{wrap_chat_model, ProviderError};
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};
17use crate::BoxedChatModel;
18
19pub(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).
21
22Document content:
23{context}
24
25Question: {input}
26
27Please output in the following format:
28Relevance score: <score>
29Answer: <your answer>";
30
31pub struct MapRerankDocumentsChain {
36 llm: BoxedChatModel,
37 map_prompt_template: String,
38 document_variable_name: String,
39 input_key: String,
40 output_key: String,
41 name: String,
42 verbose: bool,
43 top_k: usize,
45 default_score: Option<u32>,
48}
49
50static SCORE_RE: LazyLock<Regex> =
52 LazyLock::new(|| Regex::new(r"(?i)(?:relevance\s*score|相关性评分)\s*[::]\s*(\d+)").unwrap());
53static SCORE_RE2: LazyLock<Regex> =
54 LazyLock::new(|| Regex::new(r"(?i)score\s*[::]\s*(\d+)").unwrap());
55
56fn truncate_str(s: &str, max_len: usize) -> &str {
58 if s.chars().count() <= max_len {
59 s
60 } else {
61 let end = s
62 .char_indices()
63 .nth(max_len)
64 .map(|(i, _)| i)
65 .unwrap_or(s.len());
66 &s[..end]
67 }
68}
69
70pub fn extract_score(text: &str) -> Option<(u32, String)> {
76 for re in [&*SCORE_RE, &*SCORE_RE2] {
77 if let Some(caps) = re.captures(text) {
78 if let Ok(score) = caps[1].parse::<u32>() {
79 let cleaned = re.replace(text, "").trim().to_string();
80 let cleaned = cleaned
81 .trim_start_matches("Answer")
82 .trim_start_matches("答案")
83 .trim_start_matches(&[':', ':'][..])
84 .trim()
85 .to_string();
86 return Some((
87 std::cmp::min(score, 100),
88 if cleaned.is_empty() {
89 text.to_string()
90 } else {
91 cleaned
92 },
93 ));
94 }
95 }
96 }
97 None
98}
99
100impl MapRerankDocumentsChain {
101 pub fn new<L>(llm: L) -> Self
103 where
104 L: BaseChatModel + Send + Sync + 'static,
105 L::Error: Into<ProviderError>,
106 {
107 Self {
108 llm: wrap_chat_model(llm),
109 map_prompt_template: DEFAULT_MAP_RERANK_PROMPT.to_string(),
110 document_variable_name: "context".to_string(),
111 input_key: "input".to_string(),
112 output_key: "output".to_string(),
113 name: "map_rerank_documents".to_string(),
114 verbose: false,
115 top_k: 1,
116 default_score: None,
117 }
118 }
119
120 pub fn with_map_prompt(mut self, template: impl Into<String>) -> Self {
122 self.map_prompt_template = template.into();
123 self
124 }
125
126 pub fn with_document_variable(mut self, name: impl Into<String>) -> Self {
128 self.document_variable_name = name.into();
129 self
130 }
131
132 pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
134 self.input_key = key.into();
135 self
136 }
137
138 pub fn with_output_key(mut self, key: impl Into<String>) -> Self {
140 self.output_key = key.into();
141 self
142 }
143
144 pub fn with_name(mut self, name: impl Into<String>) -> Self {
146 self.name = name.into();
147 self
148 }
149
150 pub fn with_verbose(mut self, verbose: bool) -> Self {
152 self.verbose = verbose;
153 self
154 }
155
156 pub fn with_top_k(mut self, k: usize) -> Self {
158 self.top_k = k;
159 self
160 }
161
162 pub fn with_default_score(mut self, score: u32) -> Self {
167 self.default_score = Some(score);
168 self
169 }
170
171 pub fn build_map_prompt(&self, context: &str, input: &str) -> String {
173 self.map_prompt_template
174 .replace(&format!("{{{}}}", self.document_variable_name), context)
175 .replace("{input}", input)
176 }
177
178 async fn map_document(
179 &self,
180 doc: &Document,
181 input: &str,
182 index: usize,
183 ) -> Result<Option<(u32, String)>, ChainError> {
184 let prompt = self.build_map_prompt(&doc.content, input);
185 if self.verbose {
186 println!("\n--- Map document {} ---", index + 1);
187 }
188 let messages = vec![Message::human(&prompt)];
189 let response = self.llm.invoke(messages, None).await.map_err(|e| {
190 ChainError::ExecutionError(format!("Map call failed (document {}): {}", index + 1, e))
191 })?;
192
193 self.rank_output(&response.content, index)
194 }
195
196 fn rank_output(&self, output: &str, index: usize) -> Result<Option<(u32, String)>, ChainError> {
200 let scored = match extract_score(output) {
203 Some(pair) => Some(pair),
204 None => match self.default_score {
205 Some(n) => Some((n, output.trim().to_string())),
206 None => {
207 log::warn!(
208 "MapRerank: document {} output has no parseable score; excluded from ranking",
209 index + 1
210 );
211 None
212 }
213 },
214 };
215
216 if self.verbose {
217 if let Some((score, answer)) = &scored {
218 println!(
219 "Document {} score: {}, answer: {}",
220 index + 1,
221 score,
222 truncate_str(answer, 80)
223 );
224 } else {
225 println!("Document {} excluded (no score)", index + 1);
226 }
227 }
228 Ok(scored)
229 }
230
231 async fn map_document_stream(
235 &self,
236 doc: &Document,
237 input: &str,
238 index: usize,
239 ) -> Result<Option<(u32, String)>, ChainError> {
240 let prompt = self.build_map_prompt(&doc.content, input);
241 if self.verbose {
242 println!("\n--- Map document {} (stream) ---", index + 1);
243 }
244 let messages = vec![Message::human(&prompt)];
245 let mut llm_stream = self.llm.stream_chat(messages, None).await.map_err(|e| {
246 ChainError::StreamError(format!("Map stream failed (document {}): {}", index + 1, e))
247 })?;
248
249 let mut text = String::new();
250 while let Some(chunk) = llm_stream.next().await {
251 match chunk {
252 Ok(token) => text.push_str(&token),
253 Err(e) => {
254 return Err(ChainError::StreamError(format!(
255 "Map stream token error (document {}): {}",
256 index + 1,
257 e
258 )));
259 }
260 }
261 }
262 self.rank_output(&text, index)
263 }
264
265 pub async fn invoke_with_documents(
267 &self,
268 documents: Vec<Document>,
269 input: &str,
270 ) -> Result<Vec<(u32, String)>, ChainError> {
271 if documents.is_empty() {
272 return Err(ChainError::ExecutionError(
273 "Document list is empty".to_string(),
274 ));
275 }
276
277 if self.verbose {
278 println!("\n=== MapRerankDocumentsChain ===");
279 println!("Document count: {}, Input: {}", documents.len(), input);
280 println!("\n--- Map phase ---");
281 }
282
283 let mut map_futures = Vec::new();
284 for (i, doc) in documents.iter().enumerate() {
285 map_futures.push(self.map_document(doc, input, i));
286 }
287 let mut results: Vec<(u32, String)> = try_join_all(map_futures)
289 .await?
290 .into_iter()
291 .flatten()
292 .collect();
293
294 if results.is_empty() {
295 return Err(ChainError::ExecutionError(
296 "All documents were excluded: no document produced a parseable score".to_string(),
297 ));
298 }
299
300 results.sort_by(|a, b| b.0.cmp(&a.0));
301
302 if self.verbose {
303 println!("\n--- Rerank phase ---");
304 for (i, (score, answer)) in results.iter().enumerate() {
305 println!(
306 "Rank {}: score={}, answer={}",
307 i + 1,
308 score,
309 truncate_str(answer, 100)
310 );
311 }
312 }
313
314 let top_results: Vec<(u32, String)> = results.into_iter().take(self.top_k).collect();
315 if self.verbose {
316 println!("Selected {} best results", top_results.len());
317 println!("=== MapRerankDocumentsChain complete ===\n");
318 }
319 Ok(top_results)
320 }
321}
322
323#[async_trait]
324impl BaseChain for MapRerankDocumentsChain {
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 struct MockLLM;
429
430 #[async_trait]
431 impl Runnable<Vec<Message>, LLMResult> for MockLLM {
432 type Error = ProviderError;
433 async fn invoke(
434 &self,
435 _input: Vec<Message>,
436 _config: Option<RunnableConfig>,
437 ) -> Result<LLMResult, Self::Error> {
438 Ok(LLMResult {
439 content: "Relevance score: 90\nAnswer: best answer".to_string(),
440 model: "mock".to_string(),
441 token_usage: None,
442 tool_calls: None,
443 thinking_content: None,
444 })
445 }
446 }
447
448 #[async_trait]
449 impl BaseLanguageModel<Vec<Message>, LLMResult> for MockLLM {
450 fn model_name(&self) -> &str {
451 "mock"
452 }
453 fn get_num_tokens(&self, t: &str) -> usize {
454 t.len()
455 }
456 fn with_temperature(self, _: f32) -> Self {
457 self
458 }
459 fn with_max_tokens(self, _: usize) -> Self {
460 self
461 }
462 }
463
464 #[async_trait]
465 impl BaseChatModel for MockLLM {
466 async fn chat(
467 &self,
468 _messages: Vec<Message>,
469 _config: Option<RunnableConfig>,
470 ) -> Result<LLMResult, Self::Error> {
471 Ok(LLMResult {
472 content: "Relevance score: 90\nAnswer: best answer".to_string(),
473 model: "mock".to_string(),
474 token_usage: None,
475 tool_calls: None,
476 thinking_content: None,
477 })
478 }
479 async fn stream_chat(
480 &self,
481 _messages: Vec<Message>,
482 _config: Option<RunnableConfig>,
483 ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error>
484 {
485 let tokens = [Ok("Relevance score: 90\nAnswer: best answer".to_string())];
486 Ok(Box::pin(futures_util::stream::iter(tokens)))
487 }
488 }
489
490 #[tokio::test]
491 async fn test_map_rerank_stream_emits_top_answer() {
492 let chain = MapRerankDocumentsChain::new(MockLLM);
493 let docs = vec![Document::new("doc one"), Document::new("doc two")];
494 let docs_value = serde_json::to_value(docs).unwrap();
495 let mut inputs = HashMap::new();
496 inputs.insert("input".to_string(), Value::String("question".to_string()));
497 inputs.insert("documents".to_string(), docs_value);
498
499 let mut stream = chain.stream(inputs).await.unwrap();
500 let mut tokens = Vec::new();
501 while let Some(item) = stream.next().await {
502 tokens.push(item.unwrap());
503 }
504 assert_eq!(tokens.len(), 1);
505 assert!(tokens[0].is_final);
506 assert!(
507 tokens[0].token.contains("best answer"),
508 "top answer should be streamed, got {:?}",
509 tokens[0].token
510 );
511 }
512
513 #[tokio::test]
514 async fn test_map_rerank_stream_empty_documents() {
515 let chain = MapRerankDocumentsChain::new(MockLLM);
516 let mut inputs = HashMap::new();
517 inputs.insert("input".to_string(), Value::String("q".to_string()));
518 inputs.insert("documents".to_string(), serde_json::json!([]));
519 let err = match chain.stream(inputs).await {
520 Ok(_) => panic!("expected an execution error"),
521 Err(e) => e,
522 };
523 assert!(matches!(err, ChainError::ExecutionError(_)));
524 }
525
526 #[test]
527 fn test_rank_output_uses_default_score_for_unscored() {
528 let chain = MapRerankDocumentsChain::new(MockLLM).with_default_score(40);
529 let scored = chain.rank_output("plain answer without score", 0).unwrap();
530 assert_eq!(scored, Some((40, "plain answer without score".to_string())));
531 }
532
533 #[test]
534 fn test_rank_output_skips_unscored_without_default() {
535 let chain = MapRerankDocumentsChain::new(MockLLM);
536 assert_eq!(chain.rank_output("no score here", 0).unwrap(), None);
537 }
538}