Skip to main content

lc_vector_stores/
chromadb.rs

1// lc-vector-stores/src/chromadb.rs
2//! ChromaDB vector store implementation (HTTP API)
3//!
4//! Uses ChromaDB's REST API for vector storage and retrieval.
5//! Supports connecting to a remote ChromaDB service (docker run -p 8000:8000 chromadb/chroma).
6
7use async_trait::async_trait;
8use lc_core::http::{BoundedResponse, HttpClient, HttpError, RequestOptions};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use std::collections::HashMap;
12
13use crate::{Document, FilterOp, MetadataFilter, SearchResult, VectorStore, VectorStoreError};
14
15/// Number of IDs to fetch+delete per round of [`ChromaDBVectorStore::clear`].
16///
17/// 0.25.0 B3: `clear` pages through the collection rather than fetching every
18/// document at once, so a very large collection is drained in bounded chunks
19/// instead of one unbounded `/get`. Deletions shift Chroma's internal ordering,
20/// so pagination must NOT use `offset` — paging only by the leading fetch (of
21/// this many IDs) always converges on the first remaining documents.
22const CHROMA_CLEAR_PAGE_SIZE: usize = 500;
23
24/// ChromaDB configuration
25#[derive(Debug, Clone)]
26pub struct ChromaDBConfig {
27    /// ChromaDB service URL, default http://localhost:8000
28    pub host: String,
29    /// Collection name
30    pub collection_name: String,
31    /// Vector dimension
32    pub vector_size: usize,
33    /// Collection metadata (optional)
34    pub metadata: Option<HashMap<String, String>>,
35}
36
37impl Default for ChromaDBConfig {
38    fn default() -> Self {
39        Self {
40            host: "http://localhost:8000".to_string(),
41            collection_name: "langchainrust".to_string(),
42            vector_size: 1536,
43            metadata: None,
44        }
45    }
46}
47
48impl ChromaDBConfig {
49    /// Creates a new ChromaDB configuration
50    pub fn new(
51        host: impl Into<String>,
52        collection_name: impl Into<String>,
53        vector_size: usize,
54    ) -> Self {
55        Self {
56            host: host.into(),
57            collection_name: collection_name.into(),
58            vector_size,
59            metadata: None,
60        }
61    }
62}
63
64/// ChromaDB collection info (parsed from the API response)
65#[derive(Debug, Deserialize)]
66#[allow(dead_code)]
67struct ChromaCollection {
68    id: String,
69    name: String,
70    #[serde(default)]
71    metadata: Option<serde_json::Value>,
72}
73
74/// ChromaDB add request body
75#[derive(Debug, Serialize)]
76struct ChromaAddRequest {
77    ids: Vec<String>,
78    embeddings: Vec<Vec<f32>>,
79    documents: Vec<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    metadatas: Option<Vec<HashMap<String, serde_json::Value>>>,
82}
83
84/// ChromaDB query request body
85#[derive(Debug, Serialize)]
86struct ChromaQueryRequest {
87    query_embeddings: Vec<Vec<f32>>,
88    n_results: usize,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    include: Option<Vec<String>>,
91    /// Chroma `where` filter dict (see [`filter_to_chroma`])
92    #[serde(rename = "where", skip_serializing_if = "Option::is_none")]
93    where_filter: Option<serde_json::Value>,
94}
95
96/// ChromaDB query response
97#[derive(Debug, Deserialize)]
98struct ChromaQueryResponse {
99    ids: Vec<Vec<String>>,
100    distances: Vec<Vec<f64>>,
101    documents: Vec<Vec<String>>,
102    #[serde(default)]
103    metadatas: Vec<Vec<Option<HashMap<String, serde_json::Value>>>>,
104}
105
106/// ChromaDB get response
107#[derive(Debug, Deserialize)]
108struct ChromaGetResponse {
109    ids: Vec<String>,
110    documents: Vec<Option<String>>,
111    #[serde(default)]
112    metadatas: Vec<Option<HashMap<String, serde_json::Value>>>,
113    embeddings: Option<Vec<Vec<f32>>>,
114}
115
116/// Null-tolerant `/get` page used by [`ChromaDBVectorStore::clear`]'s drain loop.
117///
118/// Chroma emits `"ids": null` (not `"ids": []`) when a collection is empty, which
119/// would fail to deserialize into the strict `Vec<String>` of [`ChromaGetResponse`].
120/// An `Option<Vec<String>>` (with `#[serde(default)]`) accepts `null`, a missing
121/// key, and a populated array; the loop flattens `None` to an empty batch.
122#[derive(Debug, Deserialize)]
123struct ChromaClearPage {
124    #[serde(default)]
125    ids: Option<Vec<String>>,
126}
127
128/// ChromaDB vector store
129///
130/// Connects to a ChromaDB service via HTTP API.
131///
132/// # Example
133/// ```ignore
134/// use lc_vector_stores::ChromaDBVectorStore;
135///
136/// let store = ChromaDBVectorStore::new(
137///     ChromaDBConfig::new("http://localhost:8000", "my_collection", 384)
138/// ).await?;
139/// ```
140pub struct ChromaDBVectorStore {
141    config: ChromaDBConfig,
142    http: HttpClient,
143    collection_id: Option<String>,
144}
145
146impl ChromaDBVectorStore {
147    /// Creates a ChromaDB vector store and initializes the collection automatically
148    pub async fn new(config: ChromaDBConfig) -> Result<Self, VectorStoreError> {
149        // The unified client defaults to `no_proxy()` (local proxy software such as
150        // Clash must not intercept Chroma's in-cluster traffic) and is infallible
151        // for these settings.
152        let http = HttpClient::api().build().map_err(|e| {
153            VectorStoreError::ConfigError(format!("failed to build HTTP client: {e}"))
154        })?;
155        let mut store = Self {
156            config,
157            http,
158            collection_id: None,
159        };
160        store.init_collection().await?;
161        Ok(store)
162    }
163
164    /// Maps a transport/spool error from the unified client into a
165    /// [`VectorStoreError`], keeping the server's own error body (which Chroma
166    /// puts real detail into) instead of a bare status code.
167    fn map_http_error(prefix: &str, err: HttpError) -> VectorStoreError {
168        match err {
169            HttpError::Status { status, body } => {
170                VectorStoreError::StorageError(format!("{prefix}: HTTP {status}: {body}"))
171            }
172            other => VectorStoreError::ConnectionError(format!("{prefix}: {other}")),
173        }
174    }
175
176    /// Initializes or fetches the collection
177    async fn init_collection(&mut self) -> Result<(), VectorStoreError> {
178        // try to fetch the existing collection
179        let url = format!(
180            "{}/api/v1/collections/{}",
181            self.config.host, self.config.collection_name
182        );
183        let response = self
184            .http
185            .get(&url)
186            .await
187            .map_err(|e| Self::map_http_error("ChromaDB init error", e))?;
188
189        if response.status.is_success() {
190            let collection: ChromaCollection =
191                serde_json::from_str(&response.body).map_err(|e| {
192                    VectorStoreError::StorageError(format!(
193                        "failed to parse collection info: {}",
194                        e
195                    ))
196                })?;
197            self.collection_id = Some(collection.id);
198            return Ok(());
199        }
200
201        // collection does not exist, create a new one
202        let create_url = format!("{}/api/v1/collections", self.config.host);
203        let mut body = json!({
204            "name": self.config.collection_name,
205        });
206
207        if let Some(ref meta) = self.config.metadata {
208            body["metadata"] = serde_json::to_value(meta).unwrap_or(json!({}));
209        }
210
211        let response = self
212            .http
213            .post_json_with(&create_url, &body, RequestOptions::new())
214            .await
215            .map_err(|e| Self::map_http_error("ChromaDB create error", e))?;
216
217        if response.status.is_success() {
218            let collection: ChromaCollection =
219                serde_json::from_str(&response.body).map_err(|e| {
220                    VectorStoreError::StorageError(format!(
221                        "failed to parse new collection info: {}",
222                        e
223                    ))
224                })?;
225            self.collection_id = Some(collection.id);
226            Ok(())
227        } else {
228            Err(VectorStoreError::StorageError(format!(
229                "failed to create collection: {}",
230                response.body
231            )))
232        }
233    }
234
235    /// Posts a JSON body and, on success, hands back the buffered response.
236    ///
237    /// Centralizes the per-endpoint error translation (transport/disturbed vs.
238    /// server-visible body) that every Chroma operation shared.
239    async fn post_json(
240        &self,
241        url: &str,
242        body: &serde_json::Value,
243        prefix: &str,
244    ) -> Result<BoundedResponse, VectorStoreError> {
245        self.http
246            .post_json_with(url, body, RequestOptions::new())
247            .await
248            .map_err(|e| Self::map_http_error(prefix, e))
249    }
250
251    /// Gets the collection ID
252    fn get_collection_id(&self) -> Result<&str, VectorStoreError> {
253        self.collection_id.as_deref().ok_or_else(|| {
254            VectorStoreError::StorageError("collection is not initialized".to_string())
255        })
256    }
257
258    /// Builds the collection API base URL
259    fn collection_url(&self, endpoint: &str) -> Result<String, VectorStoreError> {
260        let cid = self.get_collection_id()?;
261        Ok(format!(
262            "{}/api/v1/collections/{}/{}",
263            self.config.host, cid, endpoint
264        ))
265    }
266
267    /// Builds a Chroma query request body (pure function, convenient for testing).
268    fn query_request(
269        query_embedding: &[f32],
270        k: usize,
271        filter: Option<&MetadataFilter>,
272    ) -> ChromaQueryRequest {
273        ChromaQueryRequest {
274            query_embeddings: vec![query_embedding.to_vec()],
275            n_results: k,
276            include: Some(vec![
277                "documents".to_string(),
278                "distances".to_string(),
279                "metadatas".to_string(),
280            ]),
281            where_filter: filter.map(filter_to_chroma),
282        }
283    }
284
285    /// POSTs to `/query` and parses the result (shared by plain and filtered retrieval).
286    async fn query_impl(
287        &self,
288        request: ChromaQueryRequest,
289    ) -> Result<Vec<SearchResult>, VectorStoreError> {
290        let url = self.collection_url("query")?;
291        let body = serde_json::to_value(&request).map_err(|e| {
292            VectorStoreError::StorageError(format!("failed to serialize query: {e}"))
293        })?;
294        let response = self.post_json(&url, &body, "ChromaDB query failed").await?;
295
296        let query_result: ChromaQueryResponse =
297            serde_json::from_str(&response.body).map_err(|e| {
298                VectorStoreError::StorageError(format!("failed to parse query results: {}", e))
299            })?;
300
301        let mut results = Vec::new();
302
303        // ChromaDB returns nested arrays (one result set per query)
304        if let Some(doc_list) = query_result.documents.into_iter().next() {
305            let dist_list = query_result
306                .distances
307                .into_iter()
308                .next()
309                .unwrap_or_default();
310            let meta_list = query_result
311                .metadatas
312                .into_iter()
313                .next()
314                .unwrap_or_default();
315            let id_list = query_result.ids.into_iter().next().unwrap_or_default();
316
317            for (i, content) in doc_list.into_iter().enumerate() {
318                let score = dist_list.get(i).copied().unwrap_or(0.0);
319                // ChromaDB returns L2 distance; convert to a similarity score (1 / (1 + dist))
320                let similarity = 1.0 / (1.0 + score);
321                let metadata = meta_list
322                    .get(i)
323                    .unwrap_or(&None)
324                    .clone()
325                    .unwrap_or_default();
326                let doc_id = id_list.get(i).cloned();
327
328                results.push(SearchResult {
329                    document: Document {
330                        content,
331                        metadata,
332                        id: doc_id,
333                    },
334                    score: similarity as f32,
335                });
336            }
337        }
338
339        // sort by similarity descending
340        results.sort_by(|a, b| {
341            b.score
342                .partial_cmp(&a.score)
343                .unwrap_or(std::cmp::Ordering::Equal)
344        });
345        Ok(results)
346    }
347
348    /// Fetches up to [`CHROMA_CLEAR_PAGE_SIZE`] document IDs without any
349    /// documents/metadatas/embeddings payload (`include: []` keeps the response
350    /// tiny). Uses a dedicated nullable-`ids` struct: Chroma returns
351    /// `{"ids": null, ...}` for an empty collection, which a strict `Vec<String>`
352    /// would refuse to deserialize.
353    async fn fetch_clear_page(&self) -> Result<Vec<String>, VectorStoreError> {
354        let get_url = self.collection_url("get")?;
355        let body = json!({
356            "include": [],
357            "limit": CHROMA_CLEAR_PAGE_SIZE
358        });
359        let response = self
360            .post_json(&get_url, &body, "ChromaDB clear fetch failed")
361            .await?;
362        let page: ChromaClearPage = serde_json::from_str(&response.body).map_err(|e| {
363            VectorStoreError::StorageError(format!("failed to parse document list: {}", e))
364        })?;
365        Ok(page.ids.unwrap_or_default())
366    }
367}
368
369#[async_trait]
370impl VectorStore for ChromaDBVectorStore {
371    async fn add_documents(
372        &self,
373        documents: Vec<Document>,
374        embeddings: Vec<Vec<f32>>,
375    ) -> Result<Vec<String>, VectorStoreError> {
376        if documents.is_empty() {
377            return Ok(Vec::new());
378        }
379
380        let count = documents.len();
381        let ids: Vec<String> = (0..count)
382            .map(|i| {
383                documents[i]
384                    .id
385                    .clone()
386                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
387            })
388            .collect();
389
390        let contents: Vec<String> = documents.iter().map(|d| d.content.clone()).collect();
391        let metadatas: Vec<HashMap<String, serde_json::Value>> =
392            documents.iter().map(|d| d.metadata.clone()).collect();
393        let has_metadata = metadatas.iter().any(|m| !m.is_empty());
394
395        let request = ChromaAddRequest {
396            ids: ids.clone(),
397            embeddings,
398            documents: contents,
399            metadatas: if has_metadata { Some(metadatas) } else { None },
400        };
401
402        let url = self.collection_url("add")?;
403        let body = serde_json::to_value(&request).map_err(|e| {
404            VectorStoreError::StorageError(format!("failed to serialize add request: {e}"))
405        })?;
406        let _ = self.post_json(&url, &body, "ChromaDB add failed").await?;
407        Ok(ids)
408    }
409
410    async fn similarity_search(
411        &self,
412        query_embedding: &[f32],
413        k: usize,
414    ) -> Result<Vec<SearchResult>, VectorStoreError> {
415        let request = Self::query_request(query_embedding, k, None);
416        self.query_impl(request).await
417    }
418
419    /// S3: similarity search with metadata filtering — filtering is delegated to the server (Chroma `where` syntax).
420    async fn similarity_search_with_filter(
421        &self,
422        query_embedding: &[f32],
423        k: usize,
424        filter: Option<&MetadataFilter>,
425    ) -> Result<Vec<SearchResult>, VectorStoreError> {
426        let request = Self::query_request(query_embedding, k, filter);
427        self.query_impl(request).await
428    }
429
430    async fn get_document(&self, id: &str) -> Result<Option<Document>, VectorStoreError> {
431        let url = self.collection_url("get")?;
432        let body = json!({
433            "ids": [id],
434            "include": ["documents", "metadatas"]
435        });
436
437        let response = self
438            .http
439            .post_json_with(&url, &body, RequestOptions::new())
440            .await;
441        let response = match response {
442            // A `not found` (e.g. 404) means no document — same as an empty result.
443            Err(HttpError::Status { .. }) => return Ok(None),
444            Err(e) => return Err(Self::map_http_error("ChromaDB get_document error", e)),
445            Ok(r) => r,
446        };
447
448        let get_result: ChromaGetResponse = serde_json::from_str(&response.body).map_err(|e| {
449            VectorStoreError::StorageError(format!("failed to parse document: {}", e))
450        })?;
451
452        if get_result.ids.is_empty() {
453            return Ok(None);
454        }
455
456        let content = get_result
457            .documents
458            .into_iter()
459            .next()
460            .flatten()
461            .unwrap_or_default();
462        let metadata = get_result
463            .metadatas
464            .into_iter()
465            .next()
466            .flatten()
467            .unwrap_or_default();
468
469        Ok(Some(Document {
470            content,
471            metadata,
472            id: Some(id.to_string()),
473        }))
474    }
475
476    async fn get_embedding(&self, id: &str) -> Result<Option<Vec<f32>>, VectorStoreError> {
477        let url = self.collection_url("get")?;
478        let body = json!({
479            "ids": [id],
480            "include": ["embeddings"]
481        });
482
483        let response = self
484            .http
485            .post_json_with(&url, &body, RequestOptions::new())
486            .await;
487        let response = match response {
488            // A `not found` (e.g. 404) means no embedding — same as an empty result.
489            Err(HttpError::Status { .. }) => return Ok(None),
490            Err(e) => return Err(Self::map_http_error("ChromaDB get_embedding error", e)),
491            Ok(r) => r,
492        };
493
494        let get_result: ChromaGetResponse = serde_json::from_str(&response.body).map_err(|e| {
495            VectorStoreError::StorageError(format!("failed to parse document: {}", e))
496        })?;
497
498        if let Some(embeddings) = get_result.embeddings {
499            Ok(embeddings.into_iter().next())
500        } else {
501            Ok(None)
502        }
503    }
504
505    async fn delete_document(&self, id: &str) -> Result<(), VectorStoreError> {
506        let url = self.collection_url("delete")?;
507        let body = json!({
508            "ids": [id]
509        });
510
511        let _ = self
512            .post_json(&url, &body, "ChromaDB delete failed")
513            .await?;
514        Ok(())
515    }
516
517    async fn count(&self) -> usize {
518        let url = match self.collection_url("count") {
519            Ok(u) => u,
520            Err(e) => {
521                log::warn!("ChromaDB count() failed to build URL: {}", e);
522                return 0;
523            }
524        };
525
526        let response = self
527            .http
528            .post_json_with(&url, &serde_json::json!({}), RequestOptions::new())
529            .await;
530        match response {
531            Ok(resp) => match serde_json::from_str::<usize>(&resp.body) {
532                Ok(count) => count,
533                Err(e) => {
534                    log::warn!("ChromaDB count() failed to parse response: {}", e);
535                    0
536                }
537            },
538            Err(e) => {
539                log::warn!("ChromaDB count() request failed: {}", e);
540                0
541            }
542        }
543    }
544
545    async fn clear(&self) -> Result<(), VectorStoreError> {
546        // Drain the collection in bounded pages: repeatedly fetch the leading
547        // `PAGE_SIZE` IDs (no `offset` — deletions shift Chroma's internal
548        // ordering, so position-based pagination cannot be trusted), delete them,
549        // and repeat until a fetch returns no IDs. A safety bound prevents an
550        // infinite loop against a pathological server that never empties.
551        const MAX_ROUNDS: usize = 10_000;
552        for _round in 0..MAX_ROUNDS {
553            let ids = self.fetch_clear_page().await?;
554            if ids.is_empty() {
555                return Ok(());
556            }
557
558            let del_url = self.collection_url("delete")?;
559            let del_body = json!({ "ids": ids });
560            let _ = self
561                .post_json(&del_url, &del_body, "ChromaDB clear delete failed")
562                .await?;
563        }
564
565        Err(VectorStoreError::StorageError(format!(
566            "failed to clear collection after {MAX_ROUNDS} delete rounds"
567        )))
568    }
569}
570
571/// S3: translates [`MetadataFilter`] → Chroma `where` filter dict.
572///
573/// A single-field condition becomes `{ key: { "$op": value } }` (Chroma v2 supports
574/// `$eq $ne $gt $gte $lt $lte $in $nin`); AND/OR combinations become
575/// `{ "$and": [...] }` / `{ "$or": [...] }`. Isomorphic to the Pinecone translation,
576/// but maintained per backend independently; the semantics are fully delegated to the server.
577pub fn filter_to_chroma(filter: &MetadataFilter) -> serde_json::Value {
578    fn op_str(op: FilterOp) -> &'static str {
579        match op {
580            FilterOp::Eq => "$eq",
581            FilterOp::Ne => "$ne",
582            FilterOp::Gt => "$gt",
583            FilterOp::Gte => "$gte",
584            FilterOp::Lt => "$lt",
585            FilterOp::Lte => "$lte",
586            FilterOp::In => "$in",
587            FilterOp::Nin => "$nin",
588        }
589    }
590    match filter {
591        MetadataFilter::Field { key, op, value } => {
592            serde_json::json!({ key.clone(): { op_str(*op): value.clone() } })
593        }
594        MetadataFilter::And(filters) => {
595            let items: Vec<serde_json::Value> = filters.iter().map(filter_to_chroma).collect();
596            serde_json::json!({ "$and": items })
597        }
598        MetadataFilter::Or(filters) => {
599            let items: Vec<serde_json::Value> = filters.iter().map(filter_to_chroma).collect();
600            serde_json::json!({ "$or": items })
601        }
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use tokio::io::{AsyncReadExt, AsyncWriteExt};
609    use tokio::net::TcpListener;
610
611    /// A minimal loopback server: reads one request (headers + body) and replies
612    /// with `(status, full_request_text)` chosen by `handler(path, request_text)`.
613    async fn spawn_loopback(
614        handler: impl Fn(&str, &str) -> (u16, String) + Send + Sync + 'static,
615    ) -> String {
616        use std::sync::Arc;
617        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
618        let addr = listener.local_addr().unwrap();
619        let handler = Arc::new(handler);
620        tokio::spawn(async move {
621            loop {
622                let Ok((mut socket, _)) = listener.accept().await else {
623                    break;
624                };
625                let handler = handler.clone();
626                tokio::spawn(async move {
627                    let mut raw = Vec::new();
628                    let mut buf = [0u8; 4096];
629                    let (head_end, len) = loop {
630                        if let Some(pos) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
631                            let text = String::from_utf8_lossy(&raw).to_string();
632                            let len = text
633                                .lines()
634                                .find_map(|l| {
635                                    let lower = l.to_ascii_lowercase();
636                                    lower
637                                        .strip_prefix("content-length:")
638                                        .map(|v| v.trim().to_string())
639                                })
640                                .and_then(|v| v.parse::<usize>().ok())
641                                .unwrap_or(0);
642                            break (pos + 4, len);
643                        }
644                        let n = socket.read(&mut buf).await.unwrap_or(0);
645                        if n == 0 {
646                            break (raw.len(), 0);
647                        }
648                        raw.extend_from_slice(&buf[..n]);
649                    };
650                    while raw.len() < head_end + len {
651                        let n = socket.read(&mut buf).await.unwrap_or(0);
652                        if n == 0 {
653                            break;
654                        }
655                        raw.extend_from_slice(&buf[..n]);
656                    }
657                    let text = String::from_utf8_lossy(&raw).to_string();
658                    let path = text.split_whitespace().nth(1).unwrap_or("/").to_string();
659                    let (status, resp_body) = handler(&path, &text);
660                    let reason = if status == 200 { "OK" } else { "Error" };
661                    let head = format!(
662                        "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
663                        resp_body.len()
664                    );
665                    let _ = socket.write_all(head.as_bytes()).await;
666                    let _ = socket.write_all(resp_body.as_bytes()).await;
667                    let _ = socket.shutdown().await;
668                });
669            }
670        });
671        format!("http://{}", addr)
672    }
673
674    /// Builds a [`ChromaDBVectorStore`] already bound to `collection_id`, bypassing
675    /// `init_collection` so a test can drive a loopback endpoint directly.
676    fn store_with_collection(base: &str, cid: &str) -> ChromaDBVectorStore {
677        let http = HttpClient::api().build().expect("build http client");
678        ChromaDBVectorStore {
679            config: ChromaDBConfig::new(base, "col", 3),
680            http,
681            collection_id: Some(cid.to_string()),
682        }
683    }
684
685    /// 0.25.0 B3: `clear` drains the collection in bounded pages, tolerates a
686    /// null-`ids` empty reply, and stops once no IDs remain.
687    #[tokio::test]
688    async fn test_clear_drains_in_pages() {
689        use std::sync::{Arc, Mutex};
690
691        let gets = Arc::new(Mutex::new(0u32));
692        let deletes = Arc::new(Mutex::new(0u32));
693        let gets_h = gets.clone();
694        let deletes_h = deletes.clone();
695
696        let base = spawn_loopback(move |path, request| {
697            if path.ends_with("/get") {
698                *gets_h.lock().unwrap() += 1;
699                // First page: 3 ids; after the drain, Chroma returns null ids.
700                let body = if *gets_h.lock().unwrap() == 1 {
701                    r#"{"ids":["a","b","c"],"documents":null,"metadatas":null,"embeddings":null}"#
702                        .to_string()
703                } else {
704                    r#"{"ids":null,"documents":null,"metadatas":null,"embeddings":null}"#
705                        .to_string()
706                };
707                // The leading page must carry the size bound + no payload fetch.
708                let raw = request.to_string();
709                assert!(
710                    raw.to_ascii_lowercase()
711                        .contains("content-type: application/json"),
712                    "clear /get must carry a JSON body: {raw}"
713                );
714                (200, body)
715            } else if path.ends_with("/delete") {
716                *deletes_h.lock().unwrap() += 1;
717                (200, String::new())
718            } else {
719                (404, "not found".to_string())
720            }
721        })
722        .await;
723
724        let store = store_with_collection(&base, "cid");
725        store.clear().await.unwrap();
726        assert_eq!(*gets.lock().unwrap(), 2, "two get rounds expected");
727        // Three ids are deleted in a single batch (one delete call, many ids).
728        assert_eq!(*deletes.lock().unwrap(), 1, "one bulk delete expected");
729    }
730
731    /// 0.25.0 B3: `clear` on an already-empty collection makes a single get and
732    /// performs no delete.
733    #[tokio::test]
734    async fn test_clear_noop_when_empty() {
735        use std::sync::{Arc, Mutex};
736        let gets = Arc::new(Mutex::new(0u32));
737        let deletes = Arc::new(Mutex::new(0u32));
738        let gets_h = gets.clone();
739        let deletes_h = deletes.clone();
740
741        let base = spawn_loopback(move |path, _request| {
742            if path.ends_with("/get") {
743                *gets_h.lock().unwrap() += 1;
744                (
745                    200,
746                    r#"{"ids":null,"documents":null,"metadatas":null}"#.to_string(),
747                )
748            } else if path.ends_with("/delete") {
749                *deletes_h.lock().unwrap() += 1;
750                (200, String::new())
751            } else {
752                (404, "not found".to_string())
753            }
754        })
755        .await;
756
757        let store = store_with_collection(&base, "cid");
758        store.clear().await.unwrap();
759        assert_eq!(*gets.lock().unwrap(), 1);
760        assert_eq!(*deletes.lock().unwrap(), 0);
761    }
762
763    /// S3: single-field condition → Chroma `where` dict.
764    #[test]
765    fn test_filter_to_chroma_field() {
766        assert_eq!(
767            filter_to_chroma(&MetadataFilter::field("lang", FilterOp::Eq, "rust")),
768            serde_json::json!({ "lang": { "$eq": "rust" } })
769        );
770        assert_eq!(
771            filter_to_chroma(&MetadataFilter::field("year", FilterOp::Lt, 2020)),
772            serde_json::json!({ "year": { "$lt": 2020 } })
773        );
774    }
775
776    /// S3: AND/OR combination → nested `$and`/`$or`.
777    #[test]
778    fn test_filter_to_chroma_and_or() {
779        let f = MetadataFilter::or(vec![
780            MetadataFilter::field("lang", FilterOp::Eq, "python"),
781            MetadataFilter::and(vec![
782                MetadataFilter::field("lang", FilterOp::Eq, "rust"),
783                MetadataFilter::field("tags", FilterOp::In, vec!["ml"]),
784            ]),
785        ]);
786        assert_eq!(
787            filter_to_chroma(&f),
788            serde_json::json!({
789                "$or": [
790                    { "lang": { "$eq": "python" } },
791                    { "$and": [
792                        { "lang": { "$eq": "rust" } },
793                        { "tags": { "$in": ["ml"] } }
794                    ]}
795                ]
796            })
797        );
798    }
799
800    /// S3: without a filter, `where_filter` is None and the `where` field is not serialized.
801    #[test]
802    fn test_query_request_no_filter() {
803        let req = ChromaDBVectorStore::query_request(&[1.0, 2.0], 3, None);
804        assert!(req.where_filter.is_none());
805        let v = serde_json::to_value(&req).unwrap();
806        assert!(v.get("where").is_none());
807        assert_eq!(v["n_results"], 3);
808    }
809
810    /// S3: with a filter, the `where` field serializes to a Chroma dict.
811    #[test]
812    fn test_query_request_with_filter() {
813        let f = MetadataFilter::field("lang", FilterOp::Eq, "rust");
814        let req = ChromaDBVectorStore::query_request(&[1.0, 2.0], 3, Some(&f));
815        let v = serde_json::to_value(&req).unwrap();
816        assert_eq!(v["where"], serde_json::json!({ "lang": { "$eq": "rust" } }));
817    }
818
819    /// B3/T2: live clear-paging against a real Chroma (`CHROMA_URL`). The loopback
820    /// tests above prove the request *sequence* (`get`首页 → 批量删 → 至空); this
821    /// proves the multi-page cleanup end-to-end by inserting > 500 docs (above
822    /// Chroma's default get page limit of 500) and asserting `clear` empties all
823    /// pages. Skipped when `CHROMA_URL` is unset; exercised by the T2 services CI.
824    #[tokio::test]
825    #[ignore = "requires a running Chroma (set CHROMA_URL)"]
826    async fn clear_removes_all_pages_live() {
827        let Ok(host) = std::env::var("CHROMA_URL") else {
828            eprintln!("CHROMA_URL not set; skipping live Chroma clear-paging");
829            return;
830        };
831        let collection = format!("clear_paging_{}", std::process::id());
832        let store = ChromaDBVectorStore::new(ChromaDBConfig::new(host, collection, 8))
833            .await
834            .expect("connect to live Chroma");
835        store.clear().await.expect("clear before seeding");
836
837        // Seed 1250 docs — comfortably above a single 500-row get page.
838        let docs: Vec<Document> = (0..1250)
839            .map(|i| Document::new(format!("doc {i}")))
840            .collect();
841        let embeddings: Vec<Vec<f32>> = (0..1250).map(|i| vec![i as f32; 8]).collect();
842        let _ = store
843            .add_documents(docs, embeddings)
844            .await
845            .expect("seed live Chroma");
846        let seeded = store.count().await;
847        assert!(seeded >= 1250, "expected all seeded docs, got {seeded}");
848
849        store.clear().await.expect("live clear");
850        assert_eq!(store.count().await, 0, "clear must remove across all pages");
851    }
852}