1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::UNIX_EPOCH;
4
5use serde::{Deserialize, Serialize};
6mod build;
7mod carry_over;
8mod chunking;
9pub use chunking::*;
10mod coordinator;
11pub mod coverage;
12mod evict;
13pub(crate) mod external_origin;
14pub use coordinator::{SearchIndexBuildProgress, get_or_start_build};
15mod provider_origin;
16pub use provider_origin::ChunkOrigins;
17mod served_ranking;
18pub mod source_gate;
19#[cfg(test)]
20mod tests;
21mod walk;
22pub use walk::is_code_file;
23use walk::{admitted_scoped_source_paths, list_code_files};
24#[cfg(test)]
25use walk::{list_code_files_with, list_scoped_code_files};
26
27pub(crate) const MAX_BM25_FILES: usize = 5000;
28const CHUNK_COUNT_WARNING: usize = 50_000;
29const ZSTD_LEVEL: i32 = 9;
30
31const DEFAULT_BM25_IGNORES: &[&str] = &[
32 "vendor/**",
33 "dist/**",
34 "build/**",
35 "public/vendor/**",
36 "public/js/**",
37 "public/css/**",
38 "public/build/**",
39 ".next/**",
40 ".nuxt/**",
41 "__pycache__/**",
42 "*.min.js",
43 "*.min.css",
44 "*.bundle.js",
45 "*.chunk.js",
46];
47
48fn max_bm25_cache_bytes() -> u64 {
49 let mb = std::env::var("LEAN_CTX_BM25_MAX_CACHE_MB")
53 .ok()
54 .and_then(|v| v.parse::<u64>().ok())
55 .unwrap_or_else(|| crate::core::config::Config::load().bm25_max_cache_mb_effective());
56 mb * 1024 * 1024
57}
58
59pub fn persist_ceiling_bytes() -> u64 {
63 max_bm25_cache_bytes()
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum SaveOutcome {
71 Persisted { compressed_bytes: u64 },
73 SkippedTooLarge {
78 compressed_bytes: u64,
79 limit_bytes: u64,
80 },
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub struct CodeChunk {
85 pub file_path: String,
86 pub symbol_name: String,
87 pub kind: ChunkKind,
88 pub start_line: usize,
89 pub end_line: usize,
90 pub content: String,
91 #[serde(default)]
92 pub tokens: Vec<String>,
93 pub token_count: usize,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97pub enum ChunkKind {
98 Function,
99 Struct,
100 Impl,
101 Module,
102 Class,
103 Method,
104 Other,
105 Issue,
107 PullRequest,
108 WikiPage,
109 DbSchema,
110 ApiEndpoint,
111 Ticket,
112 ExternalOther,
113}
114
115#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
116pub struct IndexedFileState {
117 pub mtime_ms: u64,
118 pub size_bytes: u64,
119}
120
121impl IndexedFileState {
122 fn from_path(path: &Path) -> Option<Self> {
123 let meta = path.metadata().ok()?;
124 let size_bytes = meta.len();
125 let mtime_ms = meta
126 .modified()
127 .ok()
128 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
129 .map(|d| d.as_millis() as u64)?;
130 Some(Self {
131 mtime_ms,
132 size_bytes,
133 })
134 }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct BM25Index {
139 pub chunks: Vec<CodeChunk>,
140 pub inverted: HashMap<String, Vec<(usize, f64)>>,
141 pub avg_doc_len: f64,
142 pub doc_count: usize,
143 pub doc_freqs: HashMap<String, usize>,
144 #[serde(default)]
145 pub files: HashMap<String, IndexedFileState>,
146 #[serde(default)]
155 pub dirs: HashMap<String, u64>,
156 #[serde(default)]
161 pub admission_policy: Option<String>,
162 #[serde(default, skip)]
168 pub content_truncated: bool,
169 #[serde(default, skip)]
174 pub source_root: Option<PathBuf>,
175 #[serde(default, skip)]
179 pub provider_origins: std::sync::Arc<ChunkOrigins>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct SearchResult {
184 pub chunk_idx: usize,
185 pub score: f64,
186 pub file_path: String,
187 pub symbol_name: String,
188 pub kind: ChunkKind,
189 pub start_line: usize,
190 pub end_line: usize,
191 pub snippet: String,
192}
193
194const BM25_K1: f64 = 1.2;
195const BM25_B: f64 = 0.75;
196
197fn structural_query_boost(query: &str, chunk: &CodeChunk) -> f64 {
198 let raw_terms: std::collections::HashSet<String> = query
199 .split(|c: char| !(c.is_alphanumeric() || c == '_'))
200 .filter(|term| term.len() >= 3)
201 .map(str::to_ascii_lowercase)
202 .collect();
203 let path_terms: std::collections::HashSet<String> = tokenize(&chunk.file_path)
204 .into_iter()
205 .map(|term| term.to_ascii_lowercase())
206 .collect();
207
208 let mut boost = 0.0;
209 if raw_terms.contains(&chunk.symbol_name.to_ascii_lowercase()) {
210 boost += 32.0;
211 }
212 boost += raw_terms.intersection(&path_terms).count() as f64 * 8.0;
213
214 let has_identifier = query.split_whitespace().any(|term| {
215 let has_lower = term.chars().any(char::is_lowercase);
216 let has_upper = term.chars().any(char::is_uppercase);
217 (has_lower && has_upper) || term.contains('_') || term.contains('.')
218 });
219 if has_identifier {
220 let extension = std::path::Path::new(&chunk.file_path)
221 .extension()
222 .and_then(std::ffi::OsStr::to_str)
223 .unwrap_or_default()
224 .to_ascii_lowercase();
225 if matches!(extension.as_str(), "md" | "mdx" | "rst" | "txt" | "adoc") {
226 boost -= 8.0;
227 } else if !extension.is_empty() {
228 boost += 4.0;
229 }
230 }
231 boost
232}
233
234impl Default for BM25Index {
235 fn default() -> Self {
236 Self::new()
237 }
238}
239
240const ADMITTED_BATCH: usize = 256;
242const FILE_CHUNK_COUNT: usize = 4096;
244
245type ChunkedFile = (Vec<CodeChunk>, usize, usize);
246
247fn extract_sorted_chunks(
250 path: &str,
251 content: &str,
252 chunk_bytes: &mut usize,
253 chunk_count: &mut usize,
254) -> Result<Vec<CodeChunk>, String> {
255 let mut chunks = extract_admitted_chunks(path, content, chunk_bytes, chunk_count)?;
256 chunks.sort_by(|a, b| {
257 (a.start_line, a.end_line, &a.symbol_name).cmp(&(b.start_line, b.end_line, &b.symbol_name))
258 });
259 Ok(chunks)
260}
261
262fn parallel_admitted_chunks(
267 admitted: &[(&str, String)],
268) -> Vec<(usize, Option<Result<ChunkedFile, String>>)> {
269 use rayon::prelude::*;
270 admitted
271 .par_iter()
272 .map(|(path, content)| {
273 let full_allowance =
274 (64 * 1024 * 1024usize).min(content.len().saturating_mul(5).max(64 * 1024));
275 if crate::core::memory_guard::abort_requested() {
276 return (full_allowance, None);
277 }
278 let mut bytes = full_allowance;
279 let mut count = FILE_CHUNK_COUNT;
280 let result = extract_sorted_chunks(path, content, &mut bytes, &mut count)
281 .map(|chunks| (chunks, full_allowance - bytes, FILE_CHUNK_COUNT - count));
282 (full_allowance, Some(result))
283 })
284 .collect()
285}
286
287static PERSIST: std::sync::Mutex<()> = std::sync::Mutex::new(());
292
293pub(crate) fn persist_lock() -> std::sync::MutexGuard<'static, ()> {
294 PERSIST
295 .lock()
296 .unwrap_or_else(std::sync::PoisonError::into_inner)
297}
298
299impl BM25Index {
300 pub fn new() -> Self {
301 Self {
302 chunks: Vec::new(),
303 inverted: HashMap::new(),
304 avg_doc_len: 0.0,
305 doc_count: 0,
306 doc_freqs: HashMap::new(),
307 files: HashMap::new(),
308 dirs: HashMap::new(),
309 admission_policy: None,
310 content_truncated: false,
311 source_root: None,
312 provider_origins: std::sync::Arc::default(),
313 }
314 }
315
316 pub fn memory_usage_bytes(&self) -> usize {
318 let chunks_size: usize = self
319 .chunks
320 .iter()
321 .map(|c| {
322 c.content.len()
323 + c.file_path.len()
324 + c.symbol_name.len()
325 + c.tokens.iter().map(String::len).sum::<usize>()
326 + 64
327 })
328 .sum();
329 let inverted_size: usize = self
330 .inverted
331 .iter()
332 .map(|(k, v)| k.len() + v.len() * 16 + 32)
333 .sum();
334 let files_size: usize = self.files.keys().map(|k| k.len() + 24).sum();
335 let dirs_size: usize = self.dirs.keys().map(|k| k.len() + 16).sum();
336 let freqs_size: usize = self.doc_freqs.keys().map(|k| k.len() + 16).sum();
337 chunks_size + inverted_size + files_size + dirs_size + freqs_size
338 }
339
340 pub fn unload(&mut self) {
342 let usage = self.memory_usage_bytes();
343 self.chunks = Vec::new();
344 self.inverted = HashMap::new();
345 self.doc_freqs = HashMap::new();
346 self.files = HashMap::new();
347 self.dirs = HashMap::new();
348 self.provider_origins = std::sync::Arc::default();
349 self.avg_doc_len = 0.0;
350 self.doc_count = 0;
351 tracing::info!(
352 "[bm25] unloaded index, freed ~{:.1}MB",
353 usage as f64 / 1_048_576.0
354 );
355 }
356
357 pub fn shrink_resident_content_to_snippet(&mut self, keep_lines: usize) {
372 let before = self.memory_usage_bytes();
373 for chunk in &mut self.chunks {
374 if chunk.content.lines().nth(keep_lines).is_some() {
377 let trimmed: String = chunk
378 .content
379 .lines()
380 .take(keep_lines)
381 .collect::<Vec<_>>()
382 .join("\n");
383 chunk.content = trimmed;
384 chunk.content.shrink_to_fit();
386 }
387 }
388 self.content_truncated = true;
389 let after = self.memory_usage_bytes();
390 tracing::debug!(
391 "[bm25] shrank resident content to {keep_lines} lines/chunk, freed ~{:.1}MB",
392 before.saturating_sub(after) as f64 / 1_048_576.0
393 );
394 }
395
396 #[cfg(any(test, feature = "test-support"))]
398 pub fn from_chunks_for_test(chunks: Vec<CodeChunk>) -> Self {
399 let mut index = Self::new();
400 for mut chunk in chunks {
401 if chunk.token_count == 0 {
402 chunk.token_count = tokenize(&chunk.content).len();
403 }
404 index.add_chunk(chunk);
405 }
406 index.finalize();
407 index
408 }
409
410 pub fn build_from_directory(root: &Path) -> Self {
411 Self::build_from_directory_inner(root, &HashMap::new())
412 }
413
414 pub fn build_from_admitted_sources(
421 root: &Path,
422 subdir: Option<&str>,
423 in_scope: &dyn Fn(&str) -> bool,
424 admit: impl FnMut(&str) -> Result<Option<String>, String>,
425 ) -> Result<Self, String> {
426 let rules = CorpusRules::load();
427 Self::build_scoped_from_admitted_sources(root, &rules, subdir, in_scope, admit)
428 }
429
430 fn build_scoped_from_admitted_sources(
431 root: &Path,
432 rules: &CorpusRules,
433 subdir: Option<&str>,
434 in_scope: &dyn Fn(&str) -> bool,
435 mut admit: impl FnMut(&str) -> Result<Option<String>, String>,
436 ) -> Result<Self, String> {
437 if !is_safe_bm25_root(root) {
438 return Err("source root is not safe to scan".into());
439 }
440 let mut index = Self::new();
441 let mut chunk_bytes = 64 * 1024 * 1024;
442 let mut chunk_count = 100_000;
443 if crate::core::memory_guard::is_under_pressure() {
445 crate::core::memory_guard::reclaim_now();
446 }
447 let paths = admitted_scoped_source_paths(root, rules, subdir, in_scope)?;
448 for batch in paths.chunks(ADMITTED_BATCH) {
454 let mut admitted = Vec::with_capacity(batch.len());
455 for path in batch {
456 if crate::core::memory_guard::abort_requested() {
460 return Err("source search withheld under memory pressure".into());
461 }
462 let Some(content) = admit(path)? else {
463 continue;
464 };
465 if looks_minified(&content) {
466 continue;
467 }
468 admitted.push((path.as_str(), content));
469 }
470 let chunked = parallel_admitted_chunks(&admitted);
471 for ((path, content), (full_allowance, unbounded)) in admitted.iter().zip(chunked) {
472 let allowance = chunk_bytes.min(full_allowance);
473 if !crate::core::index_admission::admit(
474 crate::core::index_admission::BuildKind::Bm25,
475 allowance as u64,
476 )
477 .parallel_ok
478 {
479 return Err("source search exceeds available memory headroom".into());
480 }
481 let count_allowance = chunk_count.min(FILE_CHUNK_COUNT);
482 let (chunks, used_bytes, used_count) = match unbounded {
486 Some(Ok(result))
487 if allowance == full_allowance && count_allowance == FILE_CHUNK_COUNT =>
488 {
489 result
490 }
491 Some(Err(error))
492 if allowance == full_allowance && count_allowance == FILE_CHUNK_COUNT =>
493 {
494 return Err(error);
495 }
496 _ => {
497 let mut file_chunk_bytes = allowance;
498 let mut file_chunk_count = count_allowance;
499 let chunks = extract_sorted_chunks(
500 path,
501 content,
502 &mut file_chunk_bytes,
503 &mut file_chunk_count,
504 )?;
505 (
506 chunks,
507 allowance - file_chunk_bytes,
508 count_allowance - file_chunk_count,
509 )
510 }
511 };
512 chunk_bytes -= used_bytes;
513 chunk_count -= used_count;
514 for chunk in chunks {
515 if crate::core::memory_guard::abort_requested() {
516 return Err("source search withheld under memory pressure".into());
517 }
518 index.add_chunk_with_content_limit(chunk, None);
521 }
522 }
523 }
524 index.finalize();
525 Ok(index)
526 }
527
528 pub fn build_with_content_hint(root: &Path, content_hint: &HashMap<String, String>) -> Self {
531 Self::build_from_directory_inner(root, content_hint)
532 }
533
534 fn build_from_directory_inner(root: &Path, content_hint: &HashMap<String, String>) -> Self {
535 let root_str = root.to_string_lossy();
536 if !super::graph_index::is_safe_scan_root_public(&root_str) {
537 tracing::warn!("[bm25: scan aborted for unsafe root {root_str}]");
538 return Self::new();
539 }
540 let files = list_code_files(root);
541 let dirs = dir_states(root, &files);
546 let admission = crate::core::context_admission::stores::StoreAdmission::current();
548
549 if files.len() >= build::PARALLEL_MIN_FILES
559 && !crate::core::memory_guard::is_under_pressure()
560 && !crate::core::memory_guard::abort_requested()
561 && crate::core::index_admission::admit_files(
562 crate::core::index_admission::BuildKind::Bm25,
563 root,
564 &files,
565 )
566 .parallel_ok
567 {
568 let mut index = Self::build_parallel(root, content_hint, &files, &admission);
569 index.dirs = dirs;
570 index.admission_policy = Some(admission.digest().to_owned());
571 return index;
572 }
573 let mut index = Self::build_sequential(root, content_hint, &files, &admission);
574 index.dirs = dirs;
575 index.admission_policy = Some(admission.digest().to_owned());
576 index
577 }
578
579 fn group_prev_chunks_by_file(prev: &BM25Index) -> HashMap<String, Vec<CodeChunk>> {
584 let mut old_by_file: HashMap<String, Vec<CodeChunk>> = HashMap::new();
585 for c in &prev.chunks {
586 old_by_file
587 .entry(c.file_path.clone())
588 .or_default()
589 .push(c.clone());
590 }
591 for v in old_by_file.values_mut() {
592 v.sort_by(|a, b| {
593 a.start_line
594 .cmp(&b.start_line)
595 .then_with(|| a.end_line.cmp(&b.end_line))
596 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
597 });
598 }
599 old_by_file
600 }
601
602 pub fn rebuild_incremental(root: &Path, prev: &BM25Index) -> Self {
603 let admission = crate::core::context_admission::stores::StoreAdmission::current();
605 if prev.admission_policy.as_deref() != Some(admission.digest()) {
606 return Self::build_from_directory(root);
607 }
608 let old_by_file = Self::group_prev_chunks_by_file(prev);
609 let files = list_code_files(root);
610 let dirs = dir_states(root, &files);
611
612 if files.len() >= build::PARALLEL_MIN_FILES
622 && !crate::core::memory_guard::is_under_pressure()
623 && !crate::core::memory_guard::abort_requested()
624 && crate::core::index_admission::admit_files(
625 crate::core::index_admission::BuildKind::Bm25,
626 root,
627 &files,
628 )
629 .parallel_ok
630 {
631 let mut index =
632 Self::rebuild_incremental_parallel(root, prev, &old_by_file, &files, &admission);
633 index.dirs = dirs;
634 index.admission_policy = Some(admission.digest().to_owned());
635 return index;
636 }
637 let mut index =
638 Self::rebuild_incremental_sequential(root, prev, &old_by_file, &files, &admission);
639 index.dirs = dirs;
640 index.admission_policy = Some(admission.digest().to_owned());
641 index
642 }
643
644 pub(crate) fn rebuild_incremental_sequential(
648 root: &Path,
649 prev: &BM25Index,
650 old_by_file: &HashMap<String, Vec<CodeChunk>>,
651 files: &[String],
652 admission: &crate::core::context_admission::stores::StoreAdmission,
653 ) -> Self {
654 let mut index = Self::new();
655 const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024;
656 let root_key = root.to_string_lossy().to_string();
657 let total = files.len() as u64;
658 crate::core::index_progress::report_bm25(&root_key, 0, total);
659
660 for (i, rel) in files.iter().enumerate() {
661 if i.is_multiple_of(16) || i + 1 == files.len() {
662 crate::core::index_progress::report_bm25(&root_key, (i + 1) as u64, total);
663 }
664 if i.is_multiple_of(500) && crate::core::memory_guard::is_under_pressure() {
665 tracing::warn!(
666 "[bm25: stopping incremental rebuild at file {i}/{} due to memory pressure]",
667 files.len()
668 );
669 break;
670 }
671
672 let abs = root.join(rel);
673 let Some(state) = IndexedFileState::from_path(&abs) else {
674 continue;
675 };
676
677 let unchanged = prev.files.get(rel).is_some_and(|old| *old == state);
678 if unchanged
679 && let Some(chunks) = old_by_file.get(rel)
680 && chunks.first().is_some_and(|c| !c.content.is_empty())
681 {
682 for chunk in chunks {
683 index.add_chunk(chunk.clone());
684 }
685 index.files.insert(rel.clone(), state);
686 continue;
687 }
688
689 if state.size_bytes > MAX_FILE_SIZE_BYTES {
690 continue;
691 }
692 let content = if crate::core::extractors::is_binary_document(&abs) {
693 match std::fs::read(&abs) {
694 Ok(bytes) => crate::core::extractors::extract(&abs, &bytes).text,
695 Err(_) => continue,
696 }
697 } else {
698 match crate::core::text_decode::read_text_checked(&abs) {
701 Ok(decoded) if decoded.complete() => decoded.text,
702 _ => continue,
703 }
704 };
705 if content.is_empty() {
706 continue;
707 }
708 let Some(content) = admission.admit(&content, &abs) else {
710 continue;
711 };
712 if looks_minified(&content) {
714 tracing::debug!("[bm25: skipping minified payload {rel}]");
715 continue;
716 }
717 let mut chunks = extract_chunks(rel, &content);
718 chunks.sort_by(|a, b| {
719 a.start_line
720 .cmp(&b.start_line)
721 .then_with(|| a.end_line.cmp(&b.end_line))
722 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
723 });
724 for chunk in chunks {
725 index.add_chunk(chunk);
726 }
727 index.files.insert(rel.clone(), state);
728 }
729
730 index.finalize();
731 index
732 }
733
734 fn add_chunk(&mut self, chunk: CodeChunk) {
735 self.add_chunk_with_content_limit(chunk, Some(10));
736 }
737
738 fn add_chunk_with_content_limit(&mut self, mut chunk: CodeChunk, keep_lines: Option<usize>) {
739 let idx = self.chunks.len();
740
741 let enriched = enrich_for_bm25(&chunk);
742 let tokens = chunking::index_terms(&enriched);
743 for token in &tokens {
744 let lower = token.to_lowercase();
745 let postings = self.inverted.entry(lower.clone()).or_default();
746 if postings.last().map(|(last_idx, _)| *last_idx) != Some(idx) {
747 *self.doc_freqs.entry(lower).or_insert(0) += 1;
748 }
749 postings.push((idx, 1.0));
750 }
751
752 if let Some(keep_lines) = keep_lines.filter(|&n| chunk.content.lines().nth(n).is_some()) {
755 chunk.content = chunk
756 .content
757 .lines()
758 .take(keep_lines)
759 .collect::<Vec<_>>()
760 .join("\n");
761 chunk.content.shrink_to_fit();
762 }
763
764 self.chunks.push(CodeChunk {
765 token_count: tokens.len(),
766 tokens: Vec::new(),
767 ..chunk
768 });
769 }
770
771 fn finalize(&mut self) {
772 self.doc_count = self.chunks.len();
773 if self.doc_count == 0 {
774 return;
775 }
776
777 let total_len: usize = self.chunks.iter().map(|c| c.token_count).sum();
778 self.avg_doc_len = total_len as f64 / self.doc_count as f64;
779 }
780
781 fn rank(&self, query: &str, excluded: Option<&[bool]>) -> Vec<SearchResult> {
787 let query_tokens = tokenize(query);
788 if query_tokens.is_empty() || self.doc_count == 0 {
789 return Vec::new();
790 }
791 let (doc_count, avg_doc_len) = match excluded {
792 None => (self.doc_count, self.avg_doc_len),
793 Some(excluded) => self.served_corpus(excluded),
794 };
795 if doc_count == 0 {
796 return Vec::new();
797 }
798
799 let n = self.chunks.len();
802 let mut scores = vec![0.0f64; n];
803 let mut touched = Vec::with_capacity(n.min(256));
804
805 for token in &query_tokens {
806 let lower = token.to_lowercase();
807 let Some(postings) = self.inverted.get(&lower) else {
808 continue;
809 };
810 let df = match excluded {
811 None => *self.doc_freqs.get(&lower).unwrap_or(&0),
812 Some(excluded) => served_ranking::doc_freq(postings, excluded),
813 };
814 let df = df as f64;
815 if df == 0.0 {
816 continue;
817 }
818
819 let idf = ((doc_count as f64 - df + 0.5) / (df + 0.5) + 1.0).ln();
820
821 for &(idx, weight) in postings {
822 if excluded.is_some_and(|excluded| served_ranking::is_excluded(excluded, idx)) {
823 continue;
824 }
825 let doc_len = self.chunks[idx].token_count as f64;
826 let norm_len = doc_len / avg_doc_len.max(1.0);
827 let bm25 = idf * (weight * (BM25_K1 + 1.0))
828 / (weight + BM25_K1 * (1.0 - BM25_B + BM25_B * norm_len));
829
830 if scores[idx] == 0.0 {
831 touched.push(idx);
832 }
833 scores[idx] += bm25;
834 }
835 }
836
837 for &idx in &touched {
838 scores[idx] += structural_query_boost(query, &self.chunks[idx]);
839 }
840
841 let mut results: Vec<SearchResult> = touched
842 .iter()
843 .filter(|&&idx| scores[idx] > 0.0)
844 .map(|&idx| {
845 let chunk = &self.chunks[idx];
846 let snippet = query_snippet(&chunk.content, &query_tokens);
847 SearchResult {
848 chunk_idx: idx,
849 score: scores[idx],
850 file_path: chunk.file_path.clone(),
851 symbol_name: chunk.symbol_name.clone(),
852 kind: chunk.kind.clone(),
853 start_line: chunk.start_line,
854 end_line: chunk.end_line,
855 snippet,
856 }
857 })
858 .collect();
859
860 results.sort_by(|a, b| {
861 b.score
862 .partial_cmp(&a.score)
863 .unwrap_or(std::cmp::Ordering::Equal)
864 .then_with(|| a.file_path.cmp(&b.file_path))
865 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
866 .then_with(|| a.start_line.cmp(&b.start_line))
867 .then_with(|| a.end_line.cmp(&b.end_line))
868 });
869 results
870 }
871
872 pub fn save(&self, root: &Path) -> std::io::Result<SaveOutcome> {
873 if self.chunks.len() > CHUNK_COUNT_WARNING {
874 tracing::warn!(
875 "[bm25] index has {} chunks (threshold {}), consider adding extra_ignore_patterns",
876 self.chunks.len(),
877 CHUNK_COUNT_WARNING
878 );
879 }
880
881 let dir = index_dir(root);
882 std::fs::create_dir_all(&dir)?;
883
884 let target = dir.join("bm25_index.bin.zst");
887 let tmp = dir.join("bm25_index.bin.zst.tmp");
888 {
889 let file = std::fs::File::create(&tmp)?;
890 let buf_writer = std::io::BufWriter::new(file);
891 let mut encoder = zstd::Encoder::new(buf_writer, ZSTD_LEVEL)
892 .map_err(|e| std::io::Error::other(format!("zstd encoder init: {e}")))?;
893 postcard::to_io(self, &mut encoder)
894 .map_err(|e| std::io::Error::other(e.to_string()))?;
895 encoder.finish()?;
896 }
897
898 let compressed_bytes = std::fs::metadata(&tmp)?.len();
899 let max_bytes = max_bm25_cache_bytes();
900 if compressed_bytes > max_bytes {
901 let _ = std::fs::remove_file(&tmp);
902 tracing::warn!(
903 "[bm25] compressed index too large ({:.1} MB, limit {:.0} MB), refusing to persist: {}",
904 compressed_bytes as f64 / 1_048_576.0,
905 max_bytes / (1024 * 1024),
906 dir.display()
907 );
908 return Ok(SaveOutcome::SkippedTooLarge {
909 compressed_bytes,
910 limit_bytes: max_bytes,
911 });
912 }
913
914 tracing::info!(
915 "[bm25] index: {:.1} MB zstd compressed",
916 compressed_bytes as f64 / 1_048_576.0,
917 );
918
919 std::fs::rename(&tmp, &target)?;
920
921 let _ = std::fs::remove_file(dir.join("bm25_index.bin"));
922 let _ = std::fs::remove_file(dir.join("bm25_index.json"));
923
924 let _ = std::fs::write(
925 dir.join("project_root.txt"),
926 root.to_string_lossy().as_bytes(),
927 );
928 self.save_provider_origins(&dir);
929
930 Ok(SaveOutcome::Persisted { compressed_bytes })
931 }
932
933 pub fn load(root: &Path) -> Option<Self> {
937 let admission = crate::core::context_admission::stores::StoreAdmission::current();
938 let canonical = |path: &Path| crate::core::pathutil::safe_canonicalize_bounded(path, 2000);
943 if std::fs::read_to_string(index_dir(root).join("project_root.txt"))
944 .is_ok_and(|stored| canonical(Path::new(stored.trim())) != canonical(root))
945 {
946 return None;
947 }
948 Self::load_persisted(root)
949 .filter(|idx| idx.admission_policy.as_deref() == Some(admission.digest()))
950 .map(|idx| idx.rooted_at(root).with_provider_origins(&index_dir(root)))
951 }
952
953 fn load_persisted(root: &Path) -> Option<Self> {
954 let dir = index_dir(root);
955 let max_bytes = max_bm25_cache_bytes();
956
957 let zst_path = dir.join("bm25_index.bin.zst");
958 if zst_path.exists() {
959 let meta = std::fs::metadata(&zst_path).ok()?;
960 if meta.len() > max_bytes {
961 tracing::warn!(
962 "[bm25] compressed index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
963 meta.len() as f64 / 1_073_741_824.0,
964 max_bytes / (1024 * 1024),
965 zst_path.display()
966 );
967 let quarantined = zst_path.with_extension("zst.quarantined");
968 let _ = std::fs::rename(&zst_path, &quarantined);
969 return None;
970 }
971 let compressed = std::fs::read(&zst_path).ok()?;
972 let max_decompressed = max_decompressed_bytes(meta.len());
973 let data = bounded_zstd_decode(&compressed, max_decompressed)?;
974 let idx: Self = postcard::from_bytes(&data).ok()?;
975 return Some(idx);
976 }
977
978 let bin_path = dir.join("bm25_index.bin");
979 if bin_path.exists() {
980 let meta = std::fs::metadata(&bin_path).ok()?;
981 if meta.len() > max_bytes {
982 tracing::warn!(
983 "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
984 meta.len() as f64 / 1_073_741_824.0,
985 max_bytes / (1024 * 1024),
986 bin_path.display()
987 );
988 let quarantined = bin_path.with_extension("bin.quarantined");
989 let _ = std::fs::rename(&bin_path, &quarantined);
990 return None;
991 }
992 let data = std::fs::read(&bin_path).ok()?;
993 let idx: Self = postcard::from_bytes(&data).ok()?;
994 if let Ok(compressed) = zstd::encode_all(data.as_slice(), ZSTD_LEVEL) {
996 let zst_tmp = zst_path.with_extension("zst.tmp");
997 if std::fs::write(&zst_tmp, &compressed).is_ok()
998 && std::fs::rename(&zst_tmp, &zst_path).is_ok()
999 {
1000 tracing::info!(
1001 "[bm25] migrated {:.1} MB → {:.1} MB zstd",
1002 data.len() as f64 / 1_048_576.0,
1003 compressed.len() as f64 / 1_048_576.0
1004 );
1005 let _ = std::fs::remove_file(&bin_path);
1006 }
1007 }
1008 return Some(idx);
1009 }
1010
1011 let json_path = dir.join("bm25_index.json");
1012 if json_path.exists() {
1013 let meta = std::fs::metadata(&json_path).ok()?;
1014 if meta.len() > max_bytes {
1015 tracing::warn!(
1016 "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
1017 meta.len() as f64 / 1_073_741_824.0,
1018 max_bytes / (1024 * 1024),
1019 json_path.display()
1020 );
1021 let quarantined = json_path.with_extension("json.quarantined");
1022 let _ = std::fs::rename(&json_path, &quarantined);
1023 return None;
1024 }
1025 let data = std::fs::read_to_string(&json_path).ok()?;
1026 return serde_json::from_str(&data).ok();
1027 }
1028
1029 None
1030 }
1031
1032 pub fn load_or_build(root: &Path) -> Self {
1033 Self::load_or_build_inner(root, false)
1034 }
1035
1036 pub fn load_or_build_fast(root: &Path) -> Self {
1039 Self::load_or_build_inner(root, true)
1040 }
1041
1042 fn load_or_build_inner(root: &Path, fast_stale: bool) -> Self {
1043 if !is_safe_bm25_root(root) {
1044 return Self::default();
1045 }
1046 if let Some(idx) = Self::load(root) {
1047 let stale = if fast_stale {
1048 bm25_index_looks_stale_fast(&idx, root)
1049 } else {
1050 bm25_index_looks_stale(&idx, root)
1051 };
1052 if !stale {
1053 return idx;
1054 }
1055 tracing::debug!(
1056 "[bm25_index: stale index detected for {}; rebuilding]",
1057 root.display()
1058 );
1059 let rebuilt = if idx.files.is_empty() {
1060 Self::build_from_directory(root)
1061 } else {
1062 Self::rebuild_incremental(root, &idx)
1063 }
1064 .carrying_external_from(&idx);
1065 let _ = rebuilt.save(root);
1066 return rebuilt.rooted_at(root);
1067 }
1068
1069 let built = Self::build_from_directory(root);
1070 let _ = built.save(root);
1071 built.rooted_at(root)
1072 }
1073
1074 pub fn index_file_path(root: &Path) -> PathBuf {
1075 let dir = index_dir(root);
1076 let zst = dir.join("bm25_index.bin.zst");
1077 if zst.exists() {
1078 return zst;
1079 }
1080 let bin = dir.join("bm25_index.bin");
1081 if bin.exists() {
1082 return bin;
1083 }
1084 dir.join("bm25_index.json")
1085 }
1086
1087 pub fn persisted_chunk_count(root: &Path) -> Option<u64> {
1092 use std::io::Read;
1093 let path = Self::index_file_path(root);
1094 let file = std::fs::File::open(&path).ok()?;
1095 let mut head: Box<dyn Read> = match path.extension().and_then(|e| e.to_str()) {
1096 Some("zst") => Box::new(zstd::Decoder::new(file).ok()?),
1097 Some("bin") => Box::new(file),
1098 _ => return None,
1099 };
1100 let mut count = 0u64;
1101 for shift in (0..64).step_by(7) {
1102 let mut byte = [0u8; 1];
1103 head.read_exact(&mut byte).ok()?;
1104 count |= u64::from(byte[0] & 0x7f) << shift;
1105 if byte[0] & 0x80 == 0 {
1106 return Some(count);
1107 }
1108 }
1109 None
1110 }
1111}
1112
1113fn is_safe_bm25_root(root: &Path) -> bool {
1114 super::graph_index::is_safe_scan_root_public(&root.to_string_lossy())
1115}
1116
1117fn bm25_index_looks_stale(index: &BM25Index, root: &Path) -> bool {
1118 bm25_index_looks_stale_inner(index, root, false)
1119}
1120
1121pub fn bm25_index_looks_stale_fast(index: &BM25Index, root: &Path) -> bool {
1124 bm25_index_looks_stale_inner(index, root, true)
1125}
1126
1127fn bm25_index_looks_stale_inner(index: &BM25Index, root: &Path, fast: bool) -> bool {
1128 if index.chunks.is_empty() {
1129 return false;
1130 }
1131
1132 if index
1135 .files
1136 .keys()
1137 .any(|rel| crate::core::io_boundary::is_secret_like(Path::new(rel)).is_some())
1138 {
1139 return true;
1140 }
1141
1142 if index.files.is_empty() {
1143 let mut seen = std::collections::HashSet::<&str>::new();
1144 for chunk in &index.chunks {
1145 let rel = chunk.file_path.trim_start_matches(['/', '\\']);
1146 if rel.is_empty() {
1147 continue;
1148 }
1149 if !seen.insert(rel) {
1150 continue;
1151 }
1152 if !root.join(rel).exists() {
1153 return true;
1154 }
1155 }
1156 return false;
1157 }
1158
1159 if fast {
1160 let sample_size = index.files.len().min(SENTINEL_SAMPLE_SIZE);
1161 let step = if index.files.len() > sample_size {
1162 index.files.len() / sample_size
1163 } else {
1164 1
1165 };
1166 for (i, (rel, old_state)) in index.files.iter().enumerate() {
1167 if i % step != 0 {
1168 continue;
1169 }
1170 let abs = root.join(rel);
1171 if !abs.exists() {
1172 return true;
1173 }
1174 let Some(cur) = IndexedFileState::from_path(&abs) else {
1175 return true;
1176 };
1177 if &cur != old_state {
1178 return true;
1179 }
1180 }
1181 return dirs_look_stale(index, root);
1186 }
1187
1188 for (rel, old_state) in &index.files {
1189 let abs = root.join(rel);
1190 if !abs.exists() {
1191 return true;
1192 }
1193 let Some(cur) = IndexedFileState::from_path(&abs) else {
1194 return true;
1195 };
1196 if &cur != old_state {
1197 return true;
1198 }
1199 }
1200
1201 for rel in list_code_files(root) {
1202 if !index.files.contains_key(&rel) {
1203 return true;
1204 }
1205 }
1206
1207 false
1208}
1209
1210const SENTINEL_SAMPLE_SIZE: usize = 10;
1211
1212fn dirs_look_stale(index: &BM25Index, root: &Path) -> bool {
1219 if index.dirs.is_empty() {
1220 return true;
1221 }
1222 for (rel, recorded) in &index.dirs {
1223 let abs = if rel.is_empty() {
1224 root.to_path_buf()
1225 } else {
1226 root.join(rel)
1227 };
1228 if dir_fingerprint(&abs) != Some(*recorded) {
1229 return true;
1230 }
1231 }
1232 false
1233}
1234
1235fn dir_fingerprint(path: &Path) -> Option<u64> {
1236 use std::hash::{Hash, Hasher};
1237
1238 let meta = std::fs::metadata(path).ok()?;
1239 let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
1240 let mut entries = std::fs::read_dir(path)
1241 .ok()?
1242 .filter_map(Result::ok)
1243 .map(|entry| entry.file_name().to_string_lossy().into_owned())
1244 .collect::<Vec<_>>();
1245 entries.sort_unstable();
1246 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1247 modified.as_nanos().hash(&mut hasher);
1248 entries.hash(&mut hasher);
1249 Some(hasher.finish())
1250}
1251
1252fn dir_states(root: &Path, files: &[String]) -> HashMap<String, u64> {
1256 let mut rels: std::collections::HashSet<String> = std::collections::HashSet::new();
1257 rels.insert(String::new());
1258 for rel in files {
1259 let mut cur = Path::new(rel);
1260 while let Some(parent) = cur.parent() {
1261 let key = parent.to_string_lossy().to_string();
1262 if !rels.insert(key) {
1264 break;
1265 }
1266 cur = parent;
1267 }
1268 }
1269 rels.into_iter()
1270 .filter_map(|rel| {
1271 let abs = if rel.is_empty() {
1272 root.to_path_buf()
1273 } else {
1274 root.join(&rel)
1275 };
1276 dir_fingerprint(&abs).map(|fingerprint| (rel, fingerprint))
1277 })
1278 .collect()
1279}
1280
1281fn bounded_zstd_decode(compressed: &[u8], max_bytes: u64) -> Option<Vec<u8>> {
1289 use std::io::Read;
1290 let mut decoder = zstd::Decoder::new(compressed).ok()?;
1291 let mut buf = Vec::new();
1292 let mut chunk = vec![0u8; 65536];
1293 let mut total = 0u64;
1294 loop {
1295 let n = decoder.read(&mut chunk).ok()?;
1296 if n == 0 {
1297 break;
1298 }
1299 total += n as u64;
1300 if total > max_bytes {
1301 tracing::warn!(
1302 "[bm25] decompressed index exceeds limit ({:.0} MB > {:.0} MB), aborting load",
1303 total as f64 / (1024.0 * 1024.0),
1304 max_bytes as f64 / (1024.0 * 1024.0)
1305 );
1306 return None;
1307 }
1308 buf.extend_from_slice(&chunk[..n]);
1309 }
1310 Some(buf)
1311}
1312
1313fn max_decompressed_bytes(compressed_bytes: u64) -> u64 {
1324 const MAX_EXPANSION_RATIO: u64 = 20;
1326 const FLOOR: u64 = 64 * 1024 * 1024;
1328
1329 let file_derived = compressed_bytes
1330 .saturating_mul(MAX_EXPANSION_RATIO)
1331 .max(FLOOR);
1332 match crate::core::memory_guard::rss_limit_bytes() {
1333 Some(limit) => file_derived.min(limit.max(FLOOR)),
1334 None => file_derived,
1335 }
1336}
1337
1338fn index_dir(root: &Path) -> PathBuf {
1339 crate::core::index_namespace::vectors_dir(root)
1340}
1341
1342const CORPUS_MAX_DEPTH: usize = 20;
1344
1345pub(crate) struct CorpusRules {
1349 filter: crate::core::index_filter::IndexFileFilter,
1350 ignore_patterns: Vec<(String, glob::Pattern)>,
1353 max_files: usize,
1354}
1355
1356impl CorpusRules {
1357 pub(crate) fn load() -> Self {
1358 Self::from_config(&crate::core::config::Config::load())
1359 }
1360
1361 fn from_config(cfg: &crate::core::config::Config) -> Self {
1362 let filter = crate::core::index_filter::IndexFileFilter::resolve(cfg);
1366 let ignore_patterns = DEFAULT_BM25_IGNORES
1367 .iter()
1368 .map(|p| (*p).to_string())
1369 .chain(cfg.extra_ignore_patterns.iter().cloned())
1370 .filter_map(|p| glob::Pattern::new(&p).ok().map(|g| (p, g)))
1371 .collect();
1372 let max_files = if cfg.bm25_max_files == 0 {
1374 usize::MAX
1375 } else {
1376 cfg.bm25_max_files as usize
1377 };
1378 Self {
1379 filter,
1380 ignore_patterns,
1381 max_files,
1382 }
1383 }
1384
1385 fn walker(&self, root: &Path) -> ignore::WalkBuilder {
1388 let mut builder = ignore::WalkBuilder::new(root);
1389 builder
1390 .hidden(crate::core::walk_filter::SKIP_HIDDEN_IN_CONTENT_WALK)
1394 .git_ignore(self.filter.respect_gitignore)
1395 .git_global(self.filter.respect_gitignore)
1396 .git_exclude(self.filter.respect_gitignore)
1397 .require_git(false)
1398 .max_depth(Some(CORPUS_MAX_DEPTH));
1399 builder
1400 }
1401
1402 fn ignore_pattern_for(&self, rel: &str) -> Option<&str> {
1404 self.ignore_patterns
1405 .iter()
1406 .find(|(_, p)| p.matches(rel))
1407 .map(|(src, _)| src.as_str())
1408 }
1409
1410 fn filter_excludes(&self, rel: &str) -> bool {
1414 self.filter.is_excluded(&rel.replace('\\', "/"))
1415 }
1416}