Skip to content

Commit 19cce43

Browse files
committed
vec/gorm + fts/gorm: wrapper type, DropTable cascade, FTS5 modes,
dedupe helpers
1 parent 27f00aa commit 19cce43

38 files changed

Lines changed: 4157 additions & 81 deletions

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,74 @@ matches, _ := idx.SearchSlice(ctx, fts.Term("fox"),
142142

143143
See [`examples/fts-search/`](examples/fts-search/main.go).
144144

145+
### Deep gorm integration — tag-driven vec & FTS5
146+
147+
The `vec/gorm` and `fts/gorm` sub-packages bridge gorm models to the
148+
vector / full-text sidecars. Tag a field, register the plugin, and
149+
gorm Create/Update/Delete maintains the sidecar automatically. Typed
150+
`KNN[T]` / `Search[T]` helpers return matching gorm models in
151+
ranking order with distance / rank attached.
152+
153+
```go
154+
import (
155+
_ "github.com/go-again/sqlite"
156+
sqlitegorm "github.com/go-again/sqlite/gorm"
157+
"github.com/go-again/sqlite/fts"
158+
ftsgorm "github.com/go-again/sqlite/fts/gorm"
159+
vecgorm "github.com/go-again/sqlite/vec/gorm"
160+
)
161+
162+
type Document struct {
163+
ID uint `gorm:"primaryKey"`
164+
Title string `fts5:"tokenize=porter+unicode61"`
165+
Body string `fts5:"tokenize=porter+unicode61"`
166+
Embedding vecgorm.Embedding `vec:"dim=384;metric=cosine"`
167+
}
168+
169+
db, _ := gorm.Open(sqlitegorm.Open("app.db"), &gorm.Config{})
170+
db.Use(vecgorm.Plugin())
171+
db.Use(ftsgorm.Plugin())
172+
173+
vecgorm.Migrate(db, &Document{}) // creates documents + documents_vec
174+
ftsgorm.Migrate(db, &Document{}) // creates documents_fts + triggers
175+
176+
db.Create(&Document{Title: "Hello", Body: "world", Embedding: vec})
177+
178+
// Find documents semantically similar to a query vector:
179+
near, _ := vecgorm.KNN[Document](ctx, db, queryVec, 5)
180+
181+
// Find documents matching a phrase, ranked by BM25:
182+
hits, _ := ftsgorm.Search[Document](ctx, db, fts.Term("world"))
183+
```
184+
185+
Tag-driven features:
186+
187+
- Auto-migrate sidecar tables alongside `db.AutoMigrate`.
188+
- Sync-on-write callbacks (vec) or triggers (FTS5) — including
189+
`CreateInBatches` in a single transaction per batch.
190+
- Soft-delete awareness: models using `gorm.DeletedAt` get a
191+
metadata column on the sidecar; KNN/Search excludes them
192+
automatically. Pass `IncludeDeleted()` to override.
193+
- Typed helpers return `[]Result[T]` ordered by ranking so callers
194+
don't have to rebuild the IN-clause + re-sort dance.
195+
- `db.Migrator().DropTable(&Model{})` cascades into the sidecar
196+
(vec0 table or FTS5 table + triggers) via the dialector's
197+
`DropTableHook` interface — no manual cleanup needed.
198+
- FTS5 mode is configurable per tag: `external` (default,
199+
triggers-driven), `external=false` (in-table FTS5), or
200+
`contentless=true` (index only, no text).
201+
202+
The embedding field type is `vecgorm.Embedding` (a `[]float32`
203+
alias that implements gorm's `GormDataType`); `[]float32` with
204+
`gorm:"-"` also works for callers who prefer not to import the
205+
wrapper.
206+
207+
See [`vec/gorm/`](vec/gorm/) and [`fts/gorm/`](fts/gorm/) for full
208+
package docs and [`examples/gorm-vec-tagged/`](examples/gorm-vec-tagged/)
209+
+ [`examples/gorm-fts-tagged/`](examples/gorm-fts-tagged/) for
210+
runnable end-to-end usage; coverage matrix lives in
211+
[`docs/coverage-gorm.md`](docs/coverage-gorm.md).
212+
145213
### `embed.FS`-backed read-only databases
146214

147215
```go

docs/coverage-gorm.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,68 @@ result is **386 PASS / 0 FAIL / 22 SKIP**. The setup, reproduction
166166
recipe, and reasoning for each shim flag live in
167167
[gorm-upstream.md](gorm-upstream.md). The same recipe is enforced by
168168
the `gorm-upstream` CI job.
169+
170+
## Deep integration: `vec/gorm` and `fts/gorm`
171+
172+
Tag-driven sidecar packages live under `github.com/go-again/sqlite/vec/gorm`
173+
and `github.com/go-again/sqlite/fts/gorm`. They register as gorm
174+
plugins and own the full lifecycle of the sidecar (vec0 virtual table /
175+
FTS5 external-content table + triggers).
176+
177+
### Tag syntax — vec
178+
179+
| Key | Required | Meaning |
180+
|---|---|---|
181+
| `dim=N` | yes | Embedding dimension. |
182+
| `metric=l2 \| cosine \| dot` | no | Distance metric. Default `l2`. |
183+
| `encoding=json \| binary` | no | Wire encoding. Default `binary`. |
184+
| `table=NAME` | no | Override sidecar table name. Default `<source>_vec`. |
185+
| `column=NAME` | no | Override embedding column. Default `embedding`. |
186+
187+
The tagged field's type must be either `vecgorm.Embedding`
188+
(recommended) or `[]float32` with `gorm:"-"` alongside. The wrapper
189+
type implements gorm's `GormDataType` interface so the schema parser
190+
accepts it; the plugin then sets `IgnoreMigration=true` so no column
191+
lands on the source table.
192+
193+
### Tag syntax — fts5
194+
195+
| Key | Required | Meaning |
196+
|---|---|---|
197+
| `tokenize=NAME[+args]` | no | FTS5 tokenize option. Spaces escaped as `+`. |
198+
| `prefix=N1,N2,...` | no | Pre-computed prefix-match index sizes. |
199+
| `column=NAME` | no | Override FTS5 column name (default = lowercase field). |
200+
| `table=NAME` | no | Override FTS5 table. Default `<source>_fts`. |
201+
| `detail=full \| column \| none` | no | FTS5 detail= option. |
202+
| `external=true \| false` | no | External-content mode (default true). false → in-table FTS5 manages text itself. |
203+
| `contentless=true` | no | Contentless FTS5 (index only, no text). Snippet/highlight are rejected at search time. Mutually exclusive with `external=true`. |
204+
205+
Multiple `fts5:`-tagged fields on one model share **one** FTS5 table.
206+
Conflicting table-level keys across fields are rejected at parse time.
207+
208+
### Lifecycle matrix
209+
210+
| Event | vec/gorm behavior | fts/gorm behavior |
211+
|---|---|---|
212+
| Plugin install | `db.Use(vecgorm.Plugin())` | `db.Use(ftsgorm.Plugin())` |
213+
| AutoMigrate | `vecgorm.Migrate(db, &T{})` creates source + sidecar | `ftsgorm.Migrate(db, &T{})` creates source + FTS5 table + triggers |
214+
| Create | AfterCreate callback `BatchInsert` (single tx) | AFTER INSERT trigger writes to FTS5 |
215+
| Save/Update | AfterUpdate callback `(*vec.Table).Update` | AFTER UPDATE trigger refreshes index |
216+
| Delete (hard) | AfterDelete callback `(*vec.Table).Delete` | AFTER DELETE trigger emits FTS5 `'delete'` |
217+
| Delete (soft, via `gorm.DeletedAt`) | Sidecar `deleted` flag flipped to 1 | FTS5's UNINDEXED `deleted_at` mirror set by trigger |
218+
| KNN / Search | Soft-deleted excluded by default; `IncludeDeleted()` overrides | Same |
219+
| DropSidecar | Drops sidecar table | Drops FTS5 table + all three triggers (for external mode) |
220+
| Source DropTable | Cascades into sidecar via DropTableHook on our gorm Dialector | Cascades into FTS5 table + triggers |
221+
| dim mismatch on re-migrate | Logged warning, existing sidecar left alone | n/a |
222+
223+
### Tests
224+
225+
| File | Tests | Notes |
226+
|---|---|---|
227+
| `vec/gorm/vecgorm_test.go` | 12 | Basic create/update/delete, KNN ranking, BatchInsert single-tx, soft-delete, Embedding wrapper |
228+
| `vec/gorm/lifecycle_test.go` | 9 | DropTable cascade, DropSidecar, composite PK rejection, tag validation, WithFilter, dim mismatch |
229+
| `fts/gorm/ftsgorm_test.go` | 10 | Migrate creates index + triggers, search/snippet/highlight, ranking, soft-delete, backfill |
230+
| `fts/gorm/lifecycle_test.go` | 8 | Conflicting tags, non-string fields, composite PK, LIMIT/OFFSET, no-plugin error, DropTable cascade |
231+
| `fts/gorm/mode_test.go` | 7 | external/in-table/contentless modes, conflicting modes rejected, contentless rejects snippet, in-table soft-delete |
232+
233+
46 tests total, all passing on linux/macos.

examples/gorm-fts-tagged/main.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// gorm-fts-tagged: the tag-driven flow for combining gorm models with
2+
// SQLite FTS5. Mark text fields with `fts5:"..."` on a model, register
3+
// the ftsgorm.Plugin(), call ftsgorm.Migrate (which creates the FTS5
4+
// external-content table + the AFTER INSERT/UPDATE/DELETE triggers),
5+
// and let db.Create / ftsgorm.Search handle the index transparently.
6+
//
7+
// Compare with examples/gorm-fts/ for the side-by-side recipe — both
8+
// patterns coexist; pick the one that fits.
9+
package main
10+
11+
import (
12+
"context"
13+
"fmt"
14+
"log"
15+
16+
"gorm.io/gorm"
17+
"gorm.io/gorm/logger"
18+
19+
_ "github.com/go-again/sqlite"
20+
"github.com/go-again/sqlite/fts"
21+
ftsgorm "github.com/go-again/sqlite/fts/gorm"
22+
sqlitegorm "github.com/go-again/sqlite/gorm"
23+
)
24+
25+
// Article is a typical gorm model with two indexed text fields. Both
26+
// are tagged with `fts5:` — they share one FTS5 external-content
27+
// table named `articles_fts` (the default `<source>_fts` form).
28+
type Article struct {
29+
ID uint `gorm:"primaryKey"`
30+
Title string `fts5:"tokenize=porter+unicode61"`
31+
Body string `fts5:"tokenize=porter+unicode61"`
32+
}
33+
34+
func main() {
35+
db, err := gorm.Open(sqlitegorm.Open(":memory:"), &gorm.Config{
36+
Logger: logger.Default.LogMode(logger.Silent),
37+
})
38+
if err != nil {
39+
log.Fatal(err)
40+
}
41+
42+
if err := db.Use(ftsgorm.Plugin()); err != nil {
43+
log.Fatal(err)
44+
}
45+
if err := ftsgorm.Migrate(db, &Article{}); err != nil {
46+
log.Fatal(err)
47+
}
48+
49+
// Seed a few articles. The AFTER INSERT trigger maintains the
50+
// FTS5 index automatically — no extra Go code.
51+
articles := []Article{
52+
{Title: "Hello world", Body: "The quick brown fox jumps over the lazy dog"},
53+
{Title: "Bears are bears", Body: "Polar bears live in the arctic"},
54+
{Title: "On dogs", Body: "Dogs are loyal companions"},
55+
}
56+
if err := db.Create(&articles).Error; err != nil {
57+
log.Fatal(err)
58+
}
59+
60+
// Search for "fox" — single hit with snippet + highlight pulled
61+
// from the source via FTS5's documented auxiliary functions.
62+
ctx := context.Background()
63+
results, err := ftsgorm.Search[Article](
64+
ctx, db, fts.Term("fox"),
65+
ftsgorm.WithSnippet("body", "<b>", "</b>", "…", 8),
66+
ftsgorm.WithHighlight("body", "[", "]"),
67+
)
68+
if err != nil {
69+
log.Fatal(err)
70+
}
71+
fmt.Println("Search for 'fox':")
72+
for _, r := range results {
73+
fmt.Printf(" id=%d title=%q rank=%.4f\n", r.Model.ID, r.Model.Title, r.Rank)
74+
fmt.Printf(" snippet: %s\n", r.Snippet)
75+
fmt.Printf(" highlight: %s\n", r.Highlight)
76+
}
77+
78+
// Search across both columns with BM25 weights.
79+
results, err = ftsgorm.Search[Article](
80+
ctx, db, fts.Term("bears"),
81+
ftsgorm.WithRanking(2.0, 1.0), // weight title higher than body
82+
)
83+
if err != nil {
84+
log.Fatal(err)
85+
}
86+
fmt.Println("Search for 'bears' (title-weighted):")
87+
for _, r := range results {
88+
fmt.Printf(" id=%d title=%q\n", r.Model.ID, r.Model.Title)
89+
}
90+
91+
// Cleanup: db.Migrator().DropTable also tears down the FTS5 table
92+
// and its three triggers through the plugin's DropTableHook.
93+
if err := db.Migrator().DropTable(&Article{}); err != nil {
94+
log.Fatal(err)
95+
}
96+
fmt.Println("DropTable cascade: source + FTS5 table + triggers all gone")
97+
}

examples/gorm-vec-tagged/main.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// gorm-vec-tagged: the tag-driven flow for combining gorm models with
2+
// sqlite-vec. Mark an embedding field with `vec:"dim=N"` on a model,
3+
// register the vecgorm.Plugin(), call vecgorm.Migrate, and let
4+
// db.Create / vecgorm.KNN handle the sidecar transparently. No manual
5+
// vec.Table maintenance.
6+
//
7+
// Compare with examples/gorm-vec/ for the side-by-side recipe — both
8+
// patterns coexist; pick the one that fits.
9+
package main
10+
11+
import (
12+
"context"
13+
"fmt"
14+
"log"
15+
16+
"gorm.io/gorm"
17+
"gorm.io/gorm/logger"
18+
19+
_ "github.com/go-again/sqlite"
20+
sqlitegorm "github.com/go-again/sqlite/gorm"
21+
vecgorm "github.com/go-again/sqlite/vec/gorm"
22+
)
23+
24+
// Document is a typical gorm model with one tagged embedding field.
25+
// The vecgorm.Embedding wrapper type lets us skip the gorm:"-" tag —
26+
// the wrapper implements gorm's GormDataType interface so the schema
27+
// parser accepts it; the plugin then sets IgnoreMigration=true on the
28+
// field so no BLOB column lands on the source table.
29+
type Document struct {
30+
ID uint `gorm:"primaryKey"`
31+
Title string
32+
Body string
33+
Embedding vecgorm.Embedding `vec:"dim=4;metric=cosine"`
34+
}
35+
36+
func main() {
37+
db, err := gorm.Open(sqlitegorm.Open(":memory:"), &gorm.Config{
38+
Logger: logger.Default.LogMode(logger.Silent),
39+
})
40+
if err != nil {
41+
log.Fatal(err)
42+
}
43+
44+
if err := db.Use(vecgorm.Plugin()); err != nil {
45+
log.Fatal(err)
46+
}
47+
if err := vecgorm.Migrate(db, &Document{}); err != nil {
48+
log.Fatal(err)
49+
}
50+
51+
// Seed three documents. Embeddings populate the sidecar transparently.
52+
docs := []Document{
53+
{Title: "north", Body: "polar bears", Embedding: vecgorm.Embedding{0, 1, 0, 0}},
54+
{Title: "east", Body: "sunrises", Embedding: vecgorm.Embedding{1, 0, 0, 0}},
55+
{Title: "south", Body: "deserts", Embedding: vecgorm.Embedding{0, -1, 0, 0}},
56+
}
57+
if err := db.Create(&docs).Error; err != nil {
58+
log.Fatal(err)
59+
}
60+
61+
// Find the closest document to a query vector. KNN returns
62+
// []Result[Document] with .Distance attached — no manual IN-clause
63+
// or re-sorting required.
64+
ctx := context.Background()
65+
results, err := vecgorm.KNN[Document](ctx, db, []float32{0, 0.95, 0, 0}, 2)
66+
if err != nil {
67+
log.Fatal(err)
68+
}
69+
fmt.Println("KNN results:")
70+
for _, r := range results {
71+
fmt.Printf(" id=%d title=%q distance=%.4f\n", r.Model.ID, r.Model.Title, r.Distance)
72+
}
73+
74+
// Cleanup is automatic via gorm's Migrator. db.Migrator().DropTable
75+
// also drops the sidecar through the plugin's DropTableHook.
76+
if err := db.Migrator().DropTable(&Document{}); err != nil {
77+
log.Fatal(err)
78+
}
79+
fmt.Println("DropTable cascade: source + sidecar both gone")
80+
}

fts/fts.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ func (i *Index[K, V]) buildSearchSQL(q Query, cfg *searchConfig) (string, []any,
441441
b.WriteString(" WHERE ")
442442
b.WriteString(quote(i.name))
443443
b.WriteString(" MATCH ?")
444-
args = append(args, q.build())
444+
args = append(args, q.Build())
445445

446446
if cfg.withRank {
447447
b.WriteString(" ORDER BY __rank")

0 commit comments

Comments
 (0)