lc-agents 0.23.0

Agent system for langchainrust — ReAct, FunctionCalling, PlanExecute, CRAG, AdaptiveRAG, DeepResearch, Handoffs, Streaming
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
// src/agents/adaptive_rag.rs → adaptive_rag/ (mod.rs + types.rs + prompts.rs + tests.rs)
//! Adaptive RAG implementation.
//!
//! Uses an LLM to decide whether retrieval is needed and what strategy to use.
//! Three decision branches:
//! - **NoRetrieval**: The query can be answered from general knowledge.
//! - **SingleSearch**: A single search is sufficient.
//! - **MultiQuery**: The query is complex and needs multiple search angles.

use lc_core::language_models::BaseChatModel;
use lc_core::runnables::RunnableConfig;
use lc_core::tools::ToolDefinition;
use lc_rag::RetrieverTrait;
use lc_schema::Message;
use lc_vector_stores::Document;
use serde_json::json;

mod prompts;
#[cfg(test)]
mod tests;
mod types;

pub use types::{AdaptiveRAGError, AdaptiveRAGResult, RagDecision};

use prompts::{GENERATE_SYSTEM_PROMPT, MULTI_QUERY_PROMPT, ROUTING_PROMPT};

/// Adaptive RAG that routes queries to the most appropriate strategy.
///
/// # Overview
///
/// 1. The LLM classifies the query into one of three buckets:
///    `no_retrieval`, `single_search`, or `multi_query`.
/// 2. Based on the decision:
///    - **NoRetrieval**: call the LLM directly.
///    - **SingleSearch**: retrieve documents, then generate.
///    - **MultiQuery**: generate multiple query variants, retrieve for each,
///      merge results, then generate.
///
/// # Example
///
/// ```ignore
/// use langchainrust::agents::adaptive_rag::{AdaptiveRAG, RagDecision};
/// use langchainrust::OpenAIChat;
/// use langchainrust::retrieval::SimilarityRetriever;
///
/// let rag = AdaptiveRAG::new(llm, retriever);
/// let result = rag.invoke("What is the capital of France?").await?;
/// assert_eq!(result.decision, RagDecision::NoRetrieval);
/// ```
pub struct AdaptiveRAG<M: BaseChatModel, R: RetrieverTrait> {
    llm: M,
    retriever: R,
    /// Number of documents to retrieve per query.
    retrieve_k: usize,
    /// Number of alternative queries to generate for multi-query mode.
    multi_query_count: usize,
}

impl<M: BaseChatModel, R: RetrieverTrait> AdaptiveRAG<M, R> {
    /// Creates a new `AdaptiveRAG` with the given LLM and retriever.
    pub fn new(llm: M, retriever: R) -> Self {
        Self {
            llm,
            retriever,
            retrieve_k: 4,
            multi_query_count: 3,
        }
    }

    /// Sets the number of documents to retrieve per query.
    pub fn with_retrieve_k(mut self, k: usize) -> Self {
        self.retrieve_k = k;
        self
    }

    /// Sets the number of alternative queries for multi-query mode.
    pub fn with_multi_query_count(mut self, count: usize) -> Self {
        self.multi_query_count = count;
        self
    }

    /// Invokes the adaptive RAG pipeline for the given query.
    pub async fn invoke(&self, query: &str) -> Result<AdaptiveRAGResult, AdaptiveRAGError> {
        self.invoke_with_config(query, None).await
    }

    /// Invokes the adaptive RAG pipeline with a [`RunnableConfig`] so the
    /// routing and multi-query-planning LLM calls emit `on_llm_start/end` to
    /// the configured callbacks/OTel backend (T6, v0.23.0).
    pub async fn invoke_with_config(
        &self,
        query: &str,
        config: Option<&RunnableConfig>,
    ) -> Result<AdaptiveRAGResult, AdaptiveRAGError> {
        // Step 1: Route the query.
        let decision = self.route(query, config).await?;

        match decision {
            RagDecision::NoRetrieval => self.generate_no_retrieval(query).await,
            RagDecision::SingleSearch => self.generate_single_search(query).await,
            RagDecision::MultiQuery => self.generate_multi_query(query, config).await,
        }
    }

