1use std::collections::HashSet;
2use std::path::Path;
3
4use crate::core::bm25_index::{BM25Index, format_search_results};
5use crate::core::embedding_index::EmbeddingIndex;
6#[cfg(feature = "embeddings")]
7use crate::core::embeddings::EmbeddingEngine;
8use crate::core::hybrid_search::{HybridConfig, HybridResult, format_hybrid_results};
9use crate::tools::CrpMode;
10
11#[allow(clippy::too_many_arguments)]
13pub fn handle(
14 query: &str,
15 path: &str,
16 top_k: usize,
17 crp_mode: CrpMode,
18 languages: Option<&[String]>,
19 path_glob: Option<&str>,
20 mode: Option<&str>,
21 workspace: Option<bool>,
22 artifacts: Option<bool>,
23) -> String {
24 let root = Path::new(path);
25 if !root.exists() {
26 return format!("ERR: path does not exist: {path}");
27 }
28
29 let root = if root.is_file() {
30 root.parent().unwrap_or(root)
31 } else {
32 root
33 };
34
35 if !query.trim().is_empty()
38 && let Some(mut session) = crate::core::session::SessionState::load_latest()
39 && session.last_semantic_query.as_deref() != Some(query)
40 {
41 session.last_semantic_query = Some(query.to_string());
42 let _ = session.save();
43 }
44
45 let filter = match SearchFilter::new(languages, path_glob) {
46 Ok(f) => f,
47 Err(e) => return format!("ERR: invalid filter: {e}"),
48 };
49
50 let compact = crp_mode.is_tdd();
51 let mode = mode.unwrap_or("hybrid").to_lowercase();
52 let workspace = workspace.unwrap_or(false);
53 let artifacts = artifacts.unwrap_or(false);
54
55 if artifacts {
56 return artifacts_search(query, root, top_k, compact, &filter, workspace);
57 }
58 if workspace {
59 return workspace_search(query, root, top_k, compact, &filter, &mode);
60 }
61
62 let index = match load_or_refresh_bm25(root) {
63 Bm25LoadResult::Ready(idx) => idx,
64 Bm25LoadResult::Building => {
65 return "BM25 index is being built in the background. \
66 Run ctx_semantic_search again in ~30s, or use action=reindex to wait for completion."
67 .to_string();
68 }
69 };
70 if index.doc_count == 0 {
71 return "No code files found to index.".to_string();
72 }
73
74 match mode.as_str() {
75 "bm25" => {
76 let mut results = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
77 if filter.is_active() {
78 results.retain(|x| filter.matches(&x.file_path));
79 }
80 results.truncate(top_k);
81
82 let header = if compact {
83 format!(
84 "semantic_search(bm25,{top_k}) → {} results, {} chunks indexed\n",
85 results.len(),
86 index.doc_count
87 )
88 } else {
89 format!(
90 "Semantic search (BM25): \"{}\" ({} results from {} indexed chunks)\n",
91 truncate_query(query, 60),
92 results.len(),
93 index.doc_count,
94 )
95 };
96 format!("{header}{}", format_search_results(&results, compact))
97 }
98 "dense" => {
99 let out = dense_search_mode(query, root, &index, top_k, compact, &filter);
100 shrink_resident_after_embedding(root, index);
101 out
102 }
103 _ => {
104 let out = hybrid_search_mode(query, root, &index, top_k, compact, &filter);
105 shrink_resident_after_embedding(root, index);
106 out
107 }
108 }
109}
110
111fn shrink_resident_after_embedding(root: &Path, index: std::sync::Arc<BM25Index>) {
121 #[cfg(feature = "embeddings")]
122 {
123 drop(index);
126 if let Some(cache) = get_thread_cache() {
127 let freed = crate::core::bm25_cache::shrink_resident_to_snippet(&cache, root, 5);
128 if freed > 0 {
129 tracing::info!(
130 "[bm25_cache] reclaimed ~{:.1}MB of resident chunk bodies post-embedding",
131 freed as f64 / 1_048_576.0
132 );
133 }
134 }
135 }
136 #[cfg(not(feature = "embeddings"))]
137 {
138 let _ = (root, index);
139 }
140}
141
142pub fn search_hits(
149 query: &str,
150 path: &str,
151 top_k: usize,
152 mode: &str,
153 languages: Option<&[String]>,
154 path_glob: Option<&str>,
155) -> Result<Vec<HybridResult>, String> {
156 let root = Path::new(path);
157 if !root.exists() {
158 return Err(format!("path does not exist: {path}"));
159 }
160 let root = if root.is_file() {
161 root.parent().unwrap_or(root)
162 } else {
163 root
164 };
165
166 let filter =
167 SearchFilter::new(languages, path_glob).map_err(|e| format!("invalid filter: {e}"))?;
168
169 let index = BM25Index::load_or_build(root);
170 if index.doc_count == 0 {
171 return Ok(Vec::new());
172 }
173
174 let results = match mode.to_lowercase().as_str() {
175 "bm25" => bm25_hits(&index, query, top_k, &filter),
176 "dense" => {
177 #[cfg(feature = "embeddings")]
178 {
179 dense_results_for_root(query, root, &index, top_k, &filter).map(|(v, _)| v)?
180 }
181 #[cfg(not(feature = "embeddings"))]
182 {
183 return Err("dense mode requires the embeddings feature".to_string());
184 }
185 }
186 _ => {
187 #[cfg(feature = "embeddings")]
188 {
189 hybrid_results_for_root(query, root, &index, top_k, &filter).map(|(v, _)| v)?
190 }
191 #[cfg(not(feature = "embeddings"))]
192 {
193 bm25_hits(&index, query, top_k, &filter)
194 }
195 }
196 };
197
198 Ok(results)
199}
200
201fn bm25_hits(
202 index: &BM25Index,
203 query: &str,
204 top_k: usize,
205 filter: &SearchFilter,
206) -> Vec<HybridResult> {
207 let mut results = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
208 if filter.is_active() {
209 results.retain(|x| filter.matches(&x.file_path));
210 }
211 results.truncate(top_k);
212 results
213 .into_iter()
214 .map(HybridResult::from_bm25_public)
215 .collect()
216}
217
218pub fn handle_reindex(path: &str) -> String {
220 let root = Path::new(path);
221 if !root.exists() {
222 return format!("ERR: path does not exist: {path}");
223 }
224 let root = if root.is_file() {
225 root.parent().unwrap_or(root)
226 } else {
227 root
228 };
229
230 let idx = BM25Index::build_from_directory(root);
231 let files = idx.files.len();
232 let chunks = idx.doc_count;
233 let _ = idx.save(root);
234
235 format!("Reindexed {path}: {files} files, {chunks} chunks")
236}
237
238pub fn handle_reindex_artifacts(path: &str, workspace: bool) -> String {
239 let root = Path::new(path);
240 if !root.exists() {
241 return format!("ERR: path does not exist: {path}");
242 }
243 let root = if root.is_file() {
244 root.parent().unwrap_or(root)
245 } else {
246 root
247 };
248
249 let mut roots: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
250 let mut warnings: Vec<String> = Vec::new();
251
252 if workspace {
253 let linked = crate::core::workspace_config::load_linked_projects(root);
254 warnings.extend(linked.warnings);
255 roots.extend(linked.roots);
256 }
257
258 let mut total_files = 0usize;
259 let mut total_chunks = 0usize;
260 for r in roots {
261 let (idx, w) = crate::core::artifact_index::rebuild_from_scratch(&r);
262 warnings.extend(w);
263 total_files += idx.files.len();
264 total_chunks += idx.doc_count;
265 }
266
267 if warnings.is_empty() {
268 format!("Reindexed artifacts: {total_files} files, {total_chunks} chunks")
269 } else {
270 format!(
271 "Reindexed artifacts: {total_files} files, {total_chunks} chunks ({} warning(s))",
272 warnings.len()
273 )
274 }
275}
276
277pub fn handle_find_related(
282 file_path: &str,
283 line: usize,
284 project_root: &str,
285 top_k: usize,
286 crp_mode: CrpMode,
287) -> String {
288 let root = Path::new(project_root);
289 if !root.exists() {
290 return format!("ERR: path does not exist: {project_root}");
291 }
292
293 let index = BM25Index::load_or_build(root);
294 if index.doc_count == 0 {
295 return "ERR: empty index. Try action=reindex first.".to_string();
296 }
297
298 let source_chunk = index
299 .chunks
300 .iter()
301 .find(|c| c.file_path == file_path && c.start_line <= line && c.end_line >= line);
302
303 let Some(source_chunk) = source_chunk else {
304 return format!(
305 "ERR: no indexed chunk found at {file_path}:{line}. Try action=reindex first."
306 );
307 };
308
309 let query_text = source_chunk.content.clone();
310 let source_file = source_chunk.file_path.clone();
311 let source_start = source_chunk.start_line;
312
313 let compact = crp_mode != CrpMode::Off;
314
315 let results = find_related_internal(&query_text, root, &index, top_k + 5, compact);
316
317 let mut lines: Vec<String> = results
318 .into_iter()
319 .filter(|l| !l.contains(&format!("{source_file}:{source_start}-")))
320 .take(top_k)
321 .collect();
322
323 let header = if compact {
324 format!(
325 "find_related({file_path}:{line}) → {} results\n",
326 lines.len()
327 )
328 } else {
329 format!("Find related to {file_path}:{line} (semantic similarity)\n")
330 };
331
332 lines.insert(0, header);
333 lines.join("")
334}
335
336fn find_related_internal(
337 query: &str,
338 root: &Path,
339 index: &BM25Index,
340 top_k: usize,
341 compact: bool,
342) -> Vec<String> {
343 let Ok(filter) = SearchFilter::new(None, None) else {
344 return vec!["ERR: filter init failed\n".to_string()];
345 };
346 let output = hybrid_search_mode(query, root, index, top_k, compact, &filter);
347 output.lines().map(|l| format!("{l}\n")).collect()
348}
349
350fn truncate_query(q: &str, max: usize) -> &str {
351 if q.len() <= max {
352 return q;
353 }
354 match q.char_indices().nth(max) {
355 Some((byte_idx, _)) => &q[..byte_idx],
356 None => q,
357 }
358}
359
360std::thread_local! {
361 static BM25_SHARED_CACHE: std::cell::RefCell<Option<crate::core::bm25_cache::SharedBm25Cache>> =
362 const { std::cell::RefCell::new(None) };
363}
364
365pub fn set_thread_cache(cache: crate::core::bm25_cache::SharedBm25Cache) {
367 BM25_SHARED_CACHE.with(|c| {
368 *c.borrow_mut() = Some(cache);
369 });
370}
371
372pub fn get_thread_cache() -> Option<crate::core::bm25_cache::SharedBm25Cache> {
376 BM25_SHARED_CACHE.with(|c| c.borrow().clone())
377}
378
379pub(crate) enum Bm25LoadResult {
381 Ready(std::sync::Arc<BM25Index>),
382 Building,
383}
384
385fn load_or_refresh_bm25(root: &Path) -> Bm25LoadResult {
386 let cached = BM25_SHARED_CACHE.with(|c| {
387 let borrow = c.borrow();
388 borrow
389 .as_ref()
390 .and_then(|cache| crate::core::bm25_cache::get_or_background(cache, root))
391 });
392 if let Some(idx) = cached {
393 return Bm25LoadResult::Ready(idx);
394 }
395
396 let root_str = root.to_string_lossy().to_string();
397
398 if let Some(idx) = crate::core::index_orchestrator::try_load_bm25_index(&root_str) {
399 let idx = std::sync::Arc::new(idx);
400 store_in_thread_cache(root, &idx);
401 return Bm25LoadResult::Ready(idx);
402 }
403
404 if crate::core::index_orchestrator::is_building() {
405 return Bm25LoadResult::Building;
406 }
407
408 crate::core::index_orchestrator::ensure_all_background(&root_str);
414
415 let deadline = std::time::Instant::now() + bm25_cold_build_budget();
416 loop {
417 if let Some(idx) = crate::core::index_orchestrator::try_load_bm25_index(&root_str) {
418 let idx = std::sync::Arc::new(idx);
419 store_in_thread_cache(root, &idx);
420 return Bm25LoadResult::Ready(idx);
421 }
422 if std::time::Instant::now() >= deadline {
423 return Bm25LoadResult::Building;
424 }
425 std::thread::sleep(std::time::Duration::from_millis(50));
426 }
427}
428
429fn bm25_cold_build_budget() -> std::time::Duration {
432 let ms = std::env::var("LEAN_CTX_BM25_COLD_BUDGET_MS")
433 .ok()
434 .and_then(|v| v.parse::<u64>().ok())
435 .unwrap_or(3000);
436 std::time::Duration::from_millis(ms)
437}
438
439fn store_in_thread_cache(root: &Path, idx: &std::sync::Arc<BM25Index>) {
440 BM25_SHARED_CACHE.with(|c| {
441 let borrow = c.borrow();
442 if let Some(cache) = borrow.as_ref() {
443 let mut guard = cache
444 .lock()
445 .unwrap_or_else(std::sync::PoisonError::into_inner);
446 *guard = Some(crate::core::bm25_cache::Bm25CacheEntry {
447 root: root.to_path_buf(),
448 index: std::sync::Arc::clone(idx),
449 loaded_at: std::time::Instant::now(),
450 fingerprint: crate::core::bm25_cache::index_fingerprint(root),
451 });
452 }
453 });
454}
455
456fn filtered_candidate_k(top_k: usize, filtered: bool) -> usize {
457 if !filtered {
458 return top_k;
459 }
460 let candidates = (top_k.max(10)).saturating_mul(10);
461 candidates.clamp(50, 500)
462}
463
464const WORKSPACE_RRF_K: f64 = 60.0;
465
466fn artifacts_search(
467 query: &str,
468 root: &Path,
469 top_k: usize,
470 compact: bool,
471 filter: &SearchFilter,
472 workspace: bool,
473) -> String {
474 let mut roots: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
475 let mut warnings: Vec<String> = Vec::new();
476
477 if workspace {
478 let linked = crate::core::workspace_config::load_linked_projects(root);
479 warnings.extend(linked.warnings);
480 roots.extend(linked.roots);
481 }
482 roots.sort();
483 roots.dedup();
484
485 let mut per_project: Vec<(String, Vec<crate::core::bm25_index::SearchResult>)> = Vec::new();
486 let mut total_chunks = 0usize;
487
488 for r in &roots {
489 let label = label_for_root(r);
490 let (idx, w) = crate::core::artifact_index::load_or_build(r);
491 warnings.extend(w);
492 total_chunks += idx.doc_count;
493 if idx.doc_count == 0 {
494 continue;
495 }
496
497 let mut results = idx.search(query, filtered_candidate_k(top_k, filter.is_active()));
498 if filter.is_active() {
499 results.retain(|x| filter.matches(&x.file_path));
500 }
501 results.truncate(top_k);
502
503 for res in &mut results {
504 res.file_path = if workspace {
505 format!("[project:{label}] [artifact] {}", res.file_path)
506 } else {
507 format!("[artifact] {}", res.file_path)
508 };
509 }
510
511 per_project.push((label, results));
512 }
513
514 let mut fused: Vec<crate::core::bm25_index::SearchResult> = if per_project.len() <= 1 {
515 per_project
516 .into_iter()
517 .next()
518 .map(|(_, v)| v)
519 .unwrap_or_default()
520 } else {
521 rrf_merge_bm25(per_project, top_k)
522 };
523
524 if fused.is_empty() {
525 return "No artifact files found to index.".to_string();
526 }
527
528 fused.truncate(top_k);
529
530 let header = if compact {
531 if workspace {
532 format!(
533 "semantic_search(artifacts,workspace,{top_k}) → {} results, projects={}, {} chunks indexed\n",
534 fused.len(),
535 roots.len(),
536 total_chunks
537 )
538 } else {
539 format!(
540 "semantic_search(artifacts,{top_k}) → {} results, {} chunks indexed\n",
541 fused.len(),
542 total_chunks
543 )
544 }
545 } else if workspace {
546 format!(
547 "Semantic search (Artifacts/Workspace): \"{}\" ({} results from {} projects)\n",
548 truncate_query(query, 60),
549 fused.len(),
550 roots.len()
551 )
552 } else {
553 format!(
554 "Semantic search (Artifacts): \"{}\" ({} results)\n",
555 truncate_query(query, 60),
556 fused.len()
557 )
558 };
559
560 let mut out = format!("{header}{}", format_search_results(&fused, compact));
561 if !warnings.is_empty() && !compact {
562 out.push_str(&format!("\nWarnings ({}):\n", warnings.len()));
563 for w in warnings.iter().take(20) {
564 out.push_str(&format!("- {w}\n"));
565 }
566 }
567 out
568}
569
570fn workspace_search(
571 query: &str,
572 root: &Path,
573 top_k: usize,
574 compact: bool,
575 filter: &SearchFilter,
576 mode: &str,
577) -> String {
578 let linked = crate::core::workspace_config::load_linked_projects(root);
579 let mut warnings = linked.warnings;
580
581 let mut roots: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
582 roots.extend(linked.roots);
583 roots.sort();
584 roots.dedup();
585
586 let mut per_project: Vec<(String, Vec<HybridResult>)> = Vec::new();
587 let mut avg_cov: Option<f64> = None;
588 let mut cov_count = 0usize;
589
590 for r in &roots {
591 let label = label_for_root(r);
592 let index = BM25Index::load_or_build(r);
593 if index.doc_count == 0 {
594 continue;
595 }
596
597 let mut results: Vec<HybridResult> = match mode {
598 "bm25" => {
599 let mut bm25 = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
600 if filter.is_active() {
601 bm25.retain(|x| filter.matches(&x.file_path));
602 }
603 bm25.truncate(top_k);
604 bm25.into_iter()
605 .map(HybridResult::from_bm25_public)
606 .collect()
607 }
608 "dense" => {
609 #[cfg(feature = "embeddings")]
610 {
611 match dense_results_for_root(query, r, &index, top_k, filter) {
612 Ok((v, cov)) => {
613 avg_cov = Some(avg_cov.unwrap_or(0.0) + cov);
614 cov_count += 1;
615 v
616 }
617 Err(e) => {
618 warnings.push(format!("[{label}] dense search failed: {e}"));
619 let mut bm25 = index
620 .search(query, filtered_candidate_k(top_k, filter.is_active()));
621 if filter.is_active() {
622 bm25.retain(|x| filter.matches(&x.file_path));
623 }
624 bm25.truncate(top_k);
625 bm25.into_iter()
626 .map(HybridResult::from_bm25_public)
627 .collect()
628 }
629 }
630 }
631 #[cfg(not(feature = "embeddings"))]
632 {
633 let _ = (&label, &warnings);
634 let mut bm25 =
635 index.search(query, filtered_candidate_k(top_k, filter.is_active()));
636 if filter.is_active() {
637 bm25.retain(|x| filter.matches(&x.file_path));
638 }
639 bm25.truncate(top_k);
640 bm25.into_iter()
641 .map(HybridResult::from_bm25_public)
642 .collect()
643 }
644 }
645 _ => {
646 #[cfg(feature = "embeddings")]
647 {
648 match hybrid_results_for_root(query, r, &index, top_k, filter) {
649 Ok((v, cov)) => {
650 avg_cov = Some(avg_cov.unwrap_or(0.0) + cov);
651 cov_count += 1;
652 v
653 }
654 Err(e) => {
655 warnings.push(format!("[{label}] hybrid search failed: {e}"));
656 let mut bm25 = index
657 .search(query, filtered_candidate_k(top_k, filter.is_active()));
658 if filter.is_active() {
659 bm25.retain(|x| filter.matches(&x.file_path));
660 }
661 bm25.truncate(top_k);
662 bm25.into_iter()
663 .map(HybridResult::from_bm25_public)
664 .collect()
665 }
666 }
667 }
668 #[cfg(not(feature = "embeddings"))]
669 {
670 let _ = (&label, &warnings);
671 let mut bm25 =
672 index.search(query, filtered_candidate_k(top_k, filter.is_active()));
673 if filter.is_active() {
674 bm25.retain(|x| filter.matches(&x.file_path));
675 }
676 bm25.truncate(top_k);
677 bm25.into_iter()
678 .map(HybridResult::from_bm25_public)
679 .collect()
680 }
681 }
682 };
683
684 for res in &mut results {
685 res.file_path = format!("[project:{label}] {}", res.file_path);
686 }
687 per_project.push((label, results));
688 }
689
690 let mut fused: Vec<HybridResult> = if per_project.len() <= 1 {
691 per_project
692 .into_iter()
693 .next()
694 .map(|(_, v)| v)
695 .unwrap_or_default()
696 } else {
697 rrf_merge_hybrid(per_project, top_k)
698 };
699
700 if fused.is_empty() {
701 return "No code files found to index.".to_string();
702 }
703
704 fused.truncate(top_k);
705 let cov = avg_cov.and_then(|s| {
706 if cov_count == 0 {
707 None
708 } else {
709 Some(s / cov_count as f64)
710 }
711 });
712
713 let header = if compact {
714 match (mode, cov) {
715 (_, Some(c)) => format!(
716 "semantic_search(workspace,{mode},{top_k}) → {} results, projects={}, embed_cov={:.0}%\n",
717 fused.len(),
718 roots.len(),
719 c * 100.0
720 ),
721 _ => format!(
722 "semantic_search(workspace,{mode},{top_k}) → {} results, projects={}\n",
723 fused.len(),
724 roots.len()
725 ),
726 }
727 } else {
728 format!(
729 "Workspace semantic search ({mode}): \"{}\" ({} results from {} projects)\n",
730 truncate_query(query, 60),
731 fused.len(),
732 roots.len()
733 )
734 };
735
736 let mut out = format!("{header}{}", format_hybrid_results(&fused, compact));
737 if !warnings.is_empty() && !compact {
738 out.push_str(&format!("\nWarnings ({}):\n", warnings.len()));
739 for w in warnings.iter().take(20) {
740 out.push_str(&format!("- {w}\n"));
741 }
742 }
743 out
744}
745
746fn rrf_merge_hybrid(lists: Vec<(String, Vec<HybridResult>)>, top_k: usize) -> Vec<HybridResult> {
747 use std::collections::HashMap;
748
749 let mut acc: HashMap<String, (HybridResult, f64)> = HashMap::new();
750 for (label, results) in lists {
751 for (rank, r) in results.into_iter().enumerate() {
752 let key = format!(
753 "{label}|{}|{}|{}|{}",
754 r.file_path, r.symbol_name, r.start_line, r.end_line
755 );
756 let rrf = 1.0 / (WORKSPACE_RRF_K + (rank as f64) + 1.0);
757 acc.entry(key)
758 .and_modify(|(_, s)| *s += rrf)
759 .or_insert((r, rrf));
760 }
761 }
762
763 let mut out: Vec<HybridResult> = acc
764 .into_values()
765 .map(|(mut r, s)| {
766 r.rrf_score = s;
767 r
768 })
769 .collect();
770 out.sort_by(|a, b| {
771 b.rrf_score
772 .partial_cmp(&a.rrf_score)
773 .unwrap_or(std::cmp::Ordering::Equal)
774 .then_with(|| a.file_path.cmp(&b.file_path))
775 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
776 .then_with(|| a.start_line.cmp(&b.start_line))
777 .then_with(|| a.end_line.cmp(&b.end_line))
778 });
779 out.truncate(top_k);
780 out
781}
782
783fn rrf_merge_bm25(
784 lists: Vec<(String, Vec<crate::core::bm25_index::SearchResult>)>,
785 top_k: usize,
786) -> Vec<crate::core::bm25_index::SearchResult> {
787 use std::collections::HashMap;
788
789 let mut acc: HashMap<String, (crate::core::bm25_index::SearchResult, f64)> = HashMap::new();
790 for (label, results) in lists {
791 for (rank, r) in results.into_iter().enumerate() {
792 let key = format!(
793 "{label}|{}|{}|{}|{}",
794 r.file_path, r.symbol_name, r.start_line, r.end_line
795 );
796 let rrf = 1.0 / (WORKSPACE_RRF_K + (rank as f64) + 1.0);
797 acc.entry(key)
798 .and_modify(|(_, s)| *s += rrf)
799 .or_insert((r, rrf));
800 }
801 }
802
803 let mut out: Vec<crate::core::bm25_index::SearchResult> = acc
804 .into_values()
805 .map(|(mut r, s)| {
806 r.score = s;
807 r
808 })
809 .collect();
810 out.sort_by(|a, b| {
811 b.score
812 .partial_cmp(&a.score)
813 .unwrap_or(std::cmp::Ordering::Equal)
814 .then_with(|| a.file_path.cmp(&b.file_path))
815 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
816 .then_with(|| a.start_line.cmp(&b.start_line))
817 .then_with(|| a.end_line.cmp(&b.end_line))
818 });
819 out.truncate(top_k);
820 out
821}
822
823#[cfg(feature = "embeddings")]
824fn dense_results_for_root(
825 query: &str,
826 root: &Path,
827 index: &BM25Index,
828 top_k: usize,
829 filter: &SearchFilter,
830) -> Result<(Vec<HybridResult>, f64), String> {
831 let (engine, mut embed_idx) = load_engine_and_index(root)?;
832 let (aligned, coverage, changed_files) =
833 ensure_embeddings(root, index, engine, &mut embed_idx)?;
834
835 let backend = crate::core::dense_backend::DenseBackendKind::try_from_env()?;
836 let filter_fn = |p: &str| filter.matches(p);
837 let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
838 .is_active()
839 .then_some(&filter_fn as &dyn Fn(&str) -> bool);
840
841 let candidate_k = filtered_candidate_k(top_k, filter.is_active());
842 let mut results = crate::core::dense_backend::dense_results_as_hybrid(
843 backend,
844 root,
845 index,
846 engine,
847 &aligned,
848 &changed_files,
849 query,
850 candidate_k,
851 filter_pred,
852 )?;
853 results.truncate(top_k);
854
855 Ok((results, coverage))
856}
857
858#[cfg(feature = "embeddings")]
859fn hybrid_results_for_root(
860 query: &str,
861 root: &Path,
862 index: &BM25Index,
863 top_k: usize,
864 filter: &SearchFilter,
865) -> Result<(Vec<HybridResult>, f64), String> {
866 let (engine, mut embed_idx) = load_engine_and_index(root)?;
867 let (aligned, coverage, changed_files) =
868 ensure_embeddings(root, index, engine, &mut embed_idx)?;
869
870 let backend = crate::core::dense_backend::DenseBackendKind::try_from_env()?;
871 let cfg = HybridConfig::from_config();
872 let filter_fn = |p: &str| filter.matches(p);
873 let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
874 .is_active()
875 .then_some(&filter_fn as &dyn Fn(&str) -> bool);
876 let candidate_k = filtered_candidate_k(top_k, filter.is_active());
877 let graph_ranks = graph_rrf_ranks_for_search_root(root);
878 let graph_ranks_ref = graph_ranks.as_ref();
879 let mut results = crate::core::dense_backend::hybrid_results(
880 backend,
881 root,
882 index,
883 engine,
884 &aligned,
885 &changed_files,
886 query,
887 candidate_k,
888 &cfg,
889 filter_pred,
890 graph_ranks_ref,
891 )?;
892
893 if cfg.splade_weight > 0.0 {
894 let splade = crate::core::splade_retrieval::hybrid_retrieve(query, index, candidate_k);
895 if !splade.is_empty() {
896 boost_with_splade(&mut results, &splade, cfg.splade_weight);
897 }
898 }
899
900 results.truncate(top_k);
901 Ok((results, coverage))
902}
903
904fn boost_with_splade(
906 results: &mut [HybridResult],
907 splade: &[crate::core::splade_retrieval::SpladeResult],
908 weight: f64,
909) {
910 use std::collections::HashMap;
911 let rrf_k = 60.0_f64;
912
913 let boosts: HashMap<&str, f64> = splade
914 .iter()
915 .enumerate()
916 .map(|(rank, sr)| (sr.file_path.as_str(), weight / (rrf_k + rank as f64 + 1.0)))
917 .collect();
918
919 for r in results.iter_mut() {
920 if let Some(&boost) = boosts.get(r.file_path.as_str()) {
921 r.rrf_score += boost;
922 }
923 }
924
925 results.sort_by(|a, b| {
926 b.rrf_score
927 .partial_cmp(&a.rrf_score)
928 .unwrap_or(std::cmp::Ordering::Equal)
929 });
930}
931
932fn label_for_root(root: &Path) -> String {
933 root.file_name()
934 .and_then(|s| s.to_str())
935 .map(str::to_string)
936 .filter(|s| !s.is_empty())
937 .unwrap_or_else(|| root.to_string_lossy().to_string())
938}
939
940fn graph_rrf_ranks_for_search_root(
941 root: &Path,
942) -> Option<std::collections::HashMap<String, usize>> {
943 let root_s = root.to_string_lossy().to_string();
944 let session = crate::core::session::SessionState::load_latest_for_project_root(&root_s)?;
945
946 if session.files_touched.is_empty() {
947 return None;
948 }
949
950 let recent: Vec<String> = session
951 .files_touched
952 .iter()
953 .rev()
954 .filter(|f| path_under_search_root(&f.path, root))
955 .take(12)
956 .map(|f| f.path.clone())
957 .collect();
958
959 if recent.is_empty() {
960 return None;
961 }
962
963 crate::core::graph_context::graph_neighbor_ranks_for_recent_files(&root_s, &recent, 40, 120)
964}
965
966fn path_under_search_root(path: &str, root: &Path) -> bool {
967 let p = std::path::Path::new(path);
968 if p.is_absolute() {
969 let root_norm = crate::core::pathutil::safe_canonicalize_or_self(root);
970 let path_norm = crate::core::pathutil::safe_canonicalize_or_self(p);
971 path_norm.starts_with(&root_norm)
972 } else {
973 true
974 }
975}
976
977fn hybrid_search_mode(
978 query: &str,
979 root: &Path,
980 index: &BM25Index,
981 top_k: usize,
982 compact: bool,
983 filter: &SearchFilter,
984) -> String {
985 #[cfg(feature = "embeddings")]
986 {
987 let (engine, mut embed_idx) = match load_engine_and_index(root) {
988 Ok(v) => v,
989 Err(e) => return format!("ERR: {e}"),
990 };
991
992 let (aligned, coverage, changed_files) =
993 match ensure_embeddings(root, index, engine, &mut embed_idx) {
994 Ok(v) => v,
995 Err(e) => return format!("ERR: {e}"),
996 };
997
998 let backend = match crate::core::dense_backend::DenseBackendKind::try_from_env() {
999 Ok(v) => v,
1000 Err(e) => return format!("ERR: {e}"),
1001 };
1002
1003 let cfg = HybridConfig::from_config();
1004 let filter_fn = |p: &str| filter.matches(p);
1005 let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
1006 .is_active()
1007 .then_some(&filter_fn as &dyn Fn(&str) -> bool);
1008 let graph_ranks = graph_rrf_ranks_for_search_root(root);
1009 let graph_ranks_ref = graph_ranks.as_ref();
1010 let mut results = match crate::core::dense_backend::hybrid_results(
1011 backend,
1012 root,
1013 index,
1014 engine,
1015 &aligned,
1016 &changed_files,
1017 query,
1018 top_k,
1019 &cfg,
1020 filter_pred,
1021 graph_ranks_ref,
1022 ) {
1023 Ok(v) => v,
1024 Err(e) => return format!("ERR: {e}"),
1025 };
1026
1027 if cfg.splade_weight > 0.0 {
1028 let splade = crate::core::splade_retrieval::hybrid_retrieve(query, index, top_k);
1029 if !splade.is_empty() {
1030 boost_with_splade(&mut results, &splade, cfg.splade_weight);
1031 }
1032 }
1033
1034 results.truncate(top_k);
1035
1036 let header = if compact {
1037 format!(
1038 "semantic_search(hybrid,{top_k}) → {} results, {} chunks, embed_cov={:.0}%\n",
1039 results.len(),
1040 index.doc_count,
1041 coverage * 100.0
1042 )
1043 } else {
1044 format!(
1045 "Semantic search (Hybrid): \"{}\" ({} results from {} indexed chunks, embeddings coverage {:.0}%)\n",
1046 truncate_query(query, 60),
1047 results.len(),
1048 index.doc_count,
1049 coverage * 100.0
1050 )
1051 };
1052
1053 format!("{header}{}", format_hybrid_results(&results, compact))
1054 }
1055 #[cfg(not(feature = "embeddings"))]
1056 {
1057 let mut results = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
1058 if filter.is_active() {
1059 results.retain(|x| filter.matches(&x.file_path));
1060 }
1061
1062 if let Some(graph_ranks) = graph_rrf_ranks_for_search_root(root) {
1063 const GRAPH_RRF_K: f64 = 60.0;
1064 for r in &mut results {
1065 if let Some(&rank) = graph_ranks.get(&r.file_path) {
1066 r.score += 1.0 / (GRAPH_RRF_K + rank as f64 + 1.0);
1067 }
1068 }
1069 results.sort_by(|a, b| {
1070 b.score
1071 .partial_cmp(&a.score)
1072 .unwrap_or(std::cmp::Ordering::Equal)
1073 });
1074 }
1075
1076 results.truncate(top_k);
1077 let graph_tag = if graph_rrf_ranks_for_search_root(root).is_some() {
1078 "+graph"
1079 } else {
1080 ""
1081 };
1082 let header = if compact {
1083 format!(
1084 "semantic_search(bm25{graph_tag},{top_k}) → {} results, {} chunks indexed\n",
1085 results.len(),
1086 index.doc_count
1087 )
1088 } else {
1089 format!(
1090 "Semantic search (BM25{graph_tag}): \"{}\" ({} results from {} indexed chunks)\n",
1091 truncate_query(query, 60),
1092 results.len(),
1093 index.doc_count,
1094 )
1095 };
1096 format!("{header}{}", format_search_results(&results, compact))
1097 }
1098}
1099
1100fn dense_search_mode(
1101 query: &str,
1102 root: &Path,
1103 index: &BM25Index,
1104 top_k: usize,
1105 compact: bool,
1106 filter: &SearchFilter,
1107) -> String {
1108 #[cfg(feature = "embeddings")]
1109 {
1110 let (engine, mut embed_idx) = match load_engine_and_index(root) {
1111 Ok(v) => v,
1112 Err(e) => return format!("ERR: {e}"),
1113 };
1114
1115 let (aligned, coverage, changed_files) =
1116 match ensure_embeddings(root, index, engine, &mut embed_idx) {
1117 Ok(v) => v,
1118 Err(e) => return format!("ERR: {e}"),
1119 };
1120
1121 let backend = match crate::core::dense_backend::DenseBackendKind::try_from_env() {
1122 Ok(v) => v,
1123 Err(e) => return format!("ERR: {e}"),
1124 };
1125
1126 let filter_fn = |p: &str| filter.matches(p);
1127 let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
1128 .is_active()
1129 .then_some(&filter_fn as &dyn Fn(&str) -> bool);
1130
1131 let candidate_k = filtered_candidate_k(top_k, filter.is_active());
1132 let mut results = match crate::core::dense_backend::dense_results_as_hybrid(
1133 backend,
1134 root,
1135 index,
1136 engine,
1137 &aligned,
1138 &changed_files,
1139 query,
1140 candidate_k,
1141 filter_pred,
1142 ) {
1143 Ok(v) => v,
1144 Err(e) => return format!("ERR: {e}"),
1145 };
1146 results.truncate(top_k);
1147
1148 let header = if compact {
1149 format!(
1150 "semantic_search(dense,{top_k}) → {} results, {} chunks, embed_cov={:.0}%\n",
1151 results.len(),
1152 index.doc_count,
1153 coverage * 100.0
1154 )
1155 } else {
1156 format!(
1157 "Semantic search (Dense): \"{}\" ({} results from {} indexed chunks, embeddings coverage {:.0}%)\n",
1158 truncate_query(query, 60),
1159 results.len(),
1160 index.doc_count,
1161 coverage * 100.0
1162 )
1163 };
1164
1165 format!("{header}{}", format_hybrid_results(&results, compact))
1166 }
1167 #[cfg(not(feature = "embeddings"))]
1168 {
1169 "ERR: embeddings feature not enabled".to_string()
1170 }
1171}
1172
1173#[cfg(feature = "embeddings")]
1174fn load_engine_and_index(
1175 root: &Path,
1176) -> Result<(&'static EmbeddingEngine, EmbeddingIndex), String> {
1177 let cfg = crate::core::config::Config::load();
1178 let profile = crate::core::config::MemoryProfile::effective(&cfg);
1179 if !profile.embeddings_enabled() {
1180 return Err("embeddings disabled by memory_profile=low".into());
1181 }
1182
1183 let engine = crate::core::embeddings::shared_engine()
1184 .ok_or_else(|| "embedding engine load failed".to_string())?;
1185
1186 let model_name = engine.model_name();
1187 let mut idx = EmbeddingIndex::load(root)
1188 .unwrap_or_else(|| EmbeddingIndex::new_with_model(engine.dimensions(), model_name));
1189
1190 if let Some((stored, current)) = idx.model_mismatch(model_name) {
1191 tracing::warn!(
1192 "[embeddings] model changed: {stored} → {current}. Re-indexing all embeddings."
1193 );
1194 idx = EmbeddingIndex::new_with_model(engine.dimensions(), model_name);
1195 } else if idx.dimension_mismatch(engine.dimensions()) {
1196 tracing::warn!(
1197 "[embeddings] dimension mismatch: index={}d, engine={}d. Re-indexing.",
1198 idx.dimensions,
1199 engine.dimensions()
1200 );
1201 idx = EmbeddingIndex::new_with_model(engine.dimensions(), model_name);
1202 }
1203
1204 if idx.model_id.is_none() {
1205 idx.model_id = Some(model_name.to_string());
1206 }
1207
1208 Ok((engine, idx))
1209}
1210
1211#[cfg(feature = "embeddings")]
1215type AlignedEmbeddings = (std::sync::Arc<[Vec<f32>]>, f64, Vec<String>);
1216
1217#[cfg(feature = "embeddings")]
1218fn ensure_embeddings(
1219 root: &Path,
1220 index: &BM25Index,
1221 engine: &EmbeddingEngine,
1222 embed_idx: &mut EmbeddingIndex,
1223) -> Result<AlignedEmbeddings, String> {
1224 if index.content_truncated {
1234 let aligned = embed_idx
1235 .get_aligned_embeddings(&index.chunks)
1236 .ok_or_else(|| {
1237 "embedding alignment failed on truncated resident index; \
1238 refusing to re-embed snippet-only bodies"
1239 .to_string()
1240 })?;
1241 let coverage = embed_idx.coverage(index.chunks.len());
1242 return Ok((aligned, coverage, Vec::new()));
1243 }
1244
1245 let mut changed_files = embed_idx.files_needing_update(&index.chunks);
1246 changed_files.sort();
1247 changed_files.dedup();
1248
1249 if !changed_files.is_empty() {
1250 let changed_set: std::collections::HashSet<&str> = changed_files
1251 .iter()
1252 .map(std::string::String::as_str)
1253 .collect();
1254 let mut new_embeddings: Vec<(usize, Vec<f32>)> = Vec::new();
1255 for (i, c) in index.chunks.iter().enumerate() {
1256 if !changed_set.contains(c.file_path.as_str()) {
1257 continue;
1258 }
1259 let emb = engine
1260 .embed(&c.content)
1261 .map_err(|e| format!("embed failed for {}: {e}", c.file_path))?;
1262 new_embeddings.push((i, emb));
1263 }
1264 embed_idx.update(&index.chunks, &new_embeddings, &changed_files);
1265 embed_idx
1266 .save(root)
1267 .map_err(|e| format!("save embeddings failed: {e}"))?;
1268 }
1269
1270 if let Some(aligned) = embed_idx.get_aligned_embeddings(&index.chunks) {
1271 let coverage = embed_idx.coverage(index.chunks.len());
1272 return Ok((aligned, coverage, changed_files));
1273 }
1274
1275 let mut all_files: Vec<String> = index.chunks.iter().map(|c| c.file_path.clone()).collect();
1277 all_files.sort();
1278 all_files.dedup();
1279
1280 let mut new_embeddings: Vec<(usize, Vec<f32>)> = Vec::with_capacity(index.chunks.len());
1281 for (i, c) in index.chunks.iter().enumerate() {
1282 let emb = engine
1283 .embed(&c.content)
1284 .map_err(|e| format!("embed failed for {}: {e}", c.file_path))?;
1285 new_embeddings.push((i, emb));
1286 }
1287
1288 embed_idx.update(&index.chunks, &new_embeddings, &all_files);
1289 embed_idx
1290 .save(root)
1291 .map_err(|e| format!("save embeddings failed: {e}"))?;
1292
1293 let aligned = embed_idx
1294 .get_aligned_embeddings(&index.chunks)
1295 .ok_or_else(|| "embedding alignment failed after full rebuild".to_string())?;
1296 let coverage = embed_idx.coverage(index.chunks.len());
1297 Ok((aligned, coverage, all_files))
1298}
1299
1300struct SearchFilter {
1301 allowed_exts: Option<HashSet<String>>,
1302 path_glob: Option<glob::Pattern>,
1303}
1304
1305impl SearchFilter {
1306 fn new(languages: Option<&[String]>, path_glob: Option<&str>) -> Result<Self, String> {
1307 let allowed_exts = languages.map(normalize_languages);
1308 let path_glob = match path_glob {
1309 None => None,
1310 Some(s) if s.trim().is_empty() => None,
1311 Some(s) => Some(glob::Pattern::new(s).map_err(|e| e.msg.to_string())?),
1312 };
1313 Ok(Self {
1314 allowed_exts,
1315 path_glob,
1316 })
1317 }
1318
1319 fn is_active(&self) -> bool {
1320 self.allowed_exts.is_some() || self.path_glob.is_some()
1321 }
1322
1323 fn matches(&self, rel_path: &str) -> bool {
1324 let rel_path = rel_path.replace('\\', "/");
1325 if let Some(p) = &self.path_glob
1326 && !p.matches(&rel_path)
1327 {
1328 return false;
1329 }
1330 if let Some(exts) = &self.allowed_exts {
1331 let ext = Path::new(&rel_path)
1332 .extension()
1333 .and_then(|e| e.to_str())
1334 .unwrap_or("")
1335 .to_lowercase();
1336 if ext.is_empty() || !exts.contains(&ext) {
1337 return false;
1338 }
1339 }
1340 true
1341 }
1342}
1343
1344fn normalize_languages(langs: &[String]) -> HashSet<String> {
1345 let mut out = HashSet::new();
1346 for l in langs {
1347 let raw = l.trim().trim_start_matches('.').to_lowercase();
1348 match raw.as_str() {
1349 "rust" | "rs" => {
1350 out.insert("rs".to_string());
1351 }
1352 "ts" | "typescript" => {
1353 out.insert("ts".to_string());
1354 out.insert("tsx".to_string());
1355 }
1356 "js" | "javascript" => {
1357 out.insert("js".to_string());
1358 out.insert("jsx".to_string());
1359 out.insert("mjs".to_string());
1360 out.insert("cjs".to_string());
1361 }
1362 "py" | "python" => {
1363 out.insert("py".to_string());
1364 }
1365 "go" => {
1366 out.insert("go".to_string());
1367 }
1368 "java" => {
1369 out.insert("java".to_string());
1370 }
1371 "ruby" | "rb" => {
1372 out.insert("rb".to_string());
1373 }
1374 "php" => {
1375 out.insert("php".to_string());
1376 }
1377 "c" => {
1378 out.insert("c".to_string());
1379 out.insert("h".to_string());
1380 }
1381 "cpp" | "c++" | "cc" => {
1382 out.insert("cpp".to_string());
1383 out.insert("hpp".to_string());
1384 out.insert("cc".to_string());
1385 out.insert("hh".to_string());
1386 }
1387 "cs" | "csharp" => {
1388 out.insert("cs".to_string());
1389 }
1390 "swift" => {
1391 out.insert("swift".to_string());
1392 }
1393 "kt" | "kotlin" => {
1394 out.insert("kt".to_string());
1395 out.insert("kts".to_string());
1396 }
1397 "json" => {
1398 out.insert("json".to_string());
1399 }
1400 "yaml" | "yml" => {
1401 out.insert("yaml".to_string());
1402 out.insert("yml".to_string());
1403 }
1404 other if !other.is_empty() => {
1405 out.insert(other.to_string());
1406 }
1407 _ => {}
1408 }
1409 }
1410 out
1411}
1412
1413#[cfg(feature = "embeddings")]
1415pub fn load_engine_and_index_pub(
1416 root: &Path,
1417) -> Result<(&'static EmbeddingEngine, EmbeddingIndex), String> {
1418 load_engine_and_index(root)
1419}
1420
1421#[cfg(feature = "embeddings")]
1423pub fn ensure_embeddings_for_eval(
1424 root: &Path,
1425 index: &BM25Index,
1426 engine: &EmbeddingEngine,
1427 embed_idx: &mut EmbeddingIndex,
1428) -> Result<AlignedEmbeddings, String> {
1429 ensure_embeddings(root, index, engine, embed_idx)
1430}
1431
1432pub fn boost_with_splade_pub(
1434 results: &mut [HybridResult],
1435 splade: &[crate::core::splade_retrieval::SpladeResult],
1436 weight: f64,
1437) {
1438 boost_with_splade(results, splade, weight);
1439}
1440
1441#[cfg(test)]
1442mod filter_tests {
1443 use super::*;
1444
1445 #[test]
1446 fn filter_language_rust() {
1447 let f = SearchFilter::new(Some(&["rust".into()]), None).unwrap();
1448 assert!(f.matches("src/main.rs"));
1449 assert!(!f.matches("src/main.ts"));
1450 }
1451
1452 #[test]
1453 fn filter_path_glob() {
1454 let f = SearchFilter::new(None, Some("rust/src/**")).unwrap();
1455 assert!(f.matches("rust/src/core/mod.rs"));
1456 assert!(!f.matches("website/src/pages/index.astro"));
1457 }
1458}
1459
1460#[cfg(test)]
1461mod determinism_tests {
1462 use super::*;
1463
1464 #[test]
1465 fn rrf_merge_hybrid_is_deterministic_on_ties() {
1466 let a = HybridResult {
1467 file_path: "a.rs".to_string(),
1468 symbol_name: "foo".to_string(),
1469 kind: crate::core::bm25_index::ChunkKind::Function,
1470 start_line: 1,
1471 end_line: 1,
1472 snippet: "a".to_string(),
1473 rrf_score: 0.0,
1474 bm25_score: None,
1475 dense_score: None,
1476 bm25_rank: None,
1477 dense_rank: None,
1478 };
1479 let b = HybridResult {
1480 file_path: "b.rs".to_string(),
1481 symbol_name: "foo".to_string(),
1482 kind: crate::core::bm25_index::ChunkKind::Function,
1483 start_line: 1,
1484 end_line: 1,
1485 snippet: "b".to_string(),
1486 rrf_score: 0.0,
1487 bm25_score: None,
1488 dense_score: None,
1489 bm25_rank: None,
1490 dense_rank: None,
1491 };
1492
1493 let fused = rrf_merge_hybrid(
1495 vec![
1496 ("root".to_string(), vec![a.clone(), b.clone()]),
1497 ("root".to_string(), vec![b.clone(), a.clone()]),
1498 ],
1499 10,
1500 );
1501
1502 assert_eq!(fused.len(), 2);
1503 assert_eq!(fused[0].file_path, "a.rs");
1504 assert_eq!(fused[1].file_path, "b.rs");
1505 }
1506}