Skip to main content

lunaris_retrieve/operators/
vector.rs

1//! `Vector::new(index, k)` — embed query, run `StoragePort::vector_search`.
2//!
3//! Per the [`super::QueryContext`] convention, this operator calls
4//! [`super::QueryContext::embed_once`] which is cached per call so chained
5//! `.and(Vector::new(..), other)` operators don't re-embed the query.
6
7use std::any::Any;
8
9use async_trait::async_trait;
10use lunaris_core::LunarisError;
11
12use super::{QueryContext, Retriever, clamp_k};
13use crate::types::{RawHit, SourceOp};
14
15/// Vector retrieval operator.
16///
17/// Construction is fluent — you wire it up at `recall()`-build time, the
18/// network call only happens at `.execute()` time.
19#[derive(Clone, Debug)]
20#[must_use = "Vector is a query node — pass it to RetrievalBuilder::with_root() or chain via .and/.or/.fuse_rrf, otherwise it never executes"]
21pub struct Vector {
22    pub index: String,
23    pub k: usize,
24}
25
26impl Vector {
27    /// Build a new `Vector` operator targeting the given backend index
28    /// (e.g., `"chunks"`) with `k` candidates.
29    ///
30    /// `k` is clamped to [`super::MAX_K`] (T-02-02-03 DoS mitigation).
31    pub fn new(index: impl Into<String>, k: usize) -> Self {
32        Self { index: index.into(), k: clamp_k(k) }
33    }
34
35    /// Convenience: wrap into the standard combinator builder. Implemented in
36    /// `combinators` to avoid trait-method orphan rules with the generic
37    /// `.and / .or / .then`.
38    pub fn and<R: Retriever + 'static>(self, other: R) -> super::combinators::AndRetriever {
39        super::combinators::AndRetriever::new(Box::new(self), Box::new(other))
40    }
41    pub fn or<R: Retriever + 'static>(self, other: R) -> super::combinators::OrRetriever {
42        super::combinators::OrRetriever::new(Box::new(self), Box::new(other))
43    }
44    pub fn then<R: Retriever + 'static>(self, other: R) -> super::combinators::ThenRetriever {
45        super::combinators::ThenRetriever::new(Box::new(self), Box::new(other))
46    }
47    /// Cap the final result set after the operator tree resolves.
48    pub fn top(self, n: usize) -> super::modifiers::TopRetriever {
49        super::modifiers::TopRetriever::new(Box::new(self), n)
50    }
51
52    /// Wrap with a cross-encoder rerank pass (Plan 02-03).
53    pub fn rerank(
54        self,
55        reranker: std::sync::Arc<dyn lunaris_rerank::Reranker>,
56    ) -> super::rerank::RerankRetriever {
57        super::rerank::RerankRetriever::new(Box::new(self), reranker)
58    }
59
60    /// Wrap with a fallback retriever — if THIS vector path errors, switch to
61    /// `fallback` and tag returned hits with `degraded: true` (Plan 02-03).
62    pub fn degraded_fallback<R: Retriever + 'static>(
63        self,
64        fallback: R,
65    ) -> super::degraded::DegradedFallbackRetriever {
66        super::degraded::DegradedFallbackRetriever::new(Box::new(self), Box::new(fallback))
67    }
68}
69
70#[async_trait]
71impl Retriever for Vector {
72    async fn retrieve(&self, ctx: &QueryContext) -> Result<Vec<RawHit>, LunarisError> {
73        let q_emb = ctx.embed_once().await?;
74        // Wave 2.5C: use ctx.scope — plumbed from RetrievalBuilder::with_scope
75        // (set by ScopedLunaris::recall/dsl) so vector_search is scope-isolated
76        // at the storage layer. Bare Lunaris::recall() uses Scope::dev().
77        // F1: a scope with no writes has no FT index yet. That is "no rows",
78        // not an error — see `crate::missing_index`.
79        let hits = crate::missing_index::no_rows_if_index_absent(
80            ctx.storage
81                .vector_search(
82                    &ctx.scope,
83                    &self.index,
84                    &q_emb,
85                    self.k,
86                    ctx.query.filter.as_ref(),
87                    ctx.query.as_of,
88                    false,
89                )
90                .await,
91        )?;
92        Ok(hits
93            .into_iter()
94            .map(|h| RawHit {
95                id: h.id,
96                score: h.score,
97                rerank_applied: h.rerank_applied,
98                degraded: false,
99                metadata: super::tag_leg_index(h.metadata, &self.index),
100                source_op: SourceOp::Vector,
101            })
102            .collect())
103    }
104
105    fn as_any(&self) -> &dyn Any {
106        self
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn k_is_clamped() {
116        let v = Vector::new("chunks", 1_000_000);
117        assert_eq!(v.k, super::super::MAX_K);
118    }
119
120    #[test]
121    fn vector_new_records_index() {
122        let v = Vector::new("chunks", 30);
123        assert_eq!(v.index, "chunks");
124        assert_eq!(v.k, 30);
125    }
126}