fix(store-sqlite): #229 chunks_fts 삭제를 전체 스캔에서 rowid 조회로 #235
@@ -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**) |
|
||||
|
||||
|
||||
@@ -445,11 +445,122 @@ 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`.
|
||||
// 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.
|
||||
{
|
||||
// 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 {
|
||||
None => (
|
||||
true,
|
||||
"KB 없음 — 점검할 인덱스가 아직 없다".to_string(),
|
||||
None,
|
||||
),
|
||||
// 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(),
|
||||
),
|
||||
),
|
||||
// A verdict is only rendered for a store known to be V016 or
|
||||
// later. 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 — and there it is harmless, because
|
||||
// deletes still address rows by chunk_id. Applying V016
|
||||
// repopulates with explicit rowids and fixes it, so the
|
||||
// advice is "migrate", never "wipe". An unreadable history
|
||||
// takes the same branch: not knowing the version is not
|
||||
// grounds for telling someone to destroy their KB.
|
||||
Some(Some((ver, Ok((checked, bad))))) => match ver {
|
||||
Some(v) if v >= V016 && bad > 0 => (
|
||||
false,
|
||||
format!("chunks_fts rowid 정렬 어긋남 — 표본 {checked}행 중 {bad}행 불일치"),
|
||||
Some(
|
||||
"삭제가 엉뚱한 FTS 행을 지우고 있다. `kebab reset` 후 재색인으로 \
|
||||
인덱스를 다시 만들어라"
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
Some(v) if v >= V016 => (
|
||||
true,
|
||||
format!("chunks_fts rowid 정렬 정상 (양끝 {checked}행 표본)"),
|
||||
None,
|
||||
),
|
||||
Some(v) => (
|
||||
true,
|
||||
format!("V016 적용 전 (현재 V{v:03}) — 마이그레이션 후 점검된다"),
|
||||
None,
|
||||
),
|
||||
None => (
|
||||
true,
|
||||
"판정 보류 — 마이그레이션 이력을 읽지 못했다".to_string(),
|
||||
Some("kebab 이 만든 DB 가 아닐 수 있다".to_string()),
|
||||
),
|
||||
},
|
||||
};
|
||||
checks.push(DoctorCheck {
|
||||
name: "fts_shadow".to_string(),
|
||||
ok: fok,
|
||||
detail,
|
||||
hint,
|
||||
});
|
||||
}
|
||||
|
||||
// vector_store — Lance fragment / version-history health (issue #230).
|
||||
{
|
||||
let cfg = loaded_cfg
|
||||
.clone()
|
||||
.unwrap_or_else(kebab_config::Config::defaults);
|
||||
// Same env precedence as the two checks above — this block
|
||||
// landed in #234 reading the file-only config, so a
|
||||
// KEBAB_STORAGE_DATA_DIR user got stats for the wrong directory.
|
||||
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(),
|
||||
};
|
||||
let data_dir = kebab_config::expand_path(&cfg.storage.data_dir, "");
|
||||
let vector_dir =
|
||||
kebab_config::expand_path(&cfg.storage.vector_dir, &data_dir.to_string_lossy());
|
||||
|
||||
@@ -83,3 +83,101 @@ 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.
|
||||
///
|
||||
/// Note: doctor applies env overrides with the same precedence as
|
||||
/// `Config::load`, so `KEBAB_STORAGE_DATA_DIR` exported in the shell
|
||||
/// redirects this check away from the temp store and fails the test.
|
||||
/// That is the check behaving correctly, not a broken test — unset it.
|
||||
#[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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
//! FTS5 maintenance helpers (P2-1).
|
||||
//!
|
||||
//! `chunks_fts` is a contentless FTS5 virtual table created by
|
||||
//! `migrations/V002__fts.sql` and kept in sync with the `chunks` table by
|
||||
//! the `chunks_ai` / `chunks_ad` / `chunks_au` triggers (design §5.5).
|
||||
//! `chunks_fts` is an FTS5 virtual table (a shadow of `chunks`, not
|
||||
//! contentless — no `content=''` in the DDL) created by
|
||||
//! `migrations/V002__fts.sql`, retokenized by V009, and repointed to
|
||||
//! rowid-addressed deletes by V016. It is kept in sync with the `chunks`
|
||||
//! table by the `chunks_ai` / `chunks_ad` / `chunks_au` triggers (design
|
||||
//! §5.5). Its rowid mirrors `chunks.rowid`; anything that writes rows here
|
||||
//! must preserve that or the delete trigger stops finding them.
|
||||
//!
|
||||
//! Normal operation needs nothing from this module — every mutation on
|
||||
//! `chunks` propagates automatically inside the host transaction. The
|
||||
//! only entry point exposed here is [`rebuild_chunks_fts`], used as the
|
||||
//! escape hatch for `kb index --rebuild-fts` (wired by `kb-cli` later;
|
||||
//! out of scope for P2-1).
|
||||
//! only entry point exposed here is [`rebuild_chunks_fts`], the escape
|
||||
//! hatch for a shadow that has drifted from `chunks`. It is a library
|
||||
//! 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 rusqlite::Connection;
|
||||
@@ -48,9 +54,22 @@ pub fn rebuild_chunks_fts(conn: &Connection) -> Result<()> {
|
||||
let result: Result<()> = (|| {
|
||||
conn.execute("DELETE FROM chunks_fts", [])
|
||||
.context("DELETE FROM chunks_fts")?;
|
||||
// Mirrors the chunks_ai trigger exactly (V016 §5.5): rowid is
|
||||
// written explicitly so the shadow stays aligned with `chunks`
|
||||
// — the delete trigger addresses rows by rowid, so a rebuild
|
||||
// that let FTS5 assign its own would silently make every later
|
||||
// delete a no-op. The CASE is the same one the trigger applies;
|
||||
// without it a rebuild would strip the Korean morphemes that
|
||||
// V009 indexes and 2-character Korean queries would stop
|
||||
// matching until the next re-ingest.
|
||||
conn.execute(
|
||||
"INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text)
|
||||
SELECT chunk_id, doc_id, heading_path_json, text FROM chunks",
|
||||
"INSERT INTO chunks_fts(rowid, chunk_id, doc_id, heading_path, text)
|
||||
SELECT rowid, chunk_id, doc_id, heading_path_json,
|
||||
CASE WHEN tokenized_korean_text IS NOT NULL
|
||||
THEN tokenized_korean_text || ' ' || text
|
||||
ELSE text
|
||||
END
|
||||
FROM chunks",
|
||||
[],
|
||||
)
|
||||
.context("repopulate chunks_fts from chunks")?;
|
||||
|
||||
@@ -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,6 +351,65 @@ fn temp_path_for(dest: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
impl SqliteStore {
|
||||
/// 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
|
||||
/// 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, usize)> {
|
||||
let conn = self.read_conn();
|
||||
// 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`
|
||||
/// table. Returns `0` if the row is missing (not migrated yet) or
|
||||
/// 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");
|
||||
|
||||
// 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();
|
||||
assert_eq!(count(&conn, "chunks_fts"), 0);
|
||||
assert_eq!(count(&conn, "chunks"), 1);
|
||||
@@ -369,20 +369,20 @@ fn extract_design_5_5_fts_block() -> String {
|
||||
fts_slice[..last_end + "END;".len()].to_string()
|
||||
}
|
||||
|
||||
/// Extract the §5.5 verbatim block from the V009 migration (V009 replaces
|
||||
/// V007 's trigram tokenizer with unicode61 + CASE expression triggers for
|
||||
/// Korean morphological tokenization — V007 stays in place for historical
|
||||
/// cold-upgrade replay but V009 is now the source of truth),
|
||||
/// between the `── §5.5 verbatim block ──` anchor markers V009 carries.
|
||||
/// Extract the §5.5 verbatim block from the V016 migration (V016 repoints
|
||||
/// the sync triggers from `chunk_id` to `rowid` so deletes are a B-tree
|
||||
/// lookup instead of a full FTS5 scan — V009 stays in place for historical
|
||||
/// cold-upgrade replay but V016 is now the source of truth),
|
||||
/// between the `── §5.5 verbatim block ──` anchor markers V016 carries.
|
||||
fn extract_migration_5_5_verbatim_block() -> String {
|
||||
let migration = include_str!("../../../migrations/V009__fts_korean_morphological.sql");
|
||||
let migration = include_str!("../../../migrations/V016__fts_rowid_delete.sql");
|
||||
// The opening anchor line ends with `── §5.5 verbatim block ─...`.
|
||||
let open_marker = "§5.5 verbatim block";
|
||||
let close_marker = "End §5.5 verbatim block";
|
||||
|
||||
let open_idx = migration
|
||||
.find(open_marker)
|
||||
.expect("V009 must carry the `§5.5 verbatim block` opening anchor");
|
||||
.expect("V016 must carry the `§5.5 verbatim block` opening anchor");
|
||||
let after_open_line = open_idx
|
||||
+ migration[open_idx..]
|
||||
.find('\n')
|
||||
@@ -391,7 +391,7 @@ fn extract_migration_5_5_verbatim_block() -> String {
|
||||
|
||||
let close_idx = migration[after_open_line..]
|
||||
.find(close_marker)
|
||||
.expect("V009 must carry the `End §5.5 verbatim block` closing anchor")
|
||||
.expect("V016 must carry the `End §5.5 verbatim block` closing anchor")
|
||||
+ after_open_line;
|
||||
// Walk back from the close marker to the start of its comment line.
|
||||
let close_line_start = migration[..close_idx].rfind('\n').map_or(0, |n| n + 1);
|
||||
@@ -399,15 +399,14 @@ fn extract_migration_5_5_verbatim_block() -> String {
|
||||
migration[after_open_line..close_line_start].to_string()
|
||||
}
|
||||
|
||||
/// CI diff guard: the §5.5 block in `migrations/V009__fts_korean_morphological.sql`
|
||||
/// must match the design doc verbatim (whitespace-normalized). V009
|
||||
/// replaced V007 's trigram tokenizer with unicode61 + CASE expression
|
||||
/// triggers for Korean morphological tokenization (2026-05-28).
|
||||
/// V007 stays in place for historical replay of cold-upgrade paths
|
||||
/// but is no longer compared against the design doc — V009 is now
|
||||
/// the source of truth.
|
||||
/// CI diff guard: the §5.5 block in `migrations/V016__fts_rowid_delete.sql`
|
||||
/// must match the design doc verbatim (whitespace-normalized). V016
|
||||
/// repointed the sync triggers from `chunk_id` (UNINDEXED, so a full FTS5
|
||||
/// scan per delete) to `rowid` (2026-08-16, issue #229). V007 and V009 stay
|
||||
/// in place for historical replay of cold-upgrade paths but are no longer
|
||||
/// compared against the design doc — V016 is now the source of truth.
|
||||
#[test]
|
||||
fn fts_v009_matches_design_section_5_5_verbatim() {
|
||||
fn fts_v016_matches_design_section_5_5_verbatim() {
|
||||
let design = extract_design_5_5_fts_block();
|
||||
let migration_block = extract_migration_5_5_verbatim_block();
|
||||
|
||||
@@ -430,7 +429,7 @@ fn fts_v009_matches_design_section_5_5_verbatim() {
|
||||
let migration_n = normalize_ws(&migration_block);
|
||||
assert_eq!(
|
||||
design_n, migration_n,
|
||||
"V009__fts_korean_morphological.sql §5.5 block must match design doc §5.5 verbatim \
|
||||
"V016__fts_rowid_delete.sql §5.5 block must match design doc §5.5 verbatim \
|
||||
(whitespace-normalized). If you intentionally changed one, \
|
||||
update the other in the same commit."
|
||||
);
|
||||
@@ -656,3 +655,229 @@ fn fts_v009_english_whole_token_only() {
|
||||
"V009 unicode61: whole-token 'tokenizer' must hit"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 8. V016 rowid-addressed deletes (issue #229) ──────────────────────
|
||||
|
||||
/// The shadow's rowid must equal the source row's rowid. Every other
|
||||
/// V016 property rests on this: the delete trigger addresses rows by
|
||||
/// rowid, so a drifted rowid makes deletes silently miss.
|
||||
#[test]
|
||||
fn fts_v016_shadow_rowid_mirrors_chunks_rowid() {
|
||||
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..5u8 {
|
||||
insert_chunk(
|
||||
&conn,
|
||||
&format!("{i:032}"),
|
||||
&"d".repeat(32),
|
||||
"[]",
|
||||
&format!("body {i}"),
|
||||
);
|
||||
}
|
||||
|
||||
let drifted: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM chunks c
|
||||
LEFT JOIN chunks_fts f ON f.rowid = c.rowid
|
||||
WHERE f.rowid IS NULL OR f.chunk_id != c.chunk_id",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("join chunks to its shadow by rowid");
|
||||
assert_eq!(
|
||||
drifted, 0,
|
||||
"every chunks row must have a chunks_fts row at the same rowid carrying the same chunk_id"
|
||||
);
|
||||
}
|
||||
|
||||
/// Deleting one chunk must remove exactly that chunk's shadow row.
|
||||
/// Under the pre-V016 `WHERE chunk_id = ?` trigger this also passed —
|
||||
/// it is the correctness floor the rowid switch must not drop.
|
||||
#[test]
|
||||
fn fts_v016_delete_removes_only_the_deleted_row() {
|
||||
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..4u8 {
|
||||
insert_chunk(
|
||||
&conn,
|
||||
&format!("{i:032}"),
|
||||
&"d".repeat(32),
|
||||
"[]",
|
||||
&format!("alpha{i} shared"),
|
||||
);
|
||||
}
|
||||
assert_eq!(count(&conn, "chunks_fts"), 4);
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM chunks WHERE chunk_id = ?",
|
||||
rusqlite::params![format!("{:032}", 2u8)],
|
||||
)
|
||||
.expect("delete one chunk");
|
||||
|
||||
assert_eq!(count(&conn, "chunks"), 3);
|
||||
assert_eq!(count(&conn, "chunks_fts"), 3);
|
||||
assert_eq!(
|
||||
count_match(&conn, "alpha2"),
|
||||
0,
|
||||
"the deleted chunk must leave no shadow row behind"
|
||||
);
|
||||
for i in [0u8, 1, 3] {
|
||||
assert_eq!(
|
||||
count_match(&conn, &format!("alpha{i}")),
|
||||
1,
|
||||
"sibling chunk alpha{i} must survive its neighbour's delete"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The point of V016: the delete must be a rowid lookup, not a scan.
|
||||
/// SQLite reports a virtual table's accepted constraints in the plan's
|
||||
/// `INDEX n:str` field — FTS5 puts `=` there when it takes the rowid
|
||||
/// equality itself. Addressing by `chunk_id` leaves that field empty
|
||||
/// because `chunk_id` is UNINDEXED, and the delete degrades to a full
|
||||
/// index scan (measured at 1590s for 200 documents over 600k chunks,
|
||||
/// 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]
|
||||
fn fts_v016_delete_plan_uses_rowid_not_a_scan() {
|
||||
let env = common::TestEnv::new();
|
||||
let store = SqliteStore::open(&env.config().storage).unwrap();
|
||||
store.run_migrations().unwrap();
|
||||
|
||||
let conn = raw_conn_no_fk(&env);
|
||||
let plan_for = |sql: &str| -> String {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
|
||||
.expect("prepare plan");
|
||||
stmt.query_map([], |r| r.get::<_, String>(3))
|
||||
.expect("run plan")
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.expect("collect plan")
|
||||
.join(" | ")
|
||||
};
|
||||
|
||||
let by_rowid = plan_for("DELETE FROM chunks_fts WHERE rowid = 1");
|
||||
let by_chunk_id = plan_for("DELETE FROM chunks_fts WHERE chunk_id = 'x'");
|
||||
|
||||
assert!(
|
||||
by_rowid.contains(":="),
|
||||
"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!(
|
||||
!by_chunk_id.contains(":="),
|
||||
"chunk_id is UNINDEXED so FTS5 cannot take the constraint — if this ever \
|
||||
stops holding, the premise of issue #229 changed; got {by_chunk_id:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `rebuild_chunks_fts` is the escape hatch for a drifted shadow, so it
|
||||
/// has to reproduce what the triggers write — both the rowid alignment
|
||||
/// (or every later delete becomes a no-op) and the Korean morpheme
|
||||
/// prefix (or 2-character Korean queries stop matching until the next
|
||||
/// re-ingest).
|
||||
#[test]
|
||||
fn fts_v016_rebuild_preserves_rowid_and_korean_morphemes() {
|
||||
let env = common::TestEnv::new();
|
||||
let store = SqliteStore::open(&env.config().storage).unwrap();
|
||||
store.run_migrations().unwrap();
|
||||
|
||||
let conn = raw_conn_no_fk(&env);
|
||||
let text = "한국의 수도는 서울이다";
|
||||
let tokenized = tokenize_korean_morphological(text);
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (
|
||||
chunk_id, doc_id, text, heading_path_json, section_label,
|
||||
source_spans_json, token_estimate, chunker_version,
|
||||
policy_hash, block_ids_json, created_at, tokenized_korean_text
|
||||
) VALUES (?, ?, ?, '[]', NULL, '[]', 0, 'v1', 'h', '[]', '2024-01-01T00:00:00Z', ?)",
|
||||
rusqlite::params![&"k".repeat(32), &"d".repeat(32), text, tokenized],
|
||||
)
|
||||
.expect("insert chunk with tokenized_korean_text");
|
||||
|
||||
rebuild_chunks_fts(&conn).expect("rebuild");
|
||||
|
||||
let drifted: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM chunks c
|
||||
LEFT JOIN chunks_fts f ON f.rowid = c.rowid
|
||||
WHERE f.rowid IS NULL",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("join after rebuild");
|
||||
assert_eq!(drifted, 0, "rebuild must keep the shadow rowid-aligned");
|
||||
assert!(
|
||||
count_match(&conn, "한국") >= 1,
|
||||
"rebuild must re-index the morpheme column, not just the raw text"
|
||||
);
|
||||
|
||||
// And the rebuilt rows must still be deletable through the trigger.
|
||||
conn.execute("DELETE FROM chunks", []).expect("delete all");
|
||||
assert_eq!(
|
||||
count(&conn, "chunks_fts"),
|
||||
0,
|
||||
"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(),
|
||||
(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
|
||||
// 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_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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ kebab/
|
||||
│ ├── 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-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 트리
|
||||
```
|
||||
|
||||
|
||||
@@ -1098,6 +1098,12 @@ FTS5 에 색인 (CASE expression: NULL 이면 raw text 만). '한국', '서울'
|
||||
`chunks_fts` 는 일반 FTS5 shadow table 이며 contentless 가 아님 (V002 / V009
|
||||
DDL 에 `content=''` 없음).
|
||||
|
||||
⟳ V016 (2026-08-16, issue #229): trigger 의 행 지정을 `chunk_id` 에서 `rowid`
|
||||
로 교체. `chunk_id` 는 `UNINDEXED` 라 FTS5 에 색인이 없고, `DELETE FROM
|
||||
chunks_fts WHERE chunk_id = ?` 는 색인 전체 스캔으로 떨어졌다 (chunk 60만
|
||||
기준 문서 200건 삭제 1590초). `chunks_fts.rowid` 를 `chunks.rowid` 와 맞추면
|
||||
같은 삭제가 2.0초다. 컬럼 구성·tokenizer·검색 경로는 불변.
|
||||
|
||||
```sql
|
||||
CREATE TABLE chunks (
|
||||
chunk_id TEXT PRIMARY KEY,
|
||||
@@ -1125,20 +1131,20 @@ CREATE VIRTUAL TABLE chunks_fts USING fts5(
|
||||
);
|
||||
|
||||
CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN
|
||||
INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text)
|
||||
VALUES (new.chunk_id, new.doc_id, new.heading_path_json,
|
||||
INSERT INTO chunks_fts(rowid, chunk_id, doc_id, heading_path, text)
|
||||
VALUES (new.rowid, new.chunk_id, new.doc_id, new.heading_path_json,
|
||||
CASE WHEN new.tokenized_korean_text IS NOT NULL
|
||||
THEN new.tokenized_korean_text || ' ' || new.text
|
||||
ELSE new.text
|
||||
END);
|
||||
END;
|
||||
CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN
|
||||
DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id;
|
||||
DELETE FROM chunks_fts WHERE rowid = old.rowid;
|
||||
END;
|
||||
CREATE TRIGGER chunks_au AFTER UPDATE ON chunks BEGIN
|
||||
DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id;
|
||||
INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text)
|
||||
VALUES (new.chunk_id, new.doc_id, new.heading_path_json,
|
||||
DELETE FROM chunks_fts WHERE rowid = old.rowid;
|
||||
INSERT INTO chunks_fts(rowid, chunk_id, doc_id, heading_path, text)
|
||||
VALUES (new.rowid, new.chunk_id, new.doc_id, new.heading_path_json,
|
||||
CASE WHEN new.tokenized_korean_text IS NOT NULL
|
||||
THEN new.tokenized_korean_text || ' ' || new.text
|
||||
ELSE new.text
|
||||
|
||||
116
migrations/V016__fts_rowid_delete.sql
Normal file
116
migrations/V016__fts_rowid_delete.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- V016__fts_rowid_delete.sql — chunks_fts 삭제를 chunk_id 스캔에서 rowid 조회로.
|
||||
--
|
||||
-- Per design §5.5 (chunks_fts virtual table + chunks_ai/ad/au triggers).
|
||||
-- The CREATE VIRTUAL TABLE / CREATE TRIGGER block below is reproduced
|
||||
-- VERBATIM from `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`
|
||||
-- §5.5; CI diff-checks this against the design doc (test
|
||||
-- `fts_v016_matches_design_section_5_5_verbatim` in
|
||||
-- `crates/kebab-store-sqlite/tests/fts.rs`). V009 keeps its own copy of the
|
||||
-- older block for cold-upgrade replay; V016 is now the source of truth.
|
||||
--
|
||||
-- 문제: `chunk_id` 는 FTS5 에서 UNINDEXED 라 색인이 없다. 그런데 V002 이래
|
||||
-- 삭제 트리거가 그 컬럼으로 행을 찾는다 (`DELETE FROM chunks_fts WHERE
|
||||
-- chunk_id = old.chunk_id`). FTS5 는 이걸 만족할 색인이 없으므로 테이블
|
||||
-- 전체를 훑는다 — chunk 한 건 삭제가 O(색인 전체) 다. 60만 chunk 기준 스캔
|
||||
-- 한 번이 0.29초이고 문서 하나가 평균 21 chunk 이라, 문서 하나 삭제에 FTS
|
||||
-- 스캔만 6초가 든다. 증분 재색인에서 파일이 수정될 때마다 같은 비용을 낸다.
|
||||
-- issue #229.
|
||||
--
|
||||
-- 실측 (실제 KB 사본, 문서 28,427건 / chunk 600,808건, 문서 200건 삭제):
|
||||
-- 현행 (chunk_id 로 DELETE) 1590.1초
|
||||
-- rowid 정렬 (이 마이그레이션) 2.0초
|
||||
-- 삭제 후 남은 chunks 행 수와 chunks_fts 행 수가 양쪽 다 595,741 로 같고,
|
||||
-- '한국'(15,977) / 'kebab'(3) / 'database'(1,067) 질의의 hit 수도 같다.
|
||||
--
|
||||
-- 해결: chunks_fts 의 rowid 를 chunks 의 rowid 와 맞추고, 삭제를 rowid 로
|
||||
-- 한다. FTS5 는 rowid 로 B-tree 조회를 하므로 O(log n) 이 된다. 컬럼 구성과
|
||||
-- 토크나이저는 그대로라 검색 경로(`bm25`, `snippet(chunks_fts, 3, ...)`,
|
||||
-- `f.chunk_id` / `f.doc_id` 참조)는 손대지 않는다.
|
||||
--
|
||||
-- 왜 external-content 가 아닌가: issue #229 는 `content='chunks'` 를 제안했다.
|
||||
-- 그 편이 본문 그림자(`chunks_fts_content`, 실측 550 MB)까지 회수하지만,
|
||||
-- V009 트리거가 색인하는 값이 `tokenized_korean_text || ' ' || text` 라
|
||||
-- `chunks` 의 어느 컬럼과도 일치하지 않는다. generated column 을 새로 만들고
|
||||
-- 검색 경로의 컬럼 참조를 rowid join 으로 바꾸는 변경이 딸려온다. 삭제 비용은
|
||||
-- rowid 정렬만으로 같은 복잡도로 내려가므로, 그림자 회수는 별 건으로 둔다.
|
||||
--
|
||||
-- rowid 정렬이 깨지면 어떻게 되나: `chunks_ad` 가 남의 문서 shadow 행을
|
||||
-- 지우고 아무 오류도 내지 않는다. `chunk_id` 로 찾던 때는 정렬이 어긋나도
|
||||
-- 느릴 뿐 정확했으니, 이 마이그레이션은 실패 양상을 "느림"에서 "조용한
|
||||
-- 오삭제"로 바꾼다. 그래서 doctor 에 `fts_shadow` 점검을 같이 넣었다.
|
||||
--
|
||||
-- 무엇이 정렬을 깨나: `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_fts 를 drop 후 chunks 에서 그대로 다시 채운다 (60만 chunk 기준 32초
|
||||
-- 실측).
|
||||
--
|
||||
-- corpus_revision 을 올리지 않는 이유: 색인 내용과 tokenizer 가 같으므로 bm25
|
||||
-- 점수도 snippet 도 같고, 어휘 검색의 정렬은 `ORDER BY score, f.chunk_id` 라
|
||||
-- rowid 와 무관하다. 즉 결과가 바뀌지 않으므로 미결 pagination cursor 를
|
||||
-- 무효화할 이유가 없다. 실측에서도 '한국' 15,977 / 'kebab' 3 / 'database'
|
||||
-- 1,067 로 전후 hit 수가 같았다. V009 처럼 tokenizer 가 바뀌는 경우와 다르다.
|
||||
|
||||
-- 기존 chunks_fts 제거 (chunk_id 삭제 트리거).
|
||||
DROP TRIGGER IF EXISTS chunks_au;
|
||||
DROP TRIGGER IF EXISTS chunks_ad;
|
||||
DROP TRIGGER IF EXISTS chunks_ai;
|
||||
DROP TABLE IF EXISTS chunks_fts;
|
||||
|
||||
-- ── §5.5 verbatim block ────────────────────────────────────────────────
|
||||
|
||||
CREATE VIRTUAL TABLE chunks_fts USING fts5(
|
||||
chunk_id UNINDEXED,
|
||||
doc_id UNINDEXED,
|
||||
heading_path,
|
||||
text,
|
||||
tokenize = 'unicode61'
|
||||
);
|
||||
|
||||
CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN
|
||||
INSERT INTO chunks_fts(rowid, chunk_id, doc_id, heading_path, text)
|
||||
VALUES (new.rowid, new.chunk_id, new.doc_id, new.heading_path_json,
|
||||
CASE WHEN new.tokenized_korean_text IS NOT NULL
|
||||
THEN new.tokenized_korean_text || ' ' || new.text
|
||||
ELSE new.text
|
||||
END);
|
||||
END;
|
||||
CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN
|
||||
DELETE FROM chunks_fts WHERE rowid = old.rowid;
|
||||
END;
|
||||
CREATE TRIGGER chunks_au AFTER UPDATE ON chunks BEGIN
|
||||
DELETE FROM chunks_fts WHERE rowid = old.rowid;
|
||||
INSERT INTO chunks_fts(rowid, chunk_id, doc_id, heading_path, text)
|
||||
VALUES (new.rowid, new.chunk_id, new.doc_id, new.heading_path_json,
|
||||
CASE WHEN new.tokenized_korean_text IS NOT NULL
|
||||
THEN new.tokenized_korean_text || ' ' || new.text
|
||||
ELSE new.text
|
||||
END);
|
||||
END;
|
||||
|
||||
-- ── End §5.5 verbatim block ───────────────────────────────────────────
|
||||
|
||||
-- chunks 에서 그대로 재구축. rowid 를 명시해 정렬을 만든다.
|
||||
INSERT INTO chunks_fts(rowid, chunk_id, doc_id, heading_path, text)
|
||||
SELECT rowid, chunk_id, doc_id, heading_path_json,
|
||||
CASE WHEN tokenized_korean_text IS NOT NULL
|
||||
THEN tokenized_korean_text || ' ' || text
|
||||
ELSE text
|
||||
END
|
||||
FROM chunks;
|
||||
@@ -14,6 +14,65 @@ historical contract that was implemented; this file accumulates the
|
||||
deltas so phase 5+ readers can find the live behavior without diffing
|
||||
git history.
|
||||
|
||||
## 2026-08-16 — #229 chunks_fts 삭제가 FTS5 전체 스캔 (V016)
|
||||
|
||||
### 무엇이 문제였나
|
||||
|
||||
`chunk_id` 는 `chunks_fts` 에서 `UNINDEXED` 다. 그런데 V002 이래 삭제 트리거가 그 컬럼으로 행을 찾았다 (`DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id`). FTS5 는 UNINDEXED 컬럼에 색인을 만들지 않으므로 이 조건을 만족할 색인이 없고, 삭제가 색인 전체 스캔으로 떨어진다. chunk 한 건 삭제가 O(색인 전체) 였다.
|
||||
|
||||
`chunks` 에서 DELETE 가 나가는 모든 경로가 이 비용을 냈다 — `sweep_deleted_files`, `reset --orphans-only`, 그리고 파일이 수정될 때마다 도는 `purge_orphan_at_workspace_path`. 즉 정상적인 증분 재색인이 코퍼스가 커질수록 느려지는 형태였다.
|
||||
|
||||
### 무엇을 고쳤나
|
||||
|
||||
`migrations/V016__fts_rowid_delete.sql` 이 `chunks_fts` 를 drop 후 재생성하면서 rowid 를 `chunks.rowid` 와 맞추고, 세 트리거의 행 지정을 `chunk_id` 에서 `rowid` 로 바꾼다. FTS5 는 rowid 로 B-tree 조회를 하므로 삭제가 O(log n) 이 된다.
|
||||
|
||||
컬럼 구성·tokenizer 는 그대로다. 검색 경로(`bm25`, `snippet(chunks_fts, 3, …)`, `f.chunk_id` / `f.doc_id` 참조)는 한 줄도 손대지 않았다.
|
||||
|
||||
`rebuild_chunks_fts` 도 같이 고쳤다. rowid 를 명시해 넣지 않으면 FTS5 가 자기 번호를 매겨 정렬이 깨지고, 그 뒤의 모든 삭제가 조용히 아무것도 안 하게 된다. 이 함수에는 별개의 잠복 결함도 있었다 — V009 가 색인하는 한국어 형태소 접두(`tokenized_korean_text || ' ' || text`)를 빠뜨리고 raw text 만 넣고 있었다. 재구축을 돌리면 2자 한국어 질의가 다음 재색인 때까지 안 맞는 상태가 됐다. 트리거와 같은 CASE 를 넣어 맞췄다.
|
||||
|
||||
### 왜 이슈가 제안한 external-content 가 아닌가
|
||||
|
||||
이슈는 `content='chunks'` 를 제안했다. 그 편이 본문 그림자(`chunks_fts_content`, 실측 550 MB)까지 회수한다. 하지만 V009 트리거가 색인하는 값이 `tokenized_korean_text || ' ' || text` 라 `chunks` 의 어느 컬럼과도 일치하지 않는다. generated column 을 새로 만들고, `chunk_id` / `doc_id` 를 FTS 테이블에서 빼고, 검색 경로의 컬럼 참조를 rowid join 으로 바꾸는 변경이 딸려온다. 삭제 비용은 rowid 정렬만으로 같은 복잡도로 내려가므로 그림자 회수는 별 건으로 남겼다.
|
||||
|
||||
이슈 본문의 사실관계 두 가지도 정정해 둔다. 지목된 마이그레이션은 V002 가 아니라 V009 다 (V007 이 trigram 으로, V009 가 unicode61 + 한국어 형태소 컬럼으로 각각 다시 만들었다). 그리고 `chunks_fts` 는 contentless 가 아니다 — `chunks_fts_content` 가 실재한다.
|
||||
|
||||
### 실측
|
||||
|
||||
실제 KB 사본 (문서 28,427건 / chunk 600,808건), 문서 200건 삭제:
|
||||
|
||||
| | 시간 |
|
||||
|---|---|
|
||||
| 현행 (`chunk_id` 로 DELETE) | 1590.1초 |
|
||||
| V016 (`rowid` 로 DELETE) | **2.0초** |
|
||||
|
||||
약 800배다. 삭제 후 남은 `chunks` 행 수와 `chunks_fts` 행 수가 양쪽 다 595,741 로 같다.
|
||||
|
||||
마이그레이션 자체는 60만 chunk 기준 32초 (실제 바이너리로 재봤을 때 검색 한 번을 포함해 37.8초). 재색인은 필요 없다 — `chunks` 와 임베딩은 손대지 않는다.
|
||||
|
||||
검색 결과는 바뀌지 않는다. 실제 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 점검을 같이 넣었다
|
||||
|
||||
이건 리뷰에서 지적받아 알게 된 것이다. `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 문구에 적어 두었으므로 정렬 증명으로 읽히지는 않는다. **어긋나면 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 이후 새로 색인한 것이라 점검이 정상으로 나온다.
|
||||
|
||||
복구는 `rebuild_chunks_fts` 다. 라이브러리 API 이고 CLI 로 배선돼 있지 않다 — 사용자 경로는 탐지까지가 doctor 이고, 복구는 `kebab reset` 후 재색인이다. 참고로 이슈가 제안한 external-content 도 rowid 정렬을 똑같이 깔고 있어 이 전제는 선택지 간 차이가 아니다.
|
||||
|
||||
### 설계 계약
|
||||
|
||||
design §5.5 의 verbatim block 을 rowid 트리거로 갱신하고, CI diff-check(`fts_v016_matches_design_section_5_5_verbatim`)를 V009 에서 V016 으로 재조준했다. V007·V009 는 cold-upgrade 재생을 위해 그대로 남지만 더 이상 설계 문서와 비교되지 않는다 — V007 → V009 때 쓴 것과 같은 방식이다.
|
||||
|
||||
## 2026-08-16 — #230 나머지: 삭제 배치화 + 삭제 경로 압축 + doctor 지표
|
||||
|
||||
앞 엔트리가 #230 의 제안 2(압축 정책)만 닫았다. 남은 제안 1·3·4 를 여기서 처리.
|
||||
|
||||
@@ -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