pub mod bm25;
pub mod fusion;
use crate::embed::Embedder;
use crate::llm::ChatModel;
use crate::model::{RetrievalMode, Scored};
use crate::store::VectorStore;
use crate::{RagError, Result};
use std::sync::Arc;
#[derive(Clone)]
pub struct Retriever {
store: Arc<dyn VectorStore>,
embedder: Arc<dyn Embedder>,
chat: Option<Arc<dyn ChatModel>>,
rrf_k: f32,
multiquery_n: usize,
}
impl Retriever {
pub fn new(
store: Arc<dyn VectorStore>,
embedder: Arc<dyn Embedder>,
chat: Option<Arc<dyn ChatModel>>,
) -> Self {
Retriever {
store,
embedder,
chat,
rrf_k: fusion::DEFAULT_RRF_K,
multiquery_n: 4,
}
}
pub fn with_rrf_k(mut self, k: f32) -> Self {
self.rrf_k = k;
self
}
pub fn with_multiquery_n(mut self, n: usize) -> Self {
self.multiquery_n = n.max(1);
self
}
pub async fn retrieve(
&self,
mode: RetrievalMode,
query: &str,
k: usize,
) -> Result<Vec<Scored>> {
match mode {
RetrievalMode::Vector => self.vector(query, k).await,
RetrievalMode::Bm25 => self.bm25(query, k).await,
RetrievalMode::Hybrid => self.hybrid(query, k).await,
RetrievalMode::MultiQuery => self.multi_query(query, k).await,
RetrievalMode::Hyde => self.hyde(query, k).await,
}
}
pub async fn vector(&self, query: &str, k: usize) -> Result<Vec<Scored>> {
let emb = self.embedder.embed_one(query).await?;
self.store.vector_search(&emb, k).await
}
pub async fn bm25(&self, query: &str, k: usize) -> Result<Vec<Scored>> {
let chunks = self.store.all_chunks().await?;
let index = bm25::Bm25Index::build(chunks);
Ok(index.search(query, k))
}
pub async fn hybrid(&self, query: &str, k: usize) -> Result<Vec<Scored>> {
let depth = (k * 4).max(20);
let vec_hits = self.vector(query, depth).await?;
let bm_hits = self.bm25(query, depth).await?;
Ok(fusion::rrf(&[vec_hits, bm_hits], self.rrf_k, k))
}
pub async fn multi_query(&self, query: &str, k: usize) -> Result<Vec<Scored>> {
let chat = self.require_chat()?;
let system = "You rewrite a user's question into diverse search queries that \
surface relevant documents. Output only the queries, one per line, \
with no numbering or commentary.";
let user = format!(
"Rewrite this question into {} diverse search queries:\n\n{}",
self.multiquery_n, query
);
let raw = chat.ask(system, &user).await?;
let mut queries: Vec<String> = raw
.lines()
.map(|l| {
l.trim()
.trim_start_matches(|c: char| {
c.is_ascii_digit() || c == '.' || c == '-' || c == ')'
})
.trim()
})
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.take(self.multiquery_n)
.collect();
queries.push(query.to_string());
let depth = (k * 4).max(20);
let mut rankings = Vec::with_capacity(queries.len());
for q in &queries {
rankings.push(self.hybrid(q, depth).await?);
}
Ok(fusion::rrf(&rankings, self.rrf_k, k))
}
pub async fn hyde(&self, query: &str, k: usize) -> Result<Vec<Scored>> {
let chat = self.require_chat()?;
let system = "You are helping a search system. Write a short, factual passage \
(2-4 sentences) that could plausibly answer the user's question, as \
if quoted from a relevant document. Do not hedge or mention that it \
is hypothetical.";
let hypothetical = chat.ask(system, query).await?;
let search_text = if hypothetical.trim().is_empty() {
query
} else {
&hypothetical
};
let emb = self.embedder.embed_one(search_text).await?;
self.store.vector_search(&emb, k).await
}
fn require_chat(&self) -> Result<&Arc<dyn ChatModel>> {
self.chat.as_ref().ok_or_else(|| {
RagError::Llm("this retrieval mode needs an LLM; set OPENROUTER_API_KEY".into())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embed::HashEmbedder;
use crate::llm::Message;
use crate::model::{Chunk, Document};
use crate::store::memory::MemoryStore;
use async_trait::async_trait;
async fn seeded_store() -> Arc<dyn VectorStore> {
let store = Arc::new(MemoryStore::new());
let embedder = HashEmbedder::new(512);
let doc = Document::new("mem://t", "T", "h");
store.upsert_document(&doc).await.unwrap();
let texts = [
"postgres vector database stores embeddings for semantic search",
"a banana smoothie recipe with yogurt and honey",
"rust async runtime tokio spawns tasks on a thread pool",
];
let mut chunks = Vec::new();
for (i, t) in texts.iter().enumerate() {
let mut c = Chunk::new(&doc.id, i as i64, *t, 0);
c.embedding = Some(
crate::embed::Embedder::embed(&embedder, &[t.to_string()])
.await
.unwrap()
.pop()
.unwrap(),
);
chunks.push(c);
}
store.insert_chunks(&chunks).await.unwrap();
store
}
#[tokio::test]
async fn vector_bm25_hybrid_find_relevant_chunk() {
let store = seeded_store().await;
let embedder: Arc<dyn Embedder> = Arc::new(HashEmbedder::new(512));
let r = Retriever::new(store, embedder, None);
for mode in RetrievalMode::OFFLINE {
let hits = r
.retrieve(mode, "semantic search vector database", 3)
.await
.unwrap();
assert!(!hits.is_empty(), "{mode} returned nothing");
assert!(
hits[0].chunk.text.contains("vector database"),
"{mode} ranked the wrong chunk first: {}",
hits[0].chunk.text
);
}
}
struct MockChat;
#[async_trait]
impl ChatModel for MockChat {
async fn complete(&self, messages: &[Message]) -> Result<String> {
let user = messages.last().map(|m| m.content.as_str()).unwrap_or("");
if user.contains("Rewrite") {
Ok("vector database\nsemantic search embeddings\npostgres storage".into())
} else {
Ok("A vector database stores embeddings and performs semantic search.".into())
}
}
}
#[tokio::test]
async fn multiquery_and_hyde_use_the_llm() {
let store = seeded_store().await;
let embedder: Arc<dyn Embedder> = Arc::new(HashEmbedder::new(512));
let chat: Arc<dyn ChatModel> = Arc::new(MockChat);
let r = Retriever::new(store, embedder, Some(chat));
let mq = r
.retrieve(RetrievalMode::MultiQuery, "how are embeddings stored?", 3)
.await
.unwrap();
assert!(mq.iter().any(|h| h.chunk.text.contains("vector database")));
let hyde = r
.retrieve(RetrievalMode::Hyde, "how are embeddings stored?", 3)
.await
.unwrap();
assert!(hyde
.iter()
.any(|h| h.chunk.text.contains("vector database")));
}
#[tokio::test]
async fn llm_modes_error_without_chat() {
let store = seeded_store().await;
let embedder: Arc<dyn Embedder> = Arc::new(HashEmbedder::new(512));
let r = Retriever::new(store, embedder, None);
assert!(r.retrieve(RetrievalMode::Hyde, "q", 3).await.is_err());
}
}