use crate::sync_util::{into_inner_or_recover, lock_or_recover};
use anyhow::Result;
use log::trace;
use std::path::Path;
use std::sync::Arc;
use crate::agents::Generator;
use crate::attach::AttachStrategy;
use crate::chunker::{Chunker, MarkdownChunker};
use crate::citation::{CitationFormatter, DefaultCitationFormatter};
use crate::corpora::Corpus;
use crate::embed::Embedder;
use crate::parsers::{DocumentParsers, build_parsers};
use crate::store::VectorStore;
use crate::types::{AttachedDocument, ChunkConfig, PipelineFilter};
const DEFAULT_CHAT_WITH_DOCS: &str = "\
You are a helpful document assistant. Answer the user's question \
explicitly using the provided Context snippets.\n\
\n\
Context:\n{context}\n";
const DEFAULT_CHAT_WITHOUT_DOCS: &str = "\
You are a helpful assistant. Answer the user's question.\n";
const DEFAULT_REWRITE: &str = "\
You are a query rewriter. Given the conversation and the \
latest question, produce a single self-contained search query \
that captures all relevant context. Output ONLY the rewritten \
query, nothing else.\n\n\
Latest question: {question}";
const RESERVED_PROMPT_TOKENS: usize = 1024;
const CHARS_PER_TOKEN_ESTIMATE: usize = 3;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct StageTimings {
pub rewrite: std::time::Duration,
pub embed: std::time::Duration,
pub search: std::time::Duration,
pub prompt: std::time::Duration,
pub generate: std::time::Duration,
}
#[derive(Clone, Debug)]
pub struct RagResponse {
pub answer: String,
pub system_prompt: String,
pub user_prompt: String,
pub chunks_retrieved: Option<usize>,
pub documents: Option<Vec<crate::types::DocumentId>>,
pub retrieved: Option<Vec<crate::store::ScoredChunk>>,
pub rewritten_query: Option<String>,
pub elapsed: Option<std::time::Duration>,
pub timings: StageTimings,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChatPrompt {
pub system: String,
pub turns: Vec<(String, String)>,
pub user: String,
}
impl ChatPrompt {
pub fn render(&self) -> String {
let mut out = format!("<|system|>\n{}\n", self.system);
for (role, text) in &self.turns {
out.push_str(&format!("<|{}|>\n{}\n", role, text));
}
out.push_str(&format!("<|user|>\n{}\n<|assistant|>\n", self.user));
out
}
}
pub type PromptHook = dyn Fn(&ChatPrompt) + Send + Sync;
#[derive(Clone)]
pub struct RagAgent {
generator: Box<dyn Generator>,
embedder: Box<dyn Embedder>,
store: Box<dyn VectorStore>,
chunker: Box<dyn Chunker>,
parsers: Arc<DocumentParsers>,
chunk_config: ChunkConfig,
search_filter: Option<PipelineFilter>,
pipeline: Option<String>,
system_prompt: String,
chat_without_docs: String,
rewriter: Option<Box<dyn Generator>>,
rewrite_prompt: String,
context_tokens: usize,
top_k: usize,
similarity_threshold: f64,
attach_strategy: Option<Box<dyn AttachStrategy>>,
citation_formatter: Box<dyn CitationFormatter>,
prompt_hook: Option<Arc<PromptHook>>,
}
impl std::fmt::Debug for RagAgent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RagAgent")
.field("generator", &self.generator)
.field("embedder", &self.embedder)
.field("store", &self.store)
.field("chunker", &self.chunker)
.field("parsers", &self.parsers)
.field("chunk_config", &self.chunk_config)
.field("search_filter", &self.search_filter)
.field("pipeline", &self.pipeline)
.field("system_prompt", &self.system_prompt)
.field("chat_without_docs", &self.chat_without_docs)
.field("rewriter", &self.rewriter)
.field("rewrite_prompt", &self.rewrite_prompt)
.field("context_tokens", &self.context_tokens)
.field("top_k", &self.top_k)
.field("similarity_threshold", &self.similarity_threshold)
.field("attach_strategy", &self.attach_strategy)
.field("citation_formatter", &self.citation_formatter)
.field("prompt_hook", &self.prompt_hook.is_some())
.finish()
}
}
impl RagAgent {
pub fn builder() -> RagAgentBuilder {
RagAgentBuilder::default()
}
pub async fn generate_with_context(
&self,
query: &str,
transcript: &[(impl AsRef<str>, impl AsRef<str>)],
) -> Result<String> {
let built = self.build_prompt_inner(query, transcript).await?;
self.notify_prompt(&built.prompt);
self.generator.generate(&built.prompt.render()).await
}
pub async fn generate_with_context_detailed(
&self,
query: &str,
transcript: &[(impl AsRef<str>, impl AsRef<str>)],
) -> Result<RagResponse> {
let start = std::time::Instant::now();
let built = self.build_prompt_inner(query, transcript).await?;
self.notify_prompt(&built.prompt);
let generate_start = std::time::Instant::now();
let answer = self.generator.generate(&built.prompt.render()).await?;
let stage_generate = generate_start.elapsed();
let elapsed = start.elapsed();
let timings = built.timings(stage_generate);
Ok(RagResponse {
answer,
system_prompt: built.system_prompt,
user_prompt: query.to_string(),
chunks_retrieved: built.chunks_retrieved,
documents: built.documents,
retrieved: built.retrieved,
rewritten_query: built.rewritten_query,
elapsed: Some(elapsed),
timings,
})
}
pub async fn generate_with_turns(
&self,
query: &str,
turns: &[crate::Turn],
) -> Result<RagResponse> {
let transcript: Vec<(&str, &str)> = turns
.iter()
.map(|t| (t.role.as_str(), t.text.as_str()))
.collect();
self.generate_with_context_detailed(query, &transcript)
.await
}
pub async fn generate_with_context_streaming(
&self,
query: &str,
transcript: &[(impl AsRef<str>, impl AsRef<str>)],
on_token: &(dyn Fn(String) + Sync),
) -> Result<()> {
let built = self.build_prompt_inner(query, transcript).await?;
self.notify_prompt(&built.prompt);
self.generator
.generate_stream(&built.prompt.render(), on_token)
.await
}
pub async fn generate_with_context_streaming_detailed(
&self,
query: &str,
transcript: &[(impl AsRef<str>, impl AsRef<str>)],
on_token: &(dyn Fn(String) + Sync),
cancel: Option<&dyn crate::progress::Cancel>,
) -> Result<RagResponse> {
let start = std::time::Instant::now();
let built = self.build_prompt_inner(query, transcript).await?;
self.notify_prompt(&built.prompt);
let acc = std::sync::Mutex::new(String::new());
let wrapped = |token: String| {
lock_or_recover(&acc).push_str(&token);
on_token(token);
};
let generate_start = std::time::Instant::now();
match cancel {
Some(c) => {
self.generator
.generate_stream_with_cancel(&built.prompt.render(), &wrapped, c)
.await?
}
None => {
self.generator
.generate_stream(&built.prompt.render(), &wrapped)
.await?
}
}
let stage_generate = generate_start.elapsed();
let elapsed = start.elapsed();
let timings = built.timings(stage_generate);
Ok(RagResponse {
answer: into_inner_or_recover(acc.into_inner()),
system_prompt: built.system_prompt,
user_prompt: query.to_string(),
chunks_retrieved: built.chunks_retrieved,
documents: built.documents,
retrieved: built.retrieved,
rewritten_query: built.rewritten_query,
elapsed: Some(elapsed),
timings,
})
}
pub async fn generate_with_attachment(
&self,
query: &str,
transcript: &[(impl AsRef<str>, impl AsRef<str>)],
attachments: &[AttachedDocument],
) -> Result<RagResponse> {
let start = std::time::Instant::now();
let att = if attachments.is_empty() {
None
} else {
Some(attachments)
};
let built = self
.build_prompt_inner_with_attachments(query, transcript, att)
.await?;
self.notify_prompt(&built.prompt);
let generate_start = std::time::Instant::now();
let answer = self.generator.generate(&built.prompt.render()).await?;
let stage_generate = generate_start.elapsed();
let elapsed = start.elapsed();
let timings = built.timings(stage_generate);
Ok(RagResponse {
answer,
system_prompt: built.system_prompt,
user_prompt: query.to_string(),
chunks_retrieved: built.chunks_retrieved,
documents: built.documents,
retrieved: built.retrieved,
rewritten_query: built.rewritten_query,
elapsed: Some(elapsed),
timings,
})
}
pub async fn generate_with_turns_and_attachment(
&self,
query: &str,
turns: &[crate::Turn],
attachments: &[AttachedDocument],
) -> Result<RagResponse> {
let transcript: Vec<(&str, &str)> = turns
.iter()
.map(|t| (t.role.as_str(), t.text.as_str()))
.collect();
self.generate_with_attachment(query, &transcript, attachments)
.await
}
pub async fn generate_with_attachment_streaming(
&self,
query: &str,
transcript: &[(impl AsRef<str>, impl AsRef<str>)],
attachments: &[AttachedDocument],
on_token: &(dyn Fn(String) + Sync),
) -> Result<()> {
let att = if attachments.is_empty() {
None
} else {
Some(attachments)
};
let built = self
.build_prompt_inner_with_attachments(query, transcript, att)
.await?;
self.notify_prompt(&built.prompt);
self.generator
.generate_stream(&built.prompt.render(), on_token)
.await
}
pub async fn generate_with_attachment_streaming_detailed(
&self,
query: &str,
transcript: &[(impl AsRef<str>, impl AsRef<str>)],
attachments: &[AttachedDocument],
on_token: &(dyn Fn(String) + Sync),
cancel: Option<&dyn crate::progress::Cancel>,
) -> Result<RagResponse> {
let start = std::time::Instant::now();
let att = if attachments.is_empty() {
None
} else {
Some(attachments)
};
let built = self
.build_prompt_inner_with_attachments(query, transcript, att)
.await?;
self.notify_prompt(&built.prompt);
let acc = std::sync::Mutex::new(String::new());
let wrapped = |token: String| {
lock_or_recover(&acc).push_str(&token);
on_token(token);
};
let generate_start = std::time::Instant::now();
match cancel {
Some(c) => {
self.generator
.generate_stream_with_cancel(&built.prompt.render(), &wrapped, c)
.await?
}
None => {
self.generator
.generate_stream(&built.prompt.render(), &wrapped)
.await?
}
}
let stage_generate = generate_start.elapsed();
let elapsed = start.elapsed();
let timings = built.timings(stage_generate);
Ok(RagResponse {
answer: into_inner_or_recover(acc.into_inner()),
system_prompt: built.system_prompt,
user_prompt: query.to_string(),
chunks_retrieved: built.chunks_retrieved,
documents: built.documents,
retrieved: built.retrieved,
rewritten_query: built.rewritten_query,
elapsed: Some(elapsed),
timings,
})
}
fn notify_prompt(&self, prompt: &ChatPrompt) {
if let Some(hook) = &self.prompt_hook {
hook(prompt);
}
}
async fn retrieve_context_detailed(&self, search_query: &str) -> RetrieveResult {
let embedding_on = self.embedder.is_enabled();
if !embedding_on {
return RetrieveResult::empty();
}
if let Err(e) = self.store.validate_embedder(&self.embedder.metadata()) {
log::error!("Embedding model mismatch: {e}");
return RetrieveResult::empty();
}
let embed_start = std::time::Instant::now();
let embedded = match self.embedder.embed(vec![search_query.to_string()]).await {
Ok(e) => e,
Err(_) => return RetrieveResult::empty(),
};
let embed_time = embed_start.elapsed();
let Some(embedding) = embedded.first() else {
return RetrieveResult::empty();
};
let query_vec = &embedding.vector;
let search_start = std::time::Instant::now();
let search_result = match &self.search_filter {
Some(filter) => {
self.store
.search_filtered(
query_vec,
search_query,
self.top_k,
self.similarity_threshold,
filter,
)
.await
}
None => {
self.store
.search(
query_vec,
search_query,
self.top_k,
self.similarity_threshold,
)
.await
}
};
let results = match search_result {
Ok(r) => r,
Err(_) => return RetrieveResult::empty(),
};
if results.is_empty() {
return RetrieveResult::empty();
}
let chunks_retrieved = results.len();
trace!(
"Retrieved {} chunks (cosine threshold {:.3}):",
chunks_retrieved, self.similarity_threshold
);
for sc in &results {
trace!(
" [{:.4}] {} — {:.80}...",
sc.score,
sc.chunk.document,
sc.chunk.text.trim()
);
}
let mut documents: Vec<crate::types::DocumentId> =
results.iter().map(|sc| sc.chunk.document.clone()).collect();
documents.sort();
documents.dedup();
let max_ctx_chars = (self.context_tokens.saturating_sub(RESERVED_PROMPT_TOKENS))
.saturating_mul(CHARS_PER_TOKEN_ESTIMATE);
let ctx = match self
.citation_formatter
.format(&results, max_ctx_chars)
.await
{
Ok(ctx) => ctx,
Err(e) => {
log::error!("citation formatter failed: {e}");
String::new()
}
};
RetrieveResult {
context: ctx,
chunks_retrieved,
documents,
chunks: results,
embed_time,
search_time: search_start.elapsed(),
}
}
async fn build_prompt_inner(
&self,
query: &str,
transcript: &[(impl AsRef<str>, impl AsRef<str>)],
) -> Result<BuildPromptOutput> {
self.build_prompt_inner_with_attachments(query, transcript, None)
.await
}
async fn build_prompt_inner_with_attachments(
&self,
query: &str,
transcript: &[(impl AsRef<str>, impl AsRef<str>)],
attachments: Option<&[AttachedDocument]>,
) -> Result<BuildPromptOutput> {
let (search_query, rewritten_query, stage_rewrite): (
String,
Option<String>,
std::time::Duration,
) = if let Some(ref rewriter) = self.rewriter {
let rewrite_start = std::time::Instant::now();
let memory_str = if transcript.is_empty() {
String::new()
} else {
let lines: Vec<String> = transcript
.iter()
.map(|(role, text)| format!("{}: {}", role.as_ref(), text.as_ref()))
.collect();
format!("Conversation:\n{}\n\n", lines.join("\n"))
};
let rewrite_prompt = if !memory_str.is_empty() {
format!(
"{}{}",
memory_str,
self.rewrite_prompt.replace("{question}", query)
)
} else {
self.rewrite_prompt.replace("{question}", query)
};
match rewriter.generate(&rewrite_prompt).await {
Ok(rewritten) if !rewritten.trim().is_empty() && rewritten.trim() != query => {
let rw = rewritten.trim().to_string();
trace!("Rewrite: {:?} → {:?}", query, rw);
(rw.clone(), Some(rw), rewrite_start.elapsed())
}
_ => {
trace!("Rewrite: no change from {:?}", query);
(query.to_string(), None, rewrite_start.elapsed())
}
}
} else {
(query.to_string(), None, std::time::Duration::ZERO)
};
let embedding_on = self.embedder.is_enabled();
let retrieved = self.retrieve_context_detailed(&search_query).await;
let stage_embed = retrieved.embed_time;
let stage_search = retrieved.search_time;
let (chunks_retrieved, documents, retrieved_chunks) =
if embedding_on && retrieved.chunks_retrieved > 0 {
(
Some(retrieved.chunks_retrieved),
Some(retrieved.documents),
Some(retrieved.chunks),
)
} else {
(None, None, None)
};
let prompt_start = std::time::Instant::now();
let context_block = if let (Some(strategy), Some(docs)) =
(&self.attach_strategy, attachments)
&& !docs.is_empty()
{
strategy.merge(&retrieved.context, docs)
} else {
retrieved.context
};
let system = if embedding_on && !context_block.is_empty() {
self.system_prompt.replace("{context}", &context_block)
} else if let Some(docs) = attachments
&& !docs.is_empty()
{
self.system_prompt
.replace("{context}", &format_attachments_for_prompt(docs))
} else {
self.chat_without_docs.clone()
};
let prompt = ChatPrompt {
system: system.clone(),
turns: transcript
.iter()
.map(|(role, text)| (role.as_ref().to_string(), text.as_ref().to_string()))
.collect(),
user: query.to_string(),
};
let rendered = prompt.render();
trace!(
"Context budget: {} tokens (~{} chars) | Full prompt: {} chars (~{} tokens)",
self.context_tokens,
self.context_tokens.saturating_mul(CHARS_PER_TOKEN_ESTIMATE),
rendered.len(),
rendered.len() / CHARS_PER_TOKEN_ESTIMATE,
);
Ok(BuildPromptOutput {
prompt,
system_prompt: system,
rewritten_query,
chunks_retrieved,
documents,
retrieved: retrieved_chunks,
stage_rewrite,
stage_embed,
stage_search,
stage_prompt: prompt_start.elapsed(),
})
}
pub async fn reindex_folder(&self, folder: impl AsRef<Path>) -> Result<()> {
let folder = folder.as_ref();
let corpus = crate::corpora::FolderCorpus::named("folder", folder);
crate::vector::ingest_corpus_inner(
&corpus,
&self.parsers,
self.chunker(),
self.embedder(),
&self.chunk_config,
self.store(),
self.pipeline.as_deref(),
None,
None,
)
.await?;
Ok(())
}
pub async fn reindex_folder_with_progress(
&self,
folder: impl AsRef<Path>,
progress: Option<&dyn crate::progress::Progress>,
cancel: Option<&dyn crate::progress::Cancel>,
) -> Result<()> {
let folder = folder.as_ref();
let corpus = crate::corpora::FolderCorpus::named("folder", folder);
crate::vector::ingest_corpus_inner(
&corpus,
&self.parsers,
self.chunker(),
self.embedder(),
&self.chunk_config,
self.store(),
self.pipeline.as_deref(),
progress,
cancel,
)
.await?;
Ok(())
}
pub async fn reindex_corpus(
&self,
corpus: &dyn Corpus,
) -> Result<Vec<crate::documents::FileIndexResult>> {
crate::vector::ingest_corpus_inner(
corpus,
&self.parsers,
self.chunker(),
self.embedder(),
&self.chunk_config,
self.store(),
self.pipeline.as_deref(),
None,
None,
)
.await
}
pub async fn reindex_corpus_with_progress(
&self,
corpus: &dyn Corpus,
progress: Option<&dyn crate::progress::Progress>,
cancel: Option<&dyn crate::progress::Cancel>,
) -> Result<Vec<crate::documents::FileIndexResult>> {
crate::vector::ingest_corpus_inner(
corpus,
&self.parsers,
self.chunker(),
self.embedder(),
&self.chunk_config,
self.store(),
self.pipeline.as_deref(),
progress,
cancel,
)
.await
}
pub async fn sync_corpus(
&self,
corpus: &dyn Corpus,
) -> Result<Vec<crate::documents::FileIndexResult>> {
crate::vector::sync_corpus_inner(
corpus,
&self.parsers,
self.chunker(),
self.embedder(),
&self.chunk_config,
self.store(),
self.pipeline.as_deref(),
None,
None,
)
.await
}
pub async fn sync_corpus_with_progress(
&self,
corpus: &dyn Corpus,
progress: Option<&dyn crate::progress::Progress>,
cancel: Option<&dyn crate::progress::Cancel>,
) -> Result<Vec<crate::documents::FileIndexResult>> {
crate::vector::sync_corpus_inner(
corpus,
&self.parsers,
self.chunker(),
self.embedder(),
&self.chunk_config,
self.store(),
self.pipeline.as_deref(),
progress,
cancel,
)
.await
}
pub fn chat_agent(&self) -> &dyn Generator {
&*self.generator
}
pub fn embedder(&self) -> &dyn Embedder {
&*self.embedder
}
pub fn chunker(&self) -> &dyn Chunker {
&*self.chunker
}
pub fn parsers(&self) -> &DocumentParsers {
&self.parsers
}
pub fn chunk_config(&self) -> ChunkConfig {
self.chunk_config.clone()
}
pub fn search_filter(&self) -> Option<&PipelineFilter> {
self.search_filter.as_ref()
}
pub fn store(&self) -> &dyn VectorStore {
&*self.store
}
pub fn top_k(&self) -> usize {
self.top_k
}
pub fn similarity_threshold(&self) -> f64 {
self.similarity_threshold
}
pub fn context_tokens(&self) -> usize {
self.context_tokens
}
pub fn rewriter(&self) -> Option<&dyn Generator> {
self.rewriter.as_deref()
}
pub fn attach_strategy(&self) -> Option<&dyn AttachStrategy> {
self.attach_strategy.as_deref()
}
pub fn system_prompt(&self) -> &str {
&self.system_prompt
}
pub fn chat_without_docs_prompt(&self) -> &str {
&self.chat_without_docs
}
pub fn rewrite_prompt(&self) -> &str {
&self.rewrite_prompt
}
pub fn set_chat_agent(&mut self, agent: Box<dyn Generator>) {
self.generator = agent;
}
pub fn set_embedder(&mut self, embedder: Box<dyn Embedder>) {
self.embedder = embedder;
}
pub fn set_chunker(&mut self, chunker: Box<dyn Chunker>) {
self.chunker = chunker;
}
pub fn set_parsers(&mut self, parsers: DocumentParsers) {
self.parsers = Arc::new(parsers);
}
pub fn set_chunk_config(&mut self, config: ChunkConfig) {
self.chunk_config = config;
}
pub fn set_search_filter(&mut self, filter: Option<PipelineFilter>) {
self.search_filter = filter;
}
pub fn pipeline(&self) -> Option<&str> {
self.pipeline.as_deref()
}
pub fn set_pipeline(&mut self, id: Option<String>) {
self.pipeline = id;
}
pub fn set_rewriter(&mut self, rewriter: Option<Box<dyn Generator>>) {
self.rewriter = rewriter;
}
pub fn set_attach_strategy(&mut self, strategy: Option<Box<dyn AttachStrategy>>) {
self.attach_strategy = strategy;
}
pub fn citation_formatter(&self) -> &dyn CitationFormatter {
&*self.citation_formatter
}
pub fn set_citation_formatter(&mut self, formatter: Box<dyn CitationFormatter>) {
self.citation_formatter = formatter;
}
pub fn set_system_prompt(&mut self, mut prompt: String) {
if !prompt.contains("{context}") {
prompt.push_str("\n\nContext:\n{context}\n");
}
self.chat_without_docs = strip_context_placeholder(&prompt);
self.system_prompt = prompt;
}
pub fn set_rewrite_prompt(&mut self, prompt: String) {
self.rewrite_prompt = prompt;
}
pub fn set_top_k(&mut self, n: usize) {
self.top_k = n;
}
pub fn set_similarity_threshold(&mut self, t: f64) {
self.similarity_threshold = t;
}
pub fn set_context_tokens(&mut self, n: usize) {
self.context_tokens = n;
}
pub fn set_prompt_hook(&mut self, hook: Option<Arc<PromptHook>>) {
self.prompt_hook = hook;
}
pub fn set_store(&mut self, store: Box<dyn VectorStore>) {
self.store = store;
}
pub fn set_ranker(&self, ranker: Box<dyn crate::store::Ranker>) -> Result<()> {
self.store.set_ranker(ranker)
}
pub fn ranker_name(&self) -> Option<String> {
self.store.ranker_name()
}
pub fn try_recover_from_error(&mut self, err: &anyhow::Error) -> bool {
if let Some(rag_err) = err.downcast_ref::<crate::RagrigError>()
&& let crate::RagrigError::ContextSizeExceeded { .. } = rag_err
&& let Some(suggested) = rag_err.suggested_context_tokens()
{
self.set_context_tokens(suggested);
return true;
}
false
}
}
struct RetrieveResult {
context: String,
chunks_retrieved: usize,
documents: Vec<crate::types::DocumentId>,
chunks: Vec<crate::store::ScoredChunk>,
embed_time: std::time::Duration,
search_time: std::time::Duration,
}
impl RetrieveResult {
fn empty() -> Self {
Self {
context: String::new(),
chunks_retrieved: 0,
documents: Vec::new(),
chunks: Vec::new(),
embed_time: std::time::Duration::ZERO,
search_time: std::time::Duration::ZERO,
}
}
}
struct BuildPromptOutput {
prompt: ChatPrompt,
system_prompt: String,
rewritten_query: Option<String>,
chunks_retrieved: Option<usize>,
documents: Option<Vec<crate::types::DocumentId>>,
retrieved: Option<Vec<crate::store::ScoredChunk>>,
stage_rewrite: std::time::Duration,
stage_embed: std::time::Duration,
stage_search: std::time::Duration,
stage_prompt: std::time::Duration,
}
impl BuildPromptOutput {
fn timings(&self, generate: std::time::Duration) -> StageTimings {
StageTimings {
rewrite: self.stage_rewrite,
embed: self.stage_embed,
search: self.stage_search,
prompt: self.stage_prompt,
generate,
}
}
}
#[derive(Clone)]
pub struct RagAgentBuilder {
generator: Option<Box<dyn Generator>>,
embedder: Option<Box<dyn Embedder>>,
store: Option<Box<dyn VectorStore>>,
chunker: Option<Box<dyn Chunker>>,
parsers: Option<Arc<DocumentParsers>>,
chunk_config: Option<ChunkConfig>,
ranker: Option<Box<dyn crate::store::Ranker>>,
search_filter: Option<PipelineFilter>,
pipeline: Option<String>,
system_prompt: Option<String>,
chat_without_docs: Option<String>,
rewriter: Option<Box<dyn Generator>>,
rewrite_prompt: Option<String>,
attach_strategy: Option<Box<dyn AttachStrategy>>,
citation_formatter: Option<Box<dyn CitationFormatter>>,
prompt_hook: Option<Arc<PromptHook>>,
context_tokens: usize,
top_k: usize,
similarity_threshold: f64,
}
impl std::fmt::Debug for RagAgentBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RagAgentBuilder")
.field("generator", &self.generator)
.field("embedder", &self.embedder)
.field("store", &self.store)
.field("chunker", &self.chunker)
.field("parsers", &self.parsers)
.field("chunk_config", &self.chunk_config)
.field("ranker", &self.ranker)
.field("search_filter", &self.search_filter)
.field("pipeline", &self.pipeline)
.field("system_prompt", &self.system_prompt)
.field("chat_without_docs", &self.chat_without_docs)
.field("rewriter", &self.rewriter)
.field("rewrite_prompt", &self.rewrite_prompt)
.field("attach_strategy", &self.attach_strategy)
.field("citation_formatter", &self.citation_formatter)
.field("prompt_hook", &self.prompt_hook.is_some())
.field("context_tokens", &self.context_tokens)
.field("top_k", &self.top_k)
.field("similarity_threshold", &self.similarity_threshold)
.finish()
}
}
impl Default for RagAgentBuilder {
fn default() -> Self {
Self {
generator: None,
embedder: None,
store: None,
chunker: None,
parsers: None,
chunk_config: None,
ranker: None,
search_filter: None,
pipeline: None,
system_prompt: None,
chat_without_docs: None,
rewriter: None,
rewrite_prompt: None,
attach_strategy: None,
citation_formatter: None,
prompt_hook: None,
context_tokens: 4096,
top_k: 50,
similarity_threshold: 0.04,
}
}
}
impl RagAgentBuilder {
pub fn chat(mut self, generator: Box<dyn Generator>) -> Self {
self.generator = Some(generator);
self
}
pub fn embed(mut self, embedder: Box<dyn Embedder>) -> Self {
self.embedder = Some(embedder);
self
}
pub fn store(mut self, store: Box<dyn VectorStore>) -> Self {
self.store = Some(store);
self
}
pub fn chunker(mut self, chunker: Box<dyn Chunker>) -> Self {
self.chunker = Some(chunker);
self
}
pub fn parsers(mut self, parsers: DocumentParsers) -> Self {
self.parsers = Some(Arc::new(parsers));
self
}
pub fn chunk_config(mut self, config: ChunkConfig) -> Self {
self.chunk_config = Some(config);
self
}
pub fn ranker(mut self, ranker: Box<dyn crate::store::Ranker>) -> Self {
self.ranker = Some(ranker);
self
}
pub fn search_filter(mut self, filter: PipelineFilter) -> Self {
self.search_filter = Some(filter);
self
}
pub fn pipeline(mut self, id: impl Into<String>) -> Self {
self.pipeline = Some(id.into());
self
}
pub async fn index_folder(mut self, folder: impl AsRef<Path>) -> Result<Self> {
let folder = folder.as_ref().to_path_buf();
let embedder_box = self
.embedder
.as_ref()
.ok_or_else(|| anyhow::anyhow!("embedder must be set before index_folder"))?;
let parsers = self
.parsers
.clone()
.unwrap_or_else(|| Arc::new(DocumentParsers::new(build_parsers())));
let chunker = self
.chunker
.clone()
.unwrap_or_else(|| Box::new(MarkdownChunker));
let config = self.chunk_config.clone().unwrap_or_default();
let store = crate::store::open_store(&folder).await?;
let corpus = crate::corpora::FolderCorpus::named("folder", &folder);
crate::vector::ingest_corpus_with_progress(
&corpus,
&parsers,
&*chunker,
&**embedder_box,
&config,
&*store,
None,
None,
)
.await?;
self.store = Some(store);
Ok(self)
}
pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
let p = prompt.into();
self.chat_without_docs = Some(strip_context_placeholder(&p));
self.system_prompt = Some(p);
self
}
pub fn chat_without_docs_prompt(mut self, prompt: impl Into<String>) -> Self {
self.chat_without_docs = Some(prompt.into());
self
}
pub fn rewriter(mut self, agent: Box<dyn Generator>) -> Self {
self.rewriter = Some(agent);
self
}
pub fn rewrite_prompt(mut self, prompt: impl Into<String>) -> Self {
self.rewrite_prompt = Some(prompt.into());
self
}
pub fn attach_strategy(mut self, strategy: Box<dyn AttachStrategy>) -> Self {
self.attach_strategy = Some(strategy);
self
}
pub fn citation_formatter(mut self, formatter: Box<dyn CitationFormatter>) -> Self {
self.citation_formatter = Some(formatter);
self
}
pub fn prompt_hook(mut self, hook: impl Fn(&ChatPrompt) + Send + Sync + 'static) -> Self {
self.prompt_hook = Some(Arc::new(hook));
self
}
pub fn context_tokens(mut self, n: usize) -> Self {
self.context_tokens = n;
self
}
pub fn top_k(mut self, n: usize) -> Self {
self.top_k = n;
self
}
pub fn similarity_threshold(mut self, t: f64) -> Self {
self.similarity_threshold = t;
self
}
pub fn build(self) -> Result<RagAgent> {
let generator = self.generator.ok_or_else(|| {
anyhow::anyhow!("Chat generator must be set via .chat() before build")
})?;
let embedder = self
.embedder
.ok_or_else(|| anyhow::anyhow!("Embedder must be set via .embed() before build"))?;
let store = self.store.ok_or_else(|| {
anyhow::anyhow!("Vector store must be set via .store() or .index_folder() before build")
})?;
if let Some(ranker) = self.ranker {
store.set_ranker(ranker).map_err(|e| {
anyhow::anyhow!("store does not support the ranker set via .ranker(): {e}")
})?;
}
let parsers = self
.parsers
.unwrap_or_else(|| Arc::new(DocumentParsers::new(build_parsers())));
let chunk_config = self.chunk_config.unwrap_or_default();
let system_prompt = self
.system_prompt
.unwrap_or_else(|| DEFAULT_CHAT_WITH_DOCS.to_string());
let chat_without_docs = self
.chat_without_docs
.unwrap_or_else(|| DEFAULT_CHAT_WITHOUT_DOCS.to_string());
let rewrite_prompt = self
.rewrite_prompt
.unwrap_or_else(|| DEFAULT_REWRITE.to_string());
Ok(RagAgent {
generator,
embedder,
store,
chunker: self.chunker.unwrap_or_else(|| Box::new(MarkdownChunker)),
parsers,
chunk_config,
search_filter: self.search_filter,
pipeline: self.pipeline,
system_prompt,
chat_without_docs,
rewriter: self.rewriter,
rewrite_prompt,
attach_strategy: self.attach_strategy,
citation_formatter: self
.citation_formatter
.unwrap_or_else(|| Box::new(DefaultCitationFormatter)),
prompt_hook: self.prompt_hook,
context_tokens: self.context_tokens,
top_k: self.top_k,
similarity_threshold: self.similarity_threshold,
})
}
}
fn format_attachments_for_prompt(attachments: &[AttachedDocument]) -> String {
attachments
.iter()
.map(|a| format!("[Attached: {}]\n{}\n", a.name, a.content))
.collect::<Vec<_>>()
.join("\n")
}
fn strip_context_placeholder(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let lines: Vec<&str> = text.lines().collect();
for (i, line) in lines.iter().enumerate() {
if line.contains("{context}") {
continue;
}
if let Some(next) = lines.get(i + 1)
&& next.contains("{context}")
&& (line.trim().is_empty()
|| line.trim().eq_ignore_ascii_case("Context:")
|| line.trim().eq_ignore_ascii_case("Context"))
{
continue;
}
if i > 0 && lines[i - 1].contains("{context}") && line.trim().is_empty() {
continue;
}
out.push_str(line);
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_context_removes_context_line() {
let input = "Hello\nContext:\n{context}\nWorld\n";
let result = strip_context_placeholder(input);
assert!(!result.contains("{context}"));
assert!(!result.contains("Context:"));
assert!(result.contains("Hello"));
assert!(result.contains("World"));
}
#[test]
fn builder_panics_without_chat() {
let result = RagAgent::builder()
.embed(Box::new(crate::embed::NoopEmbedder))
.build();
assert!(result.is_err());
}
#[test]
fn builder_panics_without_embed() {
let result = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.build();
assert!(result.is_err());
}
#[test]
fn builder_panics_without_store() {
let result = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.embed(Box::new(crate::embed::NoopEmbedder))
.build();
assert!(result.is_err());
}
#[test]
fn chat_prompt_render_matches_wire_format() {
let prompt = ChatPrompt {
system: "sys".into(),
turns: vec![
("User".into(), "hi".into()),
("Assistant".into(), "hello".into()),
],
user: "again".into(),
};
assert_eq!(
prompt.render(),
"<|system|>\nsys\n<|User|>\nhi\n<|Assistant|>\nhello\n<|user|>\nagain\n<|assistant|>\n"
);
}
#[tokio::test]
#[cfg(feature = "internal")]
async fn prompt_hook_receives_structured_prompt() {
let seen = Arc::new(std::sync::Mutex::new(Vec::<ChatPrompt>::new()));
let agent = RagAgent::builder()
.chat(Box::new(PromptEcho))
.embed(Box::new(ConstEmbedder))
.store(Box::new(
crate::store::BruteForceStore::open_or_create(std::path::Path::new(".")).unwrap(),
))
.prompt_hook({
let seen = seen.clone();
move |p| seen.lock().unwrap().push(p.clone())
})
.build()
.unwrap();
let transcript = [("User", "hi"), ("Assistant", "hello")];
let _ = agent.generate_with_context("q", &transcript).await.unwrap();
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].user, "q");
assert_eq!(seen[0].turns.len(), 2);
assert_eq!(seen[0].turns[0], ("User".to_string(), "hi".to_string()));
assert_eq!(
seen[0].turns[1],
("Assistant".to_string(), "hello".to_string())
);
assert!(seen[0].system.contains("helpful assistant"));
}
#[test]
#[cfg(feature = "internal")]
fn accessors_reflect_builder_values() {
let agent = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test-model".into(),
Default::default(),
None,
)))
.embed(Box::new(crate::embed::NoopEmbedder))
.store(Box::new(
crate::store::BruteForceStore::open_or_create(std::path::Path::new(".")).unwrap(),
))
.top_k(7)
.similarity_threshold(0.42)
.context_tokens(8192)
.build()
.unwrap();
assert_eq!(agent.top_k(), 7);
assert_eq!(agent.similarity_threshold(), 0.42);
assert_eq!(agent.context_tokens(), 8192);
assert_eq!(agent.chat_agent().backend_name(), "Ollama");
assert_eq!(agent.embedder().backend_name(), "None");
}
#[derive(Debug, Clone)]
struct ConstEmbedder;
#[async_trait::async_trait]
impl Embedder for ConstEmbedder {
async fn embed(&self, texts: Vec<String>) -> anyhow::Result<Vec<crate::embed::Embedding>> {
Ok(texts
.into_iter()
.map(|text| crate::embed::Embedding {
text,
vector: vec![0.5f32; 8],
})
.collect())
}
fn backend_name(&self) -> &'static str {
"const"
}
fn model_name(&self) -> &str {
"const-v1"
}
fn dimension(&self) -> usize {
8
}
}
#[derive(Debug, Clone)]
struct PromptEcho;
#[async_trait::async_trait]
impl Generator for PromptEcho {
async fn generate_stream(
&self,
prompt: &str,
on_token: &(dyn Fn(String) + Sync),
) -> anyhow::Result<()> {
on_token(prompt.to_string());
Ok(())
}
fn backend_name(&self) -> &'static str {
"echo"
}
fn model_name(&self) -> &str {
"v1"
}
}
#[derive(Debug, Clone)]
struct MetaFormatter;
#[async_trait::async_trait]
impl CitationFormatter for MetaFormatter {
async fn format(
&self,
chunks: &[crate::ScoredChunk],
_budget: usize,
) -> anyhow::Result<String> {
let mut out = String::new();
for sc in chunks {
match sc.chunk.meta.document_meta.get("doi") {
Some(doi) => out.push_str(&format!("DOI {doi}: {}\n", sc.chunk.text)),
None => out.push_str(&format!("NO-META: {}\n", sc.chunk.text)),
}
}
Ok(out)
}
}
#[tokio::test]
#[cfg(feature = "internal")]
async fn citation_formatter_receives_document_metadata() {
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"ragrig_cite_test_{}_{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
let _ = std::fs::create_dir_all(&dir);
let store = crate::store::BruteForceStore::open_or_create(&dir).unwrap();
let mut meta = std::collections::BTreeMap::new();
meta.insert("doi".to_string(), "10.1234/foo".to_string());
store
.insert(vec![crate::store::StoredChunk {
text: "chunk text".into(),
document: crate::types::DocumentId::from("paper.pdf"),
vector: vec![0.5f32; 8],
meta: crate::ChunkMeta {
document_meta: meta,
..Default::default()
},
provenance: crate::PipelineProvenance::new_with_corpus(
"papers",
"unpdf",
"markdown",
"const/const-v1",
),
}])
.await
.unwrap();
let agent = RagAgent::builder()
.chat(Box::new(PromptEcho))
.embed(Box::new(ConstEmbedder))
.store(Box::new(store))
.citation_formatter(Box::new(MetaFormatter))
.build()
.unwrap();
let resp = agent
.generate_with_context_detailed("q", &[] as &[(&str, &str)])
.await
.unwrap();
assert!(
resp.system_prompt.contains("DOI 10.1234/foo: chunk text"),
"system prompt: {}",
resp.system_prompt
);
assert_eq!(resp.chunks_retrieved, Some(1));
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
#[cfg(feature = "internal")]
async fn detailed_response_carries_retrieved_chunks_and_timings() {
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"ragrig_detailed_test_{}_{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
let _ = std::fs::create_dir_all(&dir);
let store = crate::store::BruteForceStore::open_or_create(&dir).unwrap();
store
.insert(vec![crate::store::StoredChunk {
text: "chunk text".into(),
document: crate::types::DocumentId::from("paper.pdf"),
vector: vec![0.5f32; 8],
meta: Default::default(),
provenance: crate::PipelineProvenance::new_with_corpus(
"papers",
"unpdf",
"markdown",
"const/const-v1",
),
}])
.await
.unwrap();
let agent = RagAgent::builder()
.chat(Box::new(PromptEcho))
.embed(Box::new(ConstEmbedder))
.store(Box::new(store))
.build()
.unwrap();
let resp = agent
.generate_with_context_detailed("q", &[] as &[(&str, &str)])
.await
.unwrap();
let retrieved = resp.retrieved.expect("retrieved chunks");
assert_eq!(retrieved.len(), 1);
assert_eq!(retrieved[0].chunk.document.to_string(), "paper.pdf");
assert_eq!(retrieved[0].chunk.text, "chunk text");
assert!(retrieved[0].score.value > 0.0);
assert!(resp.timings.rewrite.is_zero());
let stage_sum =
resp.timings.embed + resp.timings.search + resp.timings.prompt + resp.timings.generate;
assert!(stage_sum <= resp.elapsed.expect("elapsed"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[cfg(feature = "internal")]
fn set_mutators_update_agent() {
let mut agent = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.embed(Box::new(crate::embed::NoopEmbedder))
.store(Box::new(
crate::store::BruteForceStore::open_or_create(std::path::Path::new(".")).unwrap(),
))
.build()
.unwrap();
agent.set_top_k(15);
agent.set_context_tokens(16384);
assert_eq!(agent.top_k(), 15);
assert_eq!(agent.context_tokens(), 16384);
}
#[test]
#[cfg(feature = "internal")]
fn default_prompts_contain_placeholders() {
let agent = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.embed(Box::new(crate::embed::NoopEmbedder))
.store(Box::new(
crate::store::BruteForceStore::open_or_create(std::path::Path::new(".")).unwrap(),
))
.build()
.unwrap();
assert!(agent.system_prompt().contains("{context}"));
assert!(!agent.chat_without_docs_prompt().contains("{context}"));
assert!(agent.rewrite_prompt().contains("{question}"));
}
#[test]
#[cfg(feature = "internal")]
fn chunker_and_search_filter_mutators_update_agent() {
let mut agent = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.embed(Box::new(crate::embed::NoopEmbedder))
.store(Box::new(
crate::store::BruteForceStore::open_or_create(std::path::Path::new(".")).unwrap(),
))
.build()
.unwrap();
agent.set_chunker(Box::new(crate::chunker::TokenChunker));
assert_eq!(agent.chunker().name(), "token");
agent.set_search_filter(Some(crate::types::PipelineFilter::for_corpus("papers")));
assert_eq!(
agent.search_filter().and_then(|f| f.corpus.as_deref()),
Some("papers")
);
agent.set_search_filter(None);
assert!(agent.search_filter().is_none());
}
#[tokio::test]
#[cfg(feature = "internal")]
async fn reindex_folder_uses_configured_chunker() {
let docs = tempfile::tempdir().unwrap();
std::fs::write(docs.path().join("a.md"), b"# A\n\nSome text here.").unwrap();
let store_dir = tempfile::tempdir().unwrap();
let agent = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.embed(Box::new(ConstEmbedder))
.store(Box::new(
crate::store::BruteForceStore::open_or_create(store_dir.path()).unwrap(),
))
.chunker(Box::new(crate::chunker::TokenChunker))
.build()
.unwrap();
agent.reindex_folder(docs.path()).await.unwrap();
assert!(!agent.store().is_empty());
let hits = agent
.store()
.search(&[0.5f32; 8], "text", 10, 0.0)
.await
.unwrap();
assert!(!hits.is_empty());
assert!(hits.iter().all(|h| h.chunk.provenance.chunker == "token"));
}
#[derive(Debug)]
struct ZzzParser;
impl crate::parsers::DocumentParser for ZzzParser {
fn extensions(&self) -> &[&str] {
&["zzz"]
}
fn parse(&self, _path: &std::path::Path) -> anyhow::Result<String> {
Ok("custom-parsed zzz content".into())
}
fn name(&self) -> &'static str {
"zzz-custom"
}
}
#[derive(Debug)]
struct ZzzCorpus;
#[async_trait::async_trait]
impl crate::corpora::Corpus for ZzzCorpus {
fn name(&self) -> String {
"zzz-corpus".into()
}
async fn documents(&self) -> anyhow::Result<Vec<crate::corpora::Document>> {
Ok(vec![crate::corpora::Document {
corpus: "zzz-corpus".into(),
id: crate::types::DocumentId::from("doc.zzz"),
format: "zzz".into(),
data: crate::corpora::DocumentData::Bytes(b"irrelevant".to_vec()),
meta: Default::default(),
}])
}
}
#[derive(Debug, Clone)]
struct NamedRanker(&'static str);
#[async_trait::async_trait]
impl crate::store::Ranker for NamedRanker {
async fn rank(
&self,
chunks: &[crate::store::StoredChunk],
_query_vec: &[f32],
_query_text: &str,
top_k: usize,
_threshold: f64,
) -> Vec<crate::store::ScoredChunk> {
chunks
.iter()
.take(top_k)
.map(|c| crate::store::ScoredChunk {
score: crate::types::RankScore::cosine(1.0f64),
chunk: crate::types::DocumentChunk {
text: c.text.clone(),
document: c.document.clone(),
meta: c.meta.clone(),
provenance: c.provenance.clone(),
},
})
.collect()
}
fn name(&self) -> &'static str {
self.0
}
}
#[tokio::test]
#[cfg(feature = "internal")]
async fn custom_parsers_are_used_by_ingestion() {
let store_dir = tempfile::tempdir().unwrap();
let agent = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.embed(Box::new(crate::embed::NoopEmbedder))
.store(Box::new(
crate::store::BruteForceStore::open_or_create(store_dir.path()).unwrap(),
))
.parsers(DocumentParsers::new(vec![Box::new(ZzzParser)]))
.chunker(Box::new(crate::chunker::TokenChunker))
.build()
.unwrap();
assert_eq!(agent.parsers().names(), vec!["zzz-custom"]);
agent.reindex_corpus(&ZzzCorpus).await.unwrap();
let docs = agent.store().stored_documents();
assert_eq!(docs.len(), 1);
assert_eq!(docs[0].parser, "zzz-custom");
assert_eq!(docs[0].document, crate::types::DocumentId::from("doc.zzz"));
}
#[tokio::test]
#[cfg(feature = "internal")]
async fn chunk_config_affects_ingestion() {
let docs = tempfile::tempdir().unwrap();
let text = "# Doc\n\n".to_string()
+ &"lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ".repeat(10);
std::fs::write(docs.path().join("a.md"), text).unwrap();
let build_agent = |store_dir: &std::path::Path, config: ChunkConfig| -> RagAgent {
RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.embed(Box::new(crate::embed::NoopEmbedder))
.store(Box::new(
crate::store::BruteForceStore::open_or_create(store_dir).unwrap(),
))
.chunk_config(config)
.chunker(Box::new(crate::chunker::TokenChunker))
.build()
.unwrap()
};
let default_dir = tempfile::tempdir().unwrap();
let default_agent = build_agent(default_dir.path(), ChunkConfig::default());
default_agent.reindex_folder(docs.path()).await.unwrap();
let small_dir = tempfile::tempdir().unwrap();
let small_agent = build_agent(small_dir.path(), ChunkConfig::new(32, 0).unwrap());
assert_eq!(small_agent.chunk_config().size, 32);
small_agent.reindex_folder(docs.path()).await.unwrap();
let default_chunks = default_agent.store().len();
let small_chunks = small_agent.store().len();
assert_eq!(default_chunks, 1, "the sample fits in one default chunk");
assert!(
small_chunks > default_chunks,
"small chunk config should split into more chunks ({small_chunks} <= {default_chunks})"
);
}
#[tokio::test]
#[cfg(feature = "internal")]
async fn builder_ranker_applies_to_store() {
let store_dir = tempfile::tempdir().unwrap();
let agent = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.embed(Box::new(crate::embed::NoopEmbedder))
.store(Box::new(
crate::store::BruteForceStore::open_or_create(store_dir.path()).unwrap(),
))
.ranker(Box::new(NamedRanker("custom-ranker")))
.build()
.unwrap();
assert_eq!(agent.ranker_name().as_deref(), Some("custom-ranker"));
}
#[tokio::test]
#[cfg(feature = "internal")]
async fn builder_index_folder_honors_pipeline_settings() {
let docs = tempfile::tempdir().unwrap();
let text = "# Doc\n\n".to_string()
+ &"lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ".repeat(10);
std::fs::write(docs.path().join("a.md"), text).unwrap();
let mut parser_list = crate::parsers::build_parsers();
parser_list.push(Box::new(ZzzParser));
let agent = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.embed(Box::new(ConstEmbedder))
.chunker(Box::new(crate::chunker::TokenChunker))
.chunk_config(ChunkConfig::new(32, 0).unwrap())
.parsers(DocumentParsers::new(parser_list))
.index_folder(docs.path())
.await
.unwrap()
.build()
.unwrap();
assert!(agent.parsers().names().contains(&"zzz-custom"));
assert!(agent.store().len() > 1);
let hits = agent
.store()
.search(&[0.5f32; 8], "lorem", 10, 0.0)
.await
.unwrap();
assert!(hits.iter().all(|h| h.chunk.provenance.chunker == "token"));
}
#[tokio::test]
#[cfg(feature = "internal")]
async fn builder_defaults_to_rrf_ranker() {
let store_dir = tempfile::tempdir().unwrap();
let agent = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.embed(Box::new(crate::embed::NoopEmbedder))
.store(Box::new(
crate::store::BruteForceStore::open_or_create(store_dir.path()).unwrap(),
))
.build()
.unwrap();
assert_eq!(agent.ranker_name().as_deref(), Some("RRFFusion"));
}
#[tokio::test]
#[cfg(feature = "lancedb")]
async fn builder_ranker_errors_on_lancedb() {
let store_dir = tempfile::tempdir().unwrap();
let store = crate::store::lance_db_store::LanceDbStore::open_or_create(store_dir.path())
.await
.unwrap();
let result = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.embed(Box::new(crate::embed::NoopEmbedder))
.store(Box::new(store))
.ranker(Box::new(NamedRanker("custom-ranker")))
.build();
assert!(result.is_err());
}
#[test]
#[cfg(feature = "internal")]
fn parser_and_chunk_config_mutators_update_agent() {
let mut agent = RagAgent::builder()
.chat(Box::new(crate::agents::OllamaGenerator::new(
"test".into(),
Default::default(),
None,
)))
.embed(Box::new(crate::embed::NoopEmbedder))
.store(Box::new(
crate::store::BruteForceStore::open_or_create(std::path::Path::new(".")).unwrap(),
))
.build()
.unwrap();
agent.set_parsers(DocumentParsers::new(vec![Box::new(ZzzParser)]));
assert_eq!(agent.parsers().names(), vec!["zzz-custom"]);
agent.set_chunk_config(ChunkConfig::new(256, 32).unwrap());
let config = agent.chunk_config();
assert_eq!(config.size, 256);
assert_eq!(config.overlap, 32);
}
}