1use crate::hybrid_native::{FusionMethod, NativeHybridQuery, NativeHybridSearch};
5use crate::{Document, FilterOp, MetadataFilter, SearchResult, VectorStore, VectorStoreError};
6use async_trait::async_trait;
7use qdrant_client::{
8 qdrant::{
9 Condition, CreateCollectionBuilder, DeletePointsBuilder, Distance, Filter, Fusion, PointId,
10 PointStruct, PrefetchQuery, PrefetchQueryBuilder, Query, QueryPointsBuilder, Range,
11 ScoredPoint, UpsertPointsBuilder, VectorParamsBuilder,
12 },
13 Payload, Qdrant,
14};
15use serde_json::Value;
16use std::collections::HashMap;
17use std::sync::Arc;
18use uuid::Uuid;
19
20#[derive(Debug, Clone)]
22pub struct QdrantConfig {
23 pub url: String,
25 pub collection_name: String,
27 pub vector_size: usize,
29 pub distance: QdrantDistance,
31}
32
33#[derive(Debug, Clone, Copy)]
35pub enum QdrantDistance {
36 Cosine,
38 Euclid,
40 Dot,
42}
43
44impl From<QdrantDistance> for Distance {
45 fn from(dist: QdrantDistance) -> Self {
46 match dist {
47 QdrantDistance::Cosine => Distance::Cosine,
48 QdrantDistance::Euclid => Distance::Euclid,
49 QdrantDistance::Dot => Distance::Dot,
50 }
51 }
52}
53
54impl Default for QdrantConfig {
55 fn default() -> Self {
56 Self {
57 url: "http://localhost:6334".to_string(),
58 collection_name: "langchainrust".to_string(),
59 vector_size: 1536,
60 distance: QdrantDistance::Cosine,
61 }
62 }
63}
64
65impl QdrantConfig {
66 pub fn new(url: impl Into<String>, collection_name: impl Into<String>) -> Self {
68 Self {
69 url: url.into(),
70 collection_name: collection_name.into(),
71 ..Default::default()
72 }
73 }
74
75 pub fn with_vector_size(mut self, size: usize) -> Self {
77 self.vector_size = size;
78 self
79 }
80
81 pub fn with_distance(mut self, distance: QdrantDistance) -> Self {
83 self.distance = distance;
84 self
85 }
86}
87
88pub struct QdrantVectorStore {
90 client: Arc<Qdrant>,
91 config: QdrantConfig,
92}
93
94impl QdrantVectorStore {
95 pub async fn new(config: QdrantConfig) -> Result<Self, VectorStoreError> {
97 let client = Qdrant::from_url(&config.url).build().map_err(|e| {
98 VectorStoreError::ConnectionError(format!("failed to connect to Qdrant: {}", e))
99 })?;
100
101 let client = Arc::new(client);
102
103 let exists = client
104 .collection_exists(&config.collection_name)
105 .await
106 .map_err(|e| {
107 VectorStoreError::StorageError(format!("failed to check collection: {}", e))
108 })?;
109
110 if !exists {
111 client
112 .create_collection(
113 CreateCollectionBuilder::new(&config.collection_name).vectors_config(
114 VectorParamsBuilder::new(
115 config.vector_size as u64,
116 Distance::from(config.distance),
117 ),
118 ),
119 )
120 .await
121 .map_err(|e| {
122 VectorStoreError::StorageError(format!("failed to create collection: {}", e))
123 })?;
124 }
125
126 Ok(Self { client, config })
127 }
128
129 pub async fn from_env() -> Result<Self, VectorStoreError> {
131 let url =
132 std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6334".to_string());
133 let collection_name =
134 std::env::var("QDRANT_COLLECTION").unwrap_or_else(|_| "langchainrust".to_string());
135
136 Self::new(QdrantConfig::new(url, collection_name)).await
137 }
138
139 pub async fn delete_by_metadata(
141 &self,
142 key: &str,
143 value: &str,
144 ) -> Result<usize, VectorStoreError> {
145 let filter = Filter::must([Condition::matches(key, value.to_string())]);
146
147 let total = self.count().await as u64;
150 let matched = self
151 .client
152 .query(
153 QueryPointsBuilder::new(&self.config.collection_name)
154 .query(vec![0.0; self.config.vector_size])
155 .filter(filter.clone())
156 .limit(total.max(1))
157 .with_payload(false),
158 )
159 .await
160 .map_err(|e| {
161 VectorStoreError::StorageError(format!(
162 "failed to count matching points by metadata: {}",
163 e
164 ))
165 })?;
166
167 let deleted = matched.result.len();
168
169 if deleted > 0 {
170 self.client
171 .delete_points(
172 DeletePointsBuilder::new(&self.config.collection_name).points(filter),
173 )
174 .await
175 .map_err(|e| {
176 VectorStoreError::StorageError(format!(
177 "failed to delete points by metadata: {}",
178 e
179 ))
180 })?;
181 }
182
183 Ok(deleted)
184 }
185
186 fn build_query_builder(
191 &self,
192 query_embedding: &[f32],
193 k: usize,
194 filter: Option<&MetadataFilter>,
195 ) -> Result<QueryPointsBuilder, VectorStoreError> {
196 if query_embedding.len() != self.config.vector_size {
197 return Err(VectorStoreError::StorageError(format!(
198 "query vector dimension mismatch: expected {}, got {}",
199 self.config.vector_size,
200 query_embedding.len()
201 )));
202 }
203
204 let mut builder = QueryPointsBuilder::new(&self.config.collection_name)
205 .query(query_embedding.to_vec())
206 .limit(k as u64)
207 .with_payload(true);
208
209 if let Some(f) = filter {
210 builder = builder.filter(filter_to_qdrant(f)?);
211 }
212
213 Ok(builder)
214 }
215
216 async fn search_impl(
219 &self,
220 builder: QueryPointsBuilder,
221 ) -> Result<Vec<SearchResult>, VectorStoreError> {
222 let search_result = self
223 .client
224 .query(builder)
225 .await
226 .map_err(|e| VectorStoreError::StorageError(format!("search failed: {}", e)))?;
227
228 Ok(search_result
229 .result
230 .into_iter()
231 .map(scored_point_to_result)
232 .collect())
233 }
234}
235
236pub(crate) fn scored_point_to_result(scored_point: ScoredPoint) -> SearchResult {
242 let payload = scored_point.payload;
243
244 let content = payload
245 .get("content")
246 .and_then(|v| v.as_str())
247 .map(|s| s.as_str())
248 .unwrap_or("")
249 .to_string();
250
251 let id = payload
252 .get("doc_id")
253 .and_then(|v| v.as_str())
254 .map(|s| s.to_string());
255
256 let mut metadata = HashMap::new();
257 for (key, value) in &payload {
258 if key != "content" && key != "doc_id" {
259 if let Some(s) = value.as_str() {
260 metadata.insert(key.clone(), s.clone().into());
261 }
262 }
263 }
264
265 SearchResult {
266 document: Document {
267 content,
268 metadata,
269 id,
270 },
271 score: scored_point.score,
272 }
273}
274
275fn match_condition(key: &str, value: &Value) -> Result<Condition, VectorStoreError> {
286 match value {
287 Value::String(s) => Ok(Condition::matches(key, s.clone())),
288 Value::Bool(b) => Ok(Condition::matches(key, *b)),
289 Value::Number(n) => {
290 let int = n
291 .as_i64()
292 .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64));
293 match int {
294 Some(i) => Ok(Condition::matches(key, i)),
295 None => Err(VectorStoreError::UnsupportedFilter(format!(
296 "Qdrant match condition requires an integer, string, or boolean value, got {n}"
297 ))),
298 }
299 }
300 other => Err(VectorStoreError::UnsupportedFilter(format!(
301 "Qdrant match condition requires a scalar value, got {other}"
302 ))),
303 }
304}
305
306fn field_to_condition(
312 key: &str,
313 op: FilterOp,
314 value: &Value,
315) -> Result<Condition, VectorStoreError> {
316 match op {
317 FilterOp::Eq => match_condition(key, value),
318 FilterOp::Ne => Ok(Condition::from(Filter::must_not([match_condition(
319 key, value,
320 )?]))),
321 FilterOp::Gt | FilterOp::Gte | FilterOp::Lt | FilterOp::Lte => {
322 let num = value.as_f64().ok_or_else(|| {
323 VectorStoreError::UnsupportedFilter(format!(
324 "Qdrant range condition requires a numeric value, got {value}"
325 ))
326 })?;
327 let mut range = Range::default();
328 match op {
329 FilterOp::Gt => range.gt = Some(num),
330 FilterOp::Gte => range.gte = Some(num),
331 FilterOp::Lt => range.lt = Some(num),
332 FilterOp::Lte => range.lte = Some(num),
333 _ => unreachable!(),
334 }
335 Ok(Condition::range(key, range))
336 }
337 FilterOp::In | FilterOp::Nin => {
338 let set = value.as_array().ok_or_else(|| {
339 VectorStoreError::UnsupportedFilter(format!(
340 "Qdrant {:?} condition requires an array value, got {value}",
341 op
342 ))
343 })?;
344 if set.is_empty() && op == FilterOp::In {
346 return Err(VectorStoreError::UnsupportedFilter(
347 "Qdrant In condition with an empty array cannot be expressed".to_string(),
348 ));
349 }
350 let conds: Result<Vec<Condition>, _> =
351 set.iter().map(|v| match_condition(key, v)).collect();
352 match op {
353 FilterOp::In => Ok(Condition::from(Filter::should(conds?))),
354 FilterOp::Nin => Ok(Condition::from(Filter::must_not(conds?))),
355 _ => unreachable!(),
356 }
357 }
358 }
359}
360
361fn to_condition(filter: &MetadataFilter) -> Result<Condition, VectorStoreError> {
368 match filter {
369 MetadataFilter::Field { key, op, value } => field_to_condition(key, *op, value),
370 MetadataFilter::And(items) => {
371 let conds: Result<Vec<Condition>, _> = items.iter().map(to_condition).collect();
372 Ok(Condition::from(Filter::must(conds?)))
373 }
374 MetadataFilter::Or(items) => {
375 let conds: Result<Vec<Condition>, _> = items.iter().map(to_condition).collect();
376 Ok(Condition::from(Filter::should(conds?)))
377 }
378 }
379}
380
381pub fn filter_to_qdrant(filter: &MetadataFilter) -> Result<Filter, VectorStoreError> {
386 Ok(Filter::must([to_condition(filter)?]))
387}
388
389#[async_trait]
390impl VectorStore for QdrantVectorStore {
391 async fn add_documents(
392 &self,
393 documents: Vec<Document>,
394 embeddings: Vec<Vec<f32>>,
395 ) -> Result<Vec<String>, VectorStoreError> {
396 if documents.len() != embeddings.len() {
397 return Err(VectorStoreError::StorageError(
398 "document count and embedding count mismatch".to_string(),
399 ));
400 }
401
402 if documents.is_empty() {
403 return Ok(Vec::new());
404 }
405
406 for embedding in &embeddings {
407 if embedding.len() != self.config.vector_size {
408 return Err(VectorStoreError::StorageError(format!(
409 "vector dimension mismatch: expected {}, got {}",
410 self.config.vector_size,
411 embedding.len()
412 )));
413 }
414 }
415
416 let mut ids = Vec::new();
417 let mut points = Vec::new();
418
419 for (doc, embedding) in documents.into_iter().zip(embeddings) {
420 let user_id = doc.id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
421
422 let internal_uuid = deterministic_point_uuid(&user_id, &self.config.collection_name);
428 let point_id = PointId::from(internal_uuid.to_string());
429
430 let mut payload = Payload::new();
431 payload.insert("content", doc.content.clone());
432 payload.insert("doc_id", user_id.clone()); for (key, value) in &doc.metadata {
435 payload.insert(key.clone(), value.clone());
436 }
437
438 let point = PointStruct::new(point_id, embedding, payload);
439 points.push(point);
440 ids.push(user_id);
441 }
442
443 self.client
444 .upsert_points(UpsertPointsBuilder::new(
445 &self.config.collection_name,
446 points,
447 ))
448 .await
449 .map_err(|e| {
450 VectorStoreError::StorageError(format!("failed to insert documents: {}", e))
451 })?;
452
453 Ok(ids)
454 }
455
456 async fn similarity_search(
457 &self,
458 query_embedding: &[f32],
459 k: usize,
460 ) -> Result<Vec<SearchResult>, VectorStoreError> {
461 let builder = self.build_query_builder(query_embedding, k, None)?;
462 self.search_impl(builder).await
463 }
464
465 async fn similarity_search_with_filter(
467 &self,
468 query_embedding: &[f32],
469 k: usize,
470 filter: Option<&MetadataFilter>,
471 ) -> Result<Vec<SearchResult>, VectorStoreError> {
472 let builder = self.build_query_builder(query_embedding, k, filter)?;
473 self.search_impl(builder).await
474 }
475
476 async fn get_document(&self, id: &str) -> Result<Option<Document>, VectorStoreError> {
477 let filter = Filter::must([Condition::matches("doc_id", id.to_string())]);
478
479 let results = self
480 .client
481 .query(
482 QueryPointsBuilder::new(&self.config.collection_name)
483 .query(vec![0.0; self.config.vector_size])
484 .filter(filter)
485 .limit(1)
486 .with_payload(true),
487 )
488 .await
489 .map_err(|e| {
490 VectorStoreError::StorageError(format!("failed to get document: {}", e))
491 })?;
492
493 if let Some(point) = results.result.first() {
494 let payload_map = point.payload.clone();
495
496 let content = payload_map
497 .get("content")
498 .and_then(|v| v.as_str())
499 .map(|s| s.as_str())
500 .unwrap_or("")
501 .to_string();
502
503 let doc_id = payload_map
504 .get("doc_id")
505 .and_then(|v| v.as_str())
506 .map(|s| s.to_string());
507
508 let mut metadata = HashMap::new();
509 for (key, value) in &payload_map {
510 if key != "content" && key != "doc_id" {
511 if let Some(s) = value.as_str() {
512 metadata.insert(key.clone(), s.clone().into());
513 }
514 }
515 }
516
517 Ok(Some(Document {
518 content,
519 metadata,
520 id: doc_id,
521 }))
522 } else {
523 Ok(None)
524 }
525 }
526
527 async fn get_embedding(&self, id: &str) -> Result<Option<Vec<f32>>, VectorStoreError> {
528 let filter = Filter::must([Condition::matches("doc_id", id.to_string())]);
529
530 let results = self
531 .client
532 .query(
533 QueryPointsBuilder::new(&self.config.collection_name)
534 .query(vec![0.0; self.config.vector_size])
535 .filter(filter)
536 .limit(1)
537 .with_payload(true),
538 )
539 .await
540 .map_err(|e| VectorStoreError::StorageError(format!("failed to get vector: {}", e)))?;
541
542 if let Some(point) = results.result.first() {
543 if let Some(vectors) = &point.vectors {
544 if let Some(qdrant_client::qdrant::vector_output::Vector::Dense(dense)) =
545 vectors.get_vector()
546 {
547 return Ok(Some(dense.data.clone()));
548 }
549 }
550 }
551 Ok(None)
552 }
553
554 async fn delete_document(&self, id: &str) -> Result<(), VectorStoreError> {
555 let filter = Filter::must([Condition::matches("doc_id", id.to_string())]);
556
557 self.client
558 .delete_points(DeletePointsBuilder::new(&self.config.collection_name).points(filter))
559 .await
560 .map_err(|e| {
561 VectorStoreError::StorageError(format!("failed to delete document: {}", e))
562 })?;
563
564 Ok(())
565 }
566
567 async fn count(&self) -> usize {
568 let info = self
569 .client
570 .collection_info(&self.config.collection_name)
571 .await;
572
573 info.map(|i| i.result.and_then(|r| r.points_count).unwrap_or(0) as usize)
574 .unwrap_or(0)
575 }
576
577 async fn clear(&self) -> Result<(), VectorStoreError> {
578 let collection_name = self.config.collection_name.clone();
579
580 self.client
581 .delete_collection(&collection_name)
582 .await
583 .map_err(|e| {
584 VectorStoreError::StorageError(format!("failed to delete collection: {}", e))
585 })?;
586
587 self.client
588 .create_collection(
589 CreateCollectionBuilder::new(&collection_name).vectors_config(
590 VectorParamsBuilder::new(
591 self.config.vector_size as u64,
592 Distance::from(self.config.distance),
593 ),
594 ),
595 )
596 .await
597 .map_err(|e| {
598 VectorStoreError::StorageError(format!("failed to recreate collection: {}", e))
599 })?;
600
601 Ok(())
602 }
603}
604
605fn deterministic_point_uuid(collection: &str, doc_id: &str) -> Uuid {
610 let namespace = uuid::uuid!("167f0e54-3d37-4a72-a58b-8b2a1c6d5f10");
613 Uuid::new_v5(&namespace, format!("{collection}:{doc_id}").as_bytes())
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn test_config_default() {
622 let config = QdrantConfig::default();
623 assert_eq!(config.url, "http://localhost:6334");
624 assert_eq!(config.collection_name, "langchainrust");
625 assert_eq!(config.vector_size, 1536);
626 }
627
628 #[test]
629 fn test_config_builder() {
630 let config = QdrantConfig::new("http://custom:6334", "test_collection")
631 .with_vector_size(3072)
632 .with_distance(QdrantDistance::Euclid);
633
634 assert_eq!(config.url, "http://custom:6334");
635 assert_eq!(config.collection_name, "test_collection");
636 assert_eq!(config.vector_size, 3072);
637 assert!(matches!(config.distance, QdrantDistance::Euclid));
638 }
639
640 #[test]
642 fn test_filter_to_qdrant_eq() {
643 let f = filter_to_qdrant(&MetadataFilter::field("lang", FilterOp::Eq, "rust")).unwrap();
644 let expected = Filter::must([Condition::matches("lang", "rust".to_string())]);
645 assert_eq!(f, expected);
646 }
647
648 #[test]
650 fn test_filter_to_qdrant_ne_number() {
651 let f = filter_to_qdrant(&MetadataFilter::field("year", FilterOp::Ne, 2020.0)).unwrap();
652 let expected = Filter::must([Condition::from(Filter::must_not([Condition::matches(
653 "year", 2020_i64,
654 )]))]);
655 assert_eq!(f, expected);
656 }
657
658 #[test]
660 fn test_filter_to_qdrant_range() {
661 let f = filter_to_qdrant(&MetadataFilter::field("year", FilterOp::Gte, 2020)).unwrap();
662 let expected = Filter::must([Condition::range(
663 "year",
664 Range {
665 gte: Some(2020.0),
666 ..Default::default()
667 },
668 )]);
669 assert_eq!(f, expected);
670 }
671
672 #[test]
674 fn test_filter_to_qdrant_in_nin() {
675 let f =
676 filter_to_qdrant(&MetadataFilter::field("tag", FilterOp::In, vec!["a", "b"])).unwrap();
677 let expected = Filter::must([Condition::from(Filter::should([
678 Condition::matches("tag", "a".to_string()),
679 Condition::matches("tag", "b".to_string()),
680 ]))]);
681 assert_eq!(f, expected);
682
683 let f = filter_to_qdrant(&MetadataFilter::field("tag", FilterOp::Nin, vec!["a"])).unwrap();
684 let expected = Filter::must([Condition::from(Filter::must_not([Condition::matches(
685 "tag",
686 "a".to_string(),
687 )]))]);
688 assert_eq!(f, expected);
689 }
690
691 #[test]
693 fn test_filter_to_qdrant_and_or() {
694 let f = MetadataFilter::and(vec![
695 MetadataFilter::field("lang", FilterOp::Eq, "rust"),
696 MetadataFilter::or(vec![
697 MetadataFilter::field("year", FilterOp::Gte, 2020),
698 MetadataFilter::field("tag", FilterOp::In, vec!["ml"]),
699 ]),
700 ]);
701 let expected = Filter::must([Condition::from(Filter::must([
702 Condition::matches("lang", "rust".to_string()),
703 Condition::from(Filter::should([
704 Condition::range(
705 "year",
706 Range {
707 gte: Some(2020.0),
708 ..Default::default()
709 },
710 ),
711 Condition::from(Filter::should([Condition::matches(
712 "tag",
713 "ml".to_string(),
714 )])),
715 ])),
716 ]))]);
717 assert_eq!(filter_to_qdrant(&f).unwrap(), expected);
718 }
719
720 #[test]
722 fn test_filter_to_qdrant_unsupported() {
723 let float_eq = filter_to_qdrant(&MetadataFilter::field("score", FilterOp::Eq, 0.5));
725 assert!(matches!(
726 float_eq,
727 Err(VectorStoreError::UnsupportedFilter(_))
728 ));
729
730 let range_on_str = filter_to_qdrant(&MetadataFilter::field("year", FilterOp::Gt, "abc"));
732 assert!(matches!(
733 range_on_str,
734 Err(VectorStoreError::UnsupportedFilter(_))
735 ));
736
737 let empty_in = filter_to_qdrant(&MetadataFilter::field(
739 "tag",
740 FilterOp::In,
741 Vec::<String>::new(),
742 ));
743 assert!(matches!(
744 empty_in,
745 Err(VectorStoreError::UnsupportedFilter(_))
746 ));
747 }
748}
749
750#[async_trait]
755impl NativeHybridSearch for QdrantVectorStore {
756 fn supports_native_hybrid(&self) -> bool {
760 true
761 }
762
763 async fn native_hybrid_search(
764 &self,
765 query: &NativeHybridQuery,
766 ) -> Result<Vec<SearchResult>, VectorStoreError> {
767 let prefetch_limit = query.effective_prefetch_limit();
769 let prefetches: Vec<PrefetchQuery> = query
770 .query_vectors
771 .iter()
772 .map(|vector| {
773 PrefetchQueryBuilder::default()
774 .query(vector.clone())
775 .limit(prefetch_limit)
776 .build()
777 })
778 .collect();
779
780 let mut builder = QueryPointsBuilder::new(&self.config.collection_name)
781 .prefetch(prefetches)
782 .query(qdrant_client::qdrant::Query::new_fusion(Fusion::from(
783 query.fusion,
784 )))
785 .limit(query.limit as u64)
786 .with_payload(true);
787
788 if let Some(filter) = &query.filter {
789 builder = builder.filter(filter_to_qdrant(filter)?);
790 }
791
792 self.search_impl(builder).await
793 }
794}
795
796#[cfg(test)]
797mod hybrid_native_tests {
798 use super::*;
799 use crate::hybrid_native::fixtures::scored_point as fixture_point;
800
801 #[test]
804 fn scored_point_maps_to_search_result() {
805 let result = scored_point_to_result(fixture_point(1, 0.98, "rust doc", "docs"));
806 assert_eq!(result.document.content, "rust doc");
807 assert_eq!(result.document.id, None, "no doc_id payload -> no id");
808 assert_eq!(result.score, 0.98);
809 assert_eq!(
810 result
811 .document
812 .metadata
813 .get("source")
814 .and_then(|v| v.as_str()),
815 Some("docs")
816 );
817 assert!(
818 !result.document.metadata.contains_key("content"),
819 "content is not duplicated into metadata"
820 );
821 }
822
823 #[test]
825 fn doc_id_payload_becomes_document_id() {
826 let mut point = fixture_point(2, 0.9, "hello", "docs");
827 point.payload.insert(
828 "doc_id".to_string(),
829 qdrant_client::qdrant::Value::from("doc-42"),
830 );
831 let result = scored_point_to_result(point);
832 assert_eq!(result.document.id.as_deref(), Some("doc-42"));
833 }
834
835 #[tokio::test]
840 #[ignore = "requires a live Qdrant server (QDRANT_URL)"]
841 async fn native_hybrid_search_end_to_end() {
842 let url = std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6334".into());
843 let store = QdrantVectorStore::new(
844 crate::QdrantConfig::new(url, "lc-native-hybrid-test")
845 .with_vector_size(4)
846 .with_distance(crate::QdrantDistance::Dot),
847 )
848 .await
849 .expect("connect to Qdrant");
850
851 let docs = vec![
852 Document::new("alpha document"),
853 Document::new("beta document"),
854 Document::new("gamma document"),
855 ];
856 let embeddings = vec![
857 vec![1.0, 0.0, 0.0, 0.0],
858 vec![0.0, 1.0, 0.0, 0.0],
859 vec![0.0, 0.0, 1.0, 0.0],
860 ];
861 store.add_documents(docs, embeddings).await.unwrap();
862
863 let query =
864 NativeHybridQuery::new(vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.9, 0.1, 0.0, 0.0]], 2)
865 .unwrap();
866 assert!(store.supports_native_hybrid());
867 let results = store.native_hybrid_search(&query).await.unwrap();
868 assert_eq!(results.len(), 2, "fused top-2");
869 assert_eq!(results[0].document.content, "alpha document");
870 }
871}