1use crate::error::{KernelError, Result};
9use qdrant_client::qdrant::point_id::PointIdOptions;
10use qdrant_client::qdrant::{
11 Condition, CountPointsBuilder, CreateCollectionBuilder, DeletePointsBuilder, Distance, Filter,
12 PointStruct, PointsIdsList, QueryPointsBuilder, ScoredPoint, UpsertPointsBuilder,
13 VectorParamsBuilder,
14};
15use qdrant_client::{Payload, Qdrant};
16
17use super::{AsyncVectorIndex, SearchHit};
18
19pub struct QdrantVectorIndex {
24 client: Qdrant,
25 collection: String,
26 dim: usize,
27}
28
29impl QdrantVectorIndex {
30 pub async fn new(url: &str, collection: &str, dim: usize) -> Result<Self> {
33 let client = Qdrant::from_url(url)
34 .build()
35 .map_err(KernelError::embedding)?;
36 let idx = Self {
37 client,
38 collection: collection.to_string(),
39 dim,
40 };
41 idx.ensure_collection().await?;
42 Ok(idx)
43 }
44
45 async fn ensure_collection(&self) -> Result<()> {
47 if !self
48 .client
49 .collection_exists(&self.collection)
50 .await
51 .map_err(KernelError::embedding)?
52 {
53 self.client
54 .create_collection(
55 CreateCollectionBuilder::new(&self.collection).vectors_config(
56 VectorParamsBuilder::new(self.dim as u64, Distance::Cosine),
57 ),
58 )
59 .await
60 .map_err(KernelError::embedding)?;
61 }
62 Ok(())
63 }
64
65 pub async fn delete_collection(&self) -> Result<()> {
67 self.client
68 .delete_collection(&self.collection)
69 .await
70 .map_err(KernelError::embedding)?;
71 Ok(())
72 }
73
74 fn extract_numeric_id(pid: &qdrant_client::qdrant::PointId) -> Option<u64> {
77 match &pid.point_id_options {
78 Some(PointIdOptions::Num(n)) => Some(*n),
79 _ => None,
80 }
81 }
82
83 fn scored_to_hit(point: &ScoredPoint) -> Option<SearchHit> {
88 let id = point.id.as_ref().and_then(Self::extract_numeric_id)?;
89 Some(SearchHit {
90 id,
91 score: point.score,
92 })
93 }
94}
95
96#[async_trait::async_trait]
97impl AsyncVectorIndex for QdrantVectorIndex {
98 async fn add(&self, vectors: &[Vec<f32>], ids: &[u64]) -> Result<()> {
99 if vectors.len() != ids.len() {
100 return Err(KernelError::Embedding(format!(
101 "vectors.len() ({}) must equal ids.len() ({})",
102 vectors.len(),
103 ids.len()
104 )));
105 }
106 if vectors.is_empty() {
107 return Ok(());
108 }
109 let payload = Payload::try_from(serde_json::json!({}))
110 .map_err(|e| KernelError::Embedding(format!("invalid empty payload: {e}")))?;
111 let points: Vec<PointStruct> = vectors
112 .iter()
113 .zip(ids.iter())
114 .map(|(v, &id)| PointStruct::new(id, v.clone(), payload.clone()))
115 .collect();
116 self.client
117 .upsert_points(UpsertPointsBuilder::new(&self.collection, points).wait(true))
118 .await
119 .map_err(KernelError::embedding)?;
120 Ok(())
121 }
122
123 async fn remove(&self, ids: &[u64]) -> Result<()> {
124 if ids.is_empty() {
125 return Ok(());
126 }
127 let id_list = PointsIdsList {
128 ids: ids.iter().map(|&id| id.into()).collect(),
129 };
130 self.client
131 .delete_points(
132 DeletePointsBuilder::new(&self.collection)
133 .points(id_list)
134 .wait(true),
135 )
136 .await
137 .map_err(KernelError::embedding)?;
138 Ok(())
139 }
140
141 async fn search(&self, query: &[f32], k: usize) -> Result<Vec<SearchHit>> {
142 let res = self
143 .client
144 .query(
145 QueryPointsBuilder::new(&self.collection)
146 .query(query.to_vec())
147 .limit(k as u64)
148 .with_payload(false),
149 )
150 .await
151 .map_err(KernelError::embedding)?;
152 Ok(res.result.iter().filter_map(Self::scored_to_hit).collect())
153 }
154
155 async fn search_filtered(
156 &self,
157 query: &[f32],
158 k: usize,
159 allowlist: &[u64],
160 ) -> Result<Vec<SearchHit>> {
161 if allowlist.is_empty() {
163 return Ok(vec![]);
164 }
165 let filter = Filter::must([Condition::has_id(allowlist.iter().copied())]);
166 let res = self
167 .client
168 .query(
169 QueryPointsBuilder::new(&self.collection)
170 .query(query.to_vec())
171 .limit(k as u64)
172 .with_payload(false)
173 .filter(filter),
174 )
175 .await
176 .map_err(KernelError::embedding)?;
177 Ok(res.result.iter().filter_map(Self::scored_to_hit).collect())
178 }
179
180 async fn len(&self) -> Result<usize> {
181 let res = self
182 .client
183 .count(CountPointsBuilder::new(&self.collection).exact(true))
184 .await
185 .map_err(KernelError::embedding)?;
186 Ok(res.result.map(|c| c.count as usize).unwrap_or(0))
187 }
188
189 fn dim(&self) -> usize {
190 self.dim
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use crate::embedding::AsyncVectorIndex;
198
199 const DIM: usize = 4;
200
201 fn unique_collection() -> String {
202 format!("llm_kernel_test_{}", std::process::id())
203 }
204
205 #[test]
207 fn extract_numeric_id_handles_num_and_uuid() {
208 use qdrant_client::qdrant::{PointId, point_id::PointIdOptions};
209 let num = PointId {
210 point_id_options: Some(PointIdOptions::Num(42)),
211 };
212 assert_eq!(QdrantVectorIndex::extract_numeric_id(&num), Some(42));
213 let uuid = PointId {
214 point_id_options: Some(PointIdOptions::Uuid("x".into())),
215 };
216 assert_eq!(QdrantVectorIndex::extract_numeric_id(&uuid), None);
217 let none = PointId {
218 point_id_options: None,
219 };
220 assert_eq!(QdrantVectorIndex::extract_numeric_id(&none), None);
221 }
222
223 async fn run_live_conformance(idx: &QdrantVectorIndex) -> Result<()> {
226 if idx.dim() != DIM {
227 return Err(KernelError::Embedding("dim mismatch".into()));
228 }
229 if !idx.is_empty().await? {
230 return Err(KernelError::Embedding("not empty at start".into()));
231 }
232 idx.add(
233 &[vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]],
234 &[1, 2],
235 )
236 .await?;
237 if idx.len().await? != 2 {
238 return Err(KernelError::Embedding("len != 2 after add".into()));
239 }
240
241 let hits = idx.search(&[1.0, 0.0, 0.0, 0.0], 1).await?;
242 if hits.len() != 1 || hits[0].id != 1 {
243 return Err(KernelError::Embedding("nearest neighbor != id 1".into()));
244 }
245
246 let filtered = idx.search_filtered(&[1.0, 0.0, 0.0, 0.0], 2, &[2]).await?;
247 if filtered.len() != 1 || filtered[0].id != 2 {
248 return Err(KernelError::Embedding("filtered search != id 2".into()));
249 }
250
251 idx.add(&[vec![0.9, 0.1, 0.0, 0.0]], &[1]).await?;
252 if idx.len().await? != 2 {
253 return Err(KernelError::Embedding("len != 2 after re-add".into()));
254 }
255
256 idx.remove(&[1]).await?;
257 if idx.len().await? != 1 {
258 return Err(KernelError::Embedding("len != 1 after remove".into()));
259 }
260 let after = idx.search(&[1.0, 0.0, 0.0, 0.0], 5).await?;
261 if after.iter().any(|h| h.id == 1) {
262 return Err(KernelError::Embedding(
263 "id 1 still present after remove".into(),
264 ));
265 }
266 Ok(())
267 }
268
269 #[tokio::test]
273 async fn live_qdrant_conformance() {
274 let url = match std::env::var("LLMKERNEL_QDRANT_URL") {
275 Ok(u) => u,
276 Err(_) => {
277 eprintln!("skipped: LLMKERNEL_QDRANT_URL unset (no live Qdrant)");
278 return;
279 }
280 };
281
282 let coll = unique_collection();
283 let idx = match QdrantVectorIndex::new(&url, &coll, DIM).await {
284 Ok(i) => i,
285 Err(e) => panic!("connect + create collection: {e:?}"),
286 };
287 let result = run_live_conformance(&idx).await;
290 let _ = idx.delete_collection().await;
291 result.expect("qdrant conformance failed");
292 }
293}