daimon_plugin_opensearch/
store.rs1use 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
12pub 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 pub fn client(&self) -> &OpenSearch {
25 &self.client
26 }
27
28 pub fn index(&self) -> &str {
30 &self.index
31 }
32
33 pub fn dimensions(&self) -> usize {
35 self.dimensions
36 }
37
38 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 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 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
273fn 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 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}