Files
kebab/docs/superpowers/specs/2026-05-10-p9-fb-37-trace-and-stats-design.md
th-kim0823 5f6b2fa259 spec(fb-37): trace + stats design
- search --trace boolean flag, additive optional `trace` field on search_response.v1
- HybridRetriever search_with_trace returns (hits, SearchTrace) — lex/vec/rrf_inputs + per-stage timing
- cache bypass when --trace (debug intent)
- schema.v1.stats extended with media_breakdown / lang_breakdown / index_bytes / stale_doc_count
- TUI search pane `t` keystroke opens TracePopup
- additive minor wire — no schema bump

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:05:31 +09:00

16 KiB
Raw Permalink Blame History

title, phase, component, task_id, status, target_version, contract_source, contract_sections, date
title phase component task_id status target_version contract_source contract_sections date
p9-fb-37 — Trace + stats design P9 kebab-core + kebab-search + kebab-store-sqlite + kebab-app + kebab-cli + kebab-mcp + kebab-tui p9-fb-37 design 0.5.0 ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
§4 search
§7 RAG
§10 UX
2026-05-10

p9-fb-37 — Trace + stats

Goal

retrieval pipeline 가시성 + KB 건강 surface. 두 axes:

  • Trace: kebab search Q --tracesearch_response.v1 에 optional trace 필드 (lexical/vector pre-fusion lists + RRF inputs + per-stage timing). agent / 사용자가 "왜 이 결과가 나왔는지" 진단.
  • Stats: kebab schema --json 의 기존 stats 객체에 4 필드 추가 (media/lang breakdown + index disk bytes + stale doc count). KB 건강 한 눈에.

둘 다 wire schema additive minor — 기존 consumer 무영향. trace 는 opt-in (cost 0 when off), stats 는 항상 채움 (저렴한 GROUP BY).

Behavior contract

CLI flag

kebab search <query> [--trace] [--json] [기존 flags ...]
kebab schema [--json]

--trace boolean, default false. 활성 시:

  • HybridRetriever 가 lexical / vector 각 단계 출력 + per-stage timing 캡처.
  • search cache bypass 강제 (debug intent — cache hit timing 무의미).
  • --jsonsearch_response.v1.trace 채움.
  • non---json 면 hits 출력 후 Trace: section pretty-print (lex/vec 카운트 + timing + top 3 hit per stage).

kebab schema --jsonstats 4 필드 항상 출력 (no flag).

Wire shape

search_response.v1 (additive minor — schema bump 없음):

{
  "schema_version": "search_response.v1",
  "hits":           [/* search_hit.v1 */],
  "next_cursor":    null,
  "truncated":      false,
  "trace": {                                  // OPTIONAL — present iff --trace
    "lexical": [
      {"chunk_id":"c1","doc_id":"d1","doc_path":"a.md","rank":1,"score":0.42}, ...
    ],
    "vector": [
      {"chunk_id":"c2","doc_id":"d2","doc_path":"b.md","rank":1,"score":0.81}, ...
    ],
    "rrf_inputs": [
      {"chunk_id":"c1","lexical_rank":2,"vector_rank":3,"fusion_score":0.0234}, ...
    ],
    "timing": {"lexical_ms":12,"vector_ms":45,"fusion_ms":1,"total_ms":58}
  }
}

#[serde(default, skip_serializing_if = "Option::is_none")]--trace 없으면 trace 키 자체 부재.

schema.v1.stats (additive minor — schema bump 없음):

"stats": {
  "doc_count": 50,
  "chunk_count": 200,
  "asset_count": 50,
  "last_ingest_at": "2026-05-10T12:34:56Z",
  // fb-37 신규
  "media_breakdown": {"markdown":12,"pdf":3,"image":5,"audio":0,"other":0},
  "lang_breakdown":  {"en":10,"ko":5,"null":5},
  "index_bytes":     {"sqlite":12345678,"lancedb":23456789},
  "stale_doc_count": 2
}
  • media_breakdown: MEDIA_KINDS (markdown/pdf/image/audio/other) 5 키 항상 채움 (0 포함). assets.media_type JSON 의 dual shape (text vs object) 는 fb-36 과 동일한 CASE WHEN 패턴.
  • lang_breakdown: 비어있을 수 있음 (corpus 비면 {}). NULL lang 은 "null" 문자열 키.
  • index_bytes.sqlite = *.sqlite + *.sqlite-wal + *.sqlite-shm 합. lancedb = 디렉터리 recursive 합 (없으면 0).
  • stale_doc_count = documents.updated_at < (now - threshold_days) count. config.search.stale_threshold_days = 0 이면 항상 0 (fb-32 의미).

Edge cases

