Skip to main content

gobby_code/vector/code_symbols/
qdrant.rs

1use 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
11// Keep code-symbol collections compatible with the Python daemon's Qdrant schema.
12pub 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}