Skip to content

Commit 36478e5

Browse files
committed
feat: add memo store for caching LLM decisions
- Introduce MemoStore for caching LLM decisions based on context fingerprints - Add memo store integration to EngineBuilder with with_memo_store method - Implement cache statistics snapshot and invalidation methods in MemoStore - Add pilot decision caching functionality in LlmPilot with context fingerprinting - Support batch invalidation by operation type and model prefix - Enable memo store integration in PipelineRetriever
1 parent 228d30b commit 36478e5

5 files changed

Lines changed: 264 additions & 3 deletions

File tree

src/client/builder.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
use std::path::PathBuf;
3737

3838
use crate::config::{Config, ConfigLoader, RetrievalConfig};
39+
use crate::memo::MemoStore;
3940
use crate::retrieval::PipelineRetriever;
4041
use crate::storage::Workspace;
4142

@@ -108,6 +109,9 @@ pub struct EngineBuilder {
108109

109110
/// Precise mode flag.
110111
precise_mode: bool,
112+
113+
/// Memo store for caching LLM decisions.
114+
memo_store: Option<MemoStore>,
111115
}
112116

113117
impl EngineBuilder {
@@ -126,6 +130,7 @@ impl EngineBuilder {
126130
top_k: None,
127131
fast_mode: false,
128132
precise_mode: false,
133+
memo_store: None,
129134
}
130135
}
131136

@@ -192,6 +197,39 @@ impl EngineBuilder {
192197
self
193198
}
194199

200+
/// Set a memo store for caching LLM decisions.
201+
///
202+
/// When enabled, the pilot will cache navigation decisions based on
203+
/// context fingerprints, avoiding redundant API calls for similar
204+
/// navigation scenarios.
205+
///
206+
/// # Example
207+
///
208+
/// ```rust,no_run
209+
/// use vectorless::client::EngineBuilder;
210+
/// use vectorless::memo::MemoStore;
211+
/// use chrono::Duration;
212+
///
213+
/// # #[tokio::main]
214+
/// # async fn main() -> Result<(), vectorless::BuildError> {
215+
/// let memo_store = MemoStore::new()
216+
/// .with_ttl(Duration::days(7))
217+
/// .with_model("gpt-4o");
218+
///
219+
/// let engine = EngineBuilder::new()
220+
/// .with_workspace("./data")
221+
/// .with_memo_store(memo_store)
222+
/// .build()
223+
/// .await?;
224+
/// # Ok(())
225+
/// # }
226+
/// ```
227+
#[must_use]
228+
pub fn with_memo_store(mut self, store: MemoStore) -> Self {
229+
self.memo_store = Some(store);
230+
self
231+
}
232+
195233
// ============================================================
196234
// LLM Configuration
197235
// ============================================================

src/memo/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ mod store;
3232
mod types;
3333

3434
pub use store::MemoStore;
35-
pub use types::{MemoEntry, MemoKey, MemoOpType, MemoValue, MemoStats};
35+
pub use types::{MemoEntry, MemoKey, MemoOpType, MemoStats, MemoValue, PilotDecisionValue};

src/memo/store.rs

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
88
use std::collections::HashMap;
99
use std::future::Future;
10+
use std::num::NonZeroUsize;
1011
use std::path::Path;
12+
use std::sync::atomic::{AtomicU64, Ordering};
1113
use std::sync::Arc;
1214

1315
use chrono::{Duration, Utc};
@@ -17,7 +19,7 @@ use serde::{Deserialize, Serialize};
1719
use tokio::sync::RwLock as AsyncRwLock;
1820
use tracing::{debug, info, warn};
1921

20-
use super::types::{MemoEntry, MemoKey, MemoStats, MemoValue};
22+
use super::types::{MemoEntry, MemoKey, MemoOpType, MemoStats, MemoValue};
2123
use crate::error::Result;
2224
use crate::utils::fingerprint::Fingerprint;
2325

