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 let opts = opts.sanitized();
54 reject_reserved_keys(filter)?;
55 let embedding = self.embedder.embed(query)?;
56 let pool = self.fused_pool(&embedding, pool_depth(k, opts), filter)?;
57 let reached = self.graph_reached(&embedding, filter, opts.hops)?;
58 Ok(fusion::fuse(pool, &reached, k, opts.graph_boost))
59 }
60
61 /// [`Self::recall_fused`] with the per-candidate score ventilation kept
62 /// (normalised vector term, graph weight, fused score) — consumed by the
63 /// context compiler's memory bridge, whose provenance records an
64 /// explainable `relevance ∈ [0, 1]` per pulled memory. Same pipeline,
65 /// same ordering, same numbers as [`Self::recall_fused`]; only the
66 /// breakdown is kept instead of dropped.
67 ///
68 /// # Errors
69 /// Returns [`MemoryError`] if embedding, vector search, or graph
70 /// traversal fails.
71 #[cfg(feature = "context")]
72 pub(crate) fn recall_fused_scored(
73 &self,
74 query: &str,
75 k: usize,
76 filter: Option<&Metadata>,
77 opts: FusionOptions,
78 ) -> Result<Vec<fusion::ScoredCandidate>, MemoryError> {
79 let query = query.trim();
80 if query.is_empty() || k == 0 {
81 return Ok(Vec::new());
82 }
83 let opts = opts.sanitized();
84 reject_reserved_keys(filter)?;
85 let embedding = self.embedder.embed(query)?;
86 let pool = self.fused_pool(&embedding, pool_depth(k, opts), filter)?;
87 let reached = self.graph_reached(&embedding, filter, opts.hops)?;
88 Ok(fusion::fuse_scored(pool, &reached, k, opts.graph_boost))
89 }
90
91 /// [`Self::recall_fused`] paired with the dated-context rendering of its
92 /// results: returns the recalled facts and the
93 /// [`DatedContext`](crate::DatedContext) built from their `date_field`
94 /// metadata (see [`format_dated_context`](crate::format_dated_context)).
95 /// Every binding that exposes a "dated recall" (the MCP `recall_fused`
96 /// tool's `date_field`, Node/WASM `recallFusedDated`) calls this, so the
97 /// "recall then format" pairing lives in exactly one place and can't drift
98 /// between surfaces.
99 ///
100 /// `date_field` can name any caller metadata key, but passing
101 /// [`crate::storage::AUTO_DATE_FIELD`] needs zero setup: `remember`
102 /// auto-stamps that key on every fact already, so a caller gets a correct
103 /// `dated_context` without ever having managed a date field itself.
104 ///
105 /// # Errors
106 /// Returns [`MemoryError`] if the underlying [`Self::recall_fused`] fails.
107 pub fn recall_fused_dated(
108 &self,
109 query: &str,
110 k: usize,
111 filter: Option<&Metadata>,
112 opts: FusionOptions,
113 date_field: &str,
114 ) -> Result<(Vec<Recollection>, crate::DatedContext), MemoryError> {
115 let hits = self.recall_fused(query, k, filter, opts)?;
116 let ctx = crate::format_dated_context(&hits, date_field);
117 Ok((hits, ctx))
118 }
119
120 /// Like [`Self::recall_fused`], but hands the FULL fused-ranked candidate
121 /// pool (before the final `k` cutoff) to `reranker` for a second-stage
122 /// re-score, then truncates to `k`. Closes the ranking-miss gap the
123 /// `LoCoMo` ceiling diagnostic found: a relevant fact can be IN the pool
124 /// (recall@64 ≈ 89% on multi-hop) yet outranked out of a tight `k`
125 /// (recall@8 ≈ 50%) — a reranker recovers it without widening `k` itself.
126 ///
127 /// No built-in reranker ships: bring your own (cross-encoder, LLM judge,
128 /// …) via [`Reranker`]. Never call this as a default — a reranker can
129 /// also *hurt* out-of-distribution conversational queries (measured on
130 /// `LoCoMo`), so it is opt-in, one call at a time.
131 ///
132 /// # Errors
133 /// Returns [`MemoryError`] if embedding, vector search, graph traversal,
134 /// or `reranker` itself fails.
135 pub fn recall_fused_reranked<R: Reranker>(
136 &self,
137 query: &str,
138 k: usize,
139 filter: Option<&Metadata>,
140 opts: FusionOptions,
141 reranker: &R,
142 ) -> Result<Vec<Recollection>, MemoryError> {
143 let query = query.trim();
144 if query.is_empty() || k == 0 {
145 return Ok(Vec::new());
146 }
147 let opts = opts.sanitized();
148 reject_reserved_keys(filter)?;
149 let embedding = self.embedder.embed(query)?;
150 let depth = pool_depth(k, opts);
151 let pool = self.fused_pool(&embedding, depth, filter)?;
152 let reached = self.graph_reached(&embedding, filter, opts.hops)?;
153 let fused = fusion::fuse(pool, &reached, depth, opts.graph_boost);
154 let ranked = reranker.rerank(query, fused)?;
155 Ok(ranked.into_iter().take(k).collect())
156 }
157
158 /// The oversampled vector pool [`Self::recall_fused`] re-ranks. One
159 /// batched metadata lookup covers the whole pool (up to hundreds of ids
160 /// at the deepest `pool_depth`), not one round trip per hit.
161 fn fused_pool(
162 &self,
163 embedding: &[f32],
164 depth: usize,
165 filter: Option<&Metadata>,
166 ) -> Result<Vec<Candidate>, MemoryError> {
167 let hits = self.search(embedding, depth, filter)?;
168 let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
169 let metadata = self.recall_metadata_batch(&ids)?;
170 Ok(hits
171 .into_iter()
172 .zip(metadata)
173 .map(|((id, score, content), metadata)| Candidate {
174 recollection: Recollection {
175 id,
176 score,
177 content,
178 metadata,
179 },
180 vector_score: f64::from(score),
181 graph_weight: 0.0,
182 })
183 .collect())
184 }
185
186 /// The caller-supplied metadata for every id in `ids` (reserved system
187 /// keys excluded, `None` per-id when it carries none), in the same
188 /// order — one batched storage round trip, so a `k`- or pool-sized
189 /// result set (here, and in [`MemoryService::recall`]) costs one
190 /// metadata lookup, not `k`/`pool_size` of them.
191 pub(crate) fn recall_metadata_batch(
192 &self,
193 ids: &[u64],
194 ) -> Result<Vec<Option<Metadata>>, MemoryError> {
195 Ok(self
196 .store
197 .get_metadata_batch(ids)?
198 .into_iter()
199 .map(strip_reserved_keys)
200 .collect())
201 }
202
203 /// Facts the graph reaches (hop ≥ 1) from the query's top vector seed,
204 /// entity hubs excluded, each weighted by [`Self::reach_weight`]: a link
205 /// through a rare, specific entity hub promotes harder than one through a
206 /// generic mega-hub whose connections carry little signal — the idf lever
207 /// validated on `HotpotQA` (+5.0pp both-facts-complete) and `LoCoMo` (turns
208 /// the graph net-positive on multi-hop, no regression elsewhere).
209 ///
210 /// `filter` is re-checked against every reached fact's own metadata, not
211 /// just the seed: the graph walk is otherwise filter-blind, so a fact
212 /// outside the caller's scope (e.g. a different tenant/project) could
213 /// leak in just by being graph-connected to the seed.
214 fn graph_reached(
215 &self,
216 embedding: &[f32],
217 filter: Option<&Metadata>,
218 hops: usize,
219 ) -> Result<Vec<Candidate>, MemoryError> {
220 let seeds = self.search(embedding, 1, filter)?;
221 let Some((seed_id, _score, seed_content)) = seeds.into_iter().next() else {
222 return Ok(Vec::new());
223 };
224 let explanation = self.traverse(seed_id, seed_content, hops)?;
225 let nodes: Vec<&MemoryNode> = explanation.nodes.iter().filter(|n| n.hop != 0).collect();
226 let ids: Vec<u64> = nodes.iter().map(|n| n.id).collect();
227 let raw_payloads = self.store.get_metadata_batch(&ids)?;
228 let mentions_by_target = index_mentions_edges(&explanation.edges);
229
230 let mut idf_cache: HashMap<u64, f64> = HashMap::new();
231 let mut reached = Vec::new();
232 for (node, raw) in nodes.into_iter().zip(raw_payloads) {
233 if let Some(candidate) =
234 self.reached_candidate(node, raw, &mentions_by_target, filter, &mut idf_cache)?
235 {
236 reached.push(candidate);
237 }
238 }
239 Ok(reached)
240 }
241
242 /// The graph-reached candidate for `node` given its already-fetched raw
243 /// payload `raw`, or `None` when it's an entity hub (internal
244 /// scaffolding, never a caller fact) or outside `filter`'s scope — split
245 /// out of [`Self::graph_reached`] to keep that loop's complexity within
246 /// budget. `raw` is fetched once, batched across the whole traversal, by
247 /// the caller — not per node — and serves both the hub check and the
248 /// returned candidate's metadata: the hub flag lives under the reserved
249 /// `_veles_hub` key, so it must be checked before `strip_reserved_keys`
250 /// removes it for the caller-facing metadata. `idf_cache` memoizes
251 /// [`Self::entity_idf`] per hub across the whole traversal (siblings
252 /// under the same hub would otherwise recompute an identical value once
253 /// per fact).
254 fn reached_candidate(
255 &self,
256 node: &MemoryNode,
257 raw: Option<Metadata>,
258 mentions_by_target: &HashMap<u64, Vec<u64>>,
259 filter: Option<&Metadata>,
260 idf_cache: &mut HashMap<u64, f64>,
261 ) -> Result<Option<Candidate>, MemoryError> {
262 if raw
263 .as_ref()
264 .is_some_and(|meta| meta.get(HUB_FIELD) == Some(&Value::Bool(true)))
265 {
266 return Ok(None);
267 }
268 let metadata = strip_reserved_keys(raw);
269 if !matches_filter(metadata.as_ref(), filter) {
270 return Ok(None);
271 }
272 let weight = self.reach_weight(node.id, mentions_by_target, idf_cache)?;
273 Ok(Some(Candidate {
274 recollection: Recollection {
275 id: node.id,
276 score: 0.0,
277 content: node.content.clone(),
278 metadata,
279 },
280 vector_score: 0.0,
281 graph_weight: weight,
282 }))
283 }
284
285 /// The strength of the link(s) that reached `fact_id`: the maximum
286 /// entity-idf ([`Self::entity_idf`]) over every hub `mentions_by_target`
287 /// lists for it, or a flat `1.0` when it was reached through a direct
288 /// (non-hub) [`Self::relate`] edge instead — idf has nothing to weight
289 /// there, so the original flat signal is kept.
290 fn reach_weight(
291 &self,
292 fact_id: u64,
293 mentions_by_target: &HashMap<u64, Vec<u64>>,
294 idf_cache: &mut HashMap<u64, f64>,
295 ) -> Result<f64, MemoryError> {
296 let Some(hub_ids) = mentions_by_target.get(&fact_id) else {
297 return Ok(1.0);
298 };
299 let mut weight: Option<f64> = None;
300 for &hub_id in hub_ids {
301 let idf = self.cached_entity_idf(hub_id, idf_cache)?;
302 weight = Some(weight.map_or(idf, |w: f64| w.max(idf)));
303 }
304 Ok(weight.unwrap_or(1.0))
305 }
306
307 /// [`Self::entity_idf`], memoized in `cache` for the lifetime of one
308 /// [`Self::graph_reached`] call — sibling facts under the same hub would
309 /// otherwise each pay a fresh `relations`+`count` store round trip for an
310 /// identical value.
311 fn cached_entity_idf(
312 &self,
313 hub_id: u64,
314 cache: &mut HashMap<u64, f64>,
315 ) -> Result<f64, MemoryError> {
316 if let Some(&idf) = cache.get(&hub_id) {
317 return Ok(idf);
318 }
319 let idf = self.entity_idf(hub_id)?;
320 cache.insert(hub_id, idf);
321 Ok(idf)
322 }
323
324 /// Normalised inverse document frequency of hub `hub_id`, in `[0, 1]`:
325 /// `1` when it links a single fact (maximally specific), trending to `0`
326 /// as it links ever more (a generic mega-hub whose links carry little
327 /// answer signal). Mirrors the `LoCoMo` harness formula
328 /// (`examples/locomo/ingest.rs`), using the store's total memory count
329 /// (facts + hubs) as a corpus-size proxy.
330 fn entity_idf(&self, hub_id: u64) -> Result<f64, MemoryError> {
331 let degree = self.store.relations(hub_id)?.len();
332 let n = self.store.count();
333 if degree == 0 || n <= 1 {
334 return Ok(0.0);
335 }
336 #[allow(clippy::cast_precision_loss)] // corpus/degree sizes are far below f64's exact range
337 let (n, d) = (n as f64, degree as f64);
338 Ok((n / d).ln() / n.ln())
339 }
340}
341
342/// Index of `mentions` edges by target, built once per [`MemoryService::graph_reached`]
343/// call: `fact_id -> [hub_id, ...]`. [`MemoryService::reach_weight`] used to
344/// rescan every edge in the traversal for each reached node, making
345/// `graph_reached` quadratic in the number of facts a hub mentions (a single
346/// entity accumulating history is the product's nominal use case, not an edge
347/// case). This index turns that per-node scan into an O(1) lookup, so the
348/// whole pass over `edges` costs O(edges) once instead of O(edges) per node.
349fn index_mentions_edges(edges: &[MemoryEdge]) -> HashMap<u64, Vec<u64>> {
350 let mut index: HashMap<u64, Vec<u64>> = HashMap::new();
351 for edge in edges {
352 if edge.relation == MENTIONS_RELATION {
353 index.entry(edge.to).or_default().push(edge.from);
354 }
355 }
356 index
357}
358
359/// True when `filter` is absent, or every key in it matches `metadata`
360/// exactly — the same "all filter keys must match" semantics
361/// [`MemoryService::search`]'s vector-side filtering applies, now also
362/// enforced on graph-reached facts so a caller-scoped `recall_fused` can't
363/// leak a fact outside that scope just because it's graph-connected to the
364/// seed.
365fn matches_filter(metadata: Option<&Metadata>, filter: Option<&Metadata>) -> bool {
366 let Some(filter) = filter else {
367 return true;
368 };
369 // Mirrors velesdb-core's `payload_matches`: an empty (not absent) filter
370 // matches everything, including a metadata-less fact — `Some({})` from a
371 // caller (e.g. a JS `recallFused(q, k, {})`) must behave exactly like
372 // `None`, not like "reject anything without metadata".
373 if filter.is_empty() {
374 return true;
375 }
376 let Some(metadata) = metadata else {
377 return false;
378 };
379 filter.iter().all(|(k, v)| metadata.get(k) == Some(v))
380}
381
382/// The oversampled candidate pool depth for a `k`-sized fused recall:
383/// `opts.pool` if the caller set one (floored at 1), else the proven default
384/// ([`fusion::pool_size`]) — either way, capped at
385/// [`crate::limits::MAX_RECALL_LIMIT`], the same `DoS` ceiling `k`/`hops`
386/// carry. Both bounds live here, not at each binding's FFI boundary:
387/// - the floor of 1 stops an explicit `pool` of 0 (a binding now exposes the
388/// knob: `options={"pool": 0}` in Python) from oversampling *zero* candidates
389/// and returning nothing. A caller can still deliberately narrow the pool
390/// below the default (e.g. `pool: 1` to admit only the top vector hit — the
391/// documented behavior fusion's tests pin); the floor only rules out the
392/// degenerate empty-set case, it does not force a minimum recall depth.
393/// - the cap bounds the default too: `k.saturating_mul(8)` exceeds the limit
394/// well before `k` itself does, so even a caller who never touches `pool` is
395/// bounded.
396fn pool_depth(k: usize, opts: FusionOptions) -> usize {
397 let depth = opts.pool.map_or_else(|| fusion::pool_size(k), |p| p.max(1));
398 crate::limits::clamp_recall_limit(depth)
399}