1use std::collections::HashMap;
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::atomic::AtomicBool;
14use std::time::Duration;
15
16use crate::vector_store::BoxFuture;
17use qdrant_client::Qdrant;
18use qdrant_client::qdrant::vector_output::Vector as VectorVariant;
19use qdrant_client::qdrant::{
20 CreateCollectionBuilder, DeletePointsBuilder, Distance, Filter, GetPointsBuilder, PointId,
21 PointStruct, PointsIdsList, QueryPointsBuilder, ScoredPoint, ScrollPointsBuilder,
22 UpsertPointsBuilder, VectorParamsBuilder, value::Kind,
23};
24
25type QdrantResult<T> = Result<T, Box<qdrant_client::QdrantError>>;
26
27const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
33
34#[derive(Clone)]
36pub struct QdrantOps {
37 client: Qdrant,
38 timeout: Duration,
39}
40
41impl std::fmt::Debug for QdrantOps {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 f.debug_struct("QdrantOps")
44 .field("timeout", &self.timeout)
45 .finish_non_exhaustive()
46 }
47}
48
49impl QdrantOps {
50 pub fn new(url: &str, api_key: Option<&str>) -> QdrantResult<Self> {
63 let mut builder = Qdrant::from_url(url);
64 if let Some(key) = api_key.filter(|k| !k.trim().is_empty()) {
65 builder = builder.api_key(key.trim());
66 }
67 let client = builder.build().map_err(Box::new)?;
68 Ok(Self {
69 client,
70 timeout: DEFAULT_TIMEOUT,
71 })
72 }
73
74 #[must_use]
91 pub fn with_timeout(mut self, timeout: Duration) -> Self {
92 self.timeout = timeout;
93 self
94 }
95
96 #[must_use]
105 pub fn client(&self) -> &Qdrant {
106 &self.client
107 }
108
109 async fn timed<T>(
119 &self,
120 fut: Pin<Box<dyn Future<Output = Result<T, qdrant_client::QdrantError>> + Send + '_>>,
121 ) -> QdrantResult<T> {
122 match tokio::time::timeout(self.timeout, fut).await {
123 Ok(result) => result.map_err(Box::new),
124 Err(_) => Err(Box::new(qdrant_client::QdrantError::Io(
125 std::io::Error::new(
126 std::io::ErrorKind::TimedOut,
127 format!("Qdrant gRPC call exceeded {:?}", self.timeout),
128 ),
129 ))),
130 }
131 }
132
133 #[tracing::instrument(name = "memory.qdrant.ensure_collection", skip_all, err)]
146 pub async fn ensure_collection(&self, collection: &str, vector_size: u64) -> QdrantResult<()> {
147 if self
148 .timed(Box::pin(self.client.collection_exists(collection)))
149 .await?
150 {
151 let existing_size = self.get_collection_vector_size(collection).await?;
152 if existing_size == Some(vector_size) {
153 return Ok(());
154 }
155 tracing::warn!(
156 collection,
157 existing = ?existing_size,
158 required = vector_size,
159 "vector dimension mismatch — recreating collection (existing data will be lost)"
160 );
161 self.timed(Box::pin(self.client.delete_collection(collection)))
162 .await?;
163 }
164 self.timed(Box::pin(
165 self.client.create_collection(
166 CreateCollectionBuilder::new(collection)
167 .vectors_config(VectorParamsBuilder::new(vector_size, Distance::Cosine)),
168 ),
169 ))
170 .await?;
171 Ok(())
172 }
173
174 #[tracing::instrument(
198 name = "memory.qdrant.get_collection_vector_size",
199 skip_all,
200 err,
201 level = "debug"
202 )]
203 pub async fn get_collection_vector_size(&self, collection: &str) -> QdrantResult<Option<u64>> {
204 let info = self
205 .timed(Box::pin(self.client.collection_info(collection)))
206 .await?;
207 let size = info
208 .result
209 .and_then(|r| r.config)
210 .and_then(|cfg| cfg.params)
211 .and_then(|params| params.vectors_config)
212 .and_then(|vc| vc.config)
213 .and_then(|cfg| match cfg {
214 qdrant_client::qdrant::vectors_config::Config::Params(vp) => Some(vp.size),
215 qdrant_client::qdrant::vectors_config::Config::ParamsMap(_) => None,
217 });
218 Ok(size)
219 }
220
221 #[tracing::instrument(name = "memory.qdrant.collection_exists", skip_all, err)]
227 pub async fn collection_exists(&self, collection: &str) -> QdrantResult<bool> {
228 self.timed(Box::pin(self.client.collection_exists(collection)))
229 .await
230 }
231
232 #[tracing::instrument(name = "memory.qdrant.delete_collection", skip_all, err)]
238 pub async fn delete_collection(&self, collection: &str) -> QdrantResult<()> {
239 self.timed(Box::pin(self.client.delete_collection(collection)))
240 .await?;
241 Ok(())
242 }
243
244 #[tracing::instrument(name = "memory.qdrant.upsert", skip_all, err)]
250 pub async fn upsert(&self, collection: &str, points: Vec<PointStruct>) -> QdrantResult<()> {
251 self.timed(Box::pin(self.client.upsert_points(
252 UpsertPointsBuilder::new(collection, points).wait(true),
253 )))
254 .await?;
255 Ok(())
256 }
257
258 #[tracing::instrument(name = "memory.qdrant.search", skip_all, err)]
267 pub async fn search(
268 &self,
269 collection: &str,
270 vector: Vec<f32>,
271 limit: u64,
272 filter: Option<Filter>,
273 ) -> QdrantResult<Vec<ScoredPoint>> {
274 let mut builder = QueryPointsBuilder::new(collection)
275 .query(vector)
276 .limit(limit)
277 .with_payload(true);
278 if let Some(f) = filter {
279 builder = builder.filter(f);
280 }
281 let results = self.timed(Box::pin(self.client.query(builder))).await?;
282 Ok(results.result)
283 }
284
285 #[tracing::instrument(name = "memory.qdrant.delete_by_ids", skip_all, err)]
291 pub async fn delete_by_ids(&self, collection: &str, ids: Vec<PointId>) -> QdrantResult<()> {
292 if ids.is_empty() {
293 return Ok(());
294 }
295 self.timed(Box::pin(
296 self.client.delete_points(
297 DeletePointsBuilder::new(collection)
298 .points(PointsIdsList { ids })
299 .wait(true),
300 ),
301 ))
302 .await?;
303 Ok(())
304 }
305
306 #[tracing::instrument(name = "memory.qdrant.scroll_all", skip_all, err)]
314 pub async fn scroll_all(
315 &self,
316 collection: &str,
317 key_field: &str,
318 ) -> QdrantResult<HashMap<String, HashMap<String, String>>> {
319 let mut result = HashMap::new();
320 let mut offset: Option<PointId> = None;
321
322 loop {
323 let mut builder = ScrollPointsBuilder::new(collection)
324 .with_payload(true)
325 .with_vectors(false)
326 .limit(100);
327
328 if let Some(ref off) = offset {
329 builder = builder.offset(off.clone());
330 }
331
332 let response = self.timed(Box::pin(self.client.scroll(builder))).await?;
333
334 for point in &response.result {
335 let Some(key_val) = point.payload.get(key_field) else {
336 continue;
337 };
338 let Some(Kind::StringValue(key)) = &key_val.kind else {
339 continue;
340 };
341
342 let mut fields = HashMap::new();
343 for (k, val) in &point.payload {
344 if let Some(Kind::StringValue(s)) = &val.kind {
345 fields.insert(k.clone(), s.clone());
346 }
347 }
348 result.insert(key.clone(), fields);
349 }
350
351 match response.next_page_offset {
352 Some(next) => offset = Some(next),
353 None => break,
354 }
355 }
356
357 Ok(result)
358 }
359
360 #[tracing::instrument(name = "memory.qdrant.scroll_all_with_point_ids", skip_all, err)]
369 pub async fn scroll_all_with_point_ids(
370 &self,
371 collection: &str,
372 key_field: &str,
373 ) -> QdrantResult<Vec<(String, HashMap<String, String>)>> {
374 let mut result = Vec::new();
375 let mut offset: Option<PointId> = None;
376
377 loop {
378 let mut builder = ScrollPointsBuilder::new(collection)
379 .with_payload(true)
380 .with_vectors(false)
381 .limit(100);
382
383 if let Some(ref off) = offset {
384 builder = builder.offset(off.clone());
385 }
386
387 let response = self.timed(Box::pin(self.client.scroll(builder))).await?;
388
389 for point in &response.result {
390 let Some(key_val) = point.payload.get(key_field) else {
391 continue;
392 };
393 let Some(Kind::StringValue(_)) = &key_val.kind else {
394 continue;
395 };
396 let Some(point_id_str) = point_id_to_string(point.id.clone()) else {
397 continue;
398 };
399
400 let mut fields = HashMap::new();
401 for (k, val) in &point.payload {
402 if let Some(Kind::StringValue(s)) = &val.kind {
403 fields.insert(k.clone(), s.clone());
404 }
405 }
406 result.push((point_id_str, fields));
407 }
408
409 match response.next_page_offset {
410 Some(next) => offset = Some(next),
411 None => break,
412 }
413 }
414
415 Ok(result)
416 }
417
418 #[tracing::instrument(
428 name = "memory.qdrant.ensure_collection_with_quantization",
429 skip_all,
430 err
431 )]
432 pub async fn ensure_collection_with_quantization(
433 &self,
434 collection: &str,
435 vector_size: u64,
436 keyword_fields: &[&str],
437 ) -> Result<(), crate::VectorStoreError> {
438 use qdrant_client::qdrant::{
439 CreateFieldIndexCollectionBuilder, FieldType, ScalarQuantizationBuilder,
440 };
441 if self
442 .timed(Box::pin(self.client.collection_exists(collection)))
443 .await
444 .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))?
445 {
446 let existing_size = self
447 .get_collection_vector_size(collection)
448 .await
449 .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))?;
450 if existing_size == Some(vector_size) {
451 return Ok(());
452 }
453 tracing::warn!(
454 collection,
455 existing = ?existing_size,
456 required = vector_size,
457 "vector dimension mismatch — recreating collection (existing data will be lost)"
458 );
459 self.timed(Box::pin(self.client.delete_collection(collection)))
460 .await
461 .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))?;
462 }
463 self.timed(Box::pin(
464 self.client.create_collection(
465 CreateCollectionBuilder::new(collection)
466 .vectors_config(VectorParamsBuilder::new(vector_size, Distance::Cosine))
467 .quantization_config(ScalarQuantizationBuilder::default()),
468 ),
469 ))
470 .await
471 .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))?;
472
473 for field in keyword_fields {
474 self.timed(Box::pin(self.client.create_field_index(
475 CreateFieldIndexCollectionBuilder::new(collection, *field, FieldType::Keyword),
476 )))
477 .await
478 .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))?;
479 }
480 Ok(())
481 }
482
483 pub fn json_to_payload(
489 value: serde_json::Value,
490 ) -> Result<HashMap<String, qdrant_client::qdrant::Value>, serde_json::Error> {
491 serde_json::from_value(value)
492 }
493}
494
495impl crate::vector_store::VectorStore for QdrantOps {
496 fn search_clamp_diagnostics(&self) -> (&'static str, &'static AtomicBool) {
497 static CLAMP_WARNED: AtomicBool = AtomicBool::new(false);
498 ("QdrantOps::search", &CLAMP_WARNED)
499 }
500
501 fn ensure_collection(
502 &self,
503 collection: &str,
504 vector_size: u64,
505 ) -> BoxFuture<'_, Result<(), crate::VectorStoreError>> {
506 let collection = collection.to_owned();
507 Box::pin(async move {
508 self.ensure_collection(&collection, vector_size)
509 .await
510 .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))
511 })
512 }
513
514 fn collection_exists(
515 &self,
516 collection: &str,
517 ) -> BoxFuture<'_, Result<bool, crate::VectorStoreError>> {
518 let collection = collection.to_owned();
519 Box::pin(async move {
520 self.collection_exists(&collection)
521 .await
522 .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))
523 })
524 }
525
526 fn delete_collection(
527 &self,
528 collection: &str,
529 ) -> BoxFuture<'_, Result<(), crate::VectorStoreError>> {
530 let collection = collection.to_owned();
531 Box::pin(async move {
532 self.delete_collection(&collection)
533 .await
534 .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))
535 })
536 }
537
538 fn upsert(
539 &self,
540 collection: &str,
541 points: Vec<crate::VectorPoint>,
542 ) -> BoxFuture<'_, Result<(), crate::VectorStoreError>> {
543 let collection = collection.to_owned();
544 Box::pin(async move {
545 let qdrant_points: Vec<PointStruct> = points
546 .into_iter()
547 .map(|p| {
548 let payload: HashMap<String, qdrant_client::qdrant::Value> =
549 serde_json::from_value(serde_json::Value::Object(
550 p.payload.into_iter().collect(),
551 ))
552 .unwrap_or_default();
553 PointStruct::new(p.id, p.vector, payload)
554 })
555 .collect();
556 self.upsert(&collection, qdrant_points)
557 .await
558 .map_err(|e| crate::VectorStoreError::Upsert(e.to_string()))
559 })
560 }
561
562 fn search_clamped(
563 &self,
564 collection: &str,
565 vector: Vec<f32>,
566 limit: u64,
567 filter: Option<crate::VectorFilter>,
568 ) -> BoxFuture<'_, Result<Vec<crate::ScoredVectorPoint>, crate::VectorStoreError>> {
569 let collection = collection.to_owned();
570 Box::pin(async move {
571 let qdrant_filter = filter.map(vector_filter_to_qdrant);
572 let results = self
573 .search(&collection, vector, limit, qdrant_filter)
574 .await
575 .map_err(|e| crate::VectorStoreError::Search(e.to_string()))?;
576 Ok(results.into_iter().map(scored_point_to_vector).collect())
577 })
578 }
579
580 fn delete_by_ids(
581 &self,
582 collection: &str,
583 ids: Vec<String>,
584 ) -> BoxFuture<'_, Result<(), crate::VectorStoreError>> {
585 let collection = collection.to_owned();
586 Box::pin(async move {
587 let point_ids: Vec<PointId> = ids.into_iter().map(PointId::from).collect();
588 self.delete_by_ids(&collection, point_ids)
589 .await
590 .map_err(|e| crate::VectorStoreError::Delete(e.to_string()))
591 })
592 }
593
594 fn scroll_all(
595 &self,
596 collection: &str,
597 key_field: &str,
598 ) -> BoxFuture<'_, Result<HashMap<String, HashMap<String, String>>, crate::VectorStoreError>>
599 {
600 let collection = collection.to_owned();
601 let key_field = key_field.to_owned();
602 Box::pin(async move {
603 self.scroll_all(&collection, &key_field)
604 .await
605 .map_err(|e| crate::VectorStoreError::Scroll(e.to_string()))
606 })
607 }
608
609 fn scroll_all_with_point_ids(
610 &self,
611 collection: &str,
612 key_field: &str,
613 ) -> BoxFuture<'_, Result<crate::vector_store::ScrollWithIdsResult, crate::VectorStoreError>>
614 {
615 let collection = collection.to_owned();
616 let key_field = key_field.to_owned();
617 Box::pin(async move {
618 self.scroll_all_with_point_ids(&collection, &key_field)
619 .await
620 .map_err(|e| crate::VectorStoreError::Scroll(e.to_string()))
621 })
622 }
623
624 fn health_check(&self) -> BoxFuture<'_, Result<bool, crate::VectorStoreError>> {
625 use tracing::Instrument as _;
626 Box::pin(
627 async move {
628 match self.timed(Box::pin(self.client.health_check())).await {
629 Ok(_) => Ok(true),
630 Err(e) => {
631 tracing::warn!(err = %e, "health_check failed");
632 Err(crate::VectorStoreError::Collection(e.to_string()))
633 }
634 }
635 }
636 .instrument(tracing::debug_span!("memory.qdrant.health_check")),
637 )
638 }
639
640 fn create_keyword_indexes(
641 &self,
642 collection: &str,
643 fields: &[&str],
644 ) -> BoxFuture<'_, Result<(), crate::VectorStoreError>> {
645 use qdrant_client::qdrant::{CreateFieldIndexCollectionBuilder, FieldType};
646 use tracing::Instrument as _;
647 let collection = collection.to_owned();
648 let fields: Vec<String> = fields.iter().map(|f| (*f).to_owned()).collect();
649 Box::pin(
650 async move {
651 for field in &fields {
652 self.timed(Box::pin(self.client.create_field_index(
653 CreateFieldIndexCollectionBuilder::new(
654 &collection,
655 field.as_str(),
656 FieldType::Keyword,
657 ),
658 )))
659 .await
660 .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))?;
661 }
662 Ok(())
663 }
664 .instrument(tracing::debug_span!("memory.qdrant.create_keyword_indexes")),
665 )
666 }
667
668 fn get_points(
669 &self,
670 collection: &str,
671 ids: Vec<String>,
672 ) -> BoxFuture<'_, Result<Vec<crate::VectorPoint>, crate::VectorStoreError>> {
673 use tracing::Instrument as _;
674 let collection = collection.to_owned();
675 Box::pin(
676 async move {
677 if ids.is_empty() {
678 return Ok(Vec::new());
679 }
680 let point_ids: Vec<PointId> = ids.into_iter().map(PointId::from).collect();
681 let response = self
682 .timed(Box::pin(
683 self.client.get_points(
684 GetPointsBuilder::new(&collection, point_ids)
685 .with_vectors(true)
686 .with_payload(true),
687 ),
688 ))
689 .await
690 .map_err(|e| {
691 tracing::error!(err = %e, "get_points failed");
692 crate::VectorStoreError::Search(e.to_string())
693 })?;
694
695 let mut result = Vec::with_capacity(response.result.len());
696 for point in response.result {
697 let Some(id_str) = point_id_to_string(point.id) else {
698 continue;
699 };
700 let vector = match point.vectors.and_then(|v| v.get_vector()) {
702 Some(VectorVariant::Dense(dv)) => dv.data,
703 _ => continue,
704 };
705 let payload: HashMap<String, serde_json::Value> = point
706 .payload
707 .into_iter()
708 .filter_map(|(k, v)| {
709 let json = qdrant_value_to_json(v.kind?)?;
710 Some((k, json))
711 })
712 .collect();
713 result.push(crate::VectorPoint {
714 id: id_str,
715 vector,
716 payload,
717 });
718 }
719 Ok(result)
720 }
721 .instrument(tracing::debug_span!("memory.qdrant.get_points")),
722 )
723 }
724}
725
726fn vector_filter_to_qdrant(filter: crate::VectorFilter) -> Filter {
727 let must: Vec<_> = filter
728 .must
729 .into_iter()
730 .map(field_condition_to_qdrant)
731 .collect();
732 let must_not: Vec<_> = filter
733 .must_not
734 .into_iter()
735 .map(field_condition_to_qdrant)
736 .collect();
737
738 let mut f = Filter::default();
739 if !must.is_empty() {
740 f.must = must;
741 }
742 if !must_not.is_empty() {
743 f.must_not = must_not;
744 }
745 f
746}
747
748fn field_condition_to_qdrant(cond: crate::FieldCondition) -> qdrant_client::qdrant::Condition {
749 match cond.value {
750 crate::FieldValue::Integer(v) => qdrant_client::qdrant::Condition::matches(cond.field, v),
751 crate::FieldValue::Text(v) => qdrant_client::qdrant::Condition::matches(cond.field, v),
752 }
753}
754
755fn point_id_to_string(pid: Option<qdrant_client::qdrant::PointId>) -> Option<String> {
759 match pid?.point_id_options? {
760 qdrant_client::qdrant::point_id::PointIdOptions::Uuid(u) => Some(u),
761 qdrant_client::qdrant::point_id::PointIdOptions::Num(n) => Some(n.to_string()),
762 }
763}
764
765fn qdrant_value_to_json(kind: Kind) -> Option<serde_json::Value> {
769 match kind {
770 Kind::StringValue(s) => Some(serde_json::Value::String(s)),
771 Kind::IntegerValue(i) => Some(serde_json::Value::Number(i.into())),
772 Kind::DoubleValue(d) => serde_json::Number::from_f64(d).map(serde_json::Value::Number),
773 Kind::BoolValue(b) => Some(serde_json::Value::Bool(b)),
774 _ => None,
775 }
776}
777
778fn scored_point_to_vector(point: ScoredPoint) -> crate::ScoredVectorPoint {
779 let payload: HashMap<String, serde_json::Value> = point
780 .payload
781 .into_iter()
782 .filter_map(|(k, v)| Some((k, qdrant_value_to_json(v.kind?)?)))
783 .collect();
784
785 let id = point_id_to_string(point.id).unwrap_or_default();
786
787 crate::ScoredVectorPoint {
788 id,
789 score: point.score,
790 payload,
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797
798 #[test]
799 fn new_valid_url() {
800 let ops = QdrantOps::new("http://localhost:6334", None);
801 assert!(ops.is_ok());
802 }
803
804 #[test]
805 fn new_invalid_url() {
806 let ops = QdrantOps::new("not a valid url", None);
807 assert!(ops.is_err());
808 }
809
810 #[test]
813 fn new_empty_api_key_is_treated_as_none() {
814 let result = QdrantOps::new("http://127.0.0.1:9999", Some(""));
815 assert!(result.is_ok(), "empty key must not cause a build error");
816 }
817
818 #[test]
820 fn new_whitespace_api_key_is_treated_as_none() {
821 let result = QdrantOps::new("http://127.0.0.1:9999", Some(" "));
822 assert!(
823 result.is_ok(),
824 "whitespace-only key must not cause a build error"
825 );
826 }
827
828 #[test]
830 fn new_with_api_key_constructs_successfully() {
831 let result = QdrantOps::new("http://127.0.0.1:9999", Some("valid-key"));
832 assert!(result.is_ok(), "valid key must not cause a build error");
833 }
834
835 #[test]
838 fn with_timeout_overrides_default() {
839 let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
840 assert!(format!("{ops:?}").contains("10s"), "default must be 10s");
841
842 let ops = ops.with_timeout(Duration::from_secs(2));
843 assert!(
844 format!("{ops:?}").contains("2s"),
845 "with_timeout must override the default"
846 );
847 }
848
849 #[tokio::test]
852 async fn timed_returns_error_instead_of_hanging() {
853 let ops = QdrantOps::new("http://localhost:6334", None)
854 .unwrap()
855 .with_timeout(Duration::from_millis(10));
856
857 let never_resolves: Pin<
858 Box<dyn Future<Output = Result<(), qdrant_client::QdrantError>> + Send>,
859 > = Box::pin(std::future::pending());
860
861 let result = ops.timed(never_resolves).await;
862 assert!(result.is_err(), "must time out instead of hanging forever");
863 }
864
865 #[test]
866 fn debug_format() {
867 let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
868 let dbg = format!("{ops:?}");
869 assert!(dbg.contains("QdrantOps"));
870 }
871
872 #[test]
873 fn json_to_payload_valid() {
874 let value = serde_json::json!({"key": "value", "num": 42});
875 let result = QdrantOps::json_to_payload(value);
876 assert!(result.is_ok());
877 }
878
879 #[test]
880 fn json_to_payload_empty() {
881 let result = QdrantOps::json_to_payload(serde_json::json!({}));
882 assert!(result.is_ok());
883 assert!(result.unwrap().is_empty());
884 }
885
886 #[test]
887 fn delete_by_ids_empty_is_ok_sync() {
888 let ops = QdrantOps::new("http://localhost:6334", None);
892 assert!(ops.is_ok());
893 }
894
895 #[tokio::test]
897 #[ignore = "requires a live Qdrant instance at localhost:6334"]
898 async fn ensure_collection_with_quantization_idempotent() {
899 let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
900 let collection = "test_quant_idempotent";
901
902 let _ = ops.delete_collection(collection).await;
904
905 ops.ensure_collection_with_quantization(collection, 128, &["language", "file_path"])
907 .await
908 .unwrap();
909
910 assert!(ops.collection_exists(collection).await.unwrap());
911
912 ops.ensure_collection_with_quantization(collection, 128, &["language", "file_path"])
914 .await
915 .unwrap();
916
917 ops.delete_collection(collection).await.unwrap();
919 }
920
921 #[tokio::test]
923 #[ignore = "requires a live Qdrant instance at localhost:6334"]
924 async fn delete_by_ids_empty_no_network_call() {
925 let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
926 let result = ops.delete_by_ids("nonexistent_collection", vec![]).await;
928 assert!(result.is_ok());
929 }
930
931 #[tokio::test]
933 #[ignore = "requires a live Qdrant instance at localhost:6334"]
934 async fn ensure_collection_idempotent_same_size() {
935 let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
936 let collection = "test_ensure_idempotent";
937
938 let _ = ops.delete_collection(collection).await;
939
940 ops.ensure_collection(collection, 128).await.unwrap();
941 assert!(ops.collection_exists(collection).await.unwrap());
942
943 ops.ensure_collection(collection, 128).await.unwrap();
945 assert!(ops.collection_exists(collection).await.unwrap());
946
947 ops.delete_collection(collection).await.unwrap();
948 }
949
950 #[tokio::test]
955 #[ignore = "requires a live Qdrant instance at localhost:6334"]
956 async fn ensure_collection_recreates_on_dimension_mismatch() {
957 let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
958 let collection = "test_dim_mismatch";
959
960 let _ = ops.delete_collection(collection).await;
961
962 ops.ensure_collection(collection, 128).await.unwrap();
964 assert_eq!(
965 ops.get_collection_vector_size(collection).await.unwrap(),
966 Some(128)
967 );
968
969 ops.ensure_collection(collection, 256).await.unwrap();
971 assert_eq!(
972 ops.get_collection_vector_size(collection).await.unwrap(),
973 Some(256),
974 "collection must have been recreated with the new dimension"
975 );
976
977 ops.delete_collection(collection).await.unwrap();
978 }
979
980 #[tokio::test]
984 #[ignore = "requires a live Qdrant instance at localhost:6334"]
985 async fn ensure_collection_with_quantization_recreates_on_dimension_mismatch() {
986 let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
987 let collection = "test_quant_dim_mismatch";
988
989 let _ = ops.delete_collection(collection).await;
990
991 ops.ensure_collection_with_quantization(collection, 128, &["language"])
992 .await
993 .unwrap();
994 assert_eq!(
995 ops.get_collection_vector_size(collection).await.unwrap(),
996 Some(128)
997 );
998
999 ops.ensure_collection_with_quantization(collection, 384, &["language"])
1001 .await
1002 .unwrap();
1003 assert_eq!(
1004 ops.get_collection_vector_size(collection).await.unwrap(),
1005 Some(384),
1006 "collection must have been recreated with the new dimension"
1007 );
1008
1009 ops.delete_collection(collection).await.unwrap();
1010 }
1011
1012 #[tokio::test]
1020 #[tracing_test::traced_test]
1021 async fn vector_store_search_clamps_oversized_limit() {
1022 use crate::vector_store::VectorStore;
1023
1024 let ops = QdrantOps::new("http://127.0.0.1:1", None).unwrap(); let _ = VectorStore::search(&ops, "col", vec![1.0, 0.0], u64::MAX, None).await;
1026 assert!(
1027 logs_contain("requested search limit exceeds MAX_SEARCH_LIMIT"),
1028 "expected the one-shot clamp warning to fire for an oversized limit"
1029 );
1030 }
1031}