    /// Streams the AdaptiveRAG execution, emitting pipeline step events.
    ///
    /// Emits `AgentStreamEvent::PipelineStep` events for routing and generation,
    /// and `AgentStreamEvent::FinalAnswer` when the answer is ready.
    pub async fn stream(
        &self,
        query: &str,
    ) -> Result<
        std::pin::Pin<
            Box<dyn futures_util::Stream<Item = crate::streaming::AgentStreamEvent> + Send>,
        >,
        AdaptiveRAGError,
    > {
        use crate::streaming::AgentStreamEvent;

        let mut events: Vec<AgentStreamEvent> = Vec::new();

        // Step 1: Route
        events.push(AgentStreamEvent::PipelineStep {
            step: "routing".to_string(),
            detail: Some("Classifying query...".to_string()),
        });

        let decision = self.route(query, None).await?;

        events.push(AgentStreamEvent::PipelineStep {
            step: "routed".to_string(),
            detail: Some(format!("Decision: {}", decision)),
        });

        // Step 2: Execute based on decision
        let result = match decision {
            RagDecision::NoRetrieval => {
                events.push(AgentStreamEvent::PipelineStep {
                    step: "generating".to_string(),
                    detail: Some("No retrieval needed, generating directly...".to_string()),
                });
                self.generate_no_retrieval(query).await?
            }
            RagDecision::SingleSearch => {
                events.push(AgentStreamEvent::PipelineStep {
                    step: "retrieving".to_string(),
                    detail: Some("Single search retrieval...".to_string()),
                });
                let result = self.generate_single_search(query).await?;
                events.push(AgentStreamEvent::PipelineStep {
                    step: "generating".to_string(),
                    detail: Some(format!("Sources: {} documents", result.sources.len())),
                });
                result
            }
            RagDecision::MultiQuery => {
                events.push(AgentStreamEvent::PipelineStep {
                    step: "multi_query".to_string(),
                    detail: Some("Generating multiple queries...".to_string()),
                });
                let result = self.generate_multi_query(query, None).await?;
                events.push(AgentStreamEvent::PipelineStep {
                    step: "generating".to_string(),
                    detail: Some(format!("Sources: {} documents", result.sources.len())),
                });
                result
            }
        };

        // Final answer
        events.push(AgentStreamEvent::FinalAnswer {
            content: result.answer,
        });

        Ok(Box::pin(futures_util::stream::iter(events)))
    }

    // -- Routing -----------------------------------------------------------

    /// Asks the LLM to classify the query.
    ///
    /// T6 (v0.23.0): `config` carries callbacks/OTel so the routing LLM call is
    /// visible (`on_llm_start/end`); pass `None` to run untraced.
    async fn route(
        &self,
        query: &str,
        config: Option<&RunnableConfig>,
    ) -> Result<RagDecision, AdaptiveRAGError> {
        let prompt = ROUTING_PROMPT.replace("{query}", query);
        let messages = vec![Message::human(&prompt)];

        // P1-3: prefer structured routing via tool_calls, falling back to text parsing.
        let structured = crate::structured::chat_structured(
            &self.llm,
            Some(route_tool()),
            messages,
            config.cloned(),
            &crate::retry::RetryConfig::default(),
        )
        .await
        .map_err(|e| AdaptiveRAGError::Llm(e.to_string()))?;

        if let Some(args) = &structured.tool_args {
            if let Some(decision) = args.get("decision").and_then(|v| v.as_str()) {
                return parse_decision(decision);
            }
        }
        parse_decision(&structured.content)
    }

    // -- No retrieval ------------------------------------------------------

    /// Generates an answer without any retrieval.
    async fn generate_no_retrieval(
        &self,
        query: &str,
    ) -> Result<AdaptiveRAGResult, AdaptiveRAGError> {
        let messages = vec![Message::human(query)];
        let result = self
            .llm
            .chat_with_system(GENERATE_SYSTEM_PROMPT.to_string(), messages)
            .await
            .map_err(|e| AdaptiveRAGError::Llm(e.to_string()))?;

        Ok(AdaptiveRAGResult {
            answer: result.content,
            decision: RagDecision::NoRetrieval,
            sources: Vec::new(),
        })
    }

    // -- Single search -----------------------------------------------------

    /// Retrieves documents with a single query and generates an answer.
    async fn generate_single_search(
        &self,
        query: &str,
    ) -> Result<AdaptiveRAGResult, AdaptiveRAGError> {
        let docs = self.retriever.retrieve(query, self.retrieve_k).await?;

        let context = build_context(&docs);
        let user_msg = format!("Context:\n{}\n\nQuestion: {}\n\nAnswer:", context, query);
        let messages = vec![Message::human(&user_msg)];

        let result = self
            .llm
            .chat_with_system(GENERATE_SYSTEM_PROMPT.to_string(), messages)
            .await
            .map_err(|e| AdaptiveRAGError::Llm(e.to_string()))?;

        Ok(AdaptiveRAGResult {
            answer: result.content,
            decision: RagDecision::SingleSearch,
            sources: docs,
        })
    }

    // -- Multi query -------------------------------------------------------

