gobby_code/vector/code_symbols/
qdrant.rs1use reqwest::StatusCode;
2use serde_json::{Value, json};
3use std::sync::OnceLock;
4use std::time::Duration;
5
6use crate::config::{CODE_SYMBOL_COLLECTION_PREFIX, QdrantConfig};
7use gobby_core::qdrant::{CollectionScope, SearchRequest};
8
9use super::types::{ExistingVectorCollectionSchema, VectorLifecycleError};
10
11pub const VECTOR_DISTANCE_COSINE: &str = "Cosine";
13const QDRANT_DELETE_TIMEOUT_SECS_ENV: &str = "GCODE_QDRANT_DELETE_TIMEOUT_SECS";
14const DEFAULT_QDRANT_DELETE_TIMEOUT: Duration = Duration::from_secs(60);
15static QDRANT_HTTP_CLIENT: OnceLock<reqwest::blocking::Client> = OnceLock::new();
16
17pub fn collection_name(collection_prefix: &str, project_id: &str) -> String {
18 let collection = format!("{collection_prefix}{project_id}");
19 gobby_core::qdrant::collection_name("gcode", CollectionScope::Custom(&collection))
20}
21
22pub(super) fn collection_path(collection: &str) -> String {
23 format!("/collections/{}", urlencoding::encode(collection))
24}
25
26pub fn delete_project_collection(
27 qdrant: &QdrantConfig,
28 project_id: &str,
29) -> Result<usize, VectorLifecycleError> {
30 let client = qdrant_http_client()?;
31 let collection = collection_name(CODE_SYMBOL_COLLECTION_PREFIX, project_id);
32 delete_qdrant_collection(&client, qdrant, &collection)
33}
34
35pub fn delete_file_vectors(
36 qdrant: &QdrantConfig,
37 project_id: &str,
38 file_path: &str,
39) -> Result<usize, VectorLifecycleError> {
40 let client = qdrant_http_client()?;
41 let collection = collection_name(CODE_SYMBOL_COLLECTION_PREFIX, project_id);
42 delete_vectors_for_filter(&client, qdrant, &collection, project_id, Some(file_path))
43}
44
45pub fn delete_code_symbol_collections_with_prefix(
46 qdrant: &QdrantConfig,
47) -> Result<Vec<String>, VectorLifecycleError> {
48 let client = qdrant_http_client()?;
49 let resp = qdrant_request_for_config(&client, qdrant, reqwest::Method::GET, "/collections")?
50 .send()
51 .map_err(|err| VectorLifecycleError::QdrantOperation(err.to_string()))?;
52 let status = resp.status();
53 if !status.is_success() {
54 return Err(qdrant_http_error("list collections", status, resp));
55 }
56
57 let data: Value = resp
58 .json()
59 .map_err(|err| VectorLifecycleError::QdrantOperation(err.to_string()))?;
60 let collections = parse_collection_names(&data)
61 .into_iter()
62 .filter(|name| name.starts_with(CODE_SYMBOL_COLLECTION_PREFIX))
63 .collect::<Vec<_>>();
64
65 let mut deleted = Vec::new();
66 for collection in collections {
67 if delete_qdrant_collection(&client, qdrant, &collection)? > 0 {
68 deleted.push(collection);
69 }
70 }
71 Ok(deleted)
72}
73
74pub fn vector_search(
75 config: &QdrantConfig,
76 collection: &str,
77 query_vector: &[f32],
78 limit: usize,
79) -> anyhow::Result<Vec<(String, f64)>> {
80 let request = SearchRequest {
81 vector: query_vector.to_vec(),
82 limit,
83 filter: None,
84 };
85 let (hits, _) = gobby_core::qdrant::with_qdrant(Some(config), Vec::new(), |config| {
86 gobby_core::qdrant::search(config, collection, request)
87 })?;
88 Ok(hits
89 .into_iter()
90 .map(|hit| (hit.id, f64::from(hit.score)))
91 .collect())
92}
93
94pub(super) fn parse_collection_schema(data: &Value) -> Option<ExistingVectorCollectionSchema> {
95 let vectors = data.pointer("/result/config/params/vectors")?;
96 let size = vectors
97 .get("size")
98 .and_then(Value::as_u64)
99 .and_then(|size| usize::try_from(size).ok());
100 let distance = vectors
101 .get("distance")
102 .and_then(Value::as_str)
103 .map(str::to_string);
104 Some(ExistingVectorCollectionSchema { size, distance })
105}
106
107fn parse_collection_names(data: &Value) -> Vec<String> {
108 data.pointer("/result/collections")
109 .and_then(Value::as_array)
110 .map(|collections| {
111 collections
112 .iter()
113 .filter_map(|collection| {
114 collection
115 .get("name")
116 .and_then(Value::as_str)
117 .map(str::to_string)
118 })
119 .collect()
120 })
121 .unwrap_or_default()
122}
123
124fn parse_points_count(data: &Value) -> Result<usize, VectorLifecycleError> {
125 data.pointer("/result/count")
126 .and_then(Value::as_u64)
127 .and_then(|count| usize::try_from(count).ok())
128 .ok_or_else(|| {
129 VectorLifecycleError::QdrantOperation(
130 "count points response did not include result.count".to_string(),
131 )
132 })
133}
134
135fn qdrant_http_client() -> Result<reqwest::blocking::Client, VectorLifecycleError> {
136 if let Some(client) = QDRANT_HTTP_CLIENT.get() {
137 return Ok(client.clone());
138 }
139 let client = reqwest::blocking::Client::builder()
140 .timeout(qdrant_delete_timeout())
141 .build()
142 .map_err(|err| VectorLifecycleError::QdrantOperation(err.to_string()))?;
143 let _ = QDRANT_HTTP_CLIENT.set(client.clone());
144 Ok(client)
145}
146
147fn qdrant_delete_timeout() -> Duration {
148 std::env::var(QDRANT_DELETE_TIMEOUT_SECS_ENV)
149 .ok()
150 .and_then(|value| value.parse::<u64>().ok())
151 .filter(|seconds| *seconds > 0)
152 .map(Duration::from_secs)
153 .unwrap_or(DEFAULT_QDRANT_DELETE_TIMEOUT)
154}
155
156pub(super) fn qdrant_request_for_config(
157 client: &reqwest::blocking::Client,
158 qdrant: &QdrantConfig,
159 method: reqwest::Method,
160 path: &str,
161) -> Result<reqwest::blocking::RequestBuilder, VectorLifecycleError> {
162 let base = qdrant
163 .url
164 .as_deref()
165 .map(str::trim)
166 .filter(|url| !url.is_empty())
167 .ok_or(VectorLifecycleError::MissingQdrantConfig)?
168 .trim_end_matches('/');
169 let url = format!("{base}{path}");
170 let mut req = client.request(method, url);
171 if let Some(key) = &qdrant.api_key {
172 req = req.header("api-key", key);
173 }
174 Ok(req)
175}
176
177fn delete_qdrant_collection(
178 client: &reqwest::blocking::Client,
179 qdrant: &QdrantConfig,
180 collection: &str,
181) -> Result<usize, VectorLifecycleError> {
182 let resp = qdrant_request_for_config(
183 client,
184 qdrant,
185 reqwest::Method::DELETE,
186 &collection_path(collection),
187 )?
188 .send()
189 .map_err(|err| VectorLifecycleError::QdrantOperation(err.to_string()))?;
190 let status = resp.status();
191 if status == StatusCode::NOT_FOUND {
192 return Ok(0);
193 }
194 if !status.is_success() {
195 return Err(qdrant_http_error("delete collection", status, resp));
196 }
197 Ok(1)
198}
199
200pub(super) fn delete_vectors_for_filter(
201 client: &reqwest::blocking::Client,
202 qdrant: &QdrantConfig,
203 collection: &str,
204 project_id: &str,
205 file_path: Option<&str>,
206) -> Result<usize, VectorLifecycleError> {
207 delete_vectors_for_filter_excluding_ids(client, qdrant, collection, project_id, file_path, &[])
208}
209
210pub(super) fn delete_vectors_for_filter_excluding_ids(
211 client: &reqwest::blocking::Client,
212 qdrant: &QdrantConfig,
213 collection: &str,
214 project_id: &str,
215 file_path: Option<&str>,
216 keep_point_ids: &[String],
217) -> Result<usize, VectorLifecycleError> {
218 let mut must = vec![json!({
219 "key": "project_id",
220 "match": {"value": project_id},
221 })];
222 if let Some(file_path) = file_path {
223 must.push(json!({
224 "key": "file_path",
225 "match": {"value": file_path},
226 }));
227 }
228 let mut filter_object = serde_json::Map::new();
229 filter_object.insert("must".to_string(), Value::Array(must));
230 if !keep_point_ids.is_empty() {
231 filter_object.insert(
232 "must_not".to_string(),
233 json!([{ "has_id": keep_point_ids }]),
234 );
235 }
236 let filter = Value::Object(filter_object);
237 let count_body = json!({ "filter": filter.clone(), "exact": true });
238 let resp = qdrant_request_for_config(
239 client,
240 qdrant,
241 reqwest::Method::POST,
242 &format!("{}/points/count", collection_path(collection)),
243 )?
244 .json(&count_body)
245 .send()
246 .map_err(|err| VectorLifecycleError::QdrantOperation(err.to_string()))?;
247 let status = resp.status();
248 if status == StatusCode::NOT_FOUND {
249 return Ok(0);
250 }
251 if !status.is_success() {
252 return Err(qdrant_http_error("count points", status, resp));
253 }
254 let data: Value = resp
255 .json()
256 .map_err(|err| VectorLifecycleError::QdrantOperation(err.to_string()))?;
257 let count = parse_points_count(&data)?;
258 if count == 0 {
259 return Ok(0);
260 }
261
262 let body = json!({ "filter": filter });
263 let resp = qdrant_request_for_config(
264 client,
265 qdrant,
266 reqwest::Method::POST,
267 &format!("{}/points/delete?wait=true", collection_path(collection)),
268 )?
269 .json(&body)
270 .send()
271 .map_err(|err| VectorLifecycleError::QdrantOperation(err.to_string()))?;
272 let status = resp.status();
273 if status == StatusCode::NOT_FOUND {
274 return Ok(0);
275 }
276 if !status.is_success() {
277 return Err(qdrant_http_error("delete points", status, resp));
278 }
279 Ok(count)
280}
281
282pub(super) fn qdrant_http_error(
283 operation: &'static str,
284 status: StatusCode,
285 resp: reqwest::blocking::Response,
286) -> VectorLifecycleError {
287 VectorLifecycleError::QdrantHttp {
288 operation,
289 status: status.as_u16(),
290 body: resp.text().unwrap_or_default(),
291 }
292}