1use 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#[derive(Debug, Clone)]
51pub struct StandardRetrievalPipelineConfig {
52 pub rerank_pool: usize,
55}
56
57impl Default for StandardRetrievalPipelineConfig {
58 fn default() -> Self {
59 Self { rerank_pool: 150 }
60 }
61}
62
63pub 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 pub fn builder() -> StandardRetrievalPipelineBuilder {
76 StandardRetrievalPipelineBuilder::default()
77 }
78
79 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 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 let keyword: Arc<dyn KeywordBackend> = Arc::new(TantivyBm25Backend::in_memory()?);
119
120 let mut builder = StandardRetrievalPipelineBuilder::default().keyword(keyword);
121
122 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 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 #[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#[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 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 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 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 _ => rrf_fuse(&leg_scores, 60, None),
317 };
318
319 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 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 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 struct StubEmbedder;
431 #[async_trait]
432 impl EmbeddingService for StubEmbedder {
433 async fn embed(&self, text: &str) -> oneai_core::Result<Vec<f32>> {
434 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 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 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 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 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 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 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 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 let req = RetrievalRequest::vector("人工智能", vec![0.0; 4], 5);
559 assert!(backend.search_hybrid(&req).await.unwrap().is_empty());
560 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}