Skip to main content

daimon_plugin_opensearch/
store.rs

1//! [`OpenSearchVectorStore`] — an OpenSearch k-NN backed [`VectorStore`] implementation.
2
3use std::collections::HashMap;
4
5use daimon_core::vector_store::VectorStore;
6use daimon_core::{DaimonError, Document, Result, ScoredDocument};
7use opensearch::OpenSearch;
8use serde_json::json;
9
10use crate::SpaceType;
11
12/// A [`VectorStore`] backed by OpenSearch with the k-NN plugin.
13///
14/// Use [`OpenSearchVectorStoreBuilder`](crate::OpenSearchVectorStoreBuilder) to construct.
15pub struct OpenSearchVectorStore {
16    pub(crate) client: OpenSearch,
17    pub(crate) index: String,
18    pub(crate) dimensions: usize,
19    pub(crate) space_type: SpaceType,
20}
21
22impl OpenSearchVectorStore {
23    /// Returns a reference to the underlying OpenSearch client.
24    pub fn client(&self) -> &OpenSearch {
25        &self.client
26    }
27
28    /// Returns the index name used by this store.
29    pub fn index(&self) -> &str {
30        &self.index
31    }
32
33    /// Returns the configured vector dimensions.
34    pub fn dimensions(&self) -> usize {
35        self.dimensions
36    }
37
38    /// Returns the configured k-NN space type (distance metric).
39    ///
40    /// This determines how OpenSearch scores query hits; see [`query`](VectorStore::query)
41    /// for why those scores are only comparable within a single space type.
42    pub fn space_type(&self) -> SpaceType {
43        self.space_type
44    }
45
46    fn map_os_error(resp: opensearch::Error) -> DaimonError {
47        DaimonError::Other(format!("opensearch error: {resp}"))
48    }
49}
50
51impl VectorStore for OpenSearchVectorStore {
52    async fn upsert(&self, id: &str, embedding: Vec<f32>, document: Document) -> Result<()> {
53        if embedding.len() != self.dimensions {
54            return Err(DaimonError::Other(format!(
55                "embedding dimension mismatch: expected {}, got {}",
56                self.dimensions,
57                embedding.len()
58            )));
59        }
60
61        let body = json!({
62            "embedding": embedding,
63            "content": document.content,
64            "metadata": document.metadata,
65        });
66
67        let response = self
68            .client
69            .index(opensearch::IndexParts::IndexId(&self.index, id))
70            .body(body)
71            .send()
72            .await
73            .map_err(Self::map_os_error)?;
74
75        let status = response.status_code();
76        if !status.is_success() {
77            let text = response
78                .text()
79                .await
80                .unwrap_or_else(|_| "unknown error".into());
81            return Err(DaimonError::Other(format!(
82                "opensearch upsert failed ({status}): {text}"
83            )));
84        }
85
86        Ok(())
87    }
88
89    async fn upsert_many(&self, items: Vec<(String, Vec<f32>, Document)>) -> Result<()> {
90        if items.is_empty() {
91            return Ok(());
92        }
93        for (_, embedding, _) in &items {
94            if embedding.len() != self.dimensions {
95                return Err(DaimonError::Other(format!(
96                    "embedding dimension mismatch: expected {}, got {}",
97                    self.dimensions,
98                    embedding.len()
99                )));
100            }
101        }
102
103        // One `_bulk` request instead of one `index` request per document.
104        let mut body: Vec<opensearch::http::request::JsonBody<serde_json::Value>> =
105            Vec::with_capacity(items.len() * 2);
106        for (id, embedding, document) in items {
107            body.push(json!({ "index": { "_id": id } }).into());
108            body.push(
109                json!({
110                    "embedding": embedding,
111                    "content": document.content,
112                    "metadata": document.metadata,
113                })
114                .into(),
115            );
116        }
117
118        let response = self
119            .client
120            .bulk(opensearch::BulkParts::Index(&self.index))
121            .body(body)
122            .send()
123            .await
124            .map_err(Self::map_os_error)?;
125
126        let status = response.status_code();
127        if !status.is_success() {
128            let text = response
129                .text()
130                .await
131                .unwrap_or_else(|_| "unknown error".into());
132            return Err(DaimonError::Other(format!(
133                "opensearch bulk upsert failed ({status}): {text}"
134            )));
135        }
136
137        // A bulk request can return 200 with per-item failures.
138        let body: serde_json::Value = response
139            .json()
140            .await
141            .map_err(|e| DaimonError::Other(format!("opensearch response parse error: {e}")))?;
142        if body["errors"].as_bool().unwrap_or(false) {
143            let first_error = body["items"]
144                .as_array()
145                .and_then(|items| {
146                    items
147                        .iter()
148                        .find_map(|item| item["index"]["error"].as_object())
149                })
150                .map(|e| serde_json::Value::Object(e.clone()).to_string())
151                .unwrap_or_else(|| "unknown item error".into());
152            return Err(DaimonError::Other(format!(
153                "opensearch bulk upsert had item failures: {first_error}"
154            )));
155        }
156
157        Ok(())
158    }
159
160    async fn query(&self, embedding: Vec<f32>, top_k: usize) -> Result<Vec<ScoredDocument>> {
161        if embedding.len() != self.dimensions {
162            return Err(DaimonError::Other(format!(
163                "embedding dimension mismatch: expected {}, got {}",
164                self.dimensions,
165                embedding.len()
166            )));
167        }
168
169        let body = json!({
170            "size": top_k,
171            "query": {
172                "knn": {
173                    "embedding": {
174                        "vector": embedding,
175                        "k": top_k
176                    }
177                }
178            },
179            "_source": ["content", "metadata"]
180        });
181
182        let response = self
183            .client
184            .search(opensearch::SearchParts::Index(&[&self.index]))
185            .body(body)
186            .send()
187            .await
188            .map_err(Self::map_os_error)?;
189
190        let status = response.status_code();
191        if !status.is_success() {
192            let text = response
193                .text()
194                .await
195                .unwrap_or_else(|_| "unknown error".into());
196            return Err(DaimonError::Other(format!(
197                "opensearch query failed ({status}): {text}"
198            )));
199        }
200
201        let body: serde_json::Value = response
202            .json()
203            .await
204            .map_err(|e| DaimonError::Other(format!("opensearch response parse error: {e}")))?;
205
206        let hits = body["hits"]["hits"]
207            .as_array()
208            .unwrap_or(&Vec::new())
209            .clone();
210
211        let mut results = Vec::with_capacity(hits.len());
212        for hit in &hits {
213            results.push(hit_to_scored_document(hit)?);
214        }
215
216        Ok(results)
217    }
218
219    async fn delete(&self, id: &str) -> Result<bool> {
220        let response = self
221            .client
222            .delete(opensearch::DeleteParts::IndexId(&self.index, id))
223            .send()
224            .await
225            .map_err(Self::map_os_error)?;
226
227        let status = response.status_code();
228        if status == opensearch::http::StatusCode::NOT_FOUND {
229            return Ok(false);
230        }
231        if !status.is_success() {
232            let text = response
233                .text()
234                .await
235                .unwrap_or_else(|_| "unknown error".into());
236            return Err(DaimonError::Other(format!(
237                "opensearch delete failed ({status}): {text}"
238            )));
239        }
240
241        Ok(true)
242    }
243
244    async fn count(&self) -> Result<usize> {
245        let response = self
246            .client
247            .count(opensearch::CountParts::Index(&[&self.index]))
248            .send()
249            .await
250            .map_err(Self::map_os_error)?;
251
252        let status = response.status_code();
253        if !status.is_success() {
254            let text = response
255                .text()
256                .await
257                .unwrap_or_else(|_| "unknown error".into());
258            return Err(DaimonError::Other(format!(
259                "opensearch count failed ({status}): {text}"
260            )));
261        }
262
263        let body: serde_json::Value = response
264            .json()
265            .await
266            .map_err(|e| DaimonError::Other(format!("opensearch response parse error: {e}")))?;
267
268        let count = body["count"].as_u64().unwrap_or(0) as usize;
269        Ok(count)
270    }
271}
272
273/// Converts a single OpenSearch search-response `hit` into a [`ScoredDocument`].
274///
275/// Extracted from [`OpenSearchVectorStore::query`] so the id-extraction path
276/// (the same round-trip-to-delete id DAIM-29 introduced) is unit-testable
277/// against a fake JSON hit without a live OpenSearch cluster. A missing or
278/// non-string `_id` returns `Err` rather than silently degrading to an empty
279/// string, since an empty id would produce a document that can never be
280/// deleted again.
281fn hit_to_scored_document(hit: &serde_json::Value) -> Result<ScoredDocument> {
282    let id = hit["_id"]
283        .as_str()
284        .ok_or_else(|| DaimonError::Other(format!("opensearch hit missing _id: {hit}")))?
285        .to_string();
286    let content = hit["_source"]["content"]
287        .as_str()
288        .unwrap_or_default()
289        .to_string();
290
291    let metadata: HashMap<String, serde_json::Value> = hit["_source"]
292        .get("metadata")
293        .and_then(|m| serde_json::from_value(m.clone()).ok())
294        .unwrap_or_default();
295
296    // This is the backend-raw `_score` returned by OpenSearch. Its scale
297    // depends on the configured space type — OpenSearch applies a
298    // different transform per metric (e.g. `1 / (1 + distance)` for l2,
299    // and metric-specific formulas for cosinesimil / innerproduct), and
300    // these are not on a common, cleanly comparable 0..1 scale. We
301    // therefore surface the raw score unchanged: it is meaningful only
302    // for *ranking within a single space type*, not for cross-metric
303    // comparison or as a calibrated similarity probability.
304    let score = hit["_score"].as_f64().unwrap_or(0.0);
305
306    let doc = Document {
307        content,
308        metadata,
309        score: Some(score),
310    };
311    Ok(ScoredDocument::new(id, doc, score))
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use serde_json::json;
318
319    #[test]
320    fn hit_to_scored_document_extracts_id_content_and_score() {
321        let hit = json!({
322            "_id": "doc-1",
323            "_score": 0.87,
324            "_source": {
325                "content": "hello world",
326                "metadata": {"k": "v"}
327            }
328        });
329
330        let scored = hit_to_scored_document(&hit).unwrap();
331        assert_eq!(scored.id, "doc-1");
332        assert_eq!(scored.document.content, "hello world");
333        assert_eq!(scored.score, 0.87);
334    }
335
336    #[test]
337    fn hit_to_scored_document_errors_on_missing_id() {
338        let hit = json!({
339            "_score": 0.5,
340            "_source": {"content": "no id here"}
341        });
342
343        let err = hit_to_scored_document(&hit).unwrap_err();
344        assert!(err.to_string().contains("_id"));
345    }
346
347    #[test]
348    fn hit_to_scored_document_errors_on_non_string_id() {
349        let hit = json!({
350            "_id": 12345,
351            "_score": 0.5,
352            "_source": {"content": "numeric id"}
353        });
354
355        let err = hit_to_scored_document(&hit).unwrap_err();
356        assert!(err.to_string().contains("_id"));
357    }
358}