Skip to main content

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}