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 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
+ [![Gem Version](https://img.shields.io/gem/v/sqlite_search)](https://rubygems.org/gems/sqlite_search)
4
+ [![CI](https://github.com/radioactive-labs/sqlite_search/actions/workflows/ci.yml/badge.svg)](https://github.com/radioactive-labs/sqlite_search/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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,5 @@
1
+ class Create<%= migration_class_suffix %> < ActiveRecord::Migration[<%= migration_version %>]
2
+ def change
3
+ create_fts5_index :<%= table_name %>, :<%= index_name %>, against: <%= against_literal %>, backfill: true
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class Create<%= migration_class_suffix %> < ActiveRecord::Migration[<%= migration_version %>]
2
+ def change
3
+ create_vec_index :<%= table_name %>, :<%= index_name %>, dimensions: <%= dimensions %>
4
+ end
5
+ 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqliteSearch
4
+ class Error < StandardError; end
5
+ 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