상황 동작
--trace --mode lexical vector: [], vector_ms: 0. rrf_inputs 모두 vector_rank: null
--trace --mode vector 대칭
--trace cache 가 hit 가능 query cache bypass 강제, fresh run
빈 corpus hits=[], trace lex/vec=[], timing 정상 (모두 작은 값)
index_bytes lancedb 디렉터리 부재 0
sqlite WAL/SHM aux 파일 부재 메인 .sqlite 만 합산
stale_doc_count threshold=0 0 (fb-32)
cursor pagination + --trace 첫 호출 trace, next_cursor 따라 재호출 trace 부재 (재요청 필요)
--trace non---json mode hits + trace 텍스트 출력 (lex/vec count, timing, top 3 per stage)

MCP SearchInput 확장

pub struct SearchInput {
    pub query: String,
    pub mode: Option<String>,
    pub k: Option<usize>,
    pub max_tokens: Option<usize>,    // fb-34
    pub snippet_chars: Option<usize>, // fb-34
    pub cursor: Option<String>,       // fb-34
    pub tags: Option<Vec<String>>,    // fb-36
    pub lang: Option<String>,         // fb-36
    pub path_glob: Option<String>,    // fb-36
    pub trust_min: Option<String>,    // fb-36
    pub media: Option<Vec<String>>,   // fb-36
    pub ingested_after: Option<String>, // fb-36
    pub doc_id: Option<String>,       // fb-36
    // fb-37
    pub trace: Option<bool>,
}

Some(true) = trace ON, Some(false) / None = OFF. 출력은 wire 와 동일 (trace 필드 mirror).

TUI Search pane

  • 결과 표시 중 (SearchPane.results 비어있지 않음) t keybind → TracePopup 모달.
  • TUI 가 kebab_app::search_with_trace_with_config 재호출 (현재 query, k, mode, filters 전부).
  • popup: 단일 scroll list (lex section / vec section / rrf section 헤더로 구분), Esc 닫기, j/k 또는 ↑↓ scroll.
  • 기존 inspect pane 무수정.

Allowed / forbidden dependencies

  • kebab-core: 신규 dep 없음. domain types 추가만.
  • kebab-store-sqlite: 신규 dep 없음. rusqlite + std::fs 만.
  • kebab-search: 신규 dep 없음. std::time::Instant 사용.
  • kebab-app: 신규 dep 없음. facade 확장.
  • kebab-cli: 신규 dep 없음. clap flag 추가.
  • kebab-mcp: 신규 dep 없음. SearchInput 확장.
  • kebab-tui: 신규 dep 없음. ratatui popup widget.

kebab-core 의 다른 kebab-* 의존 금지 룰 그대로. UI 크레이트는 facade 만.

Public surface delta

