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