sqlite_search 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/MIT-LICENSE +21 -0
- data/README.md +371 -0
- data/lib/generators/sqlite_search/fts5_generator.rb +55 -0
- data/lib/generators/sqlite_search/templates/create_fts5_index.rb.tt +5 -0
- data/lib/generators/sqlite_search/templates/create_vec_index.rb.tt +5 -0
- data/lib/generators/sqlite_search/vec_generator.rb +29 -0
- data/lib/sqlite_search/config.rb +24 -0
- data/lib/sqlite_search/embed_job.rb +30 -0
- data/lib/sqlite_search/errors.rb +5 -0
- data/lib/sqlite_search/fts5/backend.rb +71 -0
- data/lib/sqlite_search/fts5/definition.rb +33 -0
- data/lib/sqlite_search/fts5.rb +26 -0
- data/lib/sqlite_search/hybrid.rb +39 -0
- data/lib/sqlite_search/migration.rb +46 -0
- data/lib/sqlite_search/model.rb +187 -0
- data/lib/sqlite_search/query.rb +46 -0
- data/lib/sqlite_search/railtie.rb +22 -0
- data/lib/sqlite_search/sql.rb +16 -0
- data/lib/sqlite_search/vec/backend.rb +43 -0
- data/lib/sqlite_search/vec/definition.rb +50 -0
- data/lib/sqlite_search/vec.rb +51 -0
- data/lib/sqlite_search/version.rb +5 -0
- data/lib/sqlite_search.rb +23 -0
- data/lib/tasks/sqlite_search.rake +19 -0
- metadata +207 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3e39fa70c6de966aae8026bb76778f58701d7e2ffaa20cf3ede7ed1b77df4488
|
|
4
|
+
data.tar.gz: 2664f27e51d316d7d9f99c507e9213c7ee564015a3345974a7ccc880641caba5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 77745b678a9c28d335d4a440d3d90323bbecf775ba9fa4d747ca493f5e158eda6d4eea92117f4552c1ab47f9052de20b96ff0fc341cfb88b1bddcc12214cdacb
|
|
7
|
+
data.tar.gz: 433e12eee264c82ed27a0b9eb5ffaa90db564fc20a67ba8d7c9a2a0c09b175be1e5f2e079ddf63ab6cff8e978f6c25a1cda46db30cd8e8342d459dd6da6c6d6c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Full-text search over ActiveRecord with SQLite FTS5: the `fts5_scope` DSL, the
|
|
12
|
+
`create_fts5_index` migration helper (round-trips through `schema.rb`), a query
|
|
13
|
+
sanitizer that makes untrusted input safe, BM25 relevance ranking via
|
|
14
|
+
`order_by_rank`, model-owned callback sync, and `Model.reindex` plus a rake task.
|
|
15
|
+
- Vector search with [sqlite-vec](https://github.com/asg017/sqlite-vec) through the
|
|
16
|
+
[`neighbor`](https://github.com/ankane/neighbor) gem: the `vec_scope` DSL, the
|
|
17
|
+
`create_vec_index` migration helper, an app-registered `SqliteSearch.embedder`,
|
|
18
|
+
async embedding through `SqliteSearch::EmbedJob` (with a `sync: :inline` option
|
|
19
|
+
and a configurable queue), and `Model.reembed` plus a rake task.
|
|
20
|
+
- Hybrid search: `hybrid_scope` fuses an `fts5_scope` and a `vec_scope` with
|
|
21
|
+
Reciprocal Rank Fusion, with an optional, degradable `SqliteSearch.reranker`.
|
|
22
|
+
- Exact pre-filtering by chaining: conditions placed before `.search`/`.semantic`
|
|
23
|
+
(for example `Post.where(tenant_id: 5).search("...")`) push into both arms.
|
|
24
|
+
- Rails generators for the FTS5 and vec migrations.
|
|
25
|
+
|
|
26
|
+
[Unreleased]: https://github.com/radioactive-labs/sqlite_search/commits/main
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Radioactive Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# sqlite_search
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/sqlite_search)
|
|
4
|
+
[](https://github.com/radioactive-labs/sqlite_search/actions/workflows/ci.yml)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
**Full-text, vector, and hybrid search for ActiveRecord, without leaving SQLite.**
|
|
8
|
+
Declare which columns are searchable and get a query scope. No search cluster to
|
|
9
|
+
run, no second copy of your data to keep in sync.
|
|
10
|
+
|
|
11
|
+
It covers three kinds of search behind one DSL. Full-text uses SQLite's FTS5
|
|
12
|
+
module with BM25 relevance ranking. Vector (semantic) search uses
|
|
13
|
+
[sqlite-vec](https://github.com/asg017/sqlite-vec) through the
|
|
14
|
+
[`neighbor`](https://github.com/ankane/neighbor) gem, with your app supplying
|
|
15
|
+
embeddings via a callback. Hybrid search fuses the two with Reciprocal Rank
|
|
16
|
+
Fusion and an optional reranking step. There are no database triggers and no
|
|
17
|
+
background service to run. Works on Rails 8.0+.
|
|
18
|
+
|
|
19
|
+
## 30-second tour
|
|
20
|
+
|
|
21
|
+
Declare a search index in a migration, add one line to the model, and query it:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# db/migrate/xxxx_create_posts_search.rb
|
|
25
|
+
class CreatePostsSearch < ActiveRecord::Migration[8.0]
|
|
26
|
+
def change
|
|
27
|
+
create_fts5_index :posts, :search, against: { title: 2.0, body: 1.0 }, backfill: true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# app/models/post.rb
|
|
32
|
+
class Post < ApplicationRecord
|
|
33
|
+
include SqliteSearch::Model
|
|
34
|
+
fts5_scope :search, against: { title: 2.0, body: 1.0 }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# anywhere
|
|
38
|
+
Post.search("morning coffee") # a normal, chainable relation
|
|
39
|
+
Post.search("coffee").order_by_rank.first.search_rank
|
|
40
|
+
Post.where(author_id: 7).search("coffee") # searches only that author's posts
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The scope is an ordinary `ActiveRecord::Relation`, so it composes with the rest
|
|
44
|
+
of your query. User input is sanitized into a safe `MATCH` expression for you, so
|
|
45
|
+
you can pass `params[:q]` straight through.
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
Add the gem:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
gem "sqlite_search"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Then `bundle install`. In a Rails app the model concern and the migration
|
|
56
|
+
helpers are wired in automatically. Vector and hybrid search need two more gems;
|
|
57
|
+
see [Vector search](#vector-search).
|
|
58
|
+
|
|
59
|
+
## Full-text search
|
|
60
|
+
|
|
61
|
+
### Create the index in a migration
|
|
62
|
+
|
|
63
|
+
`create_fts5_index` creates an FTS5 virtual table for a model. It is mixed into
|
|
64
|
+
`ActiveRecord::Migration`, so it is available in any migration:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
create_fts5_index :posts, :search, against: { title: 2.0, body: 1.0 }, backfill: true
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`against:` takes a single column (`:body`), a list (`[:title, :body]`), or a hash
|
|
71
|
+
of column to BM25 weight (`{ title: 2.0, body: 1.0 }`, weighting title matches
|
|
72
|
+
higher). `tokenizer:` defaults to `"porter unicode61"`, which folds case and
|
|
73
|
+
accents and stems words, so a search for "running" also matches "run".
|
|
74
|
+
`backfill: true` seeds the index from rows that already exist; leave it off for a
|
|
75
|
+
brand-new table.
|
|
76
|
+
|
|
77
|
+
The second argument (`:search`) names the index. It becomes the FTS table name
|
|
78
|
+
(`posts_search_fts`) and the scope name on the model, so keep the two in step. A
|
|
79
|
+
`rails g sqlite_search:fts5` generator writes the migration for you:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
rails g sqlite_search:fts5 Post title body --weights 2,1
|
|
83
|
+
rails g sqlite_search:fts5 Post body --index search_body # a second index on the same model
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The virtual table is created with `create_virtual_table`, so it appears in
|
|
87
|
+
`schema.rb` and restores cleanly from `db:schema:load`. Nothing is hidden in the
|
|
88
|
+
database.
|
|
89
|
+
|
|
90
|
+
### Declare the scope
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
class Post < ApplicationRecord
|
|
94
|
+
include SqliteSearch::Model
|
|
95
|
+
fts5_scope :search, against: { title: 2.0, body: 1.0 }
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`fts5_scope` defines the `Post.search` scope and wires up `after_save` and
|
|
100
|
+
`after_destroy` callbacks that keep the index in step whenever an indexed column
|
|
101
|
+
changes. They run inside the transaction, so the index write is atomic with the
|
|
102
|
+
row: if it fails, both roll back. A model can declare more than one index
|
|
103
|
+
(`fts5_scope :search_body, against: :body`) and each gets its own scope.
|
|
104
|
+
|
|
105
|
+
### Query
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Post.search("coffee") # AND of the sanitized terms
|
|
109
|
+
Post.search("coffee").where(published: true) # composes with any AR scope
|
|
110
|
+
Post.search("cof", prefix: true) # prefix match on the last term
|
|
111
|
+
Post.search(raw: "coffee OR tea") # bypass the sanitizer, pass raw FTS5 syntax
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A blank or nil query (including a call with no positional argument, such as
|
|
115
|
+
`Post.search(prefix: true)`) returns `.none` rather than matching everything or
|
|
116
|
+
raising. Any string you pass as the positional argument goes through
|
|
117
|
+
`SqliteSearch::Query`, which keeps quoted phrases and alphanumeric terms and
|
|
118
|
+
strips FTS5 operator syntax, so untrusted input is safe. Reserve `raw:` for
|
|
119
|
+
trusted, internally built expressions.
|
|
120
|
+
|
|
121
|
+
### Ranking
|
|
122
|
+
|
|
123
|
+
By default the scope filters but does not order, so it composes with your own
|
|
124
|
+
`order`. Ask for relevance ordering explicitly:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
posts = Post.search("coffee").order_by_rank
|
|
128
|
+
posts.first.search_rank # higher is more relevant
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`order_by_rank` orders by SQLite's `bm25()`, inverted so higher means better, and
|
|
132
|
+
honors the per-column weights from `against:`. Each ranked record carries a
|
|
133
|
+
`<name>_rank` reader (`search_rank` for a scope named `:search`).
|
|
134
|
+
|
|
135
|
+
### Reindexing
|
|
136
|
+
|
|
137
|
+
Bulk writes that skip callbacks (`insert_all`, `update_all`, raw SQL, another
|
|
138
|
+
connection) leave the index stale. Rebuild it from the base table:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
Post.reindex(:search) # one index
|
|
142
|
+
Post.reindex # every fts5_scope on the model
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
or `rake sqlite_search:reindex[Post,search]` from the command line.
|
|
146
|
+
|
|
147
|
+
## Vector search
|
|
148
|
+
|
|
149
|
+
Semantic search ranks rows by embedding similarity instead of keywords. It uses
|
|
150
|
+
[sqlite-vec](https://github.com/asg017/sqlite-vec) through the
|
|
151
|
+
[`neighbor`](https://github.com/ankane/neighbor) gem. sqlite_search stores and
|
|
152
|
+
queries the vectors; your app produces them. Add both gems, since neither is a
|
|
153
|
+
dependency of sqlite_search itself:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
gem "neighbor"
|
|
157
|
+
gem "sqlite-vec"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Register an embedder
|
|
161
|
+
|
|
162
|
+
sqlite_search calls this block whenever it needs to turn text into a vector, both
|
|
163
|
+
when indexing a row and when running a query, so the same model does both sides:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
SqliteSearch.embedder do |text, model:, scope:|
|
|
167
|
+
MyEmbeddingClient.embed(text) # returns an Array<Float> of length `dimensions`
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The block receives the text (the `against:` columns joined), the model class, and
|
|
172
|
+
the scope name, so you can route to different embedding models per scope if you want.
|
|
173
|
+
|
|
174
|
+
### Create the index and declare the scope
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# migration
|
|
178
|
+
create_vec_index :posts, :semantic_search, dimensions: 768
|
|
179
|
+
|
|
180
|
+
# model
|
|
181
|
+
vec_scope :semantic_search, against: [:title, :body], dimensions: 768
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The vector table is `posts_semantic_search_vec`, a `vec0` virtual table that also
|
|
185
|
+
round-trips through `schema.rb`. A `rails g sqlite_search:vec Post --index
|
|
186
|
+
semantic --dimensions 768` generator writes the migration. `create_vec_index`
|
|
187
|
+
does not backfill (there is no text to embed at migration time), so run
|
|
188
|
+
`Post.reembed(:semantic_search)` once afterward to embed existing rows.
|
|
189
|
+
|
|
190
|
+
Both `create_vec_index` and `vec_scope` take a `distance:`: `:cosine` (the
|
|
191
|
+
default), `:euclidean` (L2), or `:taxicab` (L1). Set the same one on both.
|
|
192
|
+
|
|
193
|
+
By default a save enqueues a background `SqliteSearch::EmbedJob` to do the
|
|
194
|
+
embedding, so an expensive embedding call stays out of the request. Pass
|
|
195
|
+
`sync: :inline` to embed inside the callback instead, which you want when the
|
|
196
|
+
embedder is cheap or when your app does not use ActiveJob:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
vec_scope :semantic_search, against: [:title, :body], dimensions: 768, sync: :inline
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Route the job to a specific queue with `SqliteSearch.config.job_queue = :embeddings`.
|
|
203
|
+
|
|
204
|
+
### Query
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
Post.semantic_search("a warm drink to start the day", k: 20, threshold: 0.3)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`k:` caps how many nearest neighbors to fetch (default 20). Each returned record
|
|
211
|
+
exposes a `<name>_distance` reader (the raw distance, smaller is closer), and a
|
|
212
|
+
cosine scope also exposes `<name>_similarity` (`1 - distance`, in `[-1, 1]`, higher
|
|
213
|
+
is closer). `threshold:` filters by relevance: on a cosine scope it is a minimum
|
|
214
|
+
similarity, on a euclidean or taxicab scope it is a maximum distance. A blank or
|
|
215
|
+
nil query returns `.none`.
|
|
216
|
+
|
|
217
|
+
Re-embed after a bulk write the same way you reindex FTS5:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
Post.reembed(:semantic_search) # one named vec index
|
|
221
|
+
Post.reembed # every vec_scope on the model
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
or `rake sqlite_search:reembed[Post,semantic_search]` from the command line.
|
|
225
|
+
|
|
226
|
+
## Hybrid search
|
|
227
|
+
|
|
228
|
+
Hybrid search runs the keyword search and the vector search together, as two
|
|
229
|
+
"arms", and fuses their rankings, which catches both exact-term matches and
|
|
230
|
+
semantic ones. It reuses an `fts5_scope` and a `vec_scope` you have already
|
|
231
|
+
declared:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
class Post < ApplicationRecord
|
|
235
|
+
include SqliteSearch::Model
|
|
236
|
+
|
|
237
|
+
fts5_scope :search_body, against: :body
|
|
238
|
+
vec_scope :semantic_search, against: :body, dimensions: 768, sync: :inline
|
|
239
|
+
hybrid_scope :search, fts5: :search_body, vec: :semantic_search
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`fts5:` and `vec:` name the two arms. They must already be declared, or
|
|
244
|
+
`hybrid_scope` raises `SqliteSearch::Error` at load time (as it does if the
|
|
245
|
+
hybrid name collides with an arm's name). `k:` sets the RRF constant (default
|
|
246
|
+
60), which is a different `k:` than the neighbor count on `vec_scope`.
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
posts = Post.search("coffee", limit: 20)
|
|
250
|
+
posts.first.search_score # fused score, higher is better
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Each arm produces a ranked candidate list, Reciprocal Rank Fusion combines them,
|
|
254
|
+
an optional reranker reorders the result, and the top `limit` records come back
|
|
255
|
+
as a relation ordered to match, each carrying a `<name>_score` reader. `limit:`
|
|
256
|
+
defaults to 20. A blank query returns `.none`. Pass `rerank: false` to skip the
|
|
257
|
+
reranker and return the plain fused order.
|
|
258
|
+
|
|
259
|
+
### Reranking
|
|
260
|
+
|
|
261
|
+
Register a reranker once and every hybrid scope uses it, unless a call opts out:
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
SqliteSearch.reranker do |query, documents, model:, scope:|
|
|
265
|
+
# documents are the fused candidate records, already loaded, in RRF order.
|
|
266
|
+
# Return them reordered; a subset is fine, unknown records are ignored.
|
|
267
|
+
MyRerankClient.rerank(query, documents)
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`model:` is the model class the scope was declared on, and `scope:` is the hybrid
|
|
272
|
+
scope name (the same pair is passed to the embedder block). Reranking is
|
|
273
|
+
best-effort: if the block raises, the failure is logged and the search falls back
|
|
274
|
+
to the fused order, so a broken reranker never takes down a search.
|
|
275
|
+
|
|
276
|
+
## Filtering and multi-tenancy
|
|
277
|
+
|
|
278
|
+
Conditions you chain before the search push into the query as an exact
|
|
279
|
+
pre-filter, not a post-filter:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
Post.where(tenant_id: 5).published.search("coffee") # pre-filters both arms
|
|
283
|
+
Post.where(tenant_id: 5).semantic_search("coffee") # pre-filters the KNN scan
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The keyword arm uses the ordinary `WHERE`/`JOIN` SQL that ActiveRecord already
|
|
287
|
+
builds for the chained scope. The vector arm joins back to the source table
|
|
288
|
+
before the scan runs. That pre-filter is exact rather than approximate, because
|
|
289
|
+
`vec0`'s KNN is a brute-force scan to begin with, so folding in your conditions
|
|
290
|
+
just narrows what it scans. Concretely,
|
|
291
|
+
`Post.where(tenant_id: 5).semantic_search("coffee", k: 10)` returns the 10 nearest
|
|
292
|
+
neighbors within tenant 5, not the global top 10 trimmed to tenant 5 afterward.
|
|
293
|
+
|
|
294
|
+
Chaining a condition after the search is the escape hatch. It post-filters the
|
|
295
|
+
result set like any relation:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
Post.search("coffee").where("created_at > ?", 1.week.ago)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Only conditions that have a SQL form against the source table (or tables reached
|
|
302
|
+
through `joins`) can be pushed into the pre-filter. A condition that exists only
|
|
303
|
+
in Ruby has nothing to push, so it lands as a post-filter, which narrows an
|
|
304
|
+
already-fused set; raise `k:` or `limit:` if you need more rows to survive it.
|
|
305
|
+
|
|
306
|
+
## Limitations and notes
|
|
307
|
+
|
|
308
|
+
**ActiveRecord 8.0 or newer.** The migration helpers and their `schema.rb`
|
|
309
|
+
round-trip rely on `create_virtual_table`, which arrived in Rails 8.0. The gem
|
|
310
|
+
does not run on 7.1 or 7.2, and the gemspec enforces that.
|
|
311
|
+
|
|
312
|
+
**SQLite with FTS5.** The gem builds directly on SQLite's FTS5 module, so the
|
|
313
|
+
SQLite library your app links against must have FTS5 compiled in. The `sqlite3`
|
|
314
|
+
gem's bundled build has it, as do most modern system builds.
|
|
315
|
+
|
|
316
|
+
**Integer primary keys only.** FTS5 and `vec0` both key rows by an integer id, so
|
|
317
|
+
a model with a string or UUID primary key cannot be indexed. The sync callback
|
|
318
|
+
raises a clear `SqliteSearch::Error` in that case rather than letting a cryptic
|
|
319
|
+
`SQLite3::MismatchException` surface from deep in the driver.
|
|
320
|
+
|
|
321
|
+
**Sync runs in ActiveRecord callbacks, not triggers.** Any write that skips
|
|
322
|
+
callbacks (`insert_all`, `update_all`, raw SQL, a second connection) leaves the
|
|
323
|
+
index stale until you run `reindex` or `reembed`. That is the price of keeping
|
|
324
|
+
everything in `schema.rb` with nothing hidden in the database.
|
|
325
|
+
|
|
326
|
+
**A failed vector sync leaves the row committed; FTS5 does not.** The FTS5 index
|
|
327
|
+
syncs inside the transaction, so a failed FTS5 write (for example the integer-PK
|
|
328
|
+
guard) rolls the row back with it: index and row stay atomic. The vector index
|
|
329
|
+
syncs after commit, because it enqueues a job by default and embedding is an
|
|
330
|
+
out-of-band call you do not want holding a transaction open. So if a vector sync
|
|
331
|
+
raises, the row is already saved and only the vector write was skipped. Treat a
|
|
332
|
+
raised exception from the vector path as "saved, vectors may be stale," and
|
|
333
|
+
recover with `reembed`.
|
|
334
|
+
|
|
335
|
+
**A missing index table raises a plain SQLite error.** Query a scope before its
|
|
336
|
+
migration has run and you get `no such table: <table>_<scope>_fts`. Run the
|
|
337
|
+
migration or the generator. A friendlier message is on the list; it is awkward
|
|
338
|
+
today because ActiveRecord 8.1 hides virtual tables from `table_exists?`.
|
|
339
|
+
|
|
340
|
+
**Vector search needs `neighbor` and `sqlite-vec`.** Add both to your Gemfile
|
|
341
|
+
before declaring a `vec_scope`. `sqlite-vec` ships prebuilt native extensions and
|
|
342
|
+
has no build for musl platforms such as Alpine.
|
|
343
|
+
|
|
344
|
+
**Cosine, euclidean, and taxicab distance.** `vec_scope` and `create_vec_index`
|
|
345
|
+
take `distance: :cosine` (the default), `:euclidean` (L2), or `:taxicab` (L1), and
|
|
346
|
+
the two must agree. Inner-product distance is not offered: `neighbor` can compute
|
|
347
|
+
it, but `vec0` does not accept it as a table `distance_metric` (only cosine, l2,
|
|
348
|
+
and l1), so it would need a mismatched metric on the table.
|
|
349
|
+
|
|
350
|
+
**Embedding is async by default.** A row saved right now may not appear in
|
|
351
|
+
`.semantic_search` results until its `EmbedJob` runs. Use `sync: :inline` for immediate
|
|
352
|
+
indexing (or if you do not use ActiveJob).
|
|
353
|
+
|
|
354
|
+
**`.semantic_search` and `.search` are eager.** Unlike an ordinary scope, they run the
|
|
355
|
+
embedding call and the queries the moment you call them rather than building a
|
|
356
|
+
lazy relation. `.search` does the most work, re-running both arms, the fusion,
|
|
357
|
+
and any reranker (possibly a network call) on every invocation, so do not call
|
|
358
|
+
either one inside a loop.
|
|
359
|
+
|
|
360
|
+
**Hybrid's vector arm has no relevance threshold.** A bare `.semantic_search` call takes
|
|
361
|
+
`threshold:`, but `hybrid_scope` does not, so the vector arm always feeds its
|
|
362
|
+
nearest neighbors into the fusion even for a weak semantic match. Per-arm
|
|
363
|
+
thresholds may come later.
|
|
364
|
+
|
|
365
|
+
**The candidate pool is capped.** A hybrid scope pulls `[limit * 3, 100].min`
|
|
366
|
+
candidates from each arm before fusing, so a very large `limit:` still fuses from
|
|
367
|
+
at most 100 per arm.
|
|
368
|
+
|
|
369
|
+
## License
|
|
370
|
+
|
|
371
|
+
Released under the [MIT License](MIT-LICENSE).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module SqliteSearch
|
|
7
|
+
module Generators
|
|
8
|
+
class Fts5Generator < Rails::Generators::NamedBase
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
argument :columns, type: :array, default: [], banner: "column column"
|
|
14
|
+
class_option :weights, type: :string, default: nil, desc: "Comma-separated BM25 weights aligned to columns"
|
|
15
|
+
class_option :index, type: :string, default: nil,
|
|
16
|
+
desc: "Index/scope name; the FTS table becomes <table>_<index>_fts (default: search)"
|
|
17
|
+
|
|
18
|
+
def create_migration_file
|
|
19
|
+
if options[:weights]
|
|
20
|
+
weights = options[:weights].split(",")
|
|
21
|
+
if weights.length != columns.length
|
|
22
|
+
raise Thor::Error,
|
|
23
|
+
"--weights expects one weight per column (#{columns.length} columns, got #{weights.length})"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
migration_template "create_fts5_index.rb.tt", "db/migrate/create_#{index_name}_fts5.rb"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def table_name = name.tableize
|
|
33
|
+
|
|
34
|
+
def index_name
|
|
35
|
+
options[:index] || "search"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def migration_class_suffix = "#{index_name.camelize}Fts5"
|
|
39
|
+
|
|
40
|
+
def migration_version = "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
|
|
41
|
+
|
|
42
|
+
def against_literal
|
|
43
|
+
if options[:weights]
|
|
44
|
+
weights = options[:weights].split(",").map(&:strip)
|
|
45
|
+
pairs = columns.zip(weights).map { |c, w| "#{c}: #{w}" }.join(", ")
|
|
46
|
+
"{ #{pairs} }"
|
|
47
|
+
elsif columns.one?
|
|
48
|
+
":#{columns.first}"
|
|
49
|
+
else
|
|
50
|
+
"[#{columns.map { |c| ":#{c}" }.join(", ")}]"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module SqliteSearch
|
|
7
|
+
module Generators
|
|
8
|
+
class VecGenerator < Rails::Generators::NamedBase
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
class_option :index, type: :string, default: "semantic", desc: "Index/scope name (default: semantic)"
|
|
14
|
+
class_option :dimensions, type: :numeric, required: true, desc: "Embedding dimensions (e.g. 768)"
|
|
15
|
+
|
|
16
|
+
def create_migration_file
|
|
17
|
+
migration_template "create_vec_index.rb.tt", "db/migrate/create_#{index_name}_vec.rb"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def table_name = name.tableize
|
|
23
|
+
def index_name = options[:index]
|
|
24
|
+
def dimensions = options[:dimensions].to_i
|
|
25
|
+
def migration_class_suffix = "#{index_name.camelize}Vec"
|
|
26
|
+
def migration_version = "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqliteSearch
|
|
4
|
+
class Configuration
|
|
5
|
+
# embedder: block |text, model:, scope:| -> Array<Float>
|
|
6
|
+
# job_queue: the ActiveJob queue name EmbedJob is enqueued to (default: :default)
|
|
7
|
+
# reranker: block |query, documents, model:, scope:| -> reordered documents
|
|
8
|
+
attr_accessor :embedder, :job_queue, :reranker
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.config
|
|
12
|
+
@config ||= Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Register the app's embedder: a block |text, model:, scope:| -> Array<Float>.
|
|
16
|
+
def self.embedder(&block)
|
|
17
|
+
config.embedder = block
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Register the app's reranker: |query, documents, model:, scope:| -> reordered documents.
|
|
21
|
+
def self.reranker(&block)
|
|
22
|
+
config.reranker = block
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqliteSearch
|
|
4
|
+
# Lazily define SqliteSearch::EmbedJob the first time async vec sync is used.
|
|
5
|
+
# const_set gives the ActiveJob subclass a real name so it can be enqueued.
|
|
6
|
+
def self.ensure_embed_job!
|
|
7
|
+
return const_get(:EmbedJob) if const_defined?(:EmbedJob, false)
|
|
8
|
+
begin
|
|
9
|
+
require "active_job"
|
|
10
|
+
rescue LoadError
|
|
11
|
+
raise SqliteSearch::Error,
|
|
12
|
+
"Async vector indexing needs ActiveJob. Add `activejob` to your Gemfile, " \
|
|
13
|
+
"or declare the scope with `vec_scope ..., sync: :inline`."
|
|
14
|
+
end
|
|
15
|
+
job = Class.new(ActiveJob::Base) do
|
|
16
|
+
# Evaluated at enqueue time, so it picks up config set after the job is
|
|
17
|
+
# defined. Falls back to ActiveJob's :default queue when unconfigured.
|
|
18
|
+
queue_as { SqliteSearch.config.job_queue || :default }
|
|
19
|
+
|
|
20
|
+
def perform(model_name, id, scope_name)
|
|
21
|
+
klass = model_name.constantize
|
|
22
|
+
record = klass.find_by(klass.primary_key => id)
|
|
23
|
+
return unless record # deleted before the job ran
|
|
24
|
+
definition = klass.sqlite_search_vec_definitions.fetch(scope_name.to_sym)
|
|
25
|
+
SqliteSearch::Vec::Backend.new(definition).embed_and_store(record)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
const_set(:EmbedJob, job)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqliteSearch
|
|
4
|
+
module Fts5
|
|
5
|
+
# Writes to the FTS5 virtual table for one definition. Model-owned sync:
|
|
6
|
+
# upsert-by-rowid on save, delete on destroy, full rebuild on demand.
|
|
7
|
+
class Backend
|
|
8
|
+
def initialize(definition)
|
|
9
|
+
@definition = definition
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def sync(record)
|
|
13
|
+
record.class.with_connection do |conn|
|
|
14
|
+
id = record.public_send(record.class.primary_key)
|
|
15
|
+
unless id.is_a?(Integer)
|
|
16
|
+
raise SqliteSearch::Error,
|
|
17
|
+
"sqlite_search FTS5 indexing requires an integer primary key (used as the FTS rowid), " \
|
|
18
|
+
"but #{record.class.name}##{record.class.primary_key} is #{id.class} (#{id.inspect}). " \
|
|
19
|
+
"FTS5 does not support non-integer rowids."
|
|
20
|
+
end
|
|
21
|
+
conn.transaction do
|
|
22
|
+
delete_row(conn, id)
|
|
23
|
+
values = @definition.columns.map { |c| record.public_send(c) }
|
|
24
|
+
insert_row(conn, id, values) unless values.all? { |v| v.nil? || v.to_s.empty? }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def remove(record)
|
|
30
|
+
record.class.with_connection do |conn|
|
|
31
|
+
delete_row(conn, record.public_send(record.class.primary_key))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rebuild(model)
|
|
36
|
+
pk_type = model.columns_hash[model.primary_key.to_s]&.type
|
|
37
|
+
unless pk_type == :integer
|
|
38
|
+
raise SqliteSearch::Error,
|
|
39
|
+
"sqlite_search FTS5 indexing requires an integer primary key (used as the FTS rowid), " \
|
|
40
|
+
"but #{model.name}##{model.primary_key} is #{pk_type.inspect}. FTS5 does not support non-integer rowids."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
model.with_connection do |conn|
|
|
44
|
+
conn.execute("DELETE FROM #{quoted(conn)}")
|
|
45
|
+
col_list = @definition.columns.map { |c| conn.quote_column_name(c) }.join(", ")
|
|
46
|
+
non_blank = @definition.columns.map { |c| "COALESCE(#{conn.quote_column_name(c)}, '')" }.join(" || ")
|
|
47
|
+
conn.execute(<<~SQL.squish)
|
|
48
|
+
INSERT INTO #{quoted(conn)} (rowid, #{col_list})
|
|
49
|
+
SELECT #{conn.quote_column_name(model.primary_key)}, #{col_list}
|
|
50
|
+
FROM #{conn.quote_table_name(model.table_name)}
|
|
51
|
+
WHERE (#{non_blank}) <> ''
|
|
52
|
+
SQL
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def quoted(conn) = conn.quote_table_name(@definition.table_name)
|
|
59
|
+
|
|
60
|
+
def delete_row(conn, id)
|
|
61
|
+
conn.execute("DELETE FROM #{quoted(conn)} WHERE rowid = #{conn.quote(id)}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def insert_row(conn, id, values)
|
|
65
|
+
col_list = @definition.columns.map { |c| conn.quote_column_name(c) }.join(", ")
|
|
66
|
+
vals = ([id] + values).map { |v| conn.quote(v) }.join(", ")
|
|
67
|
+
conn.execute("INSERT INTO #{quoted(conn)} (rowid, #{col_list}) VALUES (#{vals})")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|