truffler 0.1.4 → 0.1.5

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +71 -8
  4. data/lib/generators/truffler/install/templates/migration.rb.tt +6 -3
  5. data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +25 -0
  6. data/lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt +32 -0
  7. data/lib/generators/truffler/upgrade/upgrade_generator.rb +49 -3
  8. data/lib/tasks/truffler.rake +28 -13
  9. data/lib/truffler/benchmark/runner.rb +1 -1
  10. data/lib/truffler/clients/evaluator.rb +44 -0
  11. data/lib/truffler/configuration.rb +9 -0
  12. data/lib/truffler/definition.rb +52 -0
  13. data/lib/truffler/embeddings/backfill.rb +40 -11
  14. data/lib/truffler/embeddings/label_vector.rb +1 -1
  15. data/lib/truffler/embeddings/neighbor_store.rb +33 -7
  16. data/lib/truffler/embeddings/vector_store.rb +9 -3
  17. data/lib/truffler/jobs/backfill_job.rb +14 -5
  18. data/lib/truffler/jobs/embed_job.rb +2 -0
  19. data/lib/truffler/jobs/label_flush_job.rb +4 -2
  20. data/lib/truffler/jobs/resume_job.rb +22 -9
  21. data/lib/truffler/label_definition.rb +13 -2
  22. data/lib/truffler/labeling/backfill.rb +100 -37
  23. data/lib/truffler/labeling/labeler.rb +20 -5
  24. data/lib/truffler/labeling/queue.rb +14 -9
  25. data/lib/truffler/labeling/supplied.rb +16 -5
  26. data/lib/truffler/lenses/backfill.rb +28 -10
  27. data/lib/truffler/model.rb +5 -0
  28. data/lib/truffler/providers/backup.rb +1 -1
  29. data/lib/truffler/query_encoding/encoder.rb +10 -6
  30. data/lib/truffler/records/backfill_spend.rb +30 -6
  31. data/lib/truffler/redaction.rb +1 -1
  32. data/lib/truffler/search/encoding.rb +12 -6
  33. data/lib/truffler/search/filler.rb +31 -6
  34. data/lib/truffler/search/keystroke.rb +29 -15
  35. data/lib/truffler/search/result.rb +11 -1
  36. data/lib/truffler/search/sql.rb +70 -10
  37. data/lib/truffler/smart_search/dispatcher.rb +1 -1
  38. data/lib/truffler/smart_search/starter.rb +1 -1
  39. data/lib/truffler/version.rb +1 -1
  40. metadata +4 -1
@@ -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
- return base if label_only? || encoding.filters.any?
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
- intent = encoding.intent_vector
57
- return if intent.empty?
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
- cases = intent.map { |key, weight| "WHEN #{quote(key)} THEN #{Float(weight)} * #{label_column('value')}" }.join(" ")
60
- "COALESCE((SELECT SUM(CASE #{label_column('label_key')} #{cases} ELSE 0.0 END) FROM #{quoted_labels} " \
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,7 @@ 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) })
71
83
  end
72
84
 
73
85
  private
@@ -115,6 +127,46 @@ module Truffler
115
127
  range.to ? scope.where(arrived_at.lt(range.to)) : scope
116
128
  end
117
129
 
130
+ def label_case_sql
131
+ cases = encoding.intent_vector.map { |key, weight| "WHEN #{quote(key)} THEN #{Float(weight)} * #{label_column('value')}" }
132
+ "CASE #{label_column('label_key')} #{cases.join(' ')} ELSE 0.0 END"
133
+ end
134
+
135
+ def label_keys_sql
136
+ "#{label_column('label_key')} IN (#{encoding.intent_vector.keys.map { |key| quote(key) }.join(', ')})"
137
+ end
138
+
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
+ def grouped_label_scores?
145
+ encoding.intent_vector.any? && every_base_record?
146
+ end
147
+
148
+ # One aggregate over the tenant's rows for the intent's keys, which
149
+ # `index_truffler_labels_for_search` (with INCLUDE (record_id)) serves
150
+ # as an index-only scan.
151
+ def label_scores_join_sql
152
+ tenant = definition.scoped? ? " AND #{label_column('tenant_key')} = #{quote(tenant_key)}" : ""
153
+ scores = connection.quote_table_name(LABEL_SCORES)
154
+ "LEFT JOIN (SELECT #{label_column('record_id')} AS record_id, SUM(#{label_case_sql}) AS score FROM #{quoted_labels} " \
155
+ "WHERE #{label_column('record_type')} = #{quote(model.polymorphic_name)}#{tenant} AND #{label_keys_sql} " \
156
+ "GROUP BY #{label_column('record_id')}) #{scores} ON #{scores}.record_id = #{primary_key}"
157
+ end
158
+
159
+ def neighbors_sql
160
+ return @neighbors_sql if defined?(@neighbors_sql)
161
+
162
+ @neighbors_sql = (store.neighbors_sql(model, tenant_key: tenant_key, vector: vector) if vector && store.respond_to?(:neighbors_sql))
163
+ end
164
+
165
+ def neighbors_join_sql
166
+ neighbors = connection.quote_table_name(NEIGHBORS)
167
+ "LEFT JOIN (#{neighbors_sql}) #{neighbors} ON #{neighbors}.record_id = #{primary_key}"
168
+ end
169
+
118
170
  def label_filter_sql(key, threshold)
