Skip to content

CPLAT-11784: back ccx cross-session search with a SQLite FTS5 index - #160

Merged
gavin-jeong merged 2 commits into
masterfrom
CPLAT-11784-search-fts5-index
Aug 30, 2026
Merged

gavin-jeong merged 2 commits into
masterfrom
CPLAT-11784-search-fts5-index

Conversation

@gavin-jeong

Copy link
Copy Markdown
Collaborator

JIRA: https://sendbird.atlassian.net/browse/CPLAT-11784

Problem

Cross-session search (Ctrl+S) re-reads every transcript on each query. On my corpus (2.5 GB, 995 sessions) that is 0.6–1.9s per search, and it gets worse as history grows. There was no index at all.

While measuring this I also found executeSearch ranging the same result channel from two goroutines — results were split non-deterministically between them and one goroutine's share was discarded. Ctrl+S has been silently dropping results.

What this does

Indexes transcript content into ~/.claude/.ccx-index.db (SQLite FTS5) and queries that, falling back to the full scan whenever the index can't answer a query.

Measured on the real corpus, at the limit the TUI actually uses:

Query Before After
worktree (broad) 1765ms 44ms
goreleaser (rare) 674ms 5ms
워크트리 (Korean) 627ms 24ms
tool:Bash worktree 1647ms 47ms

Index is 401 MB, first build ~70s, incremental refresh 25–162ms.

Design notes

Each of these was forced by measurement, not preference — worth knowing before changing them:

  • modernc.org/sqlite (pure Go) — keeps the goreleaser cross-compile matrix intact. A CGO driver like mattn/go-sqlite3 would break darwin/linux × amd64/arm64 builds.
  • tokenize='trigram' — the only option preserving the existing strings.Contains semantics and Korean matching. unicode61 returned 1930 hits where the scan returned 10471; it is not a drop-in.
  • detail=full — required, not chosen. Trigram matching is internally a phrase query, and FTS5 rejects phrase queries unless detail=full. This rules out the smaller detail=none layouts.
  • content='' + contentless_delete=1 — the text is already on disk, so storing a second copy is pure overhead (410 MB → 284 MB on a sample). contentless_delete is what makes incremental reindexing possible at all.

Known gap (intentional)

tool_result is not indexed. It is half the corpus but mostly file dumps and command output; including it nearly doubles the index for little search value. Rather than hide this, the modal shows tool output not indexed, and the full scan still finds that content.

Queries with a term shorter than 3 characters fall back to the scan automatically — a trigram index cannot match them.

Testing

  • Full suite green (go test ./...).
  • Parity tests assert index results match the full scan across 11 query shapes (AND, phrase, exclusion, Korean, substring, user:/assistant:/tool: scopes).
  • Verified against the live 2.5 GB corpus: every non-tool_result scan hit is found by the index. This is what caught a real bug — system_tag blocks were being dropped, costing 58 hits on worktree. Fixed by inverting indexableBlock to a denylist, plus a regression test.
  • Mutation-tested the tests themselves (broken exclusions, duplicate rows on reindex, the system_tag omission) to confirm they actually fail on the defects they cover.

@jinsekim jinsekim left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@jinsekim jinsekim added the auto-review/approved Auto-approved by the Slack auto-reviewer bot label Aug 28, 2026
@upwind-code-us

upwind-code-us Bot commented Aug 28, 2026

Copy link
Copy Markdown

Upwind Upwind Code Scan - ✅ Proceed with Deployment

0 newly introduced vulnerabilities · 1 resolved · 1 total in this PR vs master

🔶 1 High


Resolved · 1 finding
CVE Package Version
CVE-2026-39824 golang.org/x/sys v0.42.0

View full analysis in Upwind Console

Scan completed in 17s

Scan history (3 scans)
Commit Scanned at New Resolved Net
7163108 2026-08-28 13:02 UTC 0 -1 -1
0b918b9 2026-08-29 21:42 UTC 0 -1 -1
1db57dd < 2026-08-29 21:44 UTC 0 -1 -1

Last scanned: 1db57dd · 2026-08-29 21:44 UTC

@upwind-code-us

upwind-code-us Bot commented Aug 28, 2026

Copy link
Copy Markdown

Upwind Upwind IaC Scan - ✅ Proceed with Deployment

0 newly introduced misconfigurations · 0 resolved · 0 total in this PR vs main

