1use lc_core::language_models::BaseChatModel;
21use lc_prompts::PromptTemplate;
22use lc_providers::ProviderError;
23use lc_schema::Message;
24use lc_vector_stores::Document;
25use std::collections::HashMap;
26use std::sync::Arc;
27use tokio::sync::Semaphore;
28
29pub const CONTEXTUAL_METADATA_KEY: &str = "contextual_context";
31
32#[derive(Debug)]
34#[non_exhaustive]
35pub enum ContextualError {
36 LLMError(String),
38}
39
40impl std::fmt::Display for ContextualError {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 ContextualError::LLMError(msg) => write!(f, "LLM error: {}", msg),
44 }
45 }
46}
47
48impl std::error::Error for ContextualError {}
49
50pub struct ContextualConfig {
52 pub prompt_template: String,
54 pub max_concurrency: usize,
56}
57
58impl Default for ContextualConfig {
59 fn default() -> Self {
60 Self {
61 prompt_template: DEFAULT_CONTEXTUAL_PROMPT.to_string(),
62 max_concurrency: 4,
63 }
64 }
65}
66
67impl ContextualConfig {
68 pub fn new() -> Self {
70 Self::default()
71 }
72
73 pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
75 self.prompt_template = prompt.into();
76 self
77 }
78
79 pub fn with_max_concurrency(mut self, max_concurrency: usize) -> Self {
81 self.max_concurrency = max_concurrency.max(1);
82 self
83 }
84}
85
86const DEFAULT_CONTEXTUAL_PROMPT: &str = r#"You are preparing document chunks for a retrieval index. Write 1-2 short sentences that situate the chunk within its broader document: what the document is about and what part this chunk plays. Answer with the context sentences only — no preamble, no quotes.
87
88Chunk:
89{chunk}
90
91Context:"#;
92
93pub struct ContextualEnhancer {
99 llm: Arc<dyn BaseChatModel<Error = ProviderError> + Send + Sync>,
100 config: ContextualConfig,
101 semaphore: Arc<Semaphore>,
104}
105
106impl std::fmt::Debug for ContextualEnhancer {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct("ContextualEnhancer")
109 .field("max_concurrency", &self.config.max_concurrency)
110 .finish()
111 }
112}
113
114impl ContextualEnhancer {
115 pub fn new<L>(llm: L) -> Self
117 where
118 L: BaseChatModel + Send + Sync + 'static,
119 L::Error: Into<ProviderError>,
120 {
121 Self::new_arc(lc_providers::wrap_chat_model(llm))
122 }
123
124 pub fn new_arc(llm: Arc<dyn BaseChatModel<Error = ProviderError> + Send + Sync>) -> Self {
126 let semaphore = Arc::new(Semaphore::new(4));
127 Self {
128 llm,
129 config: ContextualConfig::default(),
130 semaphore,
131 }
132 }
133
134 pub fn with_config(mut self, config: ContextualConfig) -> Self {
136 self.semaphore = Arc::new(Semaphore::new(config.max_concurrency));
137 self.config = config;
138 self
139 }
140
141 pub fn with_max_concurrency(mut self, max_concurrency: usize) -> Self {
143 self.config.max_concurrency = max_concurrency.max(1);
144 self.semaphore = Arc::new(Semaphore::new(max_concurrency.max(1)));
145 self
146 }
147
148 pub async fn generate_context(&self, chunk: &str) -> Result<String, ContextualError> {
150 let template = PromptTemplate::new(&self.config.prompt_template);
151 let mut vars = HashMap::new();
152 vars.insert("chunk", chunk);
153 let prompt = template
154 .format(&vars)
155 .unwrap_or_else(|_| self.config.prompt_template.clone());
156
157 let response = self
158 .llm
159 .invoke(vec![Message::human(prompt)], None)
160 .await
161 .map_err(|e| ContextualError::LLMError(e.to_string()))?;
162 Ok(response.content.trim().to_string())
163 }
164
165 pub async fn enhance_document(&self, doc: &Document) -> Result<Document, ContextualError> {
172 let context = self.generate_context(&doc.content).await?;
173 let mut enhanced = Document::new(format!("{}\n{}", context, doc.content))
174 .with_metadata(CONTEXTUAL_METADATA_KEY, context.clone());
175 if let Some(id) = &doc.id {
177 enhanced = enhanced.with_id(id.clone());
178 }
179 for (k, v) in &doc.metadata {
180 enhanced.metadata.insert(k.clone(), v.clone());
181 }
182 Ok(enhanced)
183 }
184
185 pub async fn enhance_documents(&self, docs: &[Document]) -> Vec<Document> {
194 let mut handles = Vec::with_capacity(docs.len());
195 for doc in docs {
196 let doc = doc.clone();
197 let llm = self.llm.clone();
198 let prompt_template = self.config.prompt_template.clone();
199 let permit = self.semaphore.clone();
200 handles.push(tokio::spawn(async move {
201 if doc.metadata.contains_key(CONTEXTUAL_METADATA_KEY) {
202 return doc; }
204 let _permit = permit.acquire().await;
205 let template = PromptTemplate::new(&prompt_template);
206 let mut vars = HashMap::new();
207 vars.insert("chunk", doc.content.as_str());
208 let prompt = template
209 .format(&vars)
210 .unwrap_or_else(|_| prompt_template.clone());
211 match llm.invoke(vec![Message::human(prompt)], None).await {
212 Ok(response) => {
213 let context = response.content.trim().to_string();
214 if context.is_empty() {
215 log::warn!("contextual retrieval: empty context generated, keeping original content");
216 doc
217 } else {
218 let mut enhanced =
219 Document::new(format!("{}\n{}", context, doc.content))
220 .with_metadata(CONTEXTUAL_METADATA_KEY, context);
221 if let Some(id) = &doc.id {
222 enhanced = enhanced.with_id(id.clone());
223 }
224 for (k, v) in &doc.metadata {
225 enhanced.metadata.insert(k.clone(), v.clone());
226 }
227 enhanced
228 }
229 }
230 Err(e) => {
231 log::warn!(
232 "contextual retrieval: context generation failed ({}), keeping original content",
233 e
234 );
235 doc
236 }
237 }
238 }));
239 }
240 let mut out = Vec::with_capacity(handles.len());
241 for handle in handles {
242 out.push(handle.await.unwrap_or_else(|e| {
243 log::warn!(
244 "contextual retrieval: enhancement task panicked ({}), keeping placeholder",
245 e
246 );
247 Document::new("")
248 }));
249 }
250 out
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use futures_util::Stream;
258 use lc_core::language_models::{BaseLanguageModel, LLMResult, StreamChunk};
259 use lc_core::runnables::{Runnable, RunnableConfig};
260 use std::pin::Pin;
261 use std::sync::atomic::{AtomicUsize, Ordering};
262
263 #[derive(Clone)]
265 struct MockChat {
266 reply: String,
267 calls: Arc<AtomicUsize>,
268 }
269
270 impl MockChat {
271 fn new(reply: &str) -> Self {
272 Self {
273 reply: reply.to_string(),
274 calls: Arc::new(AtomicUsize::new(0)),
275 }
276 }
277 }
278
279 #[derive(Debug)]
280 struct MockChatError(String);
281
282 impl std::fmt::Display for MockChatError {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 write!(f, "mock chat error: {}", self.0)
285 }
286 }
287 impl std::error::Error for MockChatError {}
288
289 impl From<MockChatError> for ProviderError {
290 fn from(e: MockChatError) -> Self {
291 ProviderError::Config(e.to_string())
292 }
293 }
294
295 #[async_trait::async_trait]
296 impl Runnable<Vec<Message>, LLMResult> for MockChat {
297 type Error = MockChatError;
298 async fn invoke(
299 &self,
300 input: Vec<Message>,
301 _config: Option<RunnableConfig>,
302 ) -> Result<LLMResult, Self::Error> {
303 self.calls.fetch_add(1, Ordering::SeqCst);
304 let prompt = input.first().map(|m| m.content.clone()).unwrap_or_default();
305 Ok(LLMResult {
306 content: format!("{} [from: {}]", self.reply, prompt),
307 model: "mock".to_string(),
308 token_usage: None,
309 tool_calls: None,
310 thinking_content: None,
311 })
312 }
313 }
314
315 #[async_trait::async_trait]
316 impl BaseLanguageModel<Vec<Message>, LLMResult> for MockChat {
317 fn model_name(&self) -> &str {
318 "mock"
319 }
320 fn get_num_tokens(&self, t: &str) -> usize {
321 t.len()
322 }
323 fn with_temperature(self, _: f32) -> Self {
324 self
325 }
326 fn with_max_tokens(self, _: usize) -> Self {
327 self
328 }
329 }
330
331 #[async_trait::async_trait]
332 impl BaseChatModel for MockChat {
333 async fn chat(
334 &self,
335 messages: Vec<Message>,
336 config: Option<RunnableConfig>,
337 ) -> Result<LLMResult, Self::Error> {
338 <Self as Runnable<Vec<Message>, LLMResult>>::invoke(self, messages, config).await
339 }
340 async fn stream_chat(
341 &self,
342 _messages: Vec<Message>,
343 _config: Option<RunnableConfig>,
344 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
345 {
346 Err(MockChatError("no stream".to_string()))
347 }
348 }
349
350 struct FailingChat;
352
353 #[async_trait::async_trait]
354 impl Runnable<Vec<Message>, LLMResult> for FailingChat {
355 type Error = MockChatError;
356 async fn invoke(
357 &self,
358 _input: Vec<Message>,
359 _config: Option<RunnableConfig>,
360 ) -> Result<LLMResult, Self::Error> {
361 Err(MockChatError("llm down".to_string()))
362 }
363 }
364
365 #[async_trait::async_trait]
366 impl BaseLanguageModel<Vec<Message>, LLMResult> for FailingChat {
367 fn model_name(&self) -> &str {
368 "failing"
369 }
370 fn get_num_tokens(&self, t: &str) -> usize {
371 t.len()
372 }
373 fn with_temperature(self, _: f32) -> Self {
374 self
375 }
376 fn with_max_tokens(self, _: usize) -> Self {
377 self
378 }
379 }
380
381 #[async_trait::async_trait]
382 impl BaseChatModel for FailingChat {
383 async fn chat(
384 &self,
385 _messages: Vec<Message>,
386 _config: Option<RunnableConfig>,
387 ) -> Result<LLMResult, Self::Error> {
388 Err(MockChatError("llm down".to_string()))
389 }
390 async fn stream_chat(
391 &self,
392 _messages: Vec<Message>,
393 _config: Option<RunnableConfig>,
394 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
395 {
396 Err(MockChatError("no stream".to_string()))
397 }
398 }
399
400 #[test]
401 fn config_defaults_and_builders() {
402 let config = ContextualConfig::default();
403 assert_eq!(config.max_concurrency, 4);
404 assert!(config.prompt_template.contains("{chunk}"));
405
406 let config = ContextualConfig::new()
407 .with_prompt("Contextualize: {chunk}")
408 .with_max_concurrency(0); assert_eq!(config.max_concurrency, 1);
410 assert!(config.prompt_template.contains("{chunk}"));
411 }
412
413 #[tokio::test]
416 async fn enhance_document_prefixes_context_and_stores_metadata() {
417 let mock = MockChat::new("This chunk is part of the revenue report.");
418 let enhancer = ContextualEnhancer::new(mock);
419 let doc = Document::new("Revenue grew 3% year over year.");
420
421 let enhanced = enhancer.enhance_document(&doc).await.unwrap();
422
423 assert!(
424 enhanced
425 .content
426 .starts_with("This chunk is part of the revenue report."),
427 "context should prefix the original content"
428 );
429 assert!(enhanced
430 .content
431 .ends_with("Revenue grew 3% year over year."));
432 let content = &enhanced.content;
433 let expected_context_len = content.len() - doc.content.len() - "\n".len();
434 assert_eq!(
435 enhanced
436 .metadata
437 .get(CONTEXTUAL_METADATA_KEY)
438 .and_then(|v| v.as_str()),
439 Some(&content[..expected_context_len]),
440 "metadata stores exactly the generated context (the content prefix)"
441 );
442 }
443
444 #[tokio::test]
448 async fn prompt_sent_to_llm_contains_chunk() {
449 let mock = MockChat::new("ctx");
450 let enhancer = ContextualEnhancer::new(mock.clone());
451 let doc = Document::new("Revenue grew 3% year over year.");
452 let _ = enhancer.generate_context(&doc.content).await.unwrap();
453
454 let document = enhancer.enhance_document(&doc).await.unwrap();
455 let context = document
456 .metadata
457 .get(CONTEXTUAL_METADATA_KEY)
458 .and_then(|v| v.as_str())
459 .unwrap();
460 assert!(
461 context.contains("Revenue grew 3% year over year."),
462 "prompt should carry the chunk text, got: {}",
463 context
464 );
465 }
466
467 #[tokio::test]
469 async fn batch_fails_open_on_llm_error() {
470 let enhancer = ContextualEnhancer::new(FailingChat);
471 let docs = vec![Document::new("chunk a"), Document::new("chunk b")];
472
473 let out = enhancer.enhance_documents(&docs).await;
474 assert_eq!(out.len(), 2);
475 for (original, enhanced) in docs.iter().zip(out.iter()) {
476 assert_eq!(enhanced.content, original.content, "original kept");
477 assert!(
478 !enhanced.metadata.contains_key(CONTEXTUAL_METADATA_KEY),
479 "no context metadata on failure"
480 );
481 }
482 }
483
484 #[tokio::test]
486 async fn batch_enhances_each_chunk() {
487 let mock = MockChat::new("ctx");
488 let enhancer = ContextualEnhancer::new(mock.clone());
489 let docs = vec![Document::new("a"), Document::new("b"), Document::new("c")];
490
491 let out = enhancer.enhance_documents(&docs).await;
492 assert_eq!(out.len(), 3);
493 for (original, enhanced) in docs.iter().zip(out.iter()) {
494 assert!(enhanced.content.starts_with("ctx"));
495 assert!(enhanced.content.ends_with(original.content.as_str()));
496 assert!(enhanced.metadata.contains_key(CONTEXTUAL_METADATA_KEY));
497 }
498 assert_eq!(
499 mock.calls.load(Ordering::SeqCst),
500 3,
501 "one LLM call per chunk"
502 );
503 }
504
505 #[tokio::test]
507 async fn batch_is_idempotent() {
508 let mock = MockChat::new("ctx");
509 let enhancer = ContextualEnhancer::new(mock.clone());
510 let docs = vec![Document::new("a"), Document::new("b")];
511 let enhanced = enhancer.enhance_documents(&docs).await;
512 assert_eq!(mock.calls.load(Ordering::SeqCst), 2);
513
514 let rerun = enhancer.enhance_documents(&enhanced).await;
516 assert_eq!(
517 mock.calls.load(Ordering::SeqCst),
518 2,
519 "no new calls on rerun"
520 );
521 for (first, second) in enhanced.iter().zip(rerun.iter()) {
522 assert_eq!(first.content, second.content);
523 }
524 }
525
526 #[tokio::test]
528 async fn concurrency_is_bounded() {
529 #[derive(Default)]
530 struct CountingState {
531 in_flight: std::sync::Mutex<usize>,
532 max_seen: AtomicUsize,
533 }
534
535 #[derive(Clone)]
536 struct CountingChat {
537 state: Arc<CountingState>,
538 }
539
540 #[async_trait::async_trait]
541 impl Runnable<Vec<Message>, LLMResult> for CountingChat {
542 type Error = MockChatError;
543 async fn invoke(
544 &self,
545 _input: Vec<Message>,
546 _config: Option<RunnableConfig>,
547 ) -> Result<LLMResult, Self::Error> {
548 let now = {
549 let mut g = self.state.in_flight.lock().unwrap();
550 *g += 1;
551 let n = *g;
552 if n > self.state.max_seen.load(Ordering::SeqCst) {
553 self.state.max_seen.store(n, Ordering::SeqCst);
554 }
555 n
556 };
557 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
559 {
560 let mut g = self.state.in_flight.lock().unwrap();
561 *g -= 1;
562 }
563 Ok(LLMResult {
564 content: format!("ctx-{}", now),
565 model: "counting".to_string(),
566 token_usage: None,
567 tool_calls: None,
568 thinking_content: None,
569 })
570 }
571 }
572
573 #[async_trait::async_trait]
574 impl BaseLanguageModel<Vec<Message>, LLMResult> for CountingChat {
575 fn model_name(&self) -> &str {
576 "counting"
577 }
578 fn get_num_tokens(&self, t: &str) -> usize {
579 t.len()
580 }
581 fn with_temperature(self, _: f32) -> Self {
582 self
583 }
584 fn with_max_tokens(self, _: usize) -> Self {
585 self
586 }
587 }
588
589 #[async_trait::async_trait]
590 impl BaseChatModel for CountingChat {
591 async fn chat(
592 &self,
593 messages: Vec<Message>,
594 config: Option<RunnableConfig>,
595 ) -> Result<LLMResult, Self::Error> {
596 <Self as Runnable<Vec<Message>, LLMResult>>::invoke(self, messages, config).await
597 }
598 async fn stream_chat(
599 &self,
600 _messages: Vec<Message>,
601 _config: Option<RunnableConfig>,
602 ) -> Result<
603 Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>,
604 Self::Error,
605 > {
606 Err(MockChatError("no stream".to_string()))
607 }
608 }
609
610 let state = Arc::new(CountingState::default());
611 let enhancer = ContextualEnhancer::new(CountingChat {
612 state: state.clone(),
613 })
614 .with_max_concurrency(1);
615 let docs: Vec<Document> = (0..4)
616 .map(|i| Document::new(format!("chunk {}", i)))
617 .collect();
618 let out = enhancer.enhance_documents(&docs).await;
619 assert_eq!(out.len(), 4);
620 assert!(
621 state.max_seen.load(Ordering::SeqCst) <= 1,
622 "max_concurrency=1 must serialize calls"
623 );
624 }
625}