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
977#[cfg(feature = "embeddings")]
985fn bm25_graph_search(
986 query: &str,
987 root: &Path,
988 index: &BM25Index,
989 top_k: usize,
990 compact: bool,
991 filter: &SearchFilter,
992 cfg: &HybridConfig,
993) -> String {
994 let graph_ranks = graph_rrf_ranks_for_search_root(root);
995 let graph_enhances = graph_ranks.as_ref().is_some_and(|m| !m.is_empty());
996
997 let mut results = crate::core::hybrid_search::hybrid_search(
998 query,
999 index,
1000 None,
1001 None,
1002 top_k,
1003 cfg,
1004 graph_ranks.as_ref(),
1005 );
1006 if filter.is_active() {
1007 results.retain(|r| filter.matches(&r.file_path));
1008 }
1009 results.truncate(top_k);
1010
1011 if cfg.splade_weight > 0.0 {
1012 let splade = crate::core::splade_retrieval::hybrid_retrieve(query, index, top_k);
1013 if !splade.is_empty() {
1014 boost_with_splade(&mut results, &splade, cfg.splade_weight);
1015 }
1016 }
1017 results.truncate(top_k);
1018
1019 let graph_tag = if graph_enhances { "+graph" } else { "" };
1020 let header = if compact {
1021 format!(
1022 "semantic_search(bm25{graph_tag},{top_k}) → {} results, {} chunks indexed\n",
1023 results.len(),
1024 index.doc_count
1025 )
1026 } else {
1027 format!(
1028 "Semantic search (BM25{graph_tag}): \"{}\" ({} results from {} indexed chunks)\n",
1029 truncate_query(query, 60),
1030 results.len(),
1031 index.doc_count,
1032 )
1033 };
1034 format!("{header}{}", format_hybrid_results(&results, compact))
1035}
1036
1037fn hybrid_search_mode(
1038 query: &str,
1039 root: &Path,
1040 index: &BM25Index,
1041 top_k: usize,
1042 compact: bool,
1043 filter: &SearchFilter,
1044) -> String {
1045 #[cfg(feature = "embeddings")]
1046 {
1047 let cfg = HybridConfig::from_config();
1048
1049 if !cfg.dense_enabled {
1054 return bm25_graph_search(query, root, index, top_k, compact, filter, &cfg);
1055 }
1056
1057 let (engine, mut embed_idx) = match load_engine_and_index(root) {
1058 Ok(v) => v,
1059 Err(e) => return format!("ERR: {e}"),
1060 };
1061
1062 let (aligned, coverage, changed_files) =
1063 match ensure_embeddings(root, index, engine, &mut embed_idx) {
1064 Ok(v) => v,
1065 Err(e) => return format!("ERR: {e}"),
1066 };
1067
1068 let backend = match crate::core::dense_backend::DenseBackendKind::try_from_env() {
1069 Ok(v) => v,
1070 Err(e) => return format!("ERR: {e}"),
1071 };
1072 let filter_fn = |p: &str| filter.matches(p);
1073 let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
1074 .is_active()
1075 .then_some(&filter_fn as &dyn Fn(&str) -> bool);
1076 let graph_ranks = graph_rrf_ranks_for_search_root(root);
1077 let graph_ranks_ref = graph_ranks.as_ref();
1078 let mut results = match crate::core::dense_backend::hybrid_results(
1079 backend,
1080 root,
1081 index,
1082 engine,
1083 &aligned,
1084 &changed_files,
1085 query,
1086 top_k,
1087 &cfg,
1088 filter_pred,
1089 graph_ranks_ref,
1090 ) {
1091 Ok(v) => v,
1092 Err(e) => return format!("ERR: {e}"),
1093 };
1094
1095 if cfg.splade_weight > 0.0 {
1096 let splade = crate::core::splade_retrieval::hybrid_retrieve(query, index, top_k);
1097 if !splade.is_empty() {
1098 boost_with_splade(&mut results, &splade, cfg.splade_weight);
1099 }
1100 }
1101
1102 results.truncate(top_k);
1103
1104 let header = if compact {
1105 format!(
1106 "semantic_search(hybrid,{top_k}) → {} results, {} chunks, embed_cov={:.0}%\n",
1107 results.len(),
1108 index.doc_count,
1109 coverage * 100.0
1110 )
1111 } else {
1112 format!(
1113 "Semantic search (Hybrid): \"{}\" ({} results from {} indexed chunks, embeddings coverage {:.0}%)\n",
1114 truncate_query(query, 60),
1115 results.len(),
1116 index.doc_count,
1117 coverage * 100.0
1118 )
1119 };
1120
1121 format!("{header}{}", format_hybrid_results(&results, compact))
1122 }
1123 #[cfg(not(feature = "embeddings"))]
1124 {
1125 let mut results = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
1126 if filter.is_active() {
1127 results.retain(|x| filter.matches(&x.file_path));
1128 }
1129
1130 if let Some(graph_ranks) = graph_rrf_ranks_for_search_root(root) {
1131 const GRAPH_RRF_K: f64 = 60.0;
1132 for r in &mut results {
1133 if let Some(&rank) = graph_ranks.get(&r.file_path) {
1134 r.score += 1.0 / (GRAPH_RRF_K + rank as f64 + 1.0);
1135 }
1136 }
1137 results.sort_by(|a, b| {
1138 b.score
1139 .partial_cmp(&a.score)
1140 .unwrap_or(std::cmp::Ordering::Equal)
1141 });
1142 }
1143
1144 results.truncate(top_k);
1145 let graph_tag = if graph_rrf_ranks_for_search_root(root).is_some() {
1146 "+graph"
1147 } else {
1148 ""
1149 };
1150 let header = if compact {
1151 format!(
1152 "semantic_search(bm25{graph_tag},{top_k}) → {} results, {} chunks indexed\n",
1153 results.len(),
1154 index.doc_count
1155 )
1156 } else {
1157 format!(
1158 "Semantic search (BM25{graph_tag}): \"{}\" ({} results from {} indexed chunks)\n",
1159 truncate_query(query, 60),
1160 results.len(),
1161 index.doc_count,
1162 )
1163 };
1164 format!("{header}{}", format_search_results(&results, compact))
1165 }
1166}
1167
1168fn dense_search_mode(
1169 query: &str,
1170 root: &Path,
1171 index: &BM25Index,
1172 top_k: usize,
1173 compact: bool,
1174 filter: &SearchFilter,
1175) -> String {
1176 #[cfg(feature = "embeddings")]
1177 {
1178 let (engine, mut embed_idx) = match load_engine_and_index(root) {
1179 Ok(v) => v,
1180 Err(e) => return format!("ERR: {e}"),
1181 };
1182
1183 let (aligned, coverage, changed_files) =
1184 match ensure_embeddings(root, index, engine, &mut embed_idx) {
1185 Ok(v) => v,
1186 Err(e) => return format!("ERR: {e}"),
1187 };
1188
1189 let backend = match crate::core::dense_backend::DenseBackendKind::try_from_env() {
1190 Ok(v) => v,
1191 Err(e) => return format!("ERR: {e}"),
1192 };
1193
1194 let filter_fn = |p: &str| filter.matches(p);
1195 let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
1196 .is_active()
1197 .then_some(&filter_fn as &dyn Fn(&str) -> bool);
1198
1199 let candidate_k = filtered_candidate_k(top_k, filter.is_active());
1200 let mut results = match crate::core::dense_backend::dense_results_as_hybrid(
1201 backend,
1202 root,
1203 index,
1204 engine,
1205 &aligned,
1206 &changed_files,
1207 query,
1208 candidate_k,
1209 filter_pred,
1210 ) {
1211 Ok(v) => v,
1212 Err(e) => return format!("ERR: {e}"),
1213 };
1214 results.truncate(top_k);
1215
1216 let header = if compact {
1217 format!(
1218 "semantic_search(dense,{top_k}) → {} results, {} chunks, embed_cov={:.0}%\n",
1219 results.len(),
1220 index.doc_count,
1221 coverage * 100.0
1222 )
1223 } else {
1224 format!(
1225 "Semantic search (Dense): \"{}\" ({} results from {} indexed chunks, embeddings coverage {:.0}%)\n",
1226 truncate_query(query, 60),
1227 results.len(),
1228 index.doc_count,
1229 coverage * 100.0
1230 )
1231 };
1232
1233 format!("{header}{}", format_hybrid_results(&results, compact))
1234 }
1235 #[cfg(not(feature = "embeddings"))]
1236 {
1237 "ERR: embeddings feature not enabled".to_string()
1238 }
1239}
1240
1241#[cfg(feature = "embeddings")]
1242fn load_engine_and_index(
1243 root: &Path,
1244) -> Result<(&'static EmbeddingEngine, EmbeddingIndex), String> {
1245 let cfg = crate::core::config::Config::load();
1246 let profile = crate::core::config::MemoryProfile::effective(&cfg);
1247 if !profile.embeddings_enabled() {
1248 return Err("embeddings disabled by memory_profile=low".into());
1249 }
1250
1251 let engine = crate::core::embeddings::shared_engine()
1252 .ok_or_else(|| "embedding engine load failed".to_string())?;
1253
1254 let model_name = engine.model_name();
1255 let mut idx = EmbeddingIndex::load(root)
1256 .unwrap_or_else(|| EmbeddingIndex::new_with_model(engine.dimensions(), model_name));
1257
1258 if let Some((stored, current)) = idx.model_mismatch(model_name) {
1259 tracing::warn!(
1260 "[embeddings] model changed: {stored} → {current}. Re-indexing all embeddings."
1261 );
1262 idx = EmbeddingIndex::new_with_model(engine.dimensions(), model_name);
1263 } else if idx.dimension_mismatch(engine.dimensions()) {
1264 tracing::warn!(
1265 "[embeddings] dimension mismatch: index={}d, engine={}d. Re-indexing.",
1266 idx.dimensions,
1267 engine.dimensions()
1268 );
1269 idx = EmbeddingIndex::new_with_model(engine.dimensions(), model_name);
1270 }
1271
1272 if idx.model_id.is_none() {
1273 idx.model_id = Some(model_name.to_string());
1274 }
1275
1276 Ok((engine, idx))
1277}
1278
1279#[cfg(feature = "embeddings")]
1283type AlignedEmbeddings = (std::sync::Arc<[Vec<f32>]>, f64, Vec<String>);
1284
1285#[cfg(feature = "embeddings")]
1286fn ensure_embeddings(
1287 root: &Path,
1288 index: &BM25Index,
1289 engine: &EmbeddingEngine,
1290 embed_idx: &mut EmbeddingIndex,
1291) -> Result<AlignedEmbeddings, String> {
1292 if index.content_truncated {
1302 let aligned = embed_idx
1303 .get_aligned_embeddings(&index.chunks)
1304 .ok_or_else(|| {
1305 "embedding alignment failed on truncated resident index; \
1306 refusing to re-embed snippet-only bodies"
1307 .to_string()
1308 })?;
1309 let coverage = embed_idx.coverage(index.chunks.len());
1310 return Ok((aligned, coverage, Vec::new()));
1311 }
1312
1313 let mut changed_files = embed_idx.files_needing_update(&index.chunks);
1314 changed_files.sort();
1315 changed_files.dedup();
1316
1317 if !changed_files.is_empty() {
1318 let changed_set: std::collections::HashSet<&str> = changed_files
1319 .iter()
1320 .map(std::string::String::as_str)
1321 .collect();
1322 let mut new_embeddings: Vec<(usize, Vec<f32>)> = Vec::new();
1323 for (i, c) in index.chunks.iter().enumerate() {
1324 if !changed_set.contains(c.file_path.as_str()) {
1325 continue;
1326 }
1327 let emb = engine
1328 .embed(&c.content)
1329 .map_err(|e| format!("embed failed for {}: {e}", c.file_path))?;
1330 new_embeddings.push((i, emb));
1331 }
1332 embed_idx.update(&index.chunks, &new_embeddings, &changed_files);
1333 embed_idx
1334 .save(root)
1335 .map_err(|e| format!("save embeddings failed: {e}"))?;
1336 }
1337
1338 if let Some(aligned) = embed_idx.get_aligned_embeddings(&index.chunks) {
1339 let coverage = embed_idx.coverage(index.chunks.len());
1340 return Ok((aligned, coverage, changed_files));
1341 }
1342
1343 let mut all_files: Vec<String> = index.chunks.iter().map(|c| c.file_path.clone()).collect();
1345 all_files.sort();
1346 all_files.dedup();
1347
1348 let mut new_embeddings: Vec<(usize, Vec<f32>)> = Vec::with_capacity(index.chunks.len());
1349 for (i, c) in index.chunks.iter().enumerate() {
1350 let emb = engine
1351 .embed(&c.content)
1352 .map_err(|e| format!("embed failed for {}: {e}", c.file_path))?;
1353 new_embeddings.push((i, emb));
1354 }
1355
1356 embed_idx.update(&index.chunks, &new_embeddings, &all_files);
1357 embed_idx
1358 .save(root)
1359 .map_err(|e| format!("save embeddings failed: {e}"))?;
1360
1361 let aligned = embed_idx
1362 .get_aligned_embeddings(&index.chunks)
1363 .ok_or_else(|| "embedding alignment failed after full rebuild".to_string())?;
1364 let coverage = embed_idx.coverage(index.chunks.len());
1365 Ok((aligned, coverage, all_files))
1366}
1367
1368struct SearchFilter {
1369 allowed_exts: Option<HashSet<String>>,
1370 path_glob: Option<glob::Pattern>,
1371}
1372
1373impl SearchFilter {
1374 fn new(languages: Option<&[String]>, path_glob: Option<&str>) -> Result<Self, String> {
1375 let allowed_exts = languages.map(normalize_languages);
1376 let path_glob = match path_glob {
1377 None => None,
1378 Some(s) if s.trim().is_empty() => None,
1379 Some(s) => Some(glob::Pattern::new(s).map_err(|e| e.msg.to_string())?),
1380 };
1381 Ok(Self {
1382 allowed_exts,
1383 path_glob,
1384 })
1385 }
1386
1387 fn is_active(&self) -> bool {
1388 self.allowed_exts.is_some() || self.path_glob.is_some()
1389 }
1390
1391 fn matches(&self, rel_path: &str) -> bool {
1392 let rel_path = rel_path.replace('\\', "/");
1393 if let Some(p) = &self.path_glob
1394 && !p.matches(&rel_path)
1395 {
1396 return false;
1397 }
1398 if let Some(exts) = &self.allowed_exts {
1399 let ext = Path::new(&rel_path)
1400 .extension()
1401 .and_then(|e| e.to_str())
1402 .unwrap_or("")
1403 .to_lowercase();
1404 if ext.is_empty() || !exts.contains(&ext) {
1405 return false;
1406 }
1407 }
1408 true
1409 }
1410}
1411
1412fn normalize_languages(langs: &[String]) -> HashSet<String> {
1413 let mut out = HashSet::new();
1414 for l in langs {
1415 let raw = l.trim().trim_start_matches('.').to_lowercase();
1416 match raw.as_str() {
1417 "rust" | "rs" => {
1418 out.insert("rs".to_string());
1419 }
1420 "ts" | "typescript" => {
1421 out.insert("ts".to_string());
1422 out.insert("tsx".to_string());
1423 }
1424 "js" | "javascript" => {
1425 out.insert("js".to_string());
1426 out.insert("jsx".to_string());
1427 out.insert("mjs".to_string());
1428 out.insert("cjs".to_string());
1429 }
1430 "py" | "python" => {
1431 out.insert("py".to_string());
1432 }
1433 "go" => {
1434 out.insert("go".to_string());
1435 }
1436 "java" => {
1437 out.insert("java".to_string());
1438 }
1439 "ruby" | "rb" => {
1440 out.insert("rb".to_string());
1441 }
1442 "php" => {
1443 out.insert("php".to_string());
1444 }
1445 "c" => {
1446 out.insert("c".to_string());
1447 out.insert("h".to_string());
1448 }
1449 "cpp" | "c++" | "cc" => {
1450 out.insert("cpp".to_string());
1451 out.insert("hpp".to_string());
1452 out.insert("cc".to_string());
1453 out.insert("hh".to_string());
1454 }
1455 "cs" | "csharp" => {
1456 out.insert("cs".to_string());
1457 }
1458 "swift" => {
1459 out.insert("swift".to_string());
1460 }
1461 "kt" | "kotlin" => {
1462 out.insert("kt".to_string());
1463 out.insert("kts".to_string());
1464 }
1465 "json" => {
1466 out.insert("json".to_string());
1467 }
1468 "yaml" | "yml" => {
1469 out.insert("yaml".to_string());
1470 out.insert("yml".to_string());
1471 }
1472 other if !other.is_empty() => {
1473 out.insert(other.to_string());
1474 }
1475 _ => {}
1476 }
1477 }
1478 out
1479}
1480
1481#[cfg(feature = "embeddings")]
1483pub fn load_engine_and_index_pub(
1484 root: &Path,
1485) -> Result<(&'static EmbeddingEngine, EmbeddingIndex), String> {
1486 load_engine_and_index(root)
1487}
1488
1489#[cfg(feature = "embeddings")]
1491pub fn ensure_embeddings_for_eval(
1492 root: &Path,
1493 index: &BM25Index,
1494 engine: &EmbeddingEngine,
1495 embed_idx: &mut EmbeddingIndex,
1496) -> Result<AlignedEmbeddings, String> {
1497 ensure_embeddings(root, index, engine, embed_idx)
1498}
1499
1500pub fn boost_with_splade_pub(
1502 results: &mut [HybridResult],
1503 splade: &[crate::core::splade_retrieval::SpladeResult],
1504 weight: f64,
1505) {
1506 boost_with_splade(results, splade, weight);
1507}
1508
1509#[cfg(test)]
1510mod filter_tests {
1511 use super::*;
1512
1513 #[test]
1514 fn filter_language_rust() {
1515 let f = SearchFilter::new(Some(&["rust".into()]), None).unwrap();
1516 assert!(f.matches("src/main.rs"));
1517 assert!(!f.matches("src/main.ts"));
1518 }
1519
1520 #[test]
1521 fn filter_path_glob() {
1522 let f = SearchFilter::new(None, Some("rust/src/**")).unwrap();
1523 assert!(f.matches("rust/src/core/mod.rs"));
1524 assert!(!f.matches("website/src/pages/index.astro"));
1525 }
1526}
1527
1528#[cfg(test)]
1529mod determinism_tests {
1530 use super::*;
1531
1532 #[test]
1533 fn rrf_merge_hybrid_is_deterministic_on_ties() {
1534 let a = HybridResult {
1535 file_path: "a.rs".to_string(),
1536 symbol_name: "foo".to_string(),
1537 kind: crate::core::bm25_index::ChunkKind::Function,
1538 start_line: 1,
1539 end_line: 1,
1540 snippet: "a".to_string(),
1541 rrf_score: 0.0,
1542 bm25_score: None,
1543 dense_score: None,
1544 bm25_rank: None,
1545 dense_rank: None,
1546 };
1547 let b = HybridResult {
1548 file_path: "b.rs".to_string(),
1549 symbol_name: "foo".to_string(),
1550 kind: crate::core::bm25_index::ChunkKind::Function,
1551 start_line: 1,
1552 end_line: 1,
1553 snippet: "b".to_string(),
1554 rrf_score: 0.0,
1555 bm25_score: None,
1556 dense_score: None,
1557 bm25_rank: None,
1558 dense_rank: None,
1559 };
1560
1561 let fused = rrf_merge_hybrid(
1563 vec![
1564 ("root".to_string(), vec![a.clone(), b.clone()]),
1565 ("root".to_string(), vec![b.clone(), a.clone()]),
1566 ],
1567 10,
1568 );
1569
1570 assert_eq!(fused.len(), 2);
1571 assert_eq!(fused[0].file_path, "a.rs");
1572 assert_eq!(fused[1].file_path, "b.rs");
1573 }
1574}
1575
1576#[cfg(test)]
1577mod dense_config_tests {
1578 use super::*;
1579
1580 #[test]
1582 fn dense_enabled_defaults_true() {
1583 assert!(HybridConfig::default().dense_enabled);
1584 }
1585
1586 #[test]
1588 fn dense_enabled_deserializes_false() {
1589 let cfg: HybridConfig = toml::from_str("dense_enabled = false").unwrap();
1590 assert!(!cfg.dense_enabled);
1591 assert_eq!(cfg.bm25_candidates, 75);
1592 assert_eq!(cfg.splade_weight, 0.5);
1593 }
1594}
1595
1596#[cfg(all(test, feature = "embeddings"))]
1597mod dense_toggle_tests {
1598 use super::*;
1599 use crate::core::bm25_index::{BM25Index, ChunkKind, CodeChunk, tokenize};
1600
1601 fn small_index() -> BM25Index {
1602 BM25Index::from_chunks_for_test(vec![
1603 CodeChunk {
1604 file_path: "auth.rs".into(),
1605 symbol_name: "validate_token".into(),
1606 kind: ChunkKind::Function,
1607 start_line: 1,
1608 end_line: 10,
1609 content: "fn validate_token(token: &str) -> bool { check_jwt_expiry(token) }"
1610 .into(),
1611 tokens: tokenize("fn validate_token token str bool check_jwt_expiry token"),
1612 token_count: 0,
1613 },
1614 CodeChunk {
1615 file_path: "db.rs".into(),
1616 symbol_name: "connect_database".into(),
1617 kind: ChunkKind::Function,
1618 start_line: 1,
1619 end_line: 5,
1620 content: "fn connect_database(url: &str) -> Pool { create_pool(url) }".into(),
1621 tokens: tokenize("fn connect_database url str Pool create_pool url"),
1622 token_count: 0,
1623 },
1624 ])
1625 }
1626
1627 #[test]
1632 fn bm25_graph_search_ranks_without_embeddings() {
1633 let dir = tempfile::tempdir().unwrap();
1634 let root = dir.path();
1635 let index = small_index();
1636 let cfg = HybridConfig {
1637 dense_enabled: false,
1638 ..Default::default()
1639 };
1640 let filter = SearchFilter::new(None, None).unwrap();
1641
1642 let out = bm25_graph_search(
1643 "jwt token validation",
1644 root,
1645 &index,
1646 5,
1647 false,
1648 &filter,
1649 &cfg,
1650 );
1651
1652 assert!(
1653 out.contains("Semantic search (BM25"),
1654 "expected BM25 header, got: {out}"
1655 );
1656 assert!(
1657 out.contains("validate_token"),
1658 "expected lexical match, got: {out}"
1659 );
1660 assert!(
1661 !root.join("embeddings.json").exists(),
1662 "dense-disabled path must not persist embeddings.json"
1663 );
1664 }
1665}