use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use oneai_core::traits::{
EmbeddingService, FusionMode, KeywordBackend, Metadata, RerankDoc, RerankerProvider,
RetrievalBackend, RetrievalHit, RetrievalRequest, SearchMode, VectorBackend, VectorHit,
};
use tokio::sync::Mutex;
use tracing::warn;
use crate::fusion::{dbsf_fuse, rrf_fuse};
use crate::merge_meta;
#[cfg(feature = "tantivy")]
use crate::{InMemoryVectorBackend, TantivyBm25Backend};
#[derive(Debug, Clone)]
pub struct StandardRetrievalPipelineConfig {
pub rerank_pool: usize,
}
impl Default for StandardRetrievalPipelineConfig {
fn default() -> Self {
Self { rerank_pool: 150 }
}
}
pub struct StandardRetrievalPipeline {
vector: Option<Arc<dyn VectorBackend>>,
keyword: Option<Arc<dyn KeywordBackend>>,
embedder: Option<Arc<dyn EmbeddingService>>,
reranker: Option<Arc<dyn RerankerProvider>>,
content: Mutex<HashMap<String, (String, Metadata)>>,
config: StandardRetrievalPipelineConfig,
}
impl StandardRetrievalPipeline {
pub fn builder() -> StandardRetrievalPipelineBuilder {
StandardRetrievalPipelineBuilder::default()
}
pub fn new(
vector: Option<Arc<dyn VectorBackend>>,
keyword: Option<Arc<dyn KeywordBackend>>,
embedder: Option<Arc<dyn EmbeddingService>>,
reranker: Option<Arc<dyn RerankerProvider>>,
) -> Self {
Self {
vector,
keyword,
embedder,
reranker,
content: Mutex::new(HashMap::new()),
config: StandardRetrievalPipelineConfig::default(),
}
}
pub async fn in_memory_default(
embedder: Option<Arc<dyn EmbeddingService>>,
reranker: Option<Arc<dyn RerankerProvider>>,
) -> oneai_core::Result<Arc<dyn RetrievalBackend>> {
let keyword: Arc<dyn KeywordBackend> = Arc::new(TantivyBm25Backend::in_memory()?);
let mut builder = StandardRetrievalPipelineBuilder::default().keyword(keyword);
if let Some(e) = embedder.clone() {
let dim = e.actual_dimension().await?;
if dim > 0 {
let vector: Arc<dyn VectorBackend> = Arc::new(InMemoryVectorBackend::new(dim));
builder = builder.vector(vector).embedder(e);
} else {
tracing::warn!(
"StandardRetrievalPipeline::in_memory_default: embedder reported dim=0; \
building keyword-only pipeline"
);
}
}
if let Some(r) = reranker {
builder = builder.reranker(r);
}
Ok(Arc::new(builder.build()) as Arc<dyn RetrievalBackend>)
}
#[cfg(not(feature = "tantivy"))]
pub async fn in_memory_default(
_embedder: Option<Arc<dyn EmbeddingService>>,
_reranker: Option<Arc<dyn RerankerProvider>>,
) -> oneai_core::Result<Arc<dyn RetrievalBackend>> {
Err(oneai_core::OneAIError::Rag(
"StandardRetrievalPipeline::in_memory_default requires the `tantivy` feature \
(on by default) — it provides the in-memory BM25 lexical leg"
.to_string(),
))
}
#[cfg(feature = "tantivy")]
pub async fn in_memory_default_with_dim(
dim: Option<usize>,
reranker: Option<Arc<dyn RerankerProvider>>,
) -> oneai_core::Result<Arc<dyn RetrievalBackend>> {
let keyword: Arc<dyn KeywordBackend> = Arc::new(TantivyBm25Backend::in_memory()?);
let mut builder = StandardRetrievalPipelineBuilder::default().keyword(keyword);
if let Some(d) = dim.filter(|d| *d > 0) {
builder = builder.vector(Arc::new(InMemoryVectorBackend::new(d)));
}
if let Some(r) = reranker {
builder = builder.reranker(r);
}
Ok(Arc::new(builder.build()) as Arc<dyn RetrievalBackend>)
}
#[cfg(not(feature = "tantivy"))]
pub async fn in_memory_default_with_dim(
_dim: Option<usize>,
_reranker: Option<Arc<dyn RerankerProvider>>,
) -> oneai_core::Result<Arc<dyn RetrievalBackend>> {
Err(oneai_core::OneAIError::Rag(
"StandardRetrievalPipeline::in_memory_default_with_dim requires the `tantivy` \
feature (on by default) — it provides the in-memory BM25 lexical leg"
.to_string(),
))
}
}
#[derive(Default)]
pub struct StandardRetrievalPipelineBuilder {
vector: Option<Arc<dyn VectorBackend>>,
keyword: Option<Arc<dyn KeywordBackend>>,
embedder: Option<Arc<dyn EmbeddingService>>,
reranker: Option<Arc<dyn RerankerProvider>>,
config: StandardRetrievalPipelineConfig,
}
impl StandardRetrievalPipelineBuilder {
pub fn vector(mut self, v: Arc<dyn VectorBackend>) -> Self {
self.vector = Some(v);
self
}
pub fn keyword(mut self, k: Arc<dyn KeywordBackend>) -> Self {
self.keyword = Some(k);
self
}
pub fn embedder(mut self, e: Arc<dyn EmbeddingService>) -> Self {
self.embedder = Some(e);
self
}
pub fn reranker(mut self, r: Arc<dyn RerankerProvider>) -> Self {
self.reranker = Some(r);
self
}
pub fn config(mut self, c: StandardRetrievalPipelineConfig) -> Self {
self.config = c;
self
}
pub fn build(self) -> StandardRetrievalPipeline {
StandardRetrievalPipeline {
vector: self.vector,
keyword: self.keyword,
embedder: self.embedder,
reranker: self.reranker,
content: Mutex::new(HashMap::new()),
config: self.config,
}
}
}
#[async_trait]
impl RetrievalBackend for StandardRetrievalPipeline {
async fn search_hybrid(&self, req: &RetrievalRequest) -> oneai_core::Result<Vec<RetrievalHit>> {
let fetch_k = req.top_k.max(self.config.rerank_pool).max(1);
let filter = req.filter.as_ref();
let need_vector = matches!(req.mode, SearchMode::Vector | SearchMode::Hybrid);
let need_keyword = matches!(req.mode, SearchMode::Keyword | SearchMode::Hybrid);
let mut legs: Vec<Vec<VectorHit>> = Vec::new();
let mut leg_metas: HashMap<String, Metadata> = HashMap::new();
if need_vector {
if let Some(vb) = &self.vector {
let embedding = match &req.embedding {
Some(e) => Some(e.clone()),
None => match &self.embedder {
Some(emb) => Some(emb.embed(&req.text).await?),
None => {
warn!(
"StandardRetrievalPipeline: dense leg requested but no embedding \
supplied and no EmbeddingService configured — degrading to keyword-only"
);
None
}
},
};
if let Some(emb) = embedding {
let hits = vb.search(&emb, fetch_k, filter).await?;
for h in &hits {
leg_metas
.entry(h.id.clone())
.and_modify(|m| *m = merge_meta(m, &h.metadata))
.or_insert_with(|| h.metadata.clone());
}
legs.push(hits);
}
} else {
warn!("StandardRetrievalPipeline: dense leg requested but no VectorBackend configured");
}
}
if need_keyword {
if let Some(kb) = &self.keyword {
let hits = kb.search(&req.text, fetch_k, filter).await?;
for h in &hits {
leg_metas
.entry(h.id.clone())
.and_modify(|m| *m = merge_meta(m, &h.metadata))
.or_insert_with(|| h.metadata.clone());
}
legs.push(hits);
} else {
warn!("StandardRetrievalPipeline: lexical leg requested but no KeywordBackend configured");
}
}
let leg_scores: Vec<Vec<(String, f32)>> = legs
.iter()
.map(|leg| leg.iter().map(|h| (h.id.clone(), h.score)).collect())
.collect();
let fused: Vec<(String, f32)> = match &req.fusion {
FusionMode::Rrf { k, weights } => rrf_fuse(&leg_scores, *k, weights.as_deref()),
FusionMode::Dbsf => dbsf_fuse(&leg_scores),
_ => rrf_fuse(&leg_scores, 60, None),
};
let pool = fused.len().min(self.config.rerank_pool.max(req.top_k));
let mut hits: Vec<RetrievalHit> = Vec::with_capacity(pool);
let content = self.content.lock().await;
for (id, score) in fused.into_iter().take(pool) {
let (c, m) = match content.get(&id) {
Some((c, m)) => (c.clone(), m.clone()),
None => (
String::new(),
leg_metas.get(&id).cloned().unwrap_or_default(),
),
};
hits.push(RetrievalHit {
id,
content: c,
score,
embedding: None,
metadata: m,
});
}
drop(content);
if let Some(reranker) = &self.reranker {
if hits.is_empty() {
return Ok(Vec::new());
}
let docs: Vec<RerankDoc> = hits
.iter()
.map(|h| RerankDoc::new(h.id.clone(), h.content.clone()))
.collect();
let ranked = reranker.rerank(&req.text, &docs, req.top_k).await?;
let content = self.content.lock().await;
let out: Vec<RetrievalHit> = ranked
.into_iter()
.map(|r| {
let metadata = content
.get(&r.id)
.map(|(_, m)| m.clone())
.or_else(|| leg_metas.get(&r.id).cloned())
.unwrap_or_default();
RetrievalHit {
id: r.id,
content: r.content,
score: r.score,
embedding: None,
metadata,
}
})
.collect();
Ok(out)
} else {
hits.truncate(req.top_k);
Ok(hits)
}
}
async fn upsert_chunk(
&self,
id: &str,
content: &str,
metadata: Metadata,
embedding: Option<&[f32]>,
) -> oneai_core::Result<()> {
let computed = match (embedding, &self.embedder) {
(Some(_), _) => None,
(None, Some(emb)) => Some(emb.embed(content).await?),
(None, None) => None,
};
let emb_to_store: Option<&[f32]> = embedding.or(computed.as_deref());
{
let mut cache = self.content.lock().await;
cache.insert(id.to_string(), (content.to_string(), metadata.clone()));
}
if let (Some(vb), Some(emb)) = (&self.vector, emb_to_store) {
vb.upsert(id, emb, metadata.clone()).await?;
}
if let Some(kb) = &self.keyword {
kb.upsert_doc(id, content, metadata).await?;
}
Ok(())
}
async fn delete(&self, id: &str) -> oneai_core::Result<()> {
{
let mut cache = self.content.lock().await;
cache.remove(id);
}
if let Some(vb) = &self.vector {
let _ = vb.delete(id).await;
}
if let Some(kb) = &self.keyword {
let _ = kb.delete(id).await;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::InMemoryVectorBackend;
use oneai_core::traits::Filter;
struct StubEmbedder;
#[async_trait]
impl EmbeddingService for StubEmbedder {
async fn embed(&self, text: &str) -> oneai_core::Result<Vec<f32>> {
let mut v = vec![0.0f32; 4];
for (i, b) in text.as_bytes().iter().enumerate() {
v[i % 4] += *b as f32;
}
let n = (v.iter().map(|x| x * x).sum::<f32>().sqrt()).max(1e-6);
v.iter_mut().for_each(|x| *x /= n);
Ok(v)
}
async fn embed_batch(&self, texts: &[String]) -> oneai_core::Result<Vec<Vec<f32>>> {
let mut out = Vec::with_capacity(texts.len());
for t in texts {
out.push(self.embed(t).await?);
}
Ok(out)
}
fn model(&self) -> oneai_core::EmbeddingModel {
oneai_core::EmbeddingModel::new("stub-4d")
}
}
fn make_pipeline() -> StandardRetrievalPipeline {
let vector = Arc::new(InMemoryVectorBackend::new(4));
StandardRetrievalPipeline::builder()
.vector(vector)
.keyword(Arc::new(crate::TantivyBm25Backend::in_memory().unwrap()))
.embedder(Arc::new(StubEmbedder))
.build()
}
#[tokio::test]
async fn upsert_and_hybrid_search() {
let pipe = make_pipeline();
pipe.upsert_chunk(
"d1",
"今天天气不错适合出门散步",
Metadata::from([("tag".into(), "weather".into())]),
None,
)
.await
.unwrap();
pipe.upsert_chunk(
"d2",
"机器学习是人工智能的一个分支",
Metadata::from([("tag".into(), "ai".into())]),
None,
)
.await
.unwrap();
let req = RetrievalRequest::keyword("人工智能", 5);
let hits = pipe.search_hybrid(&req).await.unwrap();
assert!(hits.iter().any(|h| h.id == "d2"));
assert!(hits.iter().any(|h| h.content.contains("机器学习")));
let emb = StubEmbedder.embed("今天天气").await.unwrap();
let req = RetrievalRequest::hybrid("天气", emb, 5)
.with_filter(Filter::new().with_eq("tag", "weather"));
let hits = pipe.search_hybrid(&req).await.unwrap();
assert!(hits.iter().all(|h| h.metadata["tag"] == "weather"));
pipe.delete("d1").await.unwrap();
let hits = pipe
.search_hybrid(&RetrievalRequest::keyword("天气", 5))
.await
.unwrap();
assert!(!hits.iter().any(|h| h.id == "d1"));
}
#[tokio::test]
async fn rrf_fuses_dense_and_lexical() {
let pipe = make_pipeline();
pipe.upsert_chunk("d1", "alpha beta gamma", Metadata::new(), None)
.await
.unwrap();
pipe.upsert_chunk("d2", "delta epsilon zeta", Metadata::new(), None)
.await
.unwrap();
let emb = StubEmbedder.embed("alpha beta gamma").await.unwrap();
let req = RetrievalRequest::hybrid("alpha beta", emb, 5);
let hits = pipe.search_hybrid(&req).await.unwrap();
assert_eq!(hits[0].id, "d1");
}
#[tokio::test]
async fn in_memory_default_with_embedder_builds_dense_leg() {
let backend =
StandardRetrievalPipeline::in_memory_default(Some(Arc::new(StubEmbedder)), None)
.await
.unwrap();
backend
.upsert_chunk("d1", "rust programming language", Metadata::new(), None)
.await
.unwrap();
let emb = StubEmbedder.embed("rust language").await.unwrap();
let req = RetrievalRequest::vector("rust", emb, 5);
let hits = backend.search_hybrid(&req).await.unwrap();
assert!(hits
.iter()
.any(|h| h.id == "d1" && h.content.contains("rust")));
}
#[tokio::test]
async fn in_memory_default_without_embedder_degrades_to_keyword() {
let backend = StandardRetrievalPipeline::in_memory_default(None, None)
.await
.unwrap();
backend
.upsert_chunk("d1", "机器学习是人工智能的一个分支", Metadata::new(), None)
.await
.unwrap();
let req = RetrievalRequest::vector("人工智能", vec![0.0; 4], 5);
assert!(backend.search_hybrid(&req).await.unwrap().is_empty());
let req = RetrievalRequest::keyword("人工智能", 5);
let hits = backend.search_hybrid(&req).await.unwrap();
assert!(hits.iter().any(|h| h.content.contains("机器学习")));
}
}