use std::{error::Error, sync::Arc};
use async_trait::async_trait;
use reqwest::{Client, RequestBuilder};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use crate::{
embedding::embedder_trait::Embedder,
schemas::Document,
vectorstore::{VecStoreOptions, VectorStore},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChromaDistance {
#[default]
Cosine,
L2,
InnerProduct,
}
impl ChromaDistance {
pub(super) fn as_str(self) -> &'static str {
match self {
ChromaDistance::Cosine => "cosine",
ChromaDistance::L2 => "l2",
ChromaDistance::InnerProduct => "ip",
}
}
fn score(self, distance: f64) -> f64 {
match self {
ChromaDistance::Cosine | ChromaDistance::InnerProduct => 1.0 - distance,
ChromaDistance::L2 => 1.0 / (1.0 + distance),
}
}
}
pub struct Store {
pub(super) client: Client,
pub(super) collection_url: String,
pub(super) api_key: Option<String>,
pub(super) embedder: Arc<dyn Embedder>,
pub(super) distance: ChromaDistance,
pub(super) namespace_key: String,
}
pub type ChromaOptions = VecStoreOptions<Value>;
type Rows<T> = Option<Vec<Vec<Option<T>>>>;
#[derive(Deserialize)]
struct QueryResponse {
ids: Vec<Vec<String>>,
#[serde(default)]
documents: Rows<String>,
#[serde(default)]
metadatas: Rows<Map<String, Value>>,
#[serde(default)]
distances: Rows<f64>,
}
pub(super) async fn send(
request: RequestBuilder,
api_key: &Option<String>,
) -> Result<Value, Box<dyn Error>> {
let request = match api_key {
Some(key) => request.header("x-chroma-token", key),
None => request,
};
let response = request.send().await?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() {
let message = serde_json::from_str::<Value>(&body)
.ok()
.and_then(|v| v["message"].as_str().map(str::to_string))
.unwrap_or(body);
return Err(format!("Chroma error ({status}): {message}").into());
}
Ok(serde_json::from_str(&body).unwrap_or(Value::Null))
}
fn chroma_metadata_value(value: &Value) -> Value {
fn scalar_kind(v: &Value) -> Option<u8> {
match v {
Value::Bool(_) => Some(0),
Value::Number(_) => Some(1),
Value::String(_) => Some(2),
_ => None,
}
}
match value {
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => value.clone(),
Value::Array(items) => {
let first = items.first().and_then(scalar_kind);
if first.is_some() && items.iter().all(|i| scalar_kind(i) == first) {
value.clone()
} else {
Value::String(value.to_string())
}
}
Value::Object(_) => Value::String(value.to_string()),
}
}
impl Store {
fn namespace_filter(&self, opt: &ChromaOptions) -> Option<Value> {
let namespace = opt
.name_space
.as_ref()
.map(|ns| json!({ &self.namespace_key: ns }));
match (namespace, opt.filters.clone()) {
(Some(ns), Some(filter)) => Some(json!({ "$and": [ns, filter] })),
(ns, filter) => ns.or(filter),
}
}
fn metadatas(&self, docs: &[Document], opt: &ChromaOptions) -> Vec<Option<Map<String, Value>>> {
docs.iter()
.map(|doc| {
let mut metadata: Map<String, Value> = doc
.metadata
.iter()
.map(|(k, v)| (k.clone(), chroma_metadata_value(v)))
.collect();
if let Some(ns) = &opt.name_space {
metadata.insert(self.namespace_key.clone(), json!(ns));
}
(!metadata.is_empty()).then_some(metadata)
})
.collect()
}
}
#[async_trait]
impl VectorStore for Store {
type Options = ChromaOptions;
async fn add_documents(
&self,
docs: &[Document],
opt: &ChromaOptions,
) -> Result<Vec<String>, Box<dyn Error>> {
if docs.is_empty() {
return Ok(vec![]);
}
let embedder = opt.embedder.as_ref().unwrap_or(&self.embedder);
let texts: Vec<String> = docs.iter().map(|d| d.page_content.clone()).collect();
let embeddings = embedder.embed_documents(&texts).await?;
if embeddings.len() != docs.len() {
return Err(format!(
"embedder returned {} vectors for {} documents",
embeddings.len(),
docs.len()
)
.into());
}
let ids: Vec<String> = docs
.iter()
.map(|_| uuid::Uuid::new_v4().to_string())
.collect();
let body = json!({
"ids": ids,
"embeddings": embeddings,
"documents": texts,
"metadatas": self.metadatas(docs, opt),
});
send(
self.client
.post(format!("{}/upsert", self.collection_url))
.json(&body),
&self.api_key,
)
.await?;
Ok(ids)
}
async fn similarity_search(
&self,
query: &str,
limit: usize,
opt: &ChromaOptions,
) -> Result<Vec<Document>, Box<dyn Error>> {
let embedder = opt.embedder.as_ref().unwrap_or(&self.embedder);
let embedding = embedder.embed_query(query).await?;
let mut body = json!({
"query_embeddings": [embedding],
"n_results": limit,
"include": ["documents", "metadatas", "distances"],
});
if let Some(filter) = self.namespace_filter(opt) {
body["where"] = filter;
}
let response = send(
self.client
.post(format!("{}/query", self.collection_url))
.json(&body),
&self.api_key,
)
.await?;
let response: QueryResponse = serde_json::from_value(response)?;
let ids = response.ids.into_iter().next().unwrap_or_default();
let mut documents = response
.documents
.and_then(|d| d.into_iter().next())
.unwrap_or_default()
.into_iter();
let mut metadatas = response
.metadatas
.and_then(|m| m.into_iter().next())
.unwrap_or_default()
.into_iter();
let mut distances = response
.distances
.and_then(|d| d.into_iter().next())
.unwrap_or_default()
.into_iter();
let mut results = Vec::with_capacity(ids.len());
for _ in ids {
let page_content = documents.next().flatten().unwrap_or_default();
let metadata = metadatas
.next()
.flatten()
.unwrap_or_default()
.into_iter()
.collect();
let score = distances
.next()
.flatten()
.map_or(0.0, |d| self.distance.score(d));
if opt
.score_threshold
.is_some_and(|threshold| score < f64::from(threshold))
{
continue;
}
results.push(Document {
page_content,
metadata,
score,
});
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_values_chroma_cannot_store_become_json_strings() {
for (input, expected) in [
(json!("s"), json!("s")),
(json!(1.5), json!(1.5)),
(json!(true), json!(true)),
(json!(null), json!(null)),
(json!([1, 2]), json!([1, 2])),
(json!(["a", "b"]), json!(["a", "b"])),
(json!([1, "a"]), json!("[1,\"a\"]")),
(json!([]), json!("[]")),
(json!([[1]]), json!("[[1]]")),
(json!({"a": 1}), json!("{\"a\":1}")),
] {
assert_eq!(chroma_metadata_value(&input), expected, "{input}");
}
}
#[test]
fn scores_from_distances() {
assert_eq!(ChromaDistance::Cosine.score(0.25), 0.75);
assert_eq!(ChromaDistance::InnerProduct.score(0.25), 0.75);
assert_eq!(ChromaDistance::L2.score(1.0), 0.5);
}
}