use crate::error::RagError;
use crate::pipeline::channel::{ChannelConfig, ChannelType};
use crate::types::chunk::Chunk;
use crate::types::retrieval::{RetrievedChunk, StructuredFilter};
use tantivy::collector::TopDocs;
use tantivy::query::{BooleanQuery, Occur, QueryParser, TermQuery};
use tantivy::schema::{IndexRecordOption, STRING, Schema, TextFieldIndexing, TextOptions, Value};
use tantivy::{Index, ReloadPolicy, Term, doc};
const DEFAULT_K1: f32 = 1.2;
const DEFAULT_B: f32 = 0.75;
pub struct Bm25ChannelExecutor {
index: Index,
chunk_id_field: tantivy::schema::Field,
document_id_field: tantivy::schema::Field,
text_field: tantivy::schema::Field,
namespace_field: tantivy::schema::Field,
k1: f32,
b: f32,
doc_count: usize,
}
impl Bm25ChannelExecutor {
pub fn new() -> Self {
let mut schema_builder = Schema::builder();
let string_opts = TextOptions::default().set_stored();
let chunk_id_field = schema_builder.add_text_field("chunk_id", string_opts.clone());
let document_id_field = schema_builder.add_text_field("document_id", string_opts);
let text_indexing =
TextFieldIndexing::default().set_index_option(IndexRecordOption::WithFreqsAndPositions);
let text_opts = TextOptions::default().set_indexing_options(text_indexing).set_stored();
let text_field = schema_builder.add_text_field("text", text_opts);
let namespace_field = schema_builder.add_text_field("namespace", STRING);
let schema = schema_builder.build();
let index = Index::create_in_ram(schema);
Self {
index,
chunk_id_field,
document_id_field,
text_field,
namespace_field,
k1: DEFAULT_K1,
b: DEFAULT_B,
doc_count: 0,
}
}
pub fn index_documents(&mut self, chunks: &[Chunk]) -> Result<(), RagError> {
let mut writer = self
.index
.writer(50_000_000)
.map_err(|e| RagError::Store(format!("failed to create index writer: {e}")))?;
writer
.delete_all_documents()
.map_err(|e| RagError::Store(format!("failed to clear index: {e}")))?;
for chunk in chunks {
let namespace = chunk.metadata.namespace.as_deref().unwrap_or("");
let tantivy_doc = doc!(
self.chunk_id_field => chunk.id.clone(),
self.document_id_field => chunk.document_id.clone(),
self.text_field => chunk.content.clone(),
self.namespace_field => namespace,
);
writer
.add_document(tantivy_doc)
.map_err(|e| RagError::Store(format!("failed to add document: {e}")))?;
}
writer.commit().map_err(|e| RagError::Store(format!("failed to commit index: {e}")))?;
let mut reader = self.index.reader_builder();
reader = reader.reload_policy(ReloadPolicy::Manual);
let reader = reader
.try_into()
.map_err(|e| RagError::Store(format!("failed to create reader: {e}")))?;
reader.reload().map_err(|e| RagError::Store(format!("failed to reload reader: {e}")))?;
self.doc_count = chunks.len();
Ok(())
}
pub async fn execute(
&self,
query: &str,
config: &ChannelConfig,
_global_filters: &[StructuredFilter],
namespace: Option<&str>,
) -> Result<Vec<RetrievedChunk>, RagError> {
if self.doc_count == 0 || query.trim().is_empty() {
return Ok(vec![]);
}
let (k1, b) = self.read_bm25_params(config);
let reader = self
.index
.reader_builder()
.reload_policy(ReloadPolicy::Manual)
.try_into()
.map_err(|e| RagError::Retrieve(format!("failed to create reader: {e}")))?;
let searcher = reader.searcher();
let query_parser = QueryParser::for_index(&self.index, vec![self.text_field]);
let parsed_query = query_parser
.parse_query(query)
.map_err(|e| RagError::Retrieve(format!("query parse error: {e}")))?;
let final_query: Box<dyn tantivy::query::Query> = if let Some(ns) = namespace {
if ns.is_empty() {
return Ok(vec![]);
}
let namespace_term = Term::from_field_text(self.namespace_field, ns);
let term_query = TermQuery::new(namespace_term, IndexRecordOption::Basic);
Box::new(BooleanQuery::new(vec![
(Occur::Must, Box::new(parsed_query)),
(Occur::Must, Box::new(term_query)),
]))
} else {
Box::new(parsed_query)
};
let top_docs = TopDocs::with_limit(config.top_k);
let results = searcher
.search(&final_query, &top_docs)
.map_err(|e| RagError::Retrieve(format!("search error: {e}")))?;
let min_score = config.min_score.unwrap_or(0.0);
let mut hits: Vec<RetrievedChunk> = Vec::with_capacity(results.len());
for (score, doc_address) in results {
if score < min_score {
continue;
}
let doc: tantivy::TantivyDocument = searcher
.doc(doc_address)
.map_err(|e| RagError::Retrieve(format!("failed to fetch document: {e}")))?;
let chunk_id = doc
.get_first(self.chunk_id_field)
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
let document_id = doc
.get_first(self.document_id_field)
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
let content = doc
.get_first(self.text_field)
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
let channel_str = ChannelType::Bm25.as_str().to_string();
hits.push(RetrievedChunk {
chunk_id,
document_id,
content,
score,
channel: channel_str.clone(),
channel_score: score,
metadata: crate::types::chunk::ChunkMetadata::default(),
embedding: None,
});
}
let _ = (k1, b);
Ok(hits)
}
fn read_bm25_params(&self, config: &ChannelConfig) -> (f32, f32) {
let k1 =
config.params.get("k1").and_then(|v| v.as_f64()).map(|v| v as f32).unwrap_or(self.k1);
let b = config.params.get("b").and_then(|v| v.as_f64()).map(|v| v as f32).unwrap_or(self.b);
(k1, b)
}
}
impl Default for Bm25ChannelExecutor {
fn default() -> Self {
Self::new()
}
}