use std::{collections::HashMap, error::Error, future::Future, sync::Arc, time::Duration};
use async_trait::async_trait;
use futures::TryStreamExt;
use mongodb::{
bson::{doc, Bson, Document as BsonDocument},
Collection, SearchIndexModel, SearchIndexType,
};
use serde_json::Value;
use tokio::sync::OnceCell;
use crate::{
embedding::Embedder,
schemas::Document,
vectorstore::{VecStoreOptions, VectorStore},
};
pub(super) const SCORE_FIELD: &str = "__vector_score";
const MAX_CANDIDATES: usize = 10_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MongoSimilarity {
#[default]
Cosine,
Euclidean,
DotProduct,
}
impl MongoSimilarity {
pub(super) fn as_str(self) -> &'static str {
match self {
Self::Cosine => "cosine",
Self::Euclidean => "euclidean",
Self::DotProduct => "dotProduct",
}
}
}
pub struct Store {
pub(super) collection: Collection<BsonDocument>,
pub(super) embedder: Arc<dyn Embedder>,
pub(super) index_name: String,
pub(super) text_key: String,
pub(super) embedding_key: String,
pub(super) metadata_key: String,
pub(super) namespace_key: String,
pub(super) namespace: Option<String>,
pub(super) filter_paths: Vec<String>,
pub(super) similarity: MongoSimilarity,
pub(super) num_candidates: usize,
pub(super) create_index: bool,
pub(super) index_timeout: Duration,
pub(super) index_ready: OnceCell<()>,
}
pub(super) async fn wait_until_queryable<F, Fut>(
mut status: F,
interval: Duration,
timeout: Duration,
) -> Result<(), Box<dyn Error + Send + Sync>>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<Option<bool>, Box<dyn Error + Send + Sync>>>,
{
let deadline = tokio::time::Instant::now() + timeout;
loop {
match status().await? {
Some(true) => return Ok(()),
Some(false) => {}
None => return Err("the vector search index disappeared while building".into()),
}
if tokio::time::Instant::now() + interval > deadline {
return Err(format!(
"the vector search index was not queryable after {timeout:?}; \
raise index_timeout or check the index in Atlas"
)
.into());
}
tokio::time::sleep(interval).await;
}
}
pub(super) fn num_candidates(limit: usize, multiplier: usize) -> usize {
limit
.saturating_mul(multiplier)
.clamp(limit, MAX_CANDIDATES.max(limit))
}
fn bson_to_json(value: Bson) -> Value {
match value {
Bson::ObjectId(id) => Value::String(id.to_hex()),
Bson::DateTime(dt) => dt
.try_to_rfc3339_string()
.map(Value::String)
.unwrap_or_else(|_| Bson::DateTime(dt).into_relaxed_extjson()),
Bson::Document(doc) => {
Value::Object(doc.into_iter().map(|(k, v)| (k, bson_to_json(v))).collect())
}
Bson::Array(items) => Value::Array(items.into_iter().map(bson_to_json).collect()),
other => other.into_relaxed_extjson(),
}
}
impl Store {
pub fn collection(&self) -> &Collection<BsonDocument> {
&self.collection
}
pub fn index_name(&self) -> &str {
&self.index_name
}
pub(super) fn index_definition(&self, dim: usize) -> BsonDocument {
let mut fields = vec![
Bson::Document(doc! {
"type": "vector",
"path": &self.embedding_key,
"numDimensions": dim as i64,
"similarity": self.similarity.as_str(),
}),
Bson::Document(doc! { "type": "filter", "path": &self.namespace_key }),
];
for path in &self.filter_paths {
fields.push(Bson::Document(doc! { "type": "filter", "path": path }));
}
doc! { "fields": fields }
}
pub(super) fn to_bson(
&self,
doc: &Document,
vector: &[f64],
namespace: Option<&str>,
) -> Result<BsonDocument, Box<dyn Error>> {
let mut metadata = BsonDocument::new();
let mut keys: Vec<_> = doc.metadata.keys().collect();
keys.sort();
for key in keys {
let value = Bson::try_from(doc.metadata[key].clone())
.map_err(|e| format!("metadata {key:?} is not valid BSON: {e}"))?;
metadata.insert(key.clone(), value);
}
let mut stored = doc! {
&self.text_key: &doc.page_content,
&self.embedding_key: vector.iter().map(|x| Bson::Double(*x)).collect::<Vec<_>>(),
};
if let Some(ns) = namespace {
stored.insert(&self.namespace_key, ns);
}
stored.insert(&self.metadata_key, metadata);
Ok(stored)
}
pub(super) fn filter(
&self,
namespace: Option<&str>,
filters: Option<&Value>,
) -> Result<Option<BsonDocument>, Box<dyn Error>> {
let mut clauses = Vec::new();
if let Some(ns) = namespace {
clauses.push(doc! { &self.namespace_key: { "$eq": ns } });
}
match filters {
None | Some(Value::Null) => {}
Some(Value::Object(fields)) if fields.is_empty() => {}
Some(value @ Value::Object(_)) => match Bson::try_from(value.clone()) {
Ok(Bson::Document(doc)) => clauses.push(doc),
Ok(_) => unreachable!("a JSON object converts to a BSON document"),
Err(e) => return Err(format!("invalid MongoDB filter: {e}").into()),
},
Some(other) => {
return Err(format!(
"MongoDB filters are an MQL object like {{\"metadata.year\": {{\"$gte\": 2020}}}}, got {other}"
)
.into())
}
}
Ok(match clauses.len() {
0 => None,
1 => clauses.pop(),
_ => Some(doc! { "$and": clauses }),
})
}
pub(super) fn pipeline(
&self,
vector: &[f64],
limit: usize,
filter: Option<BsonDocument>,
) -> Vec<BsonDocument> {
let mut search = doc! {
"index": &self.index_name,
"path": &self.embedding_key,
"queryVector": vector.iter().map(|x| Bson::Double(*x)).collect::<Vec<_>>(),
"numCandidates": num_candidates(limit, self.num_candidates) as i64,
"limit": limit as i64,
};
if let Some(filter) = filter {
search.insert("filter", filter);
}
vec![
doc! { "$vectorSearch": search },
doc! { "$project": {
&self.embedding_key: 0,
SCORE_FIELD: { "$meta": "vectorSearchScore" },
} },
]
}
pub(super) fn to_document(&self, mut stored: BsonDocument) -> Result<Document, Box<dyn Error>> {
let score = match stored.remove(SCORE_FIELD) {
Some(Bson::Double(s)) => s,
Some(Bson::Int32(s)) => s.into(),
Some(Bson::Int64(s)) => s as f64,
other => return Err(format!("search result without a score: {other:?}").into()),
};
let page_content = match stored.remove(&self.text_key) {
Some(Bson::String(text)) => text,
Some(other) => {
return Err(format!("{:?} is not a string: {other}", self.text_key).into())
}
None => String::new(),
};
let metadata: HashMap<String, Value> = match stored.remove(&self.metadata_key) {
Some(Bson::Document(metadata)) => metadata
.into_iter()
.map(|(k, v)| (k, bson_to_json(v)))
.collect(),
_ => HashMap::new(),
};
Ok(Document {
page_content,
metadata,
score,
})
}
async fn index_status(&self) -> Result<Option<bool>, Box<dyn Error + Send + Sync>> {
let mut cursor = self
.collection
.list_search_indexes()
.name(self.index_name.clone())
.await?;
Ok(cursor
.try_next()
.await?
.map(|index| index.get_bool("queryable").unwrap_or(false)))
}
async fn ensure_index(&self, dim: usize) -> Result<(), Box<dyn Error + Send + Sync>> {
if !self.create_index {
return Ok(());
}
self.index_ready
.get_or_try_init(|| async {
if self.index_status().await? == Some(true) {
return Ok(());
}
let model = SearchIndexModel::builder()
.name(self.index_name.clone())
.index_type(SearchIndexType::VectorSearch)
.definition(self.index_definition(dim))
.build();
match self.collection.create_search_index(model).await {
Ok(_) => {}
Err(err) => {
if self.index_status().await?.is_none() {
return Err(err.into());
}
}
}
wait_until_queryable(
|| self.index_status(),
Duration::from_millis(500),
self.index_timeout,
)
.await
})
.await
.map(|_| ())
}
}
#[async_trait]
impl VectorStore for Store {
type Options = VecStoreOptions<Value>;
async fn add_documents(
&self,
docs: &[Document],
opt: &Self::Options,
) -> Result<Vec<String>, Box<dyn Error>> {
if docs.is_empty() {
return Ok(Vec::new());
}
let embedder = opt.embedder.as_ref().unwrap_or(&self.embedder);
let texts: Vec<String> = docs.iter().map(|d| d.page_content.clone()).collect();
let vectors = embedder.embed_documents(&texts).await?;
if vectors.len() != docs.len() {
return Err(format!(
"embedder returned {} vectors for {} documents",
vectors.len(),
docs.len()
)
.into());
}
let namespace = opt.name_space.as_deref().or(self.namespace.as_deref());
let stored = docs
.iter()
.zip(&vectors)
.map(|(doc, vector)| self.to_bson(doc, vector, namespace))
.collect::<Result<Vec<_>, _>>()?;
let inserted = self.collection.insert_many(stored).await?;
let mut ids: Vec<(usize, String)> = inserted
.inserted_ids
.into_iter()
.map(|(i, id)| match id {
Bson::ObjectId(id) => (i, id.to_hex()),
other => (i, other.to_string()),
})
.collect();
ids.sort_by_key(|(i, _)| *i);
self.ensure_index(vectors[0].len())
.await
.map_err(|e| -> Box<dyn Error> { e.to_string().into() })?;
Ok(ids.into_iter().map(|(_, id)| id).collect())
}
async fn similarity_search(
&self,
query: &str,
limit: usize,
opt: &Self::Options,
) -> Result<Vec<Document>, Box<dyn Error>> {
if limit == 0 {
return Ok(Vec::new());
}
let namespace = opt.name_space.as_deref().or(self.namespace.as_deref());
let filter = self.filter(namespace, opt.filters.as_ref())?;
let embedder = opt.embedder.as_ref().unwrap_or(&self.embedder);
let vector = embedder.embed_query(query).await?;
let results: Vec<BsonDocument> = self
.collection
.aggregate(self.pipeline(&vector, limit, filter))
.await?
.try_collect()
.await?;
let mut docs = Vec::with_capacity(results.len());
for result in results {
let doc = self.to_document(result)?;
if opt
.score_threshold
.is_some_and(|threshold| doc.score < f64::from(threshold))
{
continue;
}
docs.push(doc);
}
Ok(docs)
}
}