Skip to content

Commit bc52a4b

Browse files
committed
feat: add memoization support for LLM operations in retrieval pipeline
- Add MemoOpType variants for NodeEvaluation, SufficiencyCheck, ComplexityDetection, and QueryDecomposition - Implement caching for complexity detection with new ComplexityDetector memo store integration - Add query decomposition caching with serialization/deserialization logic for DecompositionResult - Integrate memo stores across retrieval stages (analyze, evaluate, search) to cache LLM evaluations - Add NodeEvaluation caching for both single and batch LLM evaluations - Implement sufficiency check caching in LlmJudge component - Update module exports to include MemoOpType in public API
1 parent 4f43a48 commit bc52a4b

10 files changed

Lines changed: 450 additions & 22 deletions

File tree

rust/src/llm/memo/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ mod store;
1111
mod types;
1212

1313
pub use store::MemoStore;
14-
pub use types::{MemoKey, MemoValue, PilotDecisionValue};
14+
pub use types::{MemoKey, MemoOpType, MemoValue, PilotDecisionValue};

rust/src/llm/memo/types.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ pub enum MemoOpType {
2323
/// Content extraction result.
2424
Extraction,
2525

26+
/// LLM node evaluation during retrieval.
27+
NodeEvaluation,
28+
29+
/// Sufficiency check result.
30+
SufficiencyCheck,
31+
32+
/// Query complexity detection.
33+
ComplexityDetection,
34+
35+
/// Query decomposition.
36+
QueryDecomposition,
37+
2638
/// Custom operation type.
2739
Custom(u8),
2840
}
@@ -35,6 +47,10 @@ impl MemoOpType {
3547
MemoOpType::PilotDecision => 1,
3648
MemoOpType::QueryAnalysis => 2,
3749
MemoOpType::Extraction => 3,
50+
MemoOpType::NodeEvaluation => 4,
51+
MemoOpType::SufficiencyCheck => 5,
52+
MemoOpType::ComplexityDetection => 6,
53+
MemoOpType::QueryDecomposition => 7,
3854
MemoOpType::Custom(n) => 100 + n,
3955
}
4056
}

rust/src/retrieval/complexity/detector.rs

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,40 +9,105 @@
99
use std::collections::HashSet;
1010

1111
use super::QueryComplexity;
12+
use crate::llm::memo::{MemoKey, MemoOpType, MemoStore, MemoValue};
13+
use crate::utils::fingerprint::Fingerprint;
1214

1315
/// Query complexity detector.
1416
///
1517
/// Uses LLM for classification when available; falls back to heuristic rules.
1618
pub struct ComplexityDetector {
1719
/// Optional LLM client for LLM-based detection.
1820
llm_client: Option<crate::llm::LlmClient>,
21+
/// Memo store for caching complexity detection results.
22+
memo_store: Option<MemoStore>,
1923
}
2024

