refactor(app): search LRU 캐시 제거 (p9-fb-19) — 동작 불변, 표면 축소

- App::search() → search_uncached() 직통 (캐시 로직 전체 삭제)
- SearchCacheKey struct + impl 제거
- App.search_cache 필드 + 초기화 블록 제거
- build_cache_key() / clear_search_cache() 제거
- config SearchCfg.cache_capacity + default_cache_capacity() 제거
- CLI clear_search_cache() 호출 제거; --no-cache 플래그는 유지(no-op)
- wire/schema search_cache 값 true → false (필드 유지)
- lru workspace dep + kebab-app direct dep 제거
- unicode-normalization dep 유지 (first_question_title() 에서 사용 중)
This commit is contained in:
2026-06-24 09:36:22 +00:00
parent 7c935c6b96
commit 470e94bb2d
8 changed files with 7 additions and 187 deletions

1
Cargo.lock generated
View File

@@ -4780,7 +4780,6 @@ dependencies = [
"kebab-store-sqlite",
"kebab-store-vector",
"lopdf",
"lru",
"reqwest 0.12.28",
"rusqlite",
"serde",

View File

@@ -140,9 +140,6 @@ rusqlite = { version = "0.32", features = ["bundled"] }
globset = "0.4"
tempfile = "3"
proptest = "1"
# p9-fb-19: LRU cache for `App::search` results. Bounded capacity
# from `config.search.cache_capacity` (default 256, ~1.3 MB cap).
lru = "0.12"
lopdf = "0.32"
# fastembed-rs ships ONNX runtime via the `ort-download-binaries` feature
# in its default set (which also pulls `hf-hub` for first-run model

View File

@@ -57,12 +57,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json
tracing-appender = "0.2"
toml = "0.8"
dirs = "5"
# p9-fb-19: in-process LRU cache for `App::search`. Capacity from
# `config.search.cache_capacity` (default 256, ~1.3 MB cap).
lru = { workspace = true }
# p9-fb-19: NFKC-normalize cache-key queries so `"Foo"` / `"FOO"` /
# `" foo "` collapse to one entry. Same crate kebab-normalize +
# kebab-core already use, no version drift.
unicode-normalization = "0.1"
# p9-fb-31: GitignoreBuilder for .kebabignore matching in ingest_file_with_config.
# Same version as kebab-source-fs (0.4) to avoid duplicate dep versions.

View File

@@ -33,11 +33,9 @@
//! in that mode [`App::embedder`] returns `None` and callers must fall
//! back to lexical-only search.
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::{Arc, OnceLock};
use anyhow::{Context, Result, anyhow};
use lru::LruCache;
use kebab_core::{
Answer, DocumentStore, Embedder, ExtractContext, Extractor, IndexVersion, LanguageModel,
@@ -118,12 +116,6 @@ pub struct App {
/// client per query (cheap, but still measurable on a 50-query
/// suite).
llm: OnceLock<Arc<dyn LanguageModel>>,
/// p9-fb-19: in-process LRU search-result cache. Capacity comes
/// from `config.search.cache_capacity` (default 256, ~1.3 MB
/// cap). `None` when capacity is 0 (cache disabled). The
/// `corpus_revision` snapshot embedded in `SearchCacheKey`
/// invalidates every entry the moment a new ingest commit lands.
search_cache: Option<Mutex<LruCache<SearchCacheKey, Vec<SearchHit>>>>,
/// p9-fb-41 PR-9c-2: NLI verifier built eagerly at
/// `open_with_config` time when `config.rag.nli_threshold > 0`,
/// consumed by `RagPipeline::with_verifier` on every `ask` /
@@ -137,46 +129,6 @@ pub struct App {
pipeline_verifier: Option<Arc<dyn kebab_nli::NliVerifier>>,
}
/// p9-fb-19: cache key for `App::search`. Includes every field that
/// could change the result set:
/// - normalized query (NFKC + trim + lowercase)
/// - mode + k + snippet_chars (caller knobs)
/// - embedding_version + chunker_version (model identity)
/// - corpus_revision (monotonic counter that ingest bumps)
///
/// Lexical mode has no embedding identity → empty string in that
/// slot, harmless because the rest of the key still distinguishes
/// queries.
///
/// **Naming note**: spec p9-fb-19 calls the invalidation counter
/// `index_version`, but the impl renames it to `corpus_revision` to
/// avoid confusion with the pre-existing `IndexVersion` newtype
/// (design §9 — embedding-index identity label, a completely
/// different concept). The `corpus_revision` row in the §9
/// versioning table documents the new dimension; HOTFIXES entry
/// tracks the rename.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub(crate) struct SearchCacheKey {
pub query_norm: String,
pub mode: SearchMode,
pub k: u32,
pub snippet_chars: u32,
pub embedding_version: String,
pub chunker_version: String,
pub corpus_revision: u64,
}
impl SearchCacheKey {
/// Normalize `query.text` per spec p9-fb-19: NFKC + trim +
/// lowercase. Means `"Foo"` / `"FOO"` / `" foo "` collapse to a
/// single cache entry — redundant work avoided when the user's
/// input differs only in shape.
pub fn normalize_query(text: &str) -> String {
use unicode_normalization::UnicodeNormalization;
text.trim().nfkc().collect::<String>().to_lowercase()
}
}
impl App {
/// Open the SQLite store and run migrations. Does NOT load the
/// embedder or vector store — those are lazy via
@@ -219,10 +171,6 @@ impl App {
"korean tokenizer backfill complete: {backfill_count} chunks updated"
);
}
// p9-fb-19: build the LRU cache from config. Capacity 0 →
// `None` (cache disabled — every search hits the retrievers).
let search_cache = NonZeroUsize::new(config.search.cache_capacity)
.map(|cap| Mutex::new(LruCache::new(cap)));
// post-v0.18.0 extractor-dispatch-unification: build the 11-entry
// Extractor registry. All entries are state-less unit structs with
// zero-cost `new()`, so init cost is effectively 0 and side effects
@@ -264,7 +212,6 @@ impl App {
embedder: OnceLock::new(),
vector: OnceLock::new(),
llm: OnceLock::new(),
search_cache,
pipeline_verifier,
})
}
@@ -294,73 +241,13 @@ impl App {
}
/// Run a [`SearchQuery`] through the configured retriever stack and
/// return the top-k hits. p9-fb-19: result is served from the
/// in-process LRU cache when the same `(query_norm, mode, k,
/// snippet_chars, embedding_version, chunker_version,
/// corpus_revision)` tuple was seen before; cache miss falls
/// through to [`Self::search_uncached`].
/// return the top-k hits.
///
/// Reuses any previously-built embedder / vector store on this `App`
/// — long-lived callers (kb-eval, future TUI) get amortized cost
/// across calls.
pub fn search(&self, query: SearchQuery) -> Result<Vec<SearchHit>> {
let Some(cache) = self.search_cache.as_ref() else {
// Cache disabled (capacity = 0) — straight-line.
return self.search_uncached(query);
};
// Build the cache key. embedding_version is empty for lexical
// mode (no embedder identity); for vector/hybrid we need the
// embedder built (which forces the cold-start cost), but
// that's the cost the cache exists to amortize across
// *subsequent* identical queries.
let key = self.build_cache_key(&query)?;
// Lock the cache long enough to lookup; clone the hit out so
// we can drop the lock before returning. Mutex poison
// recovery: `into_inner()` of a poison error returns the
// (still-valid) underlying guard so we can keep using the
// cache after a panic in another thread. Log once so the
// poison itself is visible — the cache is still functional
// but a panic in a previous search is worth knowing about.
let mut guard = cache.lock().unwrap_or_else(|e| {
tracing::warn!(
target: "kebab-app",
"search_cache mutex was poisoned; recovering and continuing — \
a previous search-thread panic preceded this call"
);
e.into_inner()
});
if let Some(hits) = guard.get(&key) {
tracing::debug!(
target: "kebab-app",
cache = "hit",
corpus_revision = key.corpus_revision,
"search served from LRU cache"
);
// p9-fb-32: re-stamp staleness on every cache hit. The cache
// entry was stamped at insert time against an older `now`
// and an older threshold; if either has shifted (config
// reload, time passing) the cached `stale: false` may now
// be wrong. Re-stamping is cheap (per-hit comparison) and
// avoids invalidating the cache on threshold changes.
let mut hits = hits.clone();
drop(guard);
let now = time::OffsetDateTime::now_utc();
crate::staleness::mark_stale_in_place(
&mut hits,
now,
self.config.search.stale_threshold_days,
);
return Ok(hits);
}
// Drop the lock before the (potentially slow) retriever call
// so other in-flight searches can use the cache concurrently.
drop(guard);
let hits = self.search_uncached(query)?;
let mut guard = cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.put(key, hits.clone());
Ok(hits)
self.search_uncached(query)
}
/// p9-fb-19: bypass the LRU cache and run the search directly.
@@ -898,48 +785,6 @@ impl App {
Ok(self.llm.get().cloned().unwrap_or(llm))
}
/// p9-fb-19: build a `SearchCacheKey` for `query`. For lexical
/// mode the embedding_version slot is left empty (no embedder
/// identity contributes to the result). For vector / hybrid
/// modes the embedder is built (cold-start) so the version
/// label can be read; that's the cost the cache exists to
/// amortize over the next few identical queries.
fn build_cache_key(&self, query: &SearchQuery) -> Result<SearchCacheKey> {
let embedding_version = match query.mode {
SearchMode::Lexical => String::new(),
SearchMode::Vector | SearchMode::Hybrid => {
let emb = self.embedder()?.ok_or_else(|| {
anyhow!(
"embeddings disabled; vector / hybrid search require an \
embedder — switch to --mode lexical or enable a provider"
)
})?;
vector_index_version(emb.as_ref()).0
}
};
Ok(SearchCacheKey {
query_norm: SearchCacheKey::normalize_query(&query.text),
mode: query.mode,
k: u32::try_from(query.k).unwrap_or(u32::MAX),
snippet_chars: u32::try_from(self.config.search.snippet_chars).unwrap_or(u32::MAX),
embedding_version,
chunker_version: self.config.ingest.chunking.chunker_version.clone(),
corpus_revision: self.sqlite.corpus_revision(),
})
}
/// p9-fb-19: clear the in-process search cache. Useful for tests
/// and for explicit user actions (e.g. a future `kebab cache
/// clear` admin command). No-op when the cache is disabled.
pub fn clear_search_cache(&self) {
if let Some(cache) = self.search_cache.as_ref() {
let mut guard = cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.clear();
}
}
/// p10-1A-2 Task 8b: back-fill `SearchHit.repo` from the originating
/// document's `Metadata.repo` for every hit whose `repo` field is
/// currently `None`. The search layer (kebab-search) constructs hits

