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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4780,7 +4780,6 @@ dependencies = [
|
||||
"kebab-store-sqlite",
|
||||
"kebab-store-vector",
|
||||
"lopdf",
|
||||
"lru",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user