1use std::path::Path;
2
3use crate::core::bm25_index::BM25Index;
4#[cfg(any(feature = "qdrant", feature = "pgvector"))]
5use crate::core::bm25_index::ChunkKind;
6use crate::core::hnsw::FlatEmbeddings;
7use crate::core::hybrid_search::{DenseSearchResult, HybridConfig, HybridResult};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum DenseBackendKind {
11 Local,
12 #[cfg(feature = "qdrant")]
13 Qdrant,
14 #[cfg(feature = "pgvector")]
15 Pgvector,
16}
17
18impl DenseBackendKind {
19 pub fn try_from_env() -> Result<Self, String> {
20 let explicit = std::env::var("LEANCTX_DENSE_BACKEND")
21 .ok()
22 .map(|v| v.trim().to_ascii_lowercase())
23 .filter(|v| !v.is_empty());
24
25 let inferred_qdrant =
26 std::env::var("LEANCTX_QDRANT_URL").is_ok_and(|v| !v.trim().is_empty());
27 let inferred_pgvector =
28 std::env::var("LEANCTX_PGVECTOR_URL").is_ok_and(|v| !v.trim().is_empty());
29
30 let requested = explicit
31 .or_else(|| inferred_qdrant.then_some("qdrant".to_string()))
32 .or_else(|| inferred_pgvector.then_some("pgvector".to_string()));
33
34 match requested.as_deref() {
35 None | Some("local") => Ok(Self::Local),
36 Some("qdrant") => {
37 #[cfg(feature = "qdrant")]
38 {
39 Ok(Self::Qdrant)
40 }
41 #[cfg(not(feature = "qdrant"))]
42 {
43 Err("Dense backend 'qdrant' requested, but feature 'qdrant' is not enabled. Rebuild with --features qdrant.".to_string())
44 }
45 }
46 Some("pgvector") => {
47 #[cfg(feature = "pgvector")]
48 {
49 Ok(Self::Pgvector)
50 }
51 #[cfg(not(feature = "pgvector"))]
52 {
53 Err("Dense backend 'pgvector' requested, but feature 'pgvector' is not enabled. Rebuild with --features pgvector.".to_string())
54 }
55 }
56 Some(other) => Err(format!(
57 "Unknown LEANCTX_DENSE_BACKEND={other:?} (expected 'local', 'qdrant' or 'pgvector')"
58 )),
59 }
60 }
61
62 pub fn label(&self) -> &'static str {
63 match self {
64 Self::Local => "local",
65 #[cfg(feature = "qdrant")]
66 Self::Qdrant => "qdrant",
67 #[cfg(feature = "pgvector")]
68 Self::Pgvector => "pgvector",
69 }
70 }
71}
72
73#[cfg(feature = "embeddings")]
74#[allow(clippy::too_many_arguments)]
75pub fn dense_results_as_hybrid(
76 backend: DenseBackendKind,
77 root: &Path,
78 index: &BM25Index,
79 engine: &crate::core::embeddings::EmbeddingEngine,
80 aligned_embeddings: &FlatEmbeddings,
81 changed_files: &[String],
82 query: &str,
83 top_k: usize,
84 filter: Option<&dyn Fn(&str) -> bool>,
85) -> Result<Vec<HybridResult>, String> {
86 let dense = dense_results(
87 backend,
88 root,
89 index,
90 engine,
91 aligned_embeddings,
92 changed_files,
93 query,
94 top_k,
95 filter,
96 )?;
97
98 Ok(dense
99 .into_iter()
100 .map(|d| HybridResult {
101 file_path: d.file_path,
102 symbol_name: d.symbol_name,
103 kind: d.kind,
104 start_line: d.start_line,
105 end_line: d.end_line,
106 snippet: d.snippet,
107 rrf_score: d.similarity as f64,
108 bm25_score: None,
109 dense_score: Some(d.similarity),
110 bm25_rank: None,
111 dense_rank: None,
112 })
113 .collect())
114}
115
116#[cfg(feature = "embeddings")]
117#[allow(clippy::too_many_arguments)]
118pub fn hybrid_results(
119 backend: DenseBackendKind,
120 root: &Path,
121 index: &BM25Index,
122 engine: &crate::core::embeddings::EmbeddingEngine,
123 aligned_embeddings: &FlatEmbeddings,
124 changed_files: &[String],
125 query: &str,
126 top_k: usize,
127 config: &HybridConfig,
128 filter: Option<&dyn Fn(&str) -> bool>,
129 graph_file_ranks: Option<&std::collections::HashMap<String, usize>>,
130) -> Result<Vec<HybridResult>, String> {
131 match backend {
132 DenseBackendKind::Local => {
133 let _ = (root, changed_files);
134 let mut results = crate::core::hybrid_search::hybrid_search(
135 query,
136 index,
137 Some(engine),
138 Some(aligned_embeddings),
139 top_k,
140 config,
141 graph_file_ranks,
142 );
143 if let Some(pred) = filter {
144 results.retain(|r| pred(&r.file_path));
145 }
146 results.truncate(top_k);
147 Ok(results)
148 }
149 #[cfg(feature = "qdrant")]
150 DenseBackendKind::Qdrant => remote_hybrid_results(
151 backend,
152 root,
153 index,
154 engine,
155 aligned_embeddings,
156 changed_files,
157 query,
158 top_k,
159 config,
160 filter,
161 graph_file_ranks,
162 ),
163 #[cfg(feature = "pgvector")]
164 DenseBackendKind::Pgvector => remote_hybrid_results(
165 backend,
166 root,
167 index,
168 engine,
169 aligned_embeddings,
170 changed_files,
171 query,
172 top_k,
173 config,
174 filter,
175 graph_file_ranks,
176 ),
177 }
178}
179
180#[cfg(all(feature = "embeddings", any(feature = "qdrant", feature = "pgvector")))]
183#[allow(clippy::too_many_arguments)]
184fn remote_hybrid_results(
185 backend: DenseBackendKind,
186 root: &Path,
187 index: &BM25Index,
188 engine: &crate::core::embeddings::EmbeddingEngine,
189 aligned_embeddings: &FlatEmbeddings,
190 changed_files: &[String],
191 query: &str,
192 top_k: usize,
193 config: &HybridConfig,
194 filter: Option<&dyn Fn(&str) -> bool>,
195 graph_file_ranks: Option<&std::collections::HashMap<String, usize>>,
196) -> Result<Vec<HybridResult>, String> {
197 let bm25_k = config.bm25_candidates.max(top_k);
198 let dense_k = config.dense_candidates.max(top_k);
199
200 let mut bm25 = index.search(query, bm25_k);
201 if let Some(pred) = filter {
202 bm25.retain(|r| pred(&r.file_path));
203 }
204
205 let dense = dense_results(
206 backend,
207 root,
208 index,
209 engine,
210 aligned_embeddings,
211 changed_files,
212 query,
213 dense_k,
214 filter,
215 )?;
216
217 let mut fused = crate::core::hybrid_search::reciprocal_rank_fusion(
218 &bm25,
219 &dense,
220 config,
221 top_k,
222 graph_file_ranks,
223 );
224 if let Some(pred) = filter {
225 fused.retain(|r| pred(&r.file_path));
226 }
227 fused.truncate(top_k);
228 Ok(fused)
229}
230
231#[cfg(feature = "embeddings")]
232#[allow(clippy::too_many_arguments)]
233fn dense_results(
234 backend: DenseBackendKind,
235 root: &Path,
236 index: &BM25Index,
237 engine: &crate::core::embeddings::EmbeddingEngine,
238 aligned_embeddings: &FlatEmbeddings,
239 changed_files: &[String],
240 query: &str,
241 top_k: usize,
242 filter: Option<&dyn Fn(&str) -> bool>,
243) -> Result<Vec<DenseSearchResult>, String> {
244 match backend {
245 DenseBackendKind::Local => {
246 let _ = (root, changed_files);
247 dense_results_local(index, engine, aligned_embeddings, query, top_k, filter)
248 }
249 #[cfg(feature = "qdrant")]
250 DenseBackendKind::Qdrant => {
251 let vecs: Vec<Vec<f32>> = (0..aligned_embeddings.n_vectors())
252 .map(|i| aligned_embeddings.get_vec(i))
253 .collect();
254 dense_results_qdrant(
255 root,
256 index,
257 engine,
258 &vecs,
259 changed_files,
260 query,
261 top_k,
262 filter,
263 )
264 }
265 #[cfg(feature = "pgvector")]
266 DenseBackendKind::Pgvector => {
267 let vecs: Vec<Vec<f32>> = (0..aligned_embeddings.n_vectors())
268 .map(|i| aligned_embeddings.get_vec(i))
269 .collect();
270 dense_results_pgvector(
271 root,
272 index,
273 engine,
274 &vecs,
275 changed_files,
276 query,
277 top_k,
278 filter,
279 )
280 }
281 }
282}
283
284#[cfg(feature = "embeddings")]
285fn dense_results_local(
286 index: &BM25Index,
287 engine: &crate::core::embeddings::EmbeddingEngine,
288 aligned_embeddings: &FlatEmbeddings,
289 query: &str,
290 top_k: usize,
291 filter: Option<&dyn Fn(&str) -> bool>,
292) -> Result<Vec<DenseSearchResult>, String> {
293 let query_embedding = engine
294 .embed_query(query)
295 .map_err(|e| format!("embedding failed: {e}"))?;
296
297 let top = top_k_by_similarity(&query_embedding, aligned_embeddings, top_k, |i| {
298 let Some(pred) = filter else { return true };
299 index.chunks.get(i).is_some_and(|c| pred(&c.file_path))
300 });
301
302 Ok(top
303 .into_iter()
304 .filter_map(|(idx, sim)| {
305 let chunk = index.chunks.get(idx)?;
306 let snippet = chunk.content.lines().take(5).collect::<Vec<_>>().join("\n");
307 Some(DenseSearchResult {
308 chunk_idx: idx,
309 similarity: sim,
310 file_path: chunk.file_path.clone(),
311 symbol_name: chunk.symbol_name.clone(),
312 kind: chunk.kind.clone(),
313 start_line: chunk.start_line,
314 end_line: chunk.end_line,
315 snippet,
316 })
317 })
318 .collect())
319}
320
321#[cfg(feature = "embeddings")]
329fn top_k_by_similarity(
330 query: &[f32],
331 embeddings: &FlatEmbeddings,
332 k: usize,
333 filter: impl Fn(usize) -> bool,
334) -> Vec<(usize, f32)> {
335 use std::cmp::Ordering;
336 use std::collections::BinaryHeap;
337
338 #[derive(PartialEq)]
339 struct MinEntry(f32, usize);
340
341 impl Eq for MinEntry {}
342 impl PartialOrd for MinEntry {
343 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
344 Some(self.cmp(other))
345 }
346 }
347 impl Ord for MinEntry {
348 fn cmp(&self, other: &Self) -> Ordering {
349 other
350 .0
351 .partial_cmp(&self.0)
352 .unwrap_or(Ordering::Equal)
353 .then_with(|| self.1.cmp(&other.1))
354 }
355 }
356
357 let n = embeddings.n_vectors();
358 let mut heap: BinaryHeap<MinEntry> = BinaryHeap::with_capacity(k + 1);
359
360 for i in 0..n {
361 if !filter(i) {
362 continue;
363 }
364 let emb = embeddings.get(i);
365 let sim = crate::core::embeddings::cosine_similarity(query, emb);
366 if heap.len() < k {
367 heap.push(MinEntry(sim, i));
368 } else if let Some(min) = heap.peek()
369 && sim > min.0
370 {
371 heap.pop();
372 heap.push(MinEntry(sim, i));
373 }
374 }
375
376 let mut result: Vec<(usize, f32)> = heap.into_iter().map(|e| (e.1, e.0)).collect();
377 result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
378 result
379}
380
381#[cfg(feature = "qdrant")]
382#[cfg(feature = "embeddings")]
383fn dense_results_qdrant(
384 root: &Path,
385 index: &BM25Index,
386 engine: &crate::core::embeddings::EmbeddingEngine,
387 aligned_embeddings: &[Vec<f32>],
388 changed_files: &[String],
389 query: &str,
390 top_k: usize,
391 filter: Option<&dyn Fn(&str) -> bool>,
392) -> Result<Vec<DenseSearchResult>, String> {
393 let store = crate::core::qdrant_store::QdrantStore::from_env()?;
394 let collection = store.collection_name(root, engine.dimensions())?;
395 let created_new = store.ensure_collection(&collection, engine.dimensions())?;
396 store.sync_index(
397 &collection,
398 index,
399 aligned_embeddings,
400 changed_files,
401 created_new,
402 )?;
403
404 let query_vec = engine
405 .embed_query(query)
406 .map_err(|e| format!("embedding failed: {e}"))?;
407
408 let hits = store.search(&collection, &query_vec, top_k)?;
409 let mut out = Vec::with_capacity(hits.len());
410 for hit in hits {
411 if let Some(pred) = filter
412 && !pred(&hit.file_path)
413 {
414 continue;
415 }
416 let snippet = snippet_from_disk(root, &hit.file_path, hit.start_line, hit.end_line, 5);
417 out.push(DenseSearchResult {
418 chunk_idx: 0,
419 similarity: hit.score,
420 file_path: hit.file_path,
421 symbol_name: hit.symbol_name,
422 kind: hit.kind,
423 start_line: hit.start_line,
424 end_line: hit.end_line,
425 snippet,
426 });
427 }
428 Ok(out)
429}
430
431#[cfg(feature = "pgvector")]
432#[cfg(feature = "embeddings")]
433#[allow(clippy::too_many_arguments)]
434fn dense_results_pgvector(
435 root: &Path,
436 index: &BM25Index,
437 engine: &crate::core::embeddings::EmbeddingEngine,
438 aligned_embeddings: &[Vec<f32>],
439 changed_files: &[String],
440 query: &str,
441 top_k: usize,
442 filter: Option<&dyn Fn(&str) -> bool>,
443) -> Result<Vec<DenseSearchResult>, String> {
444 let store = crate::core::pgvector_store::PgvectorStore::from_env()?;
445 let table = store.table_name(root, engine.dimensions())?;
446 let created_new = store.ensure_table(&table, engine.dimensions())?;
447 store.sync_index(
448 &table,
449 index,
450 aligned_embeddings,
451 changed_files,
452 created_new,
453 )?;
454
455 let query_vec = engine
456 .embed_query(query)
457 .map_err(|e| format!("embedding failed: {e}"))?;
458
459 let hits = store.search(&table, &query_vec, top_k)?;
460 let mut out = Vec::with_capacity(hits.len());
461 for hit in hits {
462 if let Some(pred) = filter
463 && !pred(&hit.file_path)
464 {
465 continue;
466 }
467 let snippet = snippet_from_disk(root, &hit.file_path, hit.start_line, hit.end_line, 5);
468 out.push(DenseSearchResult {
469 chunk_idx: 0,
470 similarity: hit.score,
471 file_path: hit.file_path,
472 symbol_name: hit.symbol_name,
473 kind: hit.kind,
474 start_line: hit.start_line,
475 end_line: hit.end_line,
476 snippet,
477 });
478 }
479 Ok(out)
480}
481
482#[cfg(any(feature = "qdrant", feature = "pgvector"))]
483fn snippet_from_disk(
484 root: &Path,
485 rel_path: &str,
486 start_line: usize,
487 end_line: usize,
488 max_lines: usize,
489) -> String {
490 let Ok(path) = crate::core::pathjail::jail_path(&root.join(rel_path), root) else {
491 return String::new();
492 };
493 let Ok(content) = std::fs::read_to_string(path) else {
494 return String::new();
495 };
496 let lines: Vec<&str> = content.lines().collect();
497 if lines.is_empty() {
498 return String::new();
499 }
500 let start = start_line.saturating_sub(1).min(lines.len());
501 let end = end_line.max(start_line).min(lines.len());
502 let mut slice = &lines[start..end];
503 if slice.len() > max_lines {
504 slice = &slice[..max_lines];
505 }
506 slice.join("\n")
507}
508
509#[cfg(any(feature = "qdrant", feature = "pgvector"))]
510pub(crate) fn kind_to_str(kind: &ChunkKind) -> &'static str {
511 match kind {
512 ChunkKind::Function => "Function",
513 ChunkKind::Struct => "Struct",
514 ChunkKind::Impl => "Impl",
515 ChunkKind::Module => "Module",
516 ChunkKind::Class => "Class",
517 ChunkKind::Method => "Method",
518 ChunkKind::Issue => "Issue",
519 ChunkKind::PullRequest => "PullRequest",
520 ChunkKind::WikiPage => "WikiPage",
521 ChunkKind::DbSchema => "DbSchema",
522 ChunkKind::ApiEndpoint => "ApiEndpoint",
523 ChunkKind::Ticket => "Ticket",
524 ChunkKind::ExternalOther => "ExternalOther",
525 ChunkKind::Other => "Other",
526 }
527}
528
529#[cfg(any(feature = "qdrant", feature = "pgvector"))]
530pub(crate) fn kind_from_str(s: &str) -> ChunkKind {
531 match s {
532 "Function" => ChunkKind::Function,
533 "Struct" => ChunkKind::Struct,
534 "Impl" => ChunkKind::Impl,
535 "Module" => ChunkKind::Module,
536 "Class" => ChunkKind::Class,
537 "Method" => ChunkKind::Method,
538 "Issue" => ChunkKind::Issue,
539 "PullRequest" => ChunkKind::PullRequest,
540 "WikiPage" => ChunkKind::WikiPage,
541 "DbSchema" => ChunkKind::DbSchema,
542 "ApiEndpoint" => ChunkKind::ApiEndpoint,
543 "Ticket" => ChunkKind::Ticket,
544 "ExternalOther" => ChunkKind::ExternalOther,
545 _ => ChunkKind::Other,
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 fn set_env(key: &str, value: Option<&str>) -> Option<String> {
553 let old = std::env::var(key).ok();
554 match value {
555 Some(v) => crate::test_env::set_var(key, v),
556 None => crate::test_env::remove_var(key),
557 }
558 old
559 }
560
561 fn restore_env(key: &str, old: Option<String>) {
562 match old {
563 Some(v) => crate::test_env::set_var(key, v),
564 None => crate::test_env::remove_var(key),
565 }
566 }
567
568 #[test]
569 fn dense_backend_defaults_to_local() {
570 let _g = crate::core::data_dir::test_env_lock();
571 let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
572 let old_url = set_env("LEANCTX_QDRANT_URL", None);
573 let old_pg = set_env("LEANCTX_PGVECTOR_URL", None);
574
575 let got = DenseBackendKind::try_from_env().unwrap();
576 assert_eq!(got, DenseBackendKind::Local);
577
578 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
579 restore_env("LEANCTX_QDRANT_URL", old_url);
580 restore_env("LEANCTX_PGVECTOR_URL", old_pg);
581 }
582
583 #[test]
584 fn dense_backend_unknown_value_errors() {
585 let _g = crate::core::data_dir::test_env_lock();
586 let old_backend = set_env("LEANCTX_DENSE_BACKEND", Some("wat"));
587 let old_url = set_env("LEANCTX_QDRANT_URL", None);
588
589 let err = DenseBackendKind::try_from_env().unwrap_err();
590 assert!(err.contains("Unknown LEANCTX_DENSE_BACKEND"));
591
592 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
593 restore_env("LEANCTX_QDRANT_URL", old_url);
594 }
595
596 #[cfg(feature = "qdrant")]
597 #[test]
598 fn dense_backend_infers_qdrant_from_url() {
599 let _g = crate::core::data_dir::test_env_lock();
600 let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
601 let old_url = set_env("LEANCTX_QDRANT_URL", Some("http://127.0.0.1:6333"));
602 let old_pg = set_env("LEANCTX_PGVECTOR_URL", None);
603
604 let got = DenseBackendKind::try_from_env().unwrap();
605 assert_eq!(got, DenseBackendKind::Qdrant);
606
607 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
608 restore_env("LEANCTX_QDRANT_URL", old_url);
609 restore_env("LEANCTX_PGVECTOR_URL", old_pg);
610 }
611
612 #[cfg(feature = "pgvector")]
613 #[test]
614 fn dense_backend_infers_pgvector_from_url() {
615 let _g = crate::core::data_dir::test_env_lock();
616 let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
617 let old_url = set_env("LEANCTX_QDRANT_URL", None);
618 let old_pg = set_env("LEANCTX_PGVECTOR_URL", Some("postgres://localhost/lctx"));
619
620 let got = DenseBackendKind::try_from_env().unwrap();
621 assert_eq!(got, DenseBackendKind::Pgvector);
622
623 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
624 restore_env("LEANCTX_QDRANT_URL", old_url);
625 restore_env("LEANCTX_PGVECTOR_URL", old_pg);
626 }
627
628 #[cfg(all(feature = "qdrant", feature = "pgvector"))]
629 #[test]
630 fn dense_backend_qdrant_wins_when_both_urls_set() {
631 let _g = crate::core::data_dir::test_env_lock();
632 let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
633 let old_url = set_env("LEANCTX_QDRANT_URL", Some("http://127.0.0.1:6333"));
634 let old_pg = set_env("LEANCTX_PGVECTOR_URL", Some("postgres://localhost/lctx"));
635
636 let got = DenseBackendKind::try_from_env().unwrap();
637 assert_eq!(got, DenseBackendKind::Qdrant);
638
639 crate::test_env::set_var("LEANCTX_DENSE_BACKEND", "pgvector");
641 let got = DenseBackendKind::try_from_env().unwrap();
642 assert_eq!(got, DenseBackendKind::Pgvector);
643
644 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
645 restore_env("LEANCTX_QDRANT_URL", old_url);
646 restore_env("LEANCTX_PGVECTOR_URL", old_pg);
647 }
648
649 #[cfg(not(feature = "qdrant"))]
650 #[test]
651 fn dense_backend_qdrant_requires_feature() {
652 let _g = crate::core::data_dir::test_env_lock();
653 let old_backend = set_env("LEANCTX_DENSE_BACKEND", Some("qdrant"));
654 let old_url = set_env("LEANCTX_QDRANT_URL", None);
655
656 let err = DenseBackendKind::try_from_env().unwrap_err();
657 assert!(err.contains("feature 'qdrant' is not enabled"));
658
659 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
660 restore_env("LEANCTX_QDRANT_URL", old_url);
661 }
662
663 #[cfg(not(feature = "pgvector"))]
664 #[test]
665 fn dense_backend_pgvector_requires_feature() {
666 let _g = crate::core::data_dir::test_env_lock();
667 let old_backend = set_env("LEANCTX_DENSE_BACKEND", Some("pgvector"));
668 let old_url = set_env("LEANCTX_QDRANT_URL", None);
669
670 let err = DenseBackendKind::try_from_env().unwrap_err();
671 assert!(err.contains("feature 'pgvector' is not enabled"));
672
673 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
674 restore_env("LEANCTX_QDRANT_URL", old_url);
675 }
676}