1use std::collections::HashMap;
11#[allow(unused_imports)]
12use zeph_db::sql;
13
14use zeph_db::{ActiveDialect, DbPool};
15
16use crate::vector_store::{
17 BoxFuture, FieldValue, ScoredVectorPoint, ScrollResult, ScrollWithIdsResult, VectorFilter,
18 VectorPoint, VectorStore, VectorStoreError,
19};
20
21pub struct DbVectorStore {
26 pool: DbPool,
27}
28
29pub type SqliteVectorStore = DbVectorStore;
31
32impl DbVectorStore {
33 #[must_use]
38 pub fn new(pool: DbPool) -> Self {
39 Self { pool }
40 }
41}
42
43use zeph_common::math::cosine_similarity;
44
45fn matches_filter(payload: &HashMap<String, serde_json::Value>, filter: &VectorFilter) -> bool {
46 for cond in &filter.must {
47 let Some(val) = payload.get(&cond.field) else {
48 return false;
49 };
50 let matches = match &cond.value {
51 FieldValue::Integer(i) => val.as_i64().is_some_and(|v| v == *i),
52 FieldValue::Text(t) => val.as_str().is_some_and(|v| v == t.as_str()),
53 };
54 if !matches {
55 return false;
56 }
57 }
58 for cond in &filter.must_not {
59 let Some(val) = payload.get(&cond.field) else {
60 continue;
61 };
62 let matches = match &cond.value {
63 FieldValue::Integer(i) => val.as_i64().is_some_and(|v| v == *i),
64 FieldValue::Text(t) => val.as_str().is_some_and(|v| v == t.as_str()),
65 };
66 if matches {
67 return false;
68 }
69 }
70 true
71}
72
73impl VectorStore for DbVectorStore {
74 fn ensure_collection(
75 &self,
76 collection: &str,
77 _vector_size: u64,
78 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
79 let collection = collection.to_owned();
80 Box::pin(async move {
81 let sql = zeph_db::rewrite_placeholders(&format!(
82 "{} INTO vector_collections (name) VALUES (?){}",
83 <ActiveDialect as zeph_db::dialect::Dialect>::INSERT_IGNORE,
84 <ActiveDialect as zeph_db::dialect::Dialect>::CONFLICT_NOTHING,
85 ));
86 zeph_db::query(sqlx::AssertSqlSafe(sql))
87 .bind(&collection)
88 .execute(&self.pool)
89 .await
90 .map_err(|e| VectorStoreError::Collection(e.to_string()))?;
91 Ok(())
92 })
93 }
94
95 fn collection_exists(&self, collection: &str) -> BoxFuture<'_, Result<bool, VectorStoreError>> {
96 let collection = collection.to_owned();
97 Box::pin(async move {
98 let row: (i64,) = zeph_db::query_as(sql!(
99 "SELECT COUNT(*) FROM vector_collections WHERE name = ?"
100 ))
101 .bind(&collection)
102 .fetch_one(&self.pool)
103 .await
104 .map_err(|e| VectorStoreError::Connection(e.to_string()))?;
105 Ok(row.0 > 0)
106 })
107 }
108
109 fn delete_collection(&self, collection: &str) -> BoxFuture<'_, Result<(), VectorStoreError>> {
110 let collection = collection.to_owned();
111 Box::pin(async move {
112 zeph_db::query(sql!("DELETE FROM vector_points WHERE collection = ?"))
113 .bind(&collection)
114 .execute(&self.pool)
115 .await
116 .map_err(|e| VectorStoreError::Delete(e.to_string()))?;
117 zeph_db::query(sql!("DELETE FROM vector_collections WHERE name = ?"))
118 .bind(&collection)
119 .execute(&self.pool)
120 .await
121 .map_err(|e| VectorStoreError::Delete(e.to_string()))?;
122 Ok(())
123 })
124 }
125
126 fn upsert(
127 &self,
128 collection: &str,
129 points: Vec<VectorPoint>,
130 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
131 let collection = collection.to_owned();
132 #[cfg(feature = "profiling")]
133 let span = tracing::info_span!(
134 "memory.vector_store",
135 operation = "upsert",
136 collection = %collection
137 );
138 let fut = Box::pin(async move {
139 for point in points {
140 let vector_bytes: Vec<u8> =
141 point.vector.iter().flat_map(|f| f.to_le_bytes()).collect();
142 let payload_json = serde_json::to_string(&point.payload)
143 .map_err(|e| VectorStoreError::Serialization(e.to_string()))?;
144 zeph_db::query(
145 sql!("INSERT INTO vector_points (id, collection, vector, payload) VALUES (?, ?, ?, ?) \
146 ON CONFLICT(collection, id) DO UPDATE SET vector = excluded.vector, payload = excluded.payload"),
147 )
148 .bind(&point.id)
149 .bind(&collection)
150 .bind(&vector_bytes)
151 .bind(&payload_json)
152 .execute(&self.pool)
153 .await
154 .map_err(|e| VectorStoreError::Upsert(e.to_string()))?;
155 }
156 Ok(())
157 });
158 #[cfg(feature = "profiling")]
159 return Box::pin(tracing::Instrument::instrument(fut, span));
160 #[cfg(not(feature = "profiling"))]
161 fut
162 }
163
164 fn search_clamped(
165 &self,
166 collection: &str,
167 vector: Vec<f32>,
168 limit: u64,
169 filter: Option<VectorFilter>,
170 ) -> BoxFuture<'_, Result<Vec<ScoredVectorPoint>, VectorStoreError>> {
171 let collection = collection.to_owned();
172 #[cfg(feature = "profiling")]
173 let span = tracing::info_span!(
174 "memory.vector_store",
175 operation = "search",
176 collection = %collection
177 );
178 let fut = Box::pin(async move {
179 let rows: Vec<(String, Vec<u8>, String)> = zeph_db::query_as(sql!(
180 "SELECT id, vector, payload FROM vector_points WHERE collection = ?"
181 ))
182 .bind(&collection)
183 .fetch_all(&self.pool)
184 .await
185 .map_err(|e| VectorStoreError::Search(e.to_string()))?;
186
187 let limit_usize = usize::try_from(limit).unwrap_or(usize::MAX);
188 let mut scored: Vec<ScoredVectorPoint> = rows
189 .into_iter()
190 .filter_map(|(id, blob, payload_str)| {
191 if blob.len() % 4 != 0 {
192 return None;
193 }
194 let stored: Vec<f32> = blob
195 .chunks_exact(4)
196 .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
197 .collect();
198 let payload: HashMap<String, serde_json::Value> =
199 serde_json::from_str(&payload_str).unwrap_or_default();
200
201 if filter
202 .as_ref()
203 .is_some_and(|f| !matches_filter(&payload, f))
204 {
205 return None;
206 }
207
208 let score = cosine_similarity(&vector, &stored);
209 Some(ScoredVectorPoint { id, score, payload })
210 })
211 .collect();
212
213 scored.sort_by(|a, b| {
214 b.score
215 .partial_cmp(&a.score)
216 .unwrap_or(std::cmp::Ordering::Equal)
217 });
218 scored.truncate(limit_usize);
219 Ok(scored)
220 });
221 #[cfg(feature = "profiling")]
222 return Box::pin(tracing::Instrument::instrument(fut, span));
223 #[cfg(not(feature = "profiling"))]
224 fut
225 }
226
227 fn delete_by_ids(
228 &self,
229 collection: &str,
230 ids: Vec<String>,
231 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
232 let collection = collection.to_owned();
233 #[cfg(feature = "profiling")]
234 let span = tracing::info_span!(
235 "memory.vector_store",
236 operation = "delete",
237 collection = %collection
238 );
239 let fut = Box::pin(async move {
240 for id in ids {
241 zeph_db::query(sql!(
242 "DELETE FROM vector_points WHERE collection = ? AND id = ?"
243 ))
244 .bind(&collection)
245 .bind(&id)
246 .execute(&self.pool)
247 .await
248 .map_err(|e| VectorStoreError::Delete(e.to_string()))?;
249 }
250 Ok(())
251 });
252 #[cfg(feature = "profiling")]
253 return Box::pin(tracing::Instrument::instrument(fut, span));
254 #[cfg(not(feature = "profiling"))]
255 fut
256 }
257
258 fn scroll_all(
259 &self,
260 collection: &str,
261 key_field: &str,
262 ) -> BoxFuture<'_, Result<ScrollResult, VectorStoreError>> {
263 let collection = collection.to_owned();
264 let key_field = key_field.to_owned();
265 Box::pin(async move {
266 let rows: Vec<(String, String)> = zeph_db::query_as(sql!(
267 "SELECT id, payload FROM vector_points WHERE collection = ?"
268 ))
269 .bind(&collection)
270 .fetch_all(&self.pool)
271 .await
272 .map_err(|e| VectorStoreError::Scroll(e.to_string()))?;
273
274 let mut result = ScrollResult::new();
275 for (id, payload_str) in rows {
276 let payload: HashMap<String, serde_json::Value> =
277 serde_json::from_str(&payload_str).unwrap_or_default();
278 if let Some(val) = payload.get(&key_field) {
279 let mut map = HashMap::new();
280 map.insert(
281 key_field.clone(),
282 val.as_str().unwrap_or_default().to_owned(),
283 );
284 result.insert(id, map);
285 }
286 }
287 Ok(result)
288 })
289 }
290
291 fn scroll_all_with_point_ids(
292 &self,
293 collection: &str,
294 key_field: &str,
295 ) -> BoxFuture<'_, Result<ScrollWithIdsResult, VectorStoreError>> {
296 let collection = collection.to_owned();
297 let key_field = key_field.to_owned();
298 Box::pin(async move {
299 let rows: Vec<(String, String)> = zeph_db::query_as(sql!(
300 "SELECT id, payload FROM vector_points WHERE collection = ?"
301 ))
302 .bind(&collection)
303 .fetch_all(&self.pool)
304 .await
305 .map_err(|e| VectorStoreError::Scroll(e.to_string()))?;
306
307 let mut result = Vec::new();
308 for (point_id, payload_str) in rows {
309 let payload: HashMap<String, serde_json::Value> =
310 serde_json::from_str(&payload_str).unwrap_or_default();
311 let Some(key_val) = payload.get(&key_field).and_then(|v| v.as_str()) else {
312 continue;
313 };
314 let mut fields = HashMap::new();
315 for (k, v) in &payload {
316 if let Some(s) = v.as_str() {
317 fields.insert(k.clone(), s.to_owned());
318 }
319 }
320 fields.insert(key_field.clone(), key_val.to_owned());
322 result.push((point_id, fields));
323 }
324 Ok(result)
325 })
326 }
327
328 fn health_check(&self) -> BoxFuture<'_, Result<bool, VectorStoreError>> {
329 Box::pin(async move {
330 zeph_db::query_scalar::<_, i32>(sql!("SELECT 1"))
331 .fetch_one(&self.pool)
332 .await
333 .map(|_| true)
334 .map_err(|e| VectorStoreError::Collection(e.to_string()))
335 })
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::store::SqliteStore;
343 use crate::vector_store::FieldCondition;
344
345 async fn setup() -> (DbVectorStore, SqliteStore) {
346 let store = SqliteStore::new(":memory:").await.unwrap();
347 let pool = store.pool().clone();
348 let vs = DbVectorStore::new(pool);
349 (vs, store)
350 }
351
352 #[tokio::test]
353 async fn ensure_and_exists() {
354 let (vs, _) = setup().await;
355 assert!(!vs.collection_exists("col1").await.unwrap());
356 vs.ensure_collection("col1", 4).await.unwrap();
357 assert!(vs.collection_exists("col1").await.unwrap());
358 vs.ensure_collection("col1", 4).await.unwrap();
360 assert!(vs.collection_exists("col1").await.unwrap());
361 }
362
363 #[tokio::test]
364 async fn delete_collection() {
365 let (vs, _) = setup().await;
366 vs.ensure_collection("col1", 4).await.unwrap();
367 vs.upsert(
368 "col1",
369 vec![VectorPoint {
370 id: "p1".into(),
371 vector: vec![1.0, 0.0, 0.0, 0.0],
372 payload: HashMap::new(),
373 }],
374 )
375 .await
376 .unwrap();
377 vs.delete_collection("col1").await.unwrap();
378 assert!(!vs.collection_exists("col1").await.unwrap());
379 }
380
381 #[tokio::test]
382 async fn upsert_and_search() {
383 let (vs, _) = setup().await;
384 vs.ensure_collection("c", 4).await.unwrap();
385 vs.upsert(
386 "c",
387 vec![
388 VectorPoint {
389 id: "a".into(),
390 vector: vec![1.0, 0.0, 0.0, 0.0],
391 payload: HashMap::from([("role".into(), serde_json::json!("user"))]),
392 },
393 VectorPoint {
394 id: "b".into(),
395 vector: vec![0.0, 1.0, 0.0, 0.0],
396 payload: HashMap::from([("role".into(), serde_json::json!("assistant"))]),
397 },
398 ],
399 )
400 .await
401 .unwrap();
402
403 let results = vs
404 .search("c", vec![1.0, 0.0, 0.0, 0.0], 2, None)
405 .await
406 .unwrap();
407 assert_eq!(results.len(), 2);
408 assert_eq!(results[0].id, "a");
409 assert!((results[0].score - 1.0).abs() < 1e-5);
410 }
411
412 #[tokio::test]
416 #[tracing_test::traced_test]
417 async fn search_clamps_oversized_limit() {
418 let (vs, _) = setup().await;
419 vs.ensure_collection("c", 4).await.unwrap();
420 let points: Vec<VectorPoint> = (0..(crate::MAX_SEARCH_LIMIT + 10))
421 .map(|i| VectorPoint {
422 id: format!("p{i}"),
423 vector: vec![1.0, 0.0, 0.0, 0.0],
424 payload: HashMap::new(),
425 })
426 .collect();
427 vs.upsert("c", points).await.unwrap();
428
429 let results = vs
430 .search("c", vec![1.0, 0.0, 0.0, 0.0], u64::MAX, None)
431 .await
432 .unwrap();
433 assert_eq!(results.len(), crate::MAX_SEARCH_LIMIT);
434 assert!(
435 logs_contain("requested search limit exceeds MAX_SEARCH_LIMIT"),
436 "expected a one-shot warn when the trait-impl clamp actually reduces the requested limit"
437 );
438 }
439
440 #[tokio::test]
441 async fn search_with_filter() {
442 let (vs, _) = setup().await;
443 vs.ensure_collection("c", 4).await.unwrap();
444 vs.upsert(
445 "c",
446 vec![
447 VectorPoint {
448 id: "a".into(),
449 vector: vec![1.0, 0.0, 0.0, 0.0],
450 payload: HashMap::from([("role".into(), serde_json::json!("user"))]),
451 },
452 VectorPoint {
453 id: "b".into(),
454 vector: vec![1.0, 0.0, 0.0, 0.0],
455 payload: HashMap::from([("role".into(), serde_json::json!("assistant"))]),
456 },
457 ],
458 )
459 .await
460 .unwrap();
461
462 let filter = VectorFilter {
463 must: vec![FieldCondition {
464 field: "role".into(),
465 value: FieldValue::Text("user".into()),
466 }],
467 must_not: vec![],
468 };
469 let results = vs
470 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, Some(filter))
471 .await
472 .unwrap();
473 assert_eq!(results.len(), 1);
474 assert_eq!(results[0].id, "a");
475 }
476
477 #[tokio::test]
478 async fn delete_by_ids() {
479 let (vs, _) = setup().await;
480 vs.ensure_collection("c", 4).await.unwrap();
481 vs.upsert(
482 "c",
483 vec![
484 VectorPoint {
485 id: "a".into(),
486 vector: vec![1.0, 0.0, 0.0, 0.0],
487 payload: HashMap::new(),
488 },
489 VectorPoint {
490 id: "b".into(),
491 vector: vec![0.0, 1.0, 0.0, 0.0],
492 payload: HashMap::new(),
493 },
494 ],
495 )
496 .await
497 .unwrap();
498 vs.delete_by_ids("c", vec!["a".into()]).await.unwrap();
499 let results = vs
500 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, None)
501 .await
502 .unwrap();
503 assert_eq!(results.len(), 1);
504 assert_eq!(results[0].id, "b");
505 }
506
507 #[tokio::test]
508 async fn scroll_all() {
509 let (vs, _) = setup().await;
510 vs.ensure_collection("c", 4).await.unwrap();
511 vs.upsert(
512 "c",
513 vec![VectorPoint {
514 id: "p1".into(),
515 vector: vec![1.0, 0.0, 0.0, 0.0],
516 payload: HashMap::from([("text".into(), serde_json::json!("hello"))]),
517 }],
518 )
519 .await
520 .unwrap();
521 let result = vs.scroll_all("c", "text").await.unwrap();
522 assert_eq!(result.len(), 1);
523 assert_eq!(result["p1"]["text"], "hello");
524 }
525
526 #[tokio::test]
527 async fn upsert_updates_existing() {
528 let (vs, _) = setup().await;
529 vs.ensure_collection("c", 4).await.unwrap();
530 vs.upsert(
531 "c",
532 vec![VectorPoint {
533 id: "p1".into(),
534 vector: vec![1.0, 0.0, 0.0, 0.0],
535 payload: HashMap::from([("v".into(), serde_json::json!(1))]),
536 }],
537 )
538 .await
539 .unwrap();
540 vs.upsert(
541 "c",
542 vec![VectorPoint {
543 id: "p1".into(),
544 vector: vec![0.0, 1.0, 0.0, 0.0],
545 payload: HashMap::from([("v".into(), serde_json::json!(2))]),
546 }],
547 )
548 .await
549 .unwrap();
550 let results = vs
551 .search("c", vec![0.0, 1.0, 0.0, 0.0], 1, None)
552 .await
553 .unwrap();
554 assert_eq!(results.len(), 1);
555 assert!((results[0].score - 1.0).abs() < 1e-5);
556 }
557
558 #[test]
559 fn cosine_similarity_import_wired() {
560 assert!(!cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]).is_nan());
562 }
563
564 #[tokio::test]
565 async fn search_with_must_not_filter() {
566 let (vs, _) = setup().await;
567 vs.ensure_collection("c", 4).await.unwrap();
568 vs.upsert(
569 "c",
570 vec![
571 VectorPoint {
572 id: "a".into(),
573 vector: vec![1.0, 0.0, 0.0, 0.0],
574 payload: HashMap::from([("role".into(), serde_json::json!("user"))]),
575 },
576 VectorPoint {
577 id: "b".into(),
578 vector: vec![1.0, 0.0, 0.0, 0.0],
579 payload: HashMap::from([("role".into(), serde_json::json!("system"))]),
580 },
581 ],
582 )
583 .await
584 .unwrap();
585
586 let filter = VectorFilter {
587 must: vec![],
588 must_not: vec![FieldCondition {
589 field: "role".into(),
590 value: FieldValue::Text("system".into()),
591 }],
592 };
593 let results = vs
594 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, Some(filter))
595 .await
596 .unwrap();
597 assert_eq!(results.len(), 1);
598 assert_eq!(results[0].id, "a");
599 }
600
601 #[tokio::test]
602 async fn search_with_integer_filter() {
603 let (vs, _) = setup().await;
604 vs.ensure_collection("c", 4).await.unwrap();
605 vs.upsert(
606 "c",
607 vec![
608 VectorPoint {
609 id: "a".into(),
610 vector: vec![1.0, 0.0, 0.0, 0.0],
611 payload: HashMap::from([("conv_id".into(), serde_json::json!(1))]),
612 },
613 VectorPoint {
614 id: "b".into(),
615 vector: vec![1.0, 0.0, 0.0, 0.0],
616 payload: HashMap::from([("conv_id".into(), serde_json::json!(2))]),
617 },
618 ],
619 )
620 .await
621 .unwrap();
622
623 let filter = VectorFilter {
624 must: vec![FieldCondition {
625 field: "conv_id".into(),
626 value: FieldValue::Integer(1),
627 }],
628 must_not: vec![],
629 };
630 let results = vs
631 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, Some(filter))
632 .await
633 .unwrap();
634 assert_eq!(results.len(), 1);
635 assert_eq!(results[0].id, "a");
636 }
637
638 #[tokio::test]
639 async fn search_empty_collection() {
640 let (vs, _) = setup().await;
641 vs.ensure_collection("c", 4).await.unwrap();
642 let results = vs
643 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, None)
644 .await
645 .unwrap();
646 assert!(results.is_empty());
647 }
648
649 #[tokio::test]
650 async fn search_with_must_not_integer_filter() {
651 let (vs, _) = setup().await;
652 vs.ensure_collection("c", 4).await.unwrap();
653 vs.upsert(
654 "c",
655 vec![
656 VectorPoint {
657 id: "a".into(),
658 vector: vec![1.0, 0.0, 0.0, 0.0],
659 payload: HashMap::from([("conv_id".into(), serde_json::json!(1))]),
660 },
661 VectorPoint {
662 id: "b".into(),
663 vector: vec![1.0, 0.0, 0.0, 0.0],
664 payload: HashMap::from([("conv_id".into(), serde_json::json!(2))]),
665 },
666 ],
667 )
668 .await
669 .unwrap();
670
671 let filter = VectorFilter {
672 must: vec![],
673 must_not: vec![FieldCondition {
674 field: "conv_id".into(),
675 value: FieldValue::Integer(1),
676 }],
677 };
678 let results = vs
679 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, Some(filter))
680 .await
681 .unwrap();
682 assert_eq!(results.len(), 1);
683 assert_eq!(results[0].id, "b");
684 }
685
686 #[tokio::test]
687 async fn search_with_combined_must_and_must_not() {
688 let (vs, _) = setup().await;
689 vs.ensure_collection("c", 4).await.unwrap();
690 vs.upsert(
691 "c",
692 vec![
693 VectorPoint {
694 id: "a".into(),
695 vector: vec![1.0, 0.0, 0.0, 0.0],
696 payload: HashMap::from([
697 ("role".into(), serde_json::json!("user")),
698 ("conv_id".into(), serde_json::json!(1)),
699 ]),
700 },
701 VectorPoint {
702 id: "b".into(),
703 vector: vec![1.0, 0.0, 0.0, 0.0],
704 payload: HashMap::from([
705 ("role".into(), serde_json::json!("user")),
706 ("conv_id".into(), serde_json::json!(2)),
707 ]),
708 },
709 VectorPoint {
710 id: "c".into(),
711 vector: vec![1.0, 0.0, 0.0, 0.0],
712 payload: HashMap::from([
713 ("role".into(), serde_json::json!("assistant")),
714 ("conv_id".into(), serde_json::json!(1)),
715 ]),
716 },
717 ],
718 )
719 .await
720 .unwrap();
721
722 let filter = VectorFilter {
723 must: vec![FieldCondition {
724 field: "role".into(),
725 value: FieldValue::Text("user".into()),
726 }],
727 must_not: vec![FieldCondition {
728 field: "conv_id".into(),
729 value: FieldValue::Integer(2),
730 }],
731 };
732 let results = vs
733 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, Some(filter))
734 .await
735 .unwrap();
736 assert_eq!(results.len(), 1);
738 assert_eq!(results[0].id, "a");
739 }
740
741 #[tokio::test]
742 async fn scroll_all_missing_key_field() {
743 let (vs, _) = setup().await;
744 vs.ensure_collection("c", 4).await.unwrap();
745 vs.upsert(
746 "c",
747 vec![VectorPoint {
748 id: "p1".into(),
749 vector: vec![1.0, 0.0, 0.0, 0.0],
750 payload: HashMap::from([("other".into(), serde_json::json!("value"))]),
751 }],
752 )
753 .await
754 .unwrap();
755 let result = vs.scroll_all("c", "text").await.unwrap();
757 assert!(
758 result.is_empty(),
759 "points without the key field must not appear in scroll result"
760 );
761 }
762
763 #[tokio::test]
764 async fn delete_by_ids_empty_and_nonexistent() {
765 let (vs, _) = setup().await;
766 vs.ensure_collection("c", 4).await.unwrap();
767 vs.upsert(
768 "c",
769 vec![VectorPoint {
770 id: "a".into(),
771 vector: vec![1.0, 0.0, 0.0, 0.0],
772 payload: HashMap::new(),
773 }],
774 )
775 .await
776 .unwrap();
777
778 vs.delete_by_ids("c", vec![]).await.unwrap();
780
781 vs.delete_by_ids("c", vec!["nonexistent".into()])
783 .await
784 .unwrap();
785
786 let results = vs
788 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, None)
789 .await
790 .unwrap();
791 assert_eq!(results.len(), 1);
792 assert_eq!(results[0].id, "a");
793 }
794
795 #[tokio::test]
796 async fn search_corrupt_blob_skipped() {
797 let (vs, store) = setup().await;
798 vs.ensure_collection("c", 4).await.unwrap();
799
800 vs.upsert(
802 "c",
803 vec![VectorPoint {
804 id: "valid".into(),
805 vector: vec![1.0, 0.0, 0.0, 0.0],
806 payload: HashMap::new(),
807 }],
808 )
809 .await
810 .unwrap();
811
812 let corrupt_blob: Vec<u8> = vec![0xFF, 0xFE, 0xFD];
815 let payload_json = r"{}";
816 zeph_db::query(sql!(
817 "INSERT INTO vector_points (id, collection, vector, payload) VALUES (?, ?, ?, ?)"
818 ))
819 .bind("corrupt")
820 .bind("c")
821 .bind(&corrupt_blob)
822 .bind(payload_json)
823 .execute(store.pool())
824 .await
825 .unwrap();
826
827 let results = vs
829 .search("c", vec![1.0, 0.0, 0.0, 0.0], 10, None)
830 .await
831 .unwrap();
832 assert_eq!(results.len(), 1);
833 assert_eq!(results[0].id, "valid");
834 }
835
836 #[tokio::test]
837 async fn scroll_all_with_point_ids_basic() {
838 let (vs, _) = setup().await;
839 vs.ensure_collection("c", 4).await.unwrap();
840 vs.upsert(
841 "c",
842 vec![
843 VectorPoint {
844 id: "p1".into(),
845 vector: vec![1.0, 0.0, 0.0, 0.0],
846 payload: HashMap::from([
847 ("entity_id_str".into(), serde_json::json!("42")),
848 ("name".into(), serde_json::json!("alice")),
849 ]),
850 },
851 VectorPoint {
852 id: "p2".into(),
853 vector: vec![0.0, 1.0, 0.0, 0.0],
854 payload: HashMap::from([
855 ("entity_id_str".into(), serde_json::json!("99")),
856 ("name".into(), serde_json::json!("bob")),
857 ]),
858 },
859 ],
860 )
861 .await
862 .unwrap();
863
864 let result = vs
865 .scroll_all_with_point_ids("c", "entity_id_str")
866 .await
867 .unwrap();
868 assert_eq!(result.len(), 2);
869
870 let mut by_id: std::collections::BTreeMap<
872 String,
873 std::collections::HashMap<String, String>,
874 > = result.into_iter().collect();
875 let p1 = by_id.remove("p1").expect("p1 missing");
876 let p2 = by_id.remove("p2").expect("p2 missing");
877 assert_eq!(p1.get("entity_id_str").map(String::as_str), Some("42"));
878 assert_eq!(p1.get("name").map(String::as_str), Some("alice"));
879 assert_eq!(p2.get("entity_id_str").map(String::as_str), Some("99"));
880 assert_eq!(p2.get("name").map(String::as_str), Some("bob"));
881 }
882
883 #[tokio::test]
884 async fn scroll_all_with_point_ids_skips_missing_key_field() {
885 let (vs, _) = setup().await;
886 vs.ensure_collection("c", 4).await.unwrap();
887 vs.upsert(
888 "c",
889 vec![
890 VectorPoint {
891 id: "has-key".into(),
892 vector: vec![1.0, 0.0, 0.0, 0.0],
893 payload: HashMap::from([("entity_id_str".into(), serde_json::json!("7"))]),
894 },
895 VectorPoint {
896 id: "no-key".into(),
897 vector: vec![0.0, 1.0, 0.0, 0.0],
898 payload: HashMap::from([("other".into(), serde_json::json!("value"))]),
899 },
900 ],
901 )
902 .await
903 .unwrap();
904
905 let result = vs
906 .scroll_all_with_point_ids("c", "entity_id_str")
907 .await
908 .unwrap();
909 assert_eq!(result.len(), 1);
911 assert_eq!(result[0].0, "has-key");
912 assert_eq!(
913 result[0].1.get("entity_id_str").map(String::as_str),
914 Some("7")
915 );
916 }
917}