chunks_fts 삭제가 FTS5 전체 스캔 — chunk 삭제 비용이 O(인덱스 전체) #229

Closed
opened 2026-08-05 02:12:15 +00:00 by altair823 · 1 comment
Owner

증상

chunk 삭제가 코퍼스 크기에 비례해 느려진다. chunk 51,900행 기준 문서 purge 가 초당 6건에 묶이고, 프로파일상 거의 전부 디스크 read 대기다. 문서 5,834건 삭제에 약 16분이 걸린다.

원인

chunks_fts 는 contentless FTS5 테이블이고 chunk_idUNINDEXED 다 (migrations/V002__fts.sql:19-25):

CREATE VIRTUAL TABLE chunks_fts USING fts5(
  chunk_id     UNINDEXED,
  doc_id       UNINDEXED,
  heading_path,
  text,
  tokenize = 'unicode61 remove_diacritics 2'
);

그런데 삭제 트리거는 그 chunk_id 로 행을 찾는다 (migrations/V002__fts.sql:31-38):

CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN
  DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id;
END;
CREATE TRIGGER chunks_au AFTER UPDATE ON chunks BEGIN
  DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id;
  ...
END;

FTS5 는 UNINDEXED 컬럼에 인덱스를 만들지 않는다. 따라서 WHERE chunk_id = ? 는 사용할 수 있는 인덱스가 없고 FTS5 테이블 전체 스캔으로 떨어진다. chunk 1건 삭제마다 5만여 행의 content btree 를 훑는다 → chunk 삭제가 O(전체 인덱스).

스택 샘플 (sample <pid>, 4초, 메인 스레드 100%):

sweep_deleted_files
  → purge_deleted_workspace_path
    → rusqlite::Connection::execute → sqlite3_step → sqlite3VdbeExec
      → fts5NextMethod → sqlite3_step → sqlite3BtreeNext → moveToChild
        → getAndInitPage → getPageNormal → readDbPage → unixRead → pread   ← 샘플의 45%

영향 범위

chunks 에서 DELETE 가 나가는 모든 경로:

  • sweep_deleted_files (crates/kebab-app/src/lib.rs:2038) — 삭제된 파일 정리
  • reset --orphans-only (crates/kebab-app/src/reset.rs:execute_orphans_only)
  • 편집된 asset 의 orphan chunk 정리 (purge_orphan_at_workspace_path 경로, crates/kebab-app/src/lib.rs:~1990)

즉 정상적인 증분 재색인에서도 문서가 수정될 때마다 비용을 낸다. 코퍼스가 커질수록 재색인이 느려지는 형태.

제안

external-content FTS5 로 전환해 삭제를 rowid 기준으로 바꾼다:

CREATE VIRTUAL TABLE chunks_fts USING fts5(
  heading_path, text,
  content='chunks', content_rowid='rowid',
  tokenize = 'unicode61 remove_diacritics 2'
);

CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN
  INSERT INTO chunks_fts(chunks_fts, rowid, heading_path, text)
    VALUES('delete', old.rowid, old.heading_path_json, old.text);
END;

