sqlite_graphrag/embedder/batch/entity_cache.rs
1//! Process-wide entity-embedding cache and its stats snapshot (G56).
2//!
3//! Entity names repeat heavily across a corpus, so this module memoises
4//! `(model, text)` pairs for the lifetime of one CLI invocation and only sends
5//! the misses to the backend-aware batch path.
6
7use super::passages::embed_passages_parallel_shared;
8use super::sizing::entity_embed_batch_size;
9use crate::embedder::{is_openrouter_initialized, LlmBackendKind};
10use crate::errors::AppError;
11use std::path::Path;
12use std::sync::Arc;
13use std::sync::OnceLock;
14
15/// G56: in-process cache for entity embeddings keyed by `(model, text)`.
16///
17/// Schema v13 is immutable: `entity_embeddings` does not have a `text`
18/// column, so a pure DB-side cache would require a schema bump. Instead
19/// we keep a process-wide LRU-style map that survives within one CLI
20/// invocation. The hit rate is high in `ingest` (re-embedding the same
21/// canonical entity across thousands of memories) and modest in `remember`
22/// (typical single-memory invocations).
23///
24/// Key: `blake3(model || "\0" || text)`. Value: the vector plus the instant it
25/// was stored, behind an `Arc` so eviction can drop the map entry while a `Vec`
26/// is still in flight.
27///
28/// # Bounded since v1.2.3
29///
30/// The map used to be unbounded and untimed: an `ingest` over a corpus with many
31/// distinct entity names grew it for the whole invocation with nothing but the
32/// corpus size to stop it. Both bounds now come from
33/// [`crate::constants::entity_embed_cache_max_entries`] and
34/// [`crate::constants::entity_embed_cache_ttl_secs`], each backed by an XDG key.
35/// One memoised entity vector and the instant it entered the cache.
36struct CacheEntry {
37 vector: Arc<Vec<f32>>,
38 stored_at: std::time::Instant,
39}
40
41/// The bounded map itself.
42///
43/// It keeps the `HashMap`-shaped [`Self::insert`] / [`Self::get`] pair the
44/// callers already use, so the timestamp stays an implementation detail: no
45/// caller has to remember to stamp an entry, and none can read an expired one.
46#[derive(Default)]
47pub(crate) struct EntityEmbedCacheMap {
48 entries: std::collections::HashMap<u64, CacheEntry>,
49}
50
51impl EntityEmbedCacheMap {
52 /// Stores `vector` under `key`, stamped with the current instant.
53 pub(crate) fn insert(&mut self, key: u64, vector: Arc<Vec<f32>>) {
54 self.entries.insert(
55 key,
56 CacheEntry {
57 vector,
58 stored_at: std::time::Instant::now(),
59 },
60 );
61 }
62
63 /// Returns the vector for `key` while it is still inside its TTL.
64 ///
65 /// An expired entry reads as absent instead of being removed here: the read
66 /// path only holds a shared borrow, and eviction belongs to the write path
67 /// (see [`Self::evict_expired_and_overflow`]).
68 pub(crate) fn get(&self, key: &u64) -> Option<&Arc<Vec<f32>>> {
69 let ttl = std::time::Duration::from_secs(crate::constants::entity_embed_cache_ttl_secs());
70 let now = std::time::Instant::now();
71 self.entries
72 .get(key)
73 .filter(|entry| now.duration_since(entry.stored_at) < ttl)
74 .map(|entry| &entry.vector)
75 }
76
77 /// Number of entries currently held, expired ones included.
78 #[cfg(test)]
79 pub(crate) fn len(&self) -> usize {
80 self.entries.len()
81 }
82
83 /// Drops expired entries, then trims back far enough to fit `incoming`.
84 ///
85 /// Eviction is oldest-first by insertion instant, which is the honest
86 /// ordering available here: a true LRU would need a read timestamp updated
87 /// under the same lock on every hit, and paying a write on the hot path to
88 /// protect a cache whose whole point is to avoid work is the wrong trade.
89 /// Called right before an insert batch — the only moment the map grows.
90 pub(crate) fn evict_expired_and_overflow(&mut self, incoming: usize) {
91 let ttl = std::time::Duration::from_secs(crate::constants::entity_embed_cache_ttl_secs());
92 let now = std::time::Instant::now();
93 self.entries
94 .retain(|_, entry| now.duration_since(entry.stored_at) < ttl);
95
96 let ceiling = crate::constants::entity_embed_cache_max_entries();
97 // Room the incoming batch needs. A batch larger than the whole ceiling
98 // can only be served by clearing everything; it still gets its vectors,
99 // they just do not all survive in the cache.
100 let target = ceiling.saturating_sub(incoming.min(ceiling));
101 if self.entries.len() <= target {
102 return;
103 }
104 let mut by_age: Vec<(u64, std::time::Instant)> = self
105 .entries
106 .iter()
107 .map(|(key, entry)| (*key, entry.stored_at))
108 .collect();
109 by_age.sort_by_key(|(_, stored_at)| *stored_at);
110 for (key, _) in by_age.into_iter().take(self.entries.len() - target) {
111 self.entries.remove(&key);
112 }
113 }
114}
115
116static ENTITY_EMBED_CACHE: OnceLock<parking_lot::Mutex<EntityEmbedCacheMap>> = OnceLock::new();
117
118pub(crate) fn entity_embed_cache() -> &'static parking_lot::Mutex<EntityEmbedCacheMap> {
119 ENTITY_EMBED_CACHE.get_or_init(|| parking_lot::Mutex::new(EntityEmbedCacheMap::default()))
120}
121
122pub(crate) fn entity_cache_key(model: &str, text: &str) -> u64 {
123 let mut hasher = blake3::Hasher::new();
124 hasher.update(model.as_bytes());
125 hasher.update(b"\0");
126 hasher.update(text.as_bytes());
127 let h = hasher.finalize();
128 let bytes = h.as_bytes();
129 u64::from_le_bytes([
130 bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
131 ])
132}
133
134/// G56: embeds entity-name texts through a process-wide cache.
135///
136/// Skips any `(model, text)` pair already produced in this CLI invocation
137/// and only spawns subprocesses for the cache misses. Returns vectors in
138/// the same order as `texts`.
139///
140/// Designed for entity-name batches (short texts). For chunk embeds use
141/// `super::embed_passages_parallel_local` directly — chunks are unique per
142/// memory and cache hit rate is negligible.
143pub fn embed_entity_texts_cached(
144 models_dir: &Path,
145 texts: &[String],
146 parallelism: usize,
147 backends: crate::cli::BackendChoice,
148) -> Result<(Vec<Vec<f32>>, EmbedCacheStats), AppError> {
149 let crate::cli::BackendChoice {
150 llm: llm_backend,
151 embedding: embedding_backend,
152 } = backends;
153 if texts.is_empty() {
154 return Ok((Vec::new(), EmbedCacheStats::default()));
155 }
156 // GAP-OR-ENTITY-EMBED: resolve the SAME chain the chunk path uses so the
157 // entity embedding honours `--embedding-backend`/`--llm-backend` instead
158 // of always forcing the codex subprocess (the old G56 code path).
159 let chain = embedding_backend.to_chain(llm_backend);
160
161 // `none` short-circuit: when the resolved chain is exactly `[None]`
162 // (`--embedding-backend llm --llm-backend none`) skip every backend and
163 // return empty vectors WITHOUT spawning a subprocess. Empties are never
164 // cached so a later call with a real backend in the same process is not
165 // poisoned; they count as misses for stats parity with the chunk path.
166 if chain.as_slice() == [LlmBackendKind::None] {
167 let out: Vec<Vec<f32>> = texts.iter().map(|_| Vec::new()).collect();
168 return Ok((
169 out,
170 EmbedCacheStats {
171 requested: texts.len(),
172 hits: 0,
173 misses: texts.len(),
174 },
175 ));
176 }
177
178 // Cache model label reflects the EFFECTIVE embedding backend: vectors
179 // carry that model's dim/MRL profile, so the key must not collide across
180 // dimensionalities. This cache is process-local.
181 let routed_openrouter =
182 chain.first() == Some(&LlmBackendKind::OpenRouter) && is_openrouter_initialized();
183 let model = if routed_openrouter {
184 format!("openrouter:{}", crate::constants::embedding_dim())
185 } else {
186 format!("none:{}", crate::constants::embedding_dim())
187 };
188 let cache = entity_embed_cache();
189 let mut hits: Vec<Option<Arc<Vec<f32>>>> = vec![None; texts.len()];
190 let mut miss_indices: Vec<usize> = Vec::with_capacity(texts.len());
191 {
192 let guard = cache.lock();
193 for (i, text) in texts.iter().enumerate() {
194 let key = entity_cache_key(&model, text);
195 // `get` already filters out entries past their TTL.
196 match guard.get(&key) {
197 Some(vector) => hits[i] = Some(Arc::clone(vector)),
198 None => miss_indices.push(i),
199 }
200 }
201 }
202 let miss_count = miss_indices.len();
203 if miss_count > 0 {
204 let miss_texts: Vec<String> = miss_indices.iter().map(|&i| texts[i].clone()).collect();
205 // GAP-OR-ENTITY-EMBED: route misses through the backend-aware batch
206 // helper (same one the chunk path uses). With OpenRouter this hits the
207 // REST `embed_batch` (~200ms) instead of the codex subprocess (~120s).
208 let mut miss_vecs = embed_passages_parallel_shared(
209 models_dir,
210 Arc::from(miss_texts),
211 parallelism,
212 entity_embed_batch_size(),
213 backends,
214 )?;
215 let mut guard = cache.lock();
216 guard.evict_expired_and_overflow(miss_count);
217 for (slot, &orig_idx) in miss_indices.iter().enumerate() {
218 // MOVE the freshly produced vector into the `Arc` instead of
219 // cloning it: the batch result is dead after this loop, so the copy
220 // it used to make was a full duplicate of every miss vector.
221 let vector = Arc::new(std::mem::take(&mut miss_vecs[slot]));
222 let key = entity_cache_key(&model, &texts[orig_idx]);
223 guard.insert(key, Arc::clone(&vector));
224 hits[orig_idx] = Some(vector);
225 }
226 }
227 let mut out = Vec::with_capacity(texts.len());
228 for hit in hits.into_iter() {
229 let v = hit.ok_or_else(|| {
230 AppError::Embedding(crate::i18n::validation::embedding_entity_cache_null())
231 })?;
232 out.push((*v).clone());
233 }
234 Ok((
235 out,
236 EmbedCacheStats {
237 requested: texts.len(),
238 hits: texts.len() - miss_count,
239 misses: miss_count,
240 },
241 ))
242}
243
244/// G56: stats snapshot returned by [`embed_entity_texts_cached`].
245#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize)]
246pub struct EmbedCacheStats {
247 /// Requested.
248 pub requested: usize,
249 /// Hits.
250 pub hits: usize,
251 /// Misses.
252 pub misses: usize,
253}
254
255impl EmbedCacheStats {
256 /// Hit rate as a fraction in `[0.0, 1.0]`. Returns 0.0 when nothing was requested.
257 pub fn hit_rate(&self) -> f64 {
258 if self.requested == 0 {
259 0.0
260 } else {
261 self.hits as f64 / self.requested as f64
262 }
263 }
264}