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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e39fa70c6de966aae8026bb76778f58701d7e2ffaa20cf3ede7ed1b77df4488
4
- data.tar.gz: 2664f27e51d316d7d9f99c507e9213c7ee564015a3345974a7ccc880641caba5
3
+ metadata.gz: bb082a5c1485cc56178a03f0b1421f5365f3e4b0ce225179119d1c3bee298ece
4
+ data.tar.gz: ca18eb64941711935abbbf55d0e64a88cb7f87a2837b80febabdcac7cb884f9e
5
5
  SHA512:
6
- metadata.gz: 77745b678a9c28d335d4a440d3d90323bbecf775ba9fa4d747ca493f5e158eda6d4eea92117f4552c1ab47f9052de20b96ff0fc341cfb88b1bddcc12214cdacb
7
- data.tar.gz: 433e12eee264c82ed27a0b9eb5ffaa90db564fc20a67ba8d7c9a2a0c09b175be1e5f2e079ddf63ab6cff8e978f6c25a1cda46db30cd8e8342d459dd6da6c6d6c
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 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).
3
+ All notable changes to this project will be documented in this file.
6
4
 
7
- ## [Unreleased]
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
- [Unreleased]: https://github.com/radioactive-labs/sqlite_search/commits/main
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
- background service to run. Works on Rails 8.0+.
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 concern and the migration
56
- helpers are wired in automatically. Vector and hybrid search need two more gems;
57
- see [Vector search](#vector-search).
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:`. Each ranked record carries a
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 ordered to match, each carrying a `<name>_score` reader. `limit:`
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
- # 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)
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
- `model:` is the model class the scope was declared on, and `scope:` is the hybrid
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 = klass.find_by(klass.primary_key => id)
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
- 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? }
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
- def rebuild(model)
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
- 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
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, :tokenizer, :table_name
9
+ attr_reader :model, :name, :columns, :weights, :source, :table_name
10
10
 
11
- def initialize(model:, name:, against:, tokenizer:)
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
- @tokenizer = tokenizer
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(weights.map { |w| format("%g", w) }) if weights
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
@@ -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
@@ -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
- # reordered by the reranker (RRF scores preserved), or the input unchanged
18
- # on any reranker failure.
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
- reordered.filter_map do |rec|
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, scores[id]] if scores.key?(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
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
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
- backfill_fts5_index(table, fts_table, columns, primary_key) if backfill
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
- col_list = columns.map { |c| connection.quote_column_name(c) }.join(", ")
37
- non_blank = columns.map { |c| "COALESCE(#{connection.quote_column_name(c)}, '')" }.join(" || ")
38
- connection.execute(<<~SQL.squish)
39
- INSERT INTO #{connection.quote_table_name(fts_table)} (rowid, #{col_list})
40
- SELECT #{connection.quote_column_name(primary_key)}, #{col_list}
41
- FROM #{connection.quote_table_name(table)}
42
- WHERE (#{non_blank}) <> ''
43
- SQL
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
@@ -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:, tokenizer: "porter unicode61")
19
- definition = Fts5::Definition.new(model: self, name: name, against: against, tokenizer: tokenizer)
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 = connection.quote_table_name(definition.table_name)
27
- pk = "#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(primary_key)}"
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(connection)
34
- joins("JOIN #{fts} ON #{fts}.rowid = #{pk}")
35
- .where("#{fts} MATCH ?", match)
36
- .select("#{connection.quote_table_name(table_name)}.*, -#{bm25} AS #{definition.rank_column}")
37
- .order(Arel.sql(bm25))
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.column_names
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(self) }
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
- def vec_scope(name, against:, dimensions:, distance: :cosine, embedder: nil, sync: :async)
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, dimensions: dimensions, distance: distance, embedder: embedder
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 = connection.quote_table_name(table_name)
84
- vec = connection.quote_table_name(definition.table_name)
85
- pkc = connection.quote_column_name(primary_key)
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 = "#{quoted_table_name}.#{pkc}"
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 = Arel.sql(SqliteSearch::Sql.id_case(pk_sql, ids.each_with_index.to_h, connection))
108
- cols = ["#{quoted_table_name}.*", "#{SqliteSearch::Sql.id_case(pk_sql, distances, connection)} AS #{definition.distance_method}"]
109
- if definition.cosine?
110
- cols << "#{SqliteSearch::Sql.id_case(pk_sql, distances.transform_values { |d| 1.0 - d }, connection)} AS #{definition.similarity_method}"
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).order(order).select(cols.join(", "))
132
+ where(primary_key => ids).reorder(order).select(cols.join(", ")).extending(SqliteSearch::ScoredRelation)
113
133
  end
114
134
 
115
- vec_cols = definition.column_names
135
+ vec_cols = definition.watch_names
116
136
  vec_sync = sync
117
- SqliteSearch.ensure_embed_job! unless vec_sync == :inline
118
- after_save_commit do
119
- if (saved_changes.keys & vec_cols).any?
120
- backend = SqliteSearch::Vec::Backend.new(definition)
121
- if vec_sync == :inline
122
- backend.embed_and_store(self)
123
- else
124
- SqliteSearch::EmbedJob.perform_later(self.class.name, public_send(self.class.primary_key), definition.name.to_s)
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 = Arel.sql(SqliteSearch::Sql.id_case(pk_sql, ids.each_with_index.to_h, connection))
181
- score_col = "#{SqliteSearch::Sql.id_case(pk_sql, scores, connection)} AS #{score_method}"
182
- where(primary_key => ids).order(order).select("#{quoted_table_name}.*, #{score_col}")
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
@@ -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
- PHRASE = /"([^"]+)"/
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 = phrases + words
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
- def phrases
35
- @raw.scan(PHRASE).map { |(inner)| %("#{inner.strip}") }.reject { |p| p == '""' }
36
- end
37
-
38
- def words
39
- @raw.gsub(PHRASE, " ").split(TERM_CHARS).reject { |w| w.empty? || w.match?(RESERVED) }
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 = SqliteSearch::Vec.text_for(record, @definition.columns)
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
- @definition.neighbor_model.delete_all
39
- model.find_each { |record| embed_and_store(record) }
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
- def initialize(model:, name:, against:, dimensions:, distance: :cosine, embedder: nil)
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
- Class.new(ActiveRecord::Base) do
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SqliteSearch
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/sqlite_search.rb CHANGED
@@ -4,6 +4,7 @@ require "active_record"
4
4
  require "sqlite_search/version"
5
5
  require "sqlite_search/errors"
6
6
  require "sqlite_search/sql"
7
+ require "sqlite_search/scored_relation"
7
8
  require "sqlite_search/config"
8
9
  require "sqlite_search/vec"
9
10
  require "sqlite_search/vec/definition"
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.1
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-02 00:00:00.000000000 Z
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.2'
196
+ version: '3.3'
197
197
  required_rubygems_version: !ruby/object:Gem::Requirement
198
198
  requirements:
199
199
  - - ">="