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 query(&self, embedding: Vec<f32>, top_k: usize) -> Result<Vec<ScoredDocument>> {
90        if embedding.len() != self.dimensions {
91            return Err(DaimonError::Other(format!(
92                "embedding dimension mismatch: expected {}, got {}",
93                self.dimensions,
94                embedding.len()
95            )));
96        }
97
98        let body = json!({
99            "size": top_k,
100            "query": {
101                "knn": {
102                    "embedding": {
103                        "vector": embedding,
104                        "k": top_k
105                    }
106                }
107            },
108            "_source": ["content", "metadata"]
109        });
110
111        let response = self
112            .client
113            .search(opensearch::SearchParts::Index(&[&self.index]))
114            .body(body)
115            .send()
116            .await
117            .map_err(Self::map_os_error)?;
118
119        let status = response.status_code();
120        if !status.is_success() {
121            let text = response
122                .text()
123                .await
124                .unwrap_or_else(|_| "unknown error".into());
125            return Err(DaimonError::Other(format!(
126                "opensearch query failed ({status}): {text}"
127            )));
128        }
129
130        let body: serde_json::Value = response
131            .json()
132            .await
133            .map_err(|e| DaimonError::Other(format!("opensearch response parse error: {e}")))?;
134
135        let hits = body["hits"]["hits"]
136            .as_array()
137            .unwrap_or(&Vec::new())
138            .clone();
139
140        let mut results = Vec::with_capacity(hits.len());
141        for hit in &hits {
142            let content = hit["_source"]["content"]
143                .as_str()
144                .unwrap_or_default()
145                .to_string();
146
147            let metadata: HashMap<String, serde_json::Value> = hit["_source"]
148                .get("metadata")
149                .and_then(|m| serde_json::from_value(m.clone()).ok())
150                .unwrap_or_default();
151
152            // This is the backend-raw `_score` returned by OpenSearch. Its scale
153            // depends on the configured space type — OpenSearch applies a
154            // different transform per metric (e.g. `1 / (1 + distance)` for l2,
155            // and metric-specific formulas for cosinesimil / innerproduct), and
156            // these are not on a common, cleanly comparable 0..1 scale. We
157            // therefore surface the raw score unchanged: it is meaningful only
158            // for *ranking within a single space type*, not for cross-metric
159            // comparison or as a calibrated similarity probability.
160            let score = hit["_score"].as_f64().unwrap_or(0.0);
161
162            let doc = Document {
163                content,
164                metadata,
165                score: Some(score),
166            };
167            results.push(ScoredDocument::new(doc, score));
168        }
169
170        Ok(results)
171    }
172
173    async fn delete(&self, id: &str) -> Result<bool> {
174        let response = self
175            .client
176            .delete(opensearch::DeleteParts::IndexId(&self.index, id))
177            .send()
178            .await
179            .map_err(Self::map_os_error)?;
180
181        let status = response.status_code();
182        if status == opensearch::http::StatusCode::NOT_FOUND {
183            return Ok(false);
184        }
185        if !status.is_success() {
186            let text = response
187                .text()
188                .await
189                .unwrap_or_else(|_| "unknown error".into());
190            return Err(DaimonError::Other(format!(
191                "opensearch delete failed ({status}): {text}"
192            )));
193        }
194
195        Ok(true)
196    }
197
198    async fn count(&self) -> Result<usize> {
199        let response = self
200            .client
201            .count(opensearch::CountParts::Index(&[&self.index]))
202            .send()
203            .await
204            .map_err(Self::map_os_error)?;
205
206        let status = response.status_code();
207        if !status.is_success() {
208            let text = response
209                .text()
210                .await
211                .unwrap_or_else(|_| "unknown error".into());
212            return Err(DaimonError::Other(format!(
213                "opensearch count failed ({status}): {text}"
214            )));
215        }
216
217        let body: serde_json::Value = response
218            .json()
219            .await
220            .map_err(|e| DaimonError::Other(format!("opensearch response parse error: {e}")))?;
221
222        let count = body["count"].as_u64().unwrap_or(0) as usize;
223        Ok(count)
224    }
225}