@@ -246,6 +248,99 @@ impl MemoStore {
246248
result
247249
}
248250

251+
/// Get cache statistics synchronously.
252+
///
253+
/// This acquires a read lock on the stats, which is generally fast.
254+
/// Use this when you need stats without async context.
255+
pub fn stats_snapshot(&self) -> MemoStats {
256+
// Use try_read to avoid blocking; fall back to defaults if locked
257+
match self.stats.try_read() {
258+
Ok(stats) => {
259+
let mut result = stats.clone();
260+
result.entries = self.len();
261+
result
262+
}
263+
Err(_) => MemoStats {
264+
entries: self.len(),
265+
..Default::default()
266+
},
267+
}
268+
}
269+
270+
/// Invalidate all entries of a specific operation type.
271+
///
272+
/// This is useful for batch invalidation when the algorithm for
273+
/// a specific operation type changes.
274+
///
275+
/// # Example
276+
///
277+
/// ```rust,ignore
278+
/// // Invalidate all pilot decision caches
279+
/// let removed = store.invalidate_by_op_type(MemoOpType::PilotDecision);
280+
/// println!("Removed {} cached pilot decisions", removed);
281+
/// ```
282+
pub fn invalidate_by_op_type(&self, op_type: MemoOpType) -> usize {
283+
let mut cache = self.cache.write();
284+
let before = cache.len();
285+
286+
// Collect keys to remove based on entry value type
287+
let keys_to_remove: Vec<String> = cache
288+
.iter()
289+
.filter_map(|(key, entry)| {
290+
let matches = match (&entry.value, op_type) {
291+
(MemoValue::Summary(_), MemoOpType::Summary) => true,
292+
(MemoValue::PilotDecision(_), MemoOpType::PilotDecision) => true,
293+
(MemoValue::QueryAnalysis(_), MemoOpType::QueryAnalysis) => true,
294+
(MemoValue::Extraction(_), MemoOpType::Extraction) => true,
295+
_ => false,
296+
};
297+
if matches { Some(key.clone()) } else { None }
298+
})
299+
.collect();
300+
301+
// Remove entries
302+
for key in keys_to_remove {
303+
cache.pop(&key);
304+
}
305+
306+
let removed = before - cache.len();
307+
if removed > 0 {
308+
debug!("Invalidated {} entries for op_type {:?}", removed, op_type);
309+
}
310+
removed
311+
}
312+
313+
/// Invalidate all entries matching a model ID prefix.
314+
///
315+
/// This is useful when switching models or when a model's behavior changes.
316+
///
317+
/// # Example
318+
///
319+
/// ```rust,ignore
320+
/// // Invalidate all GPT-4 caches
321+
/// let removed = store.invalidate_by_model_prefix("gpt-4");
322+
/// ```
323+
pub fn invalidate_by_model_prefix(&self, prefix: &str) -> usize {
324+
let mut cache = self.cache.write();
325+
let before = cache.len();
326+
327+
// Since the key is a fingerprint, we need to check model_id from entries
328+
// For now, we'll clear all entries if prefix matches our model_id
329+
// A better approach would be to store model_id in entry metadata
330+
let should_clear = self.model_id.as_ref()
331+
.map(|m| m.starts_with(prefix))
332+
.unwrap_or(false);
333+
334+
if should_clear {
335+
cache.clear();
336+
let removed = before;
337+
debug!("Invalidated all {} entries (model prefix '{}')", removed, prefix);
338+
return removed;
339+
}
340+
341+
0
342+
}
343+
249344
/// Remove expired entries.
250345
pub fn prune_expired(&self) -> usize {
251346
let mut cache = self.cache.write();

src/retrieval/pilot/llm_pilot.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ use tracing::{debug, info, warn};
1212

1313
use crate::document::DocumentTree;
1414
use crate::llm::{LlmClient, LlmExecutor};
15+
use crate::memo::{MemoKey, MemoOpType, MemoStore, MemoValue};
1516
use crate::throttle::ConcurrencyController;
17+
use crate::utils::fingerprint::Fingerprint;
1618

1719
use super::budget::BudgetController;
1820
use super::builder::ContextBuilder;
@@ -43,6 +45,11 @@ use super::r#trait::{Pilot, SearchState};
4345
/// │ │ Budget │ │ LlmExecutor │ │
4446
/// │ │ Controller │ │ (throttle+retry+fall) │ │
4547
/// │ └─────────────┘ └───────────────────────┘ │
48+
/// │ │
49+
/// │ ┌─────────────┐ ┌───────────────────────┐ │
50+
/// │ │ Memo │ │ (cache LLM decisions) │ │
51+
/// │ │ Store │ │ │ │
52+
/// │ └─────────────┘ └───────────────────────┘ │
4653
/// └─────────────────────────────────────────────────────────────┘
4754
/// ```
4855
///
@@ -81,6 +88,8 @@ pub struct LlmPilot {
8188
response_parser: ResponseParser,
8289
/// Feedback learner for improving decisions (optional).
8390
learner: Option<Arc<PilotLearner>>,
91+
/// Memo store for caching decisions (optional).
92+
memo_store: Option<MemoStore>,
8493
}
8594

8695
impl std::fmt::Debug for LlmPilot {
@@ -107,6 +116,7 @@ impl LlmPilot {
107116
prompt_builder: PromptBuilder::new(),
108117
response_parser: ResponseParser::new(),
109118
learner: None,
119+
memo_store: None,
110120
}
111121
}
112122

@@ -126,6 +136,7 @@ impl LlmPilot {
126136
prompt_builder: PromptBuilder::new(),
127137
response_parser: ResponseParser::new(),
128138
learner: None,
139+
memo_store: None,
129140
}
130141
}
131142