2125
impl ComplexityDetector {
2226
/// Create a new complexity detector (heuristic only).
2327
pub fn new() -> Self {
24-
Self { llm_client: None }
28+
Self {
29+
llm_client: None,
30+
memo_store: None,
31+
}
2532
}
2633

2734
/// Create with LLM client for accurate detection.
2835
pub fn with_llm_client(client: crate::llm::LlmClient) -> Self {
2936
Self {
3037
llm_client: Some(client),
38+
memo_store: None,
3139
}
3240
}
3341

42+
/// Add memo store for caching complexity detection results.
43+
pub fn with_memo_store(mut self, store: MemoStore) -> Self {
44+
self.memo_store = Some(store);
45+
self
46+
}
47+
3448
/// Detect the complexity of a query.
3549
///
3650
/// Uses LLM when available; falls back to heuristic rules.
3751
pub async fn detect(&self, query: &str) -> QueryComplexity {
38-
if let Some(ref client) = self.llm_client {
39-
if let Some(complexity) = crate::retrieval::pilot::detect_with_llm(client, query).await
52+
// Check memo cache
53+
if let Some(ref store) = self.memo_store {
54+
let cache_key = Self::build_cache_key(query);
55+
if let Some(cached) = store.get(&cache_key) {
56+
if let Some(complexity) = Self::deserialize_complexity(&cached) {
57+
return complexity;
58+
}
59+
}
60+
}
61+
62+
let result = if let Some(ref client) = self.llm_client {
63+
if let Some(complexity) =
64+
crate::retrieval::pilot::detect_with_llm(client, query).await
4065
{
41-
return complexity;
66+
complexity
67+
} else {
68+
tracing::warn!("LLM complexity detection failed, falling back to heuristic");
69+
self.detect_heuristic(query)
4270
}
43-
tracing::warn!("LLM complexity detection failed, falling back to heuristic");
71+
} else {
72+
self.detect_heuristic(query)
73+
};
74+
75+
// Cache the result
76+
if let Some(ref store) = self.memo_store {
77+
let cache_key = Self::build_cache_key(query);
78+
store.put_with_tokens(
79+
cache_key,
80+
MemoValue::Text(format!("{:?}", result)),
81+
(query.len() / 4) as u64,
82+
);
83+
}
84+
85+
result
86+
}
87+
88+
/// Build a cache key for complexity detection.
89+
fn build_cache_key(query: &str) -> MemoKey {
90+
let fp = Fingerprint::from_str(query);
91+
MemoKey {
92+
op_type: MemoOpType::ComplexityDetection,
93+
input_fp: fp,
94+
model_id: None,
95+
version: 1,
96+
context_fp: Fingerprint::zero(),
97+
}
98+
}
99+
100+
/// Deserialize a QueryComplexity from a MemoValue.
101+
fn deserialize_complexity(value: &MemoValue) -> Option<QueryComplexity> {
102+
match value {
103+
MemoValue::Text(s) => match s.as_str() {
104+
"Simple" => Some(QueryComplexity::Simple),
105+
"Medium" => Some(QueryComplexity::Medium),
106+
"Complex" => Some(QueryComplexity::Complex),
107+
_ => None,
108+
},
109+
_ => None,
44110
}
45-
self.detect_heuristic(query)
46111
}
47112

48113
/// Heuristic-based fallback: keyword matching + word count.

rust/src/retrieval/decompose.rs

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ use serde::{Deserialize, Serialize};
4848
use tracing::{debug, info};
4949

5050
use crate::llm::{LlmClient, LlmExecutor};
51+
use crate::llm::memo::{MemoKey, MemoOpType, MemoStore, MemoValue};
52+
use crate::utils::fingerprint::Fingerprint;
5153

5254
/// Sub-query resulting from decomposition.
5355
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -204,6 +206,8 @@ pub struct QueryDecomposer {
204206
llm_client: Option<LlmClient>,
205207
/// LLM executor for unified execution (optional).
206208
llm_executor: Option<LlmExecutor>,
209+
/// Memo store for caching decomposition results.
210+
memo_store: Option<MemoStore>,
207211
}
208212

209213
impl Default for QueryDecomposer {
@@ -219,6 +223,7 @@ impl QueryDecomposer {
219223
config,
220224
llm_client: None,
221225
llm_executor: None,
226+
memo_store: None,
222227
}
223228
}
224229

@@ -234,6 +239,12 @@ impl QueryDecomposer {
234239
self
235240
}
236241

242+
/// Add memo store for caching decomposition results.
243+
pub fn with_memo_store(mut self, store: MemoStore) -> Self {
244+
self.memo_store = Some(store);
245+
self
246+
}
247+
237248
/// Decompose a query into sub-queries.
238249
pub async fn decompose(&self, query: &str) -> crate::error::Result<DecompositionResult> {
239250
// Check if decomposition is needed
@@ -244,23 +255,71 @@ impl QueryDecomposer {
244255
));
245256
}
246257

258+
// Check memo cache
259+
if let Some(ref store) = self.memo_store {
260+
let cache_key = Self::build_cache_key(query);
261+
if let Some(cached) = store.get(&cache_key) {
262+
if let Some(result) = Self::deserialize_decomposition(&cached) {
263+
tracing::debug!("Memo cache hit for query decomposition");
264+
return Ok(result);
265+
}
266+
}
267+
}
268+
247269
info!("Decomposing complex query: '{}'", query);
248270

249271
// Try LLM-based decomposition if available
250-
if self.config.use_llm && (self.llm_client.is_some() || self.llm_executor.is_some()) {
272+
let result = if self.config.use_llm && (self.llm_client.is_some() || self.llm_executor.is_some()) {
251273
match self.llm_decompose(query).await {
252-
Ok(result) => return Ok(result),
274+
Ok(result) => result,
253275
Err(e) => {
254276
debug!(
255277
"LLM decomposition failed, falling back to rule-based: {}",
256278
e
257279
);
280+
self.rule_based_decompose(query)?
258281
}
259282
}
283+
} else {
284+
self.rule_based_decompose(query)?
285+
};
286+
287+
// Cache the result
288+
if let Some(ref store) = self.memo_store {
289+
let cache_key = Self::build_cache_key(query);
290+
if let Ok(json) = serde_json::to_value(&CachedDecomposition::from_result(&result)) {
291+
store.put_with_tokens(
292+
cache_key,
293+
MemoValue::Json(json),
294+
(query.len() / 4) as u64,
295+
);
296+
}
260297
}
261298

262-
// Fall back to rule-based decomposition
263-
self.rule_based_decompose(query)
299+
Ok(result)
300+
}
301+
302+
/// Build a cache key for query decomposition.
303+
fn build_cache_key(query: &str) -> MemoKey {
304+
let fp = Fingerprint::from_str(query);
305+
MemoKey {
306+
op_type: MemoOpType::QueryDecomposition,
307+
input_fp: fp,
308+
model_id: None,
309+
version: 1,
310+
context_fp: Fingerprint::zero(),
311+
}
312+
}
313+
314+
/// Deserialize a DecompositionResult from a MemoValue.
315+
fn deserialize_decomposition(value: &MemoValue) -> Option<DecompositionResult> {
316+
match value {
317+
MemoValue::Json(json) => {
318+
let cached: CachedDecomposition = serde_json::from_value(json.clone()).ok()?;
319+
Some(cached.into_result())
320+
}
321+
_ => None,
322+
}
264323
}
265324

266325
/// Check if a query should be decomposed.
@@ -536,6 +595,72 @@ fn extract_json(text: &str) -> String {
536595
text.to_string()
537596
}
538597

598+
/// Serializable decomposition result for caching.
599+
///
600+
/// Only caches the essential fields needed to reconstruct a DecompositionResult.
601+
#[derive(Debug, Clone, Serialize, Deserialize)]
602+
struct CachedSubQuery {
603+
text: String,
604+
priority: u8,
605+
query_type: String,
606+
}
607+
608+
#[derive(Debug, Clone, Serialize, Deserialize)]
609+
struct CachedDecomposition {
610+
original: String,
611+
sub_queries: Vec<CachedSubQuery>,
612+
was_decomposed: bool,
613+
reason: String,
614+
}
615+
616+
impl CachedDecomposition {
617+
fn from_result(result: &DecompositionResult) -> Self {
618+
Self {
619+
original: result.original.clone(),
620+
sub_queries: result
621+
.sub_queries
622+
.iter()
623+
.map(|sq| CachedSubQuery {
624+
text: sq.text.clone(),
625+
priority: sq.priority,
626+
query_type: format!("{:?}", sq.query_type),
627+
})
628+
.collect(),
629+
was_decomposed: result.was_decomposed,
630+
reason: result.reason.clone(),
631+
}
632+
}
633+
634+
fn into_result(self) -> DecompositionResult {
635+
let sub_queries: Vec<SubQuery> = self
636+
.sub_queries
637+
.into_iter()
638+
.map(|csq| SubQuery {
639+
text: csq.text,
640+
priority: csq.priority,
641+
query_type: match csq.query_type.as_str() {
642+
"Fact" => SubQueryType::Fact,
643+
"Explanation" => SubQueryType::Explanation,
644+
"Comparison" => SubQueryType::Comparison,
645+
"Synthesis" => SubQueryType::Synthesis,
646+
"Navigation" => SubQueryType::Navigation,
647+
_ => SubQueryType::Fact,
648+
},
649+
complexity: SubQueryComplexity::Simple,
650+
depends_on: vec![],
651+
path_constraint: None,
652+
})
653+
.collect();
654+
DecompositionResult {
655+
original: self.original,
656+
sub_queries,
657+
was_decomposed: self.was_decomposed,
658+
reason: self.reason,
659+
total_complexity: 0.5,
660+
}
661+
}
662+
}
663+
539664
/// Result aggregator for multi-turn retrieval.
540665
#[derive(Debug, Clone)]
541666
pub struct SubQueryResult {

rust/src/retrieval/pipeline_retriever.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use super::content::ContentAggregatorConfig;
1313
use super::pipeline::RetrievalOrchestrator;
1414
use super::retriever::{CostEstimate, Retriever, RetrieverError, RetrieverResult};
1515
use super::stages::{AnalyzeStage, EvaluateStage, PlanStage, SearchStage};
16+
use super::strategy::LlmStrategy;
1617
use super::stream::RetrieveEventReceiver;
1718
use super::types::{RetrieveOptions, RetrieveResponse};
1819
use crate::document::{DocumentTree, ReasoningIndex};
@@ -112,6 +113,9 @@ impl PipelineRetriever {
112113
if let Some(ref client) = self.llm_client {
113114
analyze_stage = analyze_stage.with_llm_client(client.clone());
114115
}
116+
if let Some(ref store) = self.memo_store {
117+
analyze_stage = analyze_stage.with_memo_store(store.clone());
118+
}
115119
orchestrator = orchestrator.stage(analyze_stage);
116120

117121
// Add plan stage
@@ -133,11 +137,21 @@ impl PipelineRetriever {
133137
}
134138

135139
search_stage = search_stage.with_pilot(Arc::new(pilot));
140+
141+
// Create LLM strategy with memo store for node evaluation
142+
let mut llm_strategy = LlmStrategy::new(client.clone());
143+
if let Some(ref store) = self.memo_store {
144+
llm_strategy = llm_strategy.with_memo_store(store.clone());
145+
}
146+
search_stage = search_stage.with_llm_strategy(llm_strategy);
136147
}
137148
orchestrator = orchestrator.stage(search_stage);
138149

139150
// Add evaluate stage with optional content aggregator
140151
let mut evaluate_stage = EvaluateStage::new();
152+
if let Some(ref store) = self.memo_store {
153+
evaluate_stage = evaluate_stage.with_memo_store(store.clone());
154+
}
141155
if let Some(ref client) = self.llm_client {
142156
evaluate_stage = evaluate_stage.with_llm_judge(client.clone());
143157
}

0 commit comments

Comments
 (0)