sqlite_search 0.0.1 → 0.1.0
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 +4 -4
- data/CHANGELOG.md +10 -6
- data/README.md +116 -20
- data/lib/generators/sqlite_search/fts5_generator.rb +3 -4
- data/lib/generators/sqlite_search/vec_generator.rb +2 -3
- data/lib/sqlite_search/embed_job.rb +2 -1
- data/lib/sqlite_search/fts5/backend.rb +23 -12
- data/lib/sqlite_search/fts5/definition.rb +27 -4
- data/lib/sqlite_search/fts5.rb +14 -1
- data/lib/sqlite_search/hybrid.rb +14 -8
- data/lib/sqlite_search/migration.rb +11 -9
- data/lib/sqlite_search/model.rb +82 -40
- data/lib/sqlite_search/query.rb +14 -8
- data/lib/sqlite_search/railtie.rb +7 -0
- data/lib/sqlite_search/scored_relation.rb +10 -0
- data/lib/sqlite_search/vec/backend.rb +8 -4
- data/lib/sqlite_search/vec/definition.rb +23 -3
- data/lib/sqlite_search/vec.rb +2 -5
- data/lib/sqlite_search/version.rb +1 -1
- data/lib/sqlite_search.rb +1 -0
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb082a5c1485cc56178a03f0b1421f5365f3e4b0ce225179119d1c3bee298ece
|
|
4
|
+
data.tar.gz: ca18eb64941711935abbbf55d0e64a88cb7f87a2837b80febabdcac7cb884f9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba5d6fa12b18ed351e8b3d5404ca5f9b4da36ab617d4672f8b9188772c111b216f98d89be590dfe45816d7127af6875e6d53d106140d7476d931f7552ee2fb2f
|
|
7
|
+
data.tar.gz: 7cc03facced61e28ad74fd1db2973657327ce87d949999a99b6d4f985685649ed7f7a4b6d36c8f275962c282abd65b7ac88c13aefc23af9fca0782ad155b567d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
All notable changes to this project
|
|
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).
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
6
4
|
|
|
7
|
-
## [
|
|
5
|
+
## [0.1.0] - 2026-10-03
|
|
8
6
|
|
|
9
7
|
### Added
|
|
10
8
|
|
|
@@ -22,5 +20,11 @@ All notable changes to this project are documented here. The format is based on
|
|
|
22
20
|
- Exact pre-filtering by chaining: conditions placed before `.search`/`.semantic`
|
|
23
21
|
(for example `Post.where(tenant_id: 5).search("...")`) push into both arms.
|
|
24
22
|
- Rails generators for the FTS5 and vec migrations.
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
- Derived text: `source:` and `watch:` on `fts5_scope` and `vec_scope` index text
|
|
24
|
+
computed in Ruby and resync when the watched attributes change.
|
|
25
|
+
- `vec_scope ..., sync: :manual` with `record.reembed` for apps that run their own
|
|
26
|
+
embedding pipeline.
|
|
27
|
+
- Relevance thresholds: `order_by_rank(threshold:)`, and per-arm
|
|
28
|
+
`fts5_threshold:` / `vec_threshold:` on hybrid scopes.
|
|
29
|
+
- A reranker can return `[record, score]` pairs, which set each result's
|
|
30
|
+
`<name>_score` to the reranker's score.
|
data/README.md
CHANGED
|
@@ -14,7 +14,20 @@ module with BM25 relevance ranking. Vector (semantic) search uses
|
|
|
14
14
|
[`neighbor`](https://github.com/ankane/neighbor) gem, with your app supplying
|
|
15
15
|
embeddings via a callback. Hybrid search fuses the two with Reciprocal Rank
|
|
16
16
|
Fusion and an optional reranking step. There are no database triggers and no
|
|
17
|
-
|
|
17
|
+
search server to run. Works on Rails 8.0+.
|
|
18
|
+
|
|
19
|
+
## Used in production
|
|
20
|
+
|
|
21
|
+
<a href="https://universalchatbot.com">
|
|
22
|
+
<picture>
|
|
23
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/universal-chatbot-dark.svg">
|
|
24
|
+
<img src="docs/assets/universal-chatbot-light.svg" alt="Universal Chatbot" height="33">
|
|
25
|
+
</picture>
|
|
26
|
+
</a>
|
|
27
|
+
|
|
28
|
+
sqlite_search runs the knowledge base search in
|
|
29
|
+
[Universal Chatbot](https://universalchatbot.com), where it handles keyword,
|
|
30
|
+
semantic, and hybrid retrieval over customer documents on SQLite in production.
|
|
18
31
|
|
|
19
32
|
## 30-second tour
|
|
20
33
|
|
|
@@ -30,7 +43,6 @@ end
|
|
|
30
43
|
|
|
31
44
|
# app/models/post.rb
|
|
32
45
|
class Post < ApplicationRecord
|
|
33
|
-
include SqliteSearch::Model
|
|
34
46
|
fts5_scope :search, against: { title: 2.0, body: 1.0 }
|
|
35
47
|
end
|
|
36
48
|
|
|
@@ -52,9 +64,11 @@ Add the gem:
|
|
|
52
64
|
gem "sqlite_search"
|
|
53
65
|
```
|
|
54
66
|
|
|
55
|
-
Then `bundle install`. In a Rails app the model
|
|
56
|
-
helpers are
|
|
57
|
-
|
|
67
|
+
Then `bundle install`. In a Rails app the model DSL (`fts5_scope`, `vec_scope`,
|
|
68
|
+
`hybrid_scope`) and the migration helpers are available in every model and
|
|
69
|
+
migration automatically. Outside Rails, `include SqliteSearch::Model` in your
|
|
70
|
+
models and `ActiveRecord::Migration.include(SqliteSearch::Migration)`. Vector
|
|
71
|
+
and hybrid search need two more gems; see [Vector search](#vector-search).
|
|
58
72
|
|
|
59
73
|
## Full-text search
|
|
60
74
|
|
|
@@ -91,7 +105,6 @@ database.
|
|
|
91
105
|
|
|
92
106
|
```ruby
|
|
93
107
|
class Post < ApplicationRecord
|
|
94
|
-
include SqliteSearch::Model
|
|
95
108
|
fts5_scope :search, against: { title: 2.0, body: 1.0 }
|
|
96
109
|
end
|
|
97
110
|
```
|
|
@@ -102,6 +115,30 @@ changes. They run inside the transaction, so the index write is atomic with the
|
|
|
102
115
|
row: if it fails, both roll back. A model can declare more than one index
|
|
103
116
|
(`fts5_scope :search_body, against: :body`) and each gets its own scope.
|
|
104
117
|
|
|
118
|
+
### Index derived text
|
|
119
|
+
|
|
120
|
+
When the text to index is not a plain column (assembled from JSON, cleaned up,
|
|
121
|
+
or stemmed in Ruby), name a method with `source:`. It returns a hash of FTS
|
|
122
|
+
column to text, and `against:` still names those columns and their weights.
|
|
123
|
+
`watch:` lists the attributes whose change triggers a resync:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
class Article < ApplicationRecord
|
|
127
|
+
fts5_scope :keyword, against: { text: 1.0, tags: 2.0 },
|
|
128
|
+
source: :search_document, watch: [:body, :metadata]
|
|
129
|
+
|
|
130
|
+
def search_document
|
|
131
|
+
{ text: body, tags: metadata["tags"].join(" ") }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`watch:` is required with `source:` (without `source:` it defaults to the
|
|
137
|
+
`against:` columns). `reindex` rebuilds a `source:` index record by record in
|
|
138
|
+
Ruby, inside one transaction. `backfill: true` in the migration copies columns
|
|
139
|
+
straight from the table, so it does not apply here (it raises if the table lacks
|
|
140
|
+
the `against:` columns); run `reindex` after the migration instead.
|
|
141
|
+
|
|
105
142
|
### Query
|
|
106
143
|
|
|
107
144
|
```ruby
|
|
@@ -129,7 +166,10 @@ posts.first.search_rank # higher is more relevant
|
|
|
129
166
|
```
|
|
130
167
|
|
|
131
168
|
`order_by_rank` orders by SQLite's `bm25()`, inverted so higher means better, and
|
|
132
|
-
honors the per-column weights from `against:`.
|
|
169
|
+
honors the per-column weights from `against:`. It replaces any `order` chained
|
|
170
|
+
before it; chain an `order` after it to add a tiebreaker.
|
|
171
|
+
`order_by_rank(threshold: 8.0)` keeps only rows whose rank is at least the
|
|
172
|
+
threshold. Each ranked record carries a
|
|
133
173
|
`<name>_rank` reader (`search_rank` for a scope named `:search`).
|
|
134
174
|
|
|
135
175
|
### Reindexing
|
|
@@ -157,6 +197,17 @@ gem "neighbor"
|
|
|
157
197
|
gem "sqlite-vec"
|
|
158
198
|
```
|
|
159
199
|
|
|
200
|
+
Then have `neighbor` load the sqlite-vec extension on every connection:
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
rails g neighbor:sqlite
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
That writes `config/initializers/neighbor.rb`, which calls
|
|
207
|
+
`Neighbor::SQLite.initialize!`. Do this before running a `create_vec_index`
|
|
208
|
+
migration: without it, migrations and `db:schema:load` (which do not load your
|
|
209
|
+
models) fail with `no such module: vec0`.
|
|
210
|
+
|
|
160
211
|
### Register an embedder
|
|
161
212
|
|
|
162
213
|
sqlite_search calls this block whenever it needs to turn text into a vector, both
|
|
@@ -201,6 +252,24 @@ vec_scope :semantic_search, against: [:title, :body], dimensions: 768, sync: :in
|
|
|
201
252
|
|
|
202
253
|
Route the job to a specific queue with `SqliteSearch.config.job_queue = :embeddings`.
|
|
203
254
|
|
|
255
|
+
Pass `sync: :manual` when your app already runs its own embedding pipeline (to
|
|
256
|
+
track progress on the row, say). Saves then never embed, destroys still remove
|
|
257
|
+
the vector, and your code embeds a record when it is ready:
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
vec_scope :semantic_search, against: :body, dimensions: 768, sync: :manual
|
|
261
|
+
|
|
262
|
+
post.reembed(:semantic_search) # embed this record now
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
To embed text that is not a plain column, name a method with `source:` that
|
|
266
|
+
returns the text, and list the attributes it depends on in `watch:` (not needed
|
|
267
|
+
with `sync: :manual`, which never re-embeds on save):
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
vec_scope :semantic_search, source: :embedding_text, watch: [:body, :metadata], dimensions: 768
|
|
271
|
+
```
|
|
272
|
+
|
|
204
273
|
### Query
|
|
205
274
|
|
|
206
275
|
```ruby
|
|
@@ -214,6 +283,9 @@ is closer). `threshold:` filters by relevance: on a cosine scope it is a minimum
|
|
|
214
283
|
similarity, on a euclidean or taxicab scope it is a maximum distance. A blank or
|
|
215
284
|
nil query returns `.none`.
|
|
216
285
|
|
|
286
|
+
Results come back nearest first, replacing any `order` chained before the
|
|
287
|
+
search.
|
|
288
|
+
|
|
217
289
|
Re-embed after a bulk write the same way you reindex FTS5:
|
|
218
290
|
|
|
219
291
|
```ruby
|
|
@@ -222,6 +294,9 @@ Post.reembed # every vec_scope on the model
|
|
|
222
294
|
```
|
|
223
295
|
|
|
224
296
|
or `rake sqlite_search:reembed[Post,semantic_search]` from the command line.
|
|
297
|
+
Re-embedding overwrites vectors row by row, so the index stays searchable while
|
|
298
|
+
it runs and an embedder failure partway leaves the remaining rows' vectors in
|
|
299
|
+
place. It then drops vectors whose row no longer exists.
|
|
225
300
|
|
|
226
301
|
## Hybrid search
|
|
227
302
|
|
|
@@ -232,8 +307,6 @@ declared:
|
|
|
232
307
|
|
|
233
308
|
```ruby
|
|
234
309
|
class Post < ApplicationRecord
|
|
235
|
-
include SqliteSearch::Model
|
|
236
|
-
|
|
237
310
|
fts5_scope :search_body, against: :body
|
|
238
311
|
vec_scope :semantic_search, against: :body, dimensions: 768, sync: :inline
|
|
239
312
|
hybrid_scope :search, fts5: :search_body, vec: :semantic_search
|
|
@@ -247,28 +320,53 @@ hybrid name collides with an arm's name). `k:` sets the RRF constant (default
|
|
|
247
320
|
|
|
248
321
|
```ruby
|
|
249
322
|
posts = Post.search("coffee", limit: 20)
|
|
250
|
-
posts.first.search_score # fused score, higher is better
|
|
323
|
+
posts.first.search_score # fused (or reranker) score, higher is better
|
|
251
324
|
```
|
|
252
325
|
|
|
253
326
|
Each arm produces a ranked candidate list, Reciprocal Rank Fusion combines them,
|
|
254
327
|
an optional reranker reorders the result, and the top `limit` records come back
|
|
255
|
-
as a relation
|
|
328
|
+
as a relation in fused order (replacing any `order` chained before the search), each carrying a `<name>_score` reader. `limit:`
|
|
256
329
|
defaults to 20. A blank query returns `.none`. Pass `rerank: false` to skip the
|
|
257
330
|
reranker and return the plain fused order.
|
|
258
331
|
|
|
332
|
+
Each arm can drop weak matches before fusion, so a poor match from one arm is
|
|
333
|
+
not lifted into the results by the other. `fts5_threshold:` is a minimum
|
|
334
|
+
keyword rank, and `vec_threshold:` is the vector scope's `threshold:` (minimum
|
|
335
|
+
similarity on a cosine scope):
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
Post.search("coffee", fts5_threshold: 8.0, vec_threshold: 0.3)
|
|
339
|
+
```
|
|
340
|
+
|
|
259
341
|
### Reranking
|
|
260
342
|
|
|
261
343
|
Register a reranker once and every hybrid scope uses it, unless a call opts out:
|
|
262
344
|
|
|
263
345
|
```ruby
|
|
264
346
|
SqliteSearch.reranker do |query, documents, model:, scope:|
|
|
265
|
-
#
|
|
266
|
-
|
|
267
|
-
|
|
347
|
+
# One relevance score per text, from your cross-encoder or rerank API.
|
|
348
|
+
scores = MyRerankClient.score(query, documents.map(&:body))
|
|
349
|
+
documents.zip(scores).sort_by { |_doc, score| -score }
|
|
268
350
|
end
|
|
269
351
|
```
|
|
270
352
|
|
|
271
|
-
`
|
|
353
|
+
`documents` is an `Array` of loaded records of the searched model, the same
|
|
354
|
+
objects `Post.find` returns, in fused (RRF) order. It holds every fused
|
|
355
|
+
candidate, before the `limit:` cut: each arm contributes up to
|
|
356
|
+
`[limit * 3, 100].min`, so at most 200 records. You choose which attributes to
|
|
357
|
+
send to your reranker (`body` above, or the text you embed).
|
|
358
|
+
|
|
359
|
+
Return `[record, score]` pairs in the order you want, as above, and each
|
|
360
|
+
record's `<name>_score` becomes your reranker's score. You can also return just
|
|
361
|
+
the records, reordered, and `<name>_score` keeps the fused RRF score. Either way
|
|
362
|
+
the order you return is the result order. Records are matched back by primary
|
|
363
|
+
key: a candidate you leave out is dropped from the results, a record that was
|
|
364
|
+
not a candidate is ignored, a repeat of a record is ignored, and an empty array
|
|
365
|
+
gives `.none`. The top `limit` of your order comes back. Score all the records
|
|
366
|
+
or none: mixing pairs and bare records would put two score scales in one
|
|
367
|
+
column, so it counts as a reranker failure.
|
|
368
|
+
|
|
369
|
+
`model:` is the class the search was called on, and `scope:` is the hybrid
|
|
272
370
|
scope name (the same pair is passed to the embedder block). Reranking is
|
|
273
371
|
best-effort: if the block raises, the failure is logged and the search falls back
|
|
274
372
|
to the fused order, so a broken reranker never takes down a search.
|
|
@@ -305,6 +403,9 @@ already-fused set; raise `k:` or `limit:` if you need more rows to survive it.
|
|
|
305
403
|
|
|
306
404
|
## Limitations and notes
|
|
307
405
|
|
|
406
|
+
**Ruby 3.3 or newer.** Vector and hybrid search run on `neighbor` 1.x, which
|
|
407
|
+
requires Ruby 3.3.
|
|
408
|
+
|
|
308
409
|
**ActiveRecord 8.0 or newer.** The migration helpers and their `schema.rb`
|
|
309
410
|
round-trip rely on `create_virtual_table`, which arrived in Rails 8.0. The gem
|
|
310
411
|
does not run on 7.1 or 7.2, and the gemspec enforces that.
|
|
@@ -357,11 +458,6 @@ lazy relation. `.search` does the most work, re-running both arms, the fusion,
|
|
|
357
458
|
and any reranker (possibly a network call) on every invocation, so do not call
|
|
358
459
|
either one inside a loop.
|
|
359
460
|
|
|
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
461
|
**The candidate pool is capped.** A hybrid scope pulls `[limit * 3, 100].min`
|
|
366
462
|
candidates from each arm before fusing, so a very large `limit:` still fuses from
|
|
367
463
|
at most 100 per arm.
|
|
@@ -16,6 +16,7 @@ module SqliteSearch
|
|
|
16
16
|
desc: "Index/scope name; the FTS table becomes <table>_<index>_fts (default: search)"
|
|
17
17
|
|
|
18
18
|
def create_migration_file
|
|
19
|
+
raise Thor::Error, "Name at least one column to index, e.g. rails g sqlite_search:fts5 Post title body" if columns.empty?
|
|
19
20
|
if options[:weights]
|
|
20
21
|
weights = options[:weights].split(",")
|
|
21
22
|
if weights.length != columns.length
|
|
@@ -24,18 +25,16 @@ module SqliteSearch
|
|
|
24
25
|
end
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
migration_template "create_fts5_index.rb.tt", "db/migrate/create_#{index_name}_fts5.rb"
|
|
28
|
+
migration_template "create_fts5_index.rb.tt", "db/migrate/create_#{table_name}_#{index_name}_fts5.rb"
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
private
|
|
31
32
|
|
|
32
|
-
def table_name = name.tableize
|
|
33
|
-
|
|
34
33
|
def index_name
|
|
35
34
|
options[:index] || "search"
|
|
36
35
|
end
|
|
37
36
|
|
|
38
|
-
def migration_class_suffix = "#{index_name.camelize}Fts5"
|
|
37
|
+
def migration_class_suffix = "#{table_name.camelize}#{index_name.camelize}Fts5"
|
|
39
38
|
|
|
40
39
|
def migration_version = "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
|
|
41
40
|
|
|
@@ -14,15 +14,14 @@ module SqliteSearch
|
|
|
14
14
|
class_option :dimensions, type: :numeric, required: true, desc: "Embedding dimensions (e.g. 768)"
|
|
15
15
|
|
|
16
16
|
def create_migration_file
|
|
17
|
-
migration_template "create_vec_index.rb.tt", "db/migrate/create_#{index_name}_vec.rb"
|
|
17
|
+
migration_template "create_vec_index.rb.tt", "db/migrate/create_#{table_name}_#{index_name}_vec.rb"
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
|
-
def table_name = name.tableize
|
|
23
22
|
def index_name = options[:index]
|
|
24
23
|
def dimensions = options[:dimensions].to_i
|
|
25
|
-
def migration_class_suffix = "#{index_name.camelize}Vec"
|
|
24
|
+
def migration_class_suffix = "#{table_name.camelize}#{index_name.camelize}Vec"
|
|
26
25
|
def migration_version = "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
|
|
27
26
|
end
|
|
28
27
|
end
|
|
@@ -19,7 +19,8 @@ module SqliteSearch
|
|
|
19
19
|
|
|
20
20
|
def perform(model_name, id, scope_name)
|
|
21
21
|
klass = model_name.constantize
|
|
22
|
-
record
|
|
22
|
+
# unscoped: a record hidden by a default_scope still needs its vector.
|
|
23
|
+
record = klass.unscoped.find_by(klass.primary_key => id)
|
|
23
24
|
return unless record # deleted before the job ran
|
|
24
25
|
definition = klass.sqlite_search_vec_definitions.fetch(scope_name.to_sym)
|
|
25
26
|
SqliteSearch::Vec::Backend.new(definition).embed_and_store(record)
|
|
@@ -20,8 +20,7 @@ module SqliteSearch
|
|
|
20
20
|
end
|
|
21
21
|
conn.transaction do
|
|
22
22
|
delete_row(conn, id)
|
|
23
|
-
|
|
24
|
-
insert_row(conn, id, values) unless values.all? { |v| v.nil? || v.to_s.empty? }
|
|
23
|
+
insert_record(conn, id, record)
|
|
25
24
|
end
|
|
26
25
|
end
|
|
27
26
|
end
|
|
@@ -32,7 +31,11 @@ module SqliteSearch
|
|
|
32
31
|
end
|
|
33
32
|
end
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
# Rebuilds from the class that declared the scope, not the one reindex was
|
|
35
|
+
# called on: an STI hierarchy shares one FTS table, so rebuilding from a
|
|
36
|
+
# subclass must still refill its siblings' rows.
|
|
37
|
+
def rebuild
|
|
38
|
+
model = @definition.model
|
|
36
39
|
pk_type = model.columns_hash[model.primary_key.to_s]&.type
|
|
37
40
|
unless pk_type == :integer
|
|
38
41
|
raise SqliteSearch::Error,
|
|
@@ -41,15 +44,18 @@ module SqliteSearch
|
|
|
41
44
|
end
|
|
42
45
|
|
|
43
46
|
model.with_connection do |conn|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
# One transaction, so readers never see an empty index and a failed
|
|
48
|
+
# insert leaves the old index in place.
|
|
49
|
+
conn.transaction do
|
|
50
|
+
conn.execute("DELETE FROM #{quoted(conn)}")
|
|
51
|
+
if @definition.source
|
|
52
|
+
# Derived text only exists in Ruby, so rebuild record by record.
|
|
53
|
+
model.unscoped.find_each { |record| insert_record(conn, record.public_send(model.primary_key), record) }
|
|
54
|
+
else
|
|
55
|
+
conn.execute(SqliteSearch::Fts5.copy_sql(conn, fts_table: @definition.table_name, source_table: model.table_name,
|
|
56
|
+
primary_key: model.primary_key, columns: @definition.columns))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
53
59
|
end
|
|
54
60
|
end
|
|
55
61
|
|
|
@@ -61,6 +67,11 @@ module SqliteSearch
|
|
|
61
67
|
conn.execute("DELETE FROM #{quoted(conn)} WHERE rowid = #{conn.quote(id)}")
|
|
62
68
|
end
|
|
63
69
|
|
|
70
|
+
def insert_record(conn, id, record)
|
|
71
|
+
values = @definition.values_for(record)
|
|
72
|
+
insert_row(conn, id, values) unless values.all? { |v| v.nil? || v.to_s.empty? }
|
|
73
|
+
end
|
|
74
|
+
|
|
64
75
|
def insert_row(conn, id, values)
|
|
65
76
|
col_list = @definition.columns.map { |c| conn.quote_column_name(c) }.join(", ")
|
|
66
77
|
vals = ([id] + values).map { |v| conn.quote(v) }.join(", ")
|
|
@@ -6,27 +6,50 @@ module SqliteSearch
|
|
|
6
6
|
module Fts5
|
|
7
7
|
# Immutable per-scope configuration.
|
|
8
8
|
class Definition
|
|
9
|
-
attr_reader :model, :name, :columns, :weights, :
|
|
9
|
+
attr_reader :model, :name, :columns, :weights, :source, :table_name
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# against: names the FTS columns (and their weights). By default each
|
|
12
|
+
# column's text is the record attribute of the same name. source: names
|
|
13
|
+
# a method returning {column => text} instead, for text derived in Ruby.
|
|
14
|
+
# watch: lists the attributes whose change triggers a resync (default:
|
|
15
|
+
# the against: columns).
|
|
16
|
+
def initialize(model:, name:, against:, source: nil, watch: nil)
|
|
12
17
|
@model = model
|
|
13
18
|
@name = name.to_sym
|
|
14
19
|
@columns = SqliteSearch::Fts5.columns_for(against)
|
|
15
20
|
@weights = SqliteSearch::Fts5.weights_for(against)
|
|
16
|
-
@
|
|
21
|
+
@source = source
|
|
22
|
+
@watch = watch
|
|
17
23
|
@table_name = "#{model.table_name}_#{@name}_fts"
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
def column_names = columns.map(&:to_s)
|
|
27
|
+
def watch_names = (@watch || columns).map(&:to_s)
|
|
28
|
+
|
|
29
|
+
# The text to index for a record, one value per column, in column order.
|
|
30
|
+
def values_for(record)
|
|
31
|
+
return columns.map { |c| record.public_send(c) } unless source
|
|
32
|
+
document = record.public_send(source)
|
|
33
|
+
columns.map { |c| document.fetch(c) }
|
|
34
|
+
end
|
|
21
35
|
|
|
22
36
|
# SQLite bm25() returns lower (more negative) = better. We ORDER BY it
|
|
23
37
|
# ascending, and expose -bm25 as the rank so higher = better.
|
|
24
38
|
def bm25_expression(connection)
|
|
25
39
|
args = [connection.quote_table_name(table_name)]
|
|
26
|
-
args.concat(
|
|
40
|
+
args.concat(table_weights(connection).map { |w| format("%g", w) }) if weights
|
|
27
41
|
"bm25(#{args.join(", ")})"
|
|
28
42
|
end
|
|
29
43
|
|
|
44
|
+
# bm25() takes weights by the FTS table's column position, which need not
|
|
45
|
+
# match the order of the against: hash, so line them up by column name.
|
|
46
|
+
def table_weights(connection)
|
|
47
|
+
@table_weights ||= begin
|
|
48
|
+
by_column = column_names.zip(weights).to_h
|
|
49
|
+
connection.columns(table_name).map { |c| by_column.fetch(c.name, 1.0) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
30
53
|
def rank_column = "#{name}_rank"
|
|
31
54
|
end
|
|
32
55
|
end
|
data/lib/sqlite_search/fts5.rb
CHANGED
|
@@ -17,10 +17,23 @@ module SqliteSearch
|
|
|
17
17
|
against.is_a?(Hash) ? against.values.map(&:to_f) : nil
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# INSERT ... SELECT that copies source-table columns into an FTS table,
|
|
21
|
+
# keyed by primary key and skipping rows with no text at all.
|
|
22
|
+
def copy_sql(conn, fts_table:, source_table:, primary_key:, columns:)
|
|
23
|
+
col_list = columns.map { |c| conn.quote_column_name(c) }.join(", ")
|
|
24
|
+
non_blank = columns.map { |c| "COALESCE(#{conn.quote_column_name(c)}, '')" }.join(" || ")
|
|
25
|
+
<<~SQL.squish
|
|
26
|
+
INSERT INTO #{conn.quote_table_name(fts_table)} (rowid, #{col_list})
|
|
27
|
+
SELECT #{conn.quote_column_name(primary_key)}, #{col_list}
|
|
28
|
+
FROM #{conn.quote_table_name(source_table)}
|
|
29
|
+
WHERE (#{non_blank}) <> ''
|
|
30
|
+
SQL
|
|
31
|
+
end
|
|
32
|
+
|
|
20
33
|
# Extended onto the .none relation returned for blank queries so that
|
|
21
34
|
# .order_by_rank chains safely (returns the same empty relation).
|
|
22
35
|
module NullRank
|
|
23
|
-
def order_by_rank = self
|
|
36
|
+
def order_by_rank(threshold: nil) = self
|
|
24
37
|
end
|
|
25
38
|
end
|
|
26
39
|
end
|
data/lib/sqlite_search/hybrid.rb
CHANGED
|
@@ -14,8 +14,11 @@ module SqliteSearch
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# Best-effort rerank. `fused` is [[id, score], ...]; returns the same shape
|
|
17
|
-
#
|
|
18
|
-
#
|
|
17
|
+
# in the reranker's order, or the input unchanged on any reranker failure.
|
|
18
|
+
# The reranker returns records, or [record, score] pairs to replace each
|
|
19
|
+
# record's RRF score with its own. Records outside `fused` and repeats are
|
|
20
|
+
# dropped. Scoring only some records would mix two score scales in one
|
|
21
|
+
# column, so it counts as a reranker failure.
|
|
19
22
|
def rerank(query, fused, model:, scope_name:, reranker:)
|
|
20
23
|
return fused unless reranker
|
|
21
24
|
ids = fused.map(&:first)
|
|
@@ -23,15 +26,18 @@ module SqliteSearch
|
|
|
23
26
|
records = ids.filter_map { |id| by_id[id] }
|
|
24
27
|
scores = fused.to_h
|
|
25
28
|
begin
|
|
26
|
-
reordered = reranker.call(query, records, model: model, scope: scope_name)
|
|
27
|
-
|
|
29
|
+
reordered = reranker.call(query, records, model: model, scope: scope_name).filter_map do |entry|
|
|
30
|
+
rec, rerank_score = entry.is_a?(Array) ? entry : [entry, nil]
|
|
28
31
|
id = rec.public_send(model.primary_key)
|
|
29
|
-
[id,
|
|
32
|
+
[id, rerank_score] if scores.key?(id)
|
|
33
|
+
end.uniq(&:first)
|
|
34
|
+
scored = reordered.count { |(_, rerank_score)| rerank_score }
|
|
35
|
+
unless scored.zero? || scored == reordered.size
|
|
36
|
+
raise SqliteSearch::Error, "reranker scored #{scored} of #{reordered.size} records; return all records or all [record, score] pairs"
|
|
30
37
|
end
|
|
38
|
+
reordered.map { |id, rerank_score| [id, rerank_score || scores[id]] }
|
|
31
39
|
rescue => e
|
|
32
|
-
|
|
33
|
-
Rails.logger.warn { "sqlite_search: rerank failed, using fused order (#{e.class}: #{e.message})" }
|
|
34
|
-
end
|
|
40
|
+
ActiveRecord::Base.logger&.warn { "sqlite_search: rerank failed, using fused order (#{e.class}: #{e.message})" }
|
|
35
41
|
fused
|
|
36
42
|
end
|
|
37
43
|
end
|
|
@@ -16,7 +16,9 @@ module SqliteSearch
|
|
|
16
16
|
options << "tokenize = '#{tokenizer}'"
|
|
17
17
|
connection.create_virtual_table(fts_table, :fts5, options)
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
return unless backfill
|
|
20
|
+
# Seeding only applies going up; dropping the table undoes it.
|
|
21
|
+
reversible { |dir| dir.up { backfill_fts5_index(table, fts_table, columns, primary_key) } }
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
# vec0 indexes are keyed by an integer `id` column holding the source row's
|
|
@@ -33,14 +35,14 @@ module SqliteSearch
|
|
|
33
35
|
private
|
|
34
36
|
|
|
35
37
|
def backfill_fts5_index(table, fts_table, columns, primary_key)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
missing = columns.map(&:to_s) - connection.columns(table).map(&:name)
|
|
39
|
+
if missing.any?
|
|
40
|
+
raise SqliteSearch::Error,
|
|
41
|
+
"create_fts5_index backfill: copies columns straight from #{table}, which has no #{missing.join(", ")}. " \
|
|
42
|
+
"For an index built with source:, drop backfill: and run Model.reindex after migrating."
|
|
43
|
+
end
|
|
44
|
+
connection.execute(SqliteSearch::Fts5.copy_sql(connection, fts_table: fts_table, source_table: table,
|
|
45
|
+
primary_key: primary_key, columns: columns))
|
|
44
46
|
end
|
|
45
47
|
end
|
|
46
48
|
end
|
data/lib/sqlite_search/model.rb
CHANGED
|
@@ -15,33 +15,40 @@ module SqliteSearch
|
|
|
15
15
|
superclass.sqlite_search_fts5_definitions.merge(own)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def fts5_scope(name, against:,
|
|
19
|
-
|
|
18
|
+
def fts5_scope(name, against:, source: nil, watch: nil)
|
|
19
|
+
if source && watch.nil?
|
|
20
|
+
raise SqliteSearch::Error, "fts5_scope :#{name} uses source:, so it needs watch: (the attributes that change the text)."
|
|
21
|
+
end
|
|
22
|
+
definition = Fts5::Definition.new(model: self, name: name, against: against, source: source, watch: watch)
|
|
20
23
|
(@sqlite_search_fts5_definitions ||= {})[definition.name] = definition
|
|
21
24
|
|
|
22
25
|
scope name, ->(query = nil, prefix: false, raw: nil) do
|
|
23
26
|
match = raw || SqliteSearch::Query.build(query, prefix: prefix)
|
|
24
27
|
next none.extending(SqliteSearch::Fts5::NullRank) if match.nil? || match.to_s.empty?
|
|
25
28
|
|
|
26
|
-
fts =
|
|
27
|
-
|
|
29
|
+
fts, pk = with_connection do |conn|
|
|
30
|
+
[conn.quote_table_name(definition.table_name), "#{quoted_table_name}.#{conn.quote_column_name(primary_key)}"]
|
|
31
|
+
end
|
|
28
32
|
|
|
29
33
|
# bm25() is only valid in a query that MATCHes the fts table, so
|
|
30
|
-
# order_by_rank joins the fts table and re-applies MATCH here.
|
|
34
|
+
# order_by_rank joins the fts table and re-applies MATCH here. It
|
|
35
|
+
# reorders: relevance replaces any order chained before the search.
|
|
31
36
|
rank_module = Module.new do
|
|
32
|
-
define_method(:order_by_rank) do
|
|
33
|
-
bm25 = definition.bm25_expression(
|
|
34
|
-
joins("JOIN #{fts} ON #{fts}.rowid = #{pk}")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
.
|
|
37
|
+
define_method(:order_by_rank) do |threshold: nil|
|
|
38
|
+
bm25 = with_connection { |conn| definition.bm25_expression(conn) }
|
|
39
|
+
ranked = joins("JOIN #{fts} ON #{fts}.rowid = #{pk}").where("#{fts} MATCH ?", match)
|
|
40
|
+
ranked = ranked.where("-#{bm25} >= ?", threshold) if threshold
|
|
41
|
+
ranked
|
|
42
|
+
.select("#{quoted_table_name}.*, -#{bm25} AS #{definition.rank_column}")
|
|
43
|
+
.reorder(Arel.sql(bm25))
|
|
44
|
+
.extending(SqliteSearch::ScoredRelation)
|
|
38
45
|
end
|
|
39
46
|
end
|
|
40
47
|
|
|
41
48
|
where("#{pk} IN (SELECT rowid FROM #{fts} WHERE #{fts} MATCH ?)", match).extending(rank_module)
|
|
42
49
|
end
|
|
43
50
|
|
|
44
|
-
cols = definition.
|
|
51
|
+
cols = definition.watch_names
|
|
45
52
|
# FTS5 sync runs inside the transaction (after_save/after_destroy), not
|
|
46
53
|
# after_commit: an FTS5 write is a cheap local write, so keeping it in the
|
|
47
54
|
# transaction makes the index atomic with the row. A failed write rolls
|
|
@@ -58,7 +65,7 @@ module SqliteSearch
|
|
|
58
65
|
|
|
59
66
|
def reindex(name = nil)
|
|
60
67
|
definitions = name ? [sqlite_search_fts5_definitions.fetch(name.to_sym)] : sqlite_search_fts5_definitions.values
|
|
61
|
-
definitions.each { |definition| SqliteSearch::Fts5::Backend.new(definition).rebuild
|
|
68
|
+
definitions.each { |definition| SqliteSearch::Fts5::Backend.new(definition).rebuild }
|
|
62
69
|
end
|
|
63
70
|
|
|
64
71
|
def sqlite_search_vec_definitions
|
|
@@ -67,10 +74,19 @@ module SqliteSearch
|
|
|
67
74
|
superclass.sqlite_search_vec_definitions.merge(own)
|
|
68
75
|
end
|
|
69
76
|
|
|
70
|
-
|
|
77
|
+
# sync: :async (enqueue EmbedJob on save), :inline (embed in the save
|
|
78
|
+
# callback), or :manual (never on save; call record.reembed yourself).
|
|
79
|
+
def vec_scope(name, dimensions:, against: nil, source: nil, watch: nil, distance: :cosine, embedder: nil, sync: :async)
|
|
80
|
+
unless SqliteSearch::Vec::SYNC_MODES.include?(sync)
|
|
81
|
+
raise SqliteSearch::Error, "Unsupported sync #{sync.inspect}. Use one of: #{SqliteSearch::Vec::SYNC_MODES.join(", ")}."
|
|
82
|
+
end
|
|
83
|
+
if source && watch.nil? && sync != :manual
|
|
84
|
+
raise SqliteSearch::Error, "vec_scope :#{name} uses source:, so it needs watch: (the attributes that change the text)."
|
|
85
|
+
end
|
|
71
86
|
SqliteSearch::Vec.load!
|
|
72
87
|
definition = SqliteSearch::Vec::Definition.new(
|
|
73
|
-
model: self, name: name, against: against,
|
|
88
|
+
model: self, name: name, against: against, source: source, watch: watch,
|
|
89
|
+
dimensions: dimensions, distance: distance, embedder: embedder
|
|
74
90
|
)
|
|
75
91
|
(@sqlite_search_vec_definitions ||= {})[definition.name] = definition
|
|
76
92
|
|
|
@@ -80,9 +96,10 @@ module SqliteSearch
|
|
|
80
96
|
vector = definition.embed(query.to_s, record_model: klass)
|
|
81
97
|
|
|
82
98
|
caller_conditions = all.only(:where, :joins)
|
|
83
|
-
src =
|
|
84
|
-
vec =
|
|
85
|
-
|
|
99
|
+
src = quoted_table_name
|
|
100
|
+
vec, pkc = with_connection do |conn|
|
|
101
|
+
[conn.quote_table_name(definition.table_name), conn.quote_column_name(primary_key)]
|
|
102
|
+
end
|
|
86
103
|
|
|
87
104
|
knn = definition.neighbor_model
|
|
88
105
|
.joins("JOIN #{src} ON #{src}.#{pkc} = #{vec}.id")
|
|
@@ -101,27 +118,38 @@ module SqliteSearch
|
|
|
101
118
|
|
|
102
119
|
ids = hits.map(&:first)
|
|
103
120
|
distances = hits.to_h
|
|
104
|
-
pk_sql = "#{
|
|
121
|
+
pk_sql = "#{src}.#{pkc}"
|
|
105
122
|
# Order and per-row scores are computed in Ruby (from the KNN), so carry
|
|
106
123
|
# them as selected CASE columns: <name>_distance is then a real attribute.
|
|
107
|
-
order
|
|
108
|
-
cols =
|
|
109
|
-
|
|
110
|
-
|
|
124
|
+
# Nearness replaces any order chained before the search.
|
|
125
|
+
order, cols = with_connection do |conn|
|
|
126
|
+
cols = ["#{src}.*", "#{SqliteSearch::Sql.id_case(pk_sql, distances, conn)} AS #{definition.distance_method}"]
|
|
127
|
+
if definition.cosine?
|
|
128
|
+
cols << "#{SqliteSearch::Sql.id_case(pk_sql, distances.transform_values { |d| 1.0 - d }, conn)} AS #{definition.similarity_method}"
|
|
129
|
+
end
|
|
130
|
+
[Arel.sql(SqliteSearch::Sql.id_case(pk_sql, ids.each_with_index.to_h, conn)), cols]
|
|
111
131
|
end
|
|
112
|
-
where(primary_key => ids).
|
|
132
|
+
where(primary_key => ids).reorder(order).select(cols.join(", ")).extending(SqliteSearch::ScoredRelation)
|
|
113
133
|
end
|
|
114
134
|
|
|
115
|
-
vec_cols = definition.
|
|
135
|
+
vec_cols = definition.watch_names
|
|
116
136
|
vec_sync = sync
|
|
117
|
-
SqliteSearch.ensure_embed_job!
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
137
|
+
SqliteSearch.ensure_embed_job! if vec_sync == :async
|
|
138
|
+
unless vec_sync == :manual
|
|
139
|
+
# saved_changes in an after_commit callback only reflects the last save
|
|
140
|
+
# of the transaction, so note a watched change on each save and act on
|
|
141
|
+
# it at commit.
|
|
142
|
+
after_save do
|
|
143
|
+
(@sqlite_search_pending_embeds ||= Set.new) << definition.name if (saved_changes.keys & vec_cols).any?
|
|
144
|
+
end
|
|
145
|
+
after_rollback { @sqlite_search_pending_embeds = nil }
|
|
146
|
+
after_save_commit do
|
|
147
|
+
if @sqlite_search_pending_embeds&.delete?(definition.name)
|
|
148
|
+
if vec_sync == :inline
|
|
149
|
+
SqliteSearch::Vec::Backend.new(definition).embed_and_store(self)
|
|
150
|
+
else
|
|
151
|
+
SqliteSearch::EmbedJob.perform_later(self.class.name, public_send(self.class.primary_key), definition.name.to_s)
|
|
152
|
+
end
|
|
125
153
|
end
|
|
126
154
|
end
|
|
127
155
|
end
|
|
@@ -157,13 +185,13 @@ module SqliteSearch
|
|
|
157
185
|
rrf_k = k
|
|
158
186
|
score_method = "#{name}_score"
|
|
159
187
|
|
|
160
|
-
scope name, ->(query = nil, limit: 20, rerank: true) do
|
|
188
|
+
scope name, ->(query = nil, limit: 20, rerank: true, fts5_threshold: nil, vec_threshold: nil) do
|
|
161
189
|
next none if query.nil? || query.to_s.strip.empty?
|
|
162
190
|
# candidate pool per arm before fusion; capped so a large limit: can't over-fetch
|
|
163
191
|
pool = [limit * 3, 100].min
|
|
164
192
|
|
|
165
|
-
fts_ids = all.public_send(fts5_name, query).order_by_rank.limit(pool).pluck(primary_key)
|
|
166
|
-
vec_ids = all.public_send(vec_name, query, k: pool).pluck(primary_key)
|
|
193
|
+
fts_ids = all.public_send(fts5_name, query).order_by_rank(threshold: fts5_threshold).limit(pool).pluck(primary_key)
|
|
194
|
+
vec_ids = all.public_send(vec_name, query, k: pool, threshold: vec_threshold).pluck(primary_key)
|
|
167
195
|
|
|
168
196
|
fused = SqliteSearch::Hybrid.rrf(fts_ids, vec_ids, k: rrf_k)
|
|
169
197
|
next none if fused.empty?
|
|
@@ -174,14 +202,28 @@ module SqliteSearch
|
|
|
174
202
|
fused = fused.first(limit)
|
|
175
203
|
ids = fused.map(&:first)
|
|
176
204
|
scores = fused.to_h
|
|
177
|
-
pk_sql = "#{quoted_table_name}.#{connection.quote_column_name(primary_key)}"
|
|
178
205
|
# Fused rank and score come from Ruby, so carry them as SQL: the score
|
|
179
206
|
# becomes a real <name>_score attribute (works with pluck, first, etc.).
|
|
180
|
-
order
|
|
181
|
-
score_col =
|
|
182
|
-
|
|
207
|
+
# The fused order replaces any order chained before the search.
|
|
208
|
+
order, score_col = with_connection do |conn|
|
|
209
|
+
pk_sql = "#{quoted_table_name}.#{conn.quote_column_name(primary_key)}"
|
|
210
|
+
[
|
|
211
|
+
Arel.sql(SqliteSearch::Sql.id_case(pk_sql, ids.each_with_index.to_h, conn)),
|
|
212
|
+
"#{SqliteSearch::Sql.id_case(pk_sql, scores, conn)} AS #{score_method}"
|
|
213
|
+
]
|
|
214
|
+
end
|
|
215
|
+
where(primary_key => ids).reorder(order).select("#{quoted_table_name}.*, #{score_col}")
|
|
216
|
+
.extending(SqliteSearch::ScoredRelation)
|
|
183
217
|
end
|
|
184
218
|
end
|
|
185
219
|
end
|
|
220
|
+
|
|
221
|
+
# Embed this record into one vec index (or every one) now. This is how a
|
|
222
|
+
# sync: :manual scope is kept current, typically from the app's own job.
|
|
223
|
+
def reembed(name = nil)
|
|
224
|
+
definitions = self.class.sqlite_search_vec_definitions
|
|
225
|
+
selected = name ? [definitions.fetch(name.to_sym)] : definitions.values
|
|
226
|
+
selected.each { |definition| SqliteSearch::Vec::Backend.new(definition).embed_and_store(self) }
|
|
227
|
+
end
|
|
186
228
|
end
|
|
187
229
|
end
|
data/lib/sqlite_search/query.rb
CHANGED
|
@@ -8,7 +8,9 @@ module SqliteSearch
|
|
|
8
8
|
# operators, punctuation) is dropped, so untrusted input cannot inject
|
|
9
9
|
# MATCH syntax. Terms are AND-joined. Returns nil when nothing usable.
|
|
10
10
|
class Query
|
|
11
|
-
|
|
11
|
+
# A quoted phrase, or a run of text between quotes. A quote with no partner
|
|
12
|
+
# matches neither and is skipped.
|
|
13
|
+
SEGMENT = /"([^"]*)"|([^"]+)/
|
|
12
14
|
TERM_CHARS = /[^[:alnum:]_]+/
|
|
13
15
|
RESERVED = /\A(?:AND|OR|NOT|NEAR)\z/
|
|
14
16
|
|
|
@@ -22,7 +24,7 @@ module SqliteSearch
|
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
def to_match
|
|
25
|
-
tokens =
|
|
27
|
+
tokens = tokenize
|
|
26
28
|
return nil if tokens.empty?
|
|
27
29
|
|
|
28
30
|
tokens[-1] = "#{tokens[-1]}*" if @prefix && !quoted?(tokens[-1])
|
|
@@ -31,12 +33,16 @@ module SqliteSearch
|
|
|
31
33
|
|
|
32
34
|
private
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
# Phrases and words in the order they were typed, so prefix: widens the
|
|
37
|
+
# last thing typed: never a finished word that came before a phrase.
|
|
38
|
+
def tokenize
|
|
39
|
+
@raw.scan(SEGMENT).flat_map do |phrase, text|
|
|
40
|
+
if phrase
|
|
41
|
+
phrase.strip.empty? ? [] : [%("#{phrase.strip}")]
|
|
42
|
+
else
|
|
43
|
+
text.split(TERM_CHARS).reject { |w| w.empty? || w.match?(RESERVED) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
def quoted?(token)
|
|
@@ -15,6 +15,13 @@ module SqliteSearch
|
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# Define EmbedJob as soon as ActiveJob loads, not when the first model with
|
|
19
|
+
# a vec_scope loads: a worker process deserializes the job by class name,
|
|
20
|
+
# possibly before any model has been loaded.
|
|
21
|
+
initializer "sqlite_search.active_job" do
|
|
22
|
+
ActiveSupport.on_load(:active_job) { SqliteSearch.ensure_embed_job! }
|
|
23
|
+
end
|
|
24
|
+
|
|
18
25
|
rake_tasks do
|
|
19
26
|
load File.expand_path("../tasks/sqlite_search.rake", __dir__)
|
|
20
27
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqliteSearch
|
|
4
|
+
# Extended onto relations that select a computed column (rank, distance,
|
|
5
|
+
# score) alongside table.*. A bare #count would wrap that whole select list
|
|
6
|
+
# in COUNT(...), which is invalid SQL, so count rows instead.
|
|
7
|
+
module ScoredRelation
|
|
8
|
+
def count(column_name = (:all unless block_given?), &) = super
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -16,8 +16,8 @@ module SqliteSearch
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
model = @definition.neighbor_model
|
|
19
|
-
text =
|
|
20
|
-
if text.empty?
|
|
19
|
+
text = @definition.text_for(record)
|
|
20
|
+
if text.strip.empty?
|
|
21
21
|
model.where(id: id).delete_all
|
|
22
22
|
return
|
|
23
23
|
end
|
|
@@ -34,9 +34,13 @@ module SqliteSearch
|
|
|
34
34
|
@definition.neighbor_model.where(id: id).delete_all
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Re-embeds in place, row by row, so the index stays searchable throughout
|
|
38
|
+
# and a failed embed leaves the remaining rows' vectors untouched. Then
|
|
39
|
+
# drops vectors whose source row is gone. Default scopes are ignored, as
|
|
40
|
+
# the FTS5 rebuild ignores them.
|
|
37
41
|
def reembed(model)
|
|
38
|
-
|
|
39
|
-
model.
|
|
42
|
+
model.unscoped.find_each { |record| embed_and_store(record) }
|
|
43
|
+
@definition.neighbor_model.where.not(id: model.base_class.unscoped.select(model.primary_key)).delete_all
|
|
40
44
|
end
|
|
41
45
|
end
|
|
42
46
|
end
|
|
@@ -5,12 +5,20 @@ require "sqlite_search/vec"
|
|
|
5
5
|
module SqliteSearch
|
|
6
6
|
module Vec
|
|
7
7
|
class Definition
|
|
8
|
-
attr_reader :model, :name, :columns, :dimensions, :distance, :table_name
|
|
8
|
+
attr_reader :model, :name, :columns, :source, :dimensions, :distance, :table_name
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# Text to embed comes from either against: (attributes joined by
|
|
11
|
+
# newlines) or source: (a method returning the text). watch: lists the
|
|
12
|
+
# attributes whose change triggers a re-embed; it defaults to against:.
|
|
13
|
+
def initialize(model:, name:, dimensions:, against: nil, source: nil, watch: nil, distance: :cosine, embedder: nil)
|
|
14
|
+
if against.nil? == source.nil?
|
|
15
|
+
raise SqliteSearch::Error, "vec_scope :#{name} needs exactly one of against: or source:."
|
|
16
|
+
end
|
|
11
17
|
@model = model
|
|
12
18
|
@name = name.to_sym
|
|
13
19
|
@columns = SqliteSearch::Vec.columns_for(against)
|
|
20
|
+
@source = source
|
|
21
|
+
@watch = watch
|
|
14
22
|
@dimensions = dimensions
|
|
15
23
|
@distance = distance.to_sym
|
|
16
24
|
@embedder = embedder
|
|
@@ -20,6 +28,15 @@ module SqliteSearch
|
|
|
20
28
|
end
|
|
21
29
|
|
|
22
30
|
def column_names = columns.map(&:to_s)
|
|
31
|
+
def watch_names = (@watch || columns).map(&:to_s)
|
|
32
|
+
|
|
33
|
+
# Text handed to the embedder: the source text, or the against
|
|
34
|
+
# attributes joined with blanks dropped.
|
|
35
|
+
def text_for(record)
|
|
36
|
+
return record.public_send(source).to_s if source
|
|
37
|
+
columns.map { |c| record.public_send(c) }.reject { |v| v.nil? || v.to_s.strip.empty? }.join("\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
23
40
|
def cosine? = distance == :cosine
|
|
24
41
|
def distance_method = "#{name}_distance"
|
|
25
42
|
def similarity_method = "#{name}_similarity"
|
|
@@ -38,7 +55,10 @@ module SqliteSearch
|
|
|
38
55
|
@neighbor_model ||= begin
|
|
39
56
|
tbl = table_name
|
|
40
57
|
dims = dimensions
|
|
41
|
-
|
|
58
|
+
# Subclass the model's abstract parent (ApplicationRecord, or the
|
|
59
|
+
# abstract class of a secondary database) so the vec table is read
|
|
60
|
+
# and written on the model's own database.
|
|
61
|
+
Class.new(model.base_class.superclass) do
|
|
42
62
|
self.table_name = tbl
|
|
43
63
|
self.primary_key = "id"
|
|
44
64
|
has_neighbors :embedding, dimensions: dims
|
data/lib/sqlite_search/vec.rb
CHANGED
|
@@ -8,6 +8,7 @@ module SqliteSearch
|
|
|
8
8
|
# is kept in step to keep the schema honest. inner_product is queryable by
|
|
9
9
|
# neighbor but is not a valid vec0 distance_metric, so it is not offered.
|
|
10
10
|
DISTANCE_METRICS = {cosine: "cosine", euclidean: "l2", taxicab: "l1"}.freeze
|
|
11
|
+
SYNC_MODES = %i[async inline manual].freeze
|
|
11
12
|
|
|
12
13
|
@loaded = false
|
|
13
14
|
|
|
@@ -38,14 +39,10 @@ module SqliteSearch
|
|
|
38
39
|
|
|
39
40
|
def columns_for(against)
|
|
40
41
|
case against
|
|
42
|
+
when nil then []
|
|
41
43
|
when Array then against.map(&:to_sym)
|
|
42
44
|
else [against.to_sym]
|
|
43
45
|
end
|
|
44
46
|
end
|
|
45
|
-
|
|
46
|
-
# Text handed to the embedder: the against columns joined, blanks dropped.
|
|
47
|
-
def text_for(record, columns)
|
|
48
|
-
columns.map { |c| record.public_send(c) }.reject { |v| v.nil? || v.to_s.strip.empty? }.join("\n")
|
|
49
|
-
end
|
|
50
47
|
end
|
|
51
48
|
end
|
data/lib/sqlite_search.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sqlite_search
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Radioactive Labs
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -168,6 +168,7 @@ files:
|
|
|
168
168
|
- lib/sqlite_search/model.rb
|
|
169
169
|
- lib/sqlite_search/query.rb
|
|
170
170
|
- lib/sqlite_search/railtie.rb
|
|
171
|
+
- lib/sqlite_search/scored_relation.rb
|
|
171
172
|
- lib/sqlite_search/sql.rb
|
|
172
173
|
- lib/sqlite_search/vec.rb
|
|
173
174
|
- lib/sqlite_search/vec/backend.rb
|
|
@@ -179,7 +180,6 @@ licenses:
|
|
|
179
180
|
- MIT
|
|
180
181
|
metadata:
|
|
181
182
|
allowed_push_host: https://rubygems.org
|
|
182
|
-
homepage_uri: https://github.com/radioactive-labs/sqlite_search
|
|
183
183
|
source_code_uri: https://github.com/radioactive-labs/sqlite_search
|
|
184
184
|
changelog_uri: https://github.com/radioactive-labs/sqlite_search/blob/main/CHANGELOG.md
|
|
185
185
|
bug_tracker_uri: https://github.com/radioactive-labs/sqlite_search/issues
|
|
@@ -193,7 +193,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
193
193
|
requirements:
|
|
194
194
|
- - ">="
|
|
195
195
|
- !ruby/object:Gem::Version
|
|
196
|
-
version: '3.
|
|
196
|
+
version: '3.3'
|
|
197
197
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
198
198
|
requirements:
|
|
199
199
|
- - ">="
|