Skip to main content

oneai_vector/
pipeline.rs

1//! The reference hybrid retrieval pipeline — [`RetrievalBackend`] composed
2//! from a [`VectorBackend`] + [`KeywordBackend`] + optional
3//! [`EmbeddingService`] + optional [`RerankerProvider`].
4//!
5//! Flow per [`RetrievalRequest`]:
6//! 1. Run the requested legs (dense via `VectorBackend`, lexical via
7//!    `KeywordBackend`). When the dense leg is needed but the request carries
8//!    no embedding, the pipeline computes one via the configured
9//!    `EmbeddingService` (BGE-M3, or any `oneai-rag` embedder). If no embedder
10//!    is configured it logs a warning and degrades to keyword-only rather
11//!    than erroring — the same "zero-burden" posture as the embedding
12//!    rework.
13//! 2. Fuse the legs per [`FusionMode`] — RRF (k=60, Cormack 2009) by default,
14//!    DBSF if requested.
15//! 3. Attach content from the in-pipeline content cache (populated at
16//!    `upsert_chunk` time — see the design note below).
17//! 4. Rerank the top-`rerank_pool` (default 150) via `RerankerProvider` if
18//!    configured, returning `top_k`; otherwise truncate to `top_k`.
19//!
20//! This is the Anthropic "Contextual Retrieval" reference pipeline: BM25 +
21//! dense → RRF → rerank, which cut top-20 retrieval failure rate by 67%.
22//!
23//! ## Why the pipeline holds a content cache
24//!
25//! [`VectorBackend`] / [`KeywordBackend`] return [`VectorHit`] (id + score +
26//! metadata — no content), but [`RetrievalHit`] and [`RerankDoc`] need the
27//! text. The low-level traits deliberately don't expose a `get(id)` for
28//! stored text (they must not leak storage internals). The pipeline is the
29//! single insertion path (`upsert_chunk`), so it retains `id → (content,
30//! metadata)` itself. An app that brings its own `RetrievalBackend` (e.g.
31//! Qdrant) stores content in its payloads and skips this pipeline entirely.
32
33use std::collections::HashMap;
34use std::sync::Arc;
35
36use async_trait::async_trait;
37use oneai_core::traits::{
38    EmbeddingService, FusionMode, KeywordBackend, Metadata, RerankDoc, RerankerProvider,
39    RetrievalBackend, RetrievalHit, RetrievalRequest, SearchMode, VectorBackend, VectorHit,
40};
41use tokio::sync::Mutex;
42use tracing::warn;
43
44use crate::fusion::{dbsf_fuse, rrf_fuse};
45use crate::merge_meta;
46#[cfg(feature = "tantivy")]
47use crate::{InMemoryVectorBackend, TantivyBm25Backend};
48
49/// Pipeline-level configuration.
50#[derive(Debug, Clone)]
51pub struct StandardRetrievalPipelineConfig {
52    /// How many fused candidates to feed the reranker (default 150 — the
53    /// Anthropic reference). Ignored when no reranker is configured.
54    pub rerank_pool: usize,
55}
56
57impl Default for StandardRetrievalPipelineConfig {
58    fn default() -> Self {
59        Self { rerank_pool: 150 }
60    }
61}
62
63/// The reference hybrid retrieval backend.
64pub struct StandardRetrievalPipeline {
65    vector: Option<Arc<dyn VectorBackend>>,
66    keyword: Option<Arc<dyn KeywordBackend>>,
67    embedder: Option<Arc<dyn EmbeddingService>>,
68    reranker: Option<Arc<dyn RerankerProvider>>,
69    content: Mutex<HashMap<String, (String, Metadata)>>,
70    config: StandardRetrievalPipelineConfig,
71}
72
73impl StandardRetrievalPipeline {
74    /// Begin a builder.
75    pub fn builder() -> StandardRetrievalPipelineBuilder {
76        StandardRetrievalPipelineBuilder::default()
77    }
78
79    /// Construct with the given components and default config.
80    pub fn new(
81        vector: Option<Arc<dyn VectorBackend>>,
82        keyword: Option<Arc<dyn KeywordBackend>>,
83        embedder: Option<Arc<dyn EmbeddingService>>,
84        reranker: Option<Arc<dyn RerankerProvider>>,
85    ) -> Self {
86        Self {
87            vector,
88            keyword,
89            embedder,
90            reranker,
91            content: Mutex::new(HashMap::new()),
92            config: StandardRetrievalPipelineConfig::default(),
93        }
94    }
95
96    /// Build the framework's default in-memory retrieval stack as a
97    /// `RetrievalBackend`: a [`TantivyBm25Backend`] (CJK-aware BM25, in-memory)
98    /// for the lexical leg + an [`InMemoryVectorBackend`] sized to the
99    /// embedder's dimension for the dense leg + the optional reranker.
100    ///
101    /// This is the zero-config stack `oneai-rag`'s `HybridDocumentIndex` and
102    /// `oneai-memory`'s `MemoryFactStore` delegate to — real BM25 + dense →
103    /// RRF(k=60) → optional rerank, no network/backend deps beyond what the
104    /// chosen `EmbeddingService` needs.
105    ///
106    /// - `embedder = None` → keyword-only pipeline (no dense leg). The same
107    ///   "zero-burden" posture as the embedding rework: a caller with no
108    ///   embedding service still gets real BM25, never a hard error.
109    /// - `embedder = Some` → the dense leg's vector dimension is resolved via
110    ///   [`EmbeddingService::actual_dimension`] (BGE-M3 = 1024 fixed; FastEmbed
111    ///   `all-MiniLM-L6-v2` = 384 from the known-dimensions table; Ollama is
112    ///   probed by generating one test embedding).
113    pub async fn in_memory_default(
114        embedder: Option<Arc<dyn EmbeddingService>>,
115        reranker: Option<Arc<dyn RerankerProvider>>,
116    ) -> oneai_core::Result<Arc<dyn RetrievalBackend>> {
117        // Lexical leg is always available — tantivy + jieba, in-memory.
118        let keyword: Arc<dyn KeywordBackend> = Arc::new(TantivyBm25Backend::in_memory()?);
119
120        let mut builder = StandardRetrievalPipelineBuilder::default().keyword(keyword);
121
122        // Dense leg only when an embedder is configured AND its dimension is
123        // resolvable (>0). A zero-dim model (e.g. an uninitialized stub) is
124        // silently skipped so the pipeline degrades to keyword-only rather than
125        // erroring — matching the fail-safe contract documented above.
126        if let Some(e) = embedder.clone() {
127            let dim = e.actual_dimension().await?;
128            if dim > 0 {
129                let vector: Arc<dyn VectorBackend> = Arc::new(InMemoryVectorBackend::new(dim));
130                builder = builder.vector(vector).embedder(e);
131            } else {
132                tracing::warn!(
133                    "StandardRetrievalPipeline::in_memory_default: embedder reported dim=0; \
134                     building keyword-only pipeline"
135                );
136            }
137        }
138
139        if let Some(r) = reranker {
140            builder = builder.reranker(r);
141        }
142
143        Ok(Arc::new(builder.build()) as Arc<dyn RetrievalBackend>)
144    }
145
146    #[cfg(not(feature = "tantivy"))]
147    pub async fn in_memory_default(
148        _embedder: Option<Arc<dyn EmbeddingService>>,
149        _reranker: Option<Arc<dyn RerankerProvider>>,
150    ) -> oneai_core::Result<Arc<dyn RetrievalBackend>> {
151        // Without the `tantivy` feature there is no in-memory BM25 backend, so
152        // the default stack can't be assembled. Surface a clear error rather
153        // than silently building a dense-only pipeline that would mislead
154        // callers expecting hybrid retrieval.
155        Err(oneai_core::OneAIError::Rag(
156            "StandardRetrievalPipeline::in_memory_default requires the `tantivy` feature \
157             (on by default) — it provides the in-memory BM25 lexical leg"
158                .to_string(),
159        ))
160    }
161
162    /// Build the default in-memory retrieval stack with an explicit dense
163    /// dimension and **no pipeline-level embedder** — the dense leg is sized
164    /// to `dim` but embeddings must be supplied by the caller via
165    /// `upsert_chunk(.., Some(embedding))` and `RetrievalRequest::embedding`.
166    ///
167    /// Use this when the caller owns the `EmbeddingService` and wants to embed
168    /// once (e.g. `oneai-rag`'s `AutoEmbeddingDocumentIndex`, which embeds
169    /// chunks at add time and the query at search time). Use
170    /// [`in_memory_default`](Self::in_memory_default) instead when the pipeline
171    /// should auto-embed (zero-config app use).
172    ///
173    /// - `dim = None` or `0` → keyword-only pipeline (no dense leg).
174    /// - `dim = Some(d)` (d>0) → `InMemoryVectorBackend::new(d)` dense leg.
175    #[cfg(feature = "tantivy")]
176    pub async fn in_memory_default_with_dim(
177        dim: Option<usize>,
178        reranker: Option<Arc<dyn RerankerProvider>>,
179    ) -> oneai_core::Result<Arc<dyn RetrievalBackend>> {
180        let keyword: Arc<dyn KeywordBackend> = Arc::new(TantivyBm25Backend::in_memory()?);
181        let mut builder = StandardRetrievalPipelineBuilder::default().keyword(keyword);
182        if let Some(d) = dim.filter(|d| *d > 0) {
183            builder = builder.vector(Arc::new(InMemoryVectorBackend::new(d)));
184        }
185        if let Some(r) = reranker {
186            builder = builder.reranker(r);
187        }
188        Ok(Arc::new(builder.build()) as Arc<dyn RetrievalBackend>)
189    }
190
191    #[cfg(not(feature = "tantivy"))]
192    pub async fn in_memory_default_with_dim(
193        _dim: Option<usize>,
194        _reranker: Option<Arc<dyn RerankerProvider>>,
195    ) -> oneai_core::Result<Arc<dyn RetrievalBackend>> {
196        Err(oneai_core::OneAIError::Rag(
197            "StandardRetrievalPipeline::in_memory_default_with_dim requires the `tantivy` \
198             feature (on by default) — it provides the in-memory BM25 lexical leg"
199                .to_string(),
200        ))
201    }
202}
203
204/// Builder for [`StandardRetrievalPipeline`].
205#[derive(Default)]
206pub struct StandardRetrievalPipelineBuilder {
207    vector: Option<Arc<dyn VectorBackend>>,
208    keyword: Option<Arc<dyn KeywordBackend>>,
209    embedder: Option<Arc<dyn EmbeddingService>>,
210    reranker: Option<Arc<dyn RerankerProvider>>,
211    config: StandardRetrievalPipelineConfig,
212}
213
214impl StandardRetrievalPipelineBuilder {
215    pub fn vector(mut self, v: Arc<dyn VectorBackend>) -> Self {
216        self.vector = Some(v);
217        self
218    }
219    pub fn keyword(mut self, k: Arc<dyn KeywordBackend>) -> Self {
220        self.keyword = Some(k);
221        self
222    }
223    pub fn embedder(mut self, e: Arc<dyn EmbeddingService>) -> Self {
224        self.embedder = Some(e);
225        self
226    }
227    pub fn reranker(mut self, r: Arc<dyn RerankerProvider>) -> Self {
228        self.reranker = Some(r);
229        self
230    }
231    pub fn config(mut self, c: StandardRetrievalPipelineConfig) -> Self {
232        self.config = c;
233        self
234    }
235    pub fn build(self) -> StandardRetrievalPipeline {
236        StandardRetrievalPipeline {
237            vector: self.vector,
238            keyword: self.keyword,
239            embedder: self.embedder,
240            reranker: self.reranker,
241            content: Mutex::new(HashMap::new()),
242            config: self.config,
243        }
244    }
245}
246
247#[async_trait]
248impl RetrievalBackend for StandardRetrievalPipeline {
249    async fn search_hybrid(&self, req: &RetrievalRequest) -> oneai_core::Result<Vec<RetrievalHit>> {
250        let fetch_k = req.top_k.max(self.config.rerank_pool).max(1);
251        let filter = req.filter.as_ref();
252
253        let need_vector = matches!(req.mode, SearchMode::Vector | SearchMode::Hybrid);
254        let need_keyword = matches!(req.mode, SearchMode::Keyword | SearchMode::Hybrid);
255
256        let mut legs: Vec<Vec<VectorHit>> = Vec::new();
257        let mut leg_metas: HashMap<String, Metadata> = HashMap::new();
258
259        // Dense leg.
260        if need_vector {
261            if let Some(vb) = &self.vector {
262                let embedding = match &req.embedding {
263                    Some(e) => Some(e.clone()),
264                    None => match &self.embedder {
265                        Some(emb) => Some(emb.embed(&req.text).await?),
266                        None => {
267                            warn!(
268                                "StandardRetrievalPipeline: dense leg requested but no embedding \
269                                 supplied and no EmbeddingService configured — degrading to keyword-only"
270                            );
271                            None
272                        }
273                    },
274                };
275                if let Some(emb) = embedding {
276                    let hits = vb.search(&emb, fetch_k, filter).await?;
277                    for h in &hits {
278                        leg_metas
279                            .entry(h.id.clone())
280                            .and_modify(|m| *m = merge_meta(m, &h.metadata))
281                            .or_insert_with(|| h.metadata.clone());
282                    }
283                    legs.push(hits);
284                }
285            } else {
286                warn!("StandardRetrievalPipeline: dense leg requested but no VectorBackend configured");
287            }
288        }
289
290        // Lexical leg.
291        if need_keyword {
292            if let Some(kb) = &self.keyword {
293                let hits = kb.search(&req.text, fetch_k, filter).await?;
294                for h in &hits {
295                    leg_metas
296                        .entry(h.id.clone())
297                        .and_modify(|m| *m = merge_meta(m, &h.metadata))
298                        .or_insert_with(|| h.metadata.clone());
299                }
300                legs.push(hits);
301            } else {
302                warn!("StandardRetrievalPipeline: lexical leg requested but no KeywordBackend configured");
303            }
304        }
305
306        // Fuse legs → ranked (id, fused_score).
307        let leg_scores: Vec<Vec<(String, f32)>> = legs
308            .iter()
309            .map(|leg| leg.iter().map(|h| (h.id.clone(), h.score)).collect())
310            .collect();
311        let fused: Vec<(String, f32)> = match &req.fusion {
312            FusionMode::Rrf { k, weights } => rrf_fuse(&leg_scores, *k, weights.as_deref()),
313            FusionMode::Dbsf => dbsf_fuse(&leg_scores),
314            // `FusionMode` is #[non_exhaustive]; unknown variants fall back to
315            // the default RRF(k=60) so a future core addition can't break us.
316            _ => rrf_fuse(&leg_scores, 60, None),
317        };
318
319        // Attach content + metadata; cap at rerank_pool for the rerank step.
320        let pool = fused.len().min(self.config.rerank_pool.max(req.top_k));
321        let mut hits: Vec<RetrievalHit> = Vec::with_capacity(pool);
322        let content = self.content.lock().await;
323        for (id, score) in fused.into_iter().take(pool) {
324            let (c, m) = match content.get(&id) {
325                Some((c, m)) => (c.clone(), m.clone()),
326                None => (
327                    String::new(),
328                    leg_metas.get(&id).cloned().unwrap_or_default(),
329                ),
330            };
331            hits.push(RetrievalHit {
332                id,
333                content: c,
334                score,
335                embedding: None,
336                metadata: m,
337            });
338        }
339        drop(content);
340
341        // Rerank (top-150 → top-k) if a reranker is configured.
342        if let Some(reranker) = &self.reranker {
343            if hits.is_empty() {
344                return Ok(Vec::new());
345            }
346            let docs: Vec<RerankDoc> = hits
347                .iter()
348                .map(|h| RerankDoc::new(h.id.clone(), h.content.clone()))
349                .collect();
350            let ranked = reranker.rerank(&req.text, &docs, req.top_k).await?;
351            let content = self.content.lock().await;
352            let out: Vec<RetrievalHit> = ranked
353                .into_iter()
354                .map(|r| {
355                    let metadata = content
356                        .get(&r.id)
357                        .map(|(_, m)| m.clone())
358                        .or_else(|| leg_metas.get(&r.id).cloned())
359                        .unwrap_or_default();
360                    RetrievalHit {
361                        id: r.id,
362                        content: r.content,
363                        score: r.score,
364                        embedding: None,
365                        metadata,
366                    }
367                })
368                .collect();
369            Ok(out)
370        } else {
371            hits.truncate(req.top_k);
372            Ok(hits)
373        }
374    }
375
376    async fn upsert_chunk(
377        &self,
378        id: &str,
379        content: &str,
380        metadata: Metadata,
381        embedding: Option<&[f32]>,
382    ) -> oneai_core::Result<()> {
383        // Auto-embed when the caller supplied no embedding but an embedder is
384        // configured — the "zero-burden" default so a caller can index text
385        // without managing embeddings itself.
386        let computed = match (embedding, &self.embedder) {
387            (Some(_), _) => None,
388            (None, Some(emb)) => Some(emb.embed(content).await?),
389            (None, None) => None,
390        };
391        let emb_to_store: Option<&[f32]> = embedding.or(computed.as_deref());
392
393        {
394            let mut cache = self.content.lock().await;
395            cache.insert(id.to_string(), (content.to_string(), metadata.clone()));
396        }
397
398        if let (Some(vb), Some(emb)) = (&self.vector, emb_to_store) {
399            vb.upsert(id, emb, metadata.clone()).await?;
400        }
401        if let Some(kb) = &self.keyword {
402            kb.upsert_doc(id, content, metadata).await?;
403        }
404        Ok(())
405    }
406
407    async fn delete(&self, id: &str) -> oneai_core::Result<()> {
408        {
409            let mut cache = self.content.lock().await;
410            cache.remove(id);
411        }
412        if let Some(vb) = &self.vector {
413            let _ = vb.delete(id).await;
414        }
415        if let Some(kb) = &self.keyword {
416            let _ = kb.delete(id).await;
417        }
418        Ok(())
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::InMemoryVectorBackend;
426    use oneai_core::traits::Filter;
427
428    /// A deterministic stub embedder: maps text → a 4-d vector by hashing.
429    /// Lets us exercise the dense leg without ort / a model file.
430    struct StubEmbedder;
431    #[async_trait]
432    impl EmbeddingService for StubEmbedder {
433        async fn embed(&self, text: &str) -> oneai_core::Result<Vec<f32>> {
434            // Simple deterministic embedding: 4 dims from char sums.
435            let mut v = vec![0.0f32; 4];
436            for (i, b) in text.as_bytes().iter().enumerate() {
437                v[i % 4] += *b as f32;
438            }
439            let n = (v.iter().map(|x| x * x).sum::<f32>().sqrt()).max(1e-6);
440            v.iter_mut().for_each(|x| *x /= n);
441            Ok(v)
442        }
443        async fn embed_batch(&self, texts: &[String]) -> oneai_core::Result<Vec<Vec<f32>>> {
444            let mut out = Vec::with_capacity(texts.len());
445            for t in texts {
446                out.push(self.embed(t).await?);
447            }
448            Ok(out)
449        }
450        fn model(&self) -> oneai_core::EmbeddingModel {
451            oneai_core::EmbeddingModel::new("stub-4d")
452        }
453    }
454
455    fn make_pipeline() -> StandardRetrievalPipeline {
456        let vector = Arc::new(InMemoryVectorBackend::new(4));
457        StandardRetrievalPipeline::builder()
458            .vector(vector)
459            .keyword(Arc::new(crate::TantivyBm25Backend::in_memory().unwrap()))
460            .embedder(Arc::new(StubEmbedder))
461            .build()
462    }
463
464    #[tokio::test]
465    async fn upsert_and_hybrid_search() {
466        let pipe = make_pipeline();
467        // Index a few docs with both content and auto-computed embeddings.
468        pipe.upsert_chunk(
469            "d1",
470            "今天天气不错适合出门散步",
471            Metadata::from([("tag".into(), "weather".into())]),
472            None,
473        )
474        .await
475        .unwrap();
476        pipe.upsert_chunk(
477            "d2",
478            "机器学习是人工智能的一个分支",
479            Metadata::from([("tag".into(), "ai".into())]),
480            None,
481        )
482        .await
483        .unwrap();
484
485        // Keyword path finds d2 for "人工智能".
486        let req = RetrievalRequest::keyword("人工智能", 5);
487        let hits = pipe.search_hybrid(&req).await.unwrap();
488        assert!(hits.iter().any(|h| h.id == "d2"));
489        assert!(hits.iter().any(|h| h.content.contains("机器学习")));
490
491        // Hybrid path with a filter.
492        let emb = StubEmbedder.embed("今天天气").await.unwrap();
493        let req = RetrievalRequest::hybrid("天气", emb, 5)
494            .with_filter(Filter::new().with_eq("tag", "weather"));
495        let hits = pipe.search_hybrid(&req).await.unwrap();
496        assert!(hits.iter().all(|h| h.metadata["tag"] == "weather"));
497
498        // Delete removes from both legs + content cache.
499        pipe.delete("d1").await.unwrap();
500        let hits = pipe
501            .search_hybrid(&RetrievalRequest::keyword("天气", 5))
502            .await
503            .unwrap();
504        assert!(!hits.iter().any(|h| h.id == "d1"));
505    }
506
507    #[tokio::test]
508    async fn rrf_fuses_dense_and_lexical() {
509        // Two docs share no content overlap but the dense embedder makes d1
510        // closest to a query whose text also lexically matches d1 — RRF should
511        // rank d1 first.
512        let pipe = make_pipeline();
513        pipe.upsert_chunk("d1", "alpha beta gamma", Metadata::new(), None)
514            .await
515            .unwrap();
516        pipe.upsert_chunk("d2", "delta epsilon zeta", Metadata::new(), None)
517            .await
518            .unwrap();
519        let emb = StubEmbedder.embed("alpha beta gamma").await.unwrap();
520        let req = RetrievalRequest::hybrid("alpha beta", emb, 5);
521        let hits = pipe.search_hybrid(&req).await.unwrap();
522        assert_eq!(hits[0].id, "d1");
523    }
524
525    #[tokio::test]
526    async fn in_memory_default_with_embedder_builds_dense_leg() {
527        // The default-stack constructor wires both legs + the embedder, so
528        // upsert_chunk(text, None) auto-embeds and a dense-only query returns
529        // the right doc.
530        let backend =
531            StandardRetrievalPipeline::in_memory_default(Some(Arc::new(StubEmbedder)), None)
532                .await
533                .unwrap();
534        backend
535            .upsert_chunk("d1", "rust programming language", Metadata::new(), None)
536            .await
537            .unwrap();
538        let emb = StubEmbedder.embed("rust language").await.unwrap();
539        let req = RetrievalRequest::vector("rust", emb, 5);
540        let hits = backend.search_hybrid(&req).await.unwrap();
541        assert!(hits
542            .iter()
543            .any(|h| h.id == "d1" && h.content.contains("rust")));
544    }
545
546    #[tokio::test]
547    async fn in_memory_default_without_embedder_degrades_to_keyword() {
548        // No embedder → keyword-only pipeline. Dense-only queries return nothing
549        // (no vector leg), keyword queries still hit via BM25.
550        let backend = StandardRetrievalPipeline::in_memory_default(None, None)
551            .await
552            .unwrap();
553        backend
554            .upsert_chunk("d1", "机器学习是人工智能的一个分支", Metadata::new(), None)
555            .await
556            .unwrap();
557        // Dense-only with a fabricated embedding → no vector leg → empty.
558        let req = RetrievalRequest::vector("人工智能", vec![0.0; 4], 5);
559        assert!(backend.search_hybrid(&req).await.unwrap().is_empty());
560        // Keyword path hits.
561        let req = RetrievalRequest::keyword("人工智能", 5);
562        let hits = backend.search_hybrid(&req).await.unwrap();
563        assert!(hits.iter().any(|h| h.content.contains("机器学习")));
564    }
565}