sqlite_search 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/MIT-LICENSE +21 -0
- data/README.md +371 -0
- data/lib/generators/sqlite_search/fts5_generator.rb +55 -0
- data/lib/generators/sqlite_search/templates/create_fts5_index.rb.tt +5 -0
- data/lib/generators/sqlite_search/templates/create_vec_index.rb.tt +5 -0
- data/lib/generators/sqlite_search/vec_generator.rb +29 -0
- data/lib/sqlite_search/config.rb +24 -0
- data/lib/sqlite_search/embed_job.rb +30 -0
- data/lib/sqlite_search/errors.rb +5 -0
- data/lib/sqlite_search/fts5/backend.rb +71 -0
- data/lib/sqlite_search/fts5/definition.rb +33 -0
- data/lib/sqlite_search/fts5.rb +26 -0
- data/lib/sqlite_search/hybrid.rb +39 -0
- data/lib/sqlite_search/migration.rb +46 -0
- data/lib/sqlite_search/model.rb +187 -0
- data/lib/sqlite_search/query.rb +46 -0
- data/lib/sqlite_search/railtie.rb +22 -0
- data/lib/sqlite_search/sql.rb +16 -0
- data/lib/sqlite_search/vec/backend.rb +43 -0
- data/lib/sqlite_search/vec/definition.rb +50 -0
- data/lib/sqlite_search/vec.rb +51 -0
- data/lib/sqlite_search/version.rb +5 -0
- data/lib/sqlite_search.rb +23 -0
- data/lib/tasks/sqlite_search.rake +19 -0
- metadata +207 -0
|
@@ -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,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
|