119
171
  "EXISTS (SELECT 1 FROM #{quoted_labels} WHERE #{label_scope_sql} AND #{label_column('label_key')} = #{quote(key)} " \
120
172
  "AND #{label_column('value')} >= #{Float(threshold)})"
@@ -171,7 +223,10 @@ module Truffler
171
223
  def text_score_sql
172
224
  return @text_score_sql if defined?(@text_score_sql)
173
225
 
174
- @text_score_sql = (store.similarity_sql(model, tenant_key: tenant_key, vector: vector).to_s if vector && store)
226
+ @text_score_sql =
227
+ if neighbors_sql then "COALESCE(#{connection.quote_table_name(NEIGHBORS)}.similarity, 0.0)"
228
+ elsif vector && store then store.similarity_sql(model, tenant_key: tenant_key, vector: vector).to_s
229
+ end
175
230
  end
176
231
 
177
232
  def text_candidate_sql
@@ -186,9 +241,14 @@ module Truffler
186
241
  definition.scoped? ? model.where(definition.tenant_column => tenant_key) : model.all
187
242
  end
188
243
 
244
+ # An id list is the fast path. A relation runs once as an array on
245
+ # Postgres, where `IN (subquery)` becomes a hashed filter over every
246
+ # tenant row.
189
247
  def membership_sql(result)
190
248
  case result
191
- when ActiveRecord::Relation then "#{primary_key} IN (#{result.reselect(result.klass.arel_table[result.klass.primary_key]).to_sql})"
249
+ when ActiveRecord::Relation
250
+ subquery = result.reselect(result.klass.arel_table[result.klass.primary_key]).to_sql
251
+ connection.adapter_name.match?(/postg/i) ? "#{primary_key} = ANY(ARRAY(#{subquery}))" : "#{primary_key} IN (#{subquery})"
192
252
  when nil then nil
193
253
  else
194
254
  ids = Array(result)
@@ -64,7 +64,7 @@ module Truffler
64
64
  if @encodings.encoded?(key) then @encodings.read_encoding(key, query)
65
65
  elsif @encodings.in_flight?(key) then @encoder.await(key, deadline: @deadline, query: query)
66
66
  end
67
- encoding&.without(run.suppressed)
67
+ encoding&.without(run.suppressed, keep_words: -> { Search::Filler.label_words(model.truffler_definition, run.tenant_key) })
68
68
  end
69
69
 
70
70
  # The snapshot narrowed to what the encoding allows, in the tenant, in
@@ -30,7 +30,7 @@ module Truffler
30
30
  local_ids = result.ids
31
31
  run = Run.create(@model, query: @query.raw.strip, tenant_key: keystroke.tenant_key, user_key: keystroke.user_key,
32
32
  surface: keystroke.surface, suppressed: @suppressed, pool_ids: pool(result, local_ids), local_ids: local_ids,
33
- local_weak: result.invite_row.present?, explicit_action: result.explicit_action, store: @store)
33
+ local_weak: result.local_weak?, explicit_action: result.explicit_action, store: @store)
34
34
  previous = @store.supersede(run.record_type, run.tenant_key, run.user_key, run.surface, run.id)
35
35
  Run.load(previous, store: @store).cancel! if previous
36
36
  Instrumentation.instrument(:smart_search, run_id: run.id, record_type: run.record_type, tenant_key: run.tenant_key,
@@ -1,3 +1,3 @@
1
1
  module Truffler
2
- VERSION = "0.1.4"
2
+ VERSION = "0.1.5"
3
3
  end
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
4
+ version: 0.1.5
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,6 +226,7 @@ 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