chore: PR #235 회차 1 리뷰 반영 — 실패 양상 탐지 + 사실관계 정정
리뷰 두 건에서 나온 지적을 반영한다. 1) 실패 양상이 바뀐 것을 다루지 않았다 (MEDIUM) `chunk_id` 로 행을 찾던 때는 shadow 정렬이 어긋나도 느릴 뿐 정확했다. rowid 로 찾으면 어긋난 순간 `chunks_ad` 가 남의 문서 shadow 행을 지우고 아무 오류도 내지 않는다. 즉 이 PR 은 실패 양상을 "느림"에서 "조용한 오삭제"로 바꿨는데, 그 불변식이 눈에 안 보이는 상태였다. `kebab doctor` 에 `fts_shadow` 점검을 넣었다. 전수 대조는 60만 chunk 에서 33초라 doctor 앞에 둘 수 없어 rowid 범위 앞뒤 200행씩만 본다 — 실측 10 ms 이고, 현실적인 드리프트가 취하는 전면 재번호는 잡는다. 표본이라는 사실을 detail 에 적어 정렬 증명으로 읽히지 않게 했다. `SqliteStore::fts_shadow_misaligned_sample` 이 질의를 들고 있다. 2) VACUUM 위험을 과장했다 (정정) 초안이 "VACUUM 이 rowid 를 다시 매길 수 있고 그러면 정렬이 깨진다"고 단정했다. 실제로 재보니 다시 매기지 않았다 — 실제 KB 사본(60만 chunk, 문서 3,000건을 지워 rowid 에 구멍을 낸 뒤)과 소형 합성 DB 양쪽에서 VACUUM 후 전수 대조 불일치가 0 이었다 (sqlite 3.53.4). SQLite 문서가 "다시 매길 수 있다"고 적은 것은 보장이 없다는 뜻이지 실제로 그렇게 한다는 뜻이 아니다. 문구를 실측대로 고쳤다. 남는 실제 경로는 앞으로 `chunks` 를 테이블 재작성 방식으로 바꾸는 마이그레이션이다. V016 주석에 "그런 마이그레이션은 repopulate 를 같이 돌려야 한다"는 울타리를 박았다. 3) 같은 실측치를 파일마다 다르게 적었다 (MEDIUM) 삭제 시간이 커밋 메시지·HOTFIXES 는 2.0초, 마이그레이션 주석·테스트 독스트링·설계 문서는 0.73초였다. 0.73초는 손으로 마이그레이션한 사본을 따뜻한 캐시에서 잰 값이고 2.0초는 릴리스 바이너리가 마이그레이션한 새 사본에서 잰 값이다. 보수적인 2.0초로 통일했다. '한국' hit 수도 15,837(문서 200건 삭제 후) 과 15,977(전체 코퍼스) 이 섞여 있어 15,977 로 통일했다. 4) docs/ARCHITECTURE.md 디렉토리 트리가 V001..V015 로 멈춰 있었다 (MEDIUM) V016 까지로 갱신. README 는 손대지 않는다 — 새 서브커맨드·플래그·config 키·`--json` 필드가 없다. 5) 잔가지 (LOW) `:=` 검사가 번들 SQLite 의 FTS5 idxStr 인코딩에 기대는 것을 assert 메시지에 적었다 (rusqlite 를 올린 직후 실패하면 거기부터 보라는 뜻). 가상 테이블은 항상 `SCAN` 으로 찍히므로 `SEARCH` 로 대체 검사할 방법이 없다는 것도 독스트링에 남겼다. `kb index --rebuild-fts` 라는 옛 이름 + 존재하지 않는 명령 참조 두 곳을 지웠다. `fts_v016_shadow_probe_detects_forced_drift` 로 탐지 자체를 시험한다 — 어긋난 shadow 행을 억지로 만들어 점검이 잡는지 본다. 잡지 못하는 점검은 없느니만 못하다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
This commit is contained in:
@@ -445,6 +445,59 @@ pub fn doctor_with_config_path(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fts_shadow — chunks_fts rowid alignment (issue #229 / V016).
|
||||||
|
//
|
||||||
|
// V016 made the FTS delete trigger address rows by rowid instead of
|
||||||
|
// by chunk_id, which is what took a document delete from 1590s to
|
||||||
|
// 2s on a 600k-chunk store. The cost is that the invariant became
|
||||||
|
// load-bearing for correctness rather than only for speed: if the
|
||||||
|
// shadow's rowids ever drift from `chunks`, `chunks_ad` deletes some
|
||||||
|
// other document's row and reports nothing. Under the old chunk_id
|
||||||
|
// predicate a drifted shadow was merely slow. Nothing in the
|
||||||
|
// codebase renumbers rowids today (and VACUUM was measured not to),
|
||||||
|
// so this exists to make a silent failure a visible one, not because
|
||||||
|
// a trigger is known.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// 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());
|
||||||
|
let (fok, detail, hint) = match probe {
|
||||||
|
Some(0) => (
|
||||||
|
true,
|
||||||
|
"chunks_fts rowid 정렬 정상 (앞뒤 200행 표본)".to_string(),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
Some(n) => (
|
||||||
|
false,
|
||||||
|
format!("chunks_fts rowid 정렬 어긋남 — 표본 400행 중 {n}행 불일치"),
|
||||||
|
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(),
|
||||||
|
ok: fok,
|
||||||
|
detail,
|
||||||
|
hint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// vector_store — Lance fragment / version-history health (issue #230).
|
// vector_store — Lance fragment / version-history health (issue #230).
|
||||||
{
|
{
|
||||||
let cfg = loaded_cfg
|
let cfg = loaded_cfg
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
//!
|
//!
|
||||||
//! Normal operation needs nothing from this module — every mutation on
|
//! Normal operation needs nothing from this module — every mutation on
|
||||||
//! `chunks` propagates automatically inside the host transaction. The
|
//! `chunks` propagates automatically inside the host transaction. The
|
||||||
//! only entry point exposed here is [`rebuild_chunks_fts`], used as the
|
//! only entry point exposed here is [`rebuild_chunks_fts`], the escape
|
||||||
//! escape hatch for `kb index --rebuild-fts` (wired by `kb-cli` later;
|
//! hatch for a shadow that has drifted from `chunks`. It is a library
|
||||||
//! out of scope for P2-1).
|
//! API with no CLI wiring — `kebab doctor`'s `fts_shadow` check detects
|
||||||
|
//! drift, but recovery through the CLI is `kebab reset` plus a
|
||||||
|
//! re-ingest.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|||||||
@@ -351,6 +351,40 @@ fn temp_path_for(dest: &Path) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteStore {
|
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.
|
||||||
|
///
|
||||||
|
/// V016 pointed the FTS delete trigger at `rowid`, so this alignment
|
||||||
|
/// is what keeps a delete from removing some other document's shadow
|
||||||
|
/// row. The full anti-join is a 33s scan at 600k chunks, so this
|
||||||
|
/// takes `sample` rows from each end of the rowid range instead —
|
||||||
|
/// 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> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
/// p9-fb-19: read the persisted `corpus_revision` from the `kv`
|
/// p9-fb-19: read the persisted `corpus_revision` from the `kv`
|
||||||
/// table. Returns `0` if the row is missing (not migrated yet) or
|
/// table. Returns `0` if the row is missing (not migrated yet) or
|
||||||
/// unparseable — defensive: callers use the value as a cache-key
|
/// unparseable — defensive: callers use the value as a cache-key
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ fn fts_rebuild_chunks_fts_recovers_from_drift() {
|
|||||||
insert_chunk(&conn, &cid, &"d".repeat(32), "[]", "recovered");
|
insert_chunk(&conn, &cid, &"d".repeat(32), "[]", "recovered");
|
||||||
|
|
||||||
// Manually wipe chunks_fts to simulate drift; this is the failure
|
// Manually wipe chunks_fts to simulate drift; this is the failure
|
||||||
// mode `kb index --rebuild-fts` exists to recover from.
|
// mode `rebuild_chunks_fts` exists to recover from.
|
||||||
conn.execute("DELETE FROM chunks_fts", []).unwrap();
|
conn.execute("DELETE FROM chunks_fts", []).unwrap();
|
||||||
assert_eq!(count(&conn, "chunks_fts"), 0);
|
assert_eq!(count(&conn, "chunks_fts"), 0);
|
||||||
assert_eq!(count(&conn, "chunks"), 1);
|
assert_eq!(count(&conn, "chunks"), 1);
|
||||||
@@ -742,7 +742,10 @@ fn fts_v016_delete_removes_only_the_deleted_row() {
|
|||||||
/// equality itself. Addressing by `chunk_id` leaves that field empty
|
/// equality itself. Addressing by `chunk_id` leaves that field empty
|
||||||
/// because `chunk_id` is UNINDEXED, and the delete degrades to a full
|
/// because `chunk_id` is UNINDEXED, and the delete degrades to a full
|
||||||
/// index scan (measured at 1590s for 200 documents over 600k chunks,
|
/// index scan (measured at 1590s for 200 documents over 600k chunks,
|
||||||
/// against 0.73s for this plan).
|
/// against 2.0s for this plan).
|
||||||
|
///
|
||||||
|
/// A virtual table is always reported as `SCAN`, so the idxStr field is
|
||||||
|
/// the only signal available here — there is no `SEARCH` to assert on.
|
||||||
#[test]
|
#[test]
|
||||||
fn fts_v016_delete_plan_uses_rowid_not_a_scan() {
|
fn fts_v016_delete_plan_uses_rowid_not_a_scan() {
|
||||||
let env = common::TestEnv::new();
|
let env = common::TestEnv::new();
|
||||||
@@ -766,7 +769,9 @@ fn fts_v016_delete_plan_uses_rowid_not_a_scan() {
|
|||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
by_rowid.contains(":="),
|
by_rowid.contains(":="),
|
||||||
"rowid delete must hand FTS5 the equality constraint; got {by_rowid:?}"
|
"rowid delete must hand FTS5 the equality constraint; got {by_rowid:?}. \
|
||||||
|
This reads the bundled SQLite's FTS5 idxStr encoding — if it fails right \
|
||||||
|
after a rusqlite bump, check that before suspecting the migration."
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!by_chunk_id.contains(":="),
|
!by_chunk_id.contains(":="),
|
||||||
@@ -824,3 +829,47 @@ fn fts_v016_rebuild_preserves_rowid_and_korean_morphemes() {
|
|||||||
"deletes after a rebuild must still find their shadow rows"
|
"deletes after a rebuild must still find their shadow rows"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `fts_shadow` doctor probe has to actually notice drift, or it is
|
||||||
|
/// worse than no check — it would report health while deletes silently
|
||||||
|
/// remove other documents' rows. Forcing a misaligned shadow row is the
|
||||||
|
/// only way to test that, since nothing in the codebase produces one.
|
||||||
|
#[test]
|
||||||
|
fn fts_v016_shadow_probe_detects_forced_drift() {
|
||||||
|
let env = common::TestEnv::new();
|
||||||
|
let store = SqliteStore::open(&env.config().storage).unwrap();
|
||||||
|
store.run_migrations().unwrap();
|
||||||
|
|
||||||
|
let conn = raw_conn_no_fk(&env);
|
||||||
|
for i in 0..3u8 {
|
||||||
|
insert_chunk(
|
||||||
|
&conn,
|
||||||
|
&format!("{i:032}"),
|
||||||
|
&"d".repeat(32),
|
||||||
|
"[]",
|
||||||
|
&format!("body {i}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
store.fts_shadow_misaligned_sample(200).unwrap(),
|
||||||
|
0,
|
||||||
|
"a freshly written shadow must be aligned"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shift one shadow row off its source. `chunks_fts` rows are not
|
||||||
|
// UPDATE-able in place, so delete and reinsert at a rowid that
|
||||||
|
// belongs to nothing.
|
||||||
|
conn.execute("DELETE FROM chunks_fts WHERE rowid = 2", [])
|
||||||
|
.expect("drop one shadow row");
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO chunks_fts(rowid, chunk_id, doc_id, heading_path, text)
|
||||||
|
VALUES (9999, ?, ?, '[]', 'body 1')",
|
||||||
|
rusqlite::params![format!("{:032}", 1u8), "d".repeat(32)],
|
||||||
|
)
|
||||||
|
.expect("reinsert at a drifted rowid");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
store.fts_shadow_misaligned_sample(200).unwrap() > 0,
|
||||||
|
"the probe must report the drifted row"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ kebab/
|
|||||||
│ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체). src/derivation_payload.rs = 캐시 payload 인코딩 (v0.21.0)
|
│ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체). src/derivation_payload.rs = 캐시 payload 인코딩 (v0.21.0)
|
||||||
│ ├── kebab-mcp/ # stdio MCP server — tools: schema, doctor, search, bulk_search, ask, fetch, ingest_file, ingest_stdin (P9-FB-30)
|
│ ├── kebab-mcp/ # stdio MCP server — tools: schema, doctor, search, bulk_search, ask, fetch, ingest_file, ingest_stdin (P9-FB-30)
|
||||||
│ └── kebab-cli/ # binary (P0 → 핫픽스로 --config flag wiring 강화)
|
│ └── kebab-cli/ # binary (P0 → 핫픽스로 --config flag wiring 강화)
|
||||||
├── migrations/ # SQLite refinery V001..V015 (V012 = derivation_cache v0.21.0, V013 = drop chunk_aliases v0.25.0, V014 = documents.source_id v0.29.0, V015 = drop chat_sessions v0.31.0)
|
├── migrations/ # SQLite refinery V001..V016 (V012 = derivation_cache v0.21.0, V013 = drop chunk_aliases v0.25.0, V014 = documents.source_id v0.29.0, V015 = drop chat_sessions v0.31.0, V016 = chunks_fts rowid 삭제 #229)
|
||||||
└── fixtures/ # 테스트 fixture 트리
|
└── fixtures/ # 테스트 fixture 트리
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1102,7 +1102,7 @@ DDL 에 `content=''` 없음).
|
|||||||
로 교체. `chunk_id` 는 `UNINDEXED` 라 FTS5 에 색인이 없고, `DELETE FROM
|
로 교체. `chunk_id` 는 `UNINDEXED` 라 FTS5 에 색인이 없고, `DELETE FROM
|
||||||
chunks_fts WHERE chunk_id = ?` 는 색인 전체 스캔으로 떨어졌다 (chunk 60만
|
chunks_fts WHERE chunk_id = ?` 는 색인 전체 스캔으로 떨어졌다 (chunk 60만
|
||||||
기준 문서 200건 삭제 1590초). `chunks_fts.rowid` 를 `chunks.rowid` 와 맞추면
|
기준 문서 200건 삭제 1590초). `chunks_fts.rowid` 를 `chunks.rowid` 와 맞추면
|
||||||
같은 삭제가 0.73초다. 컬럼 구성·tokenizer·검색 경로는 불변.
|
같은 삭제가 2.0초다. 컬럼 구성·tokenizer·검색 경로는 불변.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE chunks (
|
CREATE TABLE chunks (
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
--
|
--
|
||||||
-- 실측 (실제 KB 사본, 문서 28,427건 / chunk 600,808건, 문서 200건 삭제):
|
-- 실측 (실제 KB 사본, 문서 28,427건 / chunk 600,808건, 문서 200건 삭제):
|
||||||
-- 현행 (chunk_id 로 DELETE) 1590.1초
|
-- 현행 (chunk_id 로 DELETE) 1590.1초
|
||||||
-- rowid 정렬 (이 마이그레이션) 0.73초
|
-- rowid 정렬 (이 마이그레이션) 2.0초
|
||||||
-- 삭제 후 남은 chunks 행 수와 chunks_fts 행 수가 양쪽 다 595,741 로 같고,
|
-- 삭제 후 남은 chunks 행 수와 chunks_fts 행 수가 양쪽 다 595,741 로 같고,
|
||||||
-- '한국'(15,837) / 'kebab'(3) / 'database'(1,067) 질의의 hit 수도 같다.
|
-- '한국'(15,977) / 'kebab'(3) / 'database'(1,067) 질의의 hit 수도 같다.
|
||||||
--
|
--
|
||||||
-- 해결: chunks_fts 의 rowid 를 chunks 의 rowid 와 맞추고, 삭제를 rowid 로
|
-- 해결: chunks_fts 의 rowid 를 chunks 의 rowid 와 맞추고, 삭제를 rowid 로
|
||||||
-- 한다. FTS5 는 rowid 로 B-tree 조회를 하므로 O(log n) 이 된다. 컬럼 구성과
|
-- 한다. FTS5 는 rowid 로 B-tree 조회를 하므로 O(log n) 이 된다. 컬럼 구성과
|
||||||
@@ -34,13 +34,28 @@
|
|||||||
-- 검색 경로의 컬럼 참조를 rowid join 으로 바꾸는 변경이 딸려온다. 삭제 비용은
|
-- 검색 경로의 컬럼 참조를 rowid join 으로 바꾸는 변경이 딸려온다. 삭제 비용은
|
||||||
-- rowid 정렬만으로 같은 복잡도로 내려가므로, 그림자 회수는 별 건으로 둔다.
|
-- rowid 정렬만으로 같은 복잡도로 내려가므로, 그림자 회수는 별 건으로 둔다.
|
||||||
--
|
--
|
||||||
-- rowid 정렬의 전제: `chunks` 는 `chunk_id TEXT PRIMARY KEY` 라 INTEGER
|
-- rowid 정렬이 깨지면 어떻게 되나: `chunks_ad` 가 남의 문서 shadow 행을
|
||||||
-- PRIMARY KEY 가 없다. SQLite 의 VACUUM 은 그런 테이블의 rowid 를 다시 매길
|
-- 지우고 아무 오류도 내지 않는다. `chunk_id` 로 찾던 때는 정렬이 어긋나도
|
||||||
-- 수 있고, 그러면 이 정렬이 깨진다. kebab 은 VACUUM 을 실행하지 않으며
|
-- 느릴 뿐 정확했으니, 이 마이그레이션은 실패 양상을 "느림"에서 "조용한
|
||||||
-- (코드베이스 전체에 없음), 사용자가 직접 실행했다면
|
-- 오삭제"로 바꾼다. 그래서 doctor 에 `fts_shadow` 점검을 같이 넣었다.
|
||||||
-- `kebab_store_sqlite::rebuild_chunks_fts` 가 복구 경로다. 참고로 issue 가
|
--
|
||||||
-- 제안한 external-content 도 같은 전제를 깔고 있어 이 위험은 선택지 간
|
-- 무엇이 정렬을 깨나: `chunks` 는 `chunk_id TEXT PRIMARY KEY` 라 INTEGER
|
||||||
-- 차이가 아니다.
|
-- PRIMARY KEY 가 없고, SQLite 문서는 그런 테이블의 rowid 를 VACUUM 이 다시
|
||||||
|
-- 매길 수 있다고 적어 둔다. 다만 실제로 재봤을 때는 다시 매기지 않았다 —
|
||||||
|
-- 실제 KB 사본(60만 chunk, 문서 3,000건 삭제로 구멍을 낸 뒤)과 소형 합성
|
||||||
|
-- DB 양쪽에서 VACUUM 후 불일치가 0 이었다 (sqlite 3.53.4). 즉 오늘의
|
||||||
|
-- VACUUM 은 안전하지만 문서가 보장하지는 않는다.
|
||||||
|
--
|
||||||
|
-- **앞으로 `chunks` 를 테이블 재작성 방식으로 바꾸는 마이그레이션**
|
||||||
|
-- (새 테이블 → 복사 → DROP → RENAME) **은 rowid 를 조용히 다시 매기므로,
|
||||||
|
-- 이 파일 아래의 repopulate 를 반드시 같이 돌려야 한다.** 지금까지의
|
||||||
|
-- `chunks` 변경은 전부 in-place 다 (V009 ADD COLUMN, V013 DROP COLUMN).
|
||||||
|
--
|
||||||
|
-- 정렬이 깨졌을 때의 복구는 `kebab_store_sqlite::rebuild_chunks_fts` 다.
|
||||||
|
-- 라이브러리 API 이고 CLI 로 배선돼 있지 않다 — 사용자 진입점은 doctor 의
|
||||||
|
-- `fts_shadow` 점검(탐지)까지이고, 복구는 `kebab reset` 후 재색인이다.
|
||||||
|
-- 참고로 issue 가 제안한 external-content 도 rowid 정렬을 똑같이 깔고 있어
|
||||||
|
-- 이 전제는 선택지 간 차이가 아니다.
|
||||||
--
|
--
|
||||||
-- 재색인 불필요: `chunks` 와 임베딩은 손대지 않는다. 이 마이그레이션은
|
-- 재색인 불필요: `chunks` 와 임베딩은 손대지 않는다. 이 마이그레이션은
|
||||||
-- chunks_fts 를 drop 후 chunks 에서 그대로 다시 채운다 (60만 chunk 기준 32초
|
-- chunks_fts 를 drop 후 chunks 에서 그대로 다시 채운다 (60만 chunk 기준 32초
|
||||||
@@ -49,7 +64,7 @@
|
|||||||
-- corpus_revision 을 올리지 않는 이유: 색인 내용과 tokenizer 가 같으므로 bm25
|
-- corpus_revision 을 올리지 않는 이유: 색인 내용과 tokenizer 가 같으므로 bm25
|
||||||
-- 점수도 snippet 도 같고, 어휘 검색의 정렬은 `ORDER BY score, f.chunk_id` 라
|
-- 점수도 snippet 도 같고, 어휘 검색의 정렬은 `ORDER BY score, f.chunk_id` 라
|
||||||
-- rowid 와 무관하다. 즉 결과가 바뀌지 않으므로 미결 pagination cursor 를
|
-- rowid 와 무관하다. 즉 결과가 바뀌지 않으므로 미결 pagination cursor 를
|
||||||
-- 무효화할 이유가 없다. 실측에서도 '한국' 15,837 / 'kebab' 3 / 'database'
|
-- 무효화할 이유가 없다. 실측에서도 '한국' 15,977 / 'kebab' 3 / 'database'
|
||||||
-- 1,067 로 전후 hit 수가 같았다. V009 처럼 tokenizer 가 바뀌는 경우와 다르다.
|
-- 1,067 로 전후 hit 수가 같았다. V009 처럼 tokenizer 가 바뀌는 경우와 다르다.
|
||||||
|
|
||||||
-- 기존 chunks_fts 제거 (chunk_id 삭제 트리거).
|
-- 기존 chunks_fts 제거 (chunk_id 삭제 트리거).
|
||||||
|
|||||||
@@ -51,9 +51,19 @@ git history.
|
|||||||
|
|
||||||
검색 결과는 바뀌지 않는다. 실제 KB 에서 '한국'(15,977) / 'database'(1,067) / '서울 지하철'(230) / 'kebab'(3) 네 질의의 상위 20건을 chunk_id·bm25 점수·snippet 까지 해시로 비교했고 마이그레이션 전후가 동일했다. 그래서 V016 은 `corpus_revision` 을 올리지 않는다 — 어휘 검색 정렬이 `ORDER BY score, f.chunk_id` 라 rowid 와 무관하므로 미결 pagination cursor 를 무효화할 이유가 없다. tokenizer 가 바뀐 V009 와는 다른 경우다.
|
검색 결과는 바뀌지 않는다. 실제 KB 에서 '한국'(15,977) / 'database'(1,067) / '서울 지하철'(230) / 'kebab'(3) 네 질의의 상위 20건을 chunk_id·bm25 점수·snippet 까지 해시로 비교했고 마이그레이션 전후가 동일했다. 그래서 V016 은 `corpus_revision` 을 올리지 않는다 — 어휘 검색 정렬이 `ORDER BY score, f.chunk_id` 라 rowid 와 무관하므로 미결 pagination cursor 를 무효화할 이유가 없다. tokenizer 가 바뀐 V009 와는 다른 경우다.
|
||||||
|
|
||||||
### 알아 둘 전제
|
### 실패 양상이 바뀌었다 — 그래서 doctor 점검을 같이 넣었다
|
||||||
|
|
||||||
`chunks` 는 `chunk_id TEXT PRIMARY KEY` 라 INTEGER PRIMARY KEY 가 없다. SQLite 의 VACUUM 은 그런 테이블의 rowid 를 다시 매길 수 있고, 그러면 이 정렬이 깨진다. kebab 은 VACUUM 을 실행하지 않으며(코드베이스 전체에 없음), 사용자가 직접 실행했다면 `rebuild_chunks_fts` 가 복구 경로다. 이슈가 제안한 external-content 도 같은 전제를 깔고 있어 이 위험은 선택지 간 차이가 아니다.
|
이건 리뷰에서 지적받아 알게 된 것이다. `chunk_id` 로 행을 찾던 때는 shadow 정렬이 어긋나도 **느릴 뿐 정확**했다. rowid 로 찾으면 정렬이 어긋난 순간 `chunks_ad` 가 **남의 문서 shadow 행을 지우고 아무 오류도 내지 않는다**. 즉 이 마이그레이션은 실패 양상을 "느림"에서 "조용한 오삭제"로 바꿨다.
|
||||||
|
|
||||||
|
무엇이 정렬을 깨는지 실제로 재봤다. 처음에는 VACUUM 을 위험으로 적었는데, 측정해 보니 **VACUUM 은 rowid 를 다시 매기지 않았다**. 실제 KB 사본(60만 chunk, 문서 3,000건을 지워 rowid 에 구멍을 낸 뒤)과 소형 합성 DB 양쪽에서 VACUUM 후 전수 대조 불일치가 0 이었다 (sqlite 3.53.4). SQLite 문서는 INTEGER PRIMARY KEY 가 없는 테이블의 rowid 를 VACUUM 이 다시 매길 **수 있다**고 적어 두지만, 보장이 없다는 뜻이지 실제로 그렇게 한다는 뜻이 아니다. 앞선 초안에서 이 위험을 과장했으므로 정정한다.
|
||||||
|
|
||||||
|
남는 실제 경로는 **앞으로 `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 문구에 적어 두었으므로 정렬 증명으로 읽히지는 않는다.
|
||||||
|
|
||||||
|
참고로 V016 이전 스토어라고 해서 정렬이 어긋나 있는 건 아니다. 트리거가 매 연산을 그대로 미러링하므로 FTS5 가 알아서 매기는 번호도 `chunks` 와 나란히 간다. 다만 **V009 의 백필**은 rowid 를 명시하지 않고 `SELECT … FROM chunks` 로 채웠으므로, 그 시점에 `chunks` 의 rowid 에 구멍이 있었다면 shadow 는 1..N 으로 촘촘히 매겨져 어긋난다. V016 의 명시 rowid repopulate 가 그런 스토어를 바로잡는다. 이 머신의 실제 KB(V015, 문서 28,427건)는 V009 이후 새로 색인한 것이라 점검이 정상으로 나온다.
|
||||||
|
|
||||||
|
복구는 `rebuild_chunks_fts` 다. 라이브러리 API 이고 CLI 로 배선돼 있지 않다 — 사용자 경로는 탐지까지가 doctor 이고, 복구는 `kebab reset` 후 재색인이다. 참고로 이슈가 제안한 external-content 도 rowid 정렬을 똑같이 깔고 있어 이 전제는 선택지 간 차이가 아니다.
|
||||||
|
|
||||||
### 설계 계약
|
### 설계 계약
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user