truffler 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +79 -12
- data/lib/generators/truffler/install/templates/migration.rb.tt +14 -11
- data/lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt +1 -1
- data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +25 -0
- data/lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt +32 -0
- data/lib/generators/truffler/upgrade/upgrade_generator.rb +49 -3
- data/lib/tasks/truffler.rake +28 -13
- data/lib/truffler/benchmark/runner.rb +1 -1
- data/lib/truffler/clients/evaluator.rb +44 -0
- data/lib/truffler/configuration.rb +12 -0
- data/lib/truffler/current.rb +30 -0
- data/lib/truffler/definition.rb +52 -0
- data/lib/truffler/embeddings/backfill.rb +40 -11
- data/lib/truffler/embeddings/label_vector.rb +1 -1
- data/lib/truffler/embeddings/neighbor_store.rb +33 -7
- data/lib/truffler/embeddings/vector_store.rb +9 -3
- data/lib/truffler/jobs/backfill_job.rb +14 -5
- data/lib/truffler/jobs/embed_job.rb +2 -0
- data/lib/truffler/jobs/label_flush_job.rb +4 -2
- data/lib/truffler/jobs/resume_job.rb +22 -9
- data/lib/truffler/label_definition.rb +17 -3
- data/lib/truffler/labeling/backfill.rb +115 -37
- data/lib/truffler/labeling/labeler.rb +58 -41
- data/lib/truffler/labeling/queue.rb +14 -9
- data/lib/truffler/labeling/supplied.rb +16 -5
- data/lib/truffler/lenses/backfill.rb +28 -10
- data/lib/truffler/model.rb +5 -0
- data/lib/truffler/providers/backup.rb +1 -1
- data/lib/truffler/query_encoding/encoder.rb +75 -49
- data/lib/truffler/query_encoding/present_options.rb +54 -0
- data/lib/truffler/records/backfill_spend.rb +60 -6
- data/lib/truffler/redaction.rb +1 -1
- data/lib/truffler/search/encoding.rb +41 -10
- data/lib/truffler/search/encoding_cache.rb +12 -1
- data/lib/truffler/search/filler.rb +31 -6
- data/lib/truffler/search/keystroke.rb +60 -29
- data/lib/truffler/search/relaxation.rb +95 -0
- data/lib/truffler/search/result.rb +21 -5
- data/lib/truffler/search/sql.rb +81 -10
- data/lib/truffler/smart_search/dispatcher.rb +36 -24
- data/lib/truffler/smart_search/reranker.rb +7 -5
- data/lib/truffler/smart_search/run.rb +10 -3
- data/lib/truffler/smart_search/starter.rb +13 -11
- data/lib/truffler/version.rb +1 -1
- data/lib/truffler/vocabulary.rb +10 -0
- metadata +7 -1
|
@@ -19,6 +19,14 @@ module Truffler
|
|
|
19
19
|
:smart_run_ttl, :smart_candidate_pool, :broadcaster
|
|
20
20
|
# Generic nouns that are never required keywords on their own (Search::Filler).
|
|
21
21
|
attr_accessor :filler_words
|
|
22
|
+
# ->(model, tenant_key) { true/false } for tenant-scoped models; nil indexes every tenant.
|
|
23
|
+
attr_accessor :tenant_enabled
|
|
24
|
+
# :tenant (a spend ledger per tenant for scoped models) or :app (one per model).
|
|
25
|
+
attr_accessor :backfill_spend_cap_scope
|
|
26
|
+
# Choice options below this probability store no row and read as 0.0 (nil stores every option).
|
|
27
|
+
attr_accessor :choice_min_probability
|
|
28
|
+
# Query encoding omits choice options the tenant has no label row for (QueryEncoding::PresentOptions).
|
|
29
|
+
attr_accessor :skip_empty_options
|
|
22
30
|
|
|
23
31
|
def initialize(env: ENV)
|
|
24
32
|
@model = "jev-latest"
|
|
@@ -53,6 +61,10 @@ module Truffler
|
|
|
53
61
|
@smart_candidate_pool = 200
|
|
54
62
|
@broadcaster = nil
|
|
55
63
|
@filler_words = Search::Filler::DEFAULT_WORDS.dup
|
|
64
|
+
@tenant_enabled = nil
|
|
65
|
+
@backfill_spend_cap_scope = :tenant
|
|
66
|
+
@choice_min_probability = 0.05
|
|
67
|
+
@skip_empty_options = false
|
|
56
68
|
end
|
|
57
69
|
|
|
58
70
|
def client
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# State scoped to one unit of work: a keystroke search, a query encoding,
|
|
3
|
+
# a labeler batch, a Smart run step. Inside `scope`, a per-tenant choice
|
|
4
|
+
# `options:` callable is resolved once per (label, tenant) instead of at
|
|
5
|
+
# every vocabulary, fingerprint, and wording read. Nested scopes share the
|
|
6
|
+
# outermost one, which clears everything when it ends, so nothing carries
|
|
7
|
+
# over to the next search or job. Outside a scope nothing is memoized.
|
|
8
|
+
module Current
|
|
9
|
+
KEY = :truffler_current_options
|
|
10
|
+
|
|
11
|
+
def self.scope
|
|
12
|
+
return yield if ActiveSupport::IsolatedExecutionState.key?(KEY)
|
|
13
|
+
|
|
14
|
+
ActiveSupport::IsolatedExecutionState[KEY] = {}
|
|
15
|
+
begin
|
|
16
|
+
yield
|
|
17
|
+
ensure
|
|
18
|
+
ActiveSupport::IsolatedExecutionState.delete(KEY)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.options(label, tenant_key)
|
|
23
|
+
memo = ActiveSupport::IsolatedExecutionState[KEY]
|
|
24
|
+
return yield unless memo
|
|
25
|
+
|
|
26
|
+
key = [ label, tenant_key&.to_s ]
|
|
27
|
+
memo.fetch(key) { memo[key] = yield }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/truffler/definition.rb
CHANGED
|
@@ -116,6 +116,42 @@ module Truffler
|
|
|
116
116
|
@weak_below || DEFAULT_WEAK_BELOW
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
attr_accessor :index_if, :index_scope
|
|
120
|
+
|
|
121
|
+
# config.tenant_enabled is asked only for tenant-scoped models; an
|
|
122
|
+
# unscoped model is always enabled.
|
|
123
|
+
def tenant_enabled?(tenant_key)
|
|
124
|
+
check = Truffler.config.tenant_enabled
|
|
125
|
+
return true if check.nil? || !scoped?
|
|
126
|
+
|
|
127
|
+
check.call(model, tenant_key) ? true : false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Whether the after-commit hooks, Queue, and EmbedJob handle this record:
|
|
131
|
+
# its tenant is enabled and `index_if` (when declared) accepts it.
|
|
132
|
+
def indexable?(record)
|
|
133
|
+
tenant_enabled?(tenant_key_for(record)) && (index_if.nil? || index_if.call(record) ? true : false)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The relation the batch paths (backfills, sweeps) page over.
|
|
137
|
+
def index_relation(relation = model.all)
|
|
138
|
+
index_scope ? index_scope.call(relation) : relation
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The tenant a backfill spend ledger row belongs to: the tenant for
|
|
142
|
+
# scoped models under backfill_spend_cap_scope :tenant, else nil (app-wide).
|
|
143
|
+
def ledger_tenant(tenant_key)
|
|
144
|
+
tenant_key if scoped? && Truffler.config.backfill_spend_cap_scope.to_sym == :tenant
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
attr_writer :invite_on_pending_encoding
|
|
148
|
+
|
|
149
|
+
# Whether a model with a `keyword` source shows the Smart search row while
|
|
150
|
+
# the query's encoding is in flight (AE10). Default true.
|
|
151
|
+
def invite_on_pending_encoding
|
|
152
|
+
@invite_on_pending_encoding.nil? || @invite_on_pending_encoding
|
|
153
|
+
end
|
|
154
|
+
|
|
119
155
|
private
|
|
120
156
|
|
|
121
157
|
def table_available?
|
|
@@ -204,6 +240,22 @@ module Truffler
|
|
|
204
240
|
def weak_below(count)
|
|
205
241
|
@definition.weak_below = Integer(count)
|
|
206
242
|
end
|
|
243
|
+
|
|
244
|
+
def index_if(callable)
|
|
245
|
+
raise DefinitionError, "index_if must be callable with the record" unless callable.respond_to?(:call)
|
|
246
|
+
|
|
247
|
+
@definition.index_if = callable
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def index_scope(callable)
|
|
251
|
+
raise DefinitionError, "index_scope must be callable with a relation" unless callable.respond_to?(:call)
|
|
252
|
+
|
|
253
|
+
@definition.index_scope = callable
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def invite_on_pending_encoding(enabled)
|
|
257
|
+
@definition.invite_on_pending_encoding = enabled ? true : false
|
|
258
|
+
end
|
|
207
259
|
end
|
|
208
260
|
end
|
|
209
261
|
end
|
|
@@ -3,11 +3,15 @@ module Truffler
|
|
|
3
3
|
# Finds records whose embedding is missing or was made under another
|
|
4
4
|
# fingerprint (model, width, or fields changed) and enqueues EmbedJob for
|
|
5
5
|
# them, newest first, `batch_size` jobs at a time behind an id cursor.
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# It pages over the definition's index_scope, one tenant at a time when
|
|
7
|
+
# given `tenant_key:`, skipping disabled tenants, with a NOT EXISTS
|
|
8
|
+
# anti-join against truffler_record_states so each page stops at its
|
|
9
|
+
# limit instead of materializing every current id. ResumeJob runs a
|
|
10
|
+
# bounded pass on every sweep; hosts call `enqueue` with no limit after
|
|
11
|
+
# enabling embeddings or changing the model, width, or fields.
|
|
9
12
|
class Backfill
|
|
10
13
|
BATCH_SIZE = 1_000
|
|
14
|
+
STATES = Records::RecordState.table_name
|
|
11
15
|
|
|
12
16
|
attr_reader :model
|
|
13
17
|
|
|
@@ -15,25 +19,30 @@ module Truffler
|
|
|
15
19
|
@model = model
|
|
16
20
|
end
|
|
17
21
|
|
|
18
|
-
def stale_ids(limit: nil, before: nil)
|
|
22
|
+
def stale_ids(limit: nil, before: nil, tenant_key: nil)
|
|
19
23
|
definition = model.truffler_definition
|
|
20
24
|
return [] unless Embeddings.managed?(definition)
|
|
25
|
+
return [] if tenant_key && !definition.tenant_enabled?(tenant_key)
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
pk = model.arel_table[model.primary_key]
|
|
28
|
+
scope = definition.index_relation(model.all).where(current_embedding_missing_sql(definition))
|
|
29
|
+
if tenant_key && definition.scoped?
|
|
30
|
+
scope = scope.where(definition.tenant_column => tenant_key)
|
|
31
|
+
elsif definition.scoped? && Truffler.config.tenant_enabled
|
|
32
|
+
scope = scope.where(definition.tenant_column => tenant_keys)
|
|
33
|
+
end
|
|
34
|
+
scope = scope.where(pk.lt(before)) if before
|
|
35
|
+
scope.reorder(pk.desc).limit(limit).pluck(pk)
|
|
27
36
|
end
|
|
28
37
|
|
|
29
|
-
def enqueue(limit: nil, batch_size: BATCH_SIZE)
|
|
38
|
+
def enqueue(limit: nil, batch_size: BATCH_SIZE, tenant_key: nil)
|
|
30
39
|
count = 0
|
|
31
40
|
cursor = nil
|
|
32
41
|
loop do
|
|
33
42
|
take = limit ? [ batch_size, limit - count ].min : batch_size
|
|
34
43
|
break unless take.positive?
|
|
35
44
|
|
|
36
|
-
ids = stale_ids(limit: take, before: cursor)
|
|
45
|
+
ids = stale_ids(limit: take, before: cursor, tenant_key: tenant_key)
|
|
37
46
|
break if ids.empty?
|
|
38
47
|
|
|
39
48
|
ActiveJob.perform_all_later(ids.map { |id| Jobs::EmbedJob.new(model.polymorphic_name, id) })
|
|
@@ -43,6 +52,26 @@ module Truffler
|
|
|
43
52
|
end
|
|
44
53
|
count
|
|
45
54
|
end
|
|
55
|
+
|
|
56
|
+
# The enabled tenants with records in the index scope, for per-tenant sweeps.
|
|
57
|
+
def tenant_keys
|
|
58
|
+
definition = model.truffler_definition
|
|
59
|
+
return [ nil ] unless definition.scoped?
|
|
60
|
+
|
|
61
|
+
definition.index_relation(model.all).reorder(nil).distinct.pluck(definition.tenant_column)
|
|
62
|
+
.map(&:to_s).select { |key| definition.tenant_enabled?(key) }.sort
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def current_embedding_missing_sql(definition)
|
|
68
|
+
pk = "#{model.quoted_table_name}.#{model.connection.quote_column_name(model.primary_key)}"
|
|
69
|
+
ActiveRecord::Base.sanitize_sql_array([
|
|
70
|
+
"NOT EXISTS (SELECT 1 FROM #{STATES} WHERE #{STATES}.record_type = ? AND #{STATES}.record_id = #{pk} " \
|
|
71
|
+
"AND #{STATES}.embedding_fingerprint = ? AND #{STATES}.embedded_at IS NOT NULL)",
|
|
72
|
+
model.polymorphic_name, Embeddings.fingerprint(definition)
|
|
73
|
+
])
|
|
74
|
+
end
|
|
46
75
|
end
|
|
47
76
|
end
|
|
48
77
|
end
|
|
@@ -33,7 +33,7 @@ module Truffler
|
|
|
33
33
|
label_vocabulary_version: version, created_at: now, updated_at: now }
|
|
34
34
|
end
|
|
35
35
|
Records::Embedding.upsert_all(rows, unique_by: %i[record_type record_id],
|
|
36
|
-
update_only: %i[tenant_key label_vector label_vocabulary_version
|
|
36
|
+
update_only: %i[tenant_key label_vector label_vocabulary_version])
|
|
37
37
|
rows.size
|
|
38
38
|
end
|
|
39
39
|
|
|
@@ -2,9 +2,13 @@ module Truffler
|
|
|
2
2
|
module Embeddings
|
|
3
3
|
# Cosine distance computed by the database: pgvector's `<=>` on a vector
|
|
4
4
|
# column, or sqlite-vec's `vec_distance_cosine` on float32 blobs (the
|
|
5
|
-
# host loads the extension).
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# host loads the extension).
|
|
6
|
+
#
|
|
7
|
+
# On Postgres, search reads text similarity from the tenant's top `k`
|
|
8
|
+
# neighbors (`neighbors_sql`, one `ORDER BY embedding <=> q LIMIT k`
|
|
9
|
+
# that an HNSW index can serve), so records outside the top K score no
|
|
10
|
+
# text similarity. Pass `top_k: false` for an exact inline similarity per
|
|
11
|
+
# row, the sqlite-vec default; `top_k: true` uses the join there too.
|
|
8
12
|
class NeighborStore < VectorStore
|
|
9
13
|
TABLE = "truffler_embeddings".freeze
|
|
10
14
|
|
|
@@ -35,16 +39,31 @@ module Truffler
|
|
|
35
39
|
end
|
|
36
40
|
end
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
attr_reader :k
|
|
43
|
+
|
|
44
|
+
def initialize(dialect: nil, k: DEFAULT_K, top_k: nil)
|
|
39
45
|
@dialect = dialect
|
|
46
|
+
@k = Integer(k)
|
|
47
|
+
@top_k = top_k
|
|
40
48
|
end
|
|
41
49
|
|
|
42
|
-
def nearest(model, tenant_key:, vector:, k:
|
|
50
|
+
def nearest(model, tenant_key:, vector:, k: self.k)
|
|
43
51
|
distance = distance_sql(model, "#{TABLE}.embedding", vector)
|
|
44
52
|
embeddings(model, tenant_key, vector.size).order(Arel.sql(distance)).limit(k)
|
|
45
53
|
.pluck(:record_id, Arel.sql("1 - #{distance}")).map { |id, similarity| [ id, similarity.to_f ] }
|
|
46
54
|
end
|
|
47
55
|
|
|
56
|
+
# The tenant's `k` nearest vectors as `(record_id, similarity)` rows,
|
|
57
|
+
# for search to LEFT JOIN on record_id; nil when this store scores
|
|
58
|
+
# inline instead.
|
|
59
|
+
def neighbors_sql(model, tenant_key:, vector:, k: self.k)
|
|
60
|
+
return unless top_k?(model)
|
|
61
|
+
|
|
62
|
+
distance = distance_sql(model, "#{TABLE}.embedding", vector)
|
|
63
|
+
embeddings(model, tenant_key, vector.size).reorder(Arel.sql(distance)).limit(k)
|
|
64
|
+
.select(Arel.sql("#{TABLE}.record_id AS record_id"), Arel.sql("1 - #{distance} AS similarity")).to_sql
|
|
65
|
+
end
|
|
66
|
+
|
|
48
67
|
def similarity_sql(model, tenant_key:, vector:, k: nil)
|
|
49
68
|
similarity = "1 - #{distance_sql(model, "#{TABLE}.embedding", vector)}"
|
|
50
69
|
subquery = embeddings(model, tenant_key, vector.size).where("#{TABLE}.record_id = #{primary_key_sql(model)}")
|
|
@@ -55,11 +74,18 @@ module Truffler
|
|
|
55
74
|
true
|
|
56
75
|
end
|
|
57
76
|
|
|
77
|
+
def top_k?(model)
|
|
78
|
+
@top_k.nil? ? dialect(model) == :postgres : @top_k
|
|
79
|
+
end
|
|
80
|
+
|
|
58
81
|
private
|
|
59
82
|
|
|
60
|
-
def
|
|
83
|
+
def dialect(model)
|
|
61
84
|
@dialect ||= self.class.dialect(model.connection)
|
|
62
|
-
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def distance_sql(model, column_sql, vector)
|
|
88
|
+
self.class.distance_sql(dialect(model), model.connection, column_sql, vector)
|
|
63
89
|
end
|
|
64
90
|
end
|
|
65
91
|
end
|
|
@@ -5,7 +5,12 @@ module Truffler
|
|
|
5
5
|
# `similarity_sql` returns a scalar SQL expression over the model's table
|
|
6
6
|
# that search can put in its SELECT or ORDER BY: stores that compute
|
|
7
7
|
# similarity in the database (`inline_sql?`) scan every row exactly, and
|
|
8
|
-
# the rest fall back to a CASE over the top-K neighbors.
|
|
8
|
+
# the rest fall back to a CASE over the top-K neighbors. A store may also
|
|
9
|
+
# answer `neighbors_sql` with a `(record_id, similarity)` subquery, which
|
|
10
|
+
# search LEFT JOINs instead (see NeighborStore).
|
|
11
|
+
#
|
|
12
|
+
# `config.vector_store` is one of ADAPTERS or a store instance, such as
|
|
13
|
+
# `NeighborStore.new(k: 500)` or a subclass of this class.
|
|
9
14
|
class VectorStore
|
|
10
15
|
ADAPTERS = %i[auto ruby neighbor].freeze
|
|
11
16
|
DEFAULT_K = 200
|
|
@@ -14,12 +19,13 @@ module Truffler
|
|
|
14
19
|
embeddings = model.truffler_definition.embeddings
|
|
15
20
|
return unless embeddings
|
|
16
21
|
return ColumnStore.new(embeddings[:column]) if embeddings.key?(:column)
|
|
22
|
+
return config.vector_store if config.vector_store.respond_to?(:similarity_sql)
|
|
17
23
|
|
|
18
24
|
case config.vector_store&.to_sym
|
|
19
25
|
when :ruby then RubyStore.new
|
|
20
26
|
when :neighbor then NeighborStore.new
|
|
21
27
|
when :auto then NeighborStore.available?(model.connection) ? NeighborStore.new : RubyStore.new
|
|
22
|
-
else raise Error, "config.vector_store must be one of #{ADAPTERS.join(', ')}"
|
|
28
|
+
else raise Error, "config.vector_store must be one of #{ADAPTERS.join(', ')} or a store instance"
|
|
23
29
|
end
|
|
24
30
|
end
|
|
25
31
|
|
|
@@ -58,7 +64,7 @@ module Truffler
|
|
|
58
64
|
fingerprint: fingerprint, embedding: Records::Embedding.encode(vector), dimensions: vector.size,
|
|
59
65
|
created_at: now, updated_at: now },
|
|
60
66
|
unique_by: %i[record_type record_id],
|
|
61
|
-
update_only: %i[tenant_key fingerprint embedding dimensions
|
|
67
|
+
update_only: %i[tenant_key fingerprint embedding dimensions]
|
|
62
68
|
)
|
|
63
69
|
end
|
|
64
70
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module Truffler
|
|
2
2
|
module Jobs
|
|
3
3
|
# Backfills one model's stale, missing, failed, and demoted labels at
|
|
4
|
-
# backfill priority
|
|
4
|
+
# backfill priority, over one tenant when given `tenant_key:` (the
|
|
5
|
+
# tenant's own spend ledger) or else the whole model's indexed records.
|
|
6
|
+
# Arguments are the record type, the tenant key, the id cursor, the
|
|
5
7
|
# spend so far, the cap, the retry attempt, and the count of budget
|
|
6
8
|
# denials in a row, never record text. A budget denial reschedules the job
|
|
7
9
|
# from its cursor after the same backoff a waiting Labeling::Backfill uses
|
|
@@ -15,16 +17,23 @@ module Truffler
|
|
|
15
17
|
|
|
16
18
|
queue_as { Truffler.config.queue_name }
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
# The job's tenant keyword: the tenant for scoped models, none otherwise.
|
|
21
|
+
def self.tenant_argument(model, tenant_key)
|
|
22
|
+
model.truffler_definition.scoped? && !tenant_key.nil? ? { tenant_key: tenant_key.to_s } : {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def perform(record_type, tenant_key: nil, cursor: nil, spent: 0.0, spend_cap: Truffler.config.backfill_spend_cap, max_pages: MAX_PAGES,
|
|
19
26
|
attempt: 0, denials: 0)
|
|
20
27
|
model = record_type.safe_constantize
|
|
21
28
|
return unless model.respond_to?(:truffler_definition) && model.truffler_definition
|
|
22
29
|
|
|
23
|
-
result = Labeling::Backfill.new(model, cursor: cursor, spent: spent, spend_cap: spend_cap)
|
|
24
|
-
|
|
30
|
+
result = Labeling::Backfill.new(model, tenant_key: tenant_key, cursor: cursor, spent: spent, spend_cap: spend_cap)
|
|
31
|
+
.run(max_pages: max_pages)
|
|
32
|
+
Instrumentation.instrument(:backfill, record_type: record_type, tenant_key: tenant_key, outcome: result.status,
|
|
25
33
|
labeled_count: result.labeled, request_count: result.requests, cost: result.cost)
|
|
26
34
|
|
|
27
|
-
follow_up = { cursor: result.cursor, spent: spent + result.cost,
|
|
35
|
+
follow_up = { **self.class.tenant_argument(model, tenant_key), cursor: result.cursor, spent: spent + result.cost,
|
|
36
|
+
spend_cap: spend_cap, max_pages: max_pages }
|
|
28
37
|
case result.status
|
|
29
38
|
when :budget_denied then retry_after_denial(record_type, follow_up, result, denials)
|
|
30
39
|
when :paused then self.class.perform_later(record_type, **follow_up)
|
|
@@ -3,6 +3,7 @@ module Truffler
|
|
|
3
3
|
# Embeds one record's declared fields and stores the vector. Arguments are
|
|
4
4
|
# the record type and id only. On an embedder failure nothing is written,
|
|
5
5
|
# `embedded_at` stays as it was, and the job retries; labels are untouched.
|
|
6
|
+
# A record the definition no longer indexes is skipped.
|
|
6
7
|
class EmbedJob < ActiveJob::Base
|
|
7
8
|
queue_as { Truffler.config.queue_name }
|
|
8
9
|
|
|
@@ -17,6 +18,7 @@ module Truffler
|
|
|
17
18
|
|
|
18
19
|
record = model.find_by(model.primary_key => record_id)
|
|
19
20
|
return Records::Embedding.where(record_type: record_type, record_id: record_id).delete_all unless record
|
|
21
|
+
return unless definition.indexable?(record)
|
|
20
22
|
|
|
21
23
|
settings = definition.embeddings
|
|
22
24
|
fingerprint = Embeddings.fingerprint(definition)
|
|
@@ -4,7 +4,7 @@ module Truffler
|
|
|
4
4
|
# and tenant key only. On a Jev or budget failure the claimed rows go back
|
|
5
5
|
# to pending (or failed after max_attempts) and the job retries. Rows over
|
|
6
6
|
# the tenant's live cap drop to backfill priority and a delayed backfill
|
|
7
|
-
# is scheduled for them.
|
|
7
|
+
# of that tenant is scheduled for them. A disabled tenant is left alone.
|
|
8
8
|
class LabelFlushJob < ActiveJob::Base
|
|
9
9
|
RETRYABLE = [ ClientError, BudgetExhausted, IncompleteAnswers ].freeze
|
|
10
10
|
|
|
@@ -18,6 +18,8 @@ module Truffler
|
|
|
18
18
|
|
|
19
19
|
queue = Labeling::Queue.new(model)
|
|
20
20
|
queue.clear_marker(tenant_key)
|
|
21
|
+
return unless model.truffler_definition.tenant_enabled?(tenant_key)
|
|
22
|
+
|
|
21
23
|
states = queue.claim(tenant_key, priority: :live, limit: Truffler.config.batch_size)
|
|
22
24
|
return if states.empty?
|
|
23
25
|
|
|
@@ -30,7 +32,7 @@ module Truffler
|
|
|
30
32
|
|
|
31
33
|
if result.demoted
|
|
32
34
|
queue.demote(states)
|
|
33
|
-
queue.schedule_backfill
|
|
35
|
+
queue.schedule_backfill(tenant_key)
|
|
34
36
|
end
|
|
35
37
|
queue.schedule(tenant_key) if queue.pending?(tenant_key, priority: :live)
|
|
36
38
|
end
|
|
@@ -3,12 +3,13 @@ module Truffler
|
|
|
3
3
|
# A periodic sweep hosts schedule (every few minutes) so labeling resumes
|
|
4
4
|
# after a Jev outage or a crashed worker. It returns failed rows and rows
|
|
5
5
|
# stuck in labeling to pending, reschedules a flush for tenants whose live
|
|
6
|
-
# rows have waited past `resume_pending_after`, and starts a
|
|
7
|
-
# rows waiting at backfill priority.
|
|
6
|
+
# rows have waited past `resume_pending_after`, and starts a BackfillJob
|
|
7
|
+
# per tenant with rows waiting at backfill priority. Disabled tenants
|
|
8
|
+
# (config.tenant_enabled) get neither. For models with gem-managed
|
|
8
9
|
# embeddings it also enqueues up to `embedding_sweep_limit` missing or
|
|
9
|
-
# stale embeddings, at most once per
|
|
10
|
-
# embedder outage cannot pile duplicate
|
|
11
|
-
# type to sweep one model.
|
|
10
|
+
# stale embeddings, tenant by tenant for scoped models, at most once per
|
|
11
|
+
# `embedding_sweep_interval`, so an embedder outage cannot pile duplicate
|
|
12
|
+
# jobs onto the queue. Pass a record type to sweep one model.
|
|
12
13
|
class ResumeJob < ActiveJob::Base
|
|
13
14
|
queue_as { Truffler.config.queue_name }
|
|
14
15
|
|
|
@@ -28,16 +29,20 @@ module Truffler
|
|
|
28
29
|
requeued = requeue(states.where(status: "failed").or(states.where(status: "labeling").where(claimed_at: ...cutoff)))
|
|
29
30
|
waiting = states.where(status: "pending").where(updated_at: ...cutoff)
|
|
30
31
|
|
|
32
|
+
definition = model.truffler_definition
|
|
31
33
|
live = (requeued.select { |_, priority| priority == "live" }.map(&:first) +
|
|
32
|
-
waiting.where(priority: "live").distinct.pluck(:tenant_key)).uniq
|
|
34
|
+
waiting.where(priority: "live").distinct.pluck(:tenant_key)).uniq.select { |key| definition.tenant_enabled?(key) }
|
|
33
35
|
queue = Labeling::Queue.new(model)
|
|
34
36
|
live.each do |tenant_key|
|
|
35
37
|
queue.clear_marker(tenant_key)
|
|
36
38
|
queue.schedule(tenant_key)
|
|
37
39
|
end
|
|
38
40
|
|
|
39
|
-
backfill = requeued.
|
|
40
|
-
|
|
41
|
+
backfill = (requeued.select { |_, priority| priority == "backfill" }.map(&:first) +
|
|
42
|
+
waiting.where(priority: "backfill").distinct.pluck(:tenant_key)).uniq
|
|
43
|
+
backfill.select { |key| definition.tenant_enabled?(key) }.each do |tenant_key|
|
|
44
|
+
BackfillJob.perform_later(model.polymorphic_name, **BackfillJob.tenant_argument(model, tenant_key))
|
|
45
|
+
end
|
|
41
46
|
sweep_embeddings(model)
|
|
42
47
|
end
|
|
43
48
|
|
|
@@ -47,7 +52,15 @@ module Truffler
|
|
|
47
52
|
marker = "truffler:embedding_sweep:#{model.polymorphic_name}"
|
|
48
53
|
return unless Truffler.config.cache_store.write(marker, true, unless_exist: true, expires_in: embedding_sweep_interval)
|
|
49
54
|
|
|
50
|
-
Embeddings::Backfill.new(model)
|
|
55
|
+
backfill = Embeddings::Backfill.new(model)
|
|
56
|
+
return backfill.enqueue(limit: embedding_sweep_limit) unless model.truffler_definition.scoped?
|
|
57
|
+
|
|
58
|
+
remaining = embedding_sweep_limit
|
|
59
|
+
backfill.tenant_keys.each do |tenant_key|
|
|
60
|
+
break unless remaining.positive?
|
|
61
|
+
|
|
62
|
+
remaining -= backfill.enqueue(limit: remaining, tenant_key: tenant_key)
|
|
63
|
+
end
|
|
51
64
|
end
|
|
52
65
|
|
|
53
66
|
# Returns the distinct [tenant_key, priority] pairs it moved to pending.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module Truffler
|
|
2
2
|
# One typed label question. Nouls store their probability, scores their
|
|
3
3
|
# normalized position, and choices one row per option ("label:option") with
|
|
4
|
-
# that option's probability
|
|
4
|
+
# that option's probability, for options at or above
|
|
5
|
+
# config.choice_min_probability plus the most likely option; a missing
|
|
6
|
+
# option row reads as 0.0. Choice options may be a callable of the tenant
|
|
5
7
|
# key, which makes the vocabulary per-tenant; a tenant it gives no options
|
|
6
8
|
# (`{}` or nil) simply does not have the label. An option's value is its
|
|
7
9
|
# description, or `{ description:, search: }` to give query encoding a
|
|
@@ -104,10 +106,19 @@ module Truffler
|
|
|
104
106
|
case type
|
|
105
107
|
when :noul then { key => probability(value) }
|
|
106
108
|
when :score then { key => level(value) }
|
|
107
|
-
when :choice then choice_values(value, options(tenant_key).keys)
|
|
109
|
+
when :choice then self.class.sparse_choice(choice_values(value, options(tenant_key).keys))
|
|
108
110
|
end
|
|
109
111
|
end
|
|
110
112
|
|
|
113
|
+
# Drops choice option rows ({"label:option" => probability}) below
|
|
114
|
+
# config.choice_min_probability, always keeping the most likely option.
|
|
115
|
+
def self.sparse_choice(values, min: Truffler.config.choice_min_probability)
|
|
116
|
+
return values if min.nil? || values.empty?
|
|
117
|
+
|
|
118
|
+
top = values.max_by { |_, probability| probability.to_f }.first
|
|
119
|
+
values.select { |key, probability| key == top || probability.to_f >= min }
|
|
120
|
+
end
|
|
121
|
+
|
|
111
122
|
def storage_keys(tenant_key = nil)
|
|
112
123
|
type == :choice ? options(tenant_key).keys.map { |option| "#{key}:#{option}" } : [ key ]
|
|
113
124
|
end
|
|
@@ -120,7 +131,10 @@ module Truffler
|
|
|
120
131
|
private
|
|
121
132
|
|
|
122
133
|
def option_entries(tenant_key)
|
|
123
|
-
|
|
134
|
+
per_tenant? ? Current.options(self, tenant_key) { build_option_entries(@options.call(tenant_key)).freeze } : build_option_entries(@options)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_option_entries(options)
|
|
124
138
|
options = Array(options).to_h { |option| [ option, nil ] } unless options.is_a?(Hash)
|
|
125
139
|
options = options.to_h { |option, value| [ option.to_s, option_entry(option, value) ] }
|
|
126
140
|
raise DefinitionError, "#{key}: the option name #{NO_OPTION} is reserved" if options.key?(NO_OPTION)
|