1use crate::embedding::EmbeddingModel;
2use crate::error::AiError;
3use crate::vector::VectorStore;
4
5pub struct RagConfig {
6 pub collection_name: String,
7 pub chunk_size: usize,
8 pub chunk_overlap: usize,
9 pub top_k: usize,
10}
11
12impl Default for RagConfig {
13 fn default() -> Self {
14 Self {
15 collection_name: "rag_documents".to_string(),
16 chunk_size: 512,
17 chunk_overlap: 50,
18 top_k: 3,
19 }
20 }
21}
22
23impl RagConfig {
24 pub fn new(collection_name: impl Into<String>) -> Self {
25 Self {
26 collection_name: collection_name.into(),
27 ..Default::default()
28 }
29 }
30
31 pub fn with_chunk_size(mut self, size: usize) -> Self {
32 self.chunk_size = size;
33 self
34 }
35
36 pub fn with_chunk_overlap(mut self, overlap: usize) -> Self {
37 self.chunk_overlap = overlap;
38 self
39 }
40
41 pub fn with_top_k(mut self, k: usize) -> Self {
42 self.top_k = k;
43 self
44 }
45}
46
47#[derive(Debug, Clone)]
48pub struct Document {
49 pub id: String,
50 pub content: String,
51 pub source: Option<String>,
52 pub metadata: std::collections::HashMap<String, serde_json::Value>,
53}
54
55impl Document {
56 pub fn new(id: impl Into<String>, content: impl Into<String>) -> Self {
57 Self {
58 id: id.into(),
59 content: content.into(),
60 source: None,
61 metadata: std::collections::HashMap::new(),
62 }
63 }
64
65 pub fn with_source(mut self, source: impl Into<String>) -> Self {
66 self.source = Some(source.into());
67 self
68 }
69
70 pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
71 self.metadata.insert(key.into(), value);
72 self
73 }
74}
75
76#[derive(Debug, Clone)]
77pub struct Chunk {
78 pub id: String,
79 pub document_id: String,
80 pub content: String,
81 pub index: usize,
82 pub start_char: usize,
83 pub end_char: usize,
84 pub metadata: std::collections::HashMap<String, serde_json::Value>,
85}
86
87impl Chunk {
88 pub fn new(
89 id: impl Into<String>,
90 document_id: impl Into<String>,
91 content: impl Into<String>,
92 index: usize,
93 start_char: usize,
94 end_char: usize,
95 ) -> Self {
96 Self {
97 id: id.into(),
98 document_id: document_id.into(),
99 content: content.into(),
100 index,
101 start_char,
102 end_char,
103 metadata: std::collections::HashMap::new(),
104 }
105 }
106
107 pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
108 self.metadata.insert(key.into(), value);
109 self
110 }
111}
112
113pub struct RagEngine<E, V>
114where
115 E: EmbeddingModel,
116 V: VectorStore,
117{
118 embedding_model: E,
119 vector_store: V,
120 config: RagConfig,
121}
122
123impl<E, V> RagEngine<E, V>
124where
125 E: EmbeddingModel,
126 V: VectorStore,
127{
128 pub fn new(embedding_model: E, vector_store: V, config: RagConfig) -> Self {
129 Self {
130 embedding_model,
131 vector_store,
132 config,
133 }
134 }
135
136 pub fn with_config(mut self, config: RagConfig) -> Self {
137 self.config = config;
138 self
139 }
140
141 pub async fn index_documents(&self, documents: Vec<Document>) -> Result<usize, AiError> {
142 let collection = &self.config.collection_name;
143 let dimension = self.embedding_model.dimension();
144
145 self.vector_store
146 .create_collection(collection, dimension, None)
147 .await
148 .map_err(|e| AiError::Vector(e.to_string()))?;
149
150 let chunks = self.split_documents(documents);
151 let mut total_indexed = 0;
152
153 for chunk in chunks {
154 let vector = self.embedding_model.embed(&chunk.content).await?;
155
156 let record = crate::vector::VectorRecord::new(chunk.id.clone(), vector)
157 .with_metadata(chunk.metadata);
158
159 self.vector_store
160 .insert(collection, vec![record])
161 .await
162 .map_err(|e| AiError::Vector(e.to_string()))?;
163
164 total_indexed += 1;
165 }
166
167 Ok(total_indexed)
168 }
169
170 fn split_documents(&self, documents: Vec<Document>) -> Vec<Chunk> {
171 let mut chunks = Vec::new();
172
173 for document in documents {
174 let content = document.content.as_str();
175 let chars: Vec<char> = content.chars().collect();
176 let chunk_size = self.config.chunk_size;
177 let overlap = self.config.chunk_overlap;
178
179 let mut start = 0;
180 let mut index = 0;
181
182 while start < chars.len() {
183 let end = (start + chunk_size).min(chars.len());
184 let chunk_content: String = chars[start..end].iter().collect();
185
186 if !chunk_content.trim().is_empty() {
187 let chunk = Chunk::new(
188 format!("{}_{}", document.id, index),
189 document.id.clone(),
190 chunk_content,
191 index,
192 start,
193 end,
194 )
195 .with_metadata("source", document.source.clone().unwrap_or_default().into());
196
197 chunks.push(chunk);
198 }
199
200 if end == chars.len() {
201 break;
202 }
203
204 start = end - overlap.min(end);
205 index += 1;
206 }
207 }
208
209 chunks
210 }
211
212 pub async fn search(
213 &self,
214 query: &str,
215 filter: Option<&str>,
216 ) -> Result<Vec<RagSearchResult>, AiError> {
217 let query_vector = self.embedding_model.embed(query).await?;
218
219 let results = self
220 .vector_store
221 .search(
222 &self.config.collection_name,
223 &query_vector,
224 self.config.top_k,
225 filter,
226 )
227 .await
228 .map_err(|e| AiError::Vector(e.to_string()))?;
229
230 Ok(results
231 .into_iter()
232 .map(|r| RagSearchResult {
233 id: r.id,
234 score: r.score,
235 content: r.text.unwrap_or_default(),
236 metadata: r.metadata.unwrap_or_default(),
237 })
238 .collect())
239 }
240
241 pub async fn delete_document(&self, document_id: &str) -> Result<u64, AiError> {
242 let count = self
243 .vector_store
244 .count(&self.config.collection_name)
245 .await
246 .map_err(|e| AiError::Vector(e.to_string()))?;
247
248 if count == 0 {
249 return Ok(0);
250 }
251
252 let dimension = self.embedding_model.dimension();
253 let dummy_vector = vec![0.0; dimension];
254
255 let all_results = self
256 .vector_store
257 .search(&self.config.collection_name, &dummy_vector, count, None)
258 .await
259 .map_err(|e| AiError::Vector(e.to_string()))?;
260
261 let to_delete: Vec<String> = all_results
262 .into_iter()
263 .filter(|r| r.id.starts_with(document_id))
264 .map(|r| r.id)
265 .collect();
266
267 if to_delete.is_empty() {
268 return Ok(0);
269 }
270
271 self.vector_store
272 .delete(&self.config.collection_name, to_delete)
273 .await
274 .map_err(|e| AiError::Vector(e.to_string()))
275 }
276}
277
278#[derive(Debug, Clone)]
279pub struct RagSearchResult {
280 pub id: String,
281 pub score: f32,
282 pub content: String,
283 pub metadata: std::collections::HashMap<String, serde_json::Value>,
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
290pub enum TruncationStrategy {
291 #[default]
293 BestFirst,
294 FirstFirst,
296 Uniform,
298}
299
300impl TruncationStrategy {
301 pub fn as_str(&self) -> &'static str {
303 match self {
304 TruncationStrategy::BestFirst => "best_first",
305 TruncationStrategy::FirstFirst => "first_first",
306 TruncationStrategy::Uniform => "uniform",
307 }
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
313pub enum TokenCountStrategy {
314 #[default]
316 CharApprox,
317 WhitespaceSplit,
319 CharDiv4,
321}
322
323#[derive(Debug, Clone)]
325pub struct ContextWindowConfig {
326 pub max_tokens: usize,
328 pub system_prompt_tokens: usize,
330 pub query_tokens: usize,
332 pub answer_tokens: usize,
334 pub strategy: TruncationStrategy,
336 pub token_strategy: TokenCountStrategy,
338 pub separator: String,
340 pub include_source: bool,
342}
343
344impl Default for ContextWindowConfig {
345 fn default() -> Self {
346 Self {
347 max_tokens: 4096,
348 system_prompt_tokens: 500,
349 query_tokens: 200,
350 answer_tokens: 1000,
351 strategy: TruncationStrategy::BestFirst,
352 token_strategy: TokenCountStrategy::CharApprox,
353 separator: "\n\n---\n\n".to_string(),
354 include_source: true,
355 }
356 }
357}
358
359impl ContextWindowConfig {
360 pub fn new(max_tokens: usize) -> Self {
362 Self {
363 max_tokens,
364 ..Default::default()
365 }
366 }
367
368 pub fn with_system_prompt_tokens(mut self, tokens: usize) -> Self {
370 self.system_prompt_tokens = tokens;
371 self
372 }
373
374 pub fn with_query_tokens(mut self, tokens: usize) -> Self {
376 self.query_tokens = tokens;
377 self
378 }
379
380 pub fn with_answer_tokens(mut self, tokens: usize) -> Self {
382 self.answer_tokens = tokens;
383 self
384 }
385
386 pub fn with_strategy(mut self, strategy: TruncationStrategy) -> Self {
388 self.strategy = strategy;
389 self
390 }
391
392 pub fn with_token_strategy(mut self, strategy: TokenCountStrategy) -> Self {
394 self.token_strategy = strategy;
395 self
396 }
397
398 pub fn with_separator(mut self, separator: impl Into<String>) -> Self {
400 self.separator = separator.into();
401 self
402 }
403
404 pub fn with_source(mut self, include: bool) -> Self {
406 self.include_source = include;
407 self
408 }
409
410 pub fn available_context_tokens(&self) -> usize {
412 let reserved = self.system_prompt_tokens + self.query_tokens + self.answer_tokens;
413 self.max_tokens.saturating_sub(reserved)
414 }
415}
416
417#[derive(Debug, Clone)]
419pub struct ContextWindowResult {
420 pub context: String,
422 pub used_tokens: usize,
424 pub included_chunks: usize,
426 pub truncated_chunks: usize,
428 pub dropped_chunks: usize,
430 pub chunk_tokens: Vec<usize>,
432}
433
434impl ContextWindowResult {
435 pub fn has_truncation(&self) -> bool {
437 self.truncated_chunks > 0
438 }
439
440 pub fn has_drops(&self) -> bool {
442 self.dropped_chunks > 0
443 }
444
445 pub fn utilization(&self, available: usize) -> f32 {
447 if available == 0 {
448 return 0.0;
449 }
450 self.used_tokens as f32 / available as f32
451 }
452}
453
454pub struct ContextWindowManager {
459 config: ContextWindowConfig,
461}
462
463impl ContextWindowManager {
464 pub fn new(config: ContextWindowConfig) -> Self {
466 Self { config }
467 }
468
469 pub fn with_defaults() -> Self {
471 Self::new(ContextWindowConfig::default())
472 }
473
474 pub fn config(&self) -> &ContextWindowConfig {
476 &self.config
477 }
478
479 pub fn count_tokens(&self, text: &str) -> usize {
481 match self.config.token_strategy {
482 TokenCountStrategy::CharApprox => {
483 let chinese_count = text.chars().filter(|c| !c.is_ascii()).count();
485 let ascii_count = text.chars().filter(|c| c.is_ascii()).count();
486 (chinese_count as f32 * 1.5).ceil() as usize + (ascii_count / 4)
487 }
488 TokenCountStrategy::WhitespaceSplit => text.split_whitespace().count(),
489 TokenCountStrategy::CharDiv4 => text.chars().count() / 4,
490 }
491 }
492
493 fn separator_tokens(&self) -> usize {
495 self.count_tokens(&self.config.separator)
496 }
497
498 fn format_chunk(&self, result: &RagSearchResult) -> String {
500 if self.config.include_source {
501 let source = result
502 .metadata
503 .get("source")
504 .and_then(|v| v.as_str())
505 .unwrap_or("unknown");
506 format!("[source: {}]\n{}", source, result.content)
507 } else {
508 result.content.clone()
509 }
510 }
511
512 pub fn assemble(&self, mut results: Vec<RagSearchResult>) -> ContextWindowResult {
518 let available = self.config.available_context_tokens();
519 let separator_tokens = self.separator_tokens();
520
521 if self.config.strategy == TruncationStrategy::BestFirst {
523 results.sort_by(|a, b| {
524 b.score
525 .partial_cmp(&a.score)
526 .unwrap_or(std::cmp::Ordering::Equal)
527 });
528 }
529
530 let total_chunks = results.len();
531 let mut context_parts: Vec<String> = Vec::new();
532 let mut chunk_tokens: Vec<usize> = Vec::new();
533 let mut used_tokens = 0usize;
534 let mut included_chunks = 0usize;
535 let mut truncated_chunks = 0usize;
536
537 for (idx, result) in results.iter().enumerate() {
538 let formatted = self.format_chunk(result);
539 let chunk_token_count = self.count_tokens(&formatted);
540
541 let additional = if idx > 0 {
543 chunk_token_count + separator_tokens
544 } else {
545 chunk_token_count
546 };
547
548 if used_tokens + additional <= available {
549 context_parts.push(formatted.clone());
551 chunk_tokens.push(chunk_token_count);
552 used_tokens += additional;
553 included_chunks += 1;
554 } else {
555 let remaining = available.saturating_sub(used_tokens);
557 if remaining > separator_tokens + 10 {
558 let target_content_tokens = remaining.saturating_sub(separator_tokens);
560 let truncated = self.truncate_text(&formatted, target_content_tokens);
561 let truncated_tokens = self.count_tokens(&truncated);
562
563 if truncated_tokens > 0 {
564 context_parts.push(truncated);
565 chunk_tokens.push(truncated_tokens);
566 used_tokens +=
567 truncated_tokens + if idx > 0 { separator_tokens } else { 0 };
568 included_chunks += 1;
569 truncated_chunks += 1;
570 }
571 break;
573 } else {
574 break;
575 }
576 }
577 }
578
579 let dropped_chunks = total_chunks.saturating_sub(included_chunks);
580 let context = context_parts.join(&self.config.separator);
581
582 ContextWindowResult {
583 context,
584 used_tokens,
585 included_chunks,
586 truncated_chunks,
587 dropped_chunks,
588 chunk_tokens,
589 }
590 }
591
592 pub fn assemble_uniform(&self, mut results: Vec<RagSearchResult>) -> ContextWindowResult {
594 if self.config.strategy != TruncationStrategy::Uniform {
595 }
597
598 let available = self.config.available_context_tokens();
599 let separator_tokens = self.separator_tokens();
600
601 results.sort_by(|a, b| {
602 b.score
603 .partial_cmp(&a.score)
604 .unwrap_or(std::cmp::Ordering::Equal)
605 });
606
607 let total_chunks = results.len();
608 if total_chunks == 0 {
609 return ContextWindowResult {
610 context: String::new(),
611 used_tokens: 0,
612 included_chunks: 0,
613 truncated_chunks: 0,
614 dropped_chunks: 0,
615 chunk_tokens: Vec::new(),
616 };
617 }
618
619 let formatted: Vec<String> = results.iter().map(|r| self.format_chunk(r)).collect();
621 let token_counts: Vec<usize> = formatted.iter().map(|f| self.count_tokens(f)).collect();
622 let total_content_tokens: usize = token_counts.iter().sum();
623 let total_separator_tokens = separator_tokens * (total_chunks.saturating_sub(1));
624 let total_tokens = total_content_tokens + total_separator_tokens;
625
626 if total_tokens <= available {
627 let context = formatted.join(&self.config.separator);
629 return ContextWindowResult {
630 context,
631 used_tokens: total_tokens,
632 included_chunks: total_chunks,
633 truncated_chunks: 0,
634 dropped_chunks: 0,
635 chunk_tokens: token_counts,
636 };
637 }
638
639 let available_per_chunk = available / total_chunks;
641 let mut context_parts: Vec<String> = Vec::new();
642 let mut chunk_tokens_result: Vec<usize> = Vec::new();
643 let mut used_tokens = 0usize;
644 let mut truncated_chunks = 0usize;
645
646 for (idx, formatted_text) in formatted.iter().enumerate() {
647 let target =
648 available_per_chunk.saturating_sub(if idx > 0 { separator_tokens } else { 0 });
649 let original_tokens = token_counts[idx];
650
651 if original_tokens <= target {
652 context_parts.push(formatted_text.clone());
653 chunk_tokens_result.push(original_tokens);
654 used_tokens += original_tokens + if idx > 0 { separator_tokens } else { 0 };
655 } else {
656 let truncated = self.truncate_text(formatted_text, target);
657 let truncated_tokens = self.count_tokens(&truncated);
658 context_parts.push(truncated);
659 chunk_tokens_result.push(truncated_tokens);
660 used_tokens += truncated_tokens + if idx > 0 { separator_tokens } else { 0 };
661 truncated_chunks += 1;
662 }
663 }
664
665 let context = context_parts.join(&self.config.separator);
666 let included_chunks = total_chunks;
667
668 ContextWindowResult {
669 context,
670 used_tokens,
671 included_chunks,
672 truncated_chunks,
673 dropped_chunks: 0,
674 chunk_tokens: chunk_tokens_result,
675 }
676 }
677
678 fn truncate_text(&self, text: &str, target_tokens: usize) -> String {
680 if target_tokens == 0 {
681 return String::new();
682 }
683
684 match self.config.token_strategy {
685 TokenCountStrategy::CharApprox => {
686 let chars: Vec<char> = text.chars().collect();
688 let mut result = String::new();
689 let mut token_count: f64 = 0.0;
690 let target = target_tokens as f64;
691 for ch in chars {
692 let char_tokens = if ch.is_ascii() { 0.25 } else { 1.5 };
693 if token_count + char_tokens > target {
694 break;
695 }
696 result.push(ch);
697 token_count += char_tokens;
698 }
699 if result.len() < text.len() {
700 result.push_str("...");
701 }
702 result
703 }
704 TokenCountStrategy::WhitespaceSplit => {
705 let words: Vec<&str> = text.split_whitespace().collect();
706 let truncated: Vec<&str> = words.into_iter().take(target_tokens).collect();
707 let mut result = truncated.join(" ");
708 if result.len() < text.len() {
709 result.push_str("...");
710 }
711 result
712 }
713 TokenCountStrategy::CharDiv4 => {
714 let char_count = target_tokens * 4;
715 let chars: Vec<char> = text.chars().take(char_count).collect();
716 let mut result: String = chars.into_iter().collect();
717 if result.len() < text.len() {
718 result.push_str("...");
719 }
720 result
721 }
722 }
723 }
724
725 pub fn build_prompt(
727 &self,
728 system_prompt: &str,
729 context_result: &ContextWindowResult,
730 user_query: &str,
731 ) -> String {
732 let mut prompt = String::new();
733 prompt.push_str(system_prompt);
734 prompt.push_str("\n\n=== Context ===\n");
735 prompt.push_str(&context_result.context);
736 prompt.push_str("\n\n=== Question ===\n");
737 prompt.push_str(user_query);
738 prompt
739 }
740
741 pub fn estimate_prompt_tokens(
743 &self,
744 system_prompt: &str,
745 context_result: &ContextWindowResult,
746 user_query: &str,
747 ) -> usize {
748 let system_tokens = self.count_tokens(system_prompt);
749 let query_tokens = self.count_tokens(user_query);
750 system_tokens + context_result.used_tokens + query_tokens
751 }
752}
753
754impl Default for ContextWindowManager {
755 fn default() -> Self {
756 Self::with_defaults()
757 }
758}
759
760#[cfg(test)]
763mod tests {
764 use super::*;
765
766 fn make_result(id: &str, score: f32, content: &str, source: &str) -> RagSearchResult {
768 let mut metadata = std::collections::HashMap::new();
769 metadata.insert("source".to_string(), serde_json::json!(source));
770 RagSearchResult {
771 id: id.to_string(),
772 score,
773 content: content.to_string(),
774 metadata,
775 }
776 }
777
778 #[test]
781 fn test_truncation_strategy_default() {
782 assert_eq!(TruncationStrategy::default(), TruncationStrategy::BestFirst);
783 }
784
785 #[test]
786 fn test_truncation_strategy_as_str() {
787 assert_eq!(TruncationStrategy::BestFirst.as_str(), "best_first");
788 assert_eq!(TruncationStrategy::FirstFirst.as_str(), "first_first");
789 assert_eq!(TruncationStrategy::Uniform.as_str(), "uniform");
790 }
791
792 #[test]
795 fn test_token_count_strategy_default() {
796 assert_eq!(
797 TokenCountStrategy::default(),
798 TokenCountStrategy::CharApprox
799 );
800 }
801
802 #[test]
805 fn test_context_window_config_default() {
806 let config = ContextWindowConfig::default();
807 assert_eq!(config.max_tokens, 4096);
808 assert_eq!(config.system_prompt_tokens, 500);
809 assert_eq!(config.query_tokens, 200);
810 assert_eq!(config.answer_tokens, 1000);
811 assert_eq!(config.strategy, TruncationStrategy::BestFirst);
812 assert_eq!(config.token_strategy, TokenCountStrategy::CharApprox);
813 assert!(config.include_source);
814 }
815
816 #[test]
817 fn test_context_window_config_new() {
818 let config = ContextWindowConfig::new(8000);
819 assert_eq!(config.max_tokens, 8000);
820 }
821
822 #[test]
823 fn test_context_window_config_builders() {
824 let config = ContextWindowConfig::new(8000)
825 .with_system_prompt_tokens(600)
826 .with_query_tokens(300)
827 .with_answer_tokens(2000)
828 .with_strategy(TruncationStrategy::Uniform)
829 .with_token_strategy(TokenCountStrategy::WhitespaceSplit)
830 .with_separator("\n")
831 .with_source(false);
832
833 assert_eq!(config.system_prompt_tokens, 600);
834 assert_eq!(config.query_tokens, 300);
835 assert_eq!(config.answer_tokens, 2000);
836 assert_eq!(config.strategy, TruncationStrategy::Uniform);
837 assert_eq!(config.token_strategy, TokenCountStrategy::WhitespaceSplit);
838 assert_eq!(config.separator, "\n");
839 assert!(!config.include_source);
840 }
841
842 #[test]
843 fn test_available_context_tokens() {
844 let config = ContextWindowConfig::new(4096)
845 .with_system_prompt_tokens(500)
846 .with_query_tokens(200)
847 .with_answer_tokens(1000);
848 assert_eq!(config.available_context_tokens(), 2396);
849 }
850
851 #[test]
852 fn test_available_context_tokens_zero_when_reserved_exceeds() {
853 let config = ContextWindowConfig::new(100)
854 .with_system_prompt_tokens(500)
855 .with_query_tokens(200)
856 .with_answer_tokens(1000);
857 assert_eq!(config.available_context_tokens(), 0);
858 }
859
860 #[test]
863 fn test_context_window_result_has_truncation() {
864 let result = ContextWindowResult {
865 context: "test".to_string(),
866 used_tokens: 100,
867 included_chunks: 2,
868 truncated_chunks: 1,
869 dropped_chunks: 0,
870 chunk_tokens: vec![50, 50],
871 };
872 assert!(result.has_truncation());
873 }
874
875 #[test]
876 fn test_context_window_result_no_truncation() {
877 let result = ContextWindowResult {
878 context: "test".to_string(),
879 used_tokens: 100,
880 included_chunks: 2,
881 truncated_chunks: 0,
882 dropped_chunks: 0,
883 chunk_tokens: vec![50, 50],
884 };
885 assert!(!result.has_truncation());
886 }
887
888 #[test]
889 fn test_context_window_result_has_drops() {
890 let result = ContextWindowResult {
891 context: "test".to_string(),
892 used_tokens: 100,
893 included_chunks: 1,
894 truncated_chunks: 0,
895 dropped_chunks: 3,
896 chunk_tokens: vec![100],
897 };
898 assert!(result.has_drops());
899 }
900
901 #[test]
902 fn test_context_window_result_utilization() {
903 let result = ContextWindowResult {
904 context: "test".to_string(),
905 used_tokens: 500,
906 included_chunks: 2,
907 truncated_chunks: 0,
908 dropped_chunks: 0,
909 chunk_tokens: vec![250, 250],
910 };
911 assert!((result.utilization(1000) - 0.5).abs() < 1e-6);
912 }
913
914 #[test]
915 fn test_context_window_result_utilization_zero_available() {
916 let result = ContextWindowResult {
917 context: "test".to_string(),
918 used_tokens: 100,
919 included_chunks: 1,
920 truncated_chunks: 0,
921 dropped_chunks: 0,
922 chunk_tokens: vec![100],
923 };
924 assert_eq!(result.utilization(0), 0.0);
925 }
926
927 #[test]
930 fn test_context_window_manager_with_defaults() {
931 let manager = ContextWindowManager::with_defaults();
932 assert_eq!(manager.config().max_tokens, 4096);
933 }
934
935 #[test]
936 fn test_context_window_manager_default() {
937 let manager = ContextWindowManager::default();
938 assert_eq!(manager.config().max_tokens, 4096);
939 }
940
941 #[test]
942 fn test_count_tokens_char_approx_english() {
943 let manager = ContextWindowManager::with_defaults();
944 let tokens = manager.count_tokens("hello world");
946 assert_eq!(tokens, 2);
947 }
948
949 #[test]
950 fn test_count_tokens_char_approx_chinese() {
951 let manager = ContextWindowManager::with_defaults();
952 let tokens = manager.count_tokens("你好世界");
954 assert_eq!(tokens, 6);
955 }
956
957 #[test]
958 fn test_count_tokens_whitespace_split() {
959 let config =
960 ContextWindowConfig::default().with_token_strategy(TokenCountStrategy::WhitespaceSplit);
961 let manager = ContextWindowManager::new(config);
962 let tokens = manager.count_tokens("hello world foo bar");
963 assert_eq!(tokens, 4);
964 }
965
966 #[test]
967 fn test_count_tokens_char_div4() {
968 let config =
969 ContextWindowConfig::default().with_token_strategy(TokenCountStrategy::CharDiv4);
970 let manager = ContextWindowManager::new(config);
971 let tokens = manager.count_tokens("hello world!");
972 assert_eq!(tokens, 12 / 4); }
974
975 #[test]
976 fn test_assemble_empty_results() {
977 let manager = ContextWindowManager::with_defaults();
978 let result = manager.assemble(Vec::new());
979 assert!(result.context.is_empty());
980 assert_eq!(result.used_tokens, 0);
981 assert_eq!(result.included_chunks, 0);
982 }
983
984 #[test]
985 fn test_assemble_all_fit() {
986 let config = ContextWindowConfig::new(10000)
987 .with_system_prompt_tokens(0)
988 .with_query_tokens(0)
989 .with_answer_tokens(0)
990 .with_source(false);
991 let manager = ContextWindowManager::new(config);
992
993 let results = vec![
994 make_result("r1", 0.9, "hello world", "doc1"),
995 make_result("r2", 0.8, "foo bar baz", "doc2"),
996 ];
997 let result = manager.assemble(results);
998
999 assert_eq!(result.included_chunks, 2);
1000 assert_eq!(result.truncated_chunks, 0);
1001 assert_eq!(result.dropped_chunks, 0);
1002 assert!(!result.context.is_empty());
1003 }
1004
1005 #[test]
1006 fn test_assemble_best_first_sorts_by_score() {
1007 let config = ContextWindowConfig::new(10000)
1008 .with_system_prompt_tokens(0)
1009 .with_query_tokens(0)
1010 .with_answer_tokens(0)
1011 .with_source(false);
1012 let manager = ContextWindowManager::new(config);
1013
1014 let results = vec![
1015 make_result("r1", 0.5, "low score content", "doc1"),
1016 make_result("r2", 0.9, "high score content", "doc2"),
1017 ];
1018 let result = manager.assemble(results);
1019
1020 assert!(result.context.starts_with("high score content"));
1022 }
1023
1024 #[test]
1025 fn test_assemble_drops_when_exceeds_budget() {
1026 let config = ContextWindowConfig::new(100)
1027 .with_system_prompt_tokens(0)
1028 .with_query_tokens(0)
1029 .with_answer_tokens(0)
1030 .with_source(false)
1031 .with_separator("\n");
1032 let manager = ContextWindowManager::new(config);
1033
1034 let results = vec![
1035 make_result("r1", 0.9, &"a".repeat(500), "doc1"),
1036 make_result("r2", 0.8, &"b".repeat(500), "doc2"),
1037 ];
1038 let result = manager.assemble(results);
1039
1040 assert!(result.included_chunks <= 2);
1042 assert!(
1045 result.truncated_chunks + result.dropped_chunks >= 1,
1046 "expected at least one truncated or dropped chunk when budget exceeded, got truncated={} dropped={}",
1047 result.truncated_chunks,
1048 result.dropped_chunks
1049 );
1050 }
1051
1052 #[test]
1053 fn test_assemble_truncates_last_chunk() {
1054 let config = ContextWindowConfig::new(100)
1055 .with_system_prompt_tokens(0)
1056 .with_query_tokens(0)
1057 .with_answer_tokens(0)
1058 .with_source(false)
1059 .with_separator("\n");
1060 let manager = ContextWindowManager::new(config);
1061
1062 let results = vec![
1064 make_result("r1", 0.9, "small", "doc1"),
1065 make_result("r2", 0.8, &"x".repeat(500), "doc2"),
1066 ];
1067 let result = manager.assemble(results);
1068
1069 assert!(result.has_truncation() || result.has_drops());
1071 }
1072
1073 #[test]
1074 fn test_assemble_includes_source_when_configured() {
1075 let config = ContextWindowConfig::new(10000)
1076 .with_system_prompt_tokens(0)
1077 .with_query_tokens(0)
1078 .with_answer_tokens(0)
1079 .with_source(true);
1080 let manager = ContextWindowManager::new(config);
1081
1082 let results = vec![make_result("r1", 0.9, "content here", "mydoc")];
1083 let result = manager.assemble(results);
1084
1085 assert!(result.context.contains("source: mydoc"));
1086 }
1087
1088 #[test]
1089 fn test_assemble_excludes_source_when_disabled() {
1090 let config = ContextWindowConfig::new(10000)
1091 .with_system_prompt_tokens(0)
1092 .with_query_tokens(0)
1093 .with_answer_tokens(0)
1094 .with_source(false);
1095 let manager = ContextWindowManager::new(config);
1096
1097 let results = vec![make_result("r1", 0.9, "content here", "mydoc")];
1098 let result = manager.assemble(results);
1099
1100 assert!(!result.context.contains("source: mydoc"));
1101 }
1102
1103 #[test]
1104 fn test_assemble_uniform_all_fit() {
1105 let config = ContextWindowConfig::new(10000)
1106 .with_system_prompt_tokens(0)
1107 .with_query_tokens(0)
1108 .with_answer_tokens(0)
1109 .with_source(false);
1110 let manager = ContextWindowManager::new(config);
1111
1112 let results = vec![
1113 make_result("r1", 0.9, "hello", "doc1"),
1114 make_result("r2", 0.8, "world", "doc2"),
1115 ];
1116 let result = manager.assemble_uniform(results);
1117
1118 assert_eq!(result.included_chunks, 2);
1119 assert_eq!(result.truncated_chunks, 0);
1120 }
1121
1122 #[test]
1123 fn test_assemble_uniform_truncates_all() {
1124 let config = ContextWindowConfig::new(20)
1125 .with_system_prompt_tokens(0)
1126 .with_query_tokens(0)
1127 .with_answer_tokens(0)
1128 .with_source(false)
1129 .with_separator("");
1130 let manager = ContextWindowManager::new(config);
1131
1132 let results = vec![
1133 make_result("r1", 0.9, &"a".repeat(100), "doc1"),
1134 make_result("r2", 0.8, &"b".repeat(100), "doc2"),
1135 ];
1136 let result = manager.assemble_uniform(results);
1137
1138 assert!(result.truncated_chunks >= 1);
1140 assert_eq!(result.dropped_chunks, 0);
1141 }
1142
1143 #[test]
1144 fn test_build_prompt_structure() {
1145 let manager = ContextWindowManager::with_defaults();
1146 let context_result = ContextWindowResult {
1147 context: "some context".to_string(),
1148 used_tokens: 10,
1149 included_chunks: 1,
1150 truncated_chunks: 0,
1151 dropped_chunks: 0,
1152 chunk_tokens: vec![10],
1153 };
1154 let prompt = manager.build_prompt("You are an assistant.", &context_result, "What is X?");
1155 assert!(prompt.contains("You are an assistant."));
1156 assert!(prompt.contains("=== Context ==="));
1157 assert!(prompt.contains("some context"));
1158 assert!(prompt.contains("=== Question ==="));
1159 assert!(prompt.contains("What is X?"));
1160 }
1161
1162 #[test]
1163 fn test_estimate_prompt_tokens() {
1164 let manager = ContextWindowManager::with_defaults();
1165 let context_result = ContextWindowResult {
1166 context: "hello world".to_string(),
1167 used_tokens: 2,
1168 included_chunks: 1,
1169 truncated_chunks: 0,
1170 dropped_chunks: 0,
1171 chunk_tokens: vec![2],
1172 };
1173 let total = manager.estimate_prompt_tokens("system prompt", &context_result, "query");
1174 assert!(total > 0);
1175 }
1176
1177 #[test]
1178 fn test_assemble_single_chunk_fits() {
1179 let config = ContextWindowConfig::new(10000)
1180 .with_system_prompt_tokens(0)
1181 .with_query_tokens(0)
1182 .with_answer_tokens(0)
1183 .with_source(false);
1184 let manager = ContextWindowManager::new(config);
1185
1186 let results = vec![make_result("r1", 1.0, "single chunk content", "doc1")];
1187 let result = manager.assemble(results);
1188
1189 assert_eq!(result.included_chunks, 1);
1190 assert_eq!(result.dropped_chunks, 0);
1191 assert_eq!(result.truncated_chunks, 0);
1192 }
1193
1194 #[test]
1195 fn test_assemble_uses_separator() {
1196 let config = ContextWindowConfig::new(10000)
1197 .with_system_prompt_tokens(0)
1198 .with_query_tokens(0)
1199 .with_answer_tokens(0)
1200 .with_source(false)
1201 .with_separator("|||");
1202 let manager = ContextWindowManager::new(config);
1203
1204 let results = vec![
1205 make_result("r1", 0.9, "chunk1", "doc1"),
1206 make_result("r2", 0.8, "chunk2", "doc2"),
1207 ];
1208 let result = manager.assemble(results);
1209
1210 assert!(result.context.contains("|||"));
1211 }
1212}