Skip to content

Commit 96ec26e

Browse files
aksOpsclaude
andauthored
feat(storage): SQLite FTS5+BM25 log search (#52)
SQLite log search now routes through an FTS5 virtual table (`logs_fts`) over `(body, service_name)` with `bm25()` ranking. The index is kept in sync via AFTER INSERT/DELETE/UPDATE triggers on `logs`, so retention purges and manual deletes propagate automatically. The setup is idempotent and backfills existing rows on first boot via FTS5's `rebuild` command. User input is escaped and prefix-suffixed (`*`) so partial words still match (e.g., `conn` matches `connection`); the porter tokenizer covers inflectional matches (`panic` matches `panicked`). On any FTS5 query error the repository transparently falls back to LIKE so a misbehaving index never surfaces as a 500. Postgres keeps the existing `pg_trgm` GIN path; MySQL/SQL Server keep LIKE. New tests cover BM25 ordering, prefix and stemming matches, tenant isolation, the delete trigger sync, special-character escaping, and the GetLogsV2 search path. Docs updated in CLAUDE.md and OPERATIONS.md. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cf9c1f5 commit 96ec26e

7 files changed

Lines changed: 542 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ When none are present, `DEFAULT_TENANT` (default `"default"`) is assigned. Every
6060
| Time Series (in-memory) | `internal/tsdb/` | Ring buffer, sliding windows, pre-computed percentiles |
6161
| Graph (in-memory, legacy) | `internal/graph/` | Simple service topology — **being replaced by GraphRAG** |
6262
| Vector (embedded) | `internal/vectordb/` | TF-IDF index for semantic log search (pure Go, no CGO). Retained as a fallback similarity index for SQLite mode and for `SimilarErrors` ranking within a Drain template cluster. |
63-
| Relational (persistent) | `internal/storage/` | GORM-based, multi-DB, single source of truth. Driven by `RetentionScheduler` (hourly batched purge + daily VACUUM/ANALYZE). `logs.body` is plain TEXT (Postgres: `pg_trgm` GIN indexed for substring search); `AttributesJSON` and `AIInsight` remain `CompressedText`. |
63+
| Relational (persistent) | `internal/storage/` | GORM-based, multi-DB, single source of truth. Driven by `RetentionScheduler` (hourly batched purge + daily VACUUM/ANALYZE). `logs.body` is plain TEXT. **Log search**: SQLite uses FTS5 virtual table `logs_fts` (porter+unicode61 tokenizer) ordered by `bm25()`, kept in sync via AFTER INSERT/DELETE/UPDATE triggers; Postgres uses `pg_trgm` GIN on `logs.body` and `logs.service_name`. `AttributesJSON` and `AIInsight` remain `CompressedText`. |
6464

6565
## GraphRAG Architecture
6666

docs/OPERATIONS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,30 @@ SQLite is rejected at startup when `APP_ENV=production` unless you explicitly op
115115

116116
**Multi-tenancy.** Every row carries a `tenant_id` column. The write path reads `X-Tenant-ID` (HTTP) or `x-tenant-id` (gRPC metadata) and populates the column. The read path attaches the tenant from the request context to every repository query (`Where("tenant_id = ?", ...)`).
117117

118+
### Log search index
119+
120+
| Driver | Index | Ranking |
121+
|---|---|---|
122+
| SQLite | FTS5 virtual table `logs_fts` over `(body, service_name)`, kept in sync via AFTER INSERT/DELETE/UPDATE triggers on `logs` | `bm25(logs_fts)` ascending (lower = more relevant) |
123+
| Postgres | `pg_trgm` GIN indexes on `logs.body` and `logs.service_name` | Recency (`timestamp desc`) — substring ILIKE |
124+
| MySQL / SQL Server | None — sequential `LIKE` scan | Recency |
125+
126+
The FTS5 path uses `tokenize='porter unicode61 remove_diacritics 2'` — case-insensitive, accent-insensitive, English-stemmed (so `panic` matches `panicked`). User input is escaped and prefix-suffixed (`*`) so partial words like `conn` still match `connection`. If FTS5 errors at query time, the repository transparently falls back to LIKE so a misbehaving index does not surface as a 500 to the API.
127+
128+
The FTS5 table is provisioned automatically by `AutoMigrateModels` on every SQLite boot; setup is idempotent. To rebuild after corruption or a manual schema change:
129+
130+
```sql
131+
INSERT INTO logs_fts(logs_fts) VALUES('rebuild');
132+
```
133+
134+
The Postgres `pg_trgm` path requires the extension; if missing, AutoMigrate logs a warning and ILIKE falls back to a sequential scan. To install:
135+
136+
```sql
137+
CREATE EXTENSION pg_trgm;
138+
```
139+
140+
Phase 3b will add Postgres declarative partitioning as an opt-in adapter; at that point the GIN indexes will be created per-partition. There is no migration required to use FTS5 — existing SQLite databases are backfilled the first time the upgraded binary boots.
141+
118142
---
119143

120144
## Backup & Restore

internal/storage/factory.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,15 @@ func AutoMigrateModels(db *gorm.DB, driver string) error {
221221
log.Println("🔓 Dropped legacy FK constraints (no-op on fresh DBs)")
222222
}
223223

224+
// SQLite: provision FTS5 virtual table + triggers on logs.body / logs.service_name.
225+
// Search routes through bm25() ranking on this driver; LIKE remains the fallback
226+
// if FTS5 is unavailable (older SQLite builds without FTS5 compiled in).
227+
if driver == "sqlite" || driver == "" {
228+
if err := setupSQLiteFTS5(db); err != nil {
229+
log.Printf("⚠️ SQLite FTS5 setup failed (%v) — log search will fall back to LIKE", err)
230+
}
231+
}
232+
224233
// Postgres: enable pg_trgm and create a GIN index on logs.body for fuzzy ILIKE search.
225234
// Azure Database for PostgreSQL allows pg_trgm by default. If the role lacks
226235
// CREATE EXTENSION privilege, an operator can pre-create the extension and this

internal/storage/fts5.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package storage
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"strings"
7+
8+
"gorm.io/gorm"
9+
)
10+
11+
// fts5LogsTable is the FTS5 virtual table mirroring `logs.body` and
12+
// `logs.service_name`. It is an external-content table keyed on `logs.id` so it
13+
// stores no extra copy of the body — instead, INSERT/DELETE/UPDATE on `logs`
14+
// are mirrored via the triggers installed in setupSQLiteFTS5.
15+
const fts5LogsTable = "logs_fts"
16+
17+
// setupSQLiteFTS5 provisions the FTS5 virtual table for log search on SQLite
18+
// and the AFTER INSERT/DELETE/UPDATE triggers that keep it in sync with the
19+
// `logs` base table. The implementation is idempotent: it tolerates an
20+
// existing virtual table left over from a previous boot, repairs missing
21+
// triggers, and runs an initial backfill via the `rebuild` command so that
22+
// rows present in `logs` before the FTS table existed (e.g. migrating an
23+
// older OtelContext.db) are included in the BM25 index.
24+
//
25+
// Tokenizer rationale: `porter unicode61 remove_diacritics 2` chosen for:
26+
// - unicode61: case-insensitive, splits on whitespace+punctuation
27+
// - remove_diacritics 2: strips accents (latency vs latência both match)
28+
// - porter: English stemming so "panic" matches "panicked"/"panicking"
29+
//
30+
// All three are pure-SQLite — they do not require external linkage and work
31+
// on the modernc.org/sqlite (glebarez) build used in this project.
32+
func setupSQLiteFTS5(db *gorm.DB) error {
33+
create := `CREATE VIRTUAL TABLE IF NOT EXISTS ` + fts5LogsTable + ` USING fts5(
34+
body,
35+
service_name,
36+
content='logs',
37+
content_rowid='id',
38+
tokenize='porter unicode61 remove_diacritics 2'
39+
)`
40+
if err := db.Exec(create).Error; err != nil {
41+
// FTS5 is included in the modernc.org/sqlite amalgamation by default;
42+
// if this fails, the build was compiled without FTS5. Surface the
43+
// failure so SearchLogs can fall back to LIKE rather than producing
44+
// a confusing "no such table" error later.
45+
return fmt.Errorf("create fts5 virtual table: %w", err)
46+
}
47+
48+
triggers := []struct {
49+
name string
50+
ddl string
51+
}{
52+
{
53+
name: "logs_ai",
54+
ddl: `CREATE TRIGGER IF NOT EXISTS logs_ai AFTER INSERT ON logs BEGIN
55+
INSERT INTO ` + fts5LogsTable + `(rowid, body, service_name) VALUES (new.id, new.body, new.service_name);
56+
END`,
57+
},
58+
{
59+
name: "logs_ad",
60+
ddl: `CREATE TRIGGER IF NOT EXISTS logs_ad AFTER DELETE ON logs BEGIN
61+
INSERT INTO ` + fts5LogsTable + `(` + fts5LogsTable + `, rowid, body, service_name) VALUES ('delete', old.id, old.body, old.service_name);
62+
END`,
63+
},
64+
{
65+
name: "logs_au",
66+
ddl: `CREATE TRIGGER IF NOT EXISTS logs_au AFTER UPDATE ON logs BEGIN
67+
INSERT INTO ` + fts5LogsTable + `(` + fts5LogsTable + `, rowid, body, service_name) VALUES ('delete', old.id, old.body, old.service_name);
68+
INSERT INTO ` + fts5LogsTable + `(rowid, body, service_name) VALUES (new.id, new.body, new.service_name);
69+
END`,
70+
},
71+
}
72+
for _, tr := range triggers {
73+
if err := db.Exec(tr.ddl).Error; err != nil {
74+
return fmt.Errorf("create trigger %s: %w", tr.name, err)
75+
}
76+
}
77+
78+
// Backfill any rows already present in `logs` but not yet in the FTS index.
79+
// `rebuild` is a no-op on a fresh DB and cheap on a populated one — FTS5
80+
// streams the source rows once.
81+
if err := db.Exec(`INSERT INTO ` + fts5LogsTable + `(` + fts5LogsTable + `) VALUES ('rebuild')`).Error; err != nil {
82+
return fmt.Errorf("rebuild fts5 index: %w", err)
83+
}
84+
85+
log.Println("🔎 SQLite: FTS5 BM25 index ready on logs(body, service_name)")
86+
return nil
87+
}
88+
89+
// fts5MatchExpr translates a free-form user search string into an FTS5 MATCH
90+
// expression that approximates the previous LIKE %query% semantics:
91+
//
92+
// - whitespace-separated terms are ANDed together
93+
// - each term is double-quoted so FTS5 treats internal punctuation as
94+
// literal token separators rather than query operators
95+
// - each term is suffixed with `*` for prefix match, so a search for "conn"
96+
// still hits "connection"; combined with the porter stemmer this also
97+
// covers inflectional matches like "panic" → "panicked"
98+
//
99+
// Returns the empty string for empty/whitespace-only input — the caller is
100+
// expected to skip the WHERE-clause attachment in that case.
101+
func fts5MatchExpr(input string) string {
102+
fields := strings.Fields(input)
103+
if len(fields) == 0 {
104+
return ""
105+
}
106+
parts := make([]string, 0, len(fields))
107+
for _, f := range fields {
108+
escaped := strings.ReplaceAll(f, `"`, `""`)
109+
parts = append(parts, `"`+escaped+`"*`)
110+
}
111+
return strings.Join(parts, " ")
112+
}
113+
114+
// fts5Available reports whether the given driver should use the FTS5 path. We
115+
// only enable FTS5 on SQLite because Postgres has its own pg_trgm GIN path
116+
// (see factory.go) and MySQL/SQL Server are out of scope.
117+
func fts5Available(driver string) bool {
118+
return strings.ToLower(driver) == "sqlite"
119+
}

0 commit comments

Comments
 (0)