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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Search
|
|
3
|
+
# Zero-result relaxation (0.1.6). When a search under the encoding's
|
|
4
|
+
# filters returns nothing, the filters are demoted to soft boosts
|
|
5
|
+
# (Encoding#relax) and the words they consumed become keywords again.
|
|
6
|
+
# It relaxes as little as it can: first only the filters no record in
|
|
7
|
+
# the tenant carries at their threshold ("Source: email" in a tenant with
|
|
8
|
+
# no email sources), keeping the rest hard; if that still finds nothing,
|
|
9
|
+
# every filter, but only when the words given back (or an exact or vector
|
|
10
|
+
# match) still narrow the search: relaxing into "every record in the
|
|
11
|
+
# tenant" would show unrelated results, so that stays empty.
|
|
12
|
+
#
|
|
13
|
+
# Both attempts run as one UNION ALL query, so relaxation costs a single
|
|
14
|
+
# extra SELECT and only on an empty result. Each "these filters are
|
|
15
|
+
# missing" branch is gated on label presence in the tenant, so at most
|
|
16
|
+
# one of them can return rows; the relax-everything branch ranks after
|
|
17
|
+
# it. With more than MAX_PARTIAL_FILTERS filters only the
|
|
18
|
+
# relax-everything branch runs, to bound the branch count (2^n - 1).
|
|
19
|
+
class Relaxation
|
|
20
|
+
MAX_PARTIAL_FILTERS = 3
|
|
21
|
+
TIER = "truffler_relaxed_tier".freeze
|
|
22
|
+
BRANCH = "truffler_relaxed_branch".freeze
|
|
23
|
+
SCORE_COLUMNS = %i[truffler_score truffler_label_score truffler_text_score truffler_keyword_score truffler_exact_score].freeze
|
|
24
|
+
|
|
25
|
+
Outcome = Data.define(:records, :encoding, :relaxed_labels)
|
|
26
|
+
|
|
27
|
+
# `sql` builds the search's Sql for an encoding.
|
|
28
|
+
def initialize(model, encoding, sql:)
|
|
29
|
+
@model = model
|
|
30
|
+
@encoding = encoding
|
|
31
|
+
@sql = sql
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The relaxed search's outcome, or nil when there is no filter to relax
|
|
35
|
+
# or the relaxed search is still empty.
|
|
36
|
+
def call(scope, limit: nil)
|
|
37
|
+
filters = @encoding&.filters.to_h
|
|
38
|
+
return if filters.empty?
|
|
39
|
+
|
|
40
|
+
branches = partial_branches(filters.keys)
|
|
41
|
+
branches << filters.keys unless @sql.call(@encoding.relax(filters.keys)).every_base_record?
|
|
42
|
+
return if branches.empty?
|
|
43
|
+
|
|
44
|
+
rows = @model.find_by_sql(union_sql(scope, filters, branches, limit))
|
|
45
|
+
return if rows.empty?
|
|
46
|
+
|
|
47
|
+
partial, full = rows.partition { |row| Integer(row[TIER]) == 1 }
|
|
48
|
+
records = partial.presence || full
|
|
49
|
+
relaxed = branches.fetch(Integer(records.first[BRANCH]))
|
|
50
|
+
Outcome.new(records: records, encoding: @encoding.relax(relaxed), relaxed_labels: relaxed)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Every nonempty proper subset of the filters, as a "missing" set.
|
|
56
|
+
def partial_branches(keys)
|
|
57
|
+
return [] if keys.size > MAX_PARTIAL_FILTERS
|
|
58
|
+
|
|
59
|
+
(1...keys.size).flat_map { |size| keys.combination(size).to_a }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def union_sql(scope, filters, branches, limit)
|
|
63
|
+
present = ->(key) { @sql.call(@encoding).label_present_sql(key, filters.fetch(key)) }
|
|
64
|
+
selects = branches.each_with_index.map do |missing, index|
|
|
65
|
+
full = missing.size == filters.size
|
|
66
|
+
guard = (filters.keys - missing).map { |key| present.call(key) } + missing.map { |key| "NOT #{present.call(key)}" }
|
|
67
|
+
branch_sql(scope, missing, index, tier: full ? 0 : 1, guard: full ? [] : guard, limit: limit)
|
|
68
|
+
end
|
|
69
|
+
sql = "SELECT * FROM (#{selects.join(' UNION ALL ')}) truffler_relaxed ORDER BY #{ordering.join(', ')}"
|
|
70
|
+
limit ? "#{sql} LIMIT #{Integer(limit)}" : sql
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def branch_sql(scope, missing, index, tier:, guard:, limit:)
|
|
74
|
+
search = @sql.call(@encoding.relax(missing))
|
|
75
|
+
alias_name = "truffler_relaxed_#{index}"
|
|
76
|
+
scores = search.score_column_names
|
|
77
|
+
columns = @model.column_names.map { |name| "#{alias_name}.#{quote_column(name)}" } +
|
|
78
|
+
SCORE_COLUMNS.map { |name| scores.include?(name) ? "#{alias_name}.#{name}" : "0.0 AS #{name}" } +
|
|
79
|
+
[ "#{tier} AS #{TIER}", "#{index} AS #{BRANCH}" ]
|
|
80
|
+
where = guard.any? ? " WHERE #{guard.join(' AND ')}" : ""
|
|
81
|
+
"SELECT #{columns.join(', ')} FROM (#{search.relation(scope, limit: limit).to_sql}) #{alias_name}#{where}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def ordering
|
|
85
|
+
order_column, direction = @model.truffler_definition.order
|
|
86
|
+
[ "#{TIER} DESC", "truffler_score DESC", ("#{quote_column(order_column)} #{direction == :asc ? 'ASC' : 'DESC'}" if order_column),
|
|
87
|
+
"#{quote_column(@model.primary_key)} DESC" ].compact
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def quote_column(name)
|
|
91
|
+
@model.connection.quote_column_name(name)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -10,9 +10,12 @@ module Truffler
|
|
|
10
10
|
SCORE_COLUMNS = { label: "truffler_label_score", text: "truffler_text_score", keyword: "truffler_keyword_score",
|
|
11
11
|
exact: "truffler_exact_score" }.freeze
|
|
12
12
|
|
|
13
|
-
attr_reader :records, :query, :encoding, :encoding_status, :watermark, :explicit_action, :sources, :invite_row
|
|
13
|
+
attr_reader :records, :query, :encoding, :encoding_status, :watermark, :explicit_action, :sources, :invite_row, :relaxed_labels
|
|
14
14
|
|
|
15
|
-
def initialize(records:, query:, encoding:, encoding_status:, watermark:, explicit_action:, sources:, invite_row:, weights:, recount
|
|
15
|
+
def initialize(records:, query:, encoding:, encoding_status:, watermark:, explicit_action:, sources:, invite_row:, weights:, recount:,
|
|
16
|
+
local_weak: nil, relaxed_labels: [])
|
|
17
|
+
@local_weak = local_weak
|
|
18
|
+
@relaxed_labels = relaxed_labels
|
|
16
19
|
@records = records
|
|
17
20
|
@query = query
|
|
18
21
|
@encoding = encoding
|
|
@@ -29,12 +32,25 @@ module Truffler
|
|
|
29
32
|
records.map(&:id)
|
|
30
33
|
end
|
|
31
34
|
|
|
32
|
-
#
|
|
35
|
+
# Whether the local list is too weak to stand alone, which starts the
|
|
36
|
+
# backup provider on the explicit action. An `:encoding_pending` row on
|
|
37
|
+
# a model with a `keyword` source invites Smart search without making
|
|
38
|
+
# the list weak.
|
|
39
|
+
def local_weak?
|
|
40
|
+
@local_weak.nil? ? invite_row.present? : @local_weak
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Applied filters, then relaxed filters, then boosts, then the time range,
|
|
44
|
+
# as `{key:, label:, kind:, name:}`. A filter relaxed because it left
|
|
45
|
+
# nothing to show (`relaxed_labels`) keeps its filter chip with
|
|
46
|
+
# `relaxed: true`, so the host can say "No email sources; showing
|
|
47
|
+
# keyword matches".
|
|
33
48
|
def chips
|
|
34
49
|
return [] unless encoding
|
|
35
50
|
|
|
36
|
-
filters = encoding.filters.keys.map { |key| chip(key, :filter) }
|
|
37
|
-
|
|
51
|
+
filters = encoding.filters.keys.map { |key| chip(key, :filter) } +
|
|
52
|
+
relaxed_labels.map { |key| chip(key, :filter).merge(relaxed: true) }
|
|
53
|
+
chips = filters + (encoding.boosts.keys - encoding.filters.keys - relaxed_labels).map { |key| chip(key, :boost) }
|
|
38
54
|
time = encoding.time
|
|
39
55
|
time ? chips + [ { key: TimeRange.key, label: TimeRange.key, kind: :time, name: time.name } ] : chips
|
|
40
56
|
end
|
data/lib/truffler/search/sql.rb
CHANGED
|
@@ -3,15 +3,24 @@ module Truffler
|
|
|
3
3
|
# Builds the one keystroke query of KTD8, scored per KTD20:
|
|
4
4
|
#
|
|
5
5
|
# w_label * SUM(weight * value) over the intent's nonzero label keys
|
|
6
|
-
# + w_text * text similarity (inline SQL, or the store's top-K CASE)
|
|
6
|
+
# + w_text * text similarity (a top-K join, inline SQL, or the store's top-K CASE)
|
|
7
7
|
# + w_keyword * keyword hit + w_exact * exact-source hit
|
|
8
8
|
# + SOFT_KEYWORD * w_keyword * soft keyword hit
|
|
9
9
|
#
|
|
10
10
|
# A weighted dot product, not cosine: cosine would divide out magnitude
|
|
11
11
|
# and let a record high on unrelated labels outrank the one the query
|
|
12
12
|
# asked for. Hard filters are EXISTS subqueries that run before scoring.
|
|
13
|
+
#
|
|
14
|
+
# At scale: when every record past the filters is a candidate (label-only
|
|
15
|
+
# and filtered searches), label scores come from one grouped aggregate
|
|
16
|
+
# LEFT JOINed on record_id instead of a subquery per row. A store with
|
|
17
|
+
# `neighbors_sql` (NeighborStore on Postgres) is LEFT JOINed the same
|
|
18
|
+
# way, so text similarity is read from the tenant's top-K. Relation
|
|
19
|
+
# sources run once, as `id = ANY(ARRAY(subquery))` on Postgres.
|
|
13
20
|
class Sql
|
|
14
21
|
LABELS = "truffler_labels".freeze
|
|
22
|
+
LABEL_SCORES = "truffler_label_scores".freeze
|
|
23
|
+
NEIGHBORS = "truffler_neighbors".freeze
|
|
15
24
|
# Share of the keyword weight a soft keyword hit adds (see Encoding).
|
|
16
25
|
SOFT_KEYWORD = 0.25
|
|
17
26
|
|
|
@@ -38,7 +47,8 @@ module Truffler
|
|
|
38
47
|
# filter the filter decides membership and text matches only rank.
|
|
39
48
|
def candidates(scope)
|
|
40
49
|
base = base(scope)
|
|
41
|
-
|
|
50
|
+
base = base.joins(Arel.sql(neighbors_join_sql)) if neighbors_sql
|
|
51
|
+
return base if every_base_record?
|
|
42
52
|
|
|
43
53
|
conditions = [ keyword_sql, exact_sql, (text_candidate_sql if text_score_sql) ].compact
|
|
44
54
|
base.where(Arel.sql(conditions.any? ? conditions.map { |condition| "(#{condition})" }.join(" OR ") : "1 = 0"))
|
|
@@ -46,19 +56,21 @@ module Truffler
|
|
|
46
56
|
|
|
47
57
|
def relation(scope, limit: nil)
|
|
48
58
|
relation = candidates(scope)
|
|
59
|
+
relation = relation.joins(Arel.sql(label_scores_join_sql)) if grouped_label_scores?
|
|
49
60
|
relation = relation.select(Arel.sql("#{table}.*")) if relation.select_values.empty?
|
|
50
61
|
relation = relation.select(*score_columns.map { |name, sql| Arel.sql("(#{sql}) AS #{name}") })
|
|
51
62
|
relation = relation.reorder(*ordering)
|
|
52
63
|
limit ? relation.limit(limit) : relation
|
|
53
64
|
end
|
|
54
65
|
|
|
66
|
+
# The label term: a per-candidate subquery when sources narrow the
|
|
67
|
+
# candidates, else the grouped join's score.
|
|
55
68
|
def label_score_sql
|
|
56
|
-
|
|
57
|
-
return if
|
|
69
|
+
return if encoding.intent_vector.empty?
|
|
70
|
+
return "COALESCE(#{connection.quote_table_name(LABEL_SCORES)}.score, 0.0)" if grouped_label_scores?
|
|
58
71
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"WHERE #{label_scope_sql} AND #{label_column('label_key')} IN (#{intent.keys.map { |key| quote(key) }.join(', ')})), 0.0)"
|
|
72
|
+
"COALESCE((SELECT SUM(#{label_case_sql}) FROM #{quoted_labels} " \
|
|
73
|
+
"WHERE #{label_scope_sql} AND #{label_keys_sql}), 0.0)"
|
|
62
74
|
end
|
|
63
75
|
|
|
64
76
|
def sources
|
|
@@ -67,7 +79,23 @@ module Truffler
|
|
|
67
79
|
end
|
|
68
80
|
|
|
69
81
|
def keywords
|
|
70
|
-
encoding.keywords(query)
|
|
82
|
+
@keywords ||= encoding.keywords(query, keep: -> { Filler.label_words(definition, tenant_key) })
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Whether any record in the tenant carries `key` at or above `threshold`.
|
|
86
|
+
def label_present_sql(key, threshold)
|
|
87
|
+
tenant = definition.scoped? ? " AND #{label_column('tenant_key')} = #{quote(tenant_key)}" : ""
|
|
88
|
+
"EXISTS (SELECT 1 FROM #{quoted_labels} WHERE #{label_column('record_type')} = #{quote(model.polymorphic_name)}#{tenant} " \
|
|
89
|
+
"AND #{label_column('label_key')} = #{quote(key)} AND #{label_column('value')} >= #{Float(threshold)})"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def score_column_names
|
|
93
|
+
score_columns.keys
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Every record past the tenant, time, and filters is a candidate.
|
|
97
|
+
def every_base_record?
|
|
98
|
+
label_only? || encoding.filters.any?
|
|
71
99
|
end
|
|
72
100
|
|
|
73
101
|
private
|
|
@@ -115,6 +143,41 @@ module Truffler
|
|
|
115
143
|
range.to ? scope.where(arrived_at.lt(range.to)) : scope
|
|
116
144
|
end
|
|
117
145
|
|
|
146
|
+
def label_case_sql
|
|
147
|
+
cases = encoding.intent_vector.map { |key, weight| "WHEN #{quote(key)} THEN #{Float(weight)} * #{label_column('value')}" }
|
|
148
|
+
"CASE #{label_column('label_key')} #{cases.join(' ')} ELSE 0.0 END"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def label_keys_sql
|
|
152
|
+
"#{label_column('label_key')} IN (#{encoding.intent_vector.keys.map { |key| quote(key) }.join(', ')})"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def grouped_label_scores?
|
|
156
|
+
encoding.intent_vector.any? && every_base_record?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# One aggregate over the tenant's rows for the intent's keys, which
|
|
160
|
+
# `index_truffler_labels_for_search` (with INCLUDE (record_id)) serves
|
|
161
|
+
# as an index-only scan.
|
|
162
|
+
def label_scores_join_sql
|
|
163
|
+
tenant = definition.scoped? ? " AND #{label_column('tenant_key')} = #{quote(tenant_key)}" : ""
|
|
164
|
+
scores = connection.quote_table_name(LABEL_SCORES)
|
|
165
|
+
"LEFT JOIN (SELECT #{label_column('record_id')} AS record_id, SUM(#{label_case_sql}) AS score FROM #{quoted_labels} " \
|
|
166
|
+
"WHERE #{label_column('record_type')} = #{quote(model.polymorphic_name)}#{tenant} AND #{label_keys_sql} " \
|
|
167
|
+
"GROUP BY #{label_column('record_id')}) #{scores} ON #{scores}.record_id = #{primary_key}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def neighbors_sql
|
|
171
|
+
return @neighbors_sql if defined?(@neighbors_sql)
|
|
172
|
+
|
|
173
|
+
@neighbors_sql = (store.neighbors_sql(model, tenant_key: tenant_key, vector: vector) if vector && store.respond_to?(:neighbors_sql))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def neighbors_join_sql
|
|
177
|
+
neighbors = connection.quote_table_name(NEIGHBORS)
|
|
178
|
+
"LEFT JOIN (#{neighbors_sql}) #{neighbors} ON #{neighbors}.record_id = #{primary_key}"
|
|
179
|
+
end
|
|
180
|
+
|
|
118
181
|
def label_filter_sql(key, threshold)
|
|
119
182
|
"EXISTS (SELECT 1 FROM #{quoted_labels} WHERE #{label_scope_sql} AND #{label_column('label_key')} = #{quote(key)} " \
|
|
120
183
|
"AND #{label_column('value')} >= #{Float(threshold)})"
|
|
@@ -171,7 +234,10 @@ module Truffler
|
|
|
171
234
|
def text_score_sql
|
|
172
235
|
return @text_score_sql if defined?(@text_score_sql)
|
|
173
236
|
|
|
174
|
-
@text_score_sql =
|
|
237
|
+
@text_score_sql =
|
|
238
|
+
if neighbors_sql then "COALESCE(#{connection.quote_table_name(NEIGHBORS)}.similarity, 0.0)"
|
|
239
|
+
elsif vector && store then store.similarity_sql(model, tenant_key: tenant_key, vector: vector).to_s
|
|
240
|
+
end
|
|
175
241
|
end
|
|
176
242
|
|
|
177
243
|
def text_candidate_sql
|
|
@@ -186,9 +252,14 @@ module Truffler
|
|
|
186
252
|
definition.scoped? ? model.where(definition.tenant_column => tenant_key) : model.all
|
|
187
253
|
end
|
|
188
254
|
|
|
255
|
+
# An id list is the fast path. A relation runs once as an array on
|
|
256
|
+
# Postgres, where `IN (subquery)` becomes a hashed filter over every
|
|
257
|
+
# tenant row.
|
|
189
258
|
def membership_sql(result)
|
|
190
259
|
case result
|
|
191
|
-
when ActiveRecord::Relation
|
|
260
|
+
when ActiveRecord::Relation
|
|
261
|
+
subquery = result.reselect(result.klass.arel_table[result.klass.primary_key]).to_sql
|
|
262
|
+
connection.adapter_name.match?(/postg/i) ? "#{primary_key} = ANY(ARRAY(#{subquery}))" : "#{primary_key} IN (#{subquery})"
|
|
192
263
|
when nil then nil
|
|
193
264
|
else
|
|
194
265
|
ids = Array(result)
|
|
@@ -21,23 +21,26 @@ module Truffler
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def call(run)
|
|
24
|
-
|
|
24
|
+
Current.scope do
|
|
25
|
+
return unless run.status == :pending && run.model.try(:truffler_definition)
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
start_provider(run)
|
|
28
|
+
decision = @budget.admit(priority: :rerank, user_key: run.user_key)
|
|
29
|
+
if decision.denied?
|
|
30
|
+
run.pause!(decision.reason)
|
|
31
|
+
return
|
|
32
|
+
end
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
encoding = await_encoding(run)
|
|
35
|
+
candidate_ids, encoding, relaxed = filter(run, encoding)
|
|
36
|
+
return if run.cancelled?
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
run.plan!(candidate_ids: candidate_ids, chunk_size: @config.rerank_chunk_size, filters: encoding&.filters&.keys.to_a,
|
|
39
|
+
relaxed_labels: relaxed)
|
|
40
|
+
run.chunk_count.times { |index| @enqueue.call(run, index) }
|
|
41
|
+
run.ping(SMART) if candidate_ids.empty?
|
|
42
|
+
candidate_ids
|
|
43
|
+
end
|
|
41
44
|
end
|
|
42
45
|
|
|
43
46
|
private
|
|
@@ -64,24 +67,33 @@ module Truffler
|
|
|
64
67
|
if @encodings.encoded?(key) then @encodings.read_encoding(key, query)
|
|
65
68
|
elsif @encodings.in_flight?(key) then @encoder.await(key, deadline: @deadline, query: query)
|
|
66
69
|
end
|
|
67
|
-
encoding&.without(run.suppressed)
|
|
70
|
+
encoding&.without(run.suppressed, keep_words: -> { Search::Filler.label_words(model.truffler_definition, run.tenant_key) })
|
|
68
71
|
end
|
|
69
72
|
|
|
70
|
-
#
|
|
71
|
-
# the
|
|
72
|
-
# one), capped at
|
|
73
|
+
# `[candidate_ids, encoding, relaxed_labels]`: the snapshot narrowed to
|
|
74
|
+
# what the encoding allows, in the tenant, in the keystroke ranking the
|
|
75
|
+
# encoding gives (or snapshot order without one), capped at
|
|
76
|
+
# `rerank_depth`. When the filters leave nothing, they relax as on the
|
|
77
|
+
# keystroke (Search::Relaxation) rather than rerank an empty set.
|
|
73
78
|
def filter(run, encoding)
|
|
74
79
|
model = run.model
|
|
75
80
|
pool = model.where(model.primary_key => run.pool_ids)
|
|
76
|
-
sql = Search::Sql.new(model, tenant_key: run.tenant_key, query: run.search_query, encoding:
|
|
77
|
-
allowed = sql.base(pool).pluck(model.primary_key)
|
|
81
|
+
sql = ->(for_encoding) { Search::Sql.new(model, tenant_key: run.tenant_key, query: run.search_query, encoding: for_encoding) }
|
|
78
82
|
ids = if encoding.nil? || encoding.empty?
|
|
79
|
-
run.pool_ids &
|
|
83
|
+
run.pool_ids & sql.call(encoding).base(pool).pluck(model.primary_key)
|
|
80
84
|
else
|
|
81
|
-
ranked
|
|
82
|
-
ranked + ((run.pool_ids & allowed) - ranked)
|
|
85
|
+
ranked(run, pool, sql.call(encoding).relation(pool).map(&:id), sql.call(encoding))
|
|
83
86
|
end
|
|
84
|
-
|
|
87
|
+
relaxed = (Search::Relaxation.new(model, encoding, sql: sql).call(pool) if ids.empty? && encoding&.filters&.any?)
|
|
88
|
+
if relaxed
|
|
89
|
+
encoding = relaxed.encoding
|
|
90
|
+
ids = ranked(run, pool, relaxed.records.map(&:id), sql.call(encoding))
|
|
91
|
+
end
|
|
92
|
+
[ ids.first(@config.rerank_depth), encoding, relaxed&.relaxed_labels.to_a ]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ranked(run, pool, ranked_ids, sql)
|
|
96
|
+
ranked_ids + ((run.pool_ids & sql.base(pool).pluck(run.model.primary_key)) - ranked_ids)
|
|
85
97
|
end
|
|
86
98
|
end
|
|
87
99
|
end
|
|
@@ -24,12 +24,14 @@ module Truffler
|
|
|
24
24
|
# Scores chunk `index` of the run and appends it to the buckets.
|
|
25
25
|
# Returns :done, :cancelled, :paused, :failed, or :skipped.
|
|
26
26
|
def call(run, index)
|
|
27
|
-
|
|
27
|
+
Current.scope do
|
|
28
|
+
return :skipped unless run.active? && run.chunk_ids(index) && !run.chunk_resolved?(index)
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
started = Instrumentation.monotonic_ms
|
|
31
|
+
outcome = rerank(run, index)
|
|
32
|
+
instrument(run, index, outcome, started)
|
|
33
|
+
outcome
|
|
34
|
+
end
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
def request(run, records)
|
|
@@ -145,6 +145,12 @@ module Truffler
|
|
|
145
145
|
Array(plan&.fetch("filters", nil))
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
+
# Filters the awaited encoding applied that were relaxed because they
|
|
149
|
+
# left no candidate (see Search::Relaxation).
|
|
150
|
+
def relaxed_labels
|
|
151
|
+
Array(plan&.fetch("relaxed_labels", nil))
|
|
152
|
+
end
|
|
153
|
+
|
|
148
154
|
def chunk(index)
|
|
149
155
|
store.read(id, "chunk/#{index}") unless expired?
|
|
150
156
|
end
|
|
@@ -212,9 +218,10 @@ module Truffler
|
|
|
212
218
|
true
|
|
213
219
|
end
|
|
214
220
|
|
|
215
|
-
def plan!(candidate_ids:, chunk_size:, filters:)
|
|
221
|
+
def plan!(candidate_ids:, chunk_size:, filters:, relaxed_labels: [])
|
|
216
222
|
store.write(id, "plan", { "candidate_ids" => candidate_ids, "chunks" => candidate_ids.each_slice(chunk_size).to_a,
|
|
217
|
-
"filters" => filters, "
|
|
223
|
+
"filters" => filters, "relaxed_labels" => relaxed_labels,
|
|
224
|
+
"thresholds" => Truffler.config.smart_thresholds.transform_keys(&:to_s) })
|
|
218
225
|
end
|
|
219
226
|
|
|
220
227
|
# Appends one chunk's `[[id, score], ...]`, sorted by score within the
|
|
@@ -281,7 +288,7 @@ module Truffler
|
|
|
281
288
|
explicit_action: explicit_action, buckets: found, pending: BUCKETS.index_with { active? },
|
|
282
289
|
collapsed: collapsed_by_default, no_strong_matches: current == :complete && found[:strong].empty?,
|
|
283
290
|
promoted_ids: (found[:strong] + found[:possible]).map { |entry| entry[:id] }, applied_filters: applied_filters,
|
|
284
|
-
sections: { provider: provider_section.to_h.symbolize_keys } }
|
|
291
|
+
relaxed_labels: relaxed_labels, sections: { provider: provider_section.to_h.symbolize_keys } }
|
|
285
292
|
end
|
|
286
293
|
|
|
287
294
|
def as_json(*)
|
|
@@ -26,17 +26,19 @@ module Truffler
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def call
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
29
|
+
Current.scope do
|
|
30
|
+
result = keystroke.call
|
|
31
|
+
local_ids = result.ids
|
|
32
|
+
run = Run.create(@model, query: @query.raw.strip, tenant_key: keystroke.tenant_key, user_key: keystroke.user_key,
|
|
33
|
+
surface: keystroke.surface, suppressed: @suppressed, pool_ids: pool(result, local_ids), local_ids: local_ids,
|
|
34
|
+
local_weak: result.local_weak?, explicit_action: result.explicit_action, store: @store)
|
|
35
|
+
previous = @store.supersede(run.record_type, run.tenant_key, run.user_key, run.surface, run.id)
|
|
36
|
+
Run.load(previous, store: @store).cancel! if previous
|
|
37
|
+
Instrumentation.instrument(:smart_search, run_id: run.id, record_type: run.record_type, tenant_key: run.tenant_key,
|
|
38
|
+
surface: run.surface, candidate_count: run.pool_ids.size, local_count: local_ids.size)
|
|
39
|
+
@dispatch.call(run)
|
|
40
|
+
run
|
|
41
|
+
end
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
private
|
data/lib/truffler/version.rb
CHANGED
data/lib/truffler/vocabulary.rb
CHANGED
|
@@ -39,6 +39,16 @@ module Truffler
|
|
|
39
39
|
Canonical.digest(fingerprints(tenant_key: tenant_key, user_key: user_key, all_users: all_users))
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# The version backfill spend ledgers are keyed by: the fingerprints of the
|
|
43
|
+
# labels Jev is asked only. Supplied labels cost nothing, so changing one
|
|
44
|
+
# (an option added to a `from:` choice included) never starts a new
|
|
45
|
+
# ledger. Without supplied labels it equals `version`, so ledger rows
|
|
46
|
+
# written before 0.1.6 keep their key.
|
|
47
|
+
def ledger_version(tenant_key: nil, user_key: nil, all_users: false)
|
|
48
|
+
asked = labels_for(tenant_key: tenant_key, user_key: user_key, all_users: all_users).reject { |_, label| label.supplied? }
|
|
49
|
+
Canonical.digest(asked.transform_values { |label| fingerprint_of(label, tenant_key) })
|
|
50
|
+
end
|
|
51
|
+
|
|
42
52
|
# The version query encodings are cached under: the labeling version plus
|
|
43
53
|
# a digest of the wording only query encoding reads (label descriptions,
|
|
44
54
|
# option descriptions and search texts), which labeling never sees for
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: truffler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kieran Klaassen
|
|
@@ -202,6 +202,8 @@ files:
|
|
|
202
202
|
- lib/generators/truffler/install/templates/initializer.rb.tt
|
|
203
203
|
- lib/generators/truffler/install/templates/migration.rb.tt
|
|
204
204
|
- lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt
|
|
205
|
+
- lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt
|
|
206
|
+
- lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt
|
|
205
207
|
- lib/generators/truffler/upgrade/upgrade_generator.rb
|
|
206
208
|
- lib/tasks/truffler.rake
|
|
207
209
|
- lib/tasks/truffler/bench.rake
|
|
@@ -224,9 +226,11 @@ files:
|
|
|
224
226
|
- lib/truffler/clients/base.rb
|
|
225
227
|
- lib/truffler/clients/callable.rb
|
|
226
228
|
- lib/truffler/clients/cassette.rb
|
|
229
|
+
- lib/truffler/clients/evaluator.rb
|
|
227
230
|
- lib/truffler/clients/fake.rb
|
|
228
231
|
- lib/truffler/clients/ruby_llm_typesafe.rb
|
|
229
232
|
- lib/truffler/configuration.rb
|
|
233
|
+
- lib/truffler/current.rb
|
|
230
234
|
- lib/truffler/definition.rb
|
|
231
235
|
- lib/truffler/embeddings.rb
|
|
232
236
|
- lib/truffler/embeddings/backfill.rb
|
|
@@ -286,6 +290,7 @@ files:
|
|
|
286
290
|
- lib/truffler/query_encoding/cache.rb
|
|
287
291
|
- lib/truffler/query_encoding/encoder.rb
|
|
288
292
|
- lib/truffler/query_encoding/prefetch.rb
|
|
293
|
+
- lib/truffler/query_encoding/present_options.rb
|
|
289
294
|
- lib/truffler/questions.rb
|
|
290
295
|
- lib/truffler/railtie.rb
|
|
291
296
|
- lib/truffler/records/backfill_spend.rb
|
|
@@ -300,6 +305,7 @@ files:
|
|
|
300
305
|
- lib/truffler/search/filler.rb
|
|
301
306
|
- lib/truffler/search/keystroke.rb
|
|
302
307
|
- lib/truffler/search/query.rb
|
|
308
|
+
- lib/truffler/search/relaxation.rb
|
|
303
309
|
- lib/truffler/search/result.rb
|
|
304
310
|
- lib/truffler/search/sql.rb
|
|
305
311
|
- lib/truffler/search/time_phrase.rb
|