View File

@@ -152,7 +152,7 @@ fn capabilities_snapshot() -> Capabilities {
ingest_progress: true,
ingest_cancellation: true,
rag_multi_turn: true,
search_cache: true,
search_cache: false,
incremental_ingest: true,
streaming_ask: true,
http_daemon: false,

View File

@@ -845,7 +845,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
k,
mode,
explain: _,
no_cache,
no_cache: _,
max_tokens,
snippet_chars,
cursor,
@@ -1015,12 +1015,8 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
cursor: cursor.clone(),
trace: *trace,
};
// p9-fb-34: budget-aware path. --no-cache still bypasses the
// App-level LRU; wire wrapper applies regardless.
// p9-fb-34: budget-aware path.
let app = kebab_app::App::open_with_config(cfg)?;
if *no_cache {
app.clear_search_cache();
}
let resp = app.search_with_opts(q, opts)?;
if cli.json {

View File

@@ -338,7 +338,7 @@ mod tests {
ingest_progress: true,
ingest_cancellation: true,
rag_multi_turn: true,
search_cache: true,
search_cache: false,
incremental_ingest: true,
streaming_ask: false,
http_daemon: false,

View File

@@ -314,12 +314,6 @@ pub struct SearchCfg {
pub hybrid_fusion: String,
pub rrf_k: u32,
pub snippet_chars: usize,
/// p9-fb-19: in-memory LRU cache capacity for `App::search`.
/// One entry ≈ 5 KB → default 256 caps memory at ~1.3 MB. Set
/// to `0` to disable the cache entirely. Stale entries
/// (corpus_revision mismatch) are evicted on next access.
#[serde(default = "default_cache_capacity")]
pub cache_capacity: usize,
/// p9-fb-32: hits and citations whose source doc was last
/// re-processed more than this many days ago are marked
/// `stale: true` in wire / TUI / CLI surfaces. `0` disables.
@@ -327,10 +321,6 @@ pub struct SearchCfg {
pub stale_threshold_days: u32,
}
fn default_cache_capacity() -> usize {
256
}
/// v0.17.0 post-dogfood: matches the legacy hard-coded ceiling so
/// existing configs that omit the field keep behaving identically.
/// Overridable per config / `KEBAB_MODELS_LLM_REQUEST_TIMEOUT_SECS`.
@@ -936,7 +926,6 @@ impl Config {
hybrid_fusion: "rrf".to_string(),
rrf_k: 60,
snippet_chars: 220,
cache_capacity: default_cache_capacity(),
stale_threshold_days: 30,
},
rag: RagCfg {