View full analysis in Upwind Console →

Scan completed in 1s

Scan history (3 scans)
Commit Scanned at New Resolved Net
7163108 2026-08-28 13:02 UTC 0 0 0
0b918b9 2026-08-29 21:42 UTC 0 0 0
1db57dd < 2026-08-29 21:44 UTC 0 0 0

Last scanned: 1db57dd · 2026-08-29 21:44 UTC

Ctrl+S re-read every transcript on each query. On a 2.5 GB / 995-session
corpus that is 0.6-1.9s per search, growing with history.

Index transcript content into ~/.claude/.ccx-index.db and query that
instead, falling back to the full scan whenever the index cannot answer
a query. Measured on the real corpus:

  worktree (broad)      1765ms -> 44ms
  goreleaser (rare)      674ms ->  5ms
  워크트리 (Korean)        627ms -> 24ms
  tool:Bash worktree    1647ms -> 47ms

Index is 401 MB, first build ~70s, incremental refresh 25-162ms.

Choices worth knowing, all forced by measurement rather than taste:

- modernc.org/sqlite (pure Go) keeps the goreleaser cross-compile matrix
  working; a CGO driver would break darwin/linux x amd64/arm64.
- tokenize='trigram' preserves the existing strings.Contains semantics
  and Korean matching. unicode61 found 1930 hits where the scan found
  10471, so it is not a drop-in.
- detail=full is required, not chosen: trigram matching is internally a
  phrase query and FTS5 rejects those unless detail=full, which rules
  out the smaller layouts.
- content='' with contentless_delete=1 avoids storing a second copy of
  text that is already on disk (410 MB -> 284 MB on a sample) while
  still allowing a changed file's rows to be deleted and reindexed.

tool_result is deliberately not indexed: it is half the corpus and
mostly file dumps, and including it nearly doubles the index. The modal
says "tool output not indexed" so the gap is visible rather than silent,
and queries with a sub-trigram term fall back to the scan automatically.

Also fixes a pre-existing bug in executeSearch: it ranged the same
result channel from two goroutines, so results were split
non-deterministically and one goroutine's share was thrown away. Ctrl+S
has been dropping results.
@gavin-jeong
gavin-jeong force-pushed the CPLAT-11784-search-fts5-index branch from 7163108 to 1db57dd Compare August 29, 2026 21:34
Filtering told you which blocks matched but not where, so the term still
had to be found by eye in the pane on the right. Three paths were
missing the paint:

- Block filter (`/`) in the conversation view: decided visibility only.
  Now the surviving blocks have their matching text highlighted.
- Cross-session search (Ctrl+S): the query was dropped at the jump, so
  the message you landed on looked like any other. The query's text
  terms now follow the jump into the preview and details.
- Session-list preview: the one-line summary was already highlighted via
  highlightSnippet, but expanding a row showed the full body unpainted —
  which is precisely when you are hunting for the term.

Structural filter tokens (is:tool, tool:Bash) select whole blocks rather
than text within them, so highlightableTerms drops them; painting them
would mark the literal string "is:tool" wherever it appeared. Negated
terms (!foo) mark what must be absent and are dropped for the same
reason.

openConversation clears the carried highlight by default and the search
path re-applies it immediately after. Seven of the eight callers are not
search jumps, so defaulting to off is what keeps a stale query from
leaking into an unrelated session.
@gavin-jeong
gavin-jeong force-pushed the CPLAT-11784-search-fts5-index branch from 1db57dd to 0b918b9 Compare August 29, 2026 21:36
@gavin-jeong
gavin-jeong merged commit 901287c into master Aug 30, 2026
5 checks passed
gavin-jeong added a commit that referenced this pull request Aug 30, 2026
#160 added a SearchMode argument to updateSearchResults while #162 added
tests that call it. Both were green on their own branches and neither
touched the other's files, so git merged them without conflict — but the
merged tree does not compile:

  vet: search_resume_test.go:17: not enough arguments in call to
       a.updateSearchResults
       have ([]session.SearchResult)
       want ([]session.SearchResult, session.SearchMode)

Production code is unaffected (go build passes); only the test callers
are stale. Pass SearchModeScan, which is what these tests were written
against — they exercise live badges and resume, not index coverage.

Co-authored-by: keyolk <keyolk@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-review/approved Auto-approved by the Slack auto-reviewer bot

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants