av_loopdetect/
vector_sink.rs1use std::future::Future;
4use std::pin::Pin;
5
6pub type VectorSinkFuture<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
8
9pub type VectorSearchFuture<'a> = Pin<Box<dyn Future<Output = Result<Option<f32>, String>> + Send + 'a>>;
11
12pub trait VectorSink: Send + Sync {
14 fn nearest_similarity<'a>(&'a self, _session_id: &'a str, _vector: &'a [f32]) -> VectorSearchFuture<'a> {
16 Box::pin(async { Ok(None) })
17 }
18
19 fn record<'a>(&'a self, session_id: &'a str, vector: &'a [f32]) -> VectorSinkFuture<'a>;
21
22 fn delete_scope<'a>(&'a self, _session_scope: &'a str) -> VectorSinkFuture<'a> {
29 Box::pin(async { Ok(()) })
30 }
31}
32
33#[derive(Debug, Default)]
35pub struct NoopVectorSink;
36
37impl VectorSink for NoopVectorSink {
38 fn record<'a>(&'a self, _session_id: &'a str, _vector: &'a [f32]) -> VectorSinkFuture<'a> {
39 Box::pin(async { Ok(()) })
40 }
41}
42
43#[cfg(feature = "qdrant")]
45pub struct QdrantVectorSink {
46 client: reqwest::Client,
47 base_url: String,
48 collection: String,
49}
50
51#[cfg(feature = "qdrant")]
52impl QdrantVectorSink {
53 pub fn new(base_url: impl Into<String>, collection: impl Into<String>) -> Result<Self, String> {
56 Ok(Self {
57 client: reqwest::Client::builder()
61 .connect_timeout(std::time::Duration::from_secs(1))
62 .timeout(std::time::Duration::from_secs(2))
63 .redirect(reqwest::redirect::Policy::none())
64 .build()
65 .map_err(|error| error.to_string())?,
66 base_url: base_url.into().trim_end_matches('/').to_owned(),
67 collection: collection.into(),
68 })
69 }
70
71 pub async fn ensure_collection(&self, dimension: usize) -> Result<(), String> {
80 let url = format!("{}/collections/{}", self.base_url, self.collection);
81 let get_response = self
82 .client
83 .get(&url)
84 .send()
85 .await
86 .map_err(classify_qdrant_error)?;
87 let status = get_response.status();
88 if status.is_success() {
89 let body: serde_json::Value = get_response.json().await.map_err(classify_qdrant_error)?;
90 let params = body.pointer("/result/config/params/vectors");
91 let existing_size = params
92 .and_then(|value| value.get("size"))
93 .and_then(serde_json::Value::as_u64);
94 let existing_distance = params
95 .and_then(|value| value.get("distance"))
96 .and_then(serde_json::Value::as_str);
97 match (existing_size, existing_distance) {
98 (Some(size), Some(distance))
99 if usize::try_from(size) == Ok(dimension) && distance.eq_ignore_ascii_case("Cosine") =>
100 {
101 return Ok(());
102 }
103 (Some(size), Some(distance)) => {
104 return Err(format!(
105 "Qdrant collection {:?} exists with size={size}, distance={distance:?} \
106 but embedder configured for size={dimension}, distance=\"Cosine\" — \
107 refusing to overwrite; delete the collection or reconfigure the embedder",
108 self.collection
109 ));
110 }
111 _ => {
112 return Err(format!(
116 "Qdrant collection {:?} exists but its config shape is unrecognized \
117 (no `vectors.size`/`vectors.distance`); refusing to overwrite",
118 self.collection
119 ));
120 }
121 }
122 }
123 if status.as_u16() != 404 {
124 return Err(format!(
125 "Qdrant returned {status} when probing collection {:?}",
126 self.collection
127 ));
128 }
129 let created = self
131 .client
132 .put(url)
133 .json(&serde_json::json!({
134 "vectors": {
135 "size": dimension,
136 "distance": "Cosine"
137 }
138 }))
139 .send()
140 .await
141 .map_err(classify_qdrant_error)?;
142 let created_status = created.status();
143 if !created_status.is_success() {
144 return Err(format!(
145 "Qdrant returned {created_status} creating collection {:?}",
146 self.collection
147 ));
148 }
149 Ok(())
150 }
151}
152
153#[cfg(feature = "qdrant")]
160fn classify_qdrant_error(error: reqwest::Error) -> String {
161 error.without_url().to_string()
162}
163
164#[cfg(feature = "qdrant")]
165impl VectorSink for QdrantVectorSink {
166 fn nearest_similarity<'a>(&'a self, session_id: &'a str, vector: &'a [f32]) -> VectorSearchFuture<'a> {
167 Box::pin(async move {
168 let url = format!("{}/collections/{}/points/search", self.base_url, self.collection);
169 let raw = self
175 .client
176 .post(url)
177 .json(&serde_json::json!({
178 "vector": vector,
179 "filter": {
180 "must": [{
181 "key": "session_id",
182 "match": { "value": session_id }
183 }]
184 },
185 "limit": 1,
186 "with_payload": false,
187 "with_vector": false
188 }))
189 .send()
190 .await
191 .map_err(classify_qdrant_error)?;
192 let status = raw.status();
193 if !status.is_success() {
194 return Err(format!(
195 "Qdrant returned {status} searching collection {:?}",
196 self.collection
197 ));
198 }
199 let response: serde_json::Value = raw.json().await.map_err(classify_qdrant_error)?;
200 let Some(score) = response
201 .pointer("/result/0/score")
202 .and_then(serde_json::Value::as_f64)
203 else {
204 return Ok(None);
205 };
206 if !score.is_finite() || !(-1.000_001..=1.000_001).contains(&score) {
213 return Err(format!("Qdrant returned invalid cosine score {score}"));
214 }
215 #[allow(clippy::cast_possible_truncation)]
216 let score = score.clamp(-1.0, 1.0) as f32;
217 Ok(Some(score))
218 })
219 }
220
221 fn record<'a>(&'a self, session_id: &'a str, vector: &'a [f32]) -> VectorSinkFuture<'a> {
222 Box::pin(async move {
223 let recorded_at = av_core::time::now_ms();
224 let id = av_core::new_event_uid();
225 let url = format!(
226 "{}/collections/{}/points?wait=true",
227 self.base_url, self.collection
228 );
229 let recorded = self
230 .client
231 .put(url)
232 .json(&serde_json::json!({
233 "points": [{
234 "id": id,
235 "vector": vector,
236 "payload": {
237 "session_id": session_id,
238 "recorded_at": recorded_at,
239 }
240 }]
241 }))
242 .send()
243 .await
244 .map_err(classify_qdrant_error)?;
245 let status = recorded.status();
246 if !status.is_success() {
247 return Err(format!(
248 "Qdrant returned {status} recording a vector in collection {:?}",
249 self.collection
250 ));
251 }
252 Ok(())
253 })
254 }
255
256 fn delete_scope<'a>(&'a self, session_scope: &'a str) -> VectorSinkFuture<'a> {
257 Box::pin(async move {
258 let url = format!(
259 "{}/collections/{}/points/delete?wait=true",
260 self.base_url, self.collection
261 );
262 let deleted = self
263 .client
264 .post(url)
265 .json(&serde_json::json!({
266 "filter": {
267 "must": [{
268 "key": "session_id",
269 "match": { "value": session_scope }
270 }]
271 }
272 }))
273 .send()
274 .await
275 .map_err(classify_qdrant_error)?;
276 let status = deleted.status();
277 if !status.is_success() {
278 return Err(format!(
279 "Qdrant returned {status} deleting a session scope from collection {:?}",
280 self.collection
281 ));
282 }
283 Ok(())
284 })
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 #![allow(clippy::unwrap_used)]
291
292 use super::*;
293
294 #[tokio::test]
295 async fn noop_sink_is_total() {
296 NoopVectorSink.record("session", &[0.0, 1.0]).await.unwrap();
297 }
298}