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            let content = hit["_source"]["content"]
214                .as_str()
215                .unwrap_or_default()
216                .to_string();
217
218            let metadata: HashMap<String, serde_json::Value> = hit["_source"]
219                .get("metadata")
220                .and_then(|m| serde_json::from_value(m.clone()).ok())
221                .unwrap_or_default();
222
223            // This is the backend-raw `_score` returned by OpenSearch. Its scale
224            // depends on the configured space type — OpenSearch applies a
225            // different transform per metric (e.g. `1 / (1 + distance)` for l2,
226            // and metric-specific formulas for cosinesimil / innerproduct), and
227            // these are not on a common, cleanly comparable 0..1 scale. We
228            // therefore surface the raw score unchanged: it is meaningful only
229            // for *ranking within a single space type*, not for cross-metric
230            // comparison or as a calibrated similarity probability.
231            let score = hit["_score"].as_f64().unwrap_or(0.0);
232
233            let doc = Document {
234                content,
235                metadata,
236                score: Some(score),
237            };
238            results.push(ScoredDocument::new(doc, score));
239        }
240
241        Ok(results)
242    }
243
244    async fn delete(&self, id: &str) -> Result<bool> {
245        let response = self
246            .client
247            .delete(opensearch::DeleteParts::IndexId(&self.index, id))
248            .send()
249            .await
250            .map_err(Self::map_os_error)?;
251
252        let status = response.status_code();
253        if status == opensearch::http::StatusCode::NOT_FOUND {
254            return Ok(false);
255        }
256        if !status.is_success() {
257            let text = response
258                .text()
259                .await
260                .unwrap_or_else(|_| "unknown error".into());
261            return Err(DaimonError::Other(format!(
262                "opensearch delete failed ({status}): {text}"
263            )));
264        }
265
266        Ok(true)
267    }
268
269    async fn count(&self) -> Result<usize> {
270        let response = self
271            .client
272            .count(opensearch::CountParts::Index(&[&self.index]))
273            .send()
274            .await
275            .map_err(Self::map_os_error)?;
276
277        let status = response.status_code();
278        if !status.is_success() {
279            let text = response
280                .text()
281                .await
282                .unwrap_or_else(|_| "unknown error".into());
283            return Err(DaimonError::Other(format!(
284                "opensearch count failed ({status}): {text}"
285            )));
286        }
287
288        let body: serde_json::Value = response
289            .json()
290            .await
291            .map_err(|e| DaimonError::Other(format!("opensearch response parse error: {e}")))?;
292
293        let count = body["count"].as_u64().unwrap_or(0) as usize;
294        Ok(count)
295    }
296}