From 531e1b53a21e742e800fb92edad14d08e0e17d43 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 25 Jul 2026 12:57:48 +0200 Subject: [PATCH 1/2] fix(ctx_search): label the BM25 fallback when the dense index is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit action=semantic (no mode) and ctx_compose resolve to BM25 ranking, but reported it as "Semantic search (BM25)" — a calling agent reads that as "semantic search ran", not "semantic search was unavailable". mode=dense already fails fast with an actionable build hint; the default path did not. When no mode was requested and embeddings.bin is absent (dense enabled but never built), the header now names the degradation and the fix: Lexical search (BM25 — dense index not built, run: lean-ctx index build-semantic) Explicit mode=bm25 is unchanged — that is not a degradation. The check is a file-existence test, no embedding-engine load on the search path. Fixes #1259 Co-Authored-By: Claude Opus 5 (1M context) --- rust/src/tools/ctx_semantic_search/dense.rs | 20 ++++++++++++++++++++ rust/src/tools/ctx_semantic_search/mod.rs | 18 ++++++++++++++++-- rust/src/tools/ctx_semantic_search/tests.rs | 16 ++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/rust/src/tools/ctx_semantic_search/dense.rs b/rust/src/tools/ctx_semantic_search/dense.rs index 92282d6fe..d6ec3e5df 100644 --- a/rust/src/tools/ctx_semantic_search/dense.rs +++ b/rust/src/tools/ctx_semantic_search/dense.rs @@ -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, diff --git a/rust/src/tools/ctx_semantic_search/mod.rs b/rust/src/tools/ctx_semantic_search/mod.rs index 022fe57e5..70dadb308 100644 --- a/rust/src/tools/ctx_semantic_search/mod.rs +++ b/rust/src/tools/ctx_semantic_search/mod.rs @@ -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); @@ -74,15 +77,26 @@ 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, diff --git a/rust/src/tools/ctx_semantic_search/tests.rs b/rust/src/tools/ctx_semantic_search/tests.rs index 78e7296c3..035079955 100644 --- a/rust/src/tools/ctx_semantic_search/tests.rs +++ b/rust/src/tools/ctx_semantic_search/tests.rs @@ -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)] From 66280b3b4a769e0baa4e15e9afeac3496e5b81e1 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 25 Jul 2026 12:59:25 +0200 Subject: [PATCH 2/2] style: cargo fmt --- rust/src/tools/ctx_semantic_search/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rust/src/tools/ctx_semantic_search/mod.rs b/rust/src/tools/ctx_semantic_search/mod.rs index 70dadb308..7f551bcf8 100644 --- a/rust/src/tools/ctx_semantic_search/mod.rs +++ b/rust/src/tools/ctx_semantic_search/mod.rs @@ -84,7 +84,11 @@ pub fn handle( let header = if compact { format!( "semantic_search({},{top_k}) → {} results, {} chunks indexed\n", - if degraded { "bm25,dense-not-built" } else { "bm25" }, + if degraded { + "bm25,dense-not-built" + } else { + "bm25" + }, results.len(), index.doc_count )