kebab-core (search.rs)

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SearchTrace {
    pub lexical:    Vec<TraceCandidate>,
    pub vector:     Vec<TraceCandidate>,
    pub rrf_inputs: Vec<TraceFusionInput>,
    pub timing:     TraceTiming,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TraceCandidate {
    pub chunk_id: ChunkId,
    pub doc_id:   DocumentId,
    pub doc_path: WorkspacePath,
    pub rank:     u32,
    pub score:    f32,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TraceFusionInput {
    pub chunk_id:     ChunkId,
    pub lexical_rank: Option<u32>,
    pub vector_rank:  Option<u32>,
    pub fusion_score: f32,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceTiming {
    pub lexical_ms: u64,
    pub vector_ms:  u64,
    pub fusion_ms:  u64,
    pub total_ms:   u64,
}

IndexStats 확장 (stats.rs 또는 위치 동일):

pub struct IndexStats {
    // 기존
    pub doc_count:      u64,
    pub chunk_count:    u64,
    pub asset_count:    u64,
    pub last_ingest_at: Option<OffsetDateTime>,
    // fb-37
    #[serde(default)]
    pub media_breakdown: BTreeMap<String, u64>,
    #[serde(default)]
    pub lang_breakdown:  BTreeMap<String, u64>,
    #[serde(default)]
    pub index_bytes:     IndexBytes,
    #[serde(default)]
    pub stale_doc_count: u64,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IndexBytes {
    pub sqlite:  u64,
    pub lancedb: u64,
}

#[serde(default)] — 옛 JSON 누락 시 zero-valued 으로 deserialize (backwards-compat).

kebab-store-sqlite (stats.rs)

pub fn breakdowns(conn: &rusqlite::Connection, threshold_days: u64)
    -> rusqlite::Result<(BTreeMap<String,u64>, BTreeMap<String,u64>, u64)>;

pub fn index_bytes(data_dir: &Path) -> std::io::Result<IndexBytes>;

기존 stats helper 가 이 두 함수 호출해 IndexStats 채움. 신규 query:

  • media: SELECT CASE WHEN json_type(media_type)='text' THEN json_extract(media_type,'$') ELSE (SELECT key FROM json_each(media_type) LIMIT 1) END AS kind, COUNT(DISTINCT d.doc_id) FROM documents d JOIN assets a ON a.asset_id=d.asset_id GROUP BY kind
  • lang: SELECT COALESCE(lang,'null') AS l, COUNT(*) FROM documents GROUP BY l
  • stale: SELECT COUNT(*) FROM documents WHERE updated_at < ? (threshold_days > 0 일 때만; 0 면 0 반환).

kebab-search (hybrid.rs)

impl HybridRetriever {
    pub fn search_with_trace(&self, query: &SearchQuery)
        -> Result<(Vec<SearchHit>, SearchTrace)>;
}

기존 Retriever::search 무변경. search_with_trace 는 hybrid 전용 (lexical/vector mode 도 한 쪽만 채워 동일 type 반환). 내부:

  1. Instant::now() 기록, lex retriever 호출, lex_ms 측정.
  2. 같은 패턴 vec.
  3. fuse — fusion_ms 측정.
  4. trace 빌드: lex/vec 전체 list → TraceCandidate 매핑. rrf_inputs = lex vec union (chunk_id 기준), 각 entry 의 lexical_rank/vector_rank/fusion_score 캡처. fusion 결과 ranking 과 동일.
  5. total_ms = 처음~끝.

kebab-app (app.rs)

#[doc(hidden)]
pub fn search_with_trace_with_config(
    cfg: kebab_config::Config,
    query: &str,
    opts: SearchOpts,  // 기존 + trace: bool
) -> Result<(SearchResponse, Option<SearchTrace>)>;

opts.trace = true 시:

  • cache bypass (no_cache = true 강제).
  • HybridRetriever::search_with_trace 호출.
  • SearchResponse 빌드 + trace 별도 반환 (caller 가 wire 합성).

기존 search_with_config 무변경 (zero-overhead path).

kebab-cli (Cmd::Search)

Cmd::Search {
    // 기존 + fb-34 + fb-36
    query, k, mode, explain, no_cache,
    max_tokens, snippet_chars, cursor,
    tag, lang, path_glob, trust_min, media, ingested_after, doc_id,
    // fb-37
    #[arg(long)] trace: bool,
}

dispatch:

  • trace == false → 기존 search_with_config 경로.
  • trace == truesearch_with_trace_with_config 호출, wire 합성 시 search_response.v1 JSON 에 trace 필드 inject.

non---json 출력:

  • --trace 면 hits 후 \nTrace:\n lexical (N hits, Xms): top3...\n vector (M hits, Yms): top3...\n rrf (Zms): top3...\n total: Wms.

kebab-mcp (tools/search.rs)

SearchInput.trace: Option<bool> 추가. dispatch 시 Some(true) 이면 위 _with_trace 호출. 출력 JSON 에 trace 합성 (wire 와 동일).

kebab-tui (search.rs + trace_popup.rs 신규)

  • Apptrace_popup: Option<TracePopupState> 필드.
  • search pane key handler tkebab_app::search_with_trace_with_config (현재 query/opts) 호출 → popup state 채움.
  • trace_popup.rs: ratatui Paragraph 또는 List 로 lex/vec/rrf 3 section, scroll, Esc 닫기.
  • cheatsheet 에 t = trace 한 줄 추가.

Test plan

kind description
unit (kebab-core) SearchTrace serde roundtrip — 모든 필드
unit (kebab-core) IndexStats 신규 4 필드 default — 비어있는 map / 0 bytes / 0 stale
unit (kebab-store-sqlite) breakdowns: 3 docs (md/md/pdf, en/en/null) → media {markdown:2,pdf:1,image:0,audio:0,other:0} (5키 패딩 적용), lang {en:2,null:1}
unit (kebab-store-sqlite) index_bytes: temp dir 내 sqlite 파일 + 빈 lancedb dir → sqlite>0, lancedb=0
unit (kebab-store-sqlite) breakdowns stale_doc_count: threshold 7 day, 8일 전 doc 1 + 어제 doc 2 → 1
unit (kebab-store-sqlite) breakdowns threshold=0 → stale_doc_count=0
unit (kebab-search/hybrid) search_with_trace: lex/vec list 가 단일 retriever 호출 결과 ==
unit (kebab-search/hybrid) timing 모두 정의됨, total ≥ lex+vec+fusion 의 sum (sequential 가정)
unit (kebab-search/hybrid) mode=lexical → vector=[], vector_ms=0, rrf_inputs.vector_rank 모두 None
통합 (kebab-cli) kebab search Q --trace --json → trace 키 존재, lexical/vector/rrf_inputs/timing 모두 valid shape
통합 (kebab-cli) kebab search Q --json (no --trace) → trace 키 부재
통합 (kebab-cli) kebab schema --json → media_breakdown 5 키, lang_breakdown 가능 키, index_bytes 두 필드, stale_doc_count 모두 존재
통합 (kebab-cli) 빈 corpus kebab schema --json → media_breakdown 5키 모두 0, lang_breakdown {}
통합 (kebab-cli) kebab search Q --trace (non-json) → stdout 에 Trace: section, lex/vec count + timing 표시
통합 (kebab-mcp) search input trace:true → 응답 JSON 에 trace 필드
통합 (kebab-mcp) search input trace 미지정 → 응답 trace 부재
TUI (kebab-tui) search pane 결과 있는 상태에서 t 키 → popup 열림 (state transitions)
TUI (kebab-tui) popup 열린 상태 Esc → popup 닫힘

media_breakdown 5키 패딩 책임: kebab-store-sqlite::breakdowns 가 SQL GROUP BY 결과를 받아 MEDIA_KINDS 순회해 누락 키 0 으로 채움.

Implementation steps (high-level)

  1. kebab-core: SearchTrace + 3 sibling struct + IndexStats 4 필드 + 단위 테스트.
  2. kebab-store-sqlite::stats: breakdowns + index_bytes 헬퍼 + 단위 테스트.
  3. kebab-store-sqlite::stats: 기존 IndexStats 빌더가 신규 4 필드 채우도록.
  4. kebab-search::hybrid: search_with_trace 구현 + 단위 테스트.
  5. kebab-app: search_with_trace_with_config facade + cache bypass.
  6. kebab-cli::Cmd::Search: --trace flag + dispatch + JSON wire 합성 + non-JSON pretty-print.
  7. kebab-cli 통합 테스트.
  8. kebab-mcp::tools::search: SearchInput.trace + dispatch + 통합 테스트.
  9. kebab-tui::search + trace_popup: t keybind + popup widget + cheatsheet.
  10. README + SMOKE + INDEX/spec status flip + SKILL.

Risks / notes

  • timing 정확도: 현재 hybrid sequential. 추후 병렬화 시 total_ms = max(lex,vec) + fusion 으로 재정의 — 그 시점 schema doc note 갱신.
  • lancedb dir walk cost: 큰 corpus 에서 O(file count) IO. 도그푸딩 corpus 작아 무시. 큰 corpus 만나면 cache 또는 lazy 도입 검토.
  • media_breakdown JSON shape: fb-36 과 동일한 CASE WHEN 패턴 재사용 — MediaType serde 의 dual shape (text variant vs tuple variant) 처리.
  • lang null 키: ASCII string "null" 사용. ISO 639 어떤 코드와도 충돌 X (3자 미만).
  • cache bypass when --trace: agent 가 인지해야 (SKILL/README 명시). 안 그러면 trace timing 이 cache hit 의 sub-ms 보고할 위험.
  • wire backwards-compat: trace 필드 optional + skip_serializing_if. IndexStats 신규 필드 #[serde(default)] 로 옛 reader 가 새 응답 deserialize 가능.
  • TUI popup: 별도 t 키. 충돌 검사 — 현재 search pane keybinds 확인 (i=inspect, /=focus, j/k=move, n=next, p=prev). t 미사용.

Out of scope

  • per-stage filter 적용 전/후 카운트 (filter-debug 별도 작업).
  • search 단계 병렬화 (sequential 유지).
  • lance 테이블 별 / column 별 index_bytes (단일 sum).
  • stats 시계열 (corpus_revision history).
  • --trace-level verbosity (single boolean).
  • TUI inspect pane 안 trace 통합 (search popup 으로 격리).
  • kebab stats 별도 명령 (schema 통합 결정).
  • --explain flag deprecation 알림 (현재 search dead, 무영향 — 별도 cleanup task).

Documentation updates (implementation PR 동시)

  • README.md: kebab search row 의 flag 표기에 --trace 추가, kebab schema row 에 신규 stats 한 줄 언급.
  • docs/SMOKE.md: --trace walkthrough + kebab schema --json 출력 sample.
  • tasks/p9/p9-fb-37-trace-and-stats.md: status: open → completed, design/plan 링크 추가.
  • tasks/INDEX.md: fb-37 행 .
  • integrations/claude-code/kebab/SKILL.md: mcp__kebab__search trace 입력 + 출력 trace shape 명시. kebab schema 신규 stats 필드 mention.
  • docs/wire-schema/v1/search_response.schema.json: trace optional 필드 추가.
  • docs/wire-schema/v1/schema.schema.json: stats 4 신규 필드 추가.