chore: PR #235 회차 2 리뷰 반영 — doctor 점검의 오탐·부작용 제거
2회차 리뷰가 1회차 지적 6건은 반영됐다고 확인했고, 대신 **1회차 대응으로
새로 넣은 `fts_shadow` 점검 자체에** HIGH 1건 + MEDIUM 2건을 찾았다.
1) 진단 명령이 없던 스토어를 만들었다 (HIGH)
`SqliteStore::open` 은 마이그레이션은 안 돌리지만 `Connection::open` 이
파일을 만든다. KB 없는 머신에서 `kebab doctor` 한 번이
`<data_dir>/kebab.sqlite` 를 남겼고, 그러면 이후 `open_existing` 이
성공해 버려 `not_indexed` 로 갈렸어야 할 경로가 일반 오류로 바뀐다.
`--readonly` 규약도 진단 명령이 깬다.
`SQLITE_FILE` 을 공개하고 파일 존재를 먼저 확인한 뒤에만 연다.
`doctor_does_not_create_a_store_where_none_exists` 로 고정.
2) V016 미적용 스토어를 고장 났다고 하고, 파괴적 조치를 권했다 (MEDIUM)
doctor 는 마이그레이션을 돌리지 않으므로 V015 스토어를 그대로 읽는다.
그런데 V009 백필이 rowid 를 명시하지 않아서, 그 시점 `chunks.rowid` 에
구멍이 있던 스토어는 V015 에서 **이미 어긋나 있다**. 거기서는 삭제가
`chunk_id` 기준이라 무해한데, 새 점검이 `ok: false` → exit 3 + "`kebab
reset` 후 재색인" 을 냈다. 바이너리를 올리고 doctor 부터 돌리는 자연스러운
순서에서 멀쩡한 KB 를 날리라고 안내한 셈이다.
`migration_version()` 을 보고 V016 미만이면 `ok: true` + "마이그레이션
후 점검된다"로 간다. 실제 해법이 그것이다 — V016 의 명시 rowid
repopulate 가 이 어긋남을 고쳐 준다.
`doctor_does_not_call_a_pre_v016_store_broken` 으로 고정.
3) env override 를 무시해 엉뚱한 DB 를 검사했다 (MEDIUM)
바로 위 `data_dir_writable` 은 "Config::load 와 같은 precedence 유지"를
이유로 env 를 다시 얹는데 이 블록은 안 했다. `KEBAB_STORAGE_DATA_DIR` 을
쓰면 data_dir 은 A 로 보고하면서 인덱스는 B 를 검사한다. 같은 방식으로
맞췄다.
4) 잔가지 (LOW)
- `None` 분기가 "스토어 없음 / 열기 실패 / 읽기 실패"를 전부 "KB 없음"
으로 뭉갰다. 조용한 실패를 드러내려는 점검이 자기 실패를 삼키면 안
되므로 "점검하지 못했다" 를 따로 뒀다 (hint 가 있으므로 CLI 가 `!`
로 찍는다).
- "표본 400행" 이 chunk 400개 미만인 스토어에서 거짓이었다. ASC/DESC
두 창이 겹치면 분자와 분모를 둘 다 두 번 셌다. UNION 으로 바꾸고
실제 표본 수를 함께 돌려준다 — 반환형이 `(checked, misaligned)` 다.
- `pub const SQLITE_FILE` 위에 "Kept private" 이라는 옛 독 주석이
남아 있었다.
- README 의 doctor 행에 `fts_shadow` 가 exit 3 을 낼 수 있다고 적었다.
새 플래그도 config 키도 없지만 **doctor 가 실패하는 새 사유**는
스크립트와 에이전트가 분기하는 사용자 표면이다.
- `tasks/phase-2-lexical-search.md` 가 `kebab index --rebuild-fts` 를
산출물로 나열하고 있었다. 배선된 적 없는 명령이고 이 PR 이 "CLI
경로 없음"이라고 못박은 것과 어긋나서 정정했다.
실측 확인: env override 를 준 doctor 가 지정한 KB 를 검사하고("양끝 400행
표본"), V015 실제 KB(28,427 문서)는 "V016 적용 전 (현재 V015)" 로 나오며,
KB 없는 경로에서는 파일을 남기지 않는다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
This commit is contained in:
@@ -91,7 +91,7 @@ Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go
|
||||
| `kebab fetch chunk\|doc\|span <id> [flags]` | indexed corpus 에서 verbatim text fetch |
|
||||
| `kebab eval run \| aggregate \| compare \| variants` | golden query 회귀 측정 + 변형 일관성 진단. `compare --max-drop <낙폭>` 은 어떤 지표든 그 이상 **떨어지면** exit 1 — 절대 하한이 아니라 델타 예산이다 (없으면 delta 만 출력하고 항상 exit 0) |
|
||||
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats |
|
||||
| `kebab doctor` | 설정 / 모델 / DB 헬스 체크. `vector_store` 체크는 Lance fragment·버전 수를 정보성으로 보여준다(종료 코드에 영향 없음) |
|
||||
| `kebab doctor` | 설정 / 모델 / DB 헬스 체크. `vector_store` 체크는 Lance fragment·버전 수를 정보성으로 보여준다(종료 코드에 영향 없음). `fts_shadow` 체크는 어휘 인덱스가 원본과 어긋났는지 보며, **어긋나면 exit 3** — 이 상태에서는 문서 삭제가 엉뚱한 인덱스 행을 지운다 |
|
||||
| `kebab mcp` | MCP stdio server (`search` / `bulk_search` / `ask` / `fetch` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`) |
|
||||
| `kebab reset [--all \| --data-only \| --vector-only \| --config-only \| --orphans-only] [--yes]` | XDG 데이터 wipe (**irreversible**) |
|
||||
|
||||
|
||||
@@ -460,35 +460,75 @@ pub fn doctor_with_config_path(
|
||||
//
|
||||
// Sampled, not exhaustive: the full anti-join is a 33s scan at 600k
|
||||
// chunks, which is too slow to put in front of every `kebab doctor`.
|
||||
// The head and tail of the rowid range cost 10ms and catch wholesale
|
||||
// Both ends of the rowid range cost 10ms and catch wholesale
|
||||
// renumbering, which is the shape any realistic drift takes. The
|
||||
// detail says "표본" so this is not read as a proof of alignment.
|
||||
{
|
||||
let cfg = loaded_cfg
|
||||
.clone()
|
||||
.unwrap_or_else(kebab_config::Config::defaults);
|
||||
let probe = kebab_store_sqlite::SqliteStore::open(&cfg.storage)
|
||||
.ok()
|
||||
.and_then(|s| s.fts_shadow_misaligned_sample(200).ok());
|
||||
// Same precedence as `Config::load` — `data_dir_writable` above
|
||||
// re-applies env for the same reason. Without this, doctor would
|
||||
// report one data_dir and probe a different one whenever
|
||||
// KEBAB_STORAGE_DATA_DIR is set.
|
||||
let cfg = match loaded_cfg.as_ref() {
|
||||
Some(c) => {
|
||||
let env: std::collections::HashMap<String, String> = std::env::vars().collect();
|
||||
c.clone().apply_env(&env)
|
||||
}
|
||||
None => kebab_config::Config::defaults(),
|
||||
};
|
||||
// `SqliteStore::open` creates the file, and doctor must not
|
||||
// leave a store behind on a machine that has none — check for it
|
||||
// first and report "no KB" rather than manufacturing one.
|
||||
let db = kebab_config::expand_path(&cfg.storage.data_dir, "")
|
||||
.join(kebab_store_sqlite::SQLITE_FILE);
|
||||
let probe = db.exists().then(|| {
|
||||
kebab_store_sqlite::SqliteStore::open(&cfg.storage)
|
||||
.ok()
|
||||
.map(|s| (s.migration_version(), s.fts_shadow_misaligned_sample(200)))
|
||||
});
|
||||
const V016: u32 = 16;
|
||||
let (fok, detail, hint) = match probe {
|
||||
Some(0) => (
|
||||
None => (
|
||||
true,
|
||||
"chunks_fts rowid 정렬 정상 (앞뒤 200행 표본)".to_string(),
|
||||
"KB 없음 — 점검할 인덱스가 아직 없다".to_string(),
|
||||
None,
|
||||
),
|
||||
Some(n) => (
|
||||
// Opened, but the probe failed. Do not report this as
|
||||
// health: a check whose whole point is to surface a silent
|
||||
// failure must not swallow its own.
|
||||
Some(None) | Some(Some((_, Err(_)))) => (
|
||||
true,
|
||||
"점검하지 못했다".to_string(),
|
||||
Some(
|
||||
"SQLite 를 열거나 읽지 못했다 — 다른 kebab 프로세스가 쓰는 중이거나 \
|
||||
DB 가 손상됐을 수 있다"
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
// Pre-V016 stores can be misaligned already: V009's backfill
|
||||
// inserted without an explicit rowid, so a store with gaps in
|
||||
// `chunks.rowid` at that moment got a densely-numbered shadow.
|
||||
// That is harmless there — deletes still address rows by
|
||||
// chunk_id. Applying V016 repopulates with explicit rowids and
|
||||
// fixes it, so the advice is "migrate", never "wipe".
|
||||
Some(Some((Some(v), _))) if v < V016 => (
|
||||
true,
|
||||
format!("V016 적용 전 (현재 V{v:03}) — 마이그레이션 후 점검된다"),
|
||||
None,
|
||||
),
|
||||
Some(Some((_, Ok((checked, 0))))) => (
|
||||
true,
|
||||
format!("chunks_fts rowid 정렬 정상 (양끝 {checked}행 표본)"),
|
||||
None,
|
||||
),
|
||||
Some(Some((_, Ok((checked, bad))))) => (
|
||||
false,
|
||||
format!("chunks_fts rowid 정렬 어긋남 — 표본 400행 중 {n}행 불일치"),
|
||||
format!("chunks_fts rowid 정렬 어긋남 — 표본 {checked}행 중 {bad}행 불일치"),
|
||||
Some(
|
||||
"삭제가 엉뚱한 FTS 행을 지우고 있다. `kebab reset` 후 재색인으로 \
|
||||
인덱스를 다시 만들어라"
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
// No store yet, or the probe itself failed — either way this
|
||||
// is not evidence of drift, and claiming health would be
|
||||
// worse than saying we could not look.
|
||||
None => (true, "확인 불가 (KB 없음)".to_string(), None),
|
||||
};
|
||||
checks.push(DoctorCheck {
|
||||
name: "fts_shadow".to_string(),
|
||||
|
||||
@@ -83,3 +83,96 @@ fn doctor_flags_outdated_config() {
|
||||
.unwrap();
|
||||
assert!(check.ok, "after migrate should pass");
|
||||
}
|
||||
|
||||
/// `doctor` is a diagnostic and must not manufacture the store it is
|
||||
/// asked about. The `fts_shadow` check (issue #229 / V016) reads SQLite,
|
||||
/// and `SqliteStore::open` creates the file — so on a machine with no KB
|
||||
/// yet, running doctor once used to leave an empty `kebab.sqlite` behind.
|
||||
#[test]
|
||||
fn doctor_does_not_create_a_store_where_none_exists() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data = dir.path().join("data");
|
||||
let cfg = dir.path().join("config.toml");
|
||||
fs::write(
|
||||
&cfg,
|
||||
format!(
|
||||
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n\n\
|
||||
[storage]\ndata_dir = \"{}\"\n",
|
||||
data.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
|
||||
let check = report
|
||||
.checks
|
||||
.iter()
|
||||
.find(|c| c.name == "fts_shadow")
|
||||
.expect("doctor must report fts_shadow even with no store");
|
||||
assert!(check.ok, "a missing store is not a drifted one");
|
||||
|
||||
assert!(
|
||||
!data.join(kebab_store_sqlite::SQLITE_FILE).exists(),
|
||||
"doctor must not create {} — it only reports",
|
||||
kebab_store_sqlite::SQLITE_FILE
|
||||
);
|
||||
}
|
||||
|
||||
/// A store that predates V016 can be misaligned already — V009's
|
||||
/// backfill inserted without an explicit rowid — but there the delete
|
||||
/// trigger still addresses rows by chunk_id, so the drift is harmless.
|
||||
/// Telling that user to `kebab reset` would destroy a healthy KB over a
|
||||
/// condition that applying the migration fixes by itself.
|
||||
#[test]
|
||||
fn doctor_does_not_call_a_pre_v016_store_broken() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data = dir.path().join("data");
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
let cfg = dir.path().join("config.toml");
|
||||
fs::write(
|
||||
&cfg,
|
||||
format!(
|
||||
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n\n\
|
||||
[storage]\ndata_dir = \"{}\"\n",
|
||||
data.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A store stamped at V015 with a shadow deliberately misaligned:
|
||||
// exactly what a pre-V016 upgrade can look like.
|
||||
let db = data.join(kebab_store_sqlite::SQLITE_FILE);
|
||||
let conn = rusqlite::Connection::open(&db).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE refinery_schema_history (version INTEGER);
|
||||
INSERT INTO refinery_schema_history VALUES (15);
|
||||
CREATE TABLE chunks (chunk_id TEXT PRIMARY KEY, text TEXT);
|
||||
CREATE VIRTUAL TABLE chunks_fts USING fts5(chunk_id UNINDEXED, text);
|
||||
INSERT INTO chunks VALUES ('a', 'x'), ('b', 'y');
|
||||
INSERT INTO chunks_fts(rowid, chunk_id, text) VALUES (77, 'a', 'x');",
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
|
||||
let check = report
|
||||
.checks
|
||||
.iter()
|
||||
.find(|c| c.name == "fts_shadow")
|
||||
.unwrap();
|
||||
assert!(
|
||||
check.ok,
|
||||
"a pre-V016 store must not fail the check: {}",
|
||||
check.detail
|
||||
);
|
||||
assert!(
|
||||
check.detail.contains("V016"),
|
||||
"the detail should say the migration has not been applied, got {:?}",
|
||||
check.detail
|
||||
);
|
||||
assert!(
|
||||
check.hint.as_deref().unwrap_or_default().is_empty(),
|
||||
"and must not tell the user to reset: {:?}",
|
||||
check.hint
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,4 +35,4 @@ pub use error::StoreError;
|
||||
pub use eval::{EvalQueryResultRecord, EvalRunRecord, EvalRunRow};
|
||||
pub use fts::rebuild_chunks_fts;
|
||||
pub use jobs::IngestRunRow;
|
||||
pub use store::{CountSummary, NotIndexed, SqliteStore, purge_deleted_workspace_path};
|
||||
pub use store::{CountSummary, NotIndexed, SQLITE_FILE, SqliteStore, purge_deleted_workspace_path};
|
||||
|
||||
@@ -41,10 +41,10 @@ static TEMP_SUFFIX_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
/// truncated, mirrored from `kb-core`'s newtype invariant.
|
||||
const ASSET_ID_HEX_LEN: usize = 32;
|
||||
|
||||
/// Default file name under `config.storage.data_dir`. Kept private — the
|
||||
/// path layout is a §6.3 design decision, not part of the store's public
|
||||
/// surface.
|
||||
const SQLITE_FILE: &str = "kebab.sqlite";
|
||||
/// Filename of the main SQLite database under `storage.data_dir`.
|
||||
/// Public so read-only probes (e.g. `kebab doctor`) can test for the
|
||||
/// file before calling [`SqliteStore::open`], which creates it.
|
||||
pub const SQLITE_FILE: &str = "kebab.sqlite";
|
||||
|
||||
/// Subdirectory under `data_dir` holding shard-prefixed asset bytes
|
||||
/// (`<aa>/<asset_id>`). Mirrors design §6.3.
|
||||
@@ -351,9 +351,10 @@ fn temp_path_for(dest: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
impl SqliteStore {
|
||||
/// Count rows in a bounded sample where `chunks_fts` and `chunks`
|
||||
/// disagree about which chunk lives at a given rowid (issue #229 /
|
||||
/// V016). `0` means the sample is aligned.
|
||||
/// Sample rows from both ends of the rowid range and report
|
||||
/// `(checked, misaligned)` — how many were compared and how many
|
||||
/// disagree with `chunks` about which chunk lives at that rowid
|
||||
/// (issue #229 / V016). A `misaligned` of 0 means the sample agrees.
|
||||
///
|
||||
/// V016 pointed the FTS delete trigger at `rowid`, so this alignment
|
||||
/// is what keeps a delete from removing some other document's shadow
|
||||
@@ -362,27 +363,51 @@ impl SqliteStore {
|
||||
/// 10ms, and enough to catch the wholesale renumbering that any
|
||||
/// realistic drift would produce. It is a health probe, not a proof:
|
||||
/// a drift confined to the middle of the range passes.
|
||||
pub fn fts_shadow_misaligned_sample(&self, sample: usize) -> Result<usize> {
|
||||
pub fn fts_shadow_misaligned_sample(&self, sample: usize) -> Result<(usize, usize)> {
|
||||
let conn = self.read_conn();
|
||||
let mut total = 0usize;
|
||||
for order in ["ASC", "DESC"] {
|
||||
let n: i64 = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT COUNT(*) FROM (
|
||||
SELECT rowid AS r, chunk_id AS cid FROM chunks
|
||||
ORDER BY rowid {order} LIMIT ?1
|
||||
) c
|
||||
LEFT JOIN chunks_fts f ON f.rowid = c.r
|
||||
WHERE f.rowid IS NULL OR f.chunk_id != c.cid"
|
||||
),
|
||||
params![sample as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(StoreError::from)?;
|
||||
total += usize::try_from(n).unwrap_or(0);
|
||||
}
|
||||
Ok(total)
|
||||
// UNION of the two ends rather than two separate counts: on a
|
||||
// store with fewer rows than 2×sample the windows overlap, and
|
||||
// counting them separately would double both the numerator and
|
||||
// the denominator the caller reports.
|
||||
let (checked, bad): (i64, i64) = conn
|
||||
.query_row(
|
||||
"WITH ends AS (
|
||||
SELECT rowid AS r, chunk_id AS cid FROM
|
||||
(SELECT rowid, chunk_id FROM chunks ORDER BY rowid ASC LIMIT ?1)
|
||||
UNION
|
||||
SELECT rowid AS r, chunk_id AS cid FROM
|
||||
(SELECT rowid, chunk_id FROM chunks ORDER BY rowid DESC LIMIT ?1)
|
||||
)
|
||||
SELECT COUNT(*),
|
||||
COALESCE(SUM(f.rowid IS NULL OR f.chunk_id != c.cid), 0)
|
||||
FROM ends c LEFT JOIN chunks_fts f ON f.rowid = c.r",
|
||||
params![sample as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.map_err(StoreError::from)?;
|
||||
Ok((
|
||||
usize::try_from(checked).unwrap_or(0),
|
||||
usize::try_from(bad).unwrap_or(0),
|
||||
))
|
||||
}
|
||||
|
||||
/// Highest applied refinery migration version, or `None` if the
|
||||
/// history table is missing (a file that is not a kebab store, or
|
||||
/// one opened before `run_migrations`).
|
||||
///
|
||||
/// Read-only callers use this to tell "this store predates the
|
||||
/// migration I care about" from "this store is broken" — the two
|
||||
/// need opposite advice.
|
||||
pub fn migration_version(&self) -> Option<u32> {
|
||||
self.read_conn()
|
||||
.query_row(
|
||||
"SELECT MAX(version) FROM refinery_schema_history",
|
||||
[],
|
||||
|r| r.get::<_, Option<i64>>(0),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
}
|
||||
|
||||
/// p9-fb-19: read the persisted `corpus_revision` from the `kv`
|
||||
|
||||
@@ -852,8 +852,9 @@ fn fts_v016_shadow_probe_detects_forced_drift() {
|
||||
}
|
||||
assert_eq!(
|
||||
store.fts_shadow_misaligned_sample(200).unwrap(),
|
||||
0,
|
||||
"a freshly written shadow must be aligned"
|
||||
(3, 0),
|
||||
"a freshly written shadow must be aligned, and the two rowid \
|
||||
windows must not double-count a store smaller than the sample"
|
||||
);
|
||||
|
||||
// Shift one shadow row off its source. `chunks_fts` rows are not
|
||||
@@ -868,8 +869,15 @@ fn fts_v016_shadow_probe_detects_forced_drift() {
|
||||
)
|
||||
.expect("reinsert at a drifted rowid");
|
||||
|
||||
assert!(
|
||||
store.fts_shadow_misaligned_sample(200).unwrap() > 0,
|
||||
"the probe must report the drifted row"
|
||||
assert_eq!(
|
||||
store.fts_shadow_misaligned_sample(200).unwrap(),
|
||||
(3, 1),
|
||||
"the probe must report the one drifted row out of three checked"
|
||||
);
|
||||
assert_eq!(
|
||||
store.migration_version(),
|
||||
Some(16),
|
||||
"a migrated store must report V016 so doctor can tell it apart \
|
||||
from a pre-V016 one, where the same drift is harmless"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,11 @@ git history.
|
||||
|
||||
남는 실제 경로는 **앞으로 `chunks` 를 테이블 재작성 방식(새 테이블 → 복사 → DROP → RENAME)으로 바꾸는 마이그레이션**이다. 그러면 rowid 가 조용히 다시 매겨진다. V016 주석에 "그런 마이그레이션은 repopulate 를 같이 돌려야 한다"는 울타리를 박아 뒀다. 지금까지의 `chunks` 변경은 전부 in-place 다 (V009 `ADD COLUMN`, V013 `DROP COLUMN`).
|
||||
|
||||
알려진 유발 경로가 없더라도, 정확성을 떠받치게 된 불변식이 눈에 안 보이는 상태로 남는 게 문제다. 그래서 `kebab doctor` 에 `fts_shadow` 점검을 넣었다. 전수 대조는 60만 chunk 에서 33초라 doctor 앞에 둘 수 없어서 rowid 범위의 앞뒤 200행씩만 본다 — 10 ms 이고, 현실적인 드리프트가 취하는 형태(전면 재번호)는 잡는다. 표본이라는 사실을 detail 문구에 적어 두었으므로 정렬 증명으로 읽히지는 않는다.
|
||||
알려진 유발 경로가 없더라도, 정확성을 떠받치게 된 불변식이 눈에 안 보이는 상태로 남는 게 문제다. 그래서 `kebab doctor` 에 `fts_shadow` 점검을 넣었다. 전수 대조는 60만 chunk 에서 33초라 doctor 앞에 둘 수 없어서 rowid 범위의 양끝 200행씩만 본다 — 10 ms 이고, 현실적인 드리프트가 취하는 형태(전면 재번호)는 잡는다. 표본이라는 사실을 detail 문구에 적어 두었으므로 정렬 증명으로 읽히지는 않는다. **어긋나면 exit 3** 이므로 README 의 doctor 행에도 적었다.
|
||||
|
||||
이 점검은 세 가지를 구분한다. V016 미적용 스토어는 위에 적은 V009 백필 사정 때문에 이미 어긋나 있을 수 있는데 거기서는 무해하므로 `ok: true` 로 두고 "마이그레이션 후 점검된다"고만 말한다 — `kebab reset` 을 권했다가는 멀쩡한 KB 를 날리게 된다. SQLite 를 열거나 읽지 못한 경우도 정상으로 보고하지 않고 "점검하지 못했다"로 따로 말한다. 조용한 실패를 드러내려는 점검이 자기 실패를 삼키면 안 된다. 나머지 경우에만 실제 정렬을 판정한다.
|
||||
|
||||
doctor 는 `data_dir_writable` 과 같은 precedence 로 env 를 다시 얹는다(`KEBAB_STORAGE_DATA_DIR`). 안 그러면 data_dir 은 A 로 보고하면서 인덱스는 B 를 검사한다. 그리고 `SqliteStore::open` 이 파일을 만들기 때문에, 파일 존재를 먼저 확인한 뒤에만 연다 — 진단 명령이 없던 스토어를 만들어 놓고 가면 안 된다.
|
||||
|
||||
참고로 V016 이전 스토어라고 해서 정렬이 어긋나 있는 건 아니다. 트리거가 매 연산을 그대로 미러링하므로 FTS5 가 알아서 매기는 번호도 `chunks` 와 나란히 간다. 다만 **V009 의 백필**은 rowid 를 명시하지 않고 `SELECT … FROM chunks` 로 채웠으므로, 그 시점에 `chunks` 의 rowid 에 구멍이 있었다면 shadow 는 1..N 으로 촘촘히 매겨져 어긋난다. V016 의 명시 rowid repopulate 가 그런 스토어를 바로잡는다. 이 머신의 실제 KB(V015, 문서 28,427건)는 V009 이후 새로 색인한 것이라 점검이 정상으로 나온다.
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ pub struct SearchHit {
|
||||
## 인덱스 라이프사이클
|
||||
|
||||
- ingest 시 trigger 로 자동 동기화.
|
||||
- `kebab index --rebuild-fts` command 로 FTS table 재구축 (chunker version bump 후 사용).
|
||||
- FTS table 재구축 경로 (chunker version bump 후 사용). ※ 2026-08-16 현재 `kebab index --rebuild-fts` 는 배선되지 않았다 — 재구축은 `kebab_store_sqlite::rebuild_chunks_fts` 라이브러리 API 뿐이고, CLI 경로는 `kebab reset` 후 재색인이다.
|
||||
- `index_version` 은 `(schema_version, fts_config_hash)` 조합.
|
||||
|
||||
## kebab-app facade 확장
|
||||
@@ -87,7 +87,7 @@ pub fn search(query: SearchQuery) -> anyhow::Result<Vec<SearchHit>>;
|
||||
|
||||
```text
|
||||
kebab search "Rust workspace 설계" [--k 10] [--tag rust] [--mode lexical]
|
||||
kebab index --rebuild-fts
|
||||
# (미배선 — 위 주석 참조)
|
||||
```
|
||||
|
||||
출력 예:
|
||||
|
||||
Reference in New Issue
Block a user