truffler 0.1.5 → 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 +12 -0
- data/README.md +9 -5
- data/lib/generators/truffler/install/templates/migration.rb.tt +11 -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 +3 -3
- data/lib/truffler/configuration.rb +3 -0
- data/lib/truffler/current.rb +30 -0
- data/lib/truffler/label_definition.rb +4 -1
- data/lib/truffler/labeling/backfill.rb +23 -8
- data/lib/truffler/labeling/labeler.rb +39 -37
- data/lib/truffler/query_encoding/encoder.rb +65 -43
- data/lib/truffler/query_encoding/present_options.rb +54 -0
- data/lib/truffler/records/backfill_spend.rb +32 -2
- data/lib/truffler/search/encoding.rb +29 -4
- data/lib/truffler/search/encoding_cache.rb +12 -1
- data/lib/truffler/search/keystroke.rb +35 -18
- data/lib/truffler/search/relaxation.rb +95 -0
- data/lib/truffler/search/result.rb +11 -5
- data/lib/truffler/search/sql.rb +16 -5
- data/lib/truffler/smart_search/dispatcher.rb +35 -23
- 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 +4 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module QueryEncoding
|
|
3
|
+
# With `config.skip_empty_options`, the choice options a tenant actually
|
|
4
|
+
# has: storage keys with at least one label row at or above
|
|
5
|
+
# `config.choice_min_probability` (above 0.0 when that is nil). Query
|
|
6
|
+
# encoding offers Jev only these, so it cannot pick "Source: email" in a
|
|
7
|
+
# tenant with no email sources.
|
|
8
|
+
#
|
|
9
|
+
# The set is digested into the encoding cache key, so encodings refresh
|
|
10
|
+
# when an option appears. It is read through the cache store for TTL, so
|
|
11
|
+
# a keystroke stays one SELECT; a new option reaches query encoding (and
|
|
12
|
+
# the cache key) within TTL.
|
|
13
|
+
class PresentOptions
|
|
14
|
+
TTL = 5.minutes
|
|
15
|
+
|
|
16
|
+
def initialize(store: Truffler.config.cache_store)
|
|
17
|
+
@store = store
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.enabled?
|
|
21
|
+
Truffler.config.skip_empty_options == true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The present storage keys among `labels`' choice options, or nil when
|
|
25
|
+
# skip_empty_options is off.
|
|
26
|
+
def keys(model, labels, tenant_key:)
|
|
27
|
+
return unless self.class.enabled?
|
|
28
|
+
|
|
29
|
+
candidates = labels.values.select { |label| label.type == :choice }.flat_map { |label| label.storage_keys(tenant_key) }.sort
|
|
30
|
+
return Set.new if candidates.empty?
|
|
31
|
+
|
|
32
|
+
min = Truffler.config.choice_min_probability
|
|
33
|
+
cache_key = "truffler/present_options/#{Canonical.digest(record_type: model.polymorphic_name, tenant_key: tenant_key&.to_s,
|
|
34
|
+
keys: candidates, min: min)}"
|
|
35
|
+
Set.new(@store.fetch(cache_key, expires_in: TTL) { present(model, candidates, tenant_key, min) })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A label's options narrowed to `present` (all of them when nil).
|
|
39
|
+
def self.options(label, tenant_key, present)
|
|
40
|
+
options = label.options(tenant_key)
|
|
41
|
+
present ? options.select { |option, _| present.include?("#{label.key}:#{option}") } : options
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def present(model, candidates, tenant_key, min)
|
|
47
|
+
rows = Records::Label.where(record_type: model.polymorphic_name, label_key: candidates)
|
|
48
|
+
rows = rows.where(tenant_key: tenant_key.to_s) if model.truffler_definition.scoped?
|
|
49
|
+
rows = min ? rows.where(value: min..) : rows.where(Records::Label.arel_table[:value].gt(0.0))
|
|
50
|
+
rows.distinct.pluck(:label_key).sort
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -6,6 +6,7 @@ module Truffler
|
|
|
6
6
|
# settled in SQL, never read, added to, and written back.
|
|
7
7
|
class BackfillSpend < ActiveRecord::Base
|
|
8
8
|
self.table_name = "truffler_backfill_spends"
|
|
9
|
+
TENANT_KEY_RECHECK = 1.minute
|
|
9
10
|
|
|
10
11
|
scope :for_model, ->(model) { where(record_type: model.polymorphic_name) }
|
|
11
12
|
|
|
@@ -23,20 +24,49 @@ module Truffler
|
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
# Before `rails g truffler:upgrade` adds tenant_key, every tenant
|
|
26
|
-
# shares the app-wide row.
|
|
27
|
-
|
|
27
|
+
# shares the app-wide row. Before 0.1.6 rows were keyed by the whole
|
|
28
|
+
# vocabulary version; pass it as `legacy_version:` and the first lookup
|
|
29
|
+
# takes that row over instead of starting from zero.
|
|
30
|
+
def self.ledger(model, version, tenant_key: nil, legacy_version: nil)
|
|
28
31
|
attributes = { record_type: model.polymorphic_name, vocabulary_version: version }
|
|
29
32
|
attributes[:tenant_key] = tenant_key if tenant_ledgers?
|
|
33
|
+
adopt(attributes, legacy_version) if legacy_version && legacy_version != version
|
|
30
34
|
create_or_find_by!(attributes)
|
|
31
35
|
end
|
|
32
36
|
|
|
37
|
+
def self.adopt(attributes, legacy_version)
|
|
38
|
+
return if exists?(attributes)
|
|
39
|
+
|
|
40
|
+
where(attributes.merge(vocabulary_version: legacy_version)).update_all(vocabulary_version: attributes[:vocabulary_version])
|
|
41
|
+
rescue ActiveRecord::RecordNotUnique
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
private_class_method :adopt
|
|
45
|
+
|
|
46
|
+
# A worker booted before `db:migrate` added tenant_key has the old
|
|
47
|
+
# columns cached, so a miss reloads them at most once per
|
|
48
|
+
# TENANT_KEY_RECHECK and the worker moves to tenant ledgers without a
|
|
49
|
+
# restart.
|
|
33
50
|
def self.tenant_ledgers?
|
|
34
51
|
return true if column_names.include?("tenant_key")
|
|
35
52
|
|
|
53
|
+
if recheck_tenant_key?
|
|
54
|
+
reset_column_information
|
|
55
|
+
return true if column_names.include?("tenant_key")
|
|
56
|
+
end
|
|
36
57
|
warn_missing_tenant_key
|
|
37
58
|
false
|
|
38
59
|
end
|
|
39
60
|
|
|
61
|
+
def self.recheck_tenant_key?
|
|
62
|
+
now = Time.current
|
|
63
|
+
return false if @tenant_key_checked_at && now - @tenant_key_checked_at < TENANT_KEY_RECHECK
|
|
64
|
+
|
|
65
|
+
@tenant_key_checked_at = now
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
private_class_method :recheck_tenant_key?
|
|
69
|
+
|
|
40
70
|
def self.warn_missing_tenant_key
|
|
41
71
|
return if @missing_tenant_warned
|
|
42
72
|
|
|
@@ -65,15 +65,30 @@ module Truffler
|
|
|
65
65
|
keep = ->(key, _) { !suppressed.include?(key) && !suppressed.include?(self.class.split_key(key).first) }
|
|
66
66
|
kept = { filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep) }
|
|
67
67
|
applied = kept.values.flat_map(&:keys).to_set
|
|
68
|
-
freed =
|
|
68
|
+
freed = freed_words(applied)
|
|
69
69
|
kept_time = (time unless suppressed.include?(TimeRange.key))
|
|
70
70
|
keywords = keyword_tokens && (keyword_tokens + freed).uniq
|
|
71
71
|
if keywords && applied.empty? && kept_time.nil?
|
|
72
72
|
keywords = Filler.keywords(keywords + filler_tokens, anchored: false, keep: keep_words.respond_to?(:call) ? keep_words.call : keep_words)
|
|
73
73
|
end
|
|
74
|
-
with(**kept, time: kept_time,
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
with(**kept, time: kept_time, **free(freed, keywords))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The encoding with the filters `keys` demoted to soft boosts (zero-result
|
|
78
|
+
# relaxation, see Relaxation): they stay in the intent vector, at their
|
|
79
|
+
# intent weight or DEFAULT_BOOST when filtering gave them none, so records
|
|
80
|
+
# that match still rank first, but nothing requires them. A label term
|
|
81
|
+
# whose every applied source was relaxed becomes a keyword again, as in
|
|
82
|
+
# `without`.
|
|
83
|
+
def relax(keys)
|
|
84
|
+
relaxed = filters.slice(*Array(keys).map(&:to_s))
|
|
85
|
+
return self if relaxed.empty?
|
|
86
|
+
|
|
87
|
+
soft = relaxed.to_h { |key, _| [ key, intent_vector[key] || boosts[key] || QueryEncoding::DEFAULT_BOOST ] }
|
|
88
|
+
kept_filters = filters.except(*relaxed.keys)
|
|
89
|
+
freed = freed_words((kept_filters.keys + boosts.keys + intent_vector.keys).to_set - relaxed.keys)
|
|
90
|
+
with(filters: kept_filters, boosts: boosts.merge(soft), intent_vector: intent_vector.merge(soft),
|
|
91
|
+
**free(freed, keyword_tokens && (keyword_tokens + freed).uniq))
|
|
77
92
|
end
|
|
78
93
|
|
|
79
94
|
# Splits a storage key into its label key and choice option. Lens keys
|
|
@@ -113,6 +128,16 @@ module Truffler
|
|
|
113
128
|
|
|
114
129
|
private
|
|
115
130
|
|
|
131
|
+
# Label terms none of whose source keys is still applied.
|
|
132
|
+
def freed_words(applied)
|
|
133
|
+
label_term_sources.select { |_, keys| keys.any? && keys.none? { |key| applied.include?(key) } }.keys
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def free(freed, keywords)
|
|
137
|
+
{ label_term_tokens: label_term_tokens - freed, label_term_sources: label_term_sources.except(*freed),
|
|
138
|
+
soft_keyword_tokens: soft_keyword_tokens - freed, keyword_tokens: keywords }
|
|
139
|
+
end
|
|
140
|
+
|
|
116
141
|
def weights(hash)
|
|
117
142
|
hash.to_h.to_h { |key, weight| [ key.to_s, Float(weight) ] }
|
|
118
143
|
end
|
|
@@ -62,7 +62,18 @@ module Truffler
|
|
|
62
62
|
definition = model.truffler_definition
|
|
63
63
|
tenant = tenant_key&.to_s if definition.per_tenant_vocabulary?
|
|
64
64
|
version = definition.vocabulary.encoding_version(tenant_key: tenant_key&.to_s, user_key: user_key)
|
|
65
|
-
|
|
65
|
+
parts = { record_type: model.polymorphic_name, query: query.normalized, vocabulary_version: version, tenant_key: tenant }
|
|
66
|
+
present = present_options(model, tenant_key, user_key)
|
|
67
|
+
Canonical.digest(present ? parts.merge(present_options: Canonical.digest(present.to_a.sort)) : parts)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# With skip_empty_options, the tenant's present choice options (see
|
|
71
|
+
# QueryEncoding::PresentOptions), so an encoding refreshes when one appears.
|
|
72
|
+
def present_options(model, tenant_key, user_key)
|
|
73
|
+
return unless QueryEncoding::PresentOptions.enabled?
|
|
74
|
+
|
|
75
|
+
labels = model.truffler_definition.vocabulary.labels_for(tenant_key: tenant_key&.to_s, user_key: user_key)
|
|
76
|
+
QueryEncoding::PresentOptions.new.keys(model, labels, tenant_key: tenant_key&.to_s)
|
|
66
77
|
end
|
|
67
78
|
end
|
|
68
79
|
end
|
|
@@ -36,26 +36,36 @@ module Truffler
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def call
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
39
|
+
Current.scope do
|
|
40
|
+
started = Instrumentation.monotonic_ms
|
|
41
|
+
watermark = Time.current
|
|
42
|
+
explicit_action = surface_action
|
|
43
|
+
cached = read_encoding
|
|
44
|
+
status = encoding_status(cached)
|
|
45
|
+
encoding = visible_lenses_only(with_time(cached)&.without(suppressed, keep_words: label_words), record_usage: true)
|
|
46
|
+
sql = sql(encoding)
|
|
47
|
+
records = sql.relation(scope, limit: limit).to_a
|
|
48
|
+
relaxed = relax(encoding, records)
|
|
49
|
+
records, encoding, sql = relaxed.records, relaxed.encoding, sql(relaxed.encoding) if relaxed
|
|
50
|
+
relaxed_labels = relaxed&.relaxed_labels.to_a
|
|
51
|
+
result = Result.new(records: records, query: query, encoding: encoding, encoding_status: status, watermark: watermark,
|
|
52
|
+
explicit_action: explicit_action, sources: sql.sources, invite_row: invite_row(records, cached, status),
|
|
53
|
+
local_weak: local_weak?(records, cached), weights: @weights, relaxed_labels: relaxed_labels,
|
|
54
|
+
recount: ->(since) { count(since: since, relaxed: relaxed_labels) })
|
|
55
|
+
instrument(result, started)
|
|
56
|
+
result
|
|
57
|
+
end
|
|
52
58
|
end
|
|
53
59
|
|
|
54
60
|
# How many records the same search would return that arrived after
|
|
55
|
-
# `since` (R25)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
# `since` (R25), with the filters `relaxed` demoted as the search did.
|
|
62
|
+
# Reads the cache only and never prefetches.
|
|
63
|
+
def count(since:, relaxed: [])
|
|
64
|
+
Current.scope do
|
|
65
|
+
encoding = visible_lenses_only(with_time(read_encoding)&.without(suppressed, keep_words: label_words))
|
|
66
|
+
sql(relaxed.any? ? encoding&.relax(relaxed) : encoding).candidates(scope)
|
|
67
|
+
.where(model.arel_table[@definition.arrived_at_column].gt(since)).count
|
|
68
|
+
end
|
|
59
69
|
end
|
|
60
70
|
|
|
61
71
|
private
|
|
@@ -138,6 +148,13 @@ module Truffler
|
|
|
138
148
|
Sql.new(model, tenant_key: tenant_key, query: query, encoding: encoding, vector: read_vector, weights: @weights)
|
|
139
149
|
end
|
|
140
150
|
|
|
151
|
+
# Only an empty result under filters pays for relaxation.
|
|
152
|
+
def relax(encoding, records)
|
|
153
|
+
return unless records.empty? && encoding&.filters&.any?
|
|
154
|
+
|
|
155
|
+
Relaxation.new(model, encoding, sql: method(:sql)).call(scope, limit: limit)
|
|
156
|
+
end
|
|
157
|
+
|
|
141
158
|
# R21: the Smart search row. A query whose encoding is not cached yet
|
|
142
159
|
# invites the action even when a blind index or keyword matched,
|
|
143
160
|
# because a first-time intent query resolves on the action (AE10): on a
|
|
@@ -163,7 +180,7 @@ module Truffler
|
|
|
163
180
|
|
|
164
181
|
def instrument(result, started)
|
|
165
182
|
payload = { record_type: model.polymorphic_name, tenant_key: tenant_key, surface: surface, outcome: result.encoding_status,
|
|
166
|
-
result_count: result.records.size, filter_count: result.encoding&.filters&.size.to_i,
|
|
183
|
+
result_count: result.records.size, filter_count: result.encoding&.filters&.size.to_i, relaxed_count: result.relaxed_labels.size,
|
|
167
184
|
boost_count: result.encoding&.intent_vector&.size.to_i, sources: result.sources.map(&:to_s),
|
|
168
185
|
reason: result.invite_row&.dig(:reason), latency_ms: Instrumentation.elapsed_ms(started) }
|
|
169
186
|
payload[:query_digest] = Misses.digest(:query, query.normalized) if Misses.encrypted_model?(model)
|
|
@@ -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,11 +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
15
|
def initialize(records:, query:, encoding:, encoding_status:, watermark:, explicit_action:, sources:, invite_row:, weights:, recount:,
|
|
16
|
-
local_weak: nil)
|
|
16
|
+
local_weak: nil, relaxed_labels: [])
|
|
17
17
|
@local_weak = local_weak
|
|
18
|
+
@relaxed_labels = relaxed_labels
|
|
18
19
|
@records = records
|
|
19
20
|
@query = query
|
|
20
21
|
@encoding = encoding
|
|
@@ -39,12 +40,17 @@ module Truffler
|
|
|
39
40
|
@local_weak.nil? ? invite_row.present? : @local_weak
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
# Applied filters, then boosts, then the time range,
|
|
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".
|
|
43
48
|
def chips
|
|
44
49
|
return [] unless encoding
|
|
45
50
|
|
|
46
|
-
filters = encoding.filters.keys.map { |key| chip(key, :filter) }
|
|
47
|
-
|
|
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) }
|
|
48
54
|
time = encoding.time
|
|
49
55
|
time ? chips + [ { key: TimeRange.key, label: TimeRange.key, kind: :time, name: time.name } ] : chips
|
|
50
56
|
end
|
data/lib/truffler/search/sql.rb
CHANGED
|
@@ -82,6 +82,22 @@ module Truffler
|
|
|
82
82
|
@keywords ||= encoding.keywords(query, keep: -> { Filler.label_words(definition, tenant_key) })
|
|
83
83
|
end
|
|
84
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?
|
|
99
|
+
end
|
|
100
|
+
|
|
85
101
|
private
|
|
86
102
|
|
|
87
103
|
def definition
|
|
@@ -136,11 +152,6 @@ module Truffler
|
|
|
136
152
|
"#{label_column('label_key')} IN (#{encoding.intent_vector.keys.map { |key| quote(key) }.join(', ')})"
|
|
137
153
|
end
|
|
138
154
|
|
|
139
|
-
# Every record past the tenant, time, and filters is a candidate.
|
|
140
|
-
def every_base_record?
|
|
141
|
-
label_only? || encoding.filters.any?
|
|
142
|
-
end
|
|
143
|
-
|
|
144
155
|
def grouped_label_scores?
|
|
145
156
|
encoding.intent_vector.any? && every_base_record?
|
|
146
157
|
end
|
|
@@ -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
|
|
@@ -67,21 +70,30 @@ module Truffler
|
|
|
67
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
|