@@ -144,6 +155,7 @@ impl LlmPilot {
144155
prompt_builder: PromptBuilder::new(),
145156
response_parser: ResponseParser::new(),
146157
learner: None,
158+
memo_store: None,
147159
}
148160
}
149161

@@ -165,6 +177,7 @@ impl LlmPilot {
165177
prompt_builder,
166178
response_parser: ResponseParser::new(),
167179
learner: None,
180+
memo_store: None,
168181
}
169182
}
170183

@@ -186,6 +199,16 @@ impl LlmPilot {
186199
self
187200
}
188201

202+
/// Add a memo store for caching decisions.
203+
///
204+
/// When enabled, the pilot will cache LLM decisions based on
205+
/// context fingerprints, avoiding redundant API calls for
206+
/// similar navigation scenarios.
207+
pub fn with_memo_store(mut self, store: MemoStore) -> Self {
208+
self.memo_store = Some(store);
209+
self
210+
}
211+
189212
/// Check if using LlmExecutor (unified throttle/retry/fallback).
190213
pub fn has_executor(&self) -> bool {
191214
self.executor.is_some()
@@ -196,11 +219,21 @@ impl LlmPilot {
196219
self.learner.is_some()
197220
}
198221

222+
/// Check if using memo store.
223+
pub fn has_memo_store(&self) -> bool {
224+
self.memo_store.is_some()
225+
}
226+
199227
/// Get the feedback learner (if any).
200228
pub fn learner(&self) -> Option<&PilotLearner> {
201229
self.learner.as_deref()
202230
}
203231

232+
/// Get the memo store (if any).
233+
pub fn memo_store(&self) -> Option<&MemoStore> {
234+
self.memo_store.as_ref()
235+
}
236+
204237
/// Record feedback for a decision.
205238
pub fn record_feedback(&self, record: FeedbackRecord) {
206239
if let Some(ref learner) = self.learner {
@@ -210,6 +243,18 @@ impl LlmPilot {
210243
}
211244
}
212245

246+
/// Compute a cache key for a pilot decision.
247+
fn compute_cache_key(&self, context: &super::builder::PilotContext, point: InterventionPoint) -> Option<MemoKey> {
248+
let store = self.memo_store.as_ref()?;
249+
250+
// Build a fingerprint from the context using available methods
251+
let context_str = context.to_string();
252+
let context_fp = Fingerprint::from_str(&context_str);
253+
let query_fp = Fingerprint::from_str(&context.query_section);
254+
255+
Some(MemoKey::pilot_decision(&context_fp, &query_fp))
256+
}
257+
213258
/// Check if budget allows LLM calls.
214259
fn has_budget(&self) -> bool {
215260
self.budget.can_call()
@@ -243,6 +288,20 @@ impl LlmPilot {
243288
context: &super::builder::PilotContext,
244289
candidates: &[crate::document::NodeId],
245290
) -> PilotDecision {
291+
// Check memo cache first
292+
if let Some(ref store) = self.memo_store {
293+
if let Some(cache_key) = self.compute_cache_key(context, point) {
294+
if let Some(cached) = store.get(&cache_key) {
295+
if let MemoValue::PilotDecision(decision_value) = cached {
296+
debug!("Memo cache hit for pilot decision at {:?}", point);
297+
// Convert cached value back to PilotDecision
298+
let decision = self.cached_value_to_decision(decision_value, candidates, point);
299+
return decision;
300+
}
301+
}
302+
}
303+
}
304+
246305
// Build prompt
247306
let prompt = self.prompt_builder.build(point, context);
248307

@@ -313,6 +372,16 @@ impl LlmPilot {
313372
decision.ranked_candidates.len()
314373
);
315374

375+
// Cache the decision
376+
if let Some(ref store) = self.memo_store {
377+
if let Some(cache_key) = self.compute_cache_key(context, point) {
378+
let decision_value = self.decision_to_cached_value(&decision);
379+
let tokens_saved = prompt.estimated_tokens as u64 + output_tokens as u64;
380+
store.put_with_tokens(cache_key, MemoValue::PilotDecision(decision_value), tokens_saved);
381+
debug!("Memo cache stored for pilot decision at {:?}", point);
382+
}
383+
}
384+
316385
decision
317386
}
318387
Err(e) => {
@@ -322,6 +391,45 @@ impl LlmPilot {
322391
}
323392
}
324393

394+
/// Convert a PilotDecision to a cacheable value.
395+
fn decision_to_cached_value(&self, decision: &PilotDecision) -> crate::memo::PilotDecisionValue {
396+
crate::memo::PilotDecisionValue {
397+
selected_idx: decision.ranked_candidates.first()
398+
.map(|c| c.node_id.0.into())
399+
.unwrap_or(0),
400+
confidence: decision.confidence,
401+
reasoning: decision.reasoning.clone(),
402+
}
403+
}
404+
405+
/// Convert a cached value back to a PilotDecision.
406+
fn cached_value_to_decision(
407+
&self,
408+
value: crate::memo::PilotDecisionValue,
409+
candidates: &[crate::document::NodeId],
410+
point: InterventionPoint,
411+
) -> PilotDecision {
412+
let ranked = candidates
413+
.iter()
414+
.enumerate()
415+
.map(|(i, &node_id)| super::decision::RankedCandidate {
416+
node_id,
417+
score: if i == value.selected_idx { 1.0 } else { 0.5 / (i + 1) as f32 },
418+
reason: None,
419+
})
420+
.collect();
421+
422+
PilotDecision {
423+
ranked_candidates: ranked,
424+
direction: super::decision::SearchDirection::GoDeeper {
425+
reason: "Cached decision".to_string(),
426+
},
427+
confidence: value.confidence,
428+
reasoning: value.reasoning,
429+
intervention_point: point,
430+
}
431+
}
432+
325433
/// Create a default decision when LLM fails.
326434
fn default_decision(
327435
&self,

0 commit comments

Comments
 (0)