    /// Generates multiple query variants, retrieves for each, merges, and
    /// generates an answer.
    async fn generate_multi_query(
        &self,
        query: &str,
        config: Option<&RunnableConfig>,
    ) -> Result<AdaptiveRAGResult, AdaptiveRAGError> {
        let alternative_queries = self.generate_queries(query, config).await?;

        // Combine original + alternatives.
        let all_queries: Vec<String> = std::iter::once(query.to_string())
            .chain(alternative_queries)
            .collect();

        // Retrieve and merge.
        let docs = self.retrieve_and_merge(&all_queries).await?;

        let context = build_context(&docs);
        let user_msg = format!("Context:\n{}\n\nQuestion: {}\n\nAnswer:", context, query);
        let messages = vec![Message::human(&user_msg)];

        let result = self
            .llm
            .chat_with_system(GENERATE_SYSTEM_PROMPT.to_string(), messages)
            .await
            .map_err(|e| AdaptiveRAGError::Llm(e.to_string()))?;

        Ok(AdaptiveRAGResult {
            answer: result.content,
            decision: RagDecision::MultiQuery,
            sources: docs,
        })
    }

    /// Asks the LLM to generate alternative query variants.
    ///
    /// T6 (v0.23.0): `config` carries callbacks/OTel so the query-planning LLM
    /// call is visible; pass `None` to run untraced.
    async fn generate_queries(
        &self,
        query: &str,
        config: Option<&RunnableConfig>,
    ) -> Result<Vec<String>, AdaptiveRAGError> {
        let prompt = MULTI_QUERY_PROMPT
            .replace("{count}", &self.multi_query_count.to_string())
            .replace("{question}", query);

        let messages = vec![Message::human(&prompt)];
        let result = crate::retry::retry_chat(
            &self.llm,
            messages,
            config.cloned(),
            &crate::retry::RetryConfig::default(),
        )
        .await
        .map_err(|e| AdaptiveRAGError::Llm(e.to_string()))?;

        let queries: Vec<String> = result
            .content
            .lines()
            .filter(|line| !line.trim().is_empty())
            .map(|line| line.trim().to_string())
            .collect();

        Ok(queries)
    }

    /// Retrieves documents for each query and merges/deduplicates by content.
    async fn retrieve_and_merge(
        &self,
        queries: &[String],
    ) -> Result<Vec<Document>, AdaptiveRAGError> {
        // M11: Parallel retrieval instead of sequential.
        let futures: Vec<_> = queries
            .iter()
            .map(|q| self.retriever.retrieve(q, self.retrieve_k))
            .collect();
        let all_results = futures_util::future::join_all(futures).await;

        let mut seen_content: std::collections::HashSet<String> = std::collections::HashSet::new();
        let mut merged: Vec<Document> = Vec::new();

        for result in all_results {
            let docs = result?;
            for doc in docs {
                // M6 fix: deduplicate by full content hash instead of first 80 chars
                // to avoid false collisions on documents with common prefixes.
                let key = {
                    use std::hash::Hasher;
                    let mut hasher = std::collections::hash_map::DefaultHasher::new();
                    hasher.write(doc.content.as_bytes());
                    format!("{:016x}", hasher.finish())
                };
                if seen_content.insert(key) {
                    merged.push(doc);
                }
            }
        }

        Ok(merged)
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Routing tool definition: forces the LLM to emit a three-way decision (P1-3).
fn route_tool() -> ToolDefinition {
    ToolDefinition::new(
        "route_decision",
        "判断查询是否需要检索及检索策略:no_retrieval / single_search / multi_query",
    )
    .with_parameters(json!({
        "type": "object",
        "properties": {
            "decision": {
                "type": "string",
                "enum": ["no_retrieval", "single_search", "multi_query"]
            }
        },
        "required": ["decision"]
    }))
}

/// Parses the routing decision from the LLM response.
fn parse_decision(response: &str) -> Result<RagDecision, AdaptiveRAGError> {
    let lower = response.to_lowercase();

    // Check for exact or contained keywords, with precedence.
    if lower.contains("no_retrieval") {
        return Ok(RagDecision::NoRetrieval);
    }
    if lower.contains("multi_query") {
        return Ok(RagDecision::MultiQuery);
    }
    if lower.contains("single_search") {
        return Ok(RagDecision::SingleSearch);
    }

    Err(AdaptiveRAGError::DecisionParse(response.to_string()))
}

/// Builds a context string from source documents.
fn build_context(docs: &[Document]) -> String {
    docs.iter()
        .enumerate()
        .map(|(i, doc)| format!("[Document {}]: {}", i + 1, doc.content))
        .collect::<Vec<_>>()
        .join("\n\n")
}