Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions internal/session/list_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,32 @@ func BenchmarkSQLiteStoreListWithLargeHistories(b *testing.B) {
}
}

func BenchmarkSQLiteStoreSearchCommonTermWithLargeHistories(b *testing.B) {
ctx := context.Background()
dbPath := filepath.Join(b.TempDir(), "sessions.db")
store, err := NewSQLiteStore(Config{Enabled: true, Path: dbPath})
if err != nil {
b.Fatalf("NewSQLiteStore: %v", err)
}
defer store.Close()

const sessionCount = 120
const messagesPerSession = 400
base := time.Now().Add(-time.Duration(sessionCount) * time.Minute).UTC().Truncate(time.Second)
seedSQLiteStoreListBenchmark(b, ctx, store, sessionCount, messagesPerSession, base)

b.ResetTimer()
for i := 0; i < b.N; i++ {
matches, err := store.Search(ctx, SearchOptions{Query: "hello", Limit: 20})
if err != nil {
b.Fatalf("Search: %v", err)
}
if len(matches) != 20 {
b.Fatalf("got %d matches, want 20", len(matches))
}
}
}

func BenchmarkNewSQLiteStoreExistingDB(b *testing.B) {
dbPath := filepath.Join(b.TempDir(), "sessions.db")
store, err := NewSQLiteStore(Config{Enabled: true, Path: dbPath})
Expand Down
46 changes: 35 additions & 11 deletions internal/session/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,17 @@ CREATE TABLE messages (
compaction_tail BOOLEAN DEFAULT FALSE
)`

const (
searchFTSDefaultLimit = 20

// Search returns at most one message per session, so common terms can produce
// many same-session FTS hits before enough distinct sessions are found. Keep
// the rank/window stages bounded while still sampling a generous candidate
// set for long conversations.
searchFTSMinCandidateLimit = 2048
searchFTSCandidatesPerRequestedResult = 512
)

// NewSQLiteStore creates a new SQLite-based session store.
func NewSQLiteStore(cfg Config) (*SQLiteStore, error) {
dbPath, err := ResolveDBPath(cfg.Path)
Expand Down Expand Up @@ -1748,10 +1759,21 @@ func (s *SQLiteStore) List(ctx context.Context, opts ListOptions) ([]SessionSumm
return results, rows.Err()
}

func searchFTSCandidateLimit(resultLimit int) int {
if resultLimit <= 0 {
resultLimit = searchFTSDefaultLimit
}
candidateLimit := resultLimit * searchFTSCandidatesPerRequestedResult
if candidateLimit < searchFTSMinCandidateLimit {
return searchFTSMinCandidateLimit
}
return candidateLimit
}

// Search finds sessions containing the query text using FTS5.
func (s *SQLiteStore) Search(ctx context.Context, opts SearchOptions) ([]SearchResult, error) {
if opts.Limit == 0 {
opts.Limit = 20
if opts.Limit <= 0 {
opts.Limit = searchFTSDefaultLimit
}

ftsQuery := sqlitefts.LiteralQuery(opts.Query)
Expand Down Expand Up @@ -1829,20 +1851,22 @@ func (s *SQLiteStore) Search(ctx context.Context, opts SearchOptions) ([]SearchR
if !opts.Archived {
filterClause += " AND s.archived = FALSE"
}
args = append(args, opts.Limit)
candidateLimit := searchFTSCandidateLimit(opts.Limit)
args = append(args, candidateLimit, opts.Limit)

rows, err := s.db.QueryContext(ctx, `
WITH raw_matches AS (
SELECT rowid AS message_id, rank AS match_rank
WITH raw_matches AS MATERIALIZED (
SELECT m.id AS message_id, m.session_id, messages_fts.rank AS match_rank
FROM messages_fts
WHERE messages_fts MATCH ?
JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_id
WHERE messages_fts MATCH ?`+compactionTailClause+filterClause+`
ORDER BY messages_fts.rank
LIMIT ?
), ranked_matches AS (
SELECT m.id AS message_id, m.session_id, raw_matches.match_rank,
ROW_NUMBER() OVER (PARTITION BY m.session_id ORDER BY raw_matches.match_rank, m.id) AS session_row
SELECT message_id, session_id, match_rank,
ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY match_rank, message_id) AS session_row
FROM raw_matches
JOIN messages m ON m.id = raw_matches.message_id
JOIN sessions s ON s.id = m.session_id
WHERE 1=1`+compactionTailClause+filterClause+`
), session_matches AS (
SELECT message_id, session_id, match_rank
FROM ranked_matches
Expand Down
71 changes: 71 additions & 0 deletions internal/session/sqlite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,77 @@ func TestSQLiteStoreSearchReturnsDistinctFilteredSessionMatches(t *testing.T) {
}
}

func TestSQLiteStoreSearchCommonTermReturnsLimitFromLargeCandidateSet(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())

store, err := NewSQLiteStore(DefaultConfig())
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
defer store.Close()

ctx := context.Background()
const sessionCount = 30
const messagesPerSession = 80
const resultLimit = 20
base := time.Now().UTC().Truncate(time.Second)

tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("BeginTx: %v", err)
}
defer tx.Rollback()

sessStmt, err := tx.PrepareContext(ctx, `
INSERT INTO sessions (id, number, name, summary, provider, model, mode, origin, created_at, updated_at, last_user_message_at, last_message_at, archived, pinned, status)
VALUES (?, ?, '', '', 'test', 'test-model', 'chat', 'web', ?, ?, ?, ?, FALSE, FALSE, 'active')`)
if err != nil {
t.Fatalf("prepare session insert: %v", err)
}
defer sessStmt.Close()

msgStmt, err := tx.PrepareContext(ctx, `
INSERT INTO messages (session_id, role, parts, text_content, created_at, sequence)
VALUES (?, 'user', '[]', ?, ?, ?)`)
if err != nil {
t.Fatalf("prepare message insert: %v", err)
}
defer msgStmt.Close()

for i := 0; i < sessionCount; i++ {
sessionID := fmt.Sprintf("search-common-%02d", i)
createdAt := base.Add(time.Duration(i) * time.Minute)
lastVisibleAt := createdAt.Add(time.Duration(messagesPerSession-1) * time.Second)
if _, err := sessStmt.ExecContext(ctx, sessionID, i+1, createdAt, lastVisibleAt, createdAt, lastVisibleAt); err != nil {
t.Fatalf("insert session %d: %v", i, err)
}
for j := 0; j < messagesPerSession; j++ {
text := fmt.Sprintf("common sidebar search token in session %02d message %03d", i, j)
if _, err := msgStmt.ExecContext(ctx, sessionID, text, createdAt.Add(time.Duration(j)*time.Second), j); err != nil {
t.Fatalf("insert message %d/%d: %v", i, j, err)
}
}
}
if err := tx.Commit(); err != nil {
t.Fatalf("Commit: %v", err)
}

results, err := store.Search(ctx, SearchOptions{Query: "common", Limit: resultLimit})
if err != nil {
t.Fatalf("Search common: %v", err)
}
if len(results) != resultLimit {
t.Fatalf("Search common len = %d, want %d", len(results), resultLimit)
}
seen := map[string]bool{}
for _, result := range results {
if seen[result.SessionID] {
t.Fatalf("duplicate session in common search results: %#v", results)
}
seen[result.SessionID] = true
}
}

func TestSQLiteStorePersistsDeveloperMessages(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())

Expand Down
Loading