Skip to main content

lunaris_retrieve/operators/
keyword.rs

1//! `Keyword::bm25(index, k)` — runs `KeywordPort::keyword_search`.
2//!
3//! `MoonStorage` impls `KeywordPort` (0.7.0 is Moon-only; the `PostgresStorage`
4//! impl went with its backend). This operator stays backend-agnostic — the
5//! [`super::QueryContext`] holds the right `Arc<dyn KeywordPort>` for the
6//! handle that built it.
7
8use std::any::Any;
9
10use async_trait::async_trait;
11use lunaris_core::LunarisError;
12
13use super::{QueryContext, Retriever, clamp_k};
14use crate::types::{RawHit, SourceOp};
15
16/// Keyword (BM25) retrieval operator.
17#[derive(Clone, Debug)]
18#[must_use = "Keyword is a query node — pass it to RetrievalBuilder::with_root() or chain via .and/.or/.fuse_rrf, otherwise it never executes"]
19pub struct Keyword {
20    pub index: String,
21    pub k: usize,
22}
23
24impl Keyword {
25    /// Build a new BM25 operator. `k` is clamped to [`super::MAX_K`].
26    pub fn bm25(index: impl Into<String>, k: usize) -> Self {
27        Self { index: index.into(), k: clamp_k(k) }
28    }
29
30    pub fn and<R: Retriever + 'static>(self, other: R) -> super::combinators::AndRetriever {
31        super::combinators::AndRetriever::new(Box::new(self), Box::new(other))
32    }
33    pub fn or<R: Retriever + 'static>(self, other: R) -> super::combinators::OrRetriever {
34        super::combinators::OrRetriever::new(Box::new(self), Box::new(other))
35    }
36    pub fn then<R: Retriever + 'static>(self, other: R) -> super::combinators::ThenRetriever {
37        super::combinators::ThenRetriever::new(Box::new(self), Box::new(other))
38    }
39    pub fn top(self, n: usize) -> super::modifiers::TopRetriever {
40        super::modifiers::TopRetriever::new(Box::new(self), n)
41    }
42
43    /// Wrap with a cross-encoder rerank pass (Plan 02-03).
44    pub fn rerank(
45        self,
46        reranker: std::sync::Arc<dyn lunaris_rerank::Reranker>,
47    ) -> super::rerank::RerankRetriever {
48        super::rerank::RerankRetriever::new(Box::new(self), reranker)
49    }
50
51    /// Wrap with a fallback retriever — if THIS keyword path errors, switch to
52    /// `fallback` and tag returned hits with `degraded: true` (Plan 02-03).
53    pub fn degraded_fallback<R: Retriever + 'static>(
54        self,
55        fallback: R,
56    ) -> super::degraded::DegradedFallbackRetriever {
57        super::degraded::DegradedFallbackRetriever::new(Box::new(self), Box::new(fallback))
58    }
59}
60
61#[async_trait]
62impl Retriever for Keyword {
63    async fn retrieve(&self, ctx: &QueryContext) -> Result<Vec<RawHit>, LunarisError> {
64        // Wave 2.5A: thread scope into keyword_search (RFC 0001 §3.4 amendment).
65        // Wave 2.5C plumbs the real per-call scope from ctx.scope; until then
66        // ctx.scope == Scope::dev() for bare Lunaris::recall() callers and the
67        // caller-supplied scope for ScopedLunaris::recall() callers.
68        // F1: same rule as the vector leg — an absent index means no rows.
69        let hits = crate::missing_index::no_rows_if_index_absent(
70            ctx.keyword
71                .keyword_search(
72                    &ctx.scope,
73                    &self.index,
74                    &ctx.query.text,
75                    self.k,
76                    ctx.query.filter.as_ref(),
77                    ctx.query.as_of,
78                )
79                .await,
80        )?;
81        Ok(hits
82            .into_iter()
83            .map(|h| RawHit {
84                id: h.id,
85                score: h.score,
86                rerank_applied: false,
87                degraded: false,
88                metadata: super::tag_leg_index(h.metadata, &self.index),
89                source_op: SourceOp::Keyword,
90            })
91            .collect())
92    }
93
94    fn as_any(&self) -> &dyn Any {
95        self
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn keyword_bm25_records_fields() {
105        let k = Keyword::bm25("chunks", 30);
106        assert_eq!(k.index, "chunks");
107        assert_eq!(k.k, 30);
108    }
109
110    #[test]
111    fn keyword_k_is_clamped() {
112        let k = Keyword::bm25("chunks", 1_000_000);
113        assert_eq!(k.k, super::super::MAX_K);
114    }
115}