sqlite_search 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite_search/fts5"
4
+
5
+ module SqliteSearch
6
+ module Fts5
7
+ # Immutable per-scope configuration.
8
+ class Definition
9
+ attr_reader :model, :name, :columns, :weights, :tokenizer, :table_name
10
+
11
+ def initialize(model:, name:, against:, tokenizer:)
12
+ @model = model
13
+ @name = name.to_sym
14
+ @columns = SqliteSearch::Fts5.columns_for(against)
15
+ @weights = SqliteSearch::Fts5.weights_for(against)
16
+ @tokenizer = tokenizer
17
+ @table_name = "#{model.table_name}_#{@name}_fts"
18
+ end
19
+
20
+ def column_names = columns.map(&:to_s)
21
+
22
+ # SQLite bm25() returns lower (more negative) = better. We ORDER BY it
23
+ # ascending, and expose -bm25 as the rank so higher = better.
24
+ def bm25_expression(connection)
25
+ args = [connection.quote_table_name(table_name)]
26
+ args.concat(weights.map { |w| format("%g", w) }) if weights
27
+ "bm25(#{args.join(", ")})"
28
+ end
29
+
30
+ def rank_column = "#{name}_rank"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqliteSearch
4
+ module Fts5
5
+ module_function
6
+
7
+ # against: :body | [:title, :body] | { title: 2.0, body: 1.0 }
8
+ def columns_for(against)
9
+ case against
10
+ when Hash then against.keys.map(&:to_sym)
11
+ when Array then against.map(&:to_sym)
12
+ else [against.to_sym]
13
+ end
14
+ end
15
+
16
+ def weights_for(against)
17
+ against.is_a?(Hash) ? against.values.map(&:to_f) : nil
18
+ end
19
+
20
+ # Extended onto the .none relation returned for blank queries so that
21
+ # .order_by_rank chains safely (returns the same empty relation).
22
+ module NullRank
23
+ def order_by_rank = self
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqliteSearch
4
+ module Hybrid
5
+ module_function
6
+
7
+ # Reciprocal Rank Fusion of two id lists (each already in rank order).
8
+ # Returns [[id, score], ...] sorted by fused score desc.
9
+ def rrf(ids_a, ids_b, k: 60)
10
+ require "neighbor"
11
+ a = ids_a.map { |id| {id: id} }
12
+ b = ids_b.map { |id| {id: id} }
13
+ Neighbor::Reranking.rrf(a, b, k: k).map { |row| [row[:result][:id], row[:score]] }
14
+ end
15
+
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.
19
+ def rerank(query, fused, model:, scope_name:, reranker:)
20
+ return fused unless reranker
21
+ ids = fused.map(&:first)
22
+ by_id = model.where(model.primary_key => ids).index_by { |r| r.public_send(model.primary_key) }
23
+ records = ids.filter_map { |id| by_id[id] }
24
+ scores = fused.to_h
25
+ begin
26
+ reordered = reranker.call(query, records, model: model, scope: scope_name)
27
+ reordered.filter_map do |rec|
28
+ id = rec.public_send(model.primary_key)
29
+ [id, scores[id]] if scores.key?(id)
30
+ end
31
+ 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
35
+ fused
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite_search/fts5"
4
+ require "sqlite_search/vec"
5
+
6
+ module SqliteSearch
7
+ # Mixed into ActiveRecord::Migration. Wraps create_virtual_table so the FTS5
8
+ # table is representable in schema.rb (unlike triggers), and optionally seeds
9
+ # it from the source table.
10
+ module Migration
11
+ def create_fts5_index(table, name, against:, tokenizer: "porter unicode61", primary_key: "id", backfill: false)
12
+ columns = SqliteSearch::Fts5.columns_for(against)
13
+ fts_table = "#{table}_#{name}_fts"
14
+
15
+ options = columns.map(&:to_s)
16
+ options << "tokenize = '#{tokenizer}'"
17
+ connection.create_virtual_table(fts_table, :fts5, options)
18
+
19
+ backfill_fts5_index(table, fts_table, columns, primary_key) if backfill
20
+ end
21
+
22
+ # vec0 indexes are keyed by an integer `id` column holding the source row's
23
+ # primary-key value. distance: sets the vec0 distance_metric (:cosine,
24
+ # :euclidean, or :taxicab) and must match the vec_scope's distance.
25
+ def create_vec_index(table, name, dimensions:, distance: :cosine)
26
+ vec_table = "#{table}_#{name}_vec"
27
+ connection.create_virtual_table(vec_table, :vec0, [
28
+ "id integer primary key",
29
+ "embedding float[#{dimensions}] distance_metric=#{SqliteSearch::Vec.vec0_metric(distance)}"
30
+ ])
31
+ end
32
+
33
+ private
34
+
35
+ 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
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "sqlite_search/fts5/definition"
5
+ require "sqlite_search/vec/definition"
6
+
7
+ module SqliteSearch
8
+ module Model
9
+ extend ActiveSupport::Concern
10
+
11
+ class_methods do
12
+ def sqlite_search_fts5_definitions
13
+ own = (@sqlite_search_fts5_definitions ||= {})
14
+ return own unless superclass.respond_to?(:sqlite_search_fts5_definitions)
15
+ superclass.sqlite_search_fts5_definitions.merge(own)
16
+ end
17
+
18
+ def fts5_scope(name, against:, tokenizer: "porter unicode61")
19
+ definition = Fts5::Definition.new(model: self, name: name, against: against, tokenizer: tokenizer)
20
+ (@sqlite_search_fts5_definitions ||= {})[definition.name] = definition
21
+
22
+ scope name, ->(query = nil, prefix: false, raw: nil) do
23
+ match = raw || SqliteSearch::Query.build(query, prefix: prefix)
24
+ next none.extending(SqliteSearch::Fts5::NullRank) if match.nil? || match.to_s.empty?
25
+
26
+ fts = connection.quote_table_name(definition.table_name)
27
+ pk = "#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(primary_key)}"
28
+
29
+ # 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.
31
+ 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))
38
+ end
39
+ end
40
+
41
+ where("#{pk} IN (SELECT rowid FROM #{fts} WHERE #{fts} MATCH ?)", match).extending(rank_module)
42
+ end
43
+
44
+ cols = definition.column_names
45
+ # FTS5 sync runs inside the transaction (after_save/after_destroy), not
46
+ # after_commit: an FTS5 write is a cheap local write, so keeping it in the
47
+ # transaction makes the index atomic with the row. A failed write rolls
48
+ # both back rather than leaving a committed row with a stale index.
49
+ after_save do
50
+ if (saved_changes.keys & cols).any?
51
+ SqliteSearch::Fts5::Backend.new(definition).sync(self)
52
+ end
53
+ end
54
+ after_destroy do
55
+ SqliteSearch::Fts5::Backend.new(definition).remove(self)
56
+ end
57
+ end
58
+
59
+ def reindex(name = nil)
60
+ 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) }
62
+ end
63
+
64
+ def sqlite_search_vec_definitions
65
+ own = (@sqlite_search_vec_definitions ||= {})
66
+ return own unless superclass.respond_to?(:sqlite_search_vec_definitions)
67
+ superclass.sqlite_search_vec_definitions.merge(own)
68
+ end
69
+
70
+ def vec_scope(name, against:, dimensions:, distance: :cosine, embedder: nil, sync: :async)
71
+ SqliteSearch::Vec.load!
72
+ definition = SqliteSearch::Vec::Definition.new(
73
+ model: self, name: name, against: against, dimensions: dimensions, distance: distance, embedder: embedder
74
+ )
75
+ (@sqlite_search_vec_definitions ||= {})[definition.name] = definition
76
+
77
+ scope name, ->(query = nil, k: 20, threshold: nil) do
78
+ next none if query.nil? || query.to_s.strip.empty?
79
+
80
+ vector = definition.embed(query.to_s, record_model: klass)
81
+
82
+ 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)
86
+
87
+ knn = definition.neighbor_model
88
+ .joins("JOIN #{src} ON #{src}.#{pkc} = #{vec}.id")
89
+ .merge(caller_conditions)
90
+ .nearest_neighbors(:embedding, vector, distance: definition.distance)
91
+ .limit(k)
92
+ hits = knn.map { |r| [r.id, r.neighbor_distance] }
93
+ if threshold
94
+ hits = if definition.cosine?
95
+ hits.select { |(_, dist)| (1.0 - dist) >= threshold } # threshold = minimum cosine similarity
96
+ else
97
+ hits.select { |(_, dist)| dist <= threshold } # threshold = maximum distance
98
+ end
99
+ end
100
+ next none if hits.empty?
101
+
102
+ ids = hits.map(&:first)
103
+ distances = hits.to_h
104
+ pk_sql = "#{quoted_table_name}.#{pkc}"
105
+ # Order and per-row scores are computed in Ruby (from the KNN), so carry
106
+ # 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}"
111
+ end
112
+ where(primary_key => ids).order(order).select(cols.join(", "))
113
+ end
114
+
115
+ vec_cols = definition.column_names
116
+ 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)
125
+ end
126
+ end
127
+ end
128
+ after_destroy_commit do
129
+ SqliteSearch::Vec::Backend.new(definition).remove(self)
130
+ end
131
+ end
132
+
133
+ def reembed(name = nil)
134
+ definitions = name ? [sqlite_search_vec_definitions.fetch(name.to_sym)] : sqlite_search_vec_definitions.values
135
+ definitions.each { |definition| SqliteSearch::Vec::Backend.new(definition).reembed(self) }
136
+ end
137
+
138
+ def sqlite_search_hybrid_definitions
139
+ own = (@sqlite_search_hybrid_definitions ||= {})
140
+ return own unless superclass.respond_to?(:sqlite_search_hybrid_definitions)
141
+ superclass.sqlite_search_hybrid_definitions.merge(own)
142
+ end
143
+
144
+ def hybrid_scope(name, fts5:, vec:, k: 60)
145
+ fts5_name = fts5.to_sym
146
+ vec_name = vec.to_sym
147
+ if name.to_sym == fts5_name || name.to_sym == vec_name
148
+ raise SqliteSearch::Error, "hybrid_scope :#{name} must not reuse its own fts5:/vec: scope name (it would overwrite that scope)."
149
+ end
150
+ unless sqlite_search_fts5_definitions.key?(fts5_name)
151
+ raise SqliteSearch::Error, "hybrid_scope :#{name} references fts5: :#{fts5_name}, but no such fts5_scope is declared on #{self.name}."
152
+ end
153
+ unless sqlite_search_vec_definitions.key?(vec_name)
154
+ raise SqliteSearch::Error, "hybrid_scope :#{name} references vec: :#{vec_name}, but no such vec_scope is declared on #{self.name}."
155
+ end
156
+ (@sqlite_search_hybrid_definitions ||= {})[name.to_sym] = {fts5: fts5_name, vec: vec_name, k: k}
157
+ rrf_k = k
158
+ score_method = "#{name}_score"
159
+
160
+ scope name, ->(query = nil, limit: 20, rerank: true) do
161
+ next none if query.nil? || query.to_s.strip.empty?
162
+ # candidate pool per arm before fusion; capped so a large limit: can't over-fetch
163
+ pool = [limit * 3, 100].min
164
+
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)
167
+
168
+ fused = SqliteSearch::Hybrid.rrf(fts_ids, vec_ids, k: rrf_k)
169
+ next none if fused.empty?
170
+
171
+ reranker = rerank ? SqliteSearch.config.reranker : nil
172
+ fused = SqliteSearch::Hybrid.rerank(query, fused, model: klass, scope_name: name.to_sym, reranker: reranker)
173
+ next none if fused.empty?
174
+ fused = fused.first(limit)
175
+ ids = fused.map(&:first)
176
+ scores = fused.to_h
177
+ pk_sql = "#{quoted_table_name}.#{connection.quote_column_name(primary_key)}"
178
+ # Fused rank and score come from Ruby, so carry them as SQL: the score
179
+ # 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}")
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqliteSearch
4
+ # Converts free user text into a safe FTS5 MATCH string.
5
+ # Only quoted phrases and term characters (Unicode alphanumerics and
6
+ # underscore; this does NOT strip to ASCII, so accented/non-Latin words
7
+ # like "café" or "日本語" survive) make it through; everything else (FTS5
8
+ # operators, punctuation) is dropped, so untrusted input cannot inject
9
+ # MATCH syntax. Terms are AND-joined. Returns nil when nothing usable.
10
+ class Query
11
+ PHRASE = /"([^"]+)"/
12
+ TERM_CHARS = /[^[:alnum:]_]+/
13
+ RESERVED = /\A(?:AND|OR|NOT|NEAR)\z/
14
+
15
+ def self.build(raw, prefix: false)
16
+ new(raw, prefix: prefix).to_match
17
+ end
18
+
19
+ def initialize(raw, prefix: false)
20
+ @raw = raw.to_s.scrub
21
+ @prefix = prefix
22
+ end
23
+
24
+ def to_match
25
+ tokens = phrases + words
26
+ return nil if tokens.empty?
27
+
28
+ tokens[-1] = "#{tokens[-1]}*" if @prefix && !quoted?(tokens[-1])
29
+ tokens.join(" AND ")
30
+ end
31
+
32
+ private
33
+
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) }
40
+ end
41
+
42
+ def quoted?(token)
43
+ token.start_with?('"')
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module SqliteSearch
6
+ class Railtie < Rails::Railtie
7
+ initializer "sqlite_search.active_record" do
8
+ ActiveSupport.on_load(:active_record) do
9
+ require "sqlite_search/model"
10
+ include SqliteSearch::Model
11
+ end
12
+ ActiveSupport.on_load(:active_record) do
13
+ require "sqlite_search/migration"
14
+ ActiveRecord::Migration.include(SqliteSearch::Migration)
15
+ end
16
+ end
17
+
18
+ rake_tasks do
19
+ load File.expand_path("../tasks/sqlite_search.rake", __dir__)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqliteSearch
4
+ module Sql
5
+ module_function
6
+
7
+ # Build "CASE <pk_sql> WHEN <id> THEN <value> ... END" from an id => value
8
+ # map, quoting each id and value through the connection. Used to carry
9
+ # per-row values computed in Ruby (KNN order, distances, fused scores) into
10
+ # the SQL as an ORDER expression or a selected column.
11
+ def id_case(pk_sql, values, connection)
12
+ whens = values.map { |id, value| "WHEN #{connection.quote(id)} THEN #{connection.quote(value)}" }.join(" ")
13
+ "CASE #{pk_sql} #{whens} END"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqliteSearch
4
+ module Vec
5
+ class Backend
6
+ def initialize(definition)
7
+ @definition = definition
8
+ end
9
+
10
+ def embed_and_store(record)
11
+ id = record.public_send(record.class.primary_key)
12
+ unless id.is_a?(Integer)
13
+ raise SqliteSearch::Error,
14
+ "vec indexing requires an integer primary key (used as the vec rowid), " \
15
+ "but #{record.class.name}##{record.class.primary_key} is #{id.class}."
16
+ end
17
+
18
+ model = @definition.neighbor_model
19
+ text = SqliteSearch::Vec.text_for(record, @definition.columns)
20
+ if text.empty?
21
+ model.where(id: id).delete_all
22
+ return
23
+ end
24
+
25
+ vector = @definition.embed(text, record_model: record.class) # compute first; if this raises, old row is untouched
26
+ model.transaction do
27
+ model.where(id: id).delete_all
28
+ model.create!(id: id, embedding: vector)
29
+ end
30
+ end
31
+
32
+ def remove(record)
33
+ id = record.public_send(record.class.primary_key)
34
+ @definition.neighbor_model.where(id: id).delete_all
35
+ end
36
+
37
+ def reembed(model)
38
+ @definition.neighbor_model.delete_all
39
+ model.find_each { |record| embed_and_store(record) }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite_search/vec"
4
+
5
+ module SqliteSearch
6
+ module Vec
7
+ class Definition
8
+ attr_reader :model, :name, :columns, :dimensions, :distance, :table_name
9
+
10
+ def initialize(model:, name:, against:, dimensions:, distance: :cosine, embedder: nil)
11
+ @model = model
12
+ @name = name.to_sym
13
+ @columns = SqliteSearch::Vec.columns_for(against)
14
+ @dimensions = dimensions
15
+ @distance = distance.to_sym
16
+ @embedder = embedder
17
+ @table_name = "#{model.table_name}_#{@name}_vec"
18
+
19
+ SqliteSearch::Vec.vec0_metric(@distance) # validates the distance is supported
20
+ end
21
+
22
+ def column_names = columns.map(&:to_s)
23
+ def cosine? = distance == :cosine
24
+ def distance_method = "#{name}_distance"
25
+ def similarity_method = "#{name}_similarity"
26
+
27
+ def embed(text, record_model: model)
28
+ callable = @embedder || SqliteSearch.config.embedder
29
+ raise SqliteSearch::Error, "No embedder registered. Call SqliteSearch.embedder { |text, model:, scope:| ... }." unless callable
30
+ vector = callable.call(text, model: record_model, scope: name)
31
+ if vector.nil? || vector.length != dimensions
32
+ raise SqliteSearch::Error, "Embedder returned #{vector&.length.inspect}-dim vector for #{model.name}##{name}, expected #{dimensions}."
33
+ end
34
+ vector
35
+ end
36
+
37
+ def neighbor_model
38
+ @neighbor_model ||= begin
39
+ tbl = table_name
40
+ dims = dimensions
41
+ Class.new(ActiveRecord::Base) do
42
+ self.table_name = tbl
43
+ self.primary_key = "id"
44
+ has_neighbors :embedding, dimensions: dims
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqliteSearch
4
+ module Vec
5
+ # Supported distance metrics, mapped to the vec0 table's distance_metric name.
6
+ # neighbor computes the distance with the matching vec_distance_* function at
7
+ # query time, so the scope's distance is what ranks results; the table metric
8
+ # is kept in step to keep the schema honest. inner_product is queryable by
9
+ # neighbor but is not a valid vec0 distance_metric, so it is not offered.
10
+ DISTANCE_METRICS = {cosine: "cosine", euclidean: "l2", taxicab: "l1"}.freeze
11
+
12
+ @loaded = false
13
+
14
+ module_function
15
+
16
+ def vec0_metric(distance)
17
+ DISTANCE_METRICS.fetch(distance.to_sym) do
18
+ raise SqliteSearch::Error,
19
+ "Unsupported distance #{distance.inspect}. Use one of: #{DISTANCE_METRICS.keys.join(", ")}."
20
+ end
21
+ end
22
+
23
+ # Lazily load the optional vector dependencies. Raises a clear error when
24
+ # they are missing so a declared vec_scope fails at boot, not mid-query.
25
+ def load!
26
+ return if @loaded
27
+ begin
28
+ require "neighbor"
29
+ require "sqlite_vec"
30
+ rescue LoadError => e
31
+ raise SqliteSearch::Error,
32
+ "Vector search needs the `neighbor` and `sqlite-vec` gems. " \
33
+ "Add them to your Gemfile. (#{e.message})"
34
+ end
35
+ Neighbor::SQLite.initialize!
36
+ @loaded = true
37
+ end
38
+
39
+ def columns_for(against)
40
+ case against
41
+ when Array then against.map(&:to_sym)
42
+ else [against.to_sym]
43
+ end
44
+ 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
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqliteSearch
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "sqlite_search/version"
5
+ require "sqlite_search/errors"
6
+ require "sqlite_search/sql"
7
+ require "sqlite_search/config"
8
+ require "sqlite_search/vec"
9
+ require "sqlite_search/vec/definition"
10
+ require "sqlite_search/vec/backend"
11
+ require "sqlite_search/hybrid"
12
+ require "sqlite_search/embed_job"
13
+ require "sqlite_search/query"
14
+ require "sqlite_search/fts5"
15
+ require "sqlite_search/migration"
16
+ require "sqlite_search/fts5/definition"
17
+ require "sqlite_search/fts5/backend"
18
+ require "sqlite_search/model"
19
+
20
+ module SqliteSearch
21
+ end
22
+
23
+ require "sqlite_search/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :sqlite_search do
4
+ desc "Rebuild FTS5 index. Usage: rake sqlite_search:reindex[Post,by_body] (scope optional)"
5
+ task :reindex, [:model, :scope] => :environment do |_t, args|
6
+ raise ArgumentError, "model is required" unless args[:model]
7
+ klass = args[:model].constantize
8
+ klass.reindex(args[:scope])
9
+ puts "Reindexed #{klass}#{" (#{args[:scope]})" if args[:scope]}."
10
+ end
11
+
12
+ desc "Re-embed a vec index. Usage: rake sqlite_search:reembed[Post,semantic]"
13
+ task :reembed, [:model, :scope] => :environment do |_t, args|
14
+ raise ArgumentError, "model is required" unless args[:model]
15
+ klass = args[:model].constantize
16
+ klass.reembed(args[:scope])
17
+ puts "Re-embedded #{klass}#{" (#{args[:scope]})" if args[:scope]}."
18
+ end
19
+ end