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
|
@@ -51,50 +51,55 @@ module Truffler
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def request(model, query, tenant_key:, user_key: nil)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
questions
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
Current.scope do
|
|
55
|
+
present = present_options(model, tenant_key, user_key)
|
|
56
|
+
labels = labels(model, tenant_key, user_key, present)
|
|
57
|
+
questions = Questions.new
|
|
58
|
+
labels.each_value do |label|
|
|
59
|
+
questions.choice(:"intent__#{label.question_key}", instructions: intent_instructions(label), criteria: INTENTS)
|
|
60
|
+
end
|
|
61
|
+
labels.each_value do |label|
|
|
62
|
+
next unless label.type == :choice
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
options = PresentOptions.options(label, tenant_key, present).merge(NO_OPTION => "The query names none of these")
|
|
65
|
+
questions.choice(:"option__#{label.question_key}", instructions: %(Which "#{label.key}" option does the search query ask about?),
|
|
66
|
+
criteria: options)
|
|
67
|
+
end
|
|
66
68
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
words = query.tokens.each_with_index.reject do |token, position|
|
|
70
|
+
query.exact_tokens.include?(token) || query.time_position?(position)
|
|
71
|
+
end
|
|
72
|
+
asked = words.first(MAX_TOKEN_QUESTIONS)
|
|
73
|
+
token_ids = asked.to_h do |_token, position|
|
|
74
|
+
id = :"token__#{position}"
|
|
75
|
+
questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]? The labels it may name, ) +
|
|
76
|
+
%(with their options, are in `labels`.), criteria: TOKEN_ROLES)
|
|
77
|
+
[ position, id.to_s ]
|
|
78
|
+
end
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
state = { "query" => query.normalized, "tokens" => query.tokens, "labels" => vocabulary_state(labels, tenant_key, present) }
|
|
81
|
+
Request.new(state: state, questions: questions.to_h, token_ids: token_ids, exact_tokens: query.exact_tokens,
|
|
82
|
+
unasked_tokens: words.drop(MAX_TOKEN_QUESTIONS).map(&:first))
|
|
83
|
+
end
|
|
81
84
|
end
|
|
82
85
|
|
|
83
86
|
# Encodes the query pending under `cache_key`. Returns the encoding, or
|
|
84
87
|
# nil when the payload expired, the vocabulary moved on, or the encode
|
|
85
88
|
# budget was denied (a silent skip). Always releases the in-flight marker.
|
|
86
89
|
def encode(cache_key)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
Current.scope do
|
|
91
|
+
pending = cache.read_payload(cache_key)
|
|
92
|
+
return unless pending
|
|
93
|
+
|
|
94
|
+
model, query, tenant_key, user_key = pending.values_at(:model, :query, :tenant_key, :user_key)
|
|
95
|
+
return unless cache.key(model, query, tenant_key: tenant_key, user_key: user_key) == cache_key
|
|
96
|
+
|
|
97
|
+
encoding = cache.encoded?(cache_key) ? cache.read_encoding(cache_key, query) : encode_labels(model, query, tenant_key, user_key)
|
|
98
|
+
embed_query(model, query, tenant_key)
|
|
99
|
+
encoding
|
|
100
|
+
ensure
|
|
101
|
+
cache.release(cache_key)
|
|
102
|
+
end
|
|
98
103
|
end
|
|
99
104
|
|
|
100
105
|
# Polls the cache until the encoding lands or the deadline (seconds
|
|
@@ -118,8 +123,9 @@ module Truffler
|
|
|
118
123
|
boosts = {}
|
|
119
124
|
intent = {}
|
|
120
125
|
names = {}
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
present = present_options(model, tenant_key, user_key)
|
|
127
|
+
labels(model, tenant_key, user_key, present).each_value do |label|
|
|
128
|
+
key = storage_key(label, answers, tenant_key, present)
|
|
123
129
|
next unless key
|
|
124
130
|
|
|
125
131
|
case answers.choice("intent__#{label.question_key}")
|
|
@@ -134,7 +140,8 @@ module Truffler
|
|
|
134
140
|
end
|
|
135
141
|
|
|
136
142
|
query = Search::Query.new(request.state["query"])
|
|
137
|
-
roles, sources, soft = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, names
|
|
143
|
+
roles, sources, soft = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, names,
|
|
144
|
+
Search::Filler.label_words(model.truffler_definition, tenant_key))
|
|
138
145
|
tokens = ->(positions) { positions.map { |position| query.tokens[position] } }
|
|
139
146
|
Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent,
|
|
140
147
|
keyword_tokens: tokens.call(roles.keys.select { |position| roles[position] == "keyword" }),
|
|
@@ -174,25 +181,41 @@ module Truffler
|
|
|
174
181
|
cache.write_vector(model, query, vector, tenant_key: tenant_key)
|
|
175
182
|
end
|
|
176
183
|
|
|
177
|
-
|
|
184
|
+
# The searcher's vocabulary; with skip_empty_options, minus choice
|
|
185
|
+
# labels the tenant has no option rows for (see PresentOptions).
|
|
186
|
+
def labels(model, tenant_key, user_key, present = present_options(model, tenant_key, user_key))
|
|
187
|
+
labels = vocabulary_labels(model, tenant_key, user_key)
|
|
188
|
+
return labels unless present
|
|
189
|
+
|
|
190
|
+
labels.select { |_, label| label.type != :choice || PresentOptions.options(label, tenant_key, present).any? }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def vocabulary_labels(model, tenant_key, user_key)
|
|
178
194
|
model.truffler_definition.vocabulary.labels_for(tenant_key: tenant_key, user_key: user_key)
|
|
179
195
|
end
|
|
180
196
|
|
|
197
|
+
def present_options(model, tenant_key, user_key)
|
|
198
|
+
return unless PresentOptions.enabled?
|
|
199
|
+
|
|
200
|
+
PresentOptions.new.keys(model, vocabulary_labels(model, tenant_key, user_key), tenant_key: tenant_key)
|
|
201
|
+
end
|
|
202
|
+
|
|
181
203
|
# The label's storage key the query names, or nil for a choice label
|
|
182
204
|
# whose option answer is NO_OPTION.
|
|
183
|
-
def storage_key(label, answers, tenant_key)
|
|
205
|
+
def storage_key(label, answers, tenant_key, present)
|
|
184
206
|
return label.key unless label.type == :choice
|
|
185
207
|
|
|
186
208
|
option = answers.choice("option__#{label.question_key}")
|
|
187
|
-
"#{label.key}:#{option}" if option != NO_OPTION &&
|
|
209
|
+
"#{label.key}:#{option}" if option != NO_OPTION && PresentOptions.options(label, tenant_key, present).key?(option)
|
|
188
210
|
end
|
|
189
211
|
|
|
190
|
-
def vocabulary_state(labels, tenant_key)
|
|
212
|
+
def vocabulary_state(labels, tenant_key, present)
|
|
191
213
|
labels.transform_values do |label|
|
|
192
214
|
entry = { "description" => label.description }
|
|
193
215
|
if label.type == :choice
|
|
194
|
-
|
|
195
|
-
|
|
216
|
+
options = PresentOptions.options(label, tenant_key, present).keys
|
|
217
|
+
entry["options"] = options
|
|
218
|
+
names = label.option_names(tenant_key).slice(*options)
|
|
196
219
|
entry["option_names"] = names if names.any?
|
|
197
220
|
end
|
|
198
221
|
entry
|
|
@@ -206,16 +229,19 @@ module Truffler
|
|
|
206
229
|
# keyword naming an applied label becomes a label term; stopwords and
|
|
207
230
|
# filler words become filler unless they are all that would be left of
|
|
208
231
|
# an encoding that applies no label and no time range (Search::Filler).
|
|
209
|
-
# A word
|
|
210
|
-
#
|
|
211
|
-
|
|
232
|
+
# A word naming any declared label (`keep`, see Filler.label_words) is
|
|
233
|
+
# never filler, even when Jev calls it that. A word Jev called a label
|
|
234
|
+
# term that names no applied label locally is sourced to every applied
|
|
235
|
+
# label.
|
|
236
|
+
def reconcile(query, answered, names, keep)
|
|
212
237
|
roles = query.tokens.each_index.to_h do |position|
|
|
213
|
-
|
|
238
|
+
role = query.time_position?(position) ? "time" : answered.fetch(position, "keyword")
|
|
239
|
+
[ position, role == "filler" && keep.include?(query.tokens[position].singularize) ? "keyword" : role ]
|
|
214
240
|
end
|
|
215
241
|
matches = roles.keys.to_h { |position| [ position, roles[position] == "time" ? {} : label_matches(query.tokens[position], names) ] }
|
|
216
242
|
words = roles.keys.select { |position| roles[position] == "keyword" && !query.exact_tokens.include?(query.tokens[position]) }
|
|
217
243
|
words.each { |position| roles[position] = "label_term" if matches[position].any? }
|
|
218
|
-
filler = words.select { |position| roles[position] == "keyword" && Search::Filler.word?(query.tokens[position]) }
|
|
244
|
+
filler = words.select { |position| roles[position] == "keyword" && Search::Filler.word?(query.tokens[position], keep: keep) }
|
|
219
245
|
Search::Filler.drop(filler, keyword_count: roles.values.count("keyword"), anchored: names.any? || !query.time_phrase.nil?,
|
|
220
246
|
stopword: ->(position) { Search::Filler.stopword?(query.tokens[position]) })
|
|
221
247
|
.each { |position| roles[position] = "filler" }
|
|
@@ -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
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
module Truffler
|
|
2
2
|
module Records
|
|
3
|
-
# The backfill spend ledger: one row per model
|
|
4
|
-
# version, so a spend cap holds across
|
|
5
|
-
# BackfillJob chains. Spend is reserved and
|
|
6
|
-
# added to, and written back.
|
|
3
|
+
# The backfill spend ledger: one row per model, tenant (nil for the
|
|
4
|
+
# app-wide ledger), and vocabulary version, so a spend cap holds across
|
|
5
|
+
# runs, reruns, and overlapping BackfillJob chains. Spend is reserved and
|
|
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
|
|
|
@@ -18,10 +19,63 @@ module Truffler
|
|
|
18
19
|
false
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
def self.
|
|
22
|
-
|
|
22
|
+
def self.for_ledger(model, tenant_key)
|
|
23
|
+
tenant_ledgers? ? for_model(model).where(tenant_key: tenant_key) : for_model(model)
|
|
23
24
|
end
|
|
24
25
|
|
|
26
|
+
# Before `rails g truffler:upgrade` adds tenant_key, every tenant
|
|
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)
|
|
31
|
+
attributes = { record_type: model.polymorphic_name, vocabulary_version: version }
|
|
32
|
+
attributes[:tenant_key] = tenant_key if tenant_ledgers?
|
|
33
|
+
adopt(attributes, legacy_version) if legacy_version && legacy_version != version
|
|
34
|
+
create_or_find_by!(attributes)
|
|
35
|
+
end
|
|
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.
|
|
50
|
+
def self.tenant_ledgers?
|
|
51
|
+
return true if column_names.include?("tenant_key")
|
|
52
|
+
|
|
53
|
+
if recheck_tenant_key?
|
|
54
|
+
reset_column_information
|
|
55
|
+
return true if column_names.include?("tenant_key")
|
|
56
|
+
end
|
|
57
|
+
warn_missing_tenant_key
|
|
58
|
+
false
|
|
59
|
+
end
|
|
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
|
+
|
|
70
|
+
def self.warn_missing_tenant_key
|
|
71
|
+
return if @missing_tenant_warned
|
|
72
|
+
|
|
73
|
+
@missing_tenant_warned = true
|
|
74
|
+
Truffler.config.logger.warn("[truffler] #{table_name}.tenant_key is missing, so backfill spend caps are app-wide. " \
|
|
75
|
+
"Run `bin/rails g truffler:upgrade && bin/rails db:migrate`.")
|
|
76
|
+
end
|
|
77
|
+
private_class_method :warn_missing_tenant_key
|
|
78
|
+
|
|
25
79
|
def self.warn_missing
|
|
26
80
|
return if @missing_warned
|
|
27
81
|
|
data/lib/truffler/redaction.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Truffler
|
|
|
6
6
|
module Redaction
|
|
7
7
|
KEYS = %i[
|
|
8
8
|
priority model cost input_tokens tokens_estimated latency_ms error_class status outcome reason
|
|
9
|
-
record_type tenant_key user_key surface section sources vocabulary_version label_key
|
|
9
|
+
record_type tenant_key user_key surface section sources vocabulary_version label_key permanent
|
|
10
10
|
].to_set.freeze
|
|
11
11
|
SUFFIXES = %w[_id _ids _count _digest _ms].freeze
|
|
12
12
|
|
|
@@ -55,21 +55,40 @@ module Truffler
|
|
|
55
55
|
|
|
56
56
|
# The encoding minus the chips the searcher removed (R20), matched by
|
|
57
57
|
# storage key or by label key; "time" removes the time range. A label
|
|
58
|
-
# term whose every source label is gone becomes a keyword again.
|
|
59
|
-
|
|
58
|
+
# term whose every source label is gone becomes a keyword again. `keep_words`
|
|
59
|
+
# (a set, or a callable returning one) holds the words that are never
|
|
60
|
+
# filler, as in `keywords`.
|
|
61
|
+
def without(suppressed, keep_words: nil)
|
|
60
62
|
suppressed = Array(suppressed).map(&:to_s).to_set
|
|
61
63
|
return self if suppressed.empty?
|
|
62
64
|
|
|
63
65
|
keep = ->(key, _) { !suppressed.include?(key) && !suppressed.include?(self.class.split_key(key).first) }
|
|
64
66
|
kept = { filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep) }
|
|
65
67
|
applied = kept.values.flat_map(&:keys).to_set
|
|
66
|
-
freed =
|
|
68
|
+
freed = freed_words(applied)
|
|
67
69
|
kept_time = (time unless suppressed.include?(TimeRange.key))
|
|
68
70
|
keywords = keyword_tokens && (keyword_tokens + freed).uniq
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
if keywords && applied.empty? && kept_time.nil?
|
|
72
|
+
keywords = Filler.keywords(keywords + filler_tokens, anchored: false, keep: keep_words.respond_to?(:call) ? keep_words.call : keep_words)
|
|
73
|
+
end
|
|
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))
|
|
73
92
|
end
|
|
74
93
|
|
|
75
94
|
# Splits a storage key into its label key and choice option. Lens keys
|
|
@@ -85,10 +104,12 @@ module Truffler
|
|
|
85
104
|
end
|
|
86
105
|
|
|
87
106
|
# Without encoder decisions (a cold cache), every search token that is
|
|
88
|
-
# not a label term, minus filler words (see Filler).
|
|
89
|
-
|
|
107
|
+
# not a label term, minus filler words (see Filler). `keep` is called
|
|
108
|
+
# only then, for the words that are never filler.
|
|
109
|
+
def keywords(query, keep: nil)
|
|
90
110
|
keyword_tokens ||
|
|
91
|
-
Filler.keywords(query.search_tokens - label_term_tokens, anchored: !empty? || !time.nil?, exact: query.exact_tokens
|
|
111
|
+
Filler.keywords(query.search_tokens - label_term_tokens, anchored: !empty? || !time.nil?, exact: query.exact_tokens,
|
|
112
|
+
keep: keep&.call)
|
|
92
113
|
end
|
|
93
114
|
|
|
94
115
|
# The cache form: decisions plus token positions in the normalized
|
|
@@ -107,6 +128,16 @@ module Truffler
|
|
|
107
128
|
|
|
108
129
|
private
|
|
109
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
|
+
|
|
110
141
|
def weights(hash)
|
|
111
142
|
hash.to_h.to_h { |key, weight| [ key.to_s, Float(weight) ] }
|
|
112
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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Truffler
|
|
2
2
|
module Search
|
|
3
3
|
# Words that carry no search meaning on their own: common stopwords and
|
|
4
|
-
# `config.filler_words` (generic nouns such as "customers" or "
|
|
4
|
+
# `config.filler_words` (generic nouns such as "customers" or "items",
|
|
5
5
|
# matched ignoring plurals). One rule serves the encoder's reconcile and
|
|
6
6
|
# the cold-cache keywords: filler is dropped as a keyword unless dropping
|
|
7
7
|
# it would leave the search with no keyword, no applied label, and no
|
|
@@ -12,7 +12,7 @@ module Truffler
|
|
|
12
12
|
them there they this to up us was we what when where which who why with you your
|
|
13
13
|
].to_set.freeze
|
|
14
14
|
|
|
15
|
-
DEFAULT_WORDS = %w[customer customers people person user users message messages
|
|
15
|
+
DEFAULT_WORDS = %w[customer customers people person user users message messages item items stuff thing things].freeze
|
|
16
16
|
|
|
17
17
|
module_function
|
|
18
18
|
|
|
@@ -20,11 +20,34 @@ module Truffler
|
|
|
20
20
|
STOPWORDS.include?(word.to_s.downcase)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
# `keep` holds singular words that are never filler (see label_words).
|
|
24
|
+
def word?(word, filler_words: Truffler.config.filler_words, keep: nil)
|
|
24
25
|
word = word.to_s.downcase
|
|
26
|
+
return false if keep&.include?(word.singularize)
|
|
27
|
+
|
|
25
28
|
stopword?(word) || Array(filler_words).any? { |filler| filler.to_s.downcase.singularize == word.singularize }
|
|
26
29
|
end
|
|
27
30
|
|
|
31
|
+
# Singular words that name one of the model's declared labels for this
|
|
32
|
+
# tenant, applied or not: words of a label key, of a choice option key,
|
|
33
|
+
# and of an option's search text (not its description, which is prose).
|
|
34
|
+
# Such a word is never filler, so "text messages" still searches
|
|
35
|
+
# "messages" when an option's search text is "text message".
|
|
36
|
+
def label_words(definition, tenant_key = nil)
|
|
37
|
+
definition.labels.each_value.with_object(Set.new) do |label, words|
|
|
38
|
+
next unless label.available?(tenant_key)
|
|
39
|
+
|
|
40
|
+
names = [ label.key ]
|
|
41
|
+
if label.type == :choice
|
|
42
|
+
options = label.encoding_wording(tenant_key)[:options]
|
|
43
|
+
names.concat(options.keys, options.values.filter_map { |entry| entry[:search] })
|
|
44
|
+
end
|
|
45
|
+
names.each do |name|
|
|
46
|
+
name.to_s.downcase.split(/[^\p{Alnum}]+/).each { |word| words << word.singularize unless word.empty? || stopword?(word) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
28
51
|
# The subset of `candidates` (droppable keywords) to drop, given how many
|
|
29
52
|
# keywords there are and whether a label or time range is applied. When
|
|
30
53
|
# nothing anchors the search and only droppable words are left, the
|
|
@@ -38,9 +61,11 @@ module Truffler
|
|
|
38
61
|
end
|
|
39
62
|
|
|
40
63
|
# `tokens` minus filler words, or only minus stopwords when nothing else
|
|
41
|
-
# would anchor the search. Exact tokens (a quoted "the")
|
|
42
|
-
|
|
43
|
-
|
|
64
|
+
# would anchor the search. Exact tokens (a quoted "the") and `keep`
|
|
65
|
+
# words are never filler.
|
|
66
|
+
def keywords(tokens, anchored:, exact: [], keep: nil)
|
|
67
|
+
dropped = drop(tokens.select { |token| !exact.include?(token) && word?(token, keep: keep) }, keyword_count: tokens.size,
|
|
68
|
+
anchored: anchored)
|
|
44
69
|
tokens - dropped
|
|
45
70
|
end
|
|
46
71
|
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
|
|
@@ -83,6 +93,10 @@ module Truffler
|
|
|
83
93
|
|
|
84
94
|
# Drops lens keys this searcher cannot see (another user's personal
|
|
85
95
|
# lens, an expired lens) and counts a use of the rest (R42, R43).
|
|
96
|
+
def label_words
|
|
97
|
+
-> { Filler.label_words(@definition, tenant_key) }
|
|
98
|
+
end
|
|
99
|
+
|
|
86
100
|
def visible_lenses_only(encoding, record_usage: false)
|
|
87
101
|
lens_keys = encoding ? (encoding.intent_vector.keys | encoding.filters.keys | encoding.boosts.keys).select { |key| lens_id(key) } : []
|
|
88
102
|
return encoding if lens_keys.empty?
|
|
@@ -90,11 +104,12 @@ module Truffler
|
|
|
90
104
|
visible = Lenses.labels(model, tenant_key: tenant_key, user_key: user_key).values.map(&:lens_id).uniq
|
|
91
105
|
hidden, shown = lens_keys.partition { |key| !visible.include?(lens_id(key)) }
|
|
92
106
|
Lenses.record_usage(shown.map { |key| lens_id(key) }.uniq) if record_usage
|
|
93
|
-
encoding.without(hidden)
|
|
107
|
+
encoding.without(hidden, keep_words: label_words)
|
|
94
108
|
end
|
|
95
109
|
|
|
96
110
|
# The query's time phrase, resolved on this search's clock, unless the
|
|
97
|
-
# searcher removed its chip.
|
|
111
|
+
# searcher removed its chip. Attached before `without`, which keeps
|
|
112
|
+
# filler dropped while a time range still anchors the search.
|
|
98
113
|
def with_time(encoding)
|
|
99
114
|
phrase = query.time_phrase
|
|
100
115
|
return encoding if phrase.nil? || suppressed.include?(TimeRange.key)
|
|
@@ -133,23 +148,39 @@ module Truffler
|
|
|
133
148
|
Sql.new(model, tenant_key: tenant_key, query: query, encoding: encoding, vector: read_vector, weights: @weights)
|
|
134
149
|
end
|
|
135
150
|
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
|
|
158
|
+
# R21: the Smart search row. A query whose encoding is not cached yet
|
|
159
|
+
# invites the action even when a blind index or keyword matched,
|
|
160
|
+
# because a first-time intent query resolves on the action (AE10): on a
|
|
161
|
+
# model with no local text search always, and with a `keyword` source
|
|
162
|
+
# while the encoding is in flight unless `invite_on_pending_encoding false`.
|
|
163
|
+
def invite_row(records, cached, status)
|
|
140
164
|
return if query.blank?
|
|
141
165
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
elsif records.empty? then :empty
|
|
145
|
-
elsif records.size < @definition.weak_below then :weak
|
|
146
|
-
end
|
|
166
|
+
pending = cached.nil? && (@definition.keyword.blank? || (@definition.invite_on_pending_encoding && status == :pending))
|
|
167
|
+
reason = pending ? :encoding_pending : weak_reason(records)
|
|
147
168
|
{ query: query.raw.strip, reason: reason } if reason
|
|
148
169
|
end
|
|
149
170
|
|
|
171
|
+
def weak_reason(records)
|
|
172
|
+
if records.empty? then :empty
|
|
173
|
+
elsif records.size < @definition.weak_below then :weak
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def local_weak?(records, cached)
|
|
178
|
+
!query.blank? && (weak_reason(records).present? || (@definition.keyword.blank? && cached.nil?))
|
|
179
|
+
end
|
|
180
|
+
|
|
150
181
|
def instrument(result, started)
|
|
151
182
|
payload = { record_type: model.polymorphic_name, tenant_key: tenant_key, surface: surface, outcome: result.encoding_status,
|
|
152
|
-
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,
|
|
153
184
|
boost_count: result.encoding&.intent_vector&.size.to_i, sources: result.sources.map(&:to_s),
|
|
154
185
|
reason: result.invite_row&.dig(:reason), latency_ms: Instrumentation.elapsed_ms(started) }
|
|
155
186
|
payload[:query_digest] = Misses.digest(:query, query.normalized) if Misses.encrypted_model?(model)
|