Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions rust/src/tools/ctx_semantic_search/dense.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ pub(crate) fn dense_build_hint(pending: usize, compact: bool) -> String {
}
}

/// #1259: is the default BM25 ranking running *because* the dense index was
/// never built? Cheap enough for the hot path — a file-existence check, no
/// engine load. Callers use it to label the degradation instead of reporting
/// lexical hits as "semantic". False when dense is deliberately off (#686):
/// there is nothing to build then.
pub(crate) fn dense_index_missing(root: &Path) -> bool {
#[cfg(feature = "embeddings")]
{
HybridConfig::from_config().dense_enabled
&& !crate::core::index_namespace::vectors_dir(root)
.join("embeddings.bin")
.exists()
}
#[cfg(not(feature = "embeddings"))]
{
let _ = root;
false
}
}

pub(crate) fn hybrid_search_mode(
query: &str,
root: &Path,
Expand Down
22 changes: 20 additions & 2 deletions rust/src/tools/ctx_semantic_search/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ pub fn handle(
};

let compact = crp_mode.is_tdd();
// #1259: separate "caller asked for BM25" from "BM25 is what the default
// resolves to" — only the latter is a silent degradation worth labelling.
let mode_defaulted = mode.is_none();
let mode = mode.unwrap_or("bm25").to_lowercase();
let workspace = workspace.unwrap_or(false);
let artifacts = artifacts.unwrap_or(false);
Expand Down Expand Up @@ -74,15 +77,30 @@ pub fn handle(
}
results.truncate(top_k);

// #1259: never call a lexical fallback "semantic". When no mode was
// requested and the dense index was never built, name the
// degradation and the one command that fixes it.
let degraded = mode_defaulted && dense_index_missing(root);
let header = if compact {
format!(
"semantic_search(bm25,{top_k}) → {} results, {} chunks indexed\n",
"semantic_search({},{top_k}) → {} results, {} chunks indexed\n",
if degraded {
"bm25,dense-not-built"
} else {
"bm25"
},
results.len(),
index.doc_count
)
} else {
format!(
"Semantic search (BM25): \"{}\" ({} results from {} indexed chunks)\n",
"{}: \"{}\" ({} results from {} indexed chunks)\n",
if degraded {
"Lexical search (BM25 — dense index not built, \
run: lean-ctx index build-semantic)"
} else {
"Semantic search (BM25)"
},
truncate_query(query, 60),
results.len(),
index.doc_count,
Expand Down
16 changes: 16 additions & 0 deletions rust/src/tools/ctx_semantic_search/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ mod filter_tests {
}
}

#[cfg(test)]
mod dense_availability_tests {
#[allow(clippy::wildcard_imports)]
use super::super::*;

/// #1259: a project that never built embeddings must be reported as
/// degraded, so the default path can say so instead of calling BM25 hits
/// "semantic".
#[test]
fn missing_embeddings_bin_is_reported_as_degraded() {
let dir = std::env::temp_dir().join("lean-ctx-dense-missing-test");
let _ = std::fs::create_dir_all(&dir);
assert_eq!(dense_index_missing(&dir), cfg!(feature = "embeddings"));
}
}

#[cfg(test)]
mod root_resolution_tests {
#[allow(clippy::wildcard_imports)]
Expand Down
Loading