use std::collections::HashMap;
use async_trait::async_trait;
use oneai_core::traits::{Filter, KeywordBackend, Metadata, VectorHit};
use tantivy::collector::TopDocs;
use tantivy::query::QueryParser;
use tantivy::schema::{
IndexRecordOption, NumericOptions, Schema, TextFieldIndexing, TextOptions, Value,
};
use tantivy::Term;
use tantivy::{doc, Index, IndexReader, IndexWriter, TantivyDocument};
use tokio::sync::Mutex;
use crate::meta_from_json;
use crate::meta_to_json;
struct SchemaFields {
content: tantivy::schema::Field,
id_str: tantivy::schema::Field,
rowid: tantivy::schema::Field,
meta: tantivy::schema::Field,
}
pub struct TantivyBm25Backend {
index: Index,
fields: SchemaFields,
schema: Schema,
writer: Mutex<IndexWriter>,
reader: IndexReader,
id_to_rowid: Mutex<HashMap<String, i64>>,
next_rowid: Mutex<i64>,
}
impl TantivyBm25Backend {
pub fn in_memory() -> oneai_core::Result<Self> {
let (schema, fields) = build_schema();
let index = Index::create_in_ram(schema.clone());
index
.tokenizers()
.register("jieba", tantivy_jieba::JiebaTokenizer::new());
Self::finish(index, schema, fields)
}
pub fn open_dir(dir: &str) -> oneai_core::Result<Self> {
let path = std::path::Path::new(dir);
let exists = path.exists()
&& path
.read_dir()
.map(|mut d| d.next().is_some())
.unwrap_or(false);
let (schema, fields) = build_schema();
let index = if exists {
Index::open_in_dir(path)
.map_err(|e| oneai_core::OneAIError::Rag(format!("tantivy open {dir}: {e}")))?
} else {
std::fs::create_dir_all(path)
.map_err(|e| oneai_core::OneAIError::Rag(format!("mkdir {dir}: {e}")))?;
Index::create_in_dir(path, schema.clone())
.map_err(|e| oneai_core::OneAIError::Rag(format!("tantovy create {dir}: {e}")))?
};
index
.tokenizers()
.register("jieba", tantivy_jieba::JiebaTokenizer::new());
Self::finish(index, schema, fields)
}
fn finish(index: Index, schema: Schema, fields: SchemaFields) -> oneai_core::Result<Self> {
let writer = index
.writer_with_num_threads(1, 50_000_000)
.map_err(|e| oneai_core::OneAIError::Rag(format!("tantivy writer: {e}")))?;
let reader = index
.reader()
.map_err(|e| oneai_core::OneAIError::Rag(format!("tantivy reader: {e}")))?;
Ok(Self {
index,
fields,
schema,
writer: Mutex::new(writer),
reader,
id_to_rowid: Mutex::new(HashMap::new()),
next_rowid: Mutex::new(1),
})
}
pub fn schema(&self) -> &Schema {
&self.schema
}
pub fn index(&self) -> &Index {
&self.index
}
}
fn build_schema() -> (Schema, SchemaFields) {
let mut builder = Schema::builder();
let content = builder.add_text_field(
"content",
TextOptions::default().set_stored().set_indexing_options(
TextFieldIndexing::default()
.set_tokenizer("jieba")
.set_index_option(IndexRecordOption::WithFreqsAndPositions),
),
);
let id_str = builder.add_text_field("id_str", TextOptions::default().set_stored());
let rowid = builder.add_i64_field("rowid_i64", NumericOptions::default().set_indexed());
let meta = builder.add_text_field("meta", TextOptions::default().set_stored());
let schema = builder.build();
(
schema,
SchemaFields {
content,
id_str,
rowid,
meta,
},
)
}
#[async_trait]
impl KeywordBackend for TantivyBm25Backend {
async fn upsert_doc(&self, id: &str, text: &str, metadata: Metadata) -> oneai_core::Result<()> {
let mut writer = self.writer.lock().await;
let mut id_to_rowid = self.id_to_rowid.lock().await;
let mut next_rowid = self.next_rowid.lock().await;
if let Some(old_rowid) = id_to_rowid.get(id).copied() {
let term = Term::from_field_i64(self.fields.rowid, old_rowid);
writer.delete_term(term);
}
let rowid_val = *next_rowid;
*next_rowid += 1;
let meta_json = meta_to_json(&metadata);
let _ = writer
.add_document(doc!(
self.fields.content => text,
self.fields.id_str => id,
self.fields.rowid => rowid_val,
self.fields.meta => meta_json,
))
.map_err(|e| oneai_core::OneAIError::Rag(format!("tantivy add: {e}")))?;
writer
.commit()
.map_err(|e| oneai_core::OneAIError::Rag(format!("tantivy commit: {e}")))?;
id_to_rowid.insert(id.to_string(), rowid_val);
Ok(())
}
async fn search(
&self,
query: &str,
top_k: usize,
filter: Option<&Filter>,
) -> oneai_core::Result<Vec<VectorHit>> {
if top_k == 0 {
return Ok(Vec::new());
}
self.reader
.reload()
.map_err(|e| oneai_core::OneAIError::Rag(format!("tantivy reload: {e}")))?;
let searcher = self.reader.searcher();
let qp = QueryParser::for_index(&self.index, vec![self.fields.content]);
let qobj = match qp.parse_query(query) {
Ok(q) => q,
Err(_) => return Ok(Vec::new()),
};
let fetch = match filter {
Some(f) if !f.metadata_eq.is_empty() || !f.metadata_in.is_empty() => {
(top_k * 4).max(top_k)
}
_ => top_k,
};
let top = searcher
.search(&qobj, &TopDocs::with_limit(fetch).order_by_score())
.map_err(|e| oneai_core::OneAIError::Rag(format!("tantivy search: {e}")))?;
let mut hits: Vec<VectorHit> = Vec::new();
for (score, addr) in top {
let d: TantivyDocument = searcher
.doc(addr)
.map_err(|e| oneai_core::OneAIError::Rag(format!("tantivy doc: {e}")))?;
let id = d
.get_first(self.fields.id_str)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let meta_json = d
.get_first(self.fields.meta)
.and_then(|v| v.as_str())
.unwrap_or("{}");
let metadata = meta_from_json(meta_json);
if let Some(f) = filter {
if !f.matches(&metadata) {
continue;
}
}
hits.push(VectorHit {
id,
score,
metadata,
});
if hits.len() >= top_k {
break;
}
}
Ok(hits)
}
async fn delete(&self, id: &str) -> oneai_core::Result<()> {
let mut writer = self.writer.lock().await;
let mut id_to_rowid = self.id_to_rowid.lock().await;
if let Some(old_rowid) = id_to_rowid.remove(id) {
let term = Term::from_field_i64(self.fields.rowid, old_rowid);
writer.delete_term(term);
writer
.commit()
.map_err(|e| oneai_core::OneAIError::Rag(format!("tantivy commit: {e}")))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn docs() -> &'static [(&'static str, &'static str)] {
&[
("d1", "今天天气不错适合出门散步"),
("d2", "明天可能会下雨记得带伞"),
("d3", "天气预报说后天放晴气温回升"),
("d4", "机器学习是人工智能的一个分支"),
]
}
async fn fresh() -> TantivyBm25Backend {
let be = TantivyBm25Backend::in_memory().unwrap();
for (id, text) in docs() {
let mut m = Metadata::new();
m.insert(
"tag".into(),
if *id == "d4" {
"ai".into()
} else {
"weather".into()
},
);
be.upsert_doc(id, text, m).await.unwrap();
}
be
}
#[tokio::test]
async fn bm25_cjk_rare_term() {
let be = fresh().await;
let hits = be.search("人工智能", 5, None).await.unwrap();
assert!(!hits.is_empty(), "expected BM25 hits for 人工智能");
assert_eq!(hits[0].id, "d4");
assert!(
hits[0].score > 1.0,
"rare-term IDF should boost score: {}",
hits[0].score
);
let hits = be.search("天气", 5, None).await.unwrap();
let ids: Vec<&str> = hits.iter().map(|h| h.id.as_str()).collect();
assert!(ids.contains(&"d1") || ids.contains(&"d3"));
}
#[tokio::test]
async fn filter_by_metadata() {
let be = fresh().await;
let f = Filter::new().with_eq("tag", "weather");
let hits = be.search("天气", 10, Some(&f)).await.unwrap();
assert!(!hits.is_empty());
assert!(hits.iter().all(|h| h.metadata["tag"] == "weather"));
assert!(hits.iter().all(|h| h.id != "d4"));
}
#[tokio::test]
async fn upsert_replaces_and_delete_removes() {
let be = TantivyBm25Backend::in_memory().unwrap();
be.upsert_doc("x", "alpha beta", Metadata::new())
.await
.unwrap();
be.upsert_doc("x", "gamma delta", Metadata::new())
.await
.unwrap();
let hits = be.search("alpha", 10, None).await.unwrap();
assert!(
!hits.iter().any(|h| h.id == "x"),
"stale content leaked after upsert"
);
let hits = be.search("gamma", 10, None).await.unwrap();
assert!(hits.iter().any(|h| h.id == "x"));
be.delete("x").await.unwrap();
let hits = be.search("gamma", 10, None).await.unwrap();
assert!(!hits.iter().any(|h| h.id == "x"), "deleted doc leaked");
}
}