검토할 항목:

  • 마이그레이션은 drop + recreate + rebuild 가 필요하다 (INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')). 51,900행 기준 일회성 비용.
  • chunk_id / doc_id 를 FTS 테이블에서 빼고 검색 시 chunks 와 rowid join 으로 가져와야 한다. 검색 경로(crates/kebab-search)의 컬럼 참조를 함께 고쳐야 함.
  • rebuild_chunks_fts (crates/kebab-store-sqlite/src/fts.rs:40) 의 DELETE + INSERT 방식도 external-content 규약('rebuild' 커맨드)으로 교체.
  • design doc §5.5 의 verbatim block 이 CI diff-check 대상이므로(migrations/V002__fts.sql:1-6) spec 쪽도 같이 갱신해야 한다. HOTFIXES dated entry 필요.

더 작은 변경으로 버티려면 chunk_id → fts rowid 매핑 테이블을 두는 방법이 있으나, 정합성 유지 부담이 external-content 전환보다 크다.

## 증상 chunk 삭제가 코퍼스 크기에 비례해 느려진다. chunk 51,900행 기준 문서 purge 가 **초당 6건**에 묶이고, 프로파일상 거의 전부 디스크 read 대기다. 문서 5,834건 삭제에 약 16분이 걸린다. ## 원인 `chunks_fts` 는 contentless FTS5 테이블이고 `chunk_id` 가 `UNINDEXED` 다 (`migrations/V002__fts.sql:19-25`): ```sql CREATE VIRTUAL TABLE chunks_fts USING fts5( chunk_id UNINDEXED, doc_id UNINDEXED, heading_path, text, tokenize = 'unicode61 remove_diacritics 2' ); ``` 그런데 삭제 트리거는 그 `chunk_id` 로 행을 찾는다 (`migrations/V002__fts.sql:31-38`): ```sql CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; END; CREATE TRIGGER chunks_au AFTER UPDATE ON chunks BEGIN DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; ... END; ``` FTS5 는 `UNINDEXED` 컬럼에 인덱스를 만들지 않는다. 따라서 `WHERE chunk_id = ?` 는 사용할 수 있는 인덱스가 없고 **FTS5 테이블 전체 스캔**으로 떨어진다. chunk 1건 삭제마다 5만여 행의 content btree 를 훑는다 → chunk 삭제가 O(전체 인덱스). 스택 샘플 (`sample <pid>`, 4초, 메인 스레드 100%): ``` sweep_deleted_files → purge_deleted_workspace_path → rusqlite::Connection::execute → sqlite3_step → sqlite3VdbeExec → fts5NextMethod → sqlite3_step → sqlite3BtreeNext → moveToChild → getAndInitPage → getPageNormal → readDbPage → unixRead → pread ← 샘플의 45% ``` ## 영향 범위 `chunks` 에서 DELETE 가 나가는 모든 경로: - `sweep_deleted_files` (`crates/kebab-app/src/lib.rs:2038`) — 삭제된 파일 정리 - `reset --orphans-only` (`crates/kebab-app/src/reset.rs:execute_orphans_only`) - 편집된 asset 의 orphan chunk 정리 (`purge_orphan_at_workspace_path` 경로, `crates/kebab-app/src/lib.rs:~1990`) 즉 정상적인 증분 재색인에서도 문서가 수정될 때마다 비용을 낸다. 코퍼스가 커질수록 재색인이 느려지는 형태. ## 제안 external-content FTS5 로 전환해 삭제를 rowid 기준으로 바꾼다: ```sql CREATE VIRTUAL TABLE chunks_fts USING fts5( heading_path, text, content='chunks', content_rowid='rowid', tokenize = 'unicode61 remove_diacritics 2' ); CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN INSERT INTO chunks_fts(chunks_fts, rowid, heading_path, text) VALUES('delete', old.rowid, old.heading_path_json, old.text); END; ``` 검토할 항목: - 마이그레이션은 drop + recreate + `rebuild` 가 필요하다 (`INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')`). 51,900행 기준 일회성 비용. - `chunk_id` / `doc_id` 를 FTS 테이블에서 빼고 검색 시 `chunks` 와 rowid join 으로 가져와야 한다. 검색 경로(`crates/kebab-search`)의 컬럼 참조를 함께 고쳐야 함. - `rebuild_chunks_fts` (`crates/kebab-store-sqlite/src/fts.rs:40`) 의 DELETE + INSERT 방식도 external-content 규약(`'rebuild'` 커맨드)으로 교체. - design doc §5.5 의 verbatim block 이 CI diff-check 대상이므로(`migrations/V002__fts.sql:1-6`) spec 쪽도 같이 갱신해야 한다. HOTFIXES dated entry 필요. 더 작은 변경으로 버티려면 `chunk_id → fts rowid` 매핑 테이블을 두는 방법이 있으나, 정합성 유지 부담이 external-content 전환보다 크다.
Author
Owner

PR #235 로 닫는다. 다만 이슈가 제안한 external-content 전환은 택하지 않았으므로 근거를 남긴다.

원인은 이슈가 지목한 그대로였다. 삭제 트리거가 chunk_id 로 행을 찾는데 그 컬럼은 FTS5 에서 색인되지 않으므로, 삭제 한 건이 색인 전체를 훑는다.

해법으로는 chunks_fts 의 rowid 를 chunks.rowid 와 일치시키고 삭제를 rowid 로 하는 쪽을 택했다 (migrations/V016__fts_rowid_delete.sql). external-content 를 택하지 않은 이유는 V009 트리거가 색인하는 값이 tokenized_korean_text || ' ' || text 라서 chunks 의 어느 컬럼과도 일치하지 않기 때문이다. 그 길로 가려면 생성 컬럼을 새로 만들고, FTS 테이블에서 chunk_id/doc_id 를 빼고, 검색 경로의 컬럼 참조를 rowid join 으로 바꿔야 한다. 삭제 비용은 rowid 정렬만으로 같은 복잡도로 내려가므로, 본문 그림자(chunks_fts_content, 실측 550 MB) 회수는 별 건으로 남긴다.

실측은 실제 KB 사본(문서 28,427건 / chunk 600,808건)에서 문서 200건 삭제 기준 1590.1초 → 2.0초다. 삭제 후 남은 chunkschunks_fts 행 수가 양쪽 다 595,741 로 같고, 네 질의의 상위 20건을 chunk_id·bm25 점수·snippet 까지 해시로 비교했을 때 마이그레이션 전후가 동일했다. 그래서 corpus_revision 은 올리지 않는다.

이슈 본문의 사실관계 두 가지를 정정해 둔다. 지목된 마이그레이션은 V002 가 아니라 V009 다 — V007 이 trigram 으로, V009 가 unicode61 + 한국어 형태소 컬럼으로 각각 chunks_fts 를 다시 만들었다. 그리고 chunks_fts 는 contentless 가 아니다. chunks_fts_content 가 실재하고, 그 550 MB 가 위에서 말한 그림자다.

부수적으로 rebuild_chunks_fts 의 잠복 결함도 잡았다. V009 가 색인하는 한국어 형태소 접두를 빠뜨리고 원문만 넣고 있어서, 재구축을 한 번 돌리면 2자 한국어 질의가 다음 재색인 때까지 안 맞았다.

리뷰에서 나온 지적 하나를 함께 반영했다. rowid 로 삭제하면 정렬이 어긋난 순간 남의 문서 shadow 행을 조용히 지운다 — 실패 양상이 '느림'에서 '조용한 오삭제'로 바뀐다. kebab doctorfts_shadow 점검을 넣어 이 불변식을 눈에 보이게 했다. 참고로 초안에서 VACUUM 을 위험으로 적었으나, 실측해 보니 rowid 를 다시 매기지 않았다(실제 KB 사본과 합성 DB 양쪽에서 불일치 0, sqlite 3.53.4). 남는 실제 경로는 앞으로 chunks 를 테이블 재작성 방식으로 바꾸는 마이그레이션이며, V016 주석에 울타리를 박아 뒀다.

PR #235 로 닫는다. 다만 이슈가 제안한 external-content 전환은 택하지 않았으므로 근거를 남긴다. 원인은 이슈가 지목한 그대로였다. 삭제 트리거가 `chunk_id` 로 행을 찾는데 그 컬럼은 FTS5 에서 색인되지 않으므로, 삭제 한 건이 색인 전체를 훑는다. 해법으로는 `chunks_fts` 의 rowid 를 `chunks.rowid` 와 일치시키고 삭제를 rowid 로 하는 쪽을 택했다 (`migrations/V016__fts_rowid_delete.sql`). external-content 를 택하지 않은 이유는 V009 트리거가 색인하는 값이 `tokenized_korean_text || ' ' || text` 라서 `chunks` 의 어느 컬럼과도 일치하지 않기 때문이다. 그 길로 가려면 생성 컬럼을 새로 만들고, FTS 테이블에서 `chunk_id`/`doc_id` 를 빼고, 검색 경로의 컬럼 참조를 rowid join 으로 바꿔야 한다. 삭제 비용은 rowid 정렬만으로 같은 복잡도로 내려가므로, 본문 그림자(`chunks_fts_content`, 실측 550 MB) 회수는 별 건으로 남긴다. 실측은 실제 KB 사본(문서 28,427건 / chunk 600,808건)에서 문서 200건 삭제 기준 1590.1초 → 2.0초다. 삭제 후 남은 `chunks` 와 `chunks_fts` 행 수가 양쪽 다 595,741 로 같고, 네 질의의 상위 20건을 chunk_id·bm25 점수·snippet 까지 해시로 비교했을 때 마이그레이션 전후가 동일했다. 그래서 `corpus_revision` 은 올리지 않는다. 이슈 본문의 사실관계 두 가지를 정정해 둔다. 지목된 마이그레이션은 V002 가 아니라 V009 다 — V007 이 trigram 으로, V009 가 unicode61 + 한국어 형태소 컬럼으로 각각 `chunks_fts` 를 다시 만들었다. 그리고 `chunks_fts` 는 contentless 가 아니다. `chunks_fts_content` 가 실재하고, 그 550 MB 가 위에서 말한 그림자다. 부수적으로 `rebuild_chunks_fts` 의 잠복 결함도 잡았다. V009 가 색인하는 한국어 형태소 접두를 빠뜨리고 원문만 넣고 있어서, 재구축을 한 번 돌리면 2자 한국어 질의가 다음 재색인 때까지 안 맞았다. 리뷰에서 나온 지적 하나를 함께 반영했다. rowid 로 삭제하면 정렬이 어긋난 순간 남의 문서 shadow 행을 조용히 지운다 — 실패 양상이 '느림'에서 '조용한 오삭제'로 바뀐다. `kebab doctor` 에 `fts_shadow` 점검을 넣어 이 불변식을 눈에 보이게 했다. 참고로 초안에서 VACUUM 을 위험으로 적었으나, 실측해 보니 rowid 를 다시 매기지 않았다(실제 KB 사본과 합성 DB 양쪽에서 불일치 0, sqlite 3.53.4). 남는 실제 경로는 앞으로 `chunks` 를 테이블 재작성 방식으로 바꾸는 마이그레이션이며, V016 주석에 울타리를 박아 뒀다.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#229