Skip to main content

velesdb_memory/
fused_recall.rs

1//! [`MemoryService::recall_fused`]: vector recall combined with the graph
2//! reach `why()` already walks, re-ranked by [`crate::fusion::fuse`]. Split
3//! out of `service.rs` to keep that file under the crate's NLOC budget; a
4//! child module of `service`, so it freely uses `MemoryService`'s private
5//! fields and methods (`traverse`, `search`, `HUB_FIELD`, …).
6
7use std::collections::HashMap;
8
9use serde_json::Value;
10
11use super::{
12    reject_reserved_keys, strip_reserved_keys, MemoryService, Metadata, HUB_FIELD,
13    MENTIONS_RELATION,
14};
15use crate::embedder::Embedder;
16use crate::error::MemoryError;
17use crate::fusion::{self, Candidate};
18use crate::model::{FusionOptions, MemoryEdge, MemoryNode, Recollection};
19use crate::rerank::Reranker;
20use crate::storage::MemoryStore;
21
22impl<E: Embedder, S: MemoryStore> MemoryService<E, S> {
23    /// Fused recall: like [`Self::recall`], but also walks the graph from the
24    /// query's top vector hit and folds any fact it reaches (hop ≥ 1) into the
25    /// ranking, scored by `opts.graph_boost · graph_weight` on top of its
26    /// normalised vector similarity. A fact the graph reaches never displaces
27    /// a strong vector hit unless the boosted score genuinely outranks it; a
28    /// fact the vector pool ranked low (or missed) can still surface if the
29    /// graph connects it. This is the tri-engine ranking measured on
30    /// HotpotQA/TimeQA/LoCoMo (`examples/multihop`, `examples/timeqa`,
31    /// `examples/locomo`) — [`Self::recall`] stays pure-vector and unchanged,
32    /// so existing callers see no behavior shift.
33    ///
34    /// The graph reach requires a wired graph to find anything: it walks
35    /// edges from [`Self::relate`] or the entity hubs
36    /// [`Self::remember_extracted`] auto-wires. Entity hubs themselves are
37    /// never returned, exactly like [`Self::recall`].
38    ///
39    /// # Errors
40    /// Returns [`MemoryError`] if embedding, vector search, or graph
41    /// traversal fails.
42    pub fn recall_fused(
43        &self,
44        query: &str,
45        k: usize,
46        filter: Option<&Metadata>,
47        opts: FusionOptions,
48    ) -> Result<Vec<Recollection>, MemoryError> {
49        let query = query.trim();
50        if query.is_empty() || k == 0 {
51            return Ok(Vec::new());
52        }
53        reject_reserved_keys(filter)?;
54        let embedding = self.embedder.embed(query)?;
55        let pool = self.fused_pool(&embedding, pool_depth(k, opts), filter)?;
56        let reached = self.graph_reached(&embedding, filter, opts.hops)?;
57        Ok(fusion::fuse(pool, &reached, k, opts.graph_boost))
58    }
59
60    /// Like [`Self::recall_fused`], but hands the FULL fused-ranked candidate
61    /// pool (before the final `k` cutoff) to `reranker` for a second-stage
62    /// re-score, then truncates to `k`. Closes the ranking-miss gap the
63    /// `LoCoMo` ceiling diagnostic found: a relevant fact can be IN the pool
64    /// (recall@64 ≈ 89% on multi-hop) yet outranked out of a tight `k`
65    /// (recall@8 ≈ 50%) — a reranker recovers it without widening `k` itself.
66    ///
67    /// No built-in reranker ships: bring your own (cross-encoder, LLM judge,
68    /// …) via [`Reranker`]. Never call this as a default — a reranker can
69    /// also *hurt* out-of-distribution conversational queries (measured on
70    /// `LoCoMo`), so it is opt-in, one call at a time.
71    ///
72    /// # Errors
73    /// Returns [`MemoryError`] if embedding, vector search, graph traversal,
74    /// or `reranker` itself fails.
75    pub fn recall_fused_reranked<R: Reranker>(
76        &self,
77        query: &str,
78        k: usize,
79        filter: Option<&Metadata>,
80        opts: FusionOptions,
81        reranker: &R,
82    ) -> Result<Vec<Recollection>, MemoryError> {
83        let query = query.trim();
84        if query.is_empty() || k == 0 {
85            return Ok(Vec::new());
86        }
87        reject_reserved_keys(filter)?;
88        let embedding = self.embedder.embed(query)?;
89        let depth = pool_depth(k, opts);
90        let pool = self.fused_pool(&embedding, depth, filter)?;
91        let reached = self.graph_reached(&embedding, filter, opts.hops)?;
92        let fused = fusion::fuse(pool, &reached, depth, opts.graph_boost);
93        let ranked = reranker.rerank(query, fused)?;
94        Ok(ranked.into_iter().take(k).collect())
95    }
96
97    /// The oversampled vector pool [`Self::recall_fused`] re-ranks. One
98    /// batched metadata lookup covers the whole pool (up to hundreds of ids
99    /// at the deepest `pool_depth`), not one round trip per hit.
100    fn fused_pool(
101        &self,
102        embedding: &[f32],
103        depth: usize,
104        filter: Option<&Metadata>,
105    ) -> Result<Vec<Candidate>, MemoryError> {
106        let hits = self.search(embedding, depth, filter)?;
107        let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
108        let metadata = self.recall_metadata_batch(&ids)?;
109        Ok(hits
110            .into_iter()
111            .zip(metadata)
112            .map(|((id, score, content), metadata)| Candidate {
113                recollection: Recollection {
114                    id,
115                    score,
116                    content,
117                    metadata,
118                },
119                vector_score: f64::from(score),
120                graph_weight: 0.0,
121            })
122            .collect())
123    }
124
125    /// The caller-supplied metadata for every id in `ids` (reserved system
126    /// keys excluded, `None` per-id when it carries none), in the same
127    /// order — one batched storage round trip, so a `k`- or pool-sized
128    /// result set (here, and in [`MemoryService::recall`]) costs one
129    /// metadata lookup, not `k`/`pool_size` of them.
130    pub(crate) fn recall_metadata_batch(
131        &self,
132        ids: &[u64],
133    ) -> Result<Vec<Option<Metadata>>, MemoryError> {
134        Ok(self
135            .store
136            .get_metadata_batch(ids)?
137            .into_iter()
138            .map(strip_reserved_keys)
139            .collect())
140    }
141
142    /// Facts the graph reaches (hop ≥ 1) from the query's top vector seed,
143    /// entity hubs excluded, each weighted by [`Self::reach_weight`]: a link
144    /// through a rare, specific entity hub promotes harder than one through a
145    /// generic mega-hub whose connections carry little signal — the idf lever
146    /// validated on `HotpotQA` (+5.0pp both-facts-complete) and `LoCoMo` (turns
147    /// the graph net-positive on multi-hop, no regression elsewhere).
148    ///
149    /// `filter` is re-checked against every reached fact's own metadata, not
150    /// just the seed: the graph walk is otherwise filter-blind, so a fact
151    /// outside the caller's scope (e.g. a different tenant/project) could
152    /// leak in just by being graph-connected to the seed.
153    fn graph_reached(
154        &self,
155        embedding: &[f32],
156        filter: Option<&Metadata>,
157        hops: usize,
158    ) -> Result<Vec<Candidate>, MemoryError> {
159        let seeds = self.search(embedding, 1, filter)?;
160        let Some((seed_id, _score, seed_content)) = seeds.into_iter().next() else {
161            return Ok(Vec::new());
162        };
163        let explanation = self.traverse(seed_id, seed_content, hops)?;
164        let nodes: Vec<&MemoryNode> = explanation.nodes.iter().filter(|n| n.hop != 0).collect();
165        let ids: Vec<u64> = nodes.iter().map(|n| n.id).collect();
166        let raw_payloads = self.store.get_metadata_batch(&ids)?;
167
168        let mut idf_cache: HashMap<u64, f64> = HashMap::new();
169        let mut reached = Vec::new();
170        for (node, raw) in nodes.into_iter().zip(raw_payloads) {
171            if let Some(candidate) =
172                self.reached_candidate(node, raw, &explanation.edges, filter, &mut idf_cache)?
173            {
174                reached.push(candidate);
175            }
176        }
177        Ok(reached)
178    }
179
180    /// The graph-reached candidate for `node` given its already-fetched raw
181    /// payload `raw`, or `None` when it's an entity hub (internal
182    /// scaffolding, never a caller fact) or outside `filter`'s scope — split
183    /// out of [`Self::graph_reached`] to keep that loop's complexity within
184    /// budget. `raw` is fetched once, batched across the whole traversal, by
185    /// the caller — not per node — and serves both the hub check and the
186    /// returned candidate's metadata: the hub flag lives under the reserved
187    /// `_veles_hub` key, so it must be checked before `strip_reserved_keys`
188    /// removes it for the caller-facing metadata. `idf_cache` memoizes
189    /// [`Self::entity_idf`] per hub across the whole traversal (siblings
190    /// under the same hub would otherwise recompute an identical value once
191    /// per fact).
192    fn reached_candidate(
193        &self,
194        node: &MemoryNode,
195        raw: Option<Metadata>,
196        edges: &[MemoryEdge],
197        filter: Option<&Metadata>,
198        idf_cache: &mut HashMap<u64, f64>,
199    ) -> Result<Option<Candidate>, MemoryError> {
200        if raw
201            .as_ref()
202            .is_some_and(|meta| meta.get(HUB_FIELD) == Some(&Value::Bool(true)))
203        {
204            return Ok(None);
205        }
206        let metadata = strip_reserved_keys(raw);
207        if !matches_filter(metadata.as_ref(), filter) {
208            return Ok(None);
209        }
210        let weight = self.reach_weight(node.id, edges, idf_cache)?;
211        Ok(Some(Candidate {
212            recollection: Recollection {
213                id: node.id,
214                score: 0.0,
215                content: node.content.clone(),
216                metadata,
217            },
218            vector_score: 0.0,
219            graph_weight: weight,
220        }))
221    }
222
223    /// The strength of the link(s) that reached `fact_id`: the maximum
224    /// entity-idf ([`Self::entity_idf`]) over every hub with a `mentions`
225    /// edge into it, or a flat `1.0` when it was reached through a direct
226    /// (non-hub) [`Self::relate`] edge instead — idf has nothing to weight
227    /// there, so the original flat signal is kept.
228    fn reach_weight(
229        &self,
230        fact_id: u64,
231        edges: &[MemoryEdge],
232        idf_cache: &mut HashMap<u64, f64>,
233    ) -> Result<f64, MemoryError> {
234        let mut weight: Option<f64> = None;
235        for edge in edges {
236            if edge.to == fact_id && edge.relation == MENTIONS_RELATION {
237                let idf = self.cached_entity_idf(edge.from, idf_cache)?;
238                weight = Some(weight.map_or(idf, |w: f64| w.max(idf)));
239            }
240        }
241        Ok(weight.unwrap_or(1.0))
242    }
243
244    /// [`Self::entity_idf`], memoized in `cache` for the lifetime of one
245    /// [`Self::graph_reached`] call — sibling facts under the same hub would
246    /// otherwise each pay a fresh `relations`+`count` store round trip for an
247    /// identical value.
248    fn cached_entity_idf(
249        &self,
250        hub_id: u64,
251        cache: &mut HashMap<u64, f64>,
252    ) -> Result<f64, MemoryError> {
253        if let Some(&idf) = cache.get(&hub_id) {
254            return Ok(idf);
255        }
256        let idf = self.entity_idf(hub_id)?;
257        cache.insert(hub_id, idf);
258        Ok(idf)
259    }
260
261    /// Normalised inverse document frequency of hub `hub_id`, in `[0, 1]`:
262    /// `1` when it links a single fact (maximally specific), trending to `0`
263    /// as it links ever more (a generic mega-hub whose links carry little
264    /// answer signal). Mirrors the `LoCoMo` harness formula
265    /// (`examples/locomo/ingest.rs`), using the store's total memory count
266    /// (facts + hubs) as a corpus-size proxy.
267    fn entity_idf(&self, hub_id: u64) -> Result<f64, MemoryError> {
268        let degree = self.store.relations(hub_id)?.len();
269        let n = self.store.count();
270        if degree == 0 || n <= 1 {
271            return Ok(0.0);
272        }
273        #[allow(clippy::cast_precision_loss)] // corpus/degree sizes are far below f64's exact range
274        let (n, d) = (n as f64, degree as f64);
275        Ok((n / d).ln() / n.ln())
276    }
277}
278
279/// True when `filter` is absent, or every key in it matches `metadata`
280/// exactly — the same "all filter keys must match" semantics
281/// [`MemoryService::search`]'s vector-side filtering applies, now also
282/// enforced on graph-reached facts so a caller-scoped `recall_fused` can't
283/// leak a fact outside that scope just because it's graph-connected to the
284/// seed.
285fn matches_filter(metadata: Option<&Metadata>, filter: Option<&Metadata>) -> bool {
286    let Some(filter) = filter else {
287        return true;
288    };
289    // Mirrors velesdb-core's `payload_matches`: an empty (not absent) filter
290    // matches everything, including a metadata-less fact — `Some({})` from a
291    // caller (e.g. a JS `recallFused(q, k, {})`) must behave exactly like
292    // `None`, not like "reject anything without metadata".
293    if filter.is_empty() {
294        return true;
295    }
296    let Some(metadata) = metadata else {
297        return false;
298    };
299    filter.iter().all(|(k, v)| metadata.get(k) == Some(v))
300}
301
302/// The oversampled candidate pool depth for a `k`-sized fused recall:
303/// `opts.pool` if the caller set one, else the proven default
304/// ([`fusion::pool_size`]) — either way, capped at
305/// [`crate::limits::MAX_RECALL_LIMIT`], the same `DoS` ceiling `k`/`hops`
306/// carry. The cap lives here, not just at each binding's FFI boundary: the
307/// *default* pool (`k.saturating_mul(8)`) exceeds it well before `k` itself
308/// reaches its own cap, so a caller who never touches `pool` at all — not
309/// just one who sets it explicitly — must still be bounded.
310fn pool_depth(k: usize, opts: FusionOptions) -> usize {
311    let depth = opts.pool.unwrap_or_else(|| fusion::pool_size(k));
312    crate::limits::clamp_recall